This Fixes: My pre-commit Hook Keeps Failing on Files I Didn’t Touch

You change one file, run git commit, and pre-commit blocks you over a dozen files you never opened — or the same formatter "fails" twice in a row on a commit you already fixed. Neither is a bug. Both are configuration, and both are quick to put right.

This is one of the most common complaints I hear about pre-commit, and it almost always comes down to three distinct behaviours that get lumped together as "the hook is broken". They are not the same problem, so the fixes are different. In order of how often I see them: a formatter that auto-fixed your code and now wants a re-add; a hook that scans the whole repository instead of your staged files; and the stash pre-commit performs on partially-staged changes. Get clear on which one you are looking at and each has a clean answer.

The formatter did not fail — it fixed something

The most frequent "it keeps failing" is a formatter such as ruff-format (or Black, or Prettier) reporting failure on a commit you are convinced is clean:

Free · 4 minutes

Is your engineering team shipping safely, or quietly accumulating risk?

Fourteen questions on how work gets from idea to production — cadence, testing, rollback, and the key-person risk in your delivery. Banded finding on screen, full sheet by email.

$ git commit -m "tidy config loader"
ruff-format..............................................................Failed
- hook id: ruff-format
- files were modified by this hook

This is not a failure in the sense you think. The hook rewrote your file to match its formatting rules, and pre-commit exits non-zero whenever a hook modifies a file. The point is to stop the commit so that what lands in history is the formatted version, not your draft. The fix it wrote is sitting in your working tree, unstaged. Add it and commit again:

$ git add -u
$ git commit -m "tidy config loader"
ruff-format..............................................................Passed

The second run passes because the file is already formatted, so the hook changes nothing. If it "keeps failing" past the second attempt, you have a genuinely non-idempotent hook — two tools fighting over the same file, most often a linter's auto-fix and a formatter disagreeing. The cure is ordering: run the linter's fixer before the formatter, exactly as Ruff's own guidance says, so the formatter always gets the last word.

The hook is scanning the whole repo, not your files

Now the more disorienting case: pre-commit flags files in directories you have not touched this decade. By default a hook receives the list of staged filenames and runs only against those. But that behaviour is controlled by one setting, pass_filenames, which defaults to true. Set it to false — as a lot of type-checkers, test runners and project-wide linters do — and pre-commit passes no filenames at all. The hook then runs however its entry tells it to, which is usually across the entire project.

Here is a local hook that behaves exactly this way:

repos:
  - repo: local
    hooks:
      - id: mypy-project
        name: mypy (whole project)
        entry: mypy src
        language: system
        pass_filenames: false
        files: ^src/.*\.py$

Read that carefully, because the interaction is the whole point. The files pattern decides whether the hook runs at all — it fires only when you stage a .py file under src/. But once it fires, entry: mypy src with pass_filenames: false checks every file under src/, staged or not. So you edit one module, and mypy reports a pre-existing error three directories away. The hook is doing precisely what it was told; the surprise is that pass_filenames: false severed the link between what you staged and what gets checked.

You have two honest options. Leave it whole-repo, because a type-checker or test suite genuinely needs the full picture to be correct — a per-file mypy run gives false results — and accept that it will occasionally surface debt you did not create. Or, if the tool can meaningfully run on individual files, drop pass_filenames: false and let pre-commit hand it your staged paths. For mypy specifically, keep it project-wide; for a formatter or a simple linter, per-file is fine.

Scope hooks with files and exclude

Even file-aware hooks nag about the wrong things if they are pointed at the wrong tree. Vendored code, generated migrations, a legacy/ directory nobody dares reformat — these produce "failures" on files you will never willingly change. The controls are files and exclude, both Python regular expressions matched with re.search, so an unanchored pattern matches anywhere in the path. Anchor them.

repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.15.17
    hooks:
      # Linter first so its fixes get reformatted afterwards
      - id: ruff-check
        args: [--fix]
        files: ^src/
        exclude: ^src/legacy/
      # Formatter last, so it always has the final say
      - id: ruff-format
        files: ^src/
        exclude: ^src/legacy/

Two details worth stating plainly. The ruff-check hook does not fix anything unless you pass --fix; without it, the linter reports and stops, which is a perfectly reasonable choice if you would rather review fixes by hand. And exclude defaults to ^$ — a pattern that matches nothing — so an empty exclude excludes nothing, which trips people up. If you want a directory left alone, name it. This kind of deliberate scoping is the difference between a hook set the team keeps and one they disable within a fortnight; I have written separately about a pre-commit baseline that actually runs, and scoping is half of why it survives contact with a real repository.

The partially-staged stash trap

The last case is the subtlest, and it explains the "I fixed it but it still complains" reports that survive the re-add advice. pre-commit only ever checks the staged content of a file. When a file has both staged and unstaged changes, it temporarily stashes the unstaged portion, runs the hooks against what you are actually committing, then restores the stash. That is correct behaviour — it stops hooks passing on code you have not staged.

The trap: you git add a file, keep editing it, then commit. A formatter reformats the staged version and reports "files were modified", but the fix it wrote never seems to take, because your later unstaged edits are what you see in the editor. The reliable habit is to stage the whole file, commit, and only then continue editing — or, when you deliberately want to commit a subset, expect the hook to judge only that subset and re-add after it fixes. If a restore ever leaves you with a conflict, pre-commit writes the stash to a patch file and tells you the path; apply it with git stash or git apply rather than assuming your work is gone.

The outcome you are after

A hook set that only nags you about your own changes is not an accident of the tool; it is three decisions made on purpose. Decide whether each hook is per-file or project-wide and set pass_filenames to match. Anchor files and exclude so vendored and legacy code is out of scope. Stage cleanly so the stash never surprises you. Get those right and the fast, honest feedback loop is the same discipline that keeps your delivery metrics trustworthy — cheap checks that fire on your work, not noise that trains the team to reach for --no-verify. Because the moment a hook fails on files people did not touch, they stop reading its output and start skipping it, and a hook everyone bypasses is worse than no hook at all.

Free interactive tool

Website compliance checklist

What your site has to do, based on what it actually does

Answer as much or as little as you like — the list builds as you go. Nothing is stored against your name and no email is required.

Most technology problems are not technology problems. They are control problems.

The systems exist. The investment has been made. The question is whether leadership can understand, direct, evidence, and sustain what those systems produce. Find out where control exists — and where it only appears to.