fix(worktree-audit): make cleanup audit merged-PR aware for issue worktrees (Closes #858)
gitea_audit_worktree_cleanup had no PR linkage. Issue worktrees therefore
reported pr_number=null and classified as active_issue_work with
removable=false permanently, even once their PR was merged and the head was
already contained in master. Observed on live master 9301739910: 42
issue_work worktrees, 0 removable, 0 with pr_number populated, while
gitea_reconcile_merged_cleanups reported the same worktree safe to remove.
Two independent gaps caused it:
* build_worktree_metadata was never given a pr_number, and only open PRs were
fetched, so no owning-PR evidence existed at all.
* clean_stale_removable was unreachable for issue_work: it required
ttl_expired, derived from a last_used_at that nothing populates, and
is_ttl_expired fail-safes to False when the timestamp is unknown.
This adds deterministic merged-PR linkage and gates removal on the complete
cleanup policy:
* build_pr_index / resolve_owning_pr link a worktree branch to exactly one
owning PR. Competing PRs on one branch, a still-open owner, a head-branch
mismatch, or missing PR state all fail closed while still reporting the
resolved pr_number.
* assess_merged_pr_worktree_cleanup requires all of: conclusive merged
ownership, branch agreement, containment of the head in authoritative
master, no open/competing PR, no active lease, no issue lock, no live
session, a clean tree, and a non-protected checkout. Unknown state blocks.
* Containment reuses merged_cleanup_reconcile.is_head_ancestor_of_ref so the
audit and the PR-scoped reconciler agree on what "already landed" means.
Lease evidence is now supplied. audit_branches_directory already accepted
leased_branches but the MCP tool never passed it, so has_active_lease was
false for every worktree in a live run. That was inert only while issue
worktrees could never become removable; it is wired to authoritative
control-plane leases here, scoped so a lease on issue N protects that issue's
work worktree and not a baseline or review tree merely named after it.
Issue work no longer becomes removable on TTL age alone, since age is not
proof that a branch landed and would otherwise reclaim a worktree holding
unmerged commits. conflict_fix keeps its existing TTL behaviour, and review,
baseline, merge-simulation, detached, dirty, open-PR, and protected
classifications are unchanged.
The assessor still performs no deletion and gains no cleanup mutation. This
is assessor-side only and does not implement the PR-scoped executor or the
expired-lease reclaim policy tracked separately by #855.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
+271
-4
@@ -34,7 +34,11 @@ import subprocess
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from merged_cleanup_reconcile import branch_worktree_folder, read_local_worktree_state
|
||||
from merged_cleanup_reconcile import (
|
||||
branch_worktree_folder,
|
||||
is_head_ancestor_of_ref,
|
||||
read_local_worktree_state,
|
||||
)
|
||||
from reviewer_worktree import parse_dirty_tracked_files, REVIEW_WORKTREE_RE
|
||||
|
||||
PROTECTED_BRANCHES = frozenset({"master", "main", "dev"})
|
||||
@@ -67,6 +71,14 @@ REMOVABLE_CLASSES = frozenset(
|
||||
{CLASS_CLEAN_STALE_REMOVABLE, CLASS_DETACHED_REVIEW_LEFTOVER}
|
||||
)
|
||||
|
||||
# Merged-PR linkage outcomes for issue worktrees (#858). Only ``LINKAGE_MERGED``
|
||||
# is ownership proof; every other outcome leaves the worktree protected.
|
||||
LINKAGE_MERGED = "merged_pr"
|
||||
LINKAGE_OPEN = "open_pr"
|
||||
LINKAGE_NONE = "no_owning_pr"
|
||||
LINKAGE_AMBIGUOUS = "ambiguous"
|
||||
LINKAGE_UNKNOWN = "unknown"
|
||||
|
||||
_ISSUE_REF_RE = re.compile(r"issue-(\d+)", re.IGNORECASE)
|
||||
_ISSUE_BRANCH_PREFIXES = ("feat/", "fix/", "docs/", "chore/")
|
||||
|
||||
@@ -169,6 +181,186 @@ def is_ttl_expired(
|
||||
return (now_dt - last).total_seconds() > ttl_hours * 3600.0
|
||||
|
||||
|
||||
def build_pr_index(prs: list[dict[str, Any]] | None) -> dict[str, list[dict[str, Any]]]:
|
||||
"""Index PR records by head branch for deterministic worktree linkage (#858).
|
||||
|
||||
Accepts Gitea PR payloads (``head`` as a dict) and pre-flattened records
|
||||
(``head_branch``/``head_sha``). Records without a usable head branch or
|
||||
number are dropped rather than guessed at, so a branch is only ever linked
|
||||
to a PR the caller actually proved.
|
||||
"""
|
||||
index: dict[str, list[dict[str, Any]]] = {}
|
||||
for pr in prs or []:
|
||||
head = pr.get("head")
|
||||
if isinstance(head, dict):
|
||||
head_branch = head.get("ref")
|
||||
head_sha = head.get("sha")
|
||||
else:
|
||||
head_branch = pr.get("head_branch") or (head if isinstance(head, str) else None)
|
||||
head_sha = pr.get("head_sha")
|
||||
number = pr.get("number")
|
||||
if not head_branch or number is None:
|
||||
continue
|
||||
try:
|
||||
pr_number = int(number)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
index.setdefault(str(head_branch).strip(), []).append(
|
||||
{
|
||||
"pr_number": pr_number,
|
||||
"head_branch": str(head_branch).strip(),
|
||||
"head_sha": head_sha,
|
||||
"merged": bool(pr.get("merged") or pr.get("merged_at")),
|
||||
"state": pr.get("state"),
|
||||
}
|
||||
)
|
||||
return index
|
||||
|
||||
|
||||
def resolve_owning_pr(
|
||||
*,
|
||||
branch: str | None,
|
||||
pr_index: dict[str, list[dict[str, Any]]] | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Resolve the single PR that owns ``branch``, failing closed when unclear.
|
||||
|
||||
Ownership is only ``LINKAGE_MERGED`` when exactly one PR claims the branch
|
||||
and that PR is merged. Several distinct PRs on one branch is a competing
|
||||
claim (``LINKAGE_AMBIGUOUS``), and a still-open owner is reported as
|
||||
``LINKAGE_OPEN`` — both keep the worktree protected while still exposing
|
||||
the PR number the audit resolved.
|
||||
"""
|
||||
if pr_index is None:
|
||||
return {
|
||||
"status": LINKAGE_UNKNOWN,
|
||||
"pr_number": None,
|
||||
"candidate_pr_numbers": [],
|
||||
"reasons": ["live PR state was not supplied; ownership unproven"],
|
||||
}
|
||||
branch_name = (branch or "").strip()
|
||||
if not branch_name:
|
||||
return {
|
||||
"status": LINKAGE_UNKNOWN,
|
||||
"pr_number": None,
|
||||
"candidate_pr_numbers": [],
|
||||
"reasons": ["worktree has no attached branch; ownership unproven"],
|
||||
}
|
||||
|
||||
candidates = list(pr_index.get(branch_name) or [])
|
||||
numbers = sorted({c["pr_number"] for c in candidates})
|
||||
if not candidates:
|
||||
return {
|
||||
"status": LINKAGE_NONE,
|
||||
"pr_number": None,
|
||||
"candidate_pr_numbers": [],
|
||||
"reasons": [f"no PR claims branch '{branch_name}'"],
|
||||
}
|
||||
if len(numbers) > 1:
|
||||
return {
|
||||
"status": LINKAGE_AMBIGUOUS,
|
||||
"pr_number": None,
|
||||
"candidate_pr_numbers": numbers,
|
||||
"reasons": [
|
||||
f"branch '{branch_name}' is claimed by competing PRs {numbers}; "
|
||||
"ownership is ambiguous"
|
||||
],
|
||||
}
|
||||
|
||||
owner = candidates[0]
|
||||
pr_number = owner["pr_number"]
|
||||
if owner.get("head_branch") != branch_name:
|
||||
return {
|
||||
"status": LINKAGE_UNKNOWN,
|
||||
"pr_number": pr_number,
|
||||
"candidate_pr_numbers": numbers,
|
||||
"reasons": [
|
||||
f"PR #{pr_number} head branch '{owner.get('head_branch')}' does not "
|
||||
f"match worktree branch '{branch_name}'"
|
||||
],
|
||||
}
|
||||
if not owner.get("merged"):
|
||||
return {
|
||||
"status": LINKAGE_OPEN,
|
||||
"pr_number": pr_number,
|
||||
"candidate_pr_numbers": numbers,
|
||||
"pr_head_sha": owner.get("head_sha"),
|
||||
"reasons": [f"owning PR #{pr_number} is not merged"],
|
||||
}
|
||||
return {
|
||||
"status": LINKAGE_MERGED,
|
||||
"pr_number": pr_number,
|
||||
"candidate_pr_numbers": numbers,
|
||||
"pr_head_sha": owner.get("head_sha"),
|
||||
"reasons": [],
|
||||
}
|
||||
|
||||
|
||||
def assess_merged_pr_worktree_cleanup(
|
||||
*,
|
||||
linkage: dict[str, Any] | None,
|
||||
head_sha: str | None,
|
||||
head_in_master: bool | None,
|
||||
is_dirty: bool,
|
||||
has_open_pr: bool,
|
||||
has_active_lease: bool,
|
||||
has_active_issue_lock: bool,
|
||||
is_protected: bool,
|
||||
has_live_session: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Decide whether a merged issue worktree satisfies the full cleanup policy.
|
||||
|
||||
Every condition must be independently proven: conclusive merged-PR
|
||||
ownership, agreement between the worktree branch and the PR head branch,
|
||||
containment of the worktree head in authoritative master (which is what
|
||||
proves no unmerged commits remain), absence of any open/competing PR,
|
||||
lease, issue lock, or live session, a clean tree, and a worktree that is
|
||||
not the protected control checkout. Anything unknown blocks.
|
||||
"""
|
||||
link = linkage or {
|
||||
"status": LINKAGE_UNKNOWN,
|
||||
"pr_number": None,
|
||||
"reasons": ["no linkage assessment supplied"],
|
||||
}
|
||||
status = link.get("status")
|
||||
reasons: list[str] = []
|
||||
|
||||
if status != LINKAGE_MERGED:
|
||||
reasons.extend(
|
||||
link.get("reasons") or ["owning PR could not be conclusively identified"]
|
||||
)
|
||||
if is_protected:
|
||||
reasons.append("worktree is protected or the stable control checkout")
|
||||
if is_dirty:
|
||||
reasons.append("worktree has uncommitted changes")
|
||||
if has_open_pr:
|
||||
reasons.append("worktree branch has an open PR")
|
||||
if has_active_lease:
|
||||
reasons.append("worktree has an active lease")
|
||||
if has_active_issue_lock:
|
||||
reasons.append("an active issue lock references this branch")
|
||||
if has_live_session:
|
||||
reasons.append("a live process or session is using this worktree")
|
||||
if not head_sha:
|
||||
reasons.append("worktree head sha is unknown")
|
||||
if head_in_master is None:
|
||||
reasons.append("containment of the worktree head in master is unknown")
|
||||
elif not head_in_master:
|
||||
reasons.append(
|
||||
"worktree head is not contained in authoritative master "
|
||||
"(unmerged commits remain)"
|
||||
)
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"linkage_status": status,
|
||||
"pr_number": link.get("pr_number"),
|
||||
"pr_head_sha": link.get("pr_head_sha"),
|
||||
"head_in_master": head_in_master,
|
||||
"proven": proven,
|
||||
"block_reasons": reasons,
|
||||
}
|
||||
|
||||
|
||||
def classify_worktree(
|
||||
*,
|
||||
workflow_type: str,
|
||||
@@ -181,6 +373,8 @@ def classify_worktree(
|
||||
ttl_expired: bool = False,
|
||||
is_protected: bool = False,
|
||||
metadata_known: bool = True,
|
||||
merged_pr_cleanup: dict[str, Any] | None = None,
|
||||
has_live_session: bool = False,
|
||||
) -> str:
|
||||
"""Classify a worktree, safety-first: any preservation signal wins.
|
||||
|
||||
@@ -199,6 +393,8 @@ def classify_worktree(
|
||||
return CLASS_ACTIVE_ISSUE_WORK # never auto-deleted (criterion 8)
|
||||
if has_active_issue_lock:
|
||||
return CLASS_ACTIVE_ISSUE_WORK
|
||||
if has_live_session:
|
||||
return CLASS_ACTIVE_ISSUE_WORK # a live session still owns this tree
|
||||
if not metadata_known or workflow_type == WORKFLOW_UNKNOWN:
|
||||
return CLASS_UNSAFE_UNKNOWN # never auto-deleted without proof
|
||||
|
||||
@@ -207,7 +403,15 @@ def classify_worktree(
|
||||
if is_detached or branch_gone:
|
||||
return CLASS_DETACHED_REVIEW_LEFTOVER
|
||||
return CLASS_CLEAN_STALE_REMOVABLE
|
||||
# issue_work / conflict_fix: only removable once the TTL has expired.
|
||||
if workflow_type == WORKFLOW_ISSUE_WORK:
|
||||
# #858: an issue worktree becomes removable only on authoritative
|
||||
# merged-PR evidence satisfying the whole cleanup policy. Age alone
|
||||
# never proves the branch landed, so TTL cannot qualify one by itself
|
||||
# — otherwise a worktree holding unmerged commits would be reclaimed.
|
||||
if (merged_pr_cleanup or {}).get("proven"):
|
||||
return CLASS_CLEAN_STALE_REMOVABLE
|
||||
return CLASS_ACTIVE_ISSUE_WORK
|
||||
# conflict_fix: only removable once the TTL has expired.
|
||||
if ttl_expired:
|
||||
return CLASS_CLEAN_STALE_REMOVABLE
|
||||
return CLASS_ACTIVE_ISSUE_WORK
|
||||
@@ -400,6 +604,20 @@ def remove_worktree(project_root: str, path: str) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def head_contained_in_ref(
|
||||
project_root: str, head_sha: str | None, ref: str | None
|
||||
) -> bool | None:
|
||||
"""Return True when ``head_sha`` is already contained in ``ref``.
|
||||
|
||||
Shares :mod:`merged_cleanup_reconcile`'s ancestry check so the audit and
|
||||
the PR-scoped reconciler agree on what "already landed" means (#858).
|
||||
Returns None when containment cannot be determined, which fails closed.
|
||||
"""
|
||||
if not head_sha or not ref:
|
||||
return None
|
||||
return is_head_ancestor_of_ref(project_root, head_sha, ref)
|
||||
|
||||
|
||||
def _is_under_branches(project_root: str, path: str) -> bool:
|
||||
branches_root = os.path.join(os.path.abspath(project_root), "branches")
|
||||
return os.path.abspath(path or "").startswith(branches_root + os.sep)
|
||||
@@ -413,16 +631,30 @@ def audit_branches_directory(
|
||||
active_issue_branches: set[str] | None = None,
|
||||
now: datetime | str | None = None,
|
||||
ttl_hours: float = DEFAULT_TTL_HOURS,
|
||||
pr_index: dict[str, list[dict[str, Any]]] | None = None,
|
||||
leased_issue_numbers: set[int] | None = None,
|
||||
live_session_paths: set[str] | None = None,
|
||||
master_ref: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Classify every session-owned worktree under ``branches/``.
|
||||
|
||||
Read-only: shells out to git for discovery and dirty state, then applies
|
||||
the pure classifier. Returns per-worktree classifications, counts, the
|
||||
list of removable candidates, and the ``git worktree list`` proof.
|
||||
|
||||
``pr_index`` (see :func:`build_pr_index`) supplies the authoritative PR
|
||||
ownership used to link issue worktrees to their merged PR (#858).
|
||||
``master_ref`` is the ref a worktree head must be contained in before it
|
||||
can be considered landed. Both are optional and their absence only ever
|
||||
fails closed: without them no issue worktree becomes removable.
|
||||
"""
|
||||
open_pr_branches = open_pr_branches or set()
|
||||
leased_branches = leased_branches or set()
|
||||
active_issue_branches = active_issue_branches or set()
|
||||
leased_issue_numbers = leased_issue_numbers or set()
|
||||
live_session_paths = {
|
||||
os.path.abspath(p) for p in (live_session_paths or set()) if p
|
||||
}
|
||||
|
||||
worktrees: list[dict[str, Any]] = []
|
||||
for entry in list_worktrees(project_root):
|
||||
@@ -433,12 +665,42 @@ def audit_branches_directory(
|
||||
)
|
||||
dirty_state = read_worktree_dirty(path)
|
||||
is_dirty = bool(dirty_state.get("dirty"))
|
||||
head_sha = entry.get("head")
|
||||
linkage = resolve_owning_pr(branch=branch, pr_index=pr_index)
|
||||
metadata = build_worktree_metadata(
|
||||
path=path, branch=branch, head_sha=entry.get("head")
|
||||
path=path,
|
||||
branch=branch,
|
||||
head_sha=head_sha,
|
||||
pr_number=linkage.get("pr_number"),
|
||||
)
|
||||
has_open_pr = bool(branch) and branch in open_pr_branches
|
||||
has_active_lease = bool(branch) and branch in leased_branches
|
||||
# A lease on issue N protects that issue's own work worktree. It must
|
||||
# not incidentally protect a baseline/review scratch tree that merely
|
||||
# carries the same issue marker in its name, which would change the
|
||||
# classification of worktrees this policy does not own.
|
||||
has_active_lease = (bool(branch) and branch in leased_branches) or (
|
||||
metadata["workflow_type"] == WORKFLOW_ISSUE_WORK
|
||||
and metadata.get("issue_number") is not None
|
||||
and metadata["issue_number"] in leased_issue_numbers
|
||||
)
|
||||
has_active_lock = bool(branch) and branch in active_issue_branches
|
||||
has_live_session = bool(path) and os.path.abspath(path) in live_session_paths
|
||||
head_in_master = (
|
||||
head_contained_in_ref(project_root, head_sha, master_ref)
|
||||
if master_ref
|
||||
else None
|
||||
)
|
||||
merged_pr_cleanup = assess_merged_pr_worktree_cleanup(
|
||||
linkage=linkage,
|
||||
head_sha=head_sha,
|
||||
head_in_master=head_in_master,
|
||||
is_dirty=is_dirty,
|
||||
has_open_pr=has_open_pr,
|
||||
has_active_lease=has_active_lease,
|
||||
has_active_issue_lock=has_active_lock,
|
||||
is_protected=is_protected,
|
||||
has_live_session=has_live_session,
|
||||
)
|
||||
ttl_expired = is_ttl_expired(
|
||||
last_used_at=metadata.get("last_used_at"), now=now, ttl_hours=ttl_hours
|
||||
)
|
||||
@@ -452,6 +714,8 @@ def audit_branches_directory(
|
||||
branch_gone=branch is None and not entry.get("detached"),
|
||||
ttl_expired=ttl_expired,
|
||||
is_protected=is_protected,
|
||||
merged_pr_cleanup=merged_pr_cleanup,
|
||||
has_live_session=has_live_session,
|
||||
)
|
||||
metadata["cleanup_eligibility"] = classification
|
||||
worktrees.append(
|
||||
@@ -463,7 +727,10 @@ def audit_branches_directory(
|
||||
"has_open_pr": has_open_pr,
|
||||
"has_active_lease": has_active_lease,
|
||||
"has_active_issue_lock": has_active_lock,
|
||||
"has_live_session": has_live_session,
|
||||
"is_protected": is_protected,
|
||||
"merged_pr_linkage": linkage,
|
||||
"merged_pr_cleanup": merged_pr_cleanup,
|
||||
"classification": classification,
|
||||
"removable": is_removable(classification),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user