From f309f92d30474cee54ba1e229254de4db1824520 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:33:54 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20hermes=20worktree=20list/prune=20?= =?UTF-8?q?=E2=80=94=20attended=20reclaim=20for=20accumulated=20worktrees?= =?UTF-8?q?=20and=20merged=20branches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cli.py | 21 +- hermes_cli/cli_commands_mixin.py | 53 +++- hermes_cli/commands.py | 6 +- hermes_cli/main.py | 53 +++- hermes_cli/worktree_cmd.py | 99 ++++++ hermes_cli/worktree_gc.py | 432 +++++++++++++++++++++++++++ tests/hermes_cli/test_worktree_gc.py | 243 +++++++++++++++ website/docs/user-guide/cli.md | 37 +++ 8 files changed, 935 insertions(+), 9 deletions(-) create mode 100644 hermes_cli/worktree_cmd.py create mode 100644 hermes_cli/worktree_gc.py create mode 100644 tests/hermes_cli/test_worktree_gc.py diff --git a/cli.py b/cli.py index 329f6efa7e..e026a56138 100644 --- a/cli.py +++ b/cli.py @@ -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. diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index 1778c28749..f3dcd7399f 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -1219,18 +1219,23 @@ class CLICommandsMixin: self._handle_resume_command(f"/resume {arg}") def _handle_worktree_command(self, cmd_original: str) -> None: - """Handle /worktree — inspect or create isolated git worktrees. + """Handle /worktree — inspect, create, or reclaim isolated git worktrees. Syntax: - /worktree — show the active worktree (if any) - /worktree new [name] — create a worktree and move this session into it - /worktree list — list worktrees under the repo's .worktrees/ + /worktree — show the active worktree (if any) + /worktree new [name] — create a worktree and move this session into it + /worktree list — list worktrees under the repo's .worktrees/ + /worktree prune [--dry-run] — reclaim safe trees + merged branches Inspired by Copilot CLI's ``/worktree new``: start isolated work in a fresh worktree without leaving the session. Creating one retargets the terminal/file tools (``TERMINAL_CWD`` + process cwd) at the new tree; the launcher's exit cleanup applies (kept only when it has unpushed commits, same as ``hermes -w``). + + ``prune`` is the same attended reclaim as ``hermes worktree prune`` + (hermes_cli/worktree_gc.py): never deletes tracked changes, unique + unpushed commits, or in-use trees; archives untracked-only scratch. """ import subprocess @@ -1250,10 +1255,50 @@ class CLICommandsMixin: print(" No active worktree for this session.") if repo_root: print(" /worktree new [name] — create one and move this session into it") + print(" /worktree prune — reclaim stale trees and merged branches") else: print(" (not inside a git repository)") return + if sub in {"prune", "gc", "clean"}: + if not repo_root: + print(" Not inside a git repository.") + return + rest = parts[2].strip().lower() if len(parts) > 2 else "" + dry_run = "--dry-run" in rest or "-n" in rest.split() + from hermes_cli import worktree_gc + + active = _cli._active_worktree + tree_records = worktree_gc.audit_worktrees(repo_root, with_sizes=False) + if active: + # Never reap the tree this very session is sitting in, even + # if a concurrent audit would judge it clean+merged. + active_path = str(active.get("path") or "") + tree_records = [ + record for record in tree_records + if record.path != active_path + ] + actions = worktree_gc.reclaim_worktrees( + repo_root, dry_run=dry_run, records=tree_records + ) + actions += worktree_gc.reclaim_branches(repo_root, dry_run=dry_run) + if actions: + for line in actions: + print(f" {line}") + print(f" {len(actions)} action(s) {'planned' if dry_run else 'done'}.") + else: + print(" Nothing to reclaim — remaining trees/branches carry real work.") + kept = [ + record for record in tree_records + if record.verdict == "keep" + and "kanban" not in record.reason and "in use" not in record.reason + ] + if kept: + print(f" Preserved {len(kept)} tree(s) with real work:") + for record in kept: + print(f" {record.name}: {record.reason}") + return + if sub in {"list", "ls"}: if not repo_root: print(" Not inside a git repository.") diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 350ee7411c..ee8eda30a4 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -167,9 +167,9 @@ COMMAND_REGISTRY: list[CommandDef] = [ args_hint="", cli_only=True), CommandDef("branch", "Branch the current session (explore a different path)", "Session", aliases=("fork",), args_hint="[name]"), - CommandDef("worktree", "Show, list, or create isolated git worktrees for this session", "Session", - cli_only=True, args_hint="[new [name]|list]", - subcommands=("new", "list")), + CommandDef("worktree", "Show, list, create, or prune isolated git worktrees", "Session", + cli_only=True, args_hint="[new [name]|list|prune [--dry-run]]", + subcommands=("new", "list", "prune")), CommandDef("compress", "Compress conversation context (add 'here [N]' to keep recent N turns; --preview shows what would happen)", "Session", aliases=("compact",), args_hint="[here [N] | focus topic | --preview|--dry-run]"), CommandDef("rollback", "List or restore filesystem checkpoints (restores keep your hand-edits; --all overrides)", "Session", diff --git a/hermes_cli/main.py b/hermes_cli/main.py index c9025d4d9c..0a19ed3048 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -11644,7 +11644,7 @@ _BUILTIN_SUBCOMMANDS = frozenset( "resume", "send", "sessions", "setup", "skin", "skills", "slack", "status", "sync", "tools", "uninstall", "update", - "version", "webhook", "whatsapp", "whatsapp-cloud", "chat", "secrets", "security", + "version", "webhook", "whatsapp", "whatsapp-cloud", "worktree", "chat", "secrets", "security", "verify", # Help-ish invocations — plugin commands not being listed in # top-level --help is an acceptable trade-off for skipping an @@ -12514,6 +12514,57 @@ def main(): ) fallback_parser.set_defaults(func=cmd_fallback) + # ========================================================================= + # worktree command — audit/reclaim accumulated git worktrees + branches + # ========================================================================= + worktree_parser = subparsers.add_parser( + "worktree", + help="Audit and reclaim accumulated git worktrees and merged branches", + description=( + "Attended reclaim for the .worktrees/ directory hermes -w sessions " + "accumulate. Never deletes uncommitted tracked changes, unique " + "unpushed commits, or in-use trees; untracked-only scratch is " + "archived to ~/.hermes/archive/worktree-prune/ before removal. See: " + "https://hermes-agent.nousresearch.com/docs/user-guide/cli#worktree-cleanup" + ), + ) + worktree_subparsers = worktree_parser.add_subparsers(dest="worktree_action") + worktree_list = worktree_subparsers.add_parser( + "list", + aliases=["ls", "audit"], + help="Classify every tree: age, size, verdict, reason (default action)", + ) + worktree_list.add_argument("--repo", help="Repo root (default: current repo)") + worktree_prune = worktree_subparsers.add_parser( + "prune", + help="Remove safe trees and delete fully-merged local branches", + ) + worktree_prune.add_argument("--repo", help="Repo root (default: current repo)") + worktree_prune.add_argument( + "--dry-run", action="store_true", + help="Show the plan without changing anything", + ) + worktree_prune.add_argument( + "--trees-only", action="store_true", + help="Only remove worktrees; leave local branches alone", + ) + worktree_prune.add_argument( + "--branches-only", action="store_true", + help="Only delete merged local branches; leave worktrees alone", + ) + + def _dispatch_worktree(_args): + from hermes_cli.worktree_cmd import cmd_worktree + + # argparse aliases set dest to the literal typed string ("ls"/"audit"). + action = getattr(_args, "worktree_action", None) + if action in ("ls", "audit"): + _args.worktree_action = "list" + return cmd_worktree(_args) + + worktree_parser.set_defaults(func=_dispatch_worktree) + + # ========================================================================= # secrets command — external secret managers (Bitwarden, 1Password) # ========================================================================= diff --git a/hermes_cli/worktree_cmd.py b/hermes_cli/worktree_cmd.py new file mode 100644 index 0000000000..d615fe9fc3 --- /dev/null +++ b/hermes_cli/worktree_cmd.py @@ -0,0 +1,99 @@ +"""``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 ).") + 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 diff --git a/hermes_cli/worktree_gc.py b/hermes_cli/worktree_gc.py new file mode 100644 index 0000000000..bfb863c869 --- /dev/null +++ b/hermes_cli/worktree_gc.py @@ -0,0 +1,432 @@ +"""On-demand worktree + branch reclaim (``hermes worktree`` / ``/worktree prune``). + +The startup pruner in ``cli._prune_stale_worktrees`` is deliberately +conservative and silent: it runs before the banner on every ``hermes -w`` +launch, so it only reaps clean, fully-merged scratch trees past an age tier +and preserves everything else. That policy is correct for an unattended +startup path — but it means real installs accumulate two kinds of debris the +startup pass can never touch: + +- **Preserved trees** whose only "dirt" is untracked scratch (PR body drafts, + logs) on an otherwise merged branch — preserved forever by the dirty guard. +- **Orphaned local branches** beyond the two auto-generated prefixes the + startup pass deletes (``hermes/hermes-*``, ``pr-*``): salvage lanes, port + branches, feature branches whose PRs merged months ago. Multi-agent boxes + reach hundreds. + +This module is the *attended* counterpart: an explicit, loud, dry-run-first +reclaim the user invokes, so it can be more thorough while staying just as +safe. Invariants shared with the startup pruner (never violated here either): + +- tracked modifications are NEVER deleted, at any age, in any mode; +- unique unpushed commits are NEVER deleted (``git cherry`` patch-equivalence + decides "unique"; shallow repos are deepened bloblessly first so the + verdict is trustworthy); +- live-locked trees (owning pid alive) are never touched; +- a branch is deleted only after its worktree removal succeeded — a failed + removal must not orphan reachable commits; +- untracked-only dirt is ARCHIVED to ``~/.hermes/archive/worktree-prune/`` + before its tree is reaped, never destroyed. + +Classification primitives are imported from ``cli`` so the two paths can +never drift apart on what "dirty", "unpushed", or "merged" means. +""" + +from __future__ import annotations + +import logging +import os +import re +import shutil +import subprocess +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import List, Optional + +logger = logging.getLogger(__name__) + +# Branches never considered for deletion, in any mode. +_PROTECTED_BRANCHES = {"main", "master", "develop", "dev", "trunk"} + +# Trees owned by another lifecycle (kanban dispatcher gc) — never touched. +_KANBAN_RE = re.compile(r"^t_[0-9a-f]+$") + +# Bounded cherry probe: a branch this far ahead of upstream is a stale-base +# lane, not merged scratch; checking it is expensive and it stays preserved. +_MAX_CHERRY_AHEAD = 50 + + +@dataclass +class TreeRecord: + name: str + path: str + branch: str + age_days: float + size_mb: Optional[int] + verdict: str # reap | reap-archive | keep + reason: str + untracked: List[str] = field(default_factory=list) + + +@dataclass +class BranchRecord: + name: str + verdict: str # delete | keep + reason: str + + +def _git(args: list, cwd: str, timeout: int = 15) -> subprocess.CompletedProcess: + """Run git, translating timeouts into a nonzero returncode. + + Every verdict in this module fails safe toward "keep" on a nonzero + returncode, so a hung/slow git call (large repos make ``git cherry`` + genuinely slow) must degrade to keep — never crash the whole audit + (live-verified failure on a 746MB .git: TimeoutExpired escaped and + aborted the branch audit mid-list). + """ + try: + return subprocess.run( + ["git", *args], + capture_output=True, text=True, encoding="utf-8", + errors="replace", timeout=timeout, cwd=cwd, + ) + except subprocess.TimeoutExpired: + return subprocess.CompletedProcess( + args=["git", *args], returncode=124, + stdout="", stderr=f"timeout after {timeout}s", + ) + + +def _tree_size_mb(path: Path) -> Optional[int]: + """Cheap directory size via ``du -sm`` — best-effort, None on failure.""" + try: + result = subprocess.run( + ["du", "-sm", str(path)], + capture_output=True, text=True, encoding="utf-8", + errors="replace", timeout=30, + ) + if result.returncode == 0 and result.stdout.strip(): + return int(result.stdout.split()[0]) + except Exception: + pass + return None + + +def _dirty_split(path: str) -> tuple[bool, List[str]]: + """Return (has_tracked_modifications, untracked_paths). + + ``git status --porcelain`` counts untracked scratch equally with real + edits; the reclaim policy treats them very differently (tracked = real + work, untracked = archivable scratch), so split here. + """ + try: + result = _git(["status", "--porcelain"], cwd=path, timeout=10) + if result.returncode != 0: + return True, [] # fail safe: treat as real work + tracked = False + untracked: List[str] = [] + for line in result.stdout.splitlines(): + if not line.strip(): + continue + if line.startswith("??"): + untracked.append(line[3:].strip()) + else: + tracked = True + return tracked, untracked + except Exception: + return True, [] + + +def _archive_untracked(tree: Path, untracked: List[str]) -> Optional[Path]: + """Copy untracked files out of a doomed tree. Returns the archive dir. + + Never destroys: on any copy failure the caller must treat the tree as + keep. Costs almost nothing and removes the "did I just delete + something?" question. + """ + stamp = time.strftime("%Y%m%d-%H%M%S") + dest = ( + Path.home() / ".hermes" / "archive" / "worktree-prune" + / f"{tree.name}-{stamp}" + ) + try: + for rel in untracked: + src = tree / rel + if not src.exists() or src.is_symlink(): + continue + target = dest / rel + target.parent.mkdir(parents=True, exist_ok=True) + if src.is_dir(): + shutil.copytree(src, target, dirs_exist_ok=True) + else: + shutil.copy2(src, target) + return dest if dest.exists() else None + except Exception as exc: + logger.warning("Could not archive untracked files from %s: %s", tree, exc) + return None + + +def audit_worktrees(repo_root: str, *, with_sizes: bool = True) -> List[TreeRecord]: + """Classify every tree under ``.worktrees/`` without mutating anything.""" + import cli as _cli # lazy: cli.py is heavy + + worktrees_dir = Path(repo_root) / ".worktrees" + if not worktrees_dir.exists(): + return [] + + if _cli._repo_is_shallow(repo_root): + _cli._deepen_shallow_repo(repo_root) + + merge_cache = _cli._load_worktree_merge_cache() + cache_size_before = len(merge_cache) + + now = time.time() + records: List[TreeRecord] = [] + for entry in sorted(worktrees_dir.iterdir()): + if not entry.is_dir(): + continue + try: + age_days = (now - entry.stat().st_mtime) / 86400.0 + except Exception: + continue + size_mb = _tree_size_mb(entry) if with_sizes else None + + try: + branch_result = _git(["branch", "--show-current"], cwd=str(entry), timeout=5) + branch = branch_result.stdout.strip() + except Exception: + branch = "" + + def rec(verdict: str, reason: str, untracked: Optional[List[str]] = None): + records.append(TreeRecord( + name=entry.name, path=str(entry), branch=branch, + age_days=age_days, size_mb=size_mb, + verdict=verdict, reason=reason, + untracked=untracked or [], + )) + + if _KANBAN_RE.match(entry.name): + rec("keep", "kanban task tree (owned by kanban gc)") + continue + + lock_state = _cli._worktree_lock_is_live(repo_root, str(entry), timeout=5) + if lock_state == "live": + rec("keep", "in use by a running hermes session") + continue + + tracked_dirty, untracked = _dirty_split(str(entry)) + if tracked_dirty: + rec("keep", "uncommitted tracked changes (real work)") + continue + + if _cli._worktree_has_unpushed_commits(str(entry), timeout=5): + merged = _cli._worktree_commits_all_merged_upstream( + str(entry), timeout=30, cache=merge_cache, + max_ahead=_MAX_CHERRY_AHEAD, + ) + if not merged: + rec("keep", "unpushed commits not found upstream") + continue + + if untracked: + rec("reap-archive", + f"merged/pushed; {len(untracked)} untracked file(s) will be archived", + untracked) + else: + rec("reap", "clean and fully merged/pushed") + + if len(merge_cache) != cache_size_before: + _cli._save_worktree_merge_cache(merge_cache) + return records + + +def reclaim_worktrees( + repo_root: str, + *, + dry_run: bool = False, + records: Optional[List[TreeRecord]] = None, +) -> List[str]: + """Remove every reap-verdict tree from a frozen audit list. + + Operates ONLY on the provided (or freshly computed) audit records — never + re-globs inside the destructive loop, so trees created by concurrent + sessions after the audit are out of scope by construction. + """ + if records is None: + records = audit_worktrees(repo_root, with_sizes=False) + actions: List[str] = [] + for record in records: + if record.verdict not in {"reap", "reap-archive"}: + continue + if dry_run: + actions.append(f"would remove {record.name} ({record.reason})") + continue + + entry = Path(record.path) + if record.verdict == "reap-archive" and record.untracked: + archive = _archive_untracked(entry, record.untracked) + if archive is None: + actions.append(f"kept {record.name} (archive of untracked files failed)") + continue + actions.append(f"archived {len(record.untracked)} untracked file(s) → {archive}") + + # Dead-pid locks must be unlocked or `remove --force` refuses. + try: + _git(["worktree", "unlock", record.path], cwd=repo_root, timeout=10) + except Exception: + pass + + try: + remove_result = _git( + ["worktree", "remove", record.path, "--force"], + cwd=repo_root, timeout=30, + ) + if remove_result.returncode != 0: + actions.append( + f"failed to remove {record.name}: {remove_result.stderr.strip()}" + ) + continue + if record.branch and record.branch not in _PROTECTED_BRANCHES: + _git(["branch", "-D", record.branch], cwd=repo_root, timeout=10) + actions.append(f"removed {record.name}") + except Exception as exc: + actions.append(f"failed to remove {record.name}: {exc}") + + if not dry_run: + try: + _git(["worktree", "prune"], cwd=repo_root, timeout=15) + except Exception: + pass + return actions + + +def audit_branches(repo_root: str) -> List[BranchRecord]: + """Classify local branches: safe to delete when their content is on + upstream (fully merged OR every commit patch-equivalent via ``git + cherry``) and they are not checked out anywhere. + + Generalizes the startup pass's prefix list (``hermes/hermes-*``/``pr-*``) + to EVERY local branch, because deletion is gated on content reachability + rather than name: a branch whose commits are all upstream loses nothing + when its ref goes. Branch names checked out in any worktree, protected + names, and branches with unique commits are kept. + """ + import cli as _cli + + if _cli._repo_is_shallow(repo_root): + _cli._deepen_shallow_repo(repo_root) + + upstream = None + for candidate in ("origin/HEAD", "origin/main", "origin/master"): + probe = _git(["rev-parse", "--verify", "--quiet", candidate], cwd=repo_root, timeout=5) + if probe.returncode == 0: + upstream = candidate + break + if upstream is None: + return [] + + result = _git(["branch", "--format=%(refname:short)"], cwd=repo_root, timeout=10) + if result.returncode != 0: + return [] + branches = [b.strip() for b in result.stdout.splitlines() if b.strip()] + + active: set = set() + wt = _git(["worktree", "list", "--porcelain"], cwd=repo_root, timeout=10) + for line in wt.stdout.splitlines(): + if line.startswith("branch refs/heads/"): + active.add(line.split("branch refs/heads/", 1)[-1].strip()) + + merged_result = _git(["branch", "--merged", upstream, "--format=%(refname:short)"], + cwd=repo_root, timeout=15) + merged = {b.strip() for b in merged_result.stdout.splitlines() if b.strip()} + + def _classify_branch(branch: str) -> BranchRecord: + if branch in _PROTECTED_BRANCHES or branch in active: + return BranchRecord(branch, "keep", "protected or checked out") + if branch in merged: + return BranchRecord(branch, "delete", "fully merged into " + upstream) + # Rebase merges rewrite SHAs, so --merged misses them; cherry + # patch-equivalence catches the dominant leak. Bounded: a branch + # far ahead is a stale-base lane, keep it. + ahead = _git(["rev-list", "--count", f"{upstream}..{branch}"], cwd=repo_root, timeout=10) + try: + ahead_count = int(ahead.stdout.strip() or "0") + except ValueError: + ahead_count = _MAX_CHERRY_AHEAD + 1 + if ahead_count == 0: + return BranchRecord(branch, "delete", "no commits beyond " + upstream) + if ahead_count > _MAX_CHERRY_AHEAD: + return BranchRecord(branch, "keep", f"{ahead_count} commits ahead (stale-base lane)") + cherry = _git(["cherry", upstream, branch], cwd=repo_root, timeout=30) + if cherry.returncode != 0: + return BranchRecord(branch, "keep", "could not verify (git cherry failed)") + lines = [ln for ln in cherry.stdout.splitlines() if ln.strip()] + if lines and all(ln.startswith("-") for ln in lines): + return BranchRecord(branch, "delete", "all commits patch-equivalent upstream") + unique = sum(1 for ln in lines if ln.startswith("+")) + return BranchRecord(branch, "keep", f"{unique} unique commit(s) not upstream") + + # Read-only classification — parallel, like the tree audit (a busy + # multi-agent box carries hundreds of local branches; serial cherry + # probes at ~0.2-1s each make the audit minutes long). + import concurrent.futures + + workers = max(1, min(8, (os.cpu_count() or 4), len(branches))) + if workers > 1: + try: + with concurrent.futures.ThreadPoolExecutor( + max_workers=workers, thread_name_prefix="hermes-branch-gc" + ) as pool: + return list(pool.map(_classify_branch, branches)) + except Exception: + pass + return [_classify_branch(b) for b in branches] + + +def reclaim_branches( + repo_root: str, + *, + dry_run: bool = False, + records: Optional[List[BranchRecord]] = None, +) -> List[str]: + """Delete every delete-verdict branch from a frozen audit list.""" + if records is None: + records = audit_branches(repo_root) + actions: List[str] = [] + for record in records: + if record.verdict != "delete": + continue + if dry_run: + actions.append(f"would delete branch {record.name} ({record.reason})") + continue + result = _git(["branch", "-D", record.name], cwd=repo_root, timeout=10) + if result.returncode == 0: + actions.append(f"deleted branch {record.name}") + else: + actions.append(f"failed to delete {record.name}: {result.stderr.strip()}") + return actions + + +def worktrees_summary(repo_root: str) -> tuple[int, Optional[int]]: + """(tree_count, total_size_mb) for the escalation notice. Size is + best-effort with a hard timeout so the startup path never stalls.""" + worktrees_dir = Path(repo_root) / ".worktrees" + if not worktrees_dir.exists(): + return 0, None + try: + count = sum(1 for e in worktrees_dir.iterdir() if e.is_dir()) + except Exception: + return 0, None + size_mb: Optional[int] = None + try: + result = subprocess.run( + ["du", "-sm", str(worktrees_dir)], + capture_output=True, text=True, encoding="utf-8", + errors="replace", timeout=20, + ) + if result.returncode == 0 and result.stdout.strip(): + size_mb = int(result.stdout.split()[0]) + except Exception: + pass + return count, size_mb diff --git a/tests/hermes_cli/test_worktree_gc.py b/tests/hermes_cli/test_worktree_gc.py new file mode 100644 index 0000000000..03e24a51ed --- /dev/null +++ b/tests/hermes_cli/test_worktree_gc.py @@ -0,0 +1,243 @@ +"""Behavior contracts for hermes_cli.worktree_gc (attended reclaim). + +Each guard gets its own contract against a REAL git repo fixture (no mocks — +the entire value of these tests is exercising actual git verdicts): + +- clean + fully merged tree → reap +- untracked-only dirt → reap-archive (files archived, then removed) +- tracked modifications → keep, any age +- unique unpushed commits → keep +- patch-equivalent commits (rebase/squash-merge leak) → reap +- live-locked tree → keep +- kanban t_ tree → keep (owned by kanban gc) +- branch GC: merged branch deleted, unique-commit branch kept, + checked-out branch kept, protected names kept +- reclaim operates ONLY on the frozen audit list (concurrent-session trap) +""" + +import os +import subprocess +from pathlib import Path + +import pytest + +from hermes_cli import worktree_gc + + +def _git(args, cwd, env=None): + e = dict(os.environ) + e.update({ + "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", + "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t", + }) + if env: + e.update(env) + result = subprocess.run( + ["git", *args], capture_output=True, text=True, cwd=str(cwd), env=e, + ) + assert result.returncode == 0, f"git {args} failed: {result.stderr}" + return result.stdout.strip() + + +@pytest.fixture +def repo(tmp_path, monkeypatch): + """origin (bare) + clone with .worktrees/, HOME redirected for archives.""" + monkeypatch.setenv("HOME", str(tmp_path / "home")) + (tmp_path / "home").mkdir() + + origin = tmp_path / "origin.git" + origin.mkdir() + _git(["init", "--bare", "-b", "main"], origin) + + clone = tmp_path / "repo" + _git(["clone", str(origin), str(clone)], tmp_path) + (clone / "README.md").write_text("hello\n") + _git(["add", "."], clone) + _git(["commit", "-m", "init"], clone) + _git(["push", "origin", "main"], clone) + # origin/HEAD so upstream resolution works like a real clone. + _git(["remote", "set-head", "origin", "main"], clone) + (clone / ".worktrees").mkdir() + return clone + + +def _add_worktree(repo_path, name, branch=None): + tree = repo_path / ".worktrees" / name + branch = branch or f"hermes/{name}" + _git(["worktree", "add", str(tree), "-b", branch], repo_path) + return tree, branch + + +def _verdict(records, name): + match = [record for record in records if record.name == name] + assert match, f"no record for {name}" + return match[0] + + +class TestAuditVerdicts: + def test_clean_merged_tree_reaps(self, repo): + _add_worktree(repo, "hermes-clean") + records = worktree_gc.audit_worktrees(str(repo), with_sizes=False) + assert _verdict(records, "hermes-clean").verdict == "reap" + + def test_tracked_modifications_keep(self, repo): + tree, _ = _add_worktree(repo, "hermes-dirty") + (tree / "README.md").write_text("edited\n") + records = worktree_gc.audit_worktrees(str(repo), with_sizes=False) + record = _verdict(records, "hermes-dirty") + assert record.verdict == "keep" + assert "tracked" in record.reason + + def test_untracked_only_is_reap_archive(self, repo): + tree, _ = _add_worktree(repo, "hermes-scratch") + (tree / "PR_BODY_DRAFT.md").write_text("draft\n") + records = worktree_gc.audit_worktrees(str(repo), with_sizes=False) + record = _verdict(records, "hermes-scratch") + assert record.verdict == "reap-archive" + assert record.untracked == ["PR_BODY_DRAFT.md"] + + def test_unique_unpushed_commits_keep(self, repo): + tree, _ = _add_worktree(repo, "hermes-work") + (tree / "new.py").write_text("x = 1\n") + _git(["add", "."], tree) + _git(["commit", "-m", "unique work"], tree) + records = worktree_gc.audit_worktrees(str(repo), with_sizes=False) + record = _verdict(records, "hermes-work") + assert record.verdict == "keep" + assert "unpushed" in record.reason + + def test_patch_equivalent_commits_reap(self, repo): + """The squash/rebase-merge leak: local commit unreachable from any + remote ref but patch-equivalent to an upstream commit → merged work.""" + tree, _ = _add_worktree(repo, "hermes-merged") + (tree / "feat.py").write_text("y = 2\n") + _git(["add", "."], tree) + _git(["commit", "-m", "feat"], tree) + sha = _git(["rev-parse", "HEAD"], tree) + # "Merge" it to main with a DIFFERENT committer so the cherry-pick + # produces a distinct sha (same-second identical-committer cherry + # picks can produce the identical sha — pitfall from the skill). + _git(["cherry-pick", sha], repo, + env={"GIT_COMMITTER_NAME": "other", "GIT_COMMITTER_EMAIL": "o@o"}) + _git(["push", "origin", "main"], repo) + records = worktree_gc.audit_worktrees(str(repo), with_sizes=False) + assert _verdict(records, "hermes-merged").verdict == "reap" + + def test_live_locked_tree_keeps(self, repo): + tree, _ = _add_worktree(repo, "hermes-live") + _git(["worktree", "lock", str(tree), + "--reason", f"hermes pid={os.getpid()}"], repo) + records = worktree_gc.audit_worktrees(str(repo), with_sizes=False) + record = _verdict(records, "hermes-live") + assert record.verdict == "keep" + assert "in use" in record.reason + + def test_kanban_tree_untouched(self, repo): + _add_worktree(repo, "t_deadbeef", branch="kanban/t_deadbeef") + records = worktree_gc.audit_worktrees(str(repo), with_sizes=False) + record = _verdict(records, "t_deadbeef") + assert record.verdict == "keep" + assert "kanban" in record.reason + + +class TestReclaim: + def test_reap_removes_tree_and_branch(self, repo): + tree, branch = _add_worktree(repo, "hermes-clean") + records = worktree_gc.audit_worktrees(str(repo), with_sizes=False) + actions = worktree_gc.reclaim_worktrees(str(repo), records=records) + assert any("removed hermes-clean" in a for a in actions) + assert not tree.exists() + probe = subprocess.run( + ["git", "rev-parse", "--verify", "--quiet", branch], + capture_output=True, text=True, cwd=str(repo), + ) + assert probe.returncode != 0, "branch should be gone with its tree" + + def test_untracked_files_archived_before_removal(self, repo): + tree, _ = _add_worktree(repo, "hermes-scratch") + (tree / "NOTES.md").write_text("important scribbles\n") + records = worktree_gc.audit_worktrees(str(repo), with_sizes=False) + worktree_gc.reclaim_worktrees(str(repo), records=records) + assert not tree.exists() + archive_root = Path.home() / ".hermes" / "archive" / "worktree-prune" + archived = list(archive_root.rglob("NOTES.md")) + assert archived, "untracked file must be archived, not destroyed" + assert archived[0].read_text() == "important scribbles\n" + + def test_dry_run_changes_nothing(self, repo): + tree, _ = _add_worktree(repo, "hermes-clean") + records = worktree_gc.audit_worktrees(str(repo), with_sizes=False) + actions = worktree_gc.reclaim_worktrees( + str(repo), dry_run=True, records=records + ) + assert any("would remove" in a for a in actions) + assert tree.exists() + + def test_frozen_list_ignores_trees_created_after_audit(self, repo): + """Concurrent-session trap: a tree created between audit and reclaim + must be out of scope by construction.""" + _add_worktree(repo, "hermes-old") + records = worktree_gc.audit_worktrees(str(repo), with_sizes=False) + late_tree, _ = _add_worktree(repo, "hermes-late") + worktree_gc.reclaim_worktrees(str(repo), records=records) + assert late_tree.exists(), "tree created after the audit must survive" + + def test_dead_locked_tree_is_unlocked_and_reaped(self, repo): + tree, _ = _add_worktree(repo, "hermes-zombie") + _git(["worktree", "lock", str(tree), + "--reason", "hermes pid=999999999"], repo) + records = worktree_gc.audit_worktrees(str(repo), with_sizes=False) + assert _verdict(records, "hermes-zombie").verdict == "reap" + worktree_gc.reclaim_worktrees(str(repo), records=records) + assert not tree.exists() + + +class TestBranchGC: + def test_merged_branch_deleted_any_name(self, repo): + """Branch GC is content-gated, not name-gated: any fully-merged local + branch is safe to delete regardless of prefix.""" + _git(["branch", "salv-12345", "main"], repo) + _git(["branch", "feat/some-old-thing", "main"], repo) + records = worktree_gc.audit_branches(str(repo)) + by_name = {record.name: record for record in records} + assert by_name["salv-12345"].verdict == "delete" + assert by_name["feat/some-old-thing"].verdict == "delete" + worktree_gc.reclaim_branches(str(repo), records=records) + out = _git(["branch", "--format=%(refname:short)"], repo) + assert "salv-12345" not in out + assert "feat/some-old-thing" not in out + + def test_unique_commit_branch_kept(self, repo): + _git(["checkout", "-b", "feat/real-work"], repo) + (repo / "wip.py").write_text("z = 3\n") + _git(["add", "."], repo) + _git(["commit", "-m", "wip"], repo) + _git(["checkout", "main"], repo) + records = worktree_gc.audit_branches(str(repo)) + by_name = {record.name: record for record in records} + assert by_name["feat/real-work"].verdict == "keep" + assert "unique" in by_name["feat/real-work"].reason + + def test_patch_equivalent_branch_deleted(self, repo): + """Rebase-merged PR branch: SHAs differ from main but every commit is + patch-equivalent — the dominant branch leak.""" + _git(["checkout", "-b", "fix/landed"], repo) + (repo / "fix.py").write_text("a = 4\n") + _git(["add", "."], repo) + _git(["commit", "-m", "fix"], repo) + sha = _git(["rev-parse", "HEAD"], repo) + _git(["checkout", "main"], repo) + _git(["cherry-pick", sha], repo, + env={"GIT_COMMITTER_NAME": "other", "GIT_COMMITTER_EMAIL": "o@o"}) + _git(["push", "origin", "main"], repo) + records = worktree_gc.audit_branches(str(repo)) + by_name = {record.name: record for record in records} + assert by_name["fix/landed"].verdict == "delete" + assert "patch-equivalent" in by_name["fix/landed"].reason + + def test_checked_out_and_protected_kept(self, repo): + _tree, branch = _add_worktree(repo, "hermes-active") + records = worktree_gc.audit_branches(str(repo)) + by_name = {record.name: record for record in records} + assert by_name["main"].verdict == "keep" + assert by_name[branch].verdict == "keep" diff --git a/website/docs/user-guide/cli.md b/website/docs/user-guide/cli.md index 9c4e7a6aab..08a9401468 100644 --- a/website/docs/user-guide/cli.md +++ b/website/docs/user-guide/cli.md @@ -58,6 +58,43 @@ hermes -w # Interactive mode in worktree hermes -w -z "Fix issue #123" # Single query in worktree ``` +### Worktree cleanup + +`hermes -w` sessions create disposable worktrees under `/.worktrees/`. +A conservative pruner runs automatically at startup (it only removes clean, +fully-merged scratch trees past an age threshold), but preserved trees and +merged local branches still accumulate on busy machines. Reclaim them +explicitly: + +```bash +hermes worktree list # audit: age, size, verdict, reason per tree +hermes worktree prune # remove safe trees + delete merged branches +hermes worktree prune --dry-run # show the plan without changing anything +hermes worktree prune --trees-only # leave local branches alone +hermes worktree prune --branches-only # leave worktrees alone +``` + +Inside a session, `/worktree prune [--dry-run]` does the same (and never +touches the tree the session is running in). + +Safety guarantees (all modes, any age): + +- Uncommitted **tracked** changes are never deleted. +- **Unique unpushed commits** are never deleted — commits that were + rebase/squash-merged upstream are detected via `git cherry` + patch-equivalence and count as merged, which is what lets the dominant + "merged PR, tree preserved forever" leak finally reclaim. +- Trees **in use by a running hermes session** are never touched. +- **Untracked-only scratch** (PR body drafts, notes) is archived to + `~/.hermes/archive/worktree-prune/` before its tree is removed — never + destroyed. +- Branch deletion is content-gated, not name-gated: any local branch whose + commits are all on upstream is safe to delete; branches with unique work, + checked-out branches, and `main`/`master`/`develop` are always kept. + +When `.worktrees/` grows past 10 trees or 5 GB, startup prints a one-line +notice pointing at these commands. + ### Plugin management The `hermes plugins` commands manage native Hermes plugins and portable Agent