"""Merged PR branch and worktree cleanup reconciliation (#269). Builds dry-run reports for merged pull requests and optionally executes remote branch deletion and local worktree removal when every safety gate passes. """ from __future__ import annotations import json import os import re import subprocess from typing import Any from reviewer_worktree import parse_dirty_tracked_files import issue_lock_store PROTECTED_BRANCHES = frozenset({"master", "main", "dev"}) CLOSES_FIXES_RE = re.compile(r"\b(?:closes|fixes)\s+#(\d+)\b", re.IGNORECASE) def extract_linked_issue(title: str, body: str) -> int | None: """Return the first Closes/Fixes issue number from PR metadata.""" for text in (title or "", body or ""): match = CLOSES_FIXES_RE.search(text) if match: return int(match.group(1)) return None def branch_worktree_folder(branch: str) -> str: return (branch or "").replace("/", "-") def resolve_worktree_path(project_root: str, branch: str) -> str: return os.path.join(project_root, "branches", branch_worktree_folder(branch)) def read_issue_lock(path: str | None = None) -> dict[str, Any] | None: if path: return issue_lock_store.read_lock_file(path.strip()) return issue_lock_store.read_session_issue_lock() def has_active_issue_lock(branch: str, lock_path: str | None = None) -> bool: if lock_path: lock = issue_lock_store.read_lock_file(lock_path.strip()) if not lock: return False return (lock.get("branch_name") or "").strip() == (branch or "").strip() return issue_lock_store.has_active_issue_lock(branch) def collect_open_pr_heads(open_prs: list[dict[str, Any]]) -> set[str]: heads: set[str] = set() for pr in open_prs or []: head = pr.get("head") if isinstance(head, dict): ref = head.get("ref") else: ref = head if ref: heads.add(str(ref)) return heads def read_local_worktree_state(worktree_path: str) -> dict[str, Any]: path = (worktree_path or "").strip() if not path or not os.path.isdir(path): return { "exists": False, "clean": None, "current_branch": None, "head_sha": None, "dirty_files": [], } branch_res = subprocess.run( ["git", "-C", path, "branch", "--show-current"], capture_output=True, text=True, check=False, ) current_branch = (branch_res.stdout or "").strip() or None status_res = subprocess.run( ["git", "-C", path, "status", "--porcelain"], capture_output=True, text=True, check=False, ) dirty_files = parse_dirty_tracked_files(status_res.stdout or "") head_res = subprocess.run( ["git", "-C", path, "rev-parse", "HEAD"], capture_output=True, text=True, check=False, ) head_sha = (head_res.stdout or "").strip() if head_res.returncode == 0 else None return { "exists": True, "clean": not dirty_files, "current_branch": current_branch, "head_sha": head_sha, "dirty_files": dirty_files, } def is_head_ancestor_of_ref(project_root: str, head_sha: str | None, base_ref: str) -> bool | None: if not head_sha: return None res = subprocess.run( ["git", "-C", project_root, "merge-base", "--is-ancestor", head_sha, base_ref], capture_output=True, text=True, check=False, ) if res.returncode == 0: return True if res.returncode == 1: return False return None def assess_remote_branch_cleanup( *, pr_number: int, head_branch: str, merged: bool, remote_branch_exists: bool, open_pr_heads: set[str], protected_branches: frozenset[str] | None = None, head_on_master: bool | None, delete_capability_allowed: bool, active_lock: bool, ) -> dict[str, Any]: protected = protected_branches or PROTECTED_BRANCHES reasons: list[str] = [] if not merged: reasons.append("PR is not merged") if not remote_branch_exists: reasons.append("remote branch already absent") 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 active_lock: reasons.append("active issue lock references this branch") if head_on_master is False: reasons.append("PR head is not an ancestor of master") if not delete_capability_allowed: reasons.append("delete_branch capability is not allowed in the active profile") safe = merged and remote_branch_exists and not reasons return { "pr_number": pr_number, "head_branch": head_branch, "remote_branch_exists": remote_branch_exists, "safe_to_delete_remote": safe, "block_reasons": reasons, "recommended_action": "delete_remote_branch" if safe else "keep_remote_branch", } def assess_local_worktree_cleanup( *, pr_number: int, head_branch: str, merged: bool, worktree_state: dict[str, Any], active_lock: bool, ) -> dict[str, Any]: reasons: list[str] = [] exists = bool(worktree_state.get("exists")) if not merged: reasons.append("PR is not merged") if not exists: reasons.append("local worktree not present") if exists and worktree_state.get("clean") is False: dirty = worktree_state.get("dirty_files") or [] reasons.append( "local worktree has tracked edits" + (f" ({', '.join(dirty)})" if dirty else "") ) if exists: current_branch = worktree_state.get("current_branch") if current_branch and current_branch != head_branch: reasons.append( f"worktree branch '{current_branch}' does not match PR head '{head_branch}'" ) if active_lock: reasons.append("active issue lock references this branch") safe = merged and exists and not reasons return { "pr_number": pr_number, "head_branch": head_branch, "worktree_path": worktree_state.get("worktree_path"), "worktree_exists": exists, "worktree_clean": worktree_state.get("clean"), "safe_to_remove_worktree": safe, "block_reasons": reasons, "recommended_action": "remove_local_worktree" if safe else "keep_local_worktree", } def build_pr_cleanup_entry( *, pr: dict[str, Any], project_root: str, open_pr_heads: set[str], remote_branch_exists: bool, head_on_master: bool | None, delete_capability_allowed: bool, issue_lock_path: str | None = None, protected_branches: frozenset[str] | None = None, ) -> dict[str, Any]: pr_number = int(pr["number"]) head_branch = pr.get("head") or "" if isinstance(head_branch, dict): head_branch = head_branch.get("ref") or "" title = pr.get("title") or "" body = pr.get("body") or "" merged = bool(pr.get("merged_at")) worktree_path = resolve_worktree_path(project_root, head_branch) worktree_state = read_local_worktree_state(worktree_path) worktree_state["worktree_path"] = worktree_path active_lock = has_active_issue_lock(head_branch, issue_lock_path) remote = assess_remote_branch_cleanup( pr_number=pr_number, head_branch=head_branch, merged=merged, remote_branch_exists=remote_branch_exists, open_pr_heads=open_pr_heads, protected_branches=protected_branches, head_on_master=head_on_master, delete_capability_allowed=delete_capability_allowed, active_lock=active_lock, ) local = assess_local_worktree_cleanup( pr_number=pr_number, head_branch=head_branch, merged=merged, worktree_state=worktree_state, active_lock=active_lock, ) return { "pr_number": pr_number, "issue_number": extract_linked_issue(title, body), "title": title, "head_branch": head_branch, "merge_commit_sha": pr.get("merge_commit_sha"), "merged_at": pr.get("merged_at"), "merged": merged, "remote_branch": remote, "local_worktree": local, } def build_reconciliation_report( *, project_root: str, closed_prs: list[dict[str, Any]], open_prs: list[dict[str, Any]], remote_branch_exists: dict[str, bool], head_on_master: dict[int, bool | None], delete_capability_allowed: bool, issue_lock_path: str | None = None, protected_branches: frozenset[str] | None = None, ) -> dict[str, Any]: open_heads = collect_open_pr_heads(open_prs) entries: list[dict[str, Any]] = [] for pr in closed_prs or []: if not pr.get("merged_at"): continue pr_number = int(pr["number"]) head = pr.get("head") or "" if isinstance(head, dict): head = head.get("ref") or "" entries.append( build_pr_cleanup_entry( pr=pr, project_root=project_root, open_pr_heads=open_heads, remote_branch_exists=bool(remote_branch_exists.get(head)), head_on_master=head_on_master.get(pr_number), delete_capability_allowed=delete_capability_allowed, issue_lock_path=issue_lock_path, protected_branches=protected_branches, ) ) return { "project_root": os.path.realpath(project_root), "merged_pr_count": len(entries), "entries": entries, "dry_run": True, "executed": False, } def remove_local_worktree(project_root: str, branch: str) -> dict[str, Any]: worktree_path = resolve_worktree_path(project_root, branch) if not os.path.isdir(worktree_path): return { "success": False, "performed": False, "message": f"worktree not found: {worktree_path}", } res = subprocess.run( ["git", "-C", project_root, "worktree", "remove", worktree_path], capture_output=True, text=True, check=False, ) if res.returncode != 0: return { "success": False, "performed": False, "message": (res.stderr or res.stdout or "worktree remove failed").strip(), } return { "success": True, "performed": True, "message": f"removed worktree {worktree_path}", "worktree_path": worktree_path, }