Skip to content

fix(hooks): install this repo's hooks into projects that carry none - #132

Draft
dlovell wants to merge 1 commit into
feat/worktree-hook-host-pathsfrom
feat/worktree-hook-distribution
Draft

fix(hooks): install this repo's hooks into projects that carry none#132
dlovell wants to merge 1 commit into
feat/worktree-hook-host-pathsfrom
feat/worktree-hook-distribution

Conversation

@dlovell

@dlovell dlovell commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

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:945 calls install_hooks for every project on start. But
symlink_hooks globs the consuming repo's dev/hooks/*:

for hook in "$root/dev/hooks/"*; do
    [ -f "$hook" ] || continue      # <- silent no-op when the dir doesn't exist

Most projects have no dev/hooks/. xorq-desktop has none — no dev/hooks/
directory, no post-checkout in .git/hooks/ — so it received neither the
worktree 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 hook
name — 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 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 in a project
    with no such config it breaks every commit — verified by installing it into a
    fresh repo with no config:

    An unexpected error has occurred: TypeError: expected str, bytes or
    os.PathLike object, not NoneType
    $ git log --oneline
    fatal: your current branch 'main' does not have any commits yet
    

    With pre-commit absent entirely it takes its own exit 1 path instead. Either
    way 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 by
symlink_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 .git are — so
it would dangle exactly where the hook matters most. A copy under .git/hooks/
rides along with the .git bind mount and works in both namespaces.

Host-side, for the same reason. Only lib/*.sh is baked into the image
(projects/devcontainer/setup-env.sh sources /usr/local/lib/devcontainer/git.sh),
never dev/hooks/ — there is nothing in there to copy from. A host-side install
reaches the container anyway through the shared .git. So the in-container caller
keeps calling install_hooks with 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 and
is 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-all is 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-checkout into a fixture project
and 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:

  1. globbing "$base/dev/hooks/"* instead of _DEVCONTAINER_SHARED_HOOKS reddens
    "a non-allowlisted hook is never distributed" — the commit-breaking regression
    above.
  2. dropping the not-ours guard reddens "a hand-written hook is left alone" —
    silently destroying a developer's own hook.

The pre-commit reproduction is deliberately not a test case: it needs
pre-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 get
post-checkout copied into .git/hooks/. For xorq-desktop that means new
worktrees 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.

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
dlovell force-pushed the feat/worktree-hook-distribution branch from 4aeae38 to c48e673 Compare August 5, 2026 19:48
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