fix(hooks): install this repo's hooks into projects that carry none - #132
Draft
dlovell wants to merge 1 commit into
Draft
fix(hooks): install this repo's hooks into projects that carry none#132dlovell wants to merge 1 commit into
dlovell wants to merge 1 commit into
Conversation
dev/devcontainer calls install_hooks for every project on start, but symlink_hooks globs the CONSUMING repo's own dev/hooks/* and is a silent no-op when that directory does not exist. Most projects have no dev/hooks/ — so most projects got neither the worktree auto-lock nor the host-form path rewrite, while this repo, which does carry them, got both. That is the difference between a repo accumulating a handful of broken worktrees and one accumulating dozens. install_hooks now takes the devcontainer repo root and fills the gaps from it, per hook name: a project shipping its own copy of a hook keeps it and still receives the ones it lacks. What gets distributed is an ALLOWLIST (_DEVCONTAINER_SHARED_HOOKS), currently just post-checkout — deliberately not the contents of dev/hooks/. A hook is only safe to push into a project that has never heard of it if it needs no configuration there. post-checkout qualifies: it does nothing outside a worktree creation. pre-commit does not, and is excluded: it ends in `exec pre-commit hook-impl --config=.pre-commit-config.yaml`, so distributing it would break every commit in a project that has not adopted pre-commit (verified: TypeError from pre-commit, commit refused), and with the tool absent it would exit 1 instead. The allowlist governs only what this repo pushes outward — a project's own dev/hooks/ is still symlinked in full. Gap-filled hooks are COPIED, not symlinked. A symlink would point into the devcontainer repo, which is not mounted inside a consuming project's container — only that project's tree and its .git are — so it would dangle precisely where the hook matters most. A copy under .git/hooks/ rides along with the .git bind mount and works in both namespaces. Installation is host-side for the same reason: only lib/*.sh is baked into the image, never dev/hooks/, and a host-side install reaches the container anyway through that shared .git. The in-container caller (projects/devcontainer/setup-env.sh) therefore keeps calling install_hooks with no argument, which preserves its current behavior exactly. Copies carry a provenance marker after the shebang, and that marker is the whole authority for overwriting: an unmarked regular file in .git/hooks/ is someone's own hook and is never touched, while a marked one is refreshed when the source moves. Rewrites happen only on a content change, so mtimes stay stable across the every-cold-start install. Guards typed per ADR-0005, both mutations recorded in the suite header: globbing dev/hooks/* instead of the allowlist reddens "a non-allowlisted hook is never distributed"; dropping the not-ours guard reddens "a hand-written hook is left alone". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0125jbHz5MeaLjHvjRKKm4BX
dlovell
force-pushed
the
feat/worktree-hook-distribution
branch
from
August 5, 2026 19:48
4aeae38 to
c48e673
Compare
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 #131 (base
feat/worktree-hook-host-paths), which is stacked on#128. #131 makes the hook do the right thing; this one makes it reach the
projects that need it.
The gap
dev/devcontainer:945callsinstall_hooksfor every project on start. Butsymlink_hooksglobs the consuming repo'sdev/hooks/*:Most projects have no
dev/hooks/.xorq-desktophas none — nodev/hooks/directory, no
post-checkoutin.git/hooks/— so it received neither theworktree auto-lock nor #131's path translation. This repo, which does carry them,
received both. That is plausibly the whole difference between the two orphan
counts that started this: 22 in xorq-desktop against 8 here.
The change
install_hooks "$DEV_BASE_DIR"now fills the gaps from this repo, per hookname — a project shipping its own copy of a hook keeps it and still receives
the ones it lacks.
Distribution is an allowlist
_DEVCONTAINER_SHARED_HOOKS, currently justpost-checkout— deliberately notthe contents of
dev/hooks/. A hook is only safe to push into a project that hasnever heard of it if it needs no configuration there:
post-checkoutqualifies. It does nothing outside a worktree creation.pre-commitdoes not, and is excluded. It ends inexec pre-commit hook-impl --config=.pre-commit-config.yaml, so in a projectwith no such config it breaks every commit — verified by installing it into a
fresh repo with no config:
With pre-commit absent entirely it takes its own
exit 1path instead. Eitherway it would break committing in any project that hasn't adopted the tool.
Adding a name to the allowlist means asserting the hook is inert in a project
that doesn't use the tool behind it. The allowlist governs only what this repo
pushes outward: a project's own
dev/hooks/is still symlinked in full bysymlink_hooks, whatever it contains.Copied, not symlinked
A symlink would point into the devcontainer repo, which is not mounted inside a
consuming project's container — only that project's tree and its
.gitare — soit would dangle exactly where the hook matters most. A copy under
.git/hooks/rides along with the
.gitbind mount and works in both namespaces.Host-side, for the same reason. Only
lib/*.shis baked into the image(
projects/devcontainer/setup-env.shsources/usr/local/lib/devcontainer/git.sh),never
dev/hooks/— there is nothing in there to copy from. A host-side installreaches the container anyway through the shared
.git. So the in-container callerkeeps calling
install_hookswith no argument, preserving its behavior exactly;only the two host callers pass a base dir.
A provenance marker is the authority for overwriting. Copies carry it after
the shebang. An unmarked regular file in
.git/hooks/is someone's own hook andis never touched; a marked one is refreshed when the source moves. Rewrites happen
only on a content change, so mtimes stay stable across the every-cold-start
install.
Verification
28 new assertions;
tests/run-allis 983 passing, 0 failing.The suite uses a fixture devcontainer repo rather than the real
dev/hooks/,so it doesn't fail whenever a hook's contents change — the fixture ships one
allowlisted hook and one non-allowlisted one, making both precedence and exclusion
observable. One case installs the real
post-checkoutinto a fixture projectand asserts it actually fires (a new worktree comes out locked).
Cases: the allowlist reads as claimed · project with no hooks of its own ·
non-allowlisted hook never distributed · allowlisted name absent from the base
repo is harmless · own hook wins · own non-allowlisted hook still linked ·
hand-written hook left alone · stale copy refreshed and unchanged copy left alone ·
no base dir preserves old behavior · base repo symlinks rather than copying onto
itself · linked worktree resolves to the shared hooks dir.
Two mutations recorded per ADR-0005:
"$base/dev/hooks/"*instead of_DEVCONTAINER_SHARED_HOOKSreddens"a non-allowlisted hook is never distributed" — the commit-breaking regression
above.
silently destroying a developer's own hook.
The
pre-commitreproduction is deliberately not a test case: it needspre-commit on PATH, which the suite must not depend on. The guard is the allowlist
assertion; the failure it prevents is recorded in the suite header with the exact
error.
Expected effect once merged
On the next
devcontainer up, projects without their own hooks getpost-checkoutcopied into.git/hooks/. Forxorq-desktopthat means newworktrees are locked and recorded in host form from then on. I did not
pre-install anything into your repos as a side effect of testing — existing
worktrees there keep their current recorded paths until
worktree-doctor --repair(from #128) is run.