"""Guards for merged-PR branch cleanup and raw git delete bypasses (#514).""" from __future__ import annotations import re from typing import Any PROTECTED_BRANCHES = frozenset({"master", "main", "dev"}) _RAW_BRANCH_DELETE_PATTERNS = ( re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+branch\s+-[dD]\b[^\n\r]*", re.I), re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+push\b[^\n\r]*\s--delete\b[^\n\r]*", re.I), re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+push\b[^\n\r]*\s:[^\s`]+", re.I), ) def raw_branch_delete_commands(text: str | None) -> list[str]: """Return raw git branch-delete commands cited in *text*.""" if not text: return [] commands: list[str] = [] for pattern in _RAW_BRANCH_DELETE_PATTERNS: commands.extend(match.group(0).strip("` ") for match in pattern.finditer(text)) return list(dict.fromkeys(commands)) def assess_raw_branch_delete_report(text: str | None) -> dict[str, Any]: """Fail closed when a report uses raw git branch deletion as cleanup proof.""" commands = raw_branch_delete_commands(text) reasons = [ ( "raw git branch deletion bypasses MCP branch.delete cleanup gates: " f"{command}" ) for command in commands ] return { "proven": not reasons, "block": bool(reasons), "commands": commands, "reasons": reasons, "safe_next_action": ( "use gitea_cleanup_merged_pr_branch or another approved cleanup " "helper with explicit branch.delete capability" if reasons else "proceed" ), } def assess_merged_pr_branch_cleanup( *, pr_number: int, head_branch: str, merged: bool, remote_branch_exists: bool, open_pr_heads: set[str], head_on_target: bool | None, delete_capability_allowed: bool, confirmation: str | None, protected_branches: frozenset[str] | None = None, ) -> dict[str, Any]: """Assess whether a merged PR source branch can be deleted via MCP.""" protected = protected_branches or PROTECTED_BRANCHES expected_confirmation = f"CLEANUP MERGED PR {pr_number} BRANCH {head_branch}" reasons: list[str] = [] if not merged: reasons.append("PR is not merged") if not remote_branch_exists: reasons.append("remote branch already absent") if not head_branch: reasons.append("PR head branch is missing") if head_branch in protected: reasons.append(f"branch '{head_branch}' is protected") if head_branch in open_pr_heads: reasons.append("an open PR still references this head branch") if head_on_target is False: reasons.append("PR head is not an ancestor of the target branch") if head_on_target is None: reasons.append("PR head ancestry could not be proven") if not delete_capability_allowed: reasons.append("gitea.branch.delete capability is not allowed") if confirmation != expected_confirmation: reasons.append( "confirmation must equal " f"'{expected_confirmation}' for branch cleanup" ) safe = not reasons return { "pr_number": pr_number, "head_branch": head_branch, "expected_confirmation": expected_confirmation, "remote_branch_exists": remote_branch_exists, "safe_to_delete": safe, "block_reasons": reasons, "recommended_action": "delete_remote_branch" if safe else "keep_remote_branch", }