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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user