Automating Git with Hooks
Make the repository catch its own mistakes. Every hook worth knowing, two complete scripts you can paste, core.hooksPath so they travel with a clone, hook managers, and why hooks are not enforcement.
Make the repository catch its own mistakes. Every hook worth knowing, two complete scripts you can paste, core.hooksPath so they travel with a clone, hook managers, and why hooks are not enforcement.
Every team collects the same small embarrassments: the commit message that reads "stuff", the API key that slipped into history, the branch that broke the build at four on a Friday. None of these are hard problems. They are attention problems, and attention is the one resource you cannot buy more of.
Git can hold some of that attention for you. By the end of this lesson your repository will check its own commit messages, run your tests before a push, and hand those checks to everyone who clones it — and you will know where that kind of automation stops being trustworthy.
A hook is an executable file that Git runs at a fixed moment in an operation. There is no plugin registry and no configuration to write: Git looks in a hooks directory, finds a file named after the moment, and runs it. The filename is the wiring.
That directory is .git/hooks, and every repository has one. Run
ls .git/hooks and you get a dozen files with names like
pre-commit.sample and commit-msg.sample, copied in from Git's
template directory when you ran git init or git clone.
The .sample suffix is what keeps them inert: Git looks for a
file named exactly pre-commit, so pre-commit.sample never
fires. Drop the suffix, make the file executable, and it goes
live on the next commit.
Now the limitation that shapes everything else here:
.git/hooks is not part of your repository. It is not
tracked, not committed, not pushed, not copied by git clone. A
hook you write this afternoon protects exactly one clone on one
machine; your teammates get the samples and nothing more. Every
practice below is a way around that fact.
Git ships around twenty client-side hooks; seven cover almost everything a team wants. What matters is whether a hook runs before the thing it watches or after: one that runs before can abort the operation by exiting non-zero, and one that runs after is a notification with no veto.
These four can say no:
pre-commit — no arguments, no stdin. It fires before Git
opens your editor, so before you have written a word of the
message. Inspect the staged snapshot here. A non-zero exit
cancels the commit.prepare-commit-msg — receives the message file as $1,
the message's source as $2 (message, template, merge,
squash, or commit), and for squash/commit a SHA as
$3. It runs before the editor opens, so this is where you
pre-fill a ticket number taken from the branch name. A non-zero
exit aborts the commit.commit-msg — receives the file holding the final message
as $1, after the editor closes. Your last chance to reject a
message or rewrite it in place. A non-zero exit cancels the
commit.pre-push — receives the remote name as $1 and its URL
as $2, plus one line per pushed ref on standard input. A
non-zero exit cancels the push before a byte leaves your
machine, which makes it the right home for slow checks.These three only report:
post-commit — no arguments. The commit already exists, so
the exit status is ignored. Notifications only.post-checkout — receives the previous HEAD, the new HEAD,
and a flag that is 1 for a branch switch and 0 for a file
checkout. It cannot stop the switch, though its exit status
becomes that of git switch. Good for "the lockfile changed,
reinstall dependencies".post-merge — receives 1 for a squash merge and 0
otherwise, and does not run when the merge stopped with
conflicts. The same job, on the pull side.Conventional Commits is a message format — type(scope): description — that lets tooling build changelogs and pick
version numbers without reading every subject line. It only works
if everyone follows it, and nobody follows a format from memory
late in the day. Let commit-msg, the hook that sees the
finished message, remember for you:
#!/usr/bin/env bash
# commit-msg — reject subjects that break Conventional Commits.
# Git passes the path of the message file as $1.
set -euo pipefail
# Find the first real line: skip comments and blank lines. The
# "|| [ -n "$line" ]" tail catches a file with no final newline.
subject=""
while IFS= read -r line || [ -n "$line" ]; do
if [[ "$line" == \#* || -z "${line//[[:space:]]/}" ]]; then
continue
fi
subject="$line"
break
done < "$1"
# Git writes its own merge and revert subjects. Let them pass.
if [[ "$subject" == Merge* || "$subject" == Revert* ]]; then
exit 0
fi
types='feat|fix|docs|style|refactor|perf|test|build|ci|chore'
# Optional (scope), optional ! for breaking, then a 1-72 char
# description. The pattern must stay unquoted inside =~.
pattern="^(${types}|revert)(\([a-z0-9._/-]+\))?!?: .{1,72}$"
if [[ ! "$subject" =~ $pattern ]]; then
cat >&2 <<MSG
Rejected: ${subject:-(empty message)}
Expected: <type>(<scope>): <description>
type is one of ${types}|revert
MSG
exit 1
fiReading the file with < "$1" rather than piping through sed | head matters: under set -o pipefail, head closing the pipe
early can hand sed a broken-pipe status and fail the hook,
rejecting a good commit for reasons nobody can see.
A rejected commit is not a lost message. Git leaves your text in
.git/COMMIT_EDITMSG, so git commit -e -F .git/COMMIT_EDITMSG reopens the editor with the body intact.
Running the test suite on every commit sounds responsible and makes committing miserable. Every push is the better trade: a few times a day rather than a few times an hour, and the last moment before your work becomes someone else's problem.
pre-push has one trap in it. Git feeds the hook a line per ref
on standard input, and for a ref you are deleting the local
object ID is all zeroes — so a naive hook runs the whole suite
for git push --delete old-branch.
#!/usr/bin/env bash
# pre-push — refuse to push when the test suite fails.
# argv: $1 = remote name, $2 = remote URL
# stdin: <local ref> <local oid> <remote ref> <remote oid>
set -euo pipefail
# An all-zero local object ID means "delete this ref". Derive it
# rather than typing 40 zeroes, so SHA-256 repos work too.
zero=$(git hash-object --stdin </dev/null | tr '[0-9a-f]' '0')
# Drain stdin before launching anything else: a test runner that
# inherits leftover ref lines behaves very oddly.
pushing_content=0
while read -r _local_ref local_oid _remote_ref _remote_oid; do
if [ "$local_oid" != "$zero" ]; then
pushing_content=1
fi
done
# Only deletions in this push: nothing to test, let it through.
[ "$pushing_content" -eq 1 ] || exit 0
echo "pre-push: running the test suite..."
# </dev/null so an interactive runner cannot stall the push.
if ! npm test --silent </dev/null; then
echo "pre-push: tests failed, push aborted." >&2
exit 1
fiSwap npm test for pytest -q or cargo test, and keep the
</dev/null — a runner that decides to prompt would otherwise
hang your terminal with no explanation.
Both hooks so far guard one clone. The fix is one config key:
core.hooksPath points Git at a hooks directory of your
choosing — including one you can commit.
mkdir .githooks
mv .git/hooks/commit-msg .git/hooks/pre-push .githooks/
chmod +x .githooks/*
git add .githooks
git commit -m "chore: add shared Git hooks"
git config core.hooksPath .githooks # each clone runs this onceYour checks now live in the repository. They get reviewed in pull requests, they change alongside the code they guard, and a new teammate receives them with the clone rather than as a README paragraph asking for a copy.
That last line stays manual on purpose: if a clone could
configure itself to run scripts, cloning an untrusted repository
would be remote code execution, so Git never reads
core.hooksPath from tracked files. Put it in your bootstrap
script, beside npm install.
Hand-written hooks stay pleasant up to about two checks. Past
that you want things a script does badly: pinned tool versions,
one hook file six people can edit, and — the sharp one — a
linter that sees only staged files. A pre-commit script that
lints the working tree passes on a broken file you left
half-fixed and fails on one you kept out of the commit
deliberately. Getting that right by hand means stashing, running
the checks, and restoring the stash even when they blow up.
Managers exist largely to do that dance for you.
pre-commit is a Python tool that manages hooks for any language. You describe the checks; it installs each in an isolated environment, pins it by revision, and runs it against staged files only.
# .pre-commit-config.yaml
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: detect-private-key # catch keys before they land
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.12.0
hooks:
- id: ruff
args: [--fix]Run pip install pre-commit, then pre-commit install per
clone. Reach for it when your checks span several languages, or
when you want those same pinned versions in CI, where
pre-commit run --all-files is a one-line workflow step.
For JavaScript projects, Husky plus lint-staged is the
equivalent, and the better pick when your team already lives in
package.json. Run npx husky init: it creates .husky/,
points core.hooksPath at it, and adds a prepare script so
npm install wires up each new clone. Put npx lint-staged in
.husky/pre-commit and configure the checks:
{
"lint-staged": {
"*.{js,ts,tsx}": ["eslint --fix", "prettier --write"]
}
}Whichever you pick, keep commit-time checks fast. A hook that
takes ten seconds trains people to reach for --no-verify, and a
bypassed hook protects nobody: formatters and linters over staged
files at commit time, tests at pre-push, the full suite in CI.
Every hook in this lesson is one flag away from being skipped:
git commit --no-verify -m "wip: mid-debug, tidying later"
git push --no-verifyOn git commit, --no-verify bypasses pre-commit and
commit-msg (prepare-commit-msg still runs, since it builds
the message rather than judging it). On git push it bypasses
pre-push.
That escape hatch is a feature — there are honest reasons to skip a check. The problem is expecting hooks to do a job they were never built for.
A fast answer. Politeness only.
Lives in a directory its owner can edit or delete, and
--no-verify skips it outright.
Its value is telling you in two seconds rather than in eight minutes of CI.
The answer that counts.
Runs on the server, on every pull request, and required status checks refuse the merge until it passes.
Nobody can pass a flag to skip this one.
Git's unbypassable hooks run on the server receiving the push.
pre-receive gets every ref of a push on stdin and can
reject the whole thing; update does the same one ref at a
time; post-receive runs after the refs have moved, driving
deploys and notifications. You can use these on a server you
administer yourself; on GitHub you cannot install them at all,
and Actions plus rulesets cover the same ground instead.
The characteristic failure of hooks is silence: your commit succeeds, the check never ran, and Git says nothing. Three causes account for nearly every case.
The executable bit. Git runs the file rather than sourcing
it, so a hook without +x is skipped without a word. Check with
ls -l .githooks/ and fix with chmod +x. If the file was
already committed without the bit, record the change in Git as
well — git update-index --chmod=+x .githooks/commit-msg — or
the next clone inherits the same broken permission.
The shebang. No shebang, a wrong interpreter path, or — the
afternoon-waster — Windows CRLF line endings, which turn
#!/usr/bin/env bash into a request for an interpreter named
bash\r. Run the hook by hand and the shell prints the error
Git swallowed:
.githooks/commit-msg .git/COMMIT_EDITMSG # should print nothingThe wrong directory. If core.hooksPath is set, .git/hooks
is dead to Git. To watch Git decide in real time, turn on trace
output:
GIT_TRACE=1 git commit -m "test: check the hook fires"The trace names each hook Git looks for and each one it runs. A hook that never appears is a naming or path problem; one that appears and does nothing is a bug in your script.
ls .git/hooks # the shipped .sample files
chmod +x .githooks/* # hooks must be executable
git update-index --chmod=+x <file> # record the bit in Git
git config core.hooksPath .githooks # use committed hooks
git config --get core.hooksPath # confirm which dir is live
git commit --no-verify -m "msg" # skip pre-commit/commit-msg
git push --no-verify # skip pre-push
git commit -e -F .git/COMMIT_EDITMSG # repair a rejected message
GIT_TRACE=1 git commit -m "msg" # see which hooks Git runs
pre-commit install # wire up the framework
pre-commit run --all-files # same checks, whole repo
npx husky init # the equivalent for JSYou can now make a repository check its own work: a commit-msg
hook holding the line on message format, a pre-push hook that
refuses to ship failing tests, a committed .githooks directory
so the team gets both, and a clear view of where hooks end and CI
begins.
Hooks automate actions. The next lesson,
".gitattributes and config that pays for itself", automates
treatment — how Git diffs, merges, and stores particular paths,
plus the settings that quietly remove whole categories of daily
annoyance. Better still, .gitattributes is a tracked file, so
unlike .git/hooks those rules travel on their own.
Before that, put this to work. Add a .githooks directory to the
project you care most about, start with one hook rather than six,
and watch which mistakes stop reaching your teammates. Automation
you keep beats automation you admire.