pre-commit¶
A generic reference for pre-commit — the hook framework itself, how its Python environment model works, and how to make
pre-commitrobust when system Python breaks (theuvtrick). For this repo's hook list + scope rules, seethis_repo/testing.md§ shellcheck + shfmt.
What pre-commit is¶
pre-commit is a language-agnostic framework for running short checks at git commit time. A .pre-commit-config.yaml file declares a list of "hook repos" (each a GitHub repo with one or more hooks), versioned by rev:. On first use, pre-commit clones each repo into ~/.cache/pre-commit/, creates an isolated virtualenv / gem / go-module / npm env per repo, and caches it keyed on rev:.
Rough lifecycle:
commit triggered
→ pre-commit run (via .git/hooks/pre-commit shim)
→ for each staged file, match against each hook's `files:` pattern
→ for each matching hook:
- ensure the hook's env exists in ~/.cache/pre-commit/repo<hash>/
- if missing, clone repo@rev, build env, cache it
- invoke the hook's entrypoint with the matched file paths
→ non-zero exit → commit aborted
The key mental model: pre-commit uses the Python it was installed with to bootstrap every hook's environment. Hooks themselves may run on any language (Node, Go, Ruby, Rust, Python), but the virtualenv/installer call is initiated by that bootstrap Python. So if that Python is broken, nothing works.
Common commands¶
pre-commit install # install the git hook (.git/hooks/pre-commit)
pre-commit install --install-hooks # plus pre-build every hook env now
pre-commit run # run against currently staged files
pre-commit run --all-files # run against EVERY tracked file
pre-commit run <hook-id> # run just one hook (e.g. shellcheck)
pre-commit run <hook-id> --files path/to/file
pre-commit autoupdate # bump every `rev:` to the repo's latest tag
pre-commit autoupdate --repo <url> # bump only one
pre-commit clean # wipe ~/.cache/pre-commit/
pre-commit gc # GC cached envs that no longer match any config
# Bypass temporarily (do NOT make it a habit):
git commit --no-verify # skip all hooks for this commit
SKIP=hook-id-1,hook-id-2 git commit # skip specific hooks
autoupdate does not run the hooks — it just rewrites rev: pins and says "remember to actually run them once". This repo wires it into just upgrade-plugins.
How this repo installs pre-commit (single source of truth)¶
This repo installs pre-commit the same way on every OS: as a uv-managed tool pinned to CPython 3.13.
That exact command is the single source of truth. It runs in three places and all three converge on ~/.local/bin/pre-commit:
| Entry point | File | When it runs |
|---|---|---|
| Ansible role | dot_ansible/roles/security_tools/tasks/main.yml |
chezmoi apply → ansible-playbook macos.yml/linux.yml, and just ansible-security |
| Justfile recipe | justfile pre-commit-install-tool (depended on by pre-commit-setup and setup-dev) |
just pre-commit-setup, just setup-dev |
| Doctor recipe | scripts/pre-commit-doctor.sh via just pre-commit-doctor |
On demand, when hooks break |
uv itself is installed by run_once_before_00_bootstrap.sh.tmpl before any ansible role runs, so the install command above always has uv available.
What uv tool install --force pre-commit --python 3.13 actually does:
- Downloads a clean CPython 3.13 into
~/.local/share/uv/python/(isolated from whatever Homebrew or the system is doing). - Creates a dedicated venv under
~/.local/share/uv/tools/pre-commit/. - Symlinks
~/.local/bin/pre-commit→ that venv's script.
After that, pre-commit runs under a Python you control, independent of Homebrew/system upgrades. New hook environments will be bootstrapped by that 3.13, which means they'll use 3.13 too (unless a hook declares language_version: overrides).
Why pinned 3.13 (not Homebrew Python)¶
Homebrew's python@3.14 has historically shipped with a bundled libexpat whose ABI disagreed with the system /usr/lib/libexpat.1.dylib on macOS, breaking every virtualenv call that pre-commit issues to build hook repos (symptom: Symbol not found: _XML_SetAllocTrackerActivationThreshold on import of pyexpat). Pinning to CPython 3.13 side-steps that whole class of brew-python-upgrade breakage — and it makes the hook cache key (which pre-commit derives from the bootstrap Python version) stable across machines and teammates.
PATH precedence: how ~/.local/bin/pre-commit wins¶
The uv install only helps if ~/.local/bin beats brew's /opt/homebrew/bin and conda's ~/miniforge3/bin on PATH. This repo's zsh export (dot_config/zsh/00_exports.zsh.tmpl:21) handles that:
Because this export runs before conda's lazy init (dot_config/zsh/tools/04_conda_mamba.zsh), the uv-managed binary wins. Verify:
The conda-shadow failure mode¶
If you see which pre-commit return ~/miniforge3/bin/pre-commit (or similar), conda installed pre-commit transitively into its base env and something in your PATH ordering put conda ahead of ~/.local/bin. Fix options:
# Option A: just ask the doctor
just pre-commit-doctor
# Option B: drop conda's copy
conda remove -n base pre-commit # if it was installed into base
conda deactivate # or just leave base
# Option C: uninstall brew's copy (if that's the shadow instead)
brew uninstall pre-commit
The PATH-shadow case is the usual reason "I ran just setup-dev but pre-commit --version shows a different version than the ansible role installed."
Team / multi-machine caveat¶
If the team shares .pre-commit-config.yaml but some teammates use Homebrew-installed pre-commit and others use uv-pinned, each group will build and maintain a separate cache under ~/.cache/pre-commit/. Harmless, just slightly wasteful. Cache keys are per-user anyway, so there is no cross-user invalidation risk — but for a fully reproducible cache key, have everyone run the ansible role / just pre-commit-setup so they all end up on the same uv-pinned 3.13.
Not to be confused with uv-* hooks¶
Astral's uv guide for pre-commit covers a different angle — providing uv-* hooks inside .pre-commit-config.yaml (e.g. uv-lock, uv-export, pip-compile). That is orthogonal to the question "which Python runs pre-commit itself?" (which is what this section is about). This repo doesn't currently use any uv-* hooks in .pre-commit-config.yaml.
Debugging broken hook environments¶
Before diving into the buckets below, try the doctor first — it covers 90% of cases:
just pre-commit-doctor # diagnose + auto-repair via uv
just pre-commit-doctor --run-hooks # same, plus smoke-test hook envs
./scripts/pre-commit-doctor.sh --check # diagnose-only (no reinstall)
The doctor checks uv is present, confirms pre-commit resolves to ~/.local/bin/pre-commit (not a conda/brew shadow), scans for the known-bad pyexpat / virtualenv error signatures, and re-runs uv tool install --force pre-commit --python 3.13 + pre-commit clean if anything is wrong.
Pre-commit errors usually fall into a few buckets:
1. "An unexpected error has occurred: CalledProcessError … virtualenv"¶
The bootstrap Python failed to build a virtualenv for one of the hook repos. Typical causes:
- Broken
pyexpat/ standard-library shared object after a Python point-release upgrade. Symptom:ImportError: dlopen(...pyexpat...Symbol not found: _XML_SetAllocTrackerActivationThreshold. That is a mismatch between the Python's bundledlibexpatABI and the system/usr/lib/libexpat.1.dylib. Fix:just pre-commit-doctor— it re-installs pre-commit under a uv-pinned CPython 3.13 that isn't affected. (Manual equivalent:uv tool install --force pre-commit --python 3.13 && pre-commit clean.) - System
libssl/libcryptoversion mismatch — similar class of issue. Same fix: re-pin via the doctor. - Disk full / permissions on
~/.cache/pre-commit/.
2. A hook fails but you don't know why¶
Verbose mode prints the full command line the hook invoked plus stdout/stderr. For formatters in -d (diff) mode (shfmt, black --check), the stderr often is the useful diff.
3. Hook runs too slowly on commit¶
# Install the env up-front so first-commit is fast:
pre-commit install --install-hooks
# Or skip a specific slow hook for this commit:
SKIP=ansible-lint git commit
4. "files were modified by this hook"¶
Auto-formatter hooks (trailing-whitespace, end-of-file-fixer, shfmt in -w mode, black) rewrite files then exit non-zero, forcing you to git add the rewritten version and re-commit. This is intentional: hooks can't modify the commit being created, only the working tree. Pattern:
git commit -m "..."
# hook rewrites files, commit aborts
git add -A
git commit -m "..."
# passes this time
If you want the formatter to check-only (so CI can fail without rewriting), most support -d / --check / --diff. This repo runs shfmt -d for that reason.
5. Reset when all else fails¶
pre-commit clean # wipe cached envs, force re-bootstrap next commit
pre-commit uninstall # remove the git hook
pre-commit install --install-hooks # reinstall + pre-build
Integration points worth knowing¶
.pre-commit-config.yamlat repo root — the hook list +rev:pins..pre-commit-hooks.yamlin a hook provider repo — how a repo exposes hooks. (Useful when writing a localrepo: localhook or a custom shared repo.)language: system— opt out of the virtualenv sandbox and just run a shell command from PATH. This repo uses it for./scripts/redact_secrets.py --fixso the hook doesn't try to provision its own Python.language: python+additional_dependencies:— install extra pip deps into the hook's venv. The hook cache is keyed on this list, so adding a dep invalidates and rebuilds.files:/exclude:regex — the only performance knob you usually need. Narrowfiles:aggressively; hooks that run on every file get slow fast.stages:— hooks can targetpre-commit,pre-push,commit-msg,post-checkout, etc. Default is justcommit.
See also¶
- pre-commit.com — upstream docs.
- Using uv with pre-commit —
uv-managed hooks. docs/this_repo/testing.md§ shellcheck + shfmt — how this repo uses pre-commit (hook list, scope, zsh carve-out).docs/this_repo/cheatsheet.md§ Pre-commit & Gitleaks — command quick-reference.