How can an auto-formatting Git pre-commit hook commit changes the developer never staged?
answer
- Two different copies of the same file
- Commits are built from the index, not disk
- git add -p makes them diverge
- Auto-fix plus git add sweeps the remainder
basics
~20 sA commit is built from Git's index, but a hook reads files from disk. If a file is only partially staged, a formatter rewrites the whole file and re-adds it, sweeping the unstaged hunks into the commit. Isolate unstaged changes first, or make hooks check-only.
solid answer
~40 sGit builds the commit from the **index**, while a hook script sees the **working tree**. Those differ whenever someone stages part of a file with `git add -p`. A formatting hook then reads the full on-disk file — staged and unstaged hunks together — rewrites it, and runs `git add` on it. The commit now contains edits the author deliberately held back, and the carefully split commit is silently wrong. That is why staged-file runners isolate the unstaged remainder before running commands and restore it afterwards, and why the same maneuver by hand uses `git stash push --keep-index` — stash away everything except what is staged, run the checks, then restore. A safer design avoids the problem entirely: have the hook only verify, and let the developer run the formatter themselves.
code
console · 5 lines$ git add -p payment.ts # stage only the bug-fix hunk
$ git diff --cached --stat
payment.ts | 3 +-
$ git diff --stat # still-unstaged experiment
payment.ts | 12 +++++++-----go deeper
Know that staging and your files on disk can differ, and that a commit is made from what you staged. That alone explains why a hook rewriting files on disk can surprise you.
Explain the divergence concretely: interactive staging puts different content in the index and the working tree, a hook reads disk, and re-staging the file pulls in the unstaged part. Name the isolate-then-restore mitigation.
Diagnose it from symptoms — a commit containing work the author held back — and prescribe the fix: check-only hooks by default, a staged-file runner when auto-fixing, never a blanket re-stage of the whole tree.
Set the policy angle: auto-fixing hooks trade a small ergonomics win for a class of silent, trust-destroying commit corruption. Decide whether your team's commit hygiene makes that trade worth it, and where the real verification lives regardless.
## The two views of a file Git has three states for content: the working tree (files on disk), the index (the staged snapshot that `git commit` turns into a tree), and `HEAD` (the last commit). `git add -p` exists precisely so you can stage some hunks of a file and leave the rest — the index copy and the disk copy of that path then legitimately differ. A hook script does not receive the index content. It runs with the working tree checked out and reads files with ordinary file I/O, so it sees the disk copy: staged hunks *plus* unstaged hunks *plus* anything else you were mid-way through editing. ## How the bug happens 1. You edit `payment.ts` in two places: a bug fix and an unrelated experiment. 2. You stage only the fix with `git add -p`. 3. You run `git commit`. The `pre-commit` hook fires. 4. The hook runs a formatter over `payment.ts`. The formatter reads the disk copy — the fix *and* the experiment — normalises whitespace and writes the whole file back. 5. The hook runs `git add payment.ts` so the formatting lands in the commit. 6. That `git add` stages the *entire* file, experiment included. The commit you thought contained one fix now also contains half-finished work. Nothing errored, nothing warned. This is a genuinely nasty failure because it damages exactly the developers doing the careful thing — the ones splitting work into reviewable commits. The same class of bug bites check-only hooks in the other direction: the hook validates disk content and passes, while the committed index content is different and would have failed. Your hook then gives a green answer about code that is not being committed. ## The mitigation staged-file runners implement A staged-file runner such as lint-staged handles this by isolating the working tree before running any command: it puts the unstaged portion aside, leaves only the staged content on disk, runs the configured commands against that, re-stages whatever they modified, and then restores the unstaged portion on top. Because it manipulates real state, an interrupted run is the one situation where you want to know how to recover — which is why these tools keep the removed work retrievable rather than deleting it. By hand, the equivalent trick is: `git stash push --keep-index --include-untracked` `--keep-index` means "stash everything, but leave the index's content on disk", so what remains in the working tree is exactly what is about to be committed. Run the checks, then `git stash pop` to bring the rest back. Expect conflicts on pop if the checks rewrote the same lines the stashed hunks touched — that is the unavoidable cost of the approach, and it is a real reason to prefer check-only hooks. A third option avoids touching the working tree at all: materialise the staged content somewhere else and check that. `git checkout-index --all --prefix=/tmp/staged/` writes the index content into a separate directory, and `git show :path/to/file` prints the staged version of one file to stdout. Both read the index directly, so no stashing is needed — at the cost of tools that expect real project layout and config resolution. ## Design guidance - **Prefer checking to fixing in a hook.** A hook that only reports leaves the developer's staged/unstaged split untouched, and its worst failure is a rejected commit rather than a corrupted one. - **If you do auto-fix, use a runner that isolates unstaged content.** Do not hand-roll `format . && git add -A` in a shell hook; `git add -A` in a hook is the single most common way teams create this bug. - **Never stage paths the developer did not touch.** Re-stage only the specific files the command modified, not the whole tree. - **Remember the escape hatch and the gap.** A developer under pressure can bypass the hook, and someone who never ran the hook manager's install has no hook at all. Whatever the hook fixes, CI must still verify on the pushed commits — the hook is an ergonomics tool, not the guarantee.
- What does git stash push --keep-index do, and why is it the manual version of this fix?It stashes your changes but leaves the index's content on disk, so the working tree ends up containing exactly what is about to be committed. Checks then run against the real commit content. Afterwards `git stash pop` restores the rest — with the caveat that if the checks rewrote the same lines, the pop conflicts, which is the main practical cost.
- How can a check-only hook still give a misleading result on a partially staged file?It reads the disk copy, which includes unstaged edits. So it can pass on content that is not being committed, or fail on an experiment the developer deliberately excluded. Either way the verdict is about the wrong snapshot. Checking the index content — via a staged-file runner, or by materialising the index elsewhere — is what makes the verdict meaningful.
- Why is `git add -A` inside a pre-commit hook a bad idea?It stages the entire working tree, so every unstaged edit, and every previously untracked file, joins the commit. Even without partial staging, that destroys the developer's intended commit contents. If a hook must re-stage after auto-fixing, it should add only the specific paths its commands actually modified.
- Given all this, when is auto-fixing in a hook still worth it?When the fix is purely mechanical and universally agreed — formatting, import ordering, trailing whitespace — and you use a runner that isolates unstaged content properly. The payoff is that formatting never appears in review. If the team splits commits heavily with interactive staging, or the tooling is hand-rolled, check-only is the safer default.
saying these in an interview costs you the question
- Assumes the hook sees the same content that will be committed
- Writes format . && git add -A in a pre-commit hook
- Believes git add -p only affects what the diff shows
- Says stashing is unnecessary because the formatter is deterministic
- Thinks the developer would obviously notice the extra changes