Pre-Commit Hooks and Local Quality Gates
Most CI failures on pull requests are not test failures. They are formatting diffs, lint errors, a forgotten generated file, a secret-shaped string, a commit message that breaks the release tooling — problems a tool could have caught in two seconds on the developer's machine, discovered instead ten minutes later after a queue and a runner spin-up. Local quality gates run those checks at the moment of git commit or git push, using exactly the same tool versions and configuration as CI, so the pipeline becomes a confirmation rather than the first line of defence. This topic, part of environment sync, secrets and CI parity, covers the pre-commit framework and alternatives, keeping hooks fast enough that nobody disables them, running identical checks in CI, making hooks work on every platform, and enforcing commit-message conventions.
The parity angle is what separates useful hooks from annoying ones. A hook that runs a different linter version, or a different configuration, than CI produces the worst of both worlds: it blocks commits locally for problems CI would not flag, and passes commits that CI then rejects. Every section below treats "same tool, same version, same config, same files" as the rule, and the hook as a fast, local execution of the CI gate rather than a separate set of opinions.
It also helps to be clear about what hooks are for. They are a convenience and an early warning, not an enforcement mechanism: any developer can bypass them with --no-verify, and a fresh clone has none installed until someone runs the installer. Enforcement lives in CI and branch protection. Hooks exist to make the enforced checks cheap to satisfy, which is why their speed and their agreement with CI matter more than their coverage.
Prerequisites
- Git 2.30+ on every developer machine and in CI.
- A hook manager. The
pre-commitframework (Python, 3.8+) is the most widely used and language-agnostic; lefthook (single Go binary) and Husky (Node) are common alternatives. This topic usespre-commitfor examples and covers lefthook where speed matters. - Pinned tool versions for every linter and formatter, either through the hook manager's own environments or through the project's toolchain file, so local and CI versions match.
- A CI job that runs the same configuration over all files, and a branch protection rule that requires it.
- Agreement on the commit convention if commit messages will be checked — Conventional Commits is the usual choice when release tooling parses messages.
Install and verify the framework once per machine; the bootstrap script should do this, not a README step:
#!/usr/bin/env bash
set -euo pipefail
command -v pipx >/dev/null || python3 -m pip install --user pipx
pipx install pre-commit==3.8.0 || pipx upgrade pre-commit
pre-commit --version
git config --get core.hooksPath && echo "WARNING: core.hooksPath is set; pre-commit install will refuse" || true
A pinned language-agnostic hook configuration
The pre-commit framework reads .pre-commit-config.yaml, clones each hook repository at an exact revision, installs the tool into an isolated environment, and runs it against staged files. Because each hook is pinned by rev, every developer and CI job runs the same versions without installing anything globally:
default_install_hook_types: [pre-commit, commit-msg, pre-push]
default_stages: [pre-commit]
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-merge-conflict
- id: check-added-large-files
args: [--maxkb=512]
- id: mixed-line-ending
args: [--fix=lf]
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.4
hooks:
- id: gitleaks
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.4
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/shellcheck-py/shellcheck-py
rev: v0.10.0.1
hooks:
- id: shellcheck
- repo: https://github.com/compilerla/conventional-pre-commit
rev: v3.4.0
hooks:
- id: conventional-pre-commit
stages: [commit-msg]
- Commit the file at the repository root.
- Run
pre-commit installonce per clone (the bootstrap target does this). - Run
pre-commit run --all-filesonce to fix existing files, and commit the result as its own change.
What goes into the first version of the file matters more than how many hooks it has. Start with checks that are fast, uncontroversial and already enforced somewhere — whitespace, merge-conflict markers, large files, secrets, and the formatter the team already uses. Each of those removes a class of CI failure without starting a style debate. Add linters with judgement-based rules later, one per pull request, after running them with --all-files and fixing or baselining the existing findings; introducing a strict linter and a thousand-line fix in the same change is the fastest way to get the whole configuration reverted.
The drift diagnostic for this section is pre-commit autoupdate --dry-run — not to upgrade automatically, but to see how far pinned revisions lag. Upgrades should be a deliberate pull request so CI proves them. The polyglot setup guide covers local hooks for tools not published as hook repositories and monorepo files: filters.
Running the same checks in CI
A hook configuration that only runs locally is optional by definition: git commit --no-verify skips it, and a fresh clone has no hooks until someone runs pre-commit install. CI makes it authoritative by running the same configuration over all files on every pull request:
name: pre-commit
on: [pull_request]
jobs:
pre-commit:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: '3.12' }
- uses: actions/cache@v4
with:
path: ~/.cache/pre-commit
key: pre-commit-${{ hashFiles('.pre-commit-config.yaml') }}
- run: pipx run pre-commit==3.8.0 run --all-files --show-diff-on-failure
There is one deliberate difference between the local and CI runs: locally, hooks see only the staged files; in CI, --all-files checks everything. That catches files that were committed with --no-verify, files changed through the hosting service's web editor, and files that a new hook would have flagged before it existed. When CI fails on a file the developer never touched, the fix is usually a one-off pre-commit run <hook> --all-files in its own commit — a sign the configuration was changed without cleaning up existing files, not a sign the developer did anything wrong.
Because hook environments are keyed by the configuration file's hash, CI caches them and a typical run takes under a minute. --show-diff-on-failure prints the exact change a formatter wanted, so the developer can see the fix without reproducing it. The guide to running the same lint checks in pre-commit and CI covers removing the duplicate lint steps most pipelines accumulate, and aligning editor integrations with the same versions.
Keeping hooks fast
A hook that takes 40 seconds gets disabled, first with --no-verify and then by uninstalling it. The budget that keeps hooks in use is roughly one to three seconds for pre-commit on a typical change. Three techniques get there. Run on staged files only — the framework does this by default, but hooks that ignore their file arguments and scan the whole repository defeat it. Move expensive checks (type-checking the whole project, unit tests) to pre-push or CI. And run independent hooks in parallel, which lefthook does natively:
pre-commit:
parallel: true
commands:
format:
glob: "*.{ts,tsx,js,json,md}"
run: npx prettier --write {staged_files} && git add {staged_files}
lint:
glob: "*.{ts,tsx}"
run: npx eslint --max-warnings=0 {staged_files}
secrets:
run: gitleaks protect --staged --redact --no-banner
pre-push:
commands:
typecheck:
run: npx tsc --noEmit
The first run of the pre-commit framework after a configuration change is always slow, because it creates the isolated environments for each hook — often a minute or two. That is a one-time cost per configuration version, not per commit, but new hires experience it on their first commit and conclude hooks are slow. Running pre-commit install --install-hooks in the bootstrap target moves that cost into setup, where it belongs.
Measure before optimising. Timing each hook shows where the time goes:
#!/usr/bin/env bash
set -euo pipefail
git stash list >/dev/null
for id in $(yq '.repos[].hooks[].id' .pre-commit-config.yaml); do
start=$(date +%s%N)
pre-commit run "$id" --files $(git diff --cached --name-only) >/dev/null 2>&1 || true
echo "$(( ($(date +%s%N) - start) / 1000000 )) ms $id"
done | sort -rn
The lefthook guide walks through moving a slow hook setup to parallel execution.
Hooks that work on every platform
Hooks break on Windows in predictable ways: shell scripts with CRLF line endings, hooks that call bash or GNU-specific flags, Python not on PATH under the expected name, and path separators in file globs. The pre-commit framework itself runs on Windows, and most published hooks are Python or Go binaries that are portable. The breakages come from local repo: local hooks written as shell scripts. The rule that avoids most of them is to write local hooks in a language the framework manages — language: python or language: node — rather than language: system with a Bash script:
repos:
- repo: local
hooks:
- id: check-env-example
name: .env.example lists every variable used in code
language: python
entry: python scripts/check_env_example.py
files: '(\.env\.example|\.py|\.ts)$'
pass_filenames: false
The framework creates the Python environment on every platform, so the hook behaves identically on macOS, Linux and native Windows.
Portability also includes the developer's editor. Many editors commit through their own Git integration, which may run a different shell, a different PATH or even a different Git binary than the terminal. A hook that works from the command line and fails from the editor's commit button usually cannot find an interpreter the terminal's shell profile adds to PATH. Framework-managed hooks avoid this because the framework records absolute interpreter paths when it installs the hook script. Testing a commit from the editor once, on each platform, as part of rolling out a new hook catches the rest.
Line endings deserve a special mention because they interact with formatters. A formatter that rewrites CRLF to LF on a Windows machine with core.autocrlf=true produces a file that Git immediately converts back on checkout, so the hook "fixes" the same file on every commit. Setting line-ending rules in .gitattributes, rather than relying on each developer's Git configuration, makes the repository's intent explicit and stops that loop. The Windows hooks guide covers the remaining cases — line endings, executable bits and core.autocrlf — and the task-runner equivalent in making task runner targets work on Windows.
Commit-message conventions
When release notes, version bumps or changelogs are generated from commit messages, a malformed message is a build failure waiting to happen. A commit-msg hook validates the message before the commit is created, while the developer still has it in their editor:
#!/usr/bin/env bash
set -euo pipefail
pre-commit install --hook-type commit-msg
echo "bad message" > /tmp/msg && pre-commit run conventional-pre-commit --hook-stage commit-msg --commit-msg-filename /tmp/msg || echo "rejected as expected"
echo "fix(api): handle empty cart totals" > /tmp/msg && pre-commit run conventional-pre-commit --hook-stage commit-msg --commit-msg-filename /tmp/msg
Message checks are the hooks people most resent when they feel arbitrary, so make the convention easy to follow before enforcing it. A commit template (git config commit.template .gitmessage) that shows the allowed types, a short rejection message that prints one valid example, and a scope list that matches the repository's directories turn a rejected commit into a ten-second fix. Teams that squash-merge can choose to validate only the pull request title, since that becomes the final commit message; the local hook then serves as guidance rather than a gate.
The same check must run in CI over every commit in the pull request, because squash merges, web-editor commits and --no-verify all bypass the local hook. The commit message guide covers commitlint as an alternative, templates that help people write valid messages, and handling merge and revert commits.
Platform caveats
macOS: the system Python is not suitable for
pip install; usepipxfrom Homebrew or the project's toolchain manager for thepre-commitbinary.
WSL2: install hooks from the environment where commits are made. If the repository is in WSL and the editor's Git integration runs on Windows, hooks run with Windows Git and must be portable; opening the folder through Remote WSL avoids the split.
Apple Silicon (ARM64): some older hook repositories download x86_64 binaries; they run under Rosetta or fail if it is missing. Prefer hook versions that publish arm64 builds, and check with
pre-commit run --all-fileson an M-series machine before rolling out.
Monorepos: use
files:andexclude:patterns so a Python formatter does not run on a change that only touched TypeScript, which keeps per-commit time proportional to the change.
Rollback and recovery
Hooks are local and additive. Uninstalling removes them from .git/hooks; CI continues to enforce the configuration. If a hook upgrade misbehaves, revert the configuration change — the framework reinstalls the previous revision on the next run:
#!/usr/bin/env bash
set -euo pipefail
pre-commit uninstall --hook-type pre-commit --hook-type commit-msg --hook-type pre-push
git revert --no-edit "$(git log -1 --format=%H -- .pre-commit-config.yaml)"
pre-commit clean
For a single urgent commit while a hook is broken, SKIP=hook-id git commit skips that one hook rather than all of them — narrower than --no-verify, and CI still runs it.
Frequently Asked Questions
If CI runs the checks anyway, why have local hooks?
Speed and focus. A hook catches a formatting or secret problem in seconds, before the commit exists, instead of after a CI queue. CI remains authoritative because hooks can be skipped.
Should hooks auto-fix files or only report?
Auto-fixing formatters (Prettier, Ruff format) are fine at pre-commit: the framework fails the commit, the developer reviews and stages the fix, and commits again. Linters that change semantics should report only.
How do we make sure new clones have hooks installed?
Run pre-commit install from the project's bootstrap target, and set default_install_hook_types in the configuration so one command installs all stages. The doctor script can check that .git/hooks/pre-commit exists.
pre-commit, lefthook or Husky?
pre-commit for polyglot repositories and its large hook ecosystem; lefthook when speed and parallelism matter and a single binary is preferred; Husky for JavaScript-only repositories that already rely on npm scripts.
Related
- Set up pre-commit for a polyglot repository
- Block committed secrets with gitleaks
- Keep onboarding docs in sync with CI checks
- Run one consolidated parity check
Every guide in this topic
- Enforcing Commit Message Conventions LocallyValidate Conventional Commits before the commit exists with a commit-msg hook, add a message template, and check PR titles in CI so release tooling never breaks.
- Fixing pre-commit Hooks That Fail Only on WindowsResolve Windows-only hook failures: bash not found, CRLF formatter loops, Python launcher issues and executable-bit checks, with portable hook rewrites.
- Running the Same Lint Checks in pre-commit and CIStop 'passes locally, fails in CI' lint results: one pinned hook config for both, all-files runs in CI, cached environments, and editors on the same versions.
- Setting Up pre-commit for a Polyglot RepositoryConfigure one .pre-commit-config.yaml for Python, TypeScript, Go, shell and Terraform: pinned hooks, local hooks, per-directory file filters and a first clean run.
- Speeding Up Slow Git Hooks With lefthookCut a 40-second commit hook to under two seconds: measure each check, run on staged files only, move type-checks to pre-push and run hooks in parallel with lefthook.