-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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)/hooks3. 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.
[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 # globalHooks 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 equivalentIf .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: trueOne mechanism, one config, committed and shared with the team. No .legacy, no clobbering.
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 setscore.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.
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 --verboseIf a hook seems not to run at all, check core.hooksPath first — it is the answer surprisingly
often, and it fails silently by design.
Start Here
Guides
- Recipe authoring
- Service authoring
- Stacks
- Extending stacks (proposed)
- Recipe catalog
- System prompt & rules (proposed)
- Secrets
- AWS SSO
- Pulumi (host login forwarding)
- Egress & exposing services
- Container filesystem
- Git hooks
- Troubleshooting
- Pin management (harnessed update)
Codebase Map
Planning & Roadmap
- open work: GitHub Issues
Research & Prompts
- research/ (home-folder requirements per harness, browse in-repo)
- prompts/ (reusable prompt templates, browse in-repo)