Skip to content

fix(worktrees): record host-form paths at creation, not just a lock - #131

Draft
dlovell wants to merge 1 commit into
feat/worktree-doctorfrom
feat/worktree-hook-host-paths
Draft

fix(worktrees): record host-form paths at creation, not just a lock#131
dlovell wants to merge 1 commit into
feat/worktree-doctorfrom
feat/worktree-hook-host-paths

Conversation

@dlovell

@dlovell dlovell commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Stacked on #128 (base feat/worktree-doctor). #128 diagnoses and cleans up;
this stops it recurring. Rebasable onto main on its own if you'd rather land
them independently — the only overlap is the README/CLAUDE.md sections #128 adds.

Why locking wasn't enough

dev/hooks/post-checkout already locked new worktrees "to prevent container-side
prune". That stops the loudest failure — a host-side git worktree prune deleting
the admin dir — but leaves the worktree unusable from the host, and git
suppresses its own prunable flag for locked worktrees, so a locked-but-broken
worktree 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 lost
theirs anyway.

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:23-27 mounts the repo at its host path precisely so worktree
gitdir files resolve in the container — but that only covers host→container.
Dockerfile:95-98 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.

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 by dev/devcontainer — verified
present in a running container) plus the /home/<hostuser>$HOME symlink,
resolved with readlink -f so a relative symlink counts the same as the absolute
one 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 mismatched state worktree-doctor reports and --repair
fixes; 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-checkout hook it fires for every caller — including an
agent harness creating its own isolated worktrees. Those never route through
dev/ scripts and produced 14 of the 22 orphans in xorq-desktop and 6 of the 8
here, 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-all is 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 /.dockerenv probe and the
real /home/dan -> /home/vscode symlink:

recorded gitdir:  /home/dan/e2e-hookrepo/wt1/.git
recorded .git:    gitdir: /home/dan/e2e-hookrepo/.git/worktrees/wt1
locked:           yes
resolves here:    yes
prunable:         0

Separately confirmed on the host that git worktree add tolerates these two
files being rewritten underneath it in post-checkout: the command exits 0, the
rewrite sticks, rev-parse still works, and git worktree list then reports the
rewritten path.

Hermetic suite. tests/ is docker-free, so the two namespaces are simulated
with 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-desktop has none
no dev/hooks/ directory, no post-checkout in .git/hooks/ — so it gets
neither the lock nor this translation, which is why it accumulated 22 orphans
against this repo's 8. dev/devcontainer:945 does call install_hooks for every
project, but symlink_hooks globs the consuming repo's dev/hooks/* and is a
silent 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 --repair from #128 is the migration path.

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
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