Skip to content
Merged
15 changes: 15 additions & 0 deletions backend/infrahub/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,10 @@ class MainSettings(BaseSettings):
default=True,
description="When enabled, diff updates are triggered for active branches after a branch merge.",
)
delete_branch_after_merge: bool = Field(
default=False,
description="When enabled, the Infrahub branch is automatically deleted after a successful merge.",
)

@field_validator("docs_index_path", mode="before")
@classmethod
Expand Down Expand Up @@ -496,6 +500,11 @@ class GitSettings(BaseSettings):
use_explicit_merge_commit: bool = Field(
default=False, description="Whether to allow explicit merge commits when infrahub merges branches"
)
delete_git_branch_after_merge: bool = Field(
default=False,
description="When enabled, the corresponding Git branch is deleted after the Infrahub branch is deleted. "
"Requires delete_branch_after_merge to be enabled.",
)

@model_validator(mode="after")
def validate_sync_branch_names(self) -> Self:
Expand Down Expand Up @@ -1030,6 +1039,12 @@ class Settings(BaseSettings):
trace: TraceSettings = TraceSettings()
experimental_features: ExperimentalFeaturesSettings = ExperimentalFeaturesSettings()

@model_validator(mode="after")
def validate_git_branch_deletion_requires_branch_deletion(self) -> Self:
if self.git.delete_git_branch_after_merge and not self.main.delete_branch_after_merge:
raise ValueError("'delete_git_branch_after_merge' requires 'delete_branch_after_merge' to be enabled")
return self

@property
def enterprise_features(self) -> list[EnterpriseFeatures]:
"""Returns a list of enterprise features that are enabled based on the settings."""
Expand Down
19 changes: 18 additions & 1 deletion backend/infrahub/core/branch/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,12 @@
from infrahub.workers.dependencies import get_component, get_database, get_event_service, get_workflow
from infrahub.workflows.catalogue import (
BRANCH_CANCEL_PROPOSED_CHANGES,
BRANCH_DELETE,
BRANCH_MERGE_POST_PROCESS,
DIFF_REFRESH_ALL,
DIFF_UPDATE,
GIT_REPOSITORIES_CREATE_BRANCH,
GIT_REPOSITORIES_DELETE_BRANCH,
IPAM_RECONCILIATION,
TRIGGER_ARTIFACT_DEFINITION_GENERATE,
TRIGGER_GENERATOR_DEFINITION_RUN,
Expand Down Expand Up @@ -415,6 +417,13 @@ async def _do_merge_branch(
parameters={"branch_name": obj.name},
)

if config.SETTINGS.main.delete_branch_after_merge and not obj.is_default:
await get_workflow().submit_workflow(
workflow=BRANCH_DELETE,
context=context,
parameters={"branch": obj.name},
)

# -------------------------------------------------------------
# Generate an event to indicate that a branch has been merged
# NOTE: we still need to convert this event and potentially pull
Expand All @@ -430,7 +439,7 @@ async def _do_merge_branch(


@flow(name="branch-delete", flow_run_name="Delete branch {branch}")
async def delete_branch(branch: str, context: InfrahubContext) -> None:
async def delete_branch(branch: str, context: InfrahubContext, delete_from_git: bool = False) -> None:
await add_tags(branches=[branch])

database = await get_database()
Expand All @@ -457,6 +466,14 @@ async def delete_branch(branch: str, context: InfrahubContext) -> None:
event_service = await get_event_service()
await event_service.send(event=event)

should_delete_git = (config.SETTINGS.git.delete_git_branch_after_merge or delete_from_git) and obj.sync_with_git
if should_delete_git:
await get_workflow().submit_workflow(
workflow=GIT_REPOSITORIES_DELETE_BRANCH,
context=context,
parameters={"branch": branch},
)


@flow(
name="branch-validate",
Expand Down
14 changes: 14 additions & 0 deletions backend/infrahub/git/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,20 @@ def get_branches_from_local(self, include_worktree: bool = True) -> dict[str, Br

return branches

def origin_has_branch(self, branch_name: str) -> bool:
"""Return True if branch_name exists as a remote branch on origin."""
return branch_name in self.get_branches_from_remote()

async def delete_remote_branch(self, branch_name: str) -> None:
"""Delete branch_name from origin and remove the local tracking ref."""
if not self.has_origin:
return
repo = self.get_git_repo_main()
repo.git.push("origin", "--delete", branch_name)
local_branches = self.get_branches_from_local(include_worktree=False)
if branch_name in local_branches:
repo.delete_head(branch_name, force=True)

@abstractmethod
def get_commit_value(self, branch_name: str, remote: bool = False) -> str:
raise NotImplementedError()
Expand Down
46 changes: 46 additions & 0 deletions backend/infrahub/git/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,25 @@ async def create_branch(branch: str, branch_id: str) -> None:
pass


@flow(name="git-repositories-delete-branch", flow_run_name="Delete git branch '{branch}'")
async def delete_git_branch(branch: str) -> None:
"""Fan out branch deletion across all CoreRepository instances."""
client = get_client()
repositories: list[CoreRepository] = await client.filters(kind=CoreRepository)
batch = await client.create_batch()
for repository in repositories:
batch.add(
task=git_branch_delete,
client=client,
branch=branch,
repository_name=repository.name.value,
repository_id=repository.id,
repository_location=repository.location.value,
)
async for _, _ in batch.execute():
pass


@flow(name="sync-git-repo-with-origin", flow_run_name="Sync git repo with origin")
async def sync_git_repo_with_origin_and_tag_on_failure(
client: InfrahubClient,
Expand Down Expand Up @@ -312,6 +331,33 @@ async def git_branch_create(
log.debug("Sent message to all workers to fetch the latest version of the repository (RefreshGitFetch)")


@task( # type: ignore[arg-type]
name="git-branch-delete",
task_run_name="Delete branch '{branch}' in repository {repository_name}",
cache_policy=NONE,
)
async def git_branch_delete(
client: InfrahubClient,
branch: str,
repository_id: str,
repository_name: str,
repository_location: str,
) -> None:
log = get_run_logger()
await add_branch_tag(branch_name=branch)
repo = await InfrahubRepository.init(
id=repository_id, name=repository_name, location=repository_location, client=client
)
async with lock.registry.get(name=repository_name, namespace="repository"):
if not repo.origin_has_branch(branch):
return

try:
await repo.delete_remote_branch(branch_name=branch)
except Exception as exc:
log.exception(f"Failed to delete Git branch '{branch}' from repository '{repository_name}' - {str(exc)}")


@flow(name="artifact-definition-generate", flow_run_name="Generate all artifacts")
async def generate_artifact_definition(branch: str, context: InfrahubContext) -> None:
await add_branch_tag(branch_name=branch)
Expand Down
18 changes: 14 additions & 4 deletions backend/infrahub/graphql/mutations/branch.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,11 @@ class BranchNameInput(InputObjectType):
name = String(required=False)


class BranchDeleteInput(InputObjectType):
name = String(required=False)
delete_from_git = Boolean(required=False, default_value=False)


class BranchUpdateInput(InputObjectType):
name = String(required=True)
description = String(required=False)
Expand All @@ -117,7 +122,7 @@ class BranchUpdateInput(InputObjectType):

class BranchDelete(Mutation):
class Arguments:
data = BranchNameInput(required=True)
data = BranchDeleteInput(required=True)
context = ContextInput(required=False)
wait_until_completion = Boolean(required=False)

Expand All @@ -129,22 +134,27 @@ async def mutate(
cls,
root: dict, # noqa: ARG003
info: GraphQLResolveInfo,
data: BranchNameInput,
data: BranchDeleteInput,
context: ContextInput | None = None,
wait_until_completion: bool = True,
) -> Self:
graphql_context: GraphqlContext = info.context
obj = await Branch.get_by_name(db=graphql_context.db, name=str(data.name))
await apply_external_context(graphql_context=graphql_context, context_input=context)

parameters = {
"branch": obj.name,
"delete_from_git": bool(data.delete_from_git),
}

if wait_until_completion:
await graphql_context.active_service.workflow.execute_workflow(
workflow=BRANCH_DELETE, context=graphql_context.get_context(), parameters={"branch": obj.name}
workflow=BRANCH_DELETE, context=graphql_context.get_context(), parameters=parameters
)
return cls(ok=True)

workflow = await graphql_context.active_service.workflow.submit_workflow(
workflow=BRANCH_DELETE, context=graphql_context.get_context(), parameters={"branch": obj.name}
workflow=BRANCH_DELETE, context=graphql_context.get_context(), parameters=parameters
)
return cls(ok=True, task={"id": str(workflow.id)})

Expand Down
8 changes: 8 additions & 0 deletions backend/infrahub/proposed_change/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@
from infrahub.validators.tasks import start_validator
from infrahub.workers.dependencies import get_cache, get_client, get_database, get_event_service, get_workflow
from infrahub.workflows.catalogue import (
BRANCH_DELETE,
GIT_REPOSITORIES_CHECK_ARTIFACT_CREATE,
GIT_REPOSITORY_INTERNAL_CHECKS_TRIGGER,
GIT_REPOSITORY_USER_CHECKS_TRIGGER,
Expand Down Expand Up @@ -245,6 +246,13 @@ async def merge_proposed_change(
user_id=context.account.account_id,
)

if config.SETTINGS.main.delete_branch_after_merge and not source_branch.is_default:
await get_workflow().submit_workflow(
workflow=BRANCH_DELETE,
context=context,
parameters={"branch": source_branch.name},
)

current_user = await NodeManager.get_one_by_id_or_default_filter(
id=context.account.account_id, kind=InfrahubKind.GENERICACCOUNT, db=db
)
Expand Down
9 changes: 9 additions & 0 deletions backend/infrahub/workflows/catalogue.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,14 @@
tags=[WorkflowTag.DATABASE_CHANGE],
)

GIT_REPOSITORIES_DELETE_BRANCH = WorkflowDefinition(
name="git-repositories-delete-branch",
type=WorkflowType.CORE,
module="infrahub.git.tasks",
function="delete_git_branch",
tags=[WorkflowTag.DATABASE_CHANGE],
)

GIT_REPOSITORY_ADD = WorkflowDefinition(
name="git-repository-add-read-write",
type=WorkflowType.CORE,
Expand Down Expand Up @@ -687,6 +695,7 @@
GIT_READ_ONLY_REPOSITORY_IMPORT_LAST_COMMIT,
GIT_REPOSITORIES_CHECK_ARTIFACT_CREATE,
GIT_REPOSITORIES_CREATE_BRANCH,
GIT_REPOSITORIES_DELETE_BRANCH,
GIT_REPOSITORIES_DIFF_NAMES_ONLY,
GIT_REPOSITORIES_IMPORT_OBJECTS,
GIT_REPOSITORIES_MERGE,
Expand Down
42 changes: 42 additions & 0 deletions backend/tests/component/git/test_delete_git_branch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from __future__ import annotations

from typing import TYPE_CHECKING

import pytest
from git import Repo

if TYPE_CHECKING:
from pathlib import Path

from infrahub.git import InfrahubRepository


async def test_has_branch_returns_true_for_existing_branch(git_repo_01: InfrahubRepository) -> None:
assert git_repo_01.origin_has_branch("branch01") is True


async def test_has_branch_returns_false_for_missing_branch(git_repo_01: InfrahubRepository) -> None:
assert git_repo_01.origin_has_branch("nonexistent-branch-xyz") is False


async def test_delete_remote_branch_removes_branch_from_origin(
git_repo_01: InfrahubRepository,
git_upstream_repo_01: dict[str, str | Path],
) -> None:
assert git_repo_01.origin_has_branch("clean-branch") is True

await git_repo_01.delete_remote_branch(branch_name="clean-branch")

# Fetch to sync remote tracking refs, then verify it's gone
await git_repo_01.fetch()
assert git_repo_01.origin_has_branch("clean-branch") is False

# Verify branch is also gone from the upstream (origin) repo
upstream = Repo(git_upstream_repo_01["path"])
upstream_branches = [b.name for b in upstream.refs if not b.is_remote()]
assert "clean-branch" not in upstream_branches


@pytest.mark.parametrize("branch_name", ["branch01", "branch02"])
async def test_has_branch_true_for_all_remote_branches(git_repo_01: InfrahubRepository, branch_name: str) -> None:
assert git_repo_01.origin_has_branch(branch_name) is True
Loading
Loading