In Git, what happens when a client-side hook such as pre-commit exits with a non-zero status?
answer
- It is just a program Git runs
- Git only reads one thing from it
- Zero versus non-zero
- Names starting with post- are too late
- Rename the .sample file, chmod +x
basics
~10 sA non-zero exit from a pre-operation hook such as pre-commit, commit-msg or pre-push aborts the operation — no commit is created, no push is sent. Post-hooks like post-commit run afterwards and cannot abort anything.
solid answer
~40 sGit hooks are ordinary executables in the repository's hook directory (`.git/hooks` by default), named exactly after the hook point — `pre-commit`, `commit-msg`, `pre-push` — with no file extension and the executable bit set. Git runs the matching file at the right moment and reads its **exit status**: zero means proceed, non-zero means abort. So a failing `pre-commit` stops `git commit` before the message editor even opens, and a failing `pre-push` makes `git push` exit without contacting the remote at all. Anything the hook writes to stderr is shown to the developer, which is how a hook explains *why* it rejected the operation. Hooks whose names start with `post-` (`post-commit`, `post-merge`, `post-checkout`) run after the work is done and cannot undo it. Git ships `.sample` versions of the hooks, which are inert until renamed.
code
bash · 7 lines#!/bin/sh
# .git/hooks/pre-commit (rename from pre-commit.sample, then chmod +x)
if git diff --cached --name-only --diff-filter=ACM | grep -q '^\.env$'; then
echo "pre-commit: refusing to commit .env" >&2
exit 1
fi
exit 0go deeper
Recall that a hook is an executable file in .git/hooks named after the event, and that exiting non-zero cancels the command. Be able to name pre-commit and commit-msg.
Explain the exit-code contract precisely, which hooks run before versus after the state change, and why the samples are inert until renamed and made executable.
Show the production judgment: hooks must be fast, must inspect the staged index rather than the working tree, and must print an actionable reason to stderr instead of failing silently.
Frame hooks as developer feedback with no enforcement guarantee, and argue for where the authoritative check belongs so that a fast local check and a binding remote check do not drift apart.
## What a hook is A Git hook is not a plugin API or a config setting — it is just a program that Git executes at a fixed point in a command's lifecycle. Git looks in the repository's hook directory (by default `.git/hooks` inside a normal clone) for a file whose name is exactly the hook point, runs it, and inspects the process exit status. Any language works as long as the file is executable and, on Unix, starts with a shebang line such as `#!/bin/sh` or `#!/usr/bin/env python3`. ## Why the samples don't fire A fresh repository already contains files like `pre-commit.sample` and `commit-msg.sample`, copied from Git's template directory when the repository was created. They are deliberately inert: Git looks for `pre-commit`, not `pre-commit.sample`, so nothing happens until you rename the file and make sure it is executable (`chmod +x`). "I wrote the hook and nothing ran" is nearly always one of three things: wrong filename, missing executable bit, or a bad shebang. ## The exit-code contract The contract is deliberately primitive. - **Exit 0** — the hook approves; Git continues. - **Any non-zero exit** — for a hook that runs *before* the operation, Git aborts. `git commit` creates no commit object, `git push` transfers nothing. That is the whole protocol. There is no rich error object, no severity level, no "warn but continue". If a hook wants to warn without blocking, it prints a message and exits 0. ## Which hooks can actually abort The pre-operation hooks can veto: `pre-commit` (before the message is even collected), `prepare-commit-msg` (before the editor opens), `commit-msg` (after the message is written), and `pre-push` (before any objects go over the wire). The notification hooks cannot: `post-commit`, `post-merge` and `post-checkout` all run after the state change is complete, and Git documents that they cannot affect the outcome. `post-checkout` has one wrinkle — its exit status becomes the exit status of `git checkout`/`git switch`, so a failing hook makes the command *report* failure even though the checkout already happened. ## Talking to the developer Standard output and standard error from a client-side hook go to the terminal, so the human sees them. Practically that means: print the reason for rejection to stderr, keep it to a couple of lines, and name the file or rule that failed. A hook that exits 1 silently produces a mystifying `git commit` that appears to do nothing. ## Hooks run in the repository Git invokes the hook with the working tree as the current directory (for a normal, non-bare repository) and sets Git environment variables such as `GIT_DIR`. A subtlety that bites `pre-commit` hooks: the commit is built from the **index**, not from the working tree, so linting the files on disk can approve content that is not what will be committed. The careful pattern is to inspect staged content, for example `git diff --cached --name-only --diff-filter=ACM`. ## Bypassing `git commit --no-verify` skips `pre-commit` and `commit-msg`; `git push --no-verify` skips `pre-push`. Because the hook is a plain file inside `.git`, a developer can also just delete it or remove its executable bit. That is why client hooks are a fast feedback mechanism, not a guarantee. ## Performance Every pre-operation hook sits directly in the developer's inner loop. A `pre-commit` hook that runs the whole test suite turns a one-second commit into a coffee break and trains the team to type `--no-verify` reflexively. Keep client hooks to sub-second checks on the staged files and let slower verification happen elsewhere.
- Why does a pre-commit hook that lints the files on disk sometimes approve a commit that is actually broken?Because the commit is built from the index, not the working tree. If a file is partially staged, or edited after staging, the on-disk version differs from what will be recorded. A correct hook inspects staged content — for example `git diff --cached --name-only` plus `git show :<path>` — rather than reading the working tree directly.
- A colleague swears their new pre-commit hook never runs. What do you check first?Three things, in order: the filename is exactly `pre-commit` with no `.sample` suffix, the file is executable, and the shebang points at an interpreter that exists. Also confirm they are committing in the repository that holds the hook — hooks live per repository, and a submodule or a linked worktree has its own hook resolution.
- Should a hook ever exit non-zero just to warn about style?No. Non-zero is a veto, and a veto for a cosmetic issue teaches the team to pass `--no-verify` habitually, which disables the checks that actually matter. Print the warning and exit 0, or fix the issue automatically and exit 0.
Think of a pre-operation hook as a bouncer at the door: the only thing the club hears from them is yes or no, and once you are inside, the bouncer at the exit (post-commit) can complain but cannot put you back outside.
saying these in an interview costs you the question
- Thinking hooks are enforced by the server or by clone
- Believing post-commit can undo or reject a commit
- Assuming .sample files are active out of the box
- Saying the hook returns a message Git parses, not an exit code
- Linting the working tree instead of the staged index