feat(worktrees): worktree-doctor + worktree-audit — classify breakage, gate deletion - #128
Draft
dlovell wants to merge 1 commit into
Draft
feat(worktrees): worktree-doctor + worktree-audit — classify breakage, gate deletion#128dlovell wants to merge 1 commit into
dlovell wants to merge 1 commit into
Conversation
…, 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
This was referenced Aug 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:.gitfile names an admin dir under the main checkoutgitdirfile names the working tree's.gitbackdocker-compose.yml:23-27mounts the repo at its host path specifically soworktree gitdir files resolve inside the container. That covers host→container.
It does not cover the reverse:
Dockerfile:95-98symlinks/home/<hostuser>→/home/vscode, andgit worktree addcanonicalizes through symlinks, so aworktree created in the container records
/home/vscode/...(or/workspaces/src/...). The host cannot resolve either, and a host-sidegit worktree prunethen deletes the admin dir out from under a working treethat 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 locationfrom the host; a host-made sibling draws the same from the container.
dev/worktree-doctorhealthymismatched--repairorphanedstale-admingit worktree pruneTwo decisions worth reviewing:
It does not trust git's
prunableflag.dev/hooks/post-checkoutlocksevery worktree on creation, and git suppresses
prunablefor 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.
--repairrefuses inside a container: repair records the canonical path ofwherever it runs, and host paths are the only form valid in both namespaces
(the container reaches them through its
/home/<hostuser>symlink). Note thatcomparing
readlink -fagainst the invocation path cannot detect this — gitalways reports the physical path — so the probe is
/.dockerenv+/proc/1/cgroup, overridable for the suite.dev/worktree-auditOnce 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-desktopsibling orphan it refuses, correctly, namingsandbox.rs,agent.rs,bootstrap.rs, an ADR and a CI workflow as contentpresent nowhere in the object database.
Testing
53 new assertions;
tests/run-allis 928 passing, 0 failing.Mutations recorded in the suite headers per ADR-0005:
what consulting
prunableamounts to) reddens 7 assertions, including thelocked-worktree case that flag cannot see
ls-files --error-unmatchreddensexactly 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 runpasses 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-checkoutfires on everygit worktree addregardless of caller—
dev/new-worktree, bare git, or Claude Code's own worktree isolation, whichis what created 14 of the 22 orphans and can never be routed through
dev/tooling. Two gaps to close in a follow-up:
cross-namespace paths unresolvable. It could normalize the recorded paths to
host form (the container knows both halves of the translation).
$root/dev/hooks/, so it only exists in repos carryingtheir own copy.
xorq-desktophas nodev/hooks/at all — which is why itaccumulated 22 orphans against this repo's 8.
devcontainer worktree-doctoras a subcommand. Implemented as standalonedev/scripts instead, matching the existing worktree family(
new-worktree/setup-worktree/cleanup-worktree) and keeping the guardedlib/command-table.tsvsurface for container subcommands. One table row plus adispatch arm each if you want completions — say so and I'll add it.