The pre-commit Framework for Polyglot Repositories Jump to heading
A repository with one language has one toolchain, and wiring a hook to it is straightforward — that is the world Husky and lint-staged were designed for. A repository with four languages has four toolchains, four version-pinning stories, and a new contributor who cannot commit until all four are installed. This part of Git Automation & CI/CD Hook Engineering covers the framework built for that situation: pre-commit, which provisions each hook’s environment itself so the only prerequisite on a developer’s machine is the framework.
The design idea is worth stating plainly, because it explains every configuration choice that follows. A hook is not “a command you have”; it is a reference to a pinned revision of a repository that declares how to build the environment the command needs. The framework creates that environment once, caches it, and reuses it. Version drift between two developers stops being possible, because the version is written down in the same file everyone shares.
Prerequisites Jump to heading
Step 1 — Install the Framework and Generate a Configuration Jump to heading
# Install once per machine. pipx keeps it out of project dependencies.
pipx install pre-commit || python3 -m pip install --user pre-commit
# From the repository root, create a starter configuration
pre-commit sample-config > .pre-commit-config.yaml
git add .pre-commit-config.yaml Confirm the framework can see the repository:
pre-commit --version # expect: pre-commit 4.x.x
git config core.hooksPath # expect: empty for now — Step 4 sets it The generated file is a starting point, not a recommendation. Every entry in it should survive a deliberate decision about whether that check belongs in a commit hook at all.
Step 2 — Declare Pinned Hook Repositories Per Language Jump to heading
Each repo block names an upstream repository and a rev — a tag or commit that pins exactly which version of the hook runs. The framework builds an isolated environment for that revision and caches it under ~/.cache/pre-commit.
# .pre-commit-config.yaml
# Every rev is a tag: the version is data, reviewed like any other change.
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-merge-conflict # refuses conflict markers in a commit
- id: check-added-large-files
args: ['--maxkb=1024']
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.4
hooks:
- id: ruff
args: ['--fix']
- id: ruff-format
- repo: https://github.com/golangci/golangci-lint
rev: v1.62.2
hooks:
- id: golangci-lint
- repo: https://github.com/rbubley/mirrors-prettier
rev: v3.4.2
hooks:
- id: prettier Verify the environments build and the pins resolve:
pre-commit install-hooks # builds every environment; slow once, cached after
pre-commit run --all-files # expect a report per hook, and probably some fixes Keep the pins current with pre-commit autoupdate, which rewrites each rev to the latest tag. Run it on a schedule, in its own commit, so a linter upgrade that changes behaviour is reviewable rather than mixed into a feature branch. That is the same discipline that makes changelog automation trustworthy: version changes are visible events, not side effects.
Step 3 — Scope Every Hook With files and exclude Jump to heading
The default scoping is by file type, which is rarely precise enough in a repository with vendored code, generated output, or a legacy area you have not cleaned yet.
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.4
hooks:
- id: ruff
# Only the modernised packages; widen this as directories are cleaned.
files: ^(services/api|services/worker)/.*\.py$
exclude: ^services/api/generated/.*$
- repo: local
hooks:
- id: terraform-fmt
name: terraform fmt
entry: terraform fmt -check -diff
language: system # uses the terraform already on PATH
files: \.tf$
pass_filenames: true What changed: ruff now sees only the two services that have been cleaned, generated code is exempt, and a locally defined hook covers Terraform without needing an upstream repository at all.
# Confirm the scoping does what you expect, without committing anything:
pre-commit run ruff --all-files --verbose | head -20
# Expect: only files under services/api and services/worker, none from generated/ The two patterns compose as a subtraction, and picturing them that way prevents most scoping bugs:
Three scoping mistakes recur. Anchoring patterns with ^ and $ matters — an unanchored \.py also matches notes.python.txt. Excluding generated directories is almost always necessary, because a formatter that rewrites generated code produces a diff that reappears on every regeneration. And pass_filenames: false is right for whole-project checks such as a dependency audit, which would otherwise be re-run once per staged file.
Step 4 — Install the Hook Into the Git Hook Path Jump to heading
# Writes .git/hooks/pre-commit, which delegates to the framework
pre-commit install
# Also govern commit messages, if you enforce a convention
pre-commit install --hook-type commit-msg
# And run the heavier checks before the network call instead
pre-commit install --hook-type pre-push Verify the wiring:
cat .git/hooks/pre-commit | head -3 # expect a generated pre-commit shim
git commit --allow-empty -m "chore: verify hooks" # hooks run, nothing to check If your repository already uses core.hooksPath — a common arrangement in monorepos, described in Sharing Husky Hooks Across a Monorepo — the framework will refuse to install rather than silently lose. Either unset the path or point the framework at it explicitly with PRE_COMMIT_HOOKS_PATH. Two systems both claiming .git/hooks is the most common cause of “my hooks stopped running”.
SAFETY WARNING —
pre-commit uninstallrestores whatever hook file was there before, but if another tool overwrote.git/hooks/pre-commitin the meantime, that content is gone. Keep hooks in version control (a.husky/directory or ahooks/directory pluscore.hooksPath) so a lost.git/hooksfile is never more than a checkout away.
Step 5 — Run the Identical Configuration in CI Jump to heading
Local hooks are skippable by design. The configuration only becomes a guarantee when the same file is executed by a job that gates the merge.
# .github/workflows/lint.yml
name: lint
on: [pull_request]
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Cache pre-commit environments
uses: actions/cache@v4
with:
path: ~/.cache/pre-commit
# The config hash is the cache key: change a rev, rebuild the env.
key: pre-commit-${{ hashFiles('.pre-commit-config.yaml') }}
- run: pip install pre-commit
- run: pre-commit run --all-files --show-diff-on-failure # Reproduce the CI run locally before pushing:
pre-commit run --all-files --show-diff-on-failure
# Expect: identical output to the CI job, because it is the same command --show-diff-on-failure is what turns a red job into a usable one: the log contains the exact patch that would fix the failure, so a contributor can apply it without guessing which formatter version produced it. Caching by the hash of the configuration file — not by a fixed key — means a pin change rebuilds the environment automatically and an unchanged config never does.
Integration With Adjacent Tooling Jump to heading
Boundary with Husky and lint-staged. The two systems overlap, and running both is the usual way to end up with hooks that fire twice or not at all. Pick one owner of .git/hooks. In a JavaScript-only repository, Husky with lint-staged is lighter and faster to start. Once a second or third language arrives, the framework’s environment provisioning is worth more than the simplicity you give up. Migration in either direction is mechanical and is covered in Migrating from Husky to the pre-commit Framework.
Boundary with pipeline triggers. The framework’s file scoping and the path filters described in CI/CD Pipeline Trigger Mapping answer the same question at different scales: which checks does this change deserve? Keep the two consistent. If a Terraform change triggers a plan job in the pipeline, the same paths should trigger the terraform fmt hook locally, so the fast feedback and the slow gate agree about what matters.
Boundary with server-side enforcement. Everything here runs on a developer’s machine and can be skipped. When a rule must hold regardless, mirror it into the server-side policy described in Server-Side Hook Enforcement — and factor the predicate so both callers share one implementation rather than two that drift.
Configuration Reference Jump to heading
| Key | Default | Effect | When to change |
|---|---|---|---|
rev | none — required | Pins the hook repository to a tag or SHA | On a scheduled autoupdate, in its own reviewed commit |
files | all files of the hook’s type | Regex of paths the hook may see | Whenever part of the tree is legacy, vendored, or generated |
exclude | empty | Regex subtracted from files | To carve generated output out of an otherwise in-scope directory |
pass_filenames | true | Whether staged paths are appended to entry | Set false for whole-project checks such as dependency audits |
stages | all installed hook types | Which Git hook the entry runs in | Move slow checks to pre-push, keep formatting at pre-commit |
language | per hook | How the environment is built (python, node, golang, system) | Use system only when the tool is guaranteed to be installed |
fail_fast | false | Stop at the first failing hook | Enable when a formatting failure makes later hooks meaningless |
default_install_hook_types | [pre-commit] | Which hook types a bare install writes | Set once so contributors do not need extra flags |
Common Failure Modes and Diagnostics Jump to heading
Hooks stopped running after a tooling change. Symptom: commits succeed with no hook output at all. Root cause: something rewrote .git/hooks/pre-commit, or core.hooksPath now points elsewhere. Fix: git config core.hooksPath to see where Git is looking, then pre-commit install --overwrite to reclaim it.
The first run in CI takes minutes. Symptom: a lint job that should take seconds spends four minutes building environments. Root cause: no cache, or a cache key that changes on every run. Fix: cache ~/.cache/pre-commit keyed by the hash of the configuration file, as in Step 5.
A formatter fights a colleague’s editor. Symptom: files flip back and forth between two formattings across commits. Root cause: the editor runs a different version of the formatter than the pinned hook. Fix: point the editor at the framework’s environment, or agree that the hook is authoritative and disable format-on-save for those file types.
A hook rewrites files and the commit fails every time. Symptom: git commit fails, the diff looks correct, committing again fails again. Root cause: the formatter fixed the file but the fix is unstaged, so the next run sees the original staged content. Fix: git add -u after the failed run, then commit — or use git commit -a habitually in repositories with rewriting hooks.
A vendored directory is reformatted on the first run. Symptom: pre-commit run --all-files produces a thousand-file diff. Root cause: no exclude for vendored or generated paths. Fix: add the exclusion, reset the working tree, and re-run before committing anything.
Team Rollout Jump to heading
Frequently Asked Questions Jump to heading
How is this different from Husky plus lint-staged? Jump to heading
Husky wires shell scripts into Git’s hook path and lint-staged filters staged files to a command you already have installed; both assume the toolchain exists on the machine. The pre-commit framework instead provisions each hook’s toolchain itself, creating an isolated environment per hook repository at a pinned revision. In a single-language JavaScript repository that extra machinery buys little. In a repository holding Python, Go, shell and Terraform it is the whole point: contributors do not need four toolchains installed to commit.
Does pre-commit run on the whole repository or only staged files? Jump to heading
By default it passes only the staged files to each hook, which is what keeps a commit fast in a large repository. Ask for everything explicitly with pre-commit run --all-files, which is the right mode in CI and after adding a new hook. A hook can also opt out of file arguments entirely with pass_filenames: false when it inspects the project as a whole.
What does pre-commit do with my unstaged changes? Jump to heading
It stashes them before running and restores them afterwards, so hooks see exactly the content that is about to be committed rather than a mix of staged and unstaged work. If a hook rewrites a file — a formatter, usually — the run fails, the fix stays in the working tree, and you stage it and commit again. The stash is kept in the framework’s own directory, so an interrupted run is recoverable.
How do I stop it from reformatting an entire legacy codebase? Jump to heading
Scope the hook with files and exclude patterns so it only sees the directories you have already cleaned, then widen the pattern as you go. The alternative — running a formatter across everything at once — produces a commit that touches every file, destroys blame, and collides with every branch in flight. Introduce the hook narrowly, and expand it in reviewed steps.
Can I write a hook that has no upstream repository? Jump to heading
Yes — declare repo: local and give the hook an entry command that already exists in the environment, such as a script committed to the project. Local hooks are the right home for project-specific rules that would never make sense upstream, and they still benefit from the framework’s file scoping and staged-file handling.
Related Jump to heading
- Migrating from Husky to the pre-commit Framework — a step-by-step move that keeps hooks working throughout, with a rollback at every stage.
- Caching pre-commit Environments in CI — turn a four-minute cold lint job into a fifteen-second warm one without stale caches.
- Writing a Custom Local pre-commit Hook — project-specific rules that have no upstream, with correct exit codes and file handling.
- Lint-Staged & Formatting Automation — the lighter alternative for single-language repositories, and the tool this framework replaces.
- Server-Side Hook Enforcement — where to put the rules that must hold even when a local hook is skipped.