Git hooks

Git hooks are where a lot of tooling quietly collides. Understanding how git actually resolves a hook explains why two well-behaved tools (beads and pre-commit) can refuse to share a repo, and why harnessed ships hook logic but never hook wiring.

How git actually finds a hook

Three rules, and every trap below follows from them.

1. core.hooksPath wins over .git/hooks. If it is set — locally or globally — git looks there and your repo’s own .git/hooks are ignored entirely. Not merged. Ignored. A local value overrides a global one.

2. Worktrees share one hooks dir. Hooks live in the common git dir, not the worktree’s .git (which is a file, not a directory). Install a hook from one worktree and every worktree of that repo gets it:

git rev-parse --git-common-dir     # <- hooks live in $(that)/hooks

3. A hook is just an executable. No chaining, no ordering, no plugin system. One file per event. Two tools that both want pre-commit must negotiate, and mostly they don’t.

The collisions

pre-commit refuses to install when core.hooksPath is set

[ERROR] Cowardly refusing to install hooks with `core.hooksPath` set.
hint: `git config --unset-all core.hooksPath`

This is correct behavior, not a bug: pre-commit installs into .git/hooks, and a core.hooksPath would silently make that a no-op. Rather than install hooks that never run, it refuses.

bd init (beads) sets a local core.hooksPath=.beads/hooks — so any repo where beads was initialized on the host will hit this. In harnessed, beads is container-only: bd lives in the pod (via the beads recipe), not on your host, and the beads recipes deliberately carry no init: so nothing initializes beads against your working copy. If a host core.hooksPath shows up anyway, something installed beads outside the container — unset it and find out what:

git config --unset core.hooksPath            # repo-local
git config --global --unset-all core.hooksPath   # global

A hook that references a tracked file breaks older branches

Hooks are shared across worktrees (rule 2), but whatever the hook points at — a script, a config — is usually a tracked file that only exists on branches carrying it. Check out an older branch, or a worktree on main from before it landed, and every commit there dies: No .pre-commit-config.yaml file was found, or a bare exec .githooks/… failing with “not found”.

Both have the same cure — make the hook a no-op when its target is absent:

[ -x .githooks/some-hook ] || exit 0              # plain shim
pre-commit install --allow-missing-config       # pre-commit's equivalent

pre-commit preserves an existing hook — and other installers fight it

If .git/hooks/pre-commit already exists, pre-commit install renames it to pre-commit.legacy and calls it. That is how it coexists with a hook another tool installed.

It is not symmetric. If you later re-run the other tool’s installer, it writes into the file pre-commit now owns — and you end up running that tool twice (once from inside pre-commit’s script, once via .legacy). If a tool exposes a “run my hook logic” entrypoint, prefer wiring it as a pre-commit hook instead of letting two installers own one file:

  - repo: local
    hooks:
      - id: some-tool
        entry: some-tool hooks run pre-commit
        language: system
        stages: [pre-commit]
        pass_filenames: false
        always_run: true

One mechanism, one config, committed and shared with the team. No .legacy, no clobbering.

The policy: harnessed ships hook LOGIC, never hook WIRING

This falls straight out of rule 3. The wiring is a contested resource — one file per event, no chaining — so any tool that claims .git/hooks/pre-commit is at war with every other tool that wants it. The logic, on the other hand, is contested by nobody: a script that reads staged content and exits non-zero composes with any wiring at all.

So:

A recipe that needs a host git hook ships a script. It never writes .git/hooks, never sets core.hooksPath, and never requires a hook framework. How the script gets wired is the user’s choice.

Agent-side gating uses harness hooks (PreToolUse), which harnessed owns end to end and which need no user setup.

Requiring a framework would buy no enforcement anyway: --no-verify bypasses any git hook, plain or framework-managed. A harness PreToolUse hook is not a git hook, so --no-verify does not reach it — that is where real enforcement lives.

Debugging

git config --get-all core.hooksPath          # set? then .git/hooks is being ignored
git rev-parse --git-common-dir               # where the hooks actually live (worktrees!)
ls "$(git rev-parse --git-common-dir)/hooks" # what is really installed
pre-commit run --all-files                   # run every hook without committing
pre-commit run <hook-id> --hook-stage pre-commit --verbose

If a hook seems not to run at all, check core.hooksPath first — it is the answer surprisingly often, and it fails silently by design.