Git Hooks¶
The hook layer is the pre-commit framework, configured by .pre-commit-config.yaml at the
repo root.
Naming note: despite the name, the framework manages every git hook stage —
pre-commit,commit-msg,pre-push, and others — not justpre-commit. The name reflects its original scope; the tool grew well beyond it.
The framework manages three artifacts the user shouldn't need to touch by hand:
- Master scripts at
.git/hooks/<stage>— git's actual extension points, written bypre-commit install. Each is a short dispatcher (# File generated by pre-commit) that hands control to the framework with the current stage name. - Hook environments at
~/.cache/pre-commit/— isolated per-repo sandboxes (Python venvs, Go builds, Nodenode_modules, etc.), one directory per upstreamrepo:block in.pre-commit-config.yaml. Populated bypre-commit install --install-hooksor lazily on first invocation. - Per-stage dispatch — when git fires a stage, the master script asks the framework which hooks declare that stage and runs them.
Lifecycle¶
The chezmoi setup script (run_after_20-setup-dotfiles-repo.sh.tmpl) runs pre-commit install on every apply. That
only writes the three master dispatcher scripts: .git/hooks/pre-commit, .git/hooks/commit-msg,
.git/hooks/pre-push. Hook environments are not fetched at apply time — this is the lazy default.
The first time a stage fires (e.g. your first git commit), the framework checks each scheduled hook against its SQLite
ledger and, for any whose env isn't installed yet, clones the upstream into ~/.cache/pre-commit/repo<hash>/ and
materialises the runtime inline before the hook runs. That means the user's first commit stalls for
[INFO] Installing environment for ... This may take a few minutes... once per upstream repo: block. Subsequent
commits are fast — envs are reused.
Deferring env install keeps chezmoi apply offline-tolerant and lets minimal / read-only environments apply dotfiles
without paying for hooks they won't use. The trade is that env-install failures (network, toolchain, etc.) surface at
first commit rather than at apply time.
To opt into the eager path on a machine where you know you'll commit:
just maint qa hooks install— pre-populates every hook env. Idempotent; already-installed envs are no-ops. Run after a freshchezmoi applyto absorb the install cost up front.just maint qa hooks repair— wipes the cache (viapre-commit clean) and re-populates. Reach for this when an env is corrupt (e.g. truncated download). The wipe is global —~/.cache/pre-commit/is shared across every git repo on the machine — so the recipe asks for confirmation.
Cache layout¶
~/.cache/pre-commit/
├── .lock # advisory lock during install/run
├── db.db # SQLite — tracks which repos+revs are installed
├── README # pointer to the framework's repo
└── repo<hash>/ # one per `repo:` block in .pre-commit-config.yaml
├── <cloned upstream source>
├── py_env-python3.14/ # if `language: python`
└── golangenv-default/ # if `language: golang`
Stages¶
Git fires three named stages this repo cares about, each with different semantics:
| Stage | When git fires it | What the hook receives | What it scans |
|---|---|---|---|
pre-commit |
After git commit, before the |
List of staged paths as argv | The index — staged content |
| the commit object is created | (unless pass_filenames: false) |
||
commit-msg |
After the commit message editor | Path to the commit message file as $1 |
The proposed message |
| closes, before object creation | |||
pre-push |
After git push, before objects |
Empty stdin + framework env vars | The push range |
| are sent to the remote | (see below) |
pre-push stdin trap: A native git
pre-pushhook receives refspec lines on stdin (local-ref local-sha remote-ref remote-sha). Under pre-commit, the framework consumes that stdin itself to populate env vars and launches hooks with stdin closed. Awhile read -r local_ref ...loop hits EOF immediately and returns 0 — the hook becomes a silent no-op. Read the env vars instead:
PRE_COMMIT_LOCAL_BRANCH— ref being pushed (e.g.refs/heads/main)PRE_COMMIT_REMOTE_BRANCH— remote ref namePRE_COMMIT_FROM_REF— sha already on the remotePRE_COMMIT_TO_REF— sha being pushed (null sha = deletion)PRE_COMMIT_REMOTE_NAME— e.g.origin
Default stages: the framework ships with default_stages: [pre-commit, pre-push]. A hook without an explicit
stages: line runs at both stages. To pin a hook to a single stage, declare stages: on the hook entry. Hooks that
only make sense at one stage (e.g. commit-msg validators) must override.
What runs at each stage in this repo¶
Hooks listed below are pinned (stages: [...]) to that stage and run only there. Most unpinned hooks (file hygiene,
shellcheck, the upstream betterleaks hook, ruff, mypy, shellcheck-tmpl) inherit the framework default and
therefore run at both pre-commit and pre-push. Whether they do useful work at pre-push depends on each hook — see
the breakdown below.
pre-commit-pinned (run only here): none — the staged-content scanners use the framework default and so also run at pre-push.commit-msg-pinned:commitlint— enforces conventional commit format, subject ≤ 80 chars, description ≥ 5 chars. Rules in.commitlintrc.yamlextend@commitlint/config-conventional.pre-push-pinned:refuse-cross-branch-push,just-test(whendot_just/changes),bats-test(when hooks or.sh/.batschange),betterleaks-pushed-commits(range-scoped PII scan of the commits being pushed viabetterleaks git --log-opts=...).- All three stages (
stages: [pre-commit, pre-push, commit-msg]):betterleaks-config-gate— verifies.betterleaks.tomlexists at the repo root, its symlink target is reachable, and theREVIEW_MEmarker has been removed. The same invariant must hold at every entry point.
What unpinned hooks do at pre-push¶
At pre-push the framework hands pass_filenames: true hooks the list of files changed in the push range
(git diff --name-only <from>...<to>), not the staged index. So:
- file hygiene,
shellcheck,shellcheck-tmpl: scan the push range — useful. ruff,mypy(pass_filenames: false, invokejust ... lint/... typecheck): run their own command against the project, unrelated to the file list. Useful but coarse.- upstream
betterleakshook (pass_filenames: false, entry uses--staged): scans the index, which at pre-push is usually empty. Effectively a no-op at pre-push. Thebetterleaks-pushed-commitslocal hook above is what actually scans the push range for PII.
Why refuse-cross-branch-push exists¶
just-test and bats-test are pass_filenames: false hooks that exercise the working tree (they run
just maint test contract test / bats test), not the pushed commits. The pre-commit framework auto-stashes unstaged
changes during pre-push so the working tree matches HEAD of the checked-out branch — but if you push a different
branch (e.g. git push origin main while on feature/X), those tests still run against feature/X's HEAD, not
main's. The guard refuses that mismatch so tests can't pass for the wrong reasons or fail on unrelated state.
The other pre-push hooks (file hygiene, shellcheck, betterleaks-pushed-commits) don't depend on the working tree at
all — they get their input from the framework's push-range file list or from git history directly.
Debugging¶
- Hook fails with a tool-not-found error: pre-commit or one of the tools a
language: systemhook calls isn't on PATH. Checkpackages.yamland runchezmoi apply. - Hook fails with a corrupt-archive /
EOFError: the cache is bad.just maint qa hooks repairwipes and re-populates. - Hooks aren't running at all:
git config --get core.hooksPathshould be empty (this repo doesn't use it);.git/hooks/<stage>should be the framework's master script (starts with# File generated by pre-commit).just dotfiles verifychecks both. - You want to see what a hook does without committing:
pre-commit run <hook-id> --all-filesruns one hook against every tracked file.pre-commit run --hook-stage pre-push --all-filessimulates the whole pre-push stage end-to-end.
For PII protection specifics — the gate, the personal config file — see Conventions: Git Hooks.