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 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

Article author: Arthur Isaev