Files
hermes-agent/hermes_cli/worktree_cmd.py
Teknium f309f92d30 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.
2026-08-19 23:55:20 -07:00

100 lines
3.6 KiB
Python

"""``hermes worktree`` — audit and reclaim accumulated git worktrees/branches.
Attended counterpart of the silent startup pruner (see
``hermes_cli/worktree_gc.py`` for the policy and shared invariants). Usage:
hermes worktree list # audit: verdict + reason per tree
hermes worktree prune # reap safe trees + merged branches
hermes worktree prune --dry-run # show the plan, change nothing
hermes worktree prune --trees-only / --branches-only
"""
from __future__ import annotations
from typing import Optional
def _repo_root() -> Optional[str]:
import cli as _cli
return _cli._git_repo_root()
def _fmt_size(size_mb: Optional[int]) -> str:
if size_mb is None:
return "?"
if size_mb >= 1024:
return f"{size_mb / 1024:.1f}G"
return f"{size_mb}M"
def cmd_worktree(args) -> int:
from hermes_cli import worktree_gc
repo_root = getattr(args, "repo", None) or _repo_root()
if not repo_root:
print("Not inside a git repository (or pass --repo <path>).")
return 1
action = getattr(args, "worktree_action", None) or "list"
if action == "list":
records = worktree_gc.audit_worktrees(repo_root)
if not records:
print("No worktrees under .worktrees/ — nothing to reclaim.")
return 0
total_mb = sum(r.size_mb or 0 for r in records)
reapable_mb = sum(
r.size_mb or 0 for r in records if r.verdict.startswith("reap")
)
print(f"{'TREE':32} {'AGE':>6} {'SIZE':>6} {'VERDICT':13} REASON")
for r in sorted(records, key=lambda x: -(x.size_mb or 0)):
print(
f"{r.name[:32]:32} {r.age_days:>5.1f}d {_fmt_size(r.size_mb):>6} "
f"{r.verdict:13} {r.reason}"
)
print(
f"\n{len(records)} tree(s), {_fmt_size(total_mb)} total — "
f"{_fmt_size(reapable_mb)} reclaimable now via `hermes worktree prune`."
)
branch_records = worktree_gc.audit_branches(repo_root)
deletable = [b for b in branch_records if b.verdict == "delete"]
if deletable:
print(
f"{len(deletable)} local branch(es) fully merged/patch-equivalent "
f"upstream would also be deleted."
)
return 0
if action == "prune":
dry_run = bool(getattr(args, "dry_run", False))
trees_only = bool(getattr(args, "trees_only", False))
branches_only = bool(getattr(args, "branches_only", False))
actions: list = []
if not branches_only:
tree_records = worktree_gc.audit_worktrees(repo_root, with_sizes=False)
actions += worktree_gc.reclaim_worktrees(
repo_root, dry_run=dry_run, records=tree_records
)
kept = [r for r in tree_records if r.verdict == "keep"
and "kanban" not in r.reason and "in use" not in r.reason]
if kept:
print(f"Preserved {len(kept)} tree(s) with real work:")
for r in kept:
print(f" {r.name}: {r.reason}")
if not trees_only:
actions += worktree_gc.reclaim_branches(repo_root, dry_run=dry_run)
if actions:
for line in actions:
print(f" {line}")
verb = "planned" if dry_run else "done"
print(f"{len(actions)} action(s) {verb}.")
else:
print("Nothing to reclaim — all trees/branches carry real work or are in use.")
return 0
print(f"Unknown worktree action: {action}")
return 1