Skip to content

feat(worktrees): worktree-doctor + worktree-audit — classify breakage, gate deletion - #128

Draft
dlovell wants to merge 1 commit into
mainfrom
feat/worktree-doctor
Draft

feat(worktrees): worktree-doctor + worktree-audit — classify breakage, gate deletion#128
dlovell wants to merge 1 commit into
mainfrom
feat/worktree-doctor

Conversation

@dlovell

@dlovell dlovell commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Two host-side tools for the cross-namespace worktree failure this repo already
half-defends against. Found while cleaning 22 orphaned worktrees out of
xorq-desktop (~11.6G) — the same breakage exists here and in four other repos.

The failure

A linked worktree is two halves that must agree, and both store absolute
paths
, recorded from the perspective of whoever ran git worktree add:

  • the working tree's .git file names an admin dir under the main checkout
  • that admin dir's gitdir file names the working tree's .git back

docker-compose.yml:23-27 mounts the repo at its host path specifically so
worktree gitdir files resolve inside the container. That covers host→container.
It does not cover the reverse: Dockerfile:95-98 symlinks /home/<hostuser>
/home/vscode, and git worktree add canonicalizes through symlinks, so a
worktree created in the container records /home/vscode/... (or
/workspaces/src/...). The host cannot resolve either, and a host-side
git worktree prune then deletes the admin dir out from under a working tree
that was perfectly fine. HEAD, index and refs go with it — the branch becomes
unrecoverable.

Verified in both directions with throwaway probes: a container-made worktree
draws Removing worktrees/probe: gitdir file points to non-existent location
from the host; a host-made sibling draws the same from the container.

dev/worktree-doctor

state meaning fix
healthy both halves agree and resolve from here
mismatched both halves exist, paths are cross-namespace --repair
orphaned working tree exists, admin dir gone audit, then delete
stale-admin admin dir exists, no working tree git worktree prune

Two decisions worth reviewing:

It does not trust git's prunable flag. dev/hooks/post-checkout locks
every worktree on creation, and git suppresses prunable for locked worktrees —
so the flag reports the common broken case as clean. An earlier draft of this
tool did trust it and called two thoroughly broken worktrees healthy. The doctor
tests reachability itself.

Attribution is on proof, not location. Sibling directories are shared ground
where every co-checked-out repo keeps its worktrees. A first pass attributed 17
other projects' worktrees to this repo as "orphans" — which would have sent
someone to delete another project's work. A candidate now counts only if it sits
inside the main tree, its recorded admin path names this repo's git dir, or this
repo has an admin dir by that name.

--repair refuses inside a container: repair records the canonical path of
wherever it runs, and host paths are the only form valid in both namespaces
(the container reaches them through its /home/<hostuser> symlink). Note that
comparing readlink -f against the invocation path cannot detect this — git
always reports the physical path — so the probe is /.dockerenv +
/proc/1/cgroup, overridable for the suite.

dev/worktree-audit

Once the admin dir is gone, git can no longer say what branch a worktree was on
or whether its work was committed. It can still answer the only question that
matters before deleting: is every byte here already in the object database?

Content is matched by hash, not path. That is the whole point — a path-based
check has no answer at all for a worktree with no HEAD, and would refuse to
delete an orphan whose only sin is that the work moved upstream. Build output and
package stores are skipped (regenerable, and where nearly all the disk sits);
symlinks are not audited, since git stores a symlink as a blob of its target
path. Anything unaccounted for, or any process with a cwd inside the tree,
blocks --delete.

It earns its keep immediately: run against this repo's 8 orphans it clears them
all, and against a xorq-desktop sibling orphan it refuses, correctly, naming
sandbox.rs, agent.rs, bootstrap.rs, an ADR and a CI workflow as content
present nowhere in the object database.

Testing

53 new assertions; tests/run-all is 928 passing, 0 failing.

Mutations recorded in the suite headers per ADR-0005:

  • neutering the doctor's reachability test (trusting the recorded path, which is
    what consulting prunable amounts to) reddens 7 assertions, including the
    locked-worktree case that flag cannot see
  • swapping the audit's content check for ls-files --error-unmatch reddens
    exactly one assertion — "committed bytes at a novel path still pass" —
    which is precisely the invariant

Both scripts added to the extensionless-shellcheck list in
.pre-commit-config.yaml; pre-commit run passes on all touched files.

Deliberately not here

Prevention. These tools diagnose and clean up; they do not stop it
recurring. The interception point already exists and is caller-agnostic:
dev/hooks/post-checkout fires on every git worktree add regardless of caller
dev/new-worktree, bare git, or Claude Code's own worktree isolation, which
is what created 14 of the 22 orphans and can never be routed through dev/
tooling. Two gaps to close in a follow-up:

  1. the hook only locks. Locking stops prune-deletion but leaves
    cross-namespace paths unresolvable. It could normalize the recorded paths to
    host form (the container knows both halves of the translation).
  2. the hook lives at $root/dev/hooks/, so it only exists in repos carrying
    their own copy. xorq-desktop has no dev/hooks/ at all — which is why it
    accumulated 22 orphans against this repo's 8.

devcontainer worktree-doctor as a subcommand. Implemented as standalone
dev/ scripts instead, matching the existing worktree family
(new-worktree/setup-worktree/cleanup-worktree) and keeping the guarded
lib/command-table.tsv surface for container subcommands. One table row plus a
dispatch arm each if you want completions — say so and I'll add it.

…, gate deletion

Linked worktrees store absolute paths in both halves of their metadata, recorded
from the perspective of whoever ran `git worktree add`. A worktree created inside
the container writes a container path (/workspaces/src/..., or /home/vscode/...
via the /home/<hostuser> symlink from Dockerfile:97) into the .git the host
shares by bind mount. docker-compose.yml mounts the repo at its host path
specifically so worktree gitdir files resolve in the container — but that only
covers host->container. In the other direction the host cannot resolve the
recorded path, and a host-side `git worktree prune` deletes the admin dir out
from under a working tree that was fine.

dev/worktree-doctor classifies every worktree of the current repo as healthy,
mismatched (cross-namespace paths, repairable), orphaned (admin dir gone,
metadata unrecoverable) or stale-admin (no working tree). It decides
reachability itself rather than consulting git's `prunable` flag, because
dev/hooks/post-checkout locks worktrees on creation and git suppresses
`prunable` for locked ones — so the flag reports the common broken case as
clean. --repair re-records the mismatched paths and refuses to run inside a
container, where it would re-record the container path form that caused the
problem; host paths are the only form valid in both namespaces.

dev/worktree-audit answers the only question still available once the admin dir
is gone: is every byte here already in the object database? Content is matched
by hash, not path, so work committed and later moved or deleted upstream still
counts as present — a path-based check has no answer at all for a worktree with
no HEAD. Build output and package stores are skipped; symlinks are not audited
(git stores them as blobs of their target path); anything unaccounted for, or
any process with a cwd inside the tree, blocks --delete.

Attribution is on proof, not location: sibling directories are shared ground
where every co-checked-out repo keeps its worktrees, so claiming a foreign
broken worktree would send someone to delete another project's work.

Guards typed per ADR-0005, with both mutations recorded in the suite headers:
neutering the doctor's reachability test reddens 7 assertions; swapping the
audit's content check for a path check reddens exactly the one assertion that
encodes content-vs-path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125jbHz5MeaLjHvjRKKm4BX
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant