fix(worktrees): record host-form paths at creation, not just a lock - #131
Draft
dlovell wants to merge 1 commit into
Draft
fix(worktrees): record host-form paths at creation, not just a lock#131dlovell wants to merge 1 commit into
dlovell wants to merge 1 commit into
Conversation
dev/hooks/post-checkout locked new worktrees to stop a host-side prune from deleting their admin dirs. That addressed the symptom with the loudest failure but left the worktree unusable from the host, and git suppresses its own `prunable` flag for locked worktrees — so a locked, broken worktree reported clean while being just as broken. The cause is that both halves of a worktree's metadata store absolute paths, recorded as the CANONICAL paths of wherever `git worktree add` ran. docker-compose.yml mounts the repo at its host path precisely so worktree gitdir files resolve in the container, but that only covers host->container: the Dockerfile symlinks /home/<hostuser> -> /home/vscode, so a worktree created in the container canonicalizes to /home/vscode/... or /workspaces/src/... and writes that into the .git the host shares by bind mount. So the hook now also re-records both halves in host form, which is the one form valid in BOTH namespaces — the container reaches host paths through that same symlink. The translation comes from DEV_WORKSPACE / DEV_CONTAINER_WORKSPACE (already exported by dev/devcontainer) plus the /home/<hostuser> -> $HOME symlink, resolved with readlink -f so a relative symlink counts the same. With neither available it declines rather than guessing. It also refuses to write a path it cannot itself resolve. Leaving container-form paths is strictly better than recording one that resolves nowhere: the first is the `mismatched` state dev/worktree-doctor reports and --repair fixes, the second breaks the worktree in both namespaces. The lock is kept and happens first — it addresses a different failure and must survive the translation declining. Being a post-checkout hook, this fires for every caller, including an agent harness creating its own isolated worktrees. Those never route through dev/ scripts and produced most of the orphans that motivated this, so no amount of tooling discipline could have covered them. Verified end-to-end in a real container: a worktree created under $HOME comes out recorded as /home/dan/..., locked, resolving in-container, and not prunable. Guard typed per ADR-0005, mutation recorded in the suite header: dropping the reachability precondition reddens 3 assertions, one of them "the worktree still works" — the concrete harm of writing an unreachable path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0125jbHz5MeaLjHvjRKKm4BX
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.
Stacked on #128 (base
feat/worktree-doctor). #128 diagnoses and cleans up;this stops it recurring. Rebasable onto
mainon its own if you'd rather landthem independently — the only overlap is the README/CLAUDE.md sections #128 adds.
Why locking wasn't enough
dev/hooks/post-checkoutalready locked new worktrees "to prevent container-sideprune". That stops the loudest failure — a host-side
git worktree prunedeletingthe admin dir — but leaves the worktree unusable from the host, and git
suppresses its own
prunableflag for locked worktrees, so a locked-but-brokenworktree reports clean.
Evidence from this repo: of 9 broken worktrees, exactly one (
adr-0004-memory)still had its admin dir and shows as repairable
mismatched; the other 8 losttheirs anyway.
The cause is that both halves of a worktree's metadata store absolute paths,
recorded as the canonical paths of wherever
git worktree addran.docker-compose.yml:23-27mounts the repo at its host path precisely so worktreegitdir files resolve in the container — but that only covers host→container.
Dockerfile:95-98symlinks/home/<hostuser>→/home/vscode, so a worktreecreated in the container canonicalizes to
/home/vscode/...or/workspaces/src/...and writes that into the.gitthe host shares by bindmount.
The change
The hook keeps locking, and now also re-records both halves in host form —
the one form valid in both namespaces, since the container reaches host paths
through that same symlink.
It never guesses. The translation comes from
DEV_WORKSPACE/DEV_CONTAINER_WORKSPACE(already exported bydev/devcontainer— verifiedpresent in a running container) plus the
/home/<hostuser>→$HOMEsymlink,resolved with
readlink -fso a relative symlink counts the same as the absoluteone the Dockerfile writes. With neither available it declines.
It also refuses to write a path it cannot itself resolve. Leaving
container-form paths is strictly better than recording one that resolves nowhere:
the first is the
mismatchedstateworktree-doctorreports and--repairfixes; the second breaks the worktree in both namespaces. The lock happens first
and survives the translation declining, since it addresses a different failure.
Because it's a
post-checkouthook it fires for every caller — including anagent harness creating its own isolated worktrees. Those never route through
dev/scripts and produced 14 of the 22 orphans inxorq-desktopand 6 of the 8here, so no amount of tooling discipline could have covered them. That's the
argument for fixing this at the hook rather than by policy.
Verification
25 new assertions;
tests/run-allis 953 passing, 0 failing.End-to-end in a real container (scratch paths only, nothing shared touched) —
a worktree created under
$HOME, with the hook's real/.dockerenvprobe and thereal
/home/dan -> /home/vscodesymlink:Separately confirmed on the host that
git worktree addtolerates these twofiles being rewritten underneath it in
post-checkout: the command exits 0, therewrite sticks,
rev-parsestill works, andgit worktree listthen reports therewritten path.
Hermetic suite.
tests/is docker-free, so the two namespaces are simulatedwith a symlink rather than a bind mount (no root). That inverts which side is
canonical relative to production — documented in the suite header, along with why
it costs no coverage: the hook only prefix-matches, substitutes, checks the result
resolves, and writes.
Cases: host (lock only, no rewrite) · container workspace-form · container
$HOME-form, absolute and relative symlink · declines an unreachable host form ·declines with no host-side env · main checkout untouched · file checkout ignored.
Mutation recorded per ADR-0005: dropping the reachability precondition reddens 3
assertions, one being "the worktree still works" — the concrete harm.
Still not fixed by this
The hook only runs in repos that carry
dev/hooks/.xorq-desktophas none —no
dev/hooks/directory, nopost-checkoutin.git/hooks/— so it getsneither the lock nor this translation, which is why it accumulated 22 orphans
against this repo's 8.
dev/devcontainer:945does callinstall_hooksfor everyproject, but
symlink_hooksglobs the consuming repo'sdev/hooks/*and is asilent no-op when that directory doesn't exist. That's the follow-up PR.
Worktrees created before this lands keep their old recorded paths;
worktree-doctor --repairfrom #128 is the migration path.