Stale worktrees accumulate under branches/ when runs stop early, race a
sibling session, hit a validation failure, or lose cwd state, making later
workflow decisions harder and riskier.
Changes:
- worktree_cleanup_audit.py — ownership metadata, safety-first classifier
(active_open_pr, active_issue_work, dirty_local_worktree,
clean_stale_removable, detached_review_leftover, unsafe_unknown), TTL
expiry, per-worktree removal proof, and success-completion cleanup plan.
Only clean_stale_removable and detached_review_leftover are removable;
dirty/PR/leased/protected/unknown worktrees are never auto-deleted.
- gitea_audit_worktree_cleanup — read-only MCP tool that classifies every
branches/ entry, fetches live open-PR heads (fails closed if unavailable),
and returns counts, removable candidates, and git worktree list proof.
- work-issue.md 22A + review-merge-pr.md 28 — cleanup/TTL workflow rules.
- tests/test_worktree_cleanup_audit.py — 30 cases covering all nine
acceptance scenarios plus inference, metadata, TTL, and report accuracy.
Validation:
/Users/jasonwalker/Development/Gitea-Tools/venv/bin/python \
-m unittest tests.test_worktree_cleanup_audit tests.test_merged_cleanup_reconcile -q
38 passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
481 lines
17 KiB
Python
481 lines
17 KiB
Python
"""Session-owned worktree cleanup audit and TTL enforcement (#401).
|
|
|
|
LLM workflows create many session-owned worktrees under ``branches/``
|
|
(review, baseline, merge-simulation, issue, and conflict-fix worktrees).
|
|
When a run stops early, races a sibling session, hits a validation failure,
|
|
or loses shell/cwd state, those worktrees are left behind and later workflow
|
|
decisions get harder and riskier.
|
|
|
|
This module provides:
|
|
|
|
* ``build_worktree_metadata`` — ownership/purpose metadata for a worktree.
|
|
* ``classify_worktree`` — safety-first classification into the audit
|
|
vocabulary (active open PR, active issue work, dirty, clean stale
|
|
removable, detached review leftover, unsafe/unknown).
|
|
* ``assess_worktree_removal`` — a per-worktree removal decision with an
|
|
explicit proof and block reasons.
|
|
* git-shelling helpers (``list_worktrees``, ``read_worktree_dirty``,
|
|
``git_worktree_list``, ``remove_worktree``) and ``audit_branches_directory``
|
|
that classify every entry under ``branches/``.
|
|
|
|
Pure assessment functions take explicit state so they are unit-testable
|
|
without a filesystem or network. Only the thin git helpers shell out, and
|
|
removal is only ever executed after ``assess_worktree_removal`` proves the
|
|
worktree is safe to delete.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import subprocess
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
PROTECTED_BRANCHES = frozenset({"master", "main", "dev"})
|
|
DEFAULT_TTL_HOURS = float(os.environ.get("GITEA_WORKTREE_TTL_HOURS", "24") or 24)
|
|
|
|
# Workflow types that can create session-owned worktrees.
|
|
WORKFLOW_REVIEW = "review"
|
|
WORKFLOW_BASELINE = "baseline"
|
|
WORKFLOW_MERGE_SIMULATION = "merge_simulation"
|
|
WORKFLOW_ISSUE_WORK = "issue_work"
|
|
WORKFLOW_CONFLICT_FIX = "conflict_fix"
|
|
WORKFLOW_UNKNOWN = "unknown"
|
|
|
|
# Workflow types whose worktrees are transient and removed automatically at
|
|
# successful workflow completion (acceptance criterion 2).
|
|
AUTO_REMOVE_ON_SUCCESS = frozenset(
|
|
{WORKFLOW_REVIEW, WORKFLOW_BASELINE, WORKFLOW_MERGE_SIMULATION}
|
|
)
|
|
|
|
# Cleanup-audit classification vocabulary (acceptance criterion 4).
|
|
CLASS_ACTIVE_OPEN_PR = "active_open_pr"
|
|
CLASS_ACTIVE_ISSUE_WORK = "active_issue_work"
|
|
CLASS_DIRTY_LOCAL = "dirty_local_worktree"
|
|
CLASS_CLEAN_STALE_REMOVABLE = "clean_stale_removable"
|
|
CLASS_DETACHED_REVIEW_LEFTOVER = "detached_review_leftover"
|
|
CLASS_UNSAFE_UNKNOWN = "unsafe_unknown"
|
|
|
|
# Only these two classifications may ever be removed automatically.
|
|
REMOVABLE_CLASSES = frozenset(
|
|
{CLASS_CLEAN_STALE_REMOVABLE, CLASS_DETACHED_REVIEW_LEFTOVER}
|
|
)
|
|
|
|
_ISSUE_REF_RE = re.compile(r"issue-(\d+)", re.IGNORECASE)
|
|
_ISSUE_BRANCH_PREFIXES = ("feat/", "fix/", "docs/", "chore/")
|
|
|
|
|
|
def infer_workflow_type(path: str | None, branch: str | None = None) -> str:
|
|
"""Infer the creating workflow type from a worktree path or branch name."""
|
|
text = f"{path or ''} {branch or ''}".lower()
|
|
if "baseline" in text:
|
|
return WORKFLOW_BASELINE
|
|
if "merge-sim" in text or "merge_sim" in text or "mergesim" in text:
|
|
return WORKFLOW_MERGE_SIMULATION
|
|
if "review" in text or "review-pr" in text:
|
|
return WORKFLOW_REVIEW
|
|
if "conflict" in text:
|
|
return WORKFLOW_CONFLICT_FIX
|
|
if "issue-" in text or (branch or "").startswith(_ISSUE_BRANCH_PREFIXES):
|
|
return WORKFLOW_ISSUE_WORK
|
|
return WORKFLOW_UNKNOWN
|
|
|
|
|
|
def _extract_issue_number(path: str | None, branch: str | None) -> int | None:
|
|
match = _ISSUE_REF_RE.search(f"{path or ''} {branch or ''}")
|
|
return int(match.group(1)) if match else None
|
|
|
|
|
|
def build_worktree_metadata(
|
|
*,
|
|
path: str,
|
|
branch: str | None = None,
|
|
head_sha: str | None = None,
|
|
workflow_type: str | None = None,
|
|
issue_number: int | None = None,
|
|
pr_number: int | None = None,
|
|
creator: str | None = None,
|
|
profile: str | None = None,
|
|
created_at: str | None = None,
|
|
last_used_at: str | None = None,
|
|
cleanup_eligibility: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Return ownership/purpose metadata for a session-owned worktree.
|
|
|
|
Covers acceptance criterion 1: path, workflow type, issue/PR number,
|
|
branch/head SHA, creator identity/profile, created and last-used
|
|
timestamps, and cleanup eligibility.
|
|
"""
|
|
wt = workflow_type or infer_workflow_type(path, branch)
|
|
issue = issue_number
|
|
if issue is None:
|
|
issue = _extract_issue_number(path, branch)
|
|
return {
|
|
"path": path,
|
|
"workflow_type": wt,
|
|
"issue_number": issue,
|
|
"pr_number": pr_number,
|
|
"branch": branch,
|
|
"head_sha": head_sha,
|
|
"creator": creator,
|
|
"profile": profile,
|
|
"created_at": created_at,
|
|
"last_used_at": last_used_at,
|
|
"auto_remove_on_success": wt in AUTO_REMOVE_ON_SUCCESS,
|
|
"cleanup_eligibility": cleanup_eligibility,
|
|
}
|
|
|
|
|
|
def _parse_timestamp(value: str | None) -> datetime | None:
|
|
if not value:
|
|
return None
|
|
text = str(value).strip()
|
|
if not text:
|
|
return None
|
|
if text.endswith("Z"):
|
|
text = text[:-1] + "+00:00"
|
|
try:
|
|
parsed = datetime.fromisoformat(text)
|
|
except ValueError:
|
|
return None
|
|
if parsed.tzinfo is None:
|
|
return parsed.replace(tzinfo=timezone.utc)
|
|
return parsed
|
|
|
|
|
|
def is_ttl_expired(
|
|
*,
|
|
last_used_at: str | None,
|
|
now: datetime | str | None,
|
|
ttl_hours: float = DEFAULT_TTL_HOURS,
|
|
) -> bool:
|
|
"""Return True only when the age is known and exceeds ``ttl_hours``.
|
|
|
|
Unknown or unparseable timestamps fail safe (not expired) so a worktree
|
|
is never treated as removable merely because its age is unknown.
|
|
"""
|
|
last = _parse_timestamp(last_used_at)
|
|
now_dt = now if isinstance(now, datetime) else _parse_timestamp(now)
|
|
if last is None or now_dt is None:
|
|
return False
|
|
if now_dt.tzinfo is None:
|
|
now_dt = now_dt.replace(tzinfo=timezone.utc)
|
|
return (now_dt - last).total_seconds() > ttl_hours * 3600.0
|
|
|
|
|
|
def classify_worktree(
|
|
*,
|
|
workflow_type: str,
|
|
is_dirty: bool,
|
|
has_open_pr: bool = False,
|
|
has_active_lease: bool = False,
|
|
has_active_issue_lock: bool = False,
|
|
is_detached: bool = False,
|
|
branch_gone: bool = False,
|
|
ttl_expired: bool = False,
|
|
is_protected: bool = False,
|
|
metadata_known: bool = True,
|
|
) -> str:
|
|
"""Classify a worktree, safety-first: any preservation signal wins.
|
|
|
|
Dirty, open-PR, leased, active-lock, protected, and unknown states are
|
|
all non-removable and are checked before any removable classification,
|
|
so nothing removable can shadow a preservation signal (criteria 6-8).
|
|
"""
|
|
if is_protected:
|
|
# The main checkout / a protected base branch is never removable.
|
|
return CLASS_UNSAFE_UNKNOWN
|
|
if is_dirty:
|
|
return CLASS_DIRTY_LOCAL # never auto-deleted (criterion 6)
|
|
if has_open_pr:
|
|
return CLASS_ACTIVE_OPEN_PR # never auto-deleted (criterion 7)
|
|
if has_active_lease:
|
|
return CLASS_ACTIVE_ISSUE_WORK # never auto-deleted (criterion 8)
|
|
if has_active_issue_lock:
|
|
return CLASS_ACTIVE_ISSUE_WORK
|
|
if not metadata_known or workflow_type == WORKFLOW_UNKNOWN:
|
|
return CLASS_UNSAFE_UNKNOWN # never auto-deleted without proof
|
|
|
|
# Clean, no PR, no lease, no lock, known workflow type.
|
|
if workflow_type in AUTO_REMOVE_ON_SUCCESS:
|
|
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 ttl_expired:
|
|
return CLASS_CLEAN_STALE_REMOVABLE
|
|
return CLASS_ACTIVE_ISSUE_WORK
|
|
|
|
|
|
def is_removable(classification: str) -> bool:
|
|
"""Return True only for the two auto-removable classifications."""
|
|
return classification in REMOVABLE_CLASSES
|
|
|
|
|
|
def assess_worktree_removal(
|
|
*,
|
|
path: str,
|
|
branch: str | None,
|
|
head_sha: str | None,
|
|
is_dirty: bool,
|
|
has_open_pr: bool,
|
|
has_active_lease: bool,
|
|
classification: str,
|
|
) -> dict[str, Any]:
|
|
"""Return a per-worktree removal decision with proof (criterion 9).
|
|
|
|
A worktree is only safe to remove when it is clean, has no active PR,
|
|
has no active lease, and its classification is auto-removable.
|
|
"""
|
|
block_reasons: list[str] = []
|
|
if is_dirty:
|
|
block_reasons.append("worktree has uncommitted changes")
|
|
if has_open_pr:
|
|
block_reasons.append("worktree branch has an open PR")
|
|
if has_active_lease:
|
|
block_reasons.append("worktree has an active lease")
|
|
if not is_removable(classification):
|
|
block_reasons.append(
|
|
f"classification '{classification}' is not auto-removable"
|
|
)
|
|
return {
|
|
"path": path,
|
|
"branch": branch,
|
|
"head_sha": head_sha,
|
|
"classification": classification,
|
|
"clean": not is_dirty,
|
|
"no_active_pr": not has_open_pr,
|
|
"no_active_lease": not has_active_lease,
|
|
"safe_to_remove": not block_reasons,
|
|
"block_reasons": block_reasons,
|
|
}
|
|
|
|
|
|
def plan_success_cleanup(
|
|
*,
|
|
metadata: dict[str, Any],
|
|
is_dirty: bool,
|
|
has_open_pr: bool,
|
|
has_active_lease: bool,
|
|
) -> dict[str, Any]:
|
|
"""Decide whether a just-completed worktree is removed at success.
|
|
|
|
Review/baseline/merge-simulation worktrees are removed automatically at
|
|
successful completion (criterion 2); everything else is preserved and
|
|
reported. Dirty/PR/leased worktrees are always preserved (criteria 6-8).
|
|
"""
|
|
workflow_type = metadata.get("workflow_type", WORKFLOW_UNKNOWN)
|
|
if not metadata.get("auto_remove_on_success"):
|
|
return {
|
|
"remove": False,
|
|
"reason": f"workflow type '{workflow_type}' is preserved by policy",
|
|
}
|
|
classification = classify_worktree(
|
|
workflow_type=workflow_type,
|
|
is_dirty=is_dirty,
|
|
has_open_pr=has_open_pr,
|
|
has_active_lease=has_active_lease,
|
|
)
|
|
decision = assess_worktree_removal(
|
|
path=metadata.get("path", ""),
|
|
branch=metadata.get("branch"),
|
|
head_sha=metadata.get("head_sha"),
|
|
is_dirty=is_dirty,
|
|
has_open_pr=has_open_pr,
|
|
has_active_lease=has_active_lease,
|
|
classification=classification,
|
|
)
|
|
return {
|
|
"remove": decision["safe_to_remove"],
|
|
"reason": "clean transient worktree removable at success completion"
|
|
if decision["safe_to_remove"]
|
|
else "; ".join(decision["block_reasons"]),
|
|
"classification": classification,
|
|
"decision": decision,
|
|
}
|
|
|
|
|
|
def cleanup_failure_report(path: str, reason: str) -> dict[str, Any]:
|
|
"""Structured leftover-worktree record for the final report (criterion 3)."""
|
|
return {"path": path, "removed": False, "reason": reason}
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# git-shelling helpers (only these touch the filesystem)
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def parse_worktree_porcelain(text: str) -> list[dict[str, Any]]:
|
|
"""Parse ``git worktree list --porcelain`` output into entries."""
|
|
entries: list[dict[str, Any]] = []
|
|
current: dict[str, Any] = {}
|
|
for raw in (text or "").splitlines():
|
|
line = raw.rstrip("\n")
|
|
if not line:
|
|
if current:
|
|
entries.append(current)
|
|
current = {}
|
|
continue
|
|
if line.startswith("worktree "):
|
|
if current:
|
|
entries.append(current)
|
|
current = {
|
|
"path": line[len("worktree ") :].strip(),
|
|
"head": None,
|
|
"branch": None,
|
|
"detached": False,
|
|
"bare": False,
|
|
}
|
|
elif line.startswith("HEAD "):
|
|
current["head"] = line[len("HEAD ") :].strip()
|
|
elif line.startswith("branch "):
|
|
ref = line[len("branch ") :].strip()
|
|
current["branch"] = ref.replace("refs/heads/", "", 1)
|
|
elif line == "detached":
|
|
current["detached"] = True
|
|
elif line == "bare":
|
|
current["bare"] = True
|
|
if current:
|
|
entries.append(current)
|
|
return entries
|
|
|
|
|
|
def list_worktrees(project_root: str) -> list[dict[str, Any]]:
|
|
"""Return parsed ``git worktree list`` entries for ``project_root``."""
|
|
result = subprocess.run(
|
|
["git", "-C", project_root, "worktree", "list", "--porcelain"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
if result.returncode != 0:
|
|
return []
|
|
return parse_worktree_porcelain(result.stdout)
|
|
|
|
|
|
def git_worktree_list(project_root: str) -> str:
|
|
"""Return plain ``git worktree list`` output for final verification (criterion 10)."""
|
|
result = subprocess.run(
|
|
["git", "-C", project_root, "worktree", "list"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
return (result.stdout or "").strip()
|
|
|
|
|
|
def read_worktree_dirty(path: str) -> dict[str, Any]:
|
|
"""Return dirty state for a worktree path via ``git status --porcelain``."""
|
|
if not path or not os.path.isdir(path):
|
|
return {"exists": False, "dirty": None, "dirty_files": []}
|
|
result = subprocess.run(
|
|
["git", "-C", path, "status", "--porcelain"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
files = [ln for ln in (result.stdout or "").splitlines() if ln.strip()]
|
|
return {"exists": True, "dirty": bool(files), "dirty_files": files}
|
|
|
|
|
|
def remove_worktree(project_root: str, path: str) -> dict[str, Any]:
|
|
"""Remove a single worktree via ``git worktree remove`` (no ``--force``)."""
|
|
result = subprocess.run(
|
|
["git", "-C", project_root, "worktree", "remove", path],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
ok = result.returncode == 0
|
|
return {
|
|
"path": path,
|
|
"removed": ok,
|
|
"reason": None if ok else (result.stderr or "git worktree remove failed").strip(),
|
|
}
|
|
|
|
|
|
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)
|
|
|
|
|
|
def audit_branches_directory(
|
|
project_root: str,
|
|
*,
|
|
open_pr_branches: set[str] | None = None,
|
|
leased_branches: set[str] | None = None,
|
|
active_issue_branches: set[str] | None = None,
|
|
now: datetime | str | None = None,
|
|
ttl_hours: float = DEFAULT_TTL_HOURS,
|
|
) -> 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.
|
|
"""
|
|
open_pr_branches = open_pr_branches or set()
|
|
leased_branches = leased_branches or set()
|
|
active_issue_branches = active_issue_branches or set()
|
|
|
|
worktrees: list[dict[str, Any]] = []
|
|
for entry in list_worktrees(project_root):
|
|
path = entry.get("path") or ""
|
|
branch = entry.get("branch")
|
|
is_protected = (branch in PROTECTED_BRANCHES) or not _is_under_branches(
|
|
project_root, path
|
|
)
|
|
dirty_state = read_worktree_dirty(path)
|
|
is_dirty = bool(dirty_state.get("dirty"))
|
|
metadata = build_worktree_metadata(
|
|
path=path, branch=branch, head_sha=entry.get("head")
|
|
)
|
|
has_open_pr = bool(branch) and branch in open_pr_branches
|
|
has_active_lease = bool(branch) and branch in leased_branches
|
|
has_active_lock = bool(branch) and branch in active_issue_branches
|
|
ttl_expired = is_ttl_expired(
|
|
last_used_at=metadata.get("last_used_at"), now=now, ttl_hours=ttl_hours
|
|
)
|
|
classification = classify_worktree(
|
|
workflow_type=metadata["workflow_type"],
|
|
is_dirty=is_dirty,
|
|
has_open_pr=has_open_pr,
|
|
has_active_lease=has_active_lease,
|
|
has_active_issue_lock=has_active_lock,
|
|
is_detached=bool(entry.get("detached")),
|
|
branch_gone=branch is None and not entry.get("detached"),
|
|
ttl_expired=ttl_expired,
|
|
is_protected=is_protected,
|
|
)
|
|
metadata["cleanup_eligibility"] = classification
|
|
worktrees.append(
|
|
{
|
|
**metadata,
|
|
"detached": bool(entry.get("detached")),
|
|
"dirty": is_dirty,
|
|
"dirty_files": dirty_state.get("dirty_files", []),
|
|
"has_open_pr": has_open_pr,
|
|
"has_active_lease": has_active_lease,
|
|
"has_active_issue_lock": has_active_lock,
|
|
"is_protected": is_protected,
|
|
"classification": classification,
|
|
"removable": is_removable(classification),
|
|
}
|
|
)
|
|
|
|
counts: dict[str, int] = {}
|
|
for wt in worktrees:
|
|
counts[wt["classification"]] = counts.get(wt["classification"], 0) + 1
|
|
removable = [wt for wt in worktrees if wt["removable"]]
|
|
|
|
return {
|
|
"project_root": project_root,
|
|
"worktrees": worktrees,
|
|
"counts": counts,
|
|
"removable_candidates": removable,
|
|
"removable_count": len(removable),
|
|
"total": len(worktrees),
|
|
"git_worktree_list": git_worktree_list(project_root),
|
|
}
|