Skip to content

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 just pre-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:

  1. Master scripts at .git/hooks/<stage> — git's actual extension points, written by pre-commit install. Each is a short dispatcher (# File generated by pre-commit) that hands control to the framework with the current stage name.
  2. Hook environments at ~/.cache/pre-commit/ — isolated per-repo sandboxes (Python venvs, Go builds, Node node_modules, etc.), one directory per upstream repo: block in .pre-commit-config.yaml. Populated by pre-commit install --install-hooks or lazily on first invocation.
  3. 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 fresh chezmoi apply to absorb the install cost up front.
  • just maint qa hooks repair — wipes the cache (via pre-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-push hook 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. A while 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 name
  • PRE_COMMIT_FROM_REF — sha already on the remote
  • PRE_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.yaml extend @commitlint/config-conventional.
  • pre-push-pinned: refuse-cross-branch-push, just-test (when dot_just/ changes), bats-test (when hooks or .sh / .bats change), betterleaks-pushed-commits (range-scoped PII scan of the commits being pushed via betterleaks git --log-opts=...).
  • All three stages (stages: [pre-commit, pre-push, commit-msg]): betterleaks-config-gate — verifies .betterleaks.toml exists at the repo root, its symlink target is reachable, and the REVIEW_ME marker 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, invoke just ... lint / ... typecheck): run their own command against the project, unrelated to the file list. Useful but coarse.
  • upstream betterleaks hook (pass_filenames: false, entry uses --staged): scans the index, which at pre-push is usually empty. Effectively a no-op at pre-push. The betterleaks-pushed-commits local 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: system hook calls isn't on PATH. Check packages.yaml and run chezmoi apply.
  • Hook fails with a corrupt-archive / EOFError: the cache is bad. just maint qa hooks repair wipes and re-populates.
  • Hooks aren't running at all: git config --get core.hooksPath should 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 verify checks both.
  • You want to see what a hook does without committing: pre-commit run <hook-id> --all-files runs one hook against every tracked file. pre-commit run --hook-stage pre-push --all-files simulates the whole pre-push stage end-to-end.

For PII protection specifics — the gate, the personal config file — see Conventions: Git Hooks.