Worktrees CLI (bernstein worktrees)
June 25, 2026 ยท View on GitHub
Audience: operators with a .sdd/runtime/worktrees/ (or legacy
.sdd/worktrees/) tree that has accumulated orphans after kills,
crashes, or aborted experiments.
Overview
bernstein worktrees inspects and reaps worktrees the orchestrator
left behind. The classifier in
src/bernstein/core/worktrees/classifier.py is the single source of
truth for state. The CLI module
(src/bernstein/cli/commands/worktrees_cmd.py) handles I/O:
rendering the table, holding the GC lock, prompting the operator,
appending a tamper-evident worktree.reap event to the HMAC audit
chain (see Audit trail), and emitting the worktree.gc
lifecycle event for plugins.
The tool honours both the spec layout (.sdd/runtime/worktrees/) and
the legacy layout (.sdd/worktrees/) the WorktreeManager currently
produces.
State machine
| State | Rule |
|---|---|
active | Task record exists at .sdd/runtime/pids/<sid>.json AND os.kill(pid, 0) succeeds. |
orphan | Directory exists but no task record. |
stale | Task record exists but PID is dead AND last trace mtime > 24h ago. |
corrupt | Directory exists but the .git anchor is missing. |
Priority on conflicts: corrupt > orphan > stale > active. A dead PID
with a fresh trace stays active to avoid racing a restart.
Unsaved-work safety
Reaching a terminal state (orphan/stale/corrupt) is necessary but
not sufficient for a reap. Before a worktree is considered reapable the
classifier probes its git state inside the worktree itself:
git status --porcelain- any uncommitted change (tracked or untracked) marks the worktree as holding unsaved work.git merge-base --is-ancestor HEAD <integration-branch>(defaultmain, with an upstream-ahead fallback) - if the worktree branch has commits that are not reachable from the integration branch they are unmerged work that a reap would destroy.
A worktree that holds unsaved work is not reapable and gc skips it
with an operator-visible message naming the directory. This matches the
invariant the rest of the codebase already enforces (the maintenance
command preserves unmerged branches by default). A missing PID record -
the trigger for orphan - is exactly the crash-recovery case where
committed-but-unmerged work is most likely stranded, so "no PID record"
is never treated as "safe to delete".
A corrupt worktree has no readable .git and cannot be probed: it is
reapable only when its directory is empty of files, otherwise it is
preserved for manual handling. Any git probe that cannot decide
(timeout, missing ref) errs toward preserving the worktree - the guard
only ever blocks deletion, it never deletes more.
CLI
bernstein worktrees list [--workdir DIR] [--json]
bernstein worktrees gc [--workdir DIR] [--yes] [--dry] [--force-unsaved]
bernstein worktrees unlock [--workdir DIR] [--force] [--json]
list- tabular dump with path, task id, state, age, size, PID. Use--jsonfor scripting. The JSONreapablefield already reflects the unsaved-work veto.gc- reap non-activeworktrees that carry no unsaved work.--yesskips the confirmation prompt;--dryprints what would be reaped without touching disk.--force-unsaved- also reap worktrees that hold unsaved work (uncommitted changes or unmerged commits). Dangerous: this destroys the only copy of that work. It requires a second explicit confirmation (unless--yesis also passed) and mirrors themaintenancecommand's--force. The reap is recorded withforced=truein the audit trail.unlock- inspect and clear a stuck GC lock. It reports who holds the lock (pid, liveness, age) and removes it when the owner process is gone; pass--forceto clear a lock whose owner still looks alive. The unlock is recorded as aworktree.gc_unlockevent in the audit trail, so a manual recovery is never silent.
GC lock
A single-file lock at .sdd/runtime/worktree-gc.lock is held via
O_EXCL for the duration of gc. The lock file records the owner pid and
start time, and is released on exception.
A lock left behind by a crashed or killed gc is reclaimed automatically on
the next run once its owner process is gone (or the lock is older than a
generous bound), so a crash no longer wedges future runs. Exit code 2
still indicates a genuine collision with a live, recent gc. To inspect or
clear a lock by hand, use bernstein worktrees unlock.
Lifecycle event
The CLI emits worktree.gc after each reap (or, with --dry,
once per would-be reap), and once per worktree preserved for safety.
Plugins can hook this event; the env keys exposed to handlers are
BERNSTEIN_WORKTREE_GC_*, including BERNSTEIN_WORKTREE_GC_REAPED
(0 for a safety-skip, 1 for a reap) and
BERNSTEIN_WORKTREE_GC_UNSAVED (1 when the worktree held unsaved
work). This notification is best-effort and ephemeral - it requires a
plugin HookRegistry and leaves no durable record. For the durable,
tamper-evident record see the audit trail below.
Audit trail
Reaping a worktree is the orchestrator's only routine destructive
action, so each reap is anchored to the HMAC-chained audit log under
.sdd/audit/ alongside the best-effort lifecycle event. The audit
write does not depend on a plugin HookRegistry: it is written
even when the CLI runs standalone.
For every reaped worktree - and for every worktree skipped for safety -
gc appends one event:
| Field | Value |
|---|---|
event_type | worktree.reap |
actor | worktrees-gc |
resource_type | worktree |
resource_id | session id (worktree directory basename) |
The details payload captures, before deletion: state, task_id,
path, size_bytes, age_seconds, last_trace_mtime, the
pre-deletion git head_sha, a dirty flag (uncommitted/unmerged
changes present), has_unsaved_work (the classifier's reap veto),
reaped (false for a safety-skip, true for an actual deletion),
forced (true when --force-unsaved overrode the veto), and
dry_run.
Safety-skips are recorded, not silent. When gc preserves a
worktree because it holds unsaved work, it still appends a
worktree.reap event flagged reaped=false with the unsaved-work
reason, so the decision to preserve is anchored in the same
tamper-evident chain as a real reap.
Pre-deletion fingerprint. head_sha and dirty are read from the
worktree before rmtree, so the entry proves exactly which commit
and working-tree state were destroyed - enough to decide whether to
restore from reflog after an accidental GC. A corrupt worktree may
have no readable .git; its fingerprint degrades to head_sha=null
and dirty=null rather than crashing GC or attributing the enclosing
repository's HEAD.
Fail-closed contract. The audit event is appended before the
directory is removed. If the append fails (e.g. audit key permission
error, full disk) the reap is aborted and the error propagates - a
worktree is never destroyed without a record. Operators whose audit
key is misconfigured will see gc fail rather than silently delete;
fix the key (see docs/security/audit-log.md) and retry.
--dry mode records the event flagged dry_run=true and performs
no rmtree, so a dry run is itself auditable.
Verify and inspect the reap events:
bernstein audit verify-hmac # exits non-zero on any tamper
bernstein audit query --event-type worktree.reap # list reap events
TUI integration
The TUI's WorktreeListPanel refreshes the same classifier output
every 10 seconds and uses count_reapable() to drive the status-bar
badge.
Examples
Inspect what's on disk:
bernstein worktrees list
Preview reap plan without touching disk:
bernstein worktrees gc --dry
Reap non-interactively (CI / cron):
bernstein worktrees gc --yes
JSON dump for piping into jq:
bernstein worktrees list --json | jq '.[] | select(.state == "orphan")'
Troubleshooting
Exit code 2 from gc. A peer gc holds the lock. Inspect
.sdd/runtime/worktree-gc.lock; if its PID is dead, remove the file
and retry. Otherwise wait for the peer to finish.
active row but the task already died. Either the task record at
.sdd/runtime/pids/<sid>.json is stale (PID matches a recycled
process) or the last trace is fresh enough that the classifier holds
active. Wait 24h for the rule to flip to stale, or remove the
pids/<sid>.json file by hand.
corrupt rows after a bad merge. The classifier marks any worktree
without a .git anchor as corrupt regardless of task record. An empty
corrupt directory is reaped automatically. A corrupt directory that
still holds files cannot be probed for unsaved work, so gc preserves
it and reports it; inspect the contents, salvage anything you need, then
delete the directory by hand (or rerun gc --force-unsaved).
gc skipped a worktree with unsaved work. This is the safety guard:
the worktree has uncommitted changes or commits not merged into the
integration branch. Open the directory and commit/push or salvage the
work. Once it is clean and merged, gc reaps it normally. To delete it
regardless - destroying the unsaved work - rerun with --force-unsaved
(an extra confirmation is required).