Hooks are executable files that Git runs at specific moments. A non-zero exit aborts the operation. That is the entire mechanism — and the interesting question is not how to write one, but which layer is actually enforcing your rules.
Topic 1: Where Hooks Fire
Client-side, in .git/hooks/:
| Hook | Fires | Typical use |
|---|---|---|
pre-commit | Before the commit is created | Lint, format, secret scan, reject large files |
prepare-commit-msg | Before the editor opens | Insert a template or a ticket ID from the branch name |
commit-msg | After the message is written | Enforce a format |
post-commit | After the commit | Notifications; cannot abort anything |
pre-rebase | Before a rebase | Refuse to rebase a protected branch |
pre-push | Before objects are sent | Run the fast test suite |
post-checkout / post-merge | After the working tree changes | Reinstall dependencies when the lockfile changed |
Server-side, on the remote:
| Hook | Fires | Typical use |
|---|---|---|
pre-receive | Once, before any ref is updated | Reject the whole push — policy, secret scan, signature checks |
update | Once per ref | Reject one branch’s update |
post-receive | After all refs are updated | Trigger CI, notify chat, deploy |
Server hooks cannot be skipped by the person pushing, which is what makes them a control rather than a suggestion. On a hosted forge you do not get to write them directly — branch protection rules, required checks, and push protection are the managed equivalents.
Topic 2: Client Hooks Are Not Enforcement
Two facts that decide how you should use them:
.git/hooksis never cloned. Hooks are not part of the repository’s content, so a fresh clone has none of yours.--no-verifyskips every client hook.git commit --no-verify,git push --no-verify. There is no configuration that prevents this, because the hook runs on the developer’s own machine and they own it.
So a client hook is fast feedback, and that is genuinely valuable: catching a lint error in two seconds beats catching it in a six-minute CI run. It is not a control. Any rule that actually matters must also exist in CI or in a server-side check, running the identical command.
The pattern that follows: write the check once as a script, call it from both the hook and the pipeline.
Topic 3: Sharing Hooks
mkdir .githooks
git config core.hooksPath .githooks
core.hooksPath points Git at a directory you can commit. Every developer still has to run the git config line once — put it in your make setup or bootstrap script, or in a post-checkout hook that only needs to run once.
A usable pre-commit:
#!/bin/sh
# .githooks/pre-commit
# Reject large files before they enter history forever
for f in $(git diff --cached --name-only --diff-filter=ACM); do
[ -f "$f" ] || continue
size=$(wc -c < "$f")
if [ "$size" -gt 1048576 ]; then
echo "error: $f is $((size / 1024))KB. Use Git LFS or add it to .gitignore." >&2
exit 1
fi
done
# Format only what is staged
staged_go=$(git diff --cached --name-only --diff-filter=ACM | grep '\.go$')
if [ -n "$staged_go" ]; then
gofmt -l $staged_go | grep . && {
echo "error: run gofmt on the files above" >&2
exit 1
}
fi
And a commit-msg:
#!/bin/sh
# .githooks/commit-msg
subject=$(head -1 "$1")
# Ignore merge and fixup commits — Git generates those
case "$subject" in
Merge*|Revert*|fixup!*|squash!*) exit 0 ;;
esac
if [ ${#subject} -gt 72 ]; then
echo "error: subject is ${#subject} characters; keep it under 72." >&2
exit 1
fi
if ! echo "$subject" | grep -qE '^(feat|fix|docs|chore|refactor|test|perf)(\(.+\))?!?: .+'; then
echo "error: use conventional commits, e.g. 'fix(auth): reject expired tokens'" >&2
exit 1
fi
Both must be executable (chmod +x), and both should exit fast — a hook that adds four seconds to every commit gets bypassed within a week, which is worse than not having it.
The pre-commit framework manages this properly for teams: hooks are declared in a committed YAML file, installed with one command, versioned, and cached. It is worth adopting the moment more than one hook exists.
Topic 4: What to Automate, and What Not To
Worth a hook:
- Secret scanning — catching a credential before it is committed is enormously cheaper than after.
- Rejecting large binaries — the alternative is a history rewrite.
- Formatting on staged files only.
- Commit message format, if you generate changelogs from it.
- A
pre-pushthat runs the fast tests, when the suite is genuinely fast.
Not worth a hook:
- The full test suite on
pre-commit. People commit constantly; a slow hook trains everyone to use--no-verify, which disables your secret scan too. - Anything requiring network access, which turns an offline commit into a failure.
- Anything with a false-positive rate. A hook that blocks legitimate work loses its authority immediately.
The rule: if it takes more than a couple of seconds, it belongs in pre-push or in CI, not in pre-commit.
Topic 5: Hooks Versus the Forge
On GitHub, GitLab or Bitbucket you cannot install a pre-receive hook. The managed equivalents do the same jobs:
| Goal | Self-hosted hook | Hosted forge feature |
|---|---|---|
| Reject force-push to main | pre-receive | Branch protection |
| Require review | pre-receive | Required approvals + CODEOWNERS |
| Require green CI | pre-receive | Required status checks |
| Block secrets | pre-receive scan | Push protection / secret scanning |
| Require signed commits | pre-receive verify | ”Require signed commits” setting |
| Enforce commit format | commit-msg + CI | A CI job (the reliable layer) |
| Trigger a build | post-receive | Webhooks / Actions / Pipelines |
Configure these once per repository, or once per organisation with a template — and prefer the organisation-level setting, because a per-repository control is only as good as whoever created the last repository.
Topic 6: Automation Beyond Hooks
Two Git features that behave like automation and are not hooks:
.gitattributes merge drivers — decide how specific files merge, which removes recurring manual conflicts:
CHANGELOG.md merge=union # keep both sides' lines
*.generated.go -diff -merge # never diff or merge; regenerate instead
git config alias with shell escapes — small, sharp, repo-agnostic commands:
git config --global alias.cleanup \
'!git branch --merged main | grep -v "^\*\|main" | xargs -r git branch -d'
git config --global alias.recent \
'branch --sort=-committerdate --format="%(committerdate:short) %(refname:short)"'
And the maintenance job that keeps a large repository responsive without anyone thinking about it:
git maintenance start # schedules gc, commit-graph, prefetch in the background
That single command replaces the folklore of running git gc manually, and on a large repository it measurably improves log and status times by keeping the commit-graph current.
Try it yourself: write a pre-commit that rejects a staged file over 1 MB, confirm it blocks the commit, then commit the same file with --no-verify. Watching your own control get bypassed in one flag is the fastest way to internalise where enforcement actually has to live.
Common mistake: treating client-side hooks as a security or compliance control and reporting them as such. They run on machines you do not control, they are skippable with a documented flag, and a fresh clone has none of them. They are excellent developer experience and they are not evidence of anything — the CI job running the same check is what you can actually point an auditor at.