git hook
a script git runs when something happens
What it is
A git hook is a program that git itself starts at a fixed point in its own work:
before a commit is recorded, once a commit message has been written, before a
push leaves the machine, or when a server receives one. Each hook is a file
named after its moment, such as pre-commit, commit-msg or pre-push, with
no extension. By default git looks for them in .git/hooks inside the
repository, and the core.hooksPath setting points it elsewhere. The file has
to be executable, since git ignores one without the executable bit, but it can
be written in any language the machine runs.
Client-side hooks run in a developer’s own clone and fire on operations such as committing and merging. Server-side hooks run on the machine that receives pushes, such as a forge, and fire when pushed commits arrive.
What turns a hook from a notification into a guard is its exit status. The
pre-commit hook takes no arguments, and a non-zero exit aborts the commit. The
commit-msg hook receives the path of a file holding the proposed message and
may reject the message or edit it. The pre-push hook gets the remote’s name
and location as arguments and one line per reference on standard input, and a
non-zero exit stops the push before anything is sent. On the server,
pre-receive runs once per push, reads the old object name, new object name and
reference name for each reference, and if it exits non-zero, none of the
references are updated. Hooks that run after the event, such as post-commit,
are meant for notification and cannot change its outcome.
When git init creates a repository, it fills the hooks directory with example
scripts whose names end in .sample. git runs one only after it has been
renamed without the suffix.
Why it matters
A hook turns a check that someone has to remember into one that happens on its own, at the cheapest moment. A formatter or a secret scanner that runs as the commit is made catches a problem while the change is still in its author’s head, rather than in review days later or in a CI run after the work has been shared.
Hooks are also how a rule becomes a property of a repository rather than of
whoever happens to be committing. A convention in a contributing guide holds
only as long as people read the guide; the same convention in a commit-msg
hook applies to every commit.
The two groups differ in how much they can promise. The Pro Git book points out
that cloning a repository does not copy its client-side hooks, and recommends
enforcing policy on the server side. A server-side hook such as pre-receive
applies to every push from everyone, whatever they have installed locally. The
cost is time: a hook runs on every commit or every push, and a check that makes
committing slow invites people to skip it.
What it looks like in practice
This site’s own repository installs its hooks through the pre-commit framework,
configured in .pre-commit-config.yaml and installed by task setup, which
runs pre-commit install --install-hooks. The file installs three git hooks. At
the pre-commit stage it runs Prettier, markdownlint, stylelint, yamllint,
codespell, Ruff’s lint and format checks, a gitleaks secret scan, an emoji check
and a tone check. At the commit-msg stage it runs
one script that holds messages to a conventional, emoji-free form. At the
pre-push stage it runs the whole gate, task check, which a comment in the file
puts at about six minutes, worth paying because a push is rare. The file’s
header says never to bypass the hooks with --no-verify, while the comment on
the pre-push hook keeps --no-verify as the escape for the case that genuinely
cannot wait.
A commit-message hook also holds four repositories to one history standard: a conventional prefix and scope, a subject under a fixed length, no emojis and no attribution trailers. The hook rejects anything that does not conform in every repository that carries work, so the standard belongs to the repository rather than to whoever commits.
A server-side hook for the company’s own forge was rehearsed before any real push reached it. Such a hook executes in a bare repository with no working tree, under an environment the forge sets, reading pushed references from standard input, so running it by hand on a laptop proves little. The rehearsal creates a bare repository, installs the hook, clones, commits, pushes and asserts on both the accept and the reject path. It found two defects that were invisible in the file, both described below.
On one project, the commit hook and the quality gate turned out to run different checks, although a document said the hook ran the gate. A test now resolves both to the checks they actually invoke and fails when the sets diverge. The five that had run only in the gate were the ones reading the CV prose, so the code was guarded on every commit and the writing only when someone remembered.
A client-side hook does not need a framework at all. This one, saved as
.git/hooks/commit-msg and made executable, rejects a commit whose subject does
not start with a conventional type:
#!/bin/sh
# git passes the path of the proposed commit message as $1
pattern='^(feat|fix|docs|chore)(\([a-z0-9-]+\))?: .+'
if head -n 1 "$1" | grep -Eq "$pattern"; then
exit 0
fi
echo "commit-msg: start the subject with a type, as in 'fix(nav): ...'" >&2
exit 1
Where it goes wrong
- A fresh clone arrives without the hooks. Each clone has to install them,
which is the job
task setupdoes here. A comment in this site’sTaskfile.ymlspells out the quiet version of the failure: withoutpre-commiton the machine,task setupinstalls nothing, every hook silently does not run, and the result looks exactly like a repository that is passing. --no-verifywalks straight past them.git commit --no-verifybypasses thepre-commitandcommit-msghooks,git push --no-verifybypassespre-push, and settingcore.hooksPathto/dev/nullturns off every hook at once. A rule that must hold belongs in a server-side hook as well.- git’s environment follows the hook into other repositories. git exports
variables such as
GIT_DIRso that git commands in a hook find the right repository, and githooks(5) advises clearing them when a hook works in another one. The forge rehearsal met exactly this: the gate checked out the pushed revision and ran the tests inside it, the tests inherited git’s pointer to the bare repository, and nineteen of them went red on correct code. - Standard input carries the edge cases. The rehearsal’s other defect was a
fallback that, with no reference to work with, substituted a placeholder git
cannot resolve; because the value arrives on standard input, the failure
surfaced as an unrelated error much further down. The page also notes that the
forge will see forced updates and branch deletions, which the rehearsal does
not construct. In githooks(5), a deleted reference reaches
pre-pushas(delete)with an all-zeroes object name. - A slow hook gets skipped. Bringing the five prose checks into the commit hook meant making two of them fast enough for a pre-commit budget, and that page is plain that runtime grows with the content and that there is a point at which a slow hook gets bypassed.
- The format passes and the meaning goes unchecked. As the page on the four repositories puts it, the format is checked and the content is not, so a conforming subject line that describes nothing passes exactly as well as one that explains the change.
Further reading
- githooks documentation — every hook git supports, when it runs, what it receives and what its exit status decides.
- Pro Git: Git Hooks — client-side and server-side hooks, how to install one, and why a clone leaves them behind.
- Pro Git: An Example Git-Enforced Policy
— a server-side
updatehook that enforces a message format and access rules, with client-side hooks that warn before a push. - git config — the
core.hooksPathsetting, for moving hooks out of.git/hooksor switching them all off. - git commit — the
--no-verifyoption and the two hooks it bypasses. - git push — the
pre-pushhook and the--no-verifyoption that skips it.