feat: hermes worktree list/prune — attended reclaim for accumulated worktrees and merged branches

The startup pruner is deliberately conservative (unattended, pre-banner),
so real installs accumulate what it can never touch: trees preserved for
untracked-only scratch, and orphaned local branches beyond the two
auto-generated prefixes it deletes. A measured multi-agent box: 35 trees /
15GB / 244 local branches, 120 of them fully merged.

New attended surface (hermes_cli/worktree_gc.py + worktree_cmd.py):
- hermes worktree list — audit every tree: age, size, verdict, reason,
  plus deletable-branch count
- hermes worktree prune [--dry-run|--trees-only|--branches-only]
- /worktree prune [--dry-run] — same engine in-session; never touches the
  session's own active tree
- startup escalation: one WARNING when .worktrees/ exceeds 10 trees or
  5GB, naming the reclaim commands (silence is how boxes hit 15GB)

Safety invariants (shared with the startup pruner via cli.py primitives):
tracked modifications and unique unpushed commits never deleted at any
age; live-locked trees untouched; branch deletion gated on worktree
removal success; untracked-only scratch ARCHIVED to
~/.hermes/archive/worktree-prune/ before its tree is reaped.

Branch GC is content-gated, not name-gated: any local branch fully merged
or git-cherry patch-equivalent upstream is safe to delete (rebase merges
rewrite SHAs, so --merged alone misses the dominant leak); unique-commit,
checked-out, protected, and stale-base (>50 ahead) branches are kept.
Classification is parallel (8 workers) — 244 branches audit in ~64s live.

git timeouts degrade to keep (returncode 124) instead of crashing the
audit — live-verified failure on a 746MB .git repo.

16 behavior-contract tests against real git fixtures; live dry-run on the
production repo: 12 trees reclaimable, 120 branches deletable, 0 false
positives among kept trees.
This commit is contained in:
Teknium
2026-08-19 23:33:54 -07:00
parent 9ed06ca2b6
commit f309f92d30
8 changed files with 935 additions and 9 deletions
+20 -1
View File
@@ -2741,12 +2741,31 @@ def _prune_stale_worktrees(repo_root: str, max_age_hours: int = 24) -> None:
if preserved_stale:
logger.warning(
"Preserving %d worktree(s) older than 7 days with unmerged work "
"(push or remove them to reclaim disk): %s",
"(run `hermes worktree prune` to review and reclaim): %s",
len(preserved_stale), ", ".join(sorted(preserved_stale)),
)
_prune_orphaned_branches(repo_root)
# Escalation notice: the startup pass is deliberately conservative, so
# installs accumulate preserved trees it can never reclaim. Once the
# footprint is clearly a problem (many trees or multi-GB), say so once
# per launch and name the attended reclaim command — silence here is how
# boxes reach 15GB+ of .worktrees/ without anyone noticing.
try:
from hermes_cli.worktree_gc import worktrees_summary
count, size_mb = worktrees_summary(repo_root)
if count >= 10 or (size_mb or 0) >= 5120:
size_txt = f"{size_mb / 1024:.1f}GB" if size_mb else "unknown size"
logger.warning(
".worktrees/ holds %d tree(s) (%s) — run `hermes worktree list` "
"to audit and `hermes worktree prune` to reclaim safely.",
count, size_txt,
)
except Exception:
pass
def _prune_orphaned_branches(repo_root: str) -> None:
"""Delete local ``hermes/hermes-*`` and ``pr-*`` branches with no worktree.