Contents

Pre-commit Hooks: Catching Mistakes Before They Reach the Repo

Stop the secret, the syntax error, and the unformatted file at the door

Contents

There is a particular flavour of shame that comes from pushing a commit, watching CI light up red ninety seconds later, and discovering the failure was a trailing-whitespace lint error or a file you forgot to format. Worse is the commit that ships a cloud access key in a .env you meant to gitignore, now etched into history forever. I have done both. The whitespace one wastes ten minutes; the leaked-key one cost me an afternoon of rotating credentials and grovelling in a channel. Pre-commit hooks catch all of it at the only moment it is cheap to catch: before the commit exists. Git has always supported hooks; the pre-commit framework just makes them sane to manage.

Why a framework and not a raw hook

Advertisement

Git’s native hooks live in .git/hooks/, which is not version-controlled and is therefore invisible to your team. You write a pre-commit shell script, it works on your machine, nobody else has it, and it bit-rots the moment someone re-clones. That is the whole reason people tried hooks once, got burned by the distribution problem, and gave up.

The pre-commit framework — a Python tool, despite working happily on a repo written in any language — fixes distribution properly. Hooks are declared in a checked-in .pre-commit-config.yaml, and each developer runs one install command to wire them into their local .git/hooks/. Change the config, everyone gets the change on their next git pull and re-install. It also manages each hook’s own dependencies in isolated environments, so a Python linter, a Go formatter and a shell checker can coexist without any of them touching your system Python or your PATH. That isolation is the quietly brilliant part: you never think about where ruff or gitleaks came from, because the framework fetches and caches the right version for you.

Install the framework once, globally. I use pipx so it does not clutter a project virtualenv, but any of these work:

1
2
pipx install pre-commit    # or: pip install pre-commit / brew install pre-commit
pre-commit --version

A config that earns its keep

Here is a .pre-commit-config.yaml close to what I put in most repos. The “hygiene” hooks at the top are universal; the rest you mix to match the language. The rev: values below are current at the time of writing — pin them, and read on for how to keep them fresh:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v6.0.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-yaml
      - id: check-added-large-files
        args: ["--maxkb=500"]
      - id: check-merge-conflict
      - id: detect-private-key

  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.24.2
    hooks:
      - id: gitleaks

  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.15.20
    hooks:
      - id: ruff
        args: ["--fix"]
      - id: ruff-format

Then, inside the repo, each developer runs:

1
2
pre-commit install            # wire it into .git/hooks
pre-commit run --all-files    # check the whole repo once, now

That second command matters more than people expect. When you first adopt hooks on an existing codebase, running against every file surfaces a pile of pre-existing issues that you want to deal with in one deliberate cleanup commit — not have them ambush individual developers one file at a time for the next month.

After install, every git commit runs the hooks against the staged files only, which keeps them fast even in a large repo. If a hook modifies a file — ruff --fix reformatting, end-of-file-fixer adding a newline — the commit aborts, the file is fixed in place, and you re-stage and commit again. Mildly annoying the first time; deeply satisfying once it becomes muscle memory.

The secret scanner is the one to never skip

Advertisement

Every hook above is useful, but gitleaks is the one I consider non-negotiable, and detect-private-key is its cheap backstop. Gitleaks scans staged changes for things that look like credentials — API keys, private keys, cloud tokens, high-entropy strings sitting in suspicious places — and blocks the commit when it finds one.

The value is not the day-to-day formatting tidiness. It is the single occasion it stops you committing a live token. If you want to understand exactly why deletion does not help — why the object stays reachable in the repository long after you think you removed it — it is worth knowing what actually happens when you type git commit. This is worth being blunt about: a secret that reaches a shared branch is compromised, full stop. It does not matter that you deleted it in the next commit — Git history keeps it, anyone who cloned has it, and CI logs may have echoed it. Rotating the credential is the only real remediation, and rotation is often the painful bit (which service? who else uses it? what breaks when it changes?). Catching the secret at the pre-commit boundary is the difference between a non-event and a genuine incident with a write-up attached.

If gitleaks flags something you know is a false positive — an example key in documentation, a test fixture — you allowlist it explicitly in a .gitleaks.toml, in a checked-in file that a reviewer can see, rather than reaching for --no-verify.

The honest catch: hooks are advisory

Here is the thing the enthusiastic blog posts skip. Pre-commit hooks run on the developer’s machine, and any developer can skip them with git commit --no-verify. They are a convenience and a safety net, not a security control. Someone in a hurry — or someone acting in bad faith — can bypass them entirely, and they only protect repositories where every contributor has actually run pre-commit install in the first place.

So the correct mental model is two layers. Pre-commit hooks give you a fast, local check that catches mistakes in seconds and keeps your CI green. The identical checks must also run in CI as the enforced gate that cannot be --no-verify’d away. The framework makes this trivial, because the same command that runs locally runs in a pipeline:

1
2
3
4
5
# GitHub Actions step
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
  with: { python-version: "3.12" }
- run: pipx install pre-commit && pre-commit run --all-files

Run the same config in both places and you get fast feedback locally plus an unbypassable backstop centrally. Treat the local hooks as the seatbelt and CI as the airbag. Neither replaces the other. This is the same layered thinking that applies to catching problems before they ship in general — the earlier a check runs, the cheaper the failure, but the last line of defence has to be one nobody can wave away. It is the same instinct behind scanning container images for known vulnerabilities in the pipeline rather than after deployment.

Troubleshooting the things that will trip you up

A few failure modes come up often enough to be worth naming, because the first time each one bites it looks like the framework is broken when it is doing exactly what you told it to.

“The hook keeps failing but my file looks fine.” Almost always ruff --fix or a formatter modified the file and then exited non-zero to force you to look. The fix is already applied — just git add the changed file and commit again. This confuses everyone once.

A hook hangs or fails on first run with a network or build error. The framework is building the hook’s isolated environment. Corporate proxies, an offline machine, or a rate-limited GitHub can all break that first fetch. pre-commit clean wipes the cached environments and forces a fresh build, which resolves a surprising share of “it worked yesterday” reports.

Merge commits get blocked or hooks fire on files you did not touch. By default hooks run only on staged files, but pre-commit run --all-files in CI runs on everything, so a pre-existing violation in an untouched file fails the pipeline. Either fix it in a cleanup commit or scope the hook with files:/exclude: patterns — do not disable the hook wholesale.

A hook is slow enough that people start reaching for --no-verify. That is a signal, not a nuisance. Heavy checks (full test suites, whole-repo type-checking) belong in CI, not in the commit path. Keep the local hooks to fast, staged-file checks so the friction stays low enough that nobody wants to skip them.

A team member’s commits mysteriously skip the hooks entirely. Almost always they cloned before you added the config, or they cloned and never ran pre-commit install. There is no magic that forces installation — the config is checked in, but wiring it into .git/hooks/ is a manual, per-clone step. This is the strongest argument for the CI mirror: it is the only part of the setup that cannot be forgotten, because it runs on infrastructure rather than trusting every contributor to have done the ceremony. Some teams add a pre-commit install line to a repo bootstrap script or a make setup target so a fresh clone wires itself up; that removes one common source of “it works on my machine but not in review.”

Keeping the config from rotting

One maintenance note that saves future confusion. The rev: pins in that config will drift as the underlying tools release, and a stale gitleaks in particular means new secret patterns go undetected. pre-commit autoupdate bumps every pin to the latest tag in one command, which I run periodically and review like any other dependency change. Pin to tags — never track a moving branch — so that a hook upstream silently changing behaviour cannot silently change what your commits are checked against. Bundle the bump with running pre-commit run --all-files afterwards, so a stricter new rule surfaces in your cleanup commit rather than in a colleague’s unrelated pull request.

Is it worth it?

For any repository with more than one contributor, or any repository where a leaked secret would ruin your week, yes — this is among the cheapest high-value tooling you can add. The config is a few lines, the install is one command, and from then on the boring class of mistakes simply stops reaching your branches. New contributors inherit consistent formatting for free, which quietly removes an entire genre of nitpicking from code review and lets humans spend their attention on logic instead of whitespace.

The honest limits are the two I have laboured: it is advisory, so it must be mirrored in CI to enforce anything, and it adds a second or two to each commit, which the impatient will resent right up until the first time it saves them from themselves. For solo throwaway scripts it is overkill; skip it and move on. For everything else — a shared repo, anything with credentials, anything you would be embarrassed to see in the reflog — install it, add gitleaks, mirror it in CI. That combination has caught more of my mistakes than any other single tool in my workflow, and unlike most tooling, it earns its keep on the very first day. If you are already thinking about what happens before code merges, it pairs naturally with treating CI as the real gate rather than a rubber stamp.

Advertisement
Advertisement
Smarc
Written by Smarc

Founder and editor of vo.rs. A lifelong tinkerer who self-hosts far more than is sensible, hardens Linux boxes for fun, and prods the latest AI tools to see what they can really do. The how-to guides here are the notes Smarc wishes had existed the first time round.