"""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) # Reviewer scratch folders: review-pr or review-pr-submit (#534). REVIEWER_SCRATCH_NAME_RE = re.compile( r"^review-pr(?P\d+)(?:-submit)?$", 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 _git_worktree_porcelain(project_root: str) -> list[dict[str, str | None]]: """Parse ``git worktree list --porcelain`` into structured records.""" res = subprocess.run( ["git", "-C", project_root, "worktree", "list", "--porcelain"], capture_output=True, text=True, check=False, ) if res.returncode != 0: return [] records: list[dict[str, str | None]] = [] current: dict[str, str | None] = {} for line in res.stdout.splitlines(): line = line.strip() if not line: if current.get("worktree"): records.append(current) current = {} continue if line.startswith("worktree "): if current.get("worktree"): records.append(current) current = {"worktree": line[9:].strip(), "branch": None, "head": None} elif line.startswith("branch ") and current: ref = line[7:].strip() current["branch"] = ( ref[11:] if ref.startswith("refs/heads/") else ref ) elif line.startswith("HEAD ") and current: current["head"] = line[5:].strip() elif line == "detached" and current: current["branch"] = None if current.get("worktree"): records.append(current) return records def discover_local_worktrees(project_root: str) -> dict[str, str]: """Return a mapping from branch name to absolute worktree path (#528).""" mapping: dict[str, str] = {} for entry in list_local_worktrees(project_root): branch = entry.get("branch") path = entry.get("path") if branch and path: mapping[str(branch)] = str(path) return mapping 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 safe_head = _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, ) if not safe_head and branch_match and not pr_head_sha and not target_ref: safe_head = True if not safe_head: 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"] = ( "branch" if candidates[0].get("branch") == head_branch else "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 parse_reviewer_scratch_folder(folder_name: str) -> int | None: """Return PR number for a reviewer scratch folder name, else None (#534).""" match = REVIEWER_SCRATCH_NAME_RE.match((folder_name or "").strip()) if not match: return None return int(match.group("pr")) def discover_reviewer_scratch_worktrees(project_root: str) -> list[dict[str, Any]]: """Discover detached reviewer scratch worktrees under ``branches/`` (#534). Matches folder names ``review-pr`` and ``review-pr-submit`` only. These are never treated as author worktree paths. """ root = os.path.realpath(project_root) branches_root = os.path.realpath(os.path.join(root, "branches")) found: list[dict[str, Any]] = [] for record in _git_worktree_porcelain(project_root): path = record.get("worktree") if not path: continue real = os.path.realpath(path) parent = os.path.dirname(real) if parent != branches_root: continue folder = os.path.basename(real) pr_number = parse_reviewer_scratch_folder(folder) if pr_number is None: continue found.append( { "pr_number": pr_number, "worktree_path": real, "folder_name": folder, "current_branch": record.get("branch"), "head_sha": record.get("head"), "worktree_kind": "reviewer_scratch", } ) return found def assess_reviewer_scratch_cleanup( *, pr_number: int, worktree_path: str, folder_name: str, pr_merged: bool, pr_closed: bool, worktree_state: dict[str, Any], active_reviewer_lease: bool, ) -> dict[str, Any]: """Assess whether a reviewer scratch worktree is safe to remove (#534).""" reasons: list[str] = [] exists = bool(worktree_state.get("exists")) if not (pr_merged or pr_closed): reasons.append("associated PR is still open") if not exists: reasons.append("reviewer scratch worktree not present") if exists and worktree_state.get("clean") is False: dirty = worktree_state.get("dirty_files") or [] reasons.append( "reviewer scratch worktree has tracked edits" + (f" ({', '.join(dirty)})" if dirty else "") ) if active_reviewer_lease: reasons.append("active reviewer lease still requires this worktree") current_branch = worktree_state.get("current_branch") if current_branch: # Scratch trees are expected detached; a named branch is ambiguous ownership. reasons.append( f"reviewer scratch worktree is on branch '{current_branch}' " "(expected detached HEAD for review-pr scratch trees)" ) safe = exists and not reasons return { "pr_number": pr_number, "worktree_path": worktree_path, "folder_name": folder_name, "worktree_kind": "reviewer_scratch", "worktree_exists": exists, "worktree_clean": worktree_state.get("clean"), "safe_to_remove_worktree": safe, "block_reasons": reasons, "recommended_action": ( "remove_reviewer_scratch_worktree" if safe else "keep_reviewer_scratch_worktree" ), # Never treat as author worktree cleanup. "author_worktree_cleanup": False, } 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, active_reviewer_leases: dict[int, bool] | None = None, pr_states: dict[int, dict[str, Any]] | 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, ) ) # #534: reviewer scratch worktrees are reported separately from author cleanup. lease_map = active_reviewer_leases or {} state_map = pr_states or {} open_pr_numbers = { int(pr["number"]) for pr in (open_prs or []) if pr.get("number") is not None } closed_by_number = { int(pr["number"]): pr for pr in (closed_prs or []) if pr.get("number") is not None } scratch_entries: list[dict[str, Any]] = [] for scratch in discover_reviewer_scratch_worktrees(project_root): pr_number = int(scratch["pr_number"]) state_info = state_map.get(pr_number) or {} closed_pr = closed_by_number.get(pr_number) if closed_pr is not None: pr_merged = bool(closed_pr.get("merged_at") or closed_pr.get("merged")) pr_closed = True elif pr_number in open_pr_numbers: pr_merged = False pr_closed = False else: pr_merged = bool(state_info.get("merged")) pr_closed = bool(state_info.get("closed") or state_info.get("merged")) worktree_path = scratch["worktree_path"] worktree_state = read_local_worktree_state(worktree_path) worktree_state["worktree_path"] = worktree_path # Prefer live branch from state (git) over porcelain branch field. assessment = assess_reviewer_scratch_cleanup( pr_number=pr_number, worktree_path=worktree_path, folder_name=scratch["folder_name"], pr_merged=pr_merged, pr_closed=pr_closed, worktree_state=worktree_state, active_reviewer_lease=bool(lease_map.get(pr_number)), ) scratch_entries.append(assessment) return { "project_root": os.path.realpath(project_root), "merged_pr_count": len(entries), "entries": entries, "reviewer_scratch_entries": scratch_entries, "reviewer_scratch_count": len(scratch_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, } def remove_reviewer_scratch_worktree( project_root: str, worktree_path: str, ) -> dict[str, Any]: """Remove a reviewer scratch worktree by absolute path (#534). Fail closed unless the path is a direct ``branches/review-pr*`` child of *project_root*. Never routes through author branch-name derivation. """ root = os.path.realpath(project_root) branches_root = os.path.realpath(os.path.join(root, "branches")) real = os.path.realpath((worktree_path or "").strip()) folder = os.path.basename(real) if os.path.dirname(real) != branches_root: return { "success": False, "performed": False, "worktree_kind": "reviewer_scratch", "message": ( "reviewer scratch path must be a direct child of " f"{branches_root}; got {real}" ), } if parse_reviewer_scratch_folder(folder) is None: return { "success": False, "performed": False, "worktree_kind": "reviewer_scratch", "message": f"path is not a reviewer scratch folder: {folder}", } if not os.path.isdir(real): return { "success": False, "performed": False, "worktree_kind": "reviewer_scratch", "message": f"worktree not found: {real}", } res = subprocess.run( ["git", "-C", project_root, "worktree", "remove", real], capture_output=True, text=True, check=False, ) if res.returncode != 0: return { "success": False, "performed": False, "worktree_kind": "reviewer_scratch", "message": (res.stderr or res.stdout or "worktree remove failed").strip(), "worktree_path": real, } return { "success": True, "performed": True, "worktree_kind": "reviewer_scratch", "message": f"removed reviewer scratch worktree {real}", "worktree_path": real, "author_worktree_cleanup": False, }