Discover branches/review-pr<N>[-submit] worktrees separately from author cleanup, fail closed on dirty trees / open PRs / active reviewer leases, and remove only assessed-safe scratch paths via reconcile.
596 lines
20 KiB
Python
596 lines
20 KiB
Python
"""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)
|
|
# Reviewer scratch folders: review-pr<N> or review-pr<N>-submit (#534).
|
|
REVIEWER_SCRATCH_NAME_RE = re.compile(
|
|
r"^review-pr(?P<pr>\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 _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 record in _git_worktree_porcelain(project_root):
|
|
branch_name = record.get("branch")
|
|
path = record.get("worktree")
|
|
if branch_name and path:
|
|
mapping[str(branch_name)] = str(path)
|
|
return mapping
|
|
|
|
|
|
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<N>`` and ``review-pr<N>-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")
|
|
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,
|
|
worktree_map: dict[str, 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"))
|
|
|
|
discovered_path = worktree_map.get(head_branch) if worktree_map else None
|
|
derived_path = resolve_worktree_path(project_root, head_branch)
|
|
if discovered_path:
|
|
worktree_path = discovered_path
|
|
match_type = "branch"
|
|
else:
|
|
worktree_path = derived_path
|
|
match_type = "path"
|
|
|
|
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,
|
|
)
|
|
local["match_type"] = match_type
|
|
|
|
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,
|
|
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)
|
|
worktree_map = discover_local_worktrees(project_root)
|
|
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,
|
|
worktree_map=worktree_map,
|
|
)
|
|
)
|
|
|
|
# #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) -> dict[str, Any]:
|
|
worktree_map = discover_local_worktrees(project_root)
|
|
discovered_path = worktree_map.get(branch)
|
|
worktree_path = discovered_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,
|
|
} |