"""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) ISSUE_MARKER_RE = re.compile(r"(?:^|[-_/])issue-(\d+)(?:[-_/]|$)", 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 extract_issue_marker(*values: str | None) -> int | None: """Return the first issue marker found in branch/path-like values.""" for value in values: match = ISSUE_MARKER_RE.search(value or "") if match: return int(match.group(1)) return None def list_local_worktrees(project_root: str) -> list[dict[str, Any]]: """Parse ``git worktree list --porcelain`` into structured entries.""" res = subprocess.run( ["git", "-C", project_root, "worktree", "list", "--porcelain"], capture_output=True, text=True, check=False, ) if res.returncode != 0: return [] entries: list[dict[str, Any]] = [] current: dict[str, Any] | None = None def flush() -> None: nonlocal current if current: entries.append(current) current = None for raw_line in (res.stdout or "").splitlines(): line = raw_line.strip() if not line: flush() continue if line.startswith("worktree "): flush() current = {"path": line[9:].strip()} elif current is not None and line.startswith("HEAD "): current["head_sha"] = line[5:].strip() elif current is not None and line.startswith("branch "): ref = line[7:].strip() current["branch_ref"] = ref current["branch"] = ( ref[len("refs/heads/"):] if ref.startswith("refs/heads/") else ref ) elif current is not None and line == "detached": current["detached"] = True flush() return entries def _is_under_branches(project_root: str, path: str) -> bool: branches_root = os.path.realpath(os.path.join(project_root, "branches")) real_path = os.path.realpath(path) return real_path == branches_root or real_path.startswith(branches_root + os.sep) def _head_is_safe_for_cleanup( *, project_root: str, candidate_head: str | None, pr_head_sha: str | None, target_ref: str | None, ) -> bool: if not candidate_head: return False if pr_head_sha and candidate_head == pr_head_sha: return True if target_ref: return is_head_ancestor_of_ref(project_root, candidate_head, target_ref) is True return False def resolve_cleanup_worktree_state( *, project_root: str, head_branch: str, issue_number: int | None, pr_head_sha: str | None = None, target_ref: str | None = None, ) -> dict[str, Any]: """Find the exact or safe alias worktree used for local cleanup (#532).""" expected_path = resolve_worktree_path(project_root, head_branch) state = read_local_worktree_state(expected_path) state["worktree_path"] = expected_path state["expected_worktree_path"] = expected_path state["discovered_worktree_path"] = expected_path if state.get("exists") else None state["match_type"] = "derived_path" if state.get("exists") else "none" state["candidate_paths"] = [] state["alias_block_reasons"] = [] if state.get("exists"): return state branch_issue = extract_issue_marker(head_branch) wanted_issue = issue_number or branch_issue candidates: list[dict[str, Any]] = [] unsafe_matches: list[str] = [] for entry in list_local_worktrees(project_root): path = entry.get("path") or "" if not path or not _is_under_branches(project_root, path): continue branch = entry.get("branch") or "" path_issue = extract_issue_marker(os.path.basename(path), path) entry_issue = extract_issue_marker(branch) or path_issue branch_match = branch == head_branch issue_match = wanted_issue is not None and entry_issue == wanted_issue if not (branch_match or issue_match): continue if not _head_is_safe_for_cleanup( project_root=project_root, candidate_head=entry.get("head_sha"), pr_head_sha=pr_head_sha, target_ref=target_ref, ): unsafe_matches.append(path) continue candidates.append(entry) state["candidate_paths"] = [entry.get("path") for entry in candidates] if len(candidates) == 1: path = candidates[0]["path"] alias_state = read_local_worktree_state(path) alias_state["worktree_path"] = path alias_state["expected_worktree_path"] = expected_path alias_state["discovered_worktree_path"] = path alias_state["match_type"] = "alias" alias_state["candidate_paths"] = [path] alias_state["alias_block_reasons"] = [] alias_state["alias_verified"] = True return alias_state if len(candidates) > 1: state["alias_block_reasons"].append( "ambiguous local worktree aliases: " + ", ".join( sorted(str(entry.get("path")) for entry in candidates) ) ) state["match_type"] = "ambiguous_alias" elif unsafe_matches: state["alias_block_reasons"].append( "matching local worktree aliases did not point to the PR head or target branch: " + ", ".join(sorted(unsafe_matches)) ) state["match_type"] = "unsafe_alias" return state 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") reasons.extend(worktree_state.get("alias_block_reasons") or []) 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 and not worktree_state.get("alias_verified") ): 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"), "expected_worktree_path": worktree_state.get("expected_worktree_path"), "discovered_worktree_path": worktree_state.get("discovered_worktree_path"), "match_type": worktree_state.get("match_type"), "candidate_paths": worktree_state.get("candidate_paths") or [], "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, target_ref: 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")) issue_number = extract_linked_issue(title, body) or extract_issue_marker(head_branch) head_payload = pr.get("head") if isinstance(pr.get("head"), dict) else {} pr_head_sha = head_payload.get("sha") if isinstance(head_payload, dict) else None worktree_state = resolve_cleanup_worktree_state( project_root=project_root, head_branch=head_branch, issue_number=issue_number, pr_head_sha=pr_head_sha, target_ref=target_ref, ) 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": issue_number, "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, target_ref: 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, target_ref=target_ref, ) ) 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, worktree_path: str | None = None, ) -> dict[str, Any]: worktree_path = worktree_path or 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, }