The second review found trees deleted with bytes or commits that were in
neither git nor the archive: untracked files over archive_max_file_mb
(skipped from the snapshot, tree still removed), gitignored embedded
repositories (ls-files --exclude-standard never lists them) and every
ignored file (.env edits, *.secret, info/exclude notes). The classic
pruner had three older gaps of the same class: status.showUntrackedFiles=no
made new files invisible, skip-worktree/assume-unchanged edits read clean,
and commits held only by refs/worktree/* or refs/bisect/* died with the
tree's admin dir.
Rule now enforced at one decision point (worktree_retention.removal_blocker)
used by archive_preflight, the post-snapshot removal boundary in
archive_then_remove (sweep, exit Archive, subagent finalize), the classic
clean-tree pruner, the attended `hermes worktree prune` and the clean exit
path: a tree is removed only when everything irreproducible is in git or the
archive. Kept: keep-large-untracked (archive ref stays), keep-unarchivable
(embedded repo, ignored or not), keep-worktree-refs, keep-ignored-work
(ignored files outside a regenerable-dir allowlist that are not
byte-identical to the main checkout's copy, so .worktreeinclude .env copies
never block). Exit Archive and the subagent note name the reason. Every
dirty check forces --untracked-files=normal and counts flagged-index edits.
The ignored-path walk skips dependency stores without descending and is
capped per tree (unverifiable -> keep).
A snapshot records a submodule only as its 160000 gitlink, so `git worktree
remove --force` after archiving deleted an initialized submodule's uncommitted
edits (and its per-worktree modules/ repo) with nothing in the archive to
restore them. The shared snapshot_worktree entry point and archive_preflight
now refuse any tree whose populated submodule is dirty (tracked or untracked,
recursively), checked out at another commit than recorded, or holds commits/
stashes no remote-tracking ref has; the planner shows it as
keep-submodule-work. Uninitialized or clean-at-recorded-commit submodules
still archive. Refusing in snapshot_worktree covers exit-time Archive and
subagent finalization, which do not go through the planner.
The post-snapshot removal gate used file/HEAD/reflog mtimes, which an
index-only change (git apply --cached) never moves. snapshot_worktree now
digests the semantic staged entries (ls-files --stage) of exactly the index
it captured; archive_then_remove compares them right before removal and keeps
the tree if they differ. A stat-only refresh by git status does not count.
execute_plan dropped archive_then_remove's removed flag, so a tree kept by the
safety gate was reported as "archived + removed"; the exit path and subagent
note said "removed" too. The flag now propagates: `hermes worktree prune`
prints "archived, kept (changed during the sweep)", the exit Archive prints
archived_kept, and the subagent note says the tree was kept (all locales).
Reported by @unsupportedpastels in review of #140603.
Review of the snapshot-then-delete sweep found paths where it could remove
work it should keep or store bytes that differ from the working files:
- `hermes worktree prune --older-than N` ran the sweep before applying N; the
plan now marks any tree idle < N days skip-young before anything runs.
- execute_plan checked liveness once per batch, then archived trees one by one.
Each tree is now re-gated (lock, /proc cwd/fds, fresh activity) right before
it is touched, and archive_then_remove re-checks after the snapshot, keeping
the tree when a process entered or a file changed during capture.
- _last_activity ignored deleted paths; it now stats each changed path's
nearest surviving folder, so a fresh deletion counts as activity.
- `--attr-source` does not cover info/attributes or core.attributesFile, so
eol rules still rewrote bytes. Every blob is re-hashed with
`hash-object --no-filters` and restore writes blob bytes directly.
- The classic CLI applied the exit Archive answer in atexit, after the exit
watchdog was armed; it is now applied synchronously in _tui_shutdown, with
a progress line, before _run_cleanup. A failed or interrupted archive keeps
the tree.
- web_git.worktree_add's existingBranch path skipped the repo registry.
- subagent-* trees got the 72h named-tree tier; one prune_tier_hours helper
now gives hermes-* and subagent-* the 24h tier in both planners.
- worktree_sweep.py had a text=True git call without encoding="utf-8" (CI red).
The worktree pruner never removed a dirty or unpushed tree at any age, so leftovers of finished
lanes (stray probe files, WIP commits on closed PRs) accumulated forever: 658 trees / ~1 TB on
one machine, 553 of them named lane trees. Approved design (Teknium, 2026-10-17):
- Snapshot-then-delete: an idle tree past its tier (hermes-*/subagent-* 24h, named 72h) with
uncommitted changes or unpushed commits is archived to refs/hermes/archive/<tree> (HEAD +
staged + unstaged + non-ignored untracked bytes, built with a temporary index; large untracked
files skipped and listed) and then removed. A failed archive keeps the tree. Branch kept when
published on a remote, otherwise the archive anchors its commits.
- Never touched at any age or budget: live hermes lock owners, any live process whose cwd/open fd
is inside the tree (/proc on Linux, psutil cwd elsewhere), trees with recent file/ref activity,
Kanban t_<hex> trees.
- No count cap; optional worktrees.max_total_gb evicts the oldest idle trees first.
- Per-profile registry (<HERMES_HOME>/worktree_repos.json) of every repo Hermes made a worktree
in (hermes -w, /worktree new, subagent isolation, Desktop git pane); the 6h cron sweep unions
it with its existing sources, scoped and throttled per profile.
- Subagent worktrees with commits/edits are archived and removed at child finish (branch kept).
- Interactive `hermes -w` (CLI and --tui) exit with a dirty/unpushed tree asks Keep (default) /
Archive & remove / Remove and discard; non-interactive exits never ask.
- `hermes worktree restore <name> [--path]`, archives in `hermes worktree list`, a full
decision table in `hermes worktree prune --dry-run`, archive retention (30 days).
- worktrees.archive_dirty: false restores the classic never-delete-dirty pruner exactly.