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.

One Check, Three Places Flow showing the same pinned check running at commit, at push and in CI. One Check, Three Places pre-commit staged files, seconds pre-push branch diff, heavier CI all files, authoritative branch rule merge blocked
The configuration is written once; each stage runs a larger slice of it.

Prerequisites

  • Git 2.30+ on every developer machine and in CI.
  • A hook manager. The pre-commit framework (Python, 3.8+) is the most widely used and language-agnostic; lefthook (single Go binary) and Husky (Node) are common alternatives. This topic uses pre-commit for 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]
  1. Commit the file at the repository root.
  2. Run pre-commit install once per clone (the bootstrap target does this).
  3. Run pre-commit run --all-files once 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.

Hook Types and What They Catch Layers of git hook stages with the kind of check each is suited for. Hook Types and What They Catch pre-commit format, lint, secrets on staged files commit-msg message convention pre-push type-check, fast tests CI full suite, all files
Put cheap checks early; move expensive ones to pre-push or CI.

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.

Separate Lint Configs vs One Shared Config Comparison of maintaining separate lint setups for hooks and CI against one shared hook configuration. Separate Lint Configs vs One Shared Config separate hook and CI setups one .pre-commit-config.yaml versions drift apart same pinned revisions passes locally, fails CI same result both places two places to update one pull request
One configuration means local and CI results can only differ by which files were checked.

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.

Pre-Commit Duration After Each Change Bar chart of pre-commit hook duration on a typical change after successive optimisations. Pre-Commit Duration After Each Change whole-repo scans 38 s staged files only 14 s typecheck to pre-push 4.1 s parallel hooks 1.9 s
Measured on a TypeScript monorepo; moving the type-check to pre-push made the largest difference.

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.

Hook Portability by Language Table rating how portable pre-commit hooks are by the language they are written in. Hook Portability by Language Hook language macOS, Linux Windows Notes python yes yes env managed node yes yes env managed golang binary yes yes prebuilt system bash yes fragile CRLF, bash path
Framework-managed languages behave the same everywhere; system shell hooks are the fragile ones.

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; use pipx from Homebrew or the project's toolchain manager for the pre-commit binary.

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-files on an M-series machine before rolling out.

Monorepos: use files: and exclude: 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.

Every guide in this topic