The pre-commit hook: what it checks, what it sees and how to skip it
When it runs
The pre-commit hook is the last thing that happens before git writes a commit object. It runs on git commit, after the index is final and before the commit message is composed, so it is the natural place for checks that must pass for the exact content that is about to be recorded. It takes no arguments.
- A non-zero exit stops the commit. Git prints whatever the hook wrote to standard error and creates nothing: no object, no ref update.
- It also runs on git commit --amend, on the content that the amended commit will have.
- The message does not exist yet. A check on the message text belongs in commit-msg, which runs later and gets the message file as an argument.
- Git sets GIT_EDITOR=: in the environment when no editor will open, so a hook that calls git commit itself does not hang waiting for input.
- It is skippable. git commit --no-verify does not run it. That is a local escape hatch, not a policy: a check that must hold for every commit has to run on the server too.
A first hook
The smallest useful hook refuses a commit that adds trailing whitespace. It is a bash script in .git/hooks/pre-commit:
#!/usr/bin/env bash if git diff --cached --check; then echo "pre-commit: no whitespace errors" else echo "pre-commit: whitespace errors in the staged changes" >&2 exit 1 fi
The file has to be executable. Without the bit git ignores it without a word, which is the most common reason a hook «does not work»:
chmod +x .git/hooks/pre-commit
Now a file with trailing spaces is refused before the commit exists:
printf "line one\nline two \n" > app.txt
git add app.txt
git commit -m "Adds a line with trailing spaces"
app.txt:2: trailing whitespace. +line two pre-commit: whitespace errors in the staged changes
git diff --cached --check is the same check git applies to git diff --check, limited to the staged diff. The exit status is non-zero when it finds anything, and the hook turns that into a refusal.
The index, not the worktree
This is the part that surprises people. The hook is given the index, not the working tree. git diff --cached shows what the hook sees, git diff shows what it does not. A file can be staged in one state and edited into another, and the hook will judge the staged one, because that is what the commit will contain.
Stage a clean line, then add the trailing spaces to the file without staging them:
printf "line one\nline two\n" > app.txt
git add app.txt
printf "line one\nline two \n" > app.txt
The two diffs now disagree: the index has one added line, the worktree has the same line changed again. The commit passes the hook:
pre-commit: no whitespace errors [main 2ada783] Adds a clean line 1 file changed, 1 insertion(+)
And the commit contains the clean line, not the one on disk. Read back from HEAD with cat -A, which marks the end of each line with $:
git show HEAD:app.txt | cat -A
line one$ line two$
The worktree still has the spaces, and git status still shows the file as modified. Nothing was lost and nothing was committed: the hook approved the staged content, and the staged content is what the commit recorded.
The consequence cuts both ways. A check that reads the file from disk can pass while the staged blob is broken, and fail while the staged blob is fine. When the check must describe the commit, it has to read the blob from the index, not the file from the worktree.
Linting what is staged
A hook that runs php -l on every staged PHP file has to solve two problems at once: find only the added or changed files, and read each one from the index. The loop below does both. It asks git for a NUL-separated list, so a path with a space does not split into two files, and writes each staged blob to a temporary file for the linter:
#!/usr/bin/env bash set -euo pipefail status=0 while IFS= read -r -d "" file; do case "$file" in *.php) ;; *) continue ;; esac tmp=$(mktemp) git show ":$file" > "$tmp" if ! php -l "$tmp" > /dev/null; then echo "pre-commit: $file does not parse as staged" >&2 status=1 fi rm -f "$tmp" done < <(git diff --cached --name-only --diff-filter=ACMR -z) exit "$status"
--diff-filter=ACMR keeps added, copied, modified and renamed files and drops deletions, which have nothing to lint. git show ":$file" is the staged version: the colon prefix means the index, not HEAD.
The difference matters. With a broken blob staged and a fixed file on disk, the disk version passes and the commit would carry the break:
printf '<?php\necho "broken"\n' > page.php
git add page.php
printf '<?php\necho "fixed";\n' > page.php
git commit -m "Adds a broken page"
PHP Parse error: syntax error, unexpected end of file, expecting "," or ";" in /tmp/tmp.kTRmjM6Nxu.php on line 3 pre-commit: page.php does not parse as staged
The hook stopped a commit that a php -l page.php would have waved through. After git add page.php the same hook lets the fixed version in.
A hook that stages files
The commit is built from the index after the hook returns, so a hook that runs git add changes the commit that is being made. That is how a formatter commits its own output: the hook rewrites the files, stages them, and the commit contains the formatted version.
It is also how a file nobody meant to commit gets in. This hook writes two files, stages one and leaves the other alone:
#!/usr/bin/env bash printf "added by the hook\n" > generated.txt git add generated.txt printf "written by the hook, never staged\n" > loose.txt exit 0
The commit that follows contains generated.txt although it was never staged by hand, and loose.txt stays untracked:
[main efdf575] Adds a 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 generated.txt ?? loose.txt
A hook that stages files is a hook that decides part of the commit. Keep it to files the hook itself produced, and print what it added, so the person committing is not surprised by a file in git show --stat.
Skipping the hook
git commit --no-verify, or its short form -n, skips pre-commit, pre-merge-commit and commit-msg. It does not skip prepare-commit-msg and it does not skip the post-* hooks, which run after the commit anyway.
The flag exists for the case where the hook is wrong, not for the case where the commit is. Two habits keep it honest: a hook that refuses prints why it refused, and the same check runs in CI on every push. A local hook is a convenience for the author; a server-side check is the rule. The pre-commit framework adds its own per-hook skip, the SKIP environment variable with a comma-separated list of hook ids:
SKIP=php-lint-src git commit -m "Skips one check"
Sharing the hook
.git/hooks is inside .git, which is not part of the repository. A clone brings the samples and nothing else, and a hook that exists only on one machine is a habit, not a rule.
The smallest way to share one is a tracked folder plus core.hooksPath. Put the script in .githooks/pre-commit in the repository, make it executable, and point git at the folder:
git config core.hooksPath .githooks
The setting is local, so every clone still runs that one command; a line in the README or in the setup script is enough. The other option is a framework, which installs the hook files itself.
The pre-commit framework
The pre-commit framework keeps the checks in .pre-commit-config.yaml at the repository root, installs the hook files and runs each tool in its own environment. One command writes .git/hooks/pre-commit:
pre-commit install
The installed file is a small launcher, not the checks themselves. It calls pre-commit with the hook type, and the framework decides which entries of the YAML file apply to the staged files. pre-commit run --all-files runs the same checks without a commit, which is what CI does.
Around every stage the framework does something a plain hook does not: it saves all unstaged changes into ~/.cache/pre-commit/patch<timestamp>-<pid>, runs git checkout -- . so the checks see a clean worktree, and re-applies the patch afterwards. For a hook this means the files on disk during the run are the staged versions, not your edits. For the person committing it means the unstaged work is briefly gone from the worktree, and comes back only if the re-apply succeeds.
That re-apply is the subject of the incident in the workflow article: one file that the post-commit hook rewrote made git apply reject the whole patch, and the unstaged work stayed out of the worktree until it was restored by hand from the patch file.
Checklist
- The hook file has the exact name and the executable bit.
- It reads the staged content: git diff --cached and git show :path, not git diff and the file on disk.
- It prints the reason for a refusal to standard error.
- The same check runs in CI, because --no-verify exists.
- The script is in the repository and core.hooksPath points at it, or the framework installs it.
- Your own hook script never runs git checkout -- . or git stash on a worktree with work in progress; the framework does it around every stage, and the workflow article shows how that fails.
Article author: Arthur Isaev