feat: add session-owned worktree cleanup audit and TTL enforcement (Closes #401)
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]>
This commit is contained in:
@@ -508,6 +508,7 @@ import reconciler_profile # noqa: E402
|
||||
import reconciliation_workflow # noqa: E402
|
||||
import review_merge_state_machine # noqa: E402
|
||||
import native_mcp_preference # noqa: E402
|
||||
import worktree_cleanup_audit # noqa: E402
|
||||
|
||||
|
||||
# Fail-closed exact-issue-lock file (#204): written by gitea_lock_issue,
|
||||
@@ -3785,6 +3786,90 @@ def gitea_scan_already_landed_open_prs(
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_audit_worktree_cleanup(
|
||||
remote: str = "dadeschools",
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
ttl_hours: float = worktree_cleanup_audit.DEFAULT_TTL_HOURS,
|
||||
) -> dict:
|
||||
"""Read-only: classify every session-owned worktree under ``branches/`` (#401).
|
||||
|
||||
Audits the local ``branches/`` directory, classifying each worktree as
|
||||
active open PR, active issue work, dirty local, clean stale removable,
|
||||
detached review leftover, or unsafe/unknown. Open PR branch heads are
|
||||
fetched live so a worktree tied to an open PR is never marked removable;
|
||||
the active issue-lock branch is read from the local lock file and treated
|
||||
as active work. Deletes nothing and mutates no Gitea state.
|
||||
|
||||
Fails closed if the live open-PR list cannot be fetched: without it,
|
||||
removability cannot be proven, so no candidates are returned.
|
||||
|
||||
Args:
|
||||
remote: Known instance — 'dadeschools' or 'prgs'.
|
||||
host: Override the Gitea host.
|
||||
org: Override the owner/organization.
|
||||
repo: Override the repository name.
|
||||
ttl_hours: Age (hours) after which a clean issue/conflict-fix
|
||||
worktree becomes stale-removable (default from
|
||||
GITEA_WORKTREE_TTL_HOURS).
|
||||
|
||||
Returns:
|
||||
dict with per-worktree classifications, counts, removable
|
||||
candidates, and the ``git worktree list`` verification proof.
|
||||
"""
|
||||
read_block = _profile_operation_gate("gitea.read")
|
||||
if read_block:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"reasons": read_block,
|
||||
"permission_report": _permission_block_report("gitea.read"),
|
||||
}
|
||||
|
||||
try:
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
auth = _auth(h)
|
||||
open_prs = api_get_all(f"{repo_api_url(h, o, r)}/pulls?state=open", auth)
|
||||
except Exception as exc:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"open_pr_state_verified": False,
|
||||
"reasons": [
|
||||
"could not fetch live open PRs; removability unverified "
|
||||
f"(fail closed): {_redact(str(exc))}"
|
||||
],
|
||||
}
|
||||
|
||||
open_pr_branches = {
|
||||
str((pr.get("head") or {}).get("ref"))
|
||||
for pr in open_prs
|
||||
if (pr.get("head") or {}).get("ref")
|
||||
}
|
||||
|
||||
active_issue_branches: set[str] = set()
|
||||
lock = merged_cleanup_reconcile.read_issue_lock(ISSUE_LOCK_FILE)
|
||||
if lock and lock.get("branch_name"):
|
||||
active_issue_branches.add(str(lock["branch_name"]).strip())
|
||||
|
||||
report = worktree_cleanup_audit.audit_branches_directory(
|
||||
PROJECT_ROOT,
|
||||
open_pr_branches=open_pr_branches,
|
||||
active_issue_branches=active_issue_branches,
|
||||
now=datetime.now(timezone.utc),
|
||||
ttl_hours=ttl_hours,
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"performed": False,
|
||||
"open_pr_state_verified": True,
|
||||
"task_mode": "work-issue",
|
||||
**report,
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_reconcile_already_landed_pr(
|
||||
pr_number: int,
|
||||
|
||||
Reference in New Issue
Block a user