Enforcing commit message policy in pre-receive Jump to heading
Client-side message checks are suggestions: --no-verify skips them, a new laptop has none installed, and some tools create commits without running hooks at all. When a message convention matters — release notes generated from commits, issue trackers linked by keys, audits that read history — the only place it can be enforced is the server. A pre-receive hook sees every commit a push introduces before any ref moves, so it can reject a push whose messages break the rules and tell the pusher exactly which commits to fix. This page builds that hook with the details that make it usable: checking only new commits, exempting merges and bots, and producing output people can act on, within server-side hook enforcement.
When to use this approach Jump to heading
- Your message convention feeds automation — changelogs, release notes, issue linking.
- Client-side checks exist, as in checking commit messages before push, but bad messages still arrive.
- You run a self-hosted server or a forge that supports server hooks.
- On hosted forges without custom hooks, use a required CI check instead; the rules are the same.
Step 1 — List the commits the push introduces Jump to heading
As with any pre-receive check, read every ref update from standard input and collect commits not already reachable from any ref on the server. That avoids rechecking old history and handles new branches correctly.
#!/bin/sh
# hooks/pre-receive — commit message policy
set -eu
zero=$(git hash-object --stdin </dev/null | tr '0-9a-f' '0')
new_commits=$(
while read -r old new ref; do
[ "$new" = "$zero" ] && continue
case "$ref" in refs/heads/*) git rev-list "$new" --not --all ;; esac
done | sort -u
)
[ -n "$new_commits" ] || exit 0 Tag pushes are skipped here; tag messages, if they matter, need their own rule.
Step 2 — Apply the rules and collect every failure Jump to heading
Check each commit, skip merge commits, and gather all failures so the pusher sees everything in one attempt.
fail=$(mktemp); trap 'rm -f "$fail"' EXIT
for c in $new_commits; do
[ "$(git rev-list --parents -n1 "$c" | wc -w)" -gt 2 ] && continue
subject=$(git log -1 --format=%s "$c")
body=$(git log -1 --format=%B "$c")
author=$(git log -1 --format=%ae "$c")
case "$author" in *[email protected]|*'[bot]'@*) continue ;; esac
printf '%s\n' "$subject" | grep -Eq '^(feat|fix|chore|docs|refactor|test|perf|build|ci|revert)(\([a-z0-9-]+\))?!?: .+' \
|| echo "$(git log -1 --format=%h "$c") not conventional: $subject" >> "$fail"
[ "${#subject}" -le 72 ] || echo "$(git log -1 --format=%h "$c") subject over 72 characters" >> "$fail"
printf '%s\n' "$body" | grep -Eq '\b[A-Z][A-Z0-9]+-[0-9]+\b' \
|| echo "$(git log -1 --format=%h "$c") no issue key: $subject" >> "$fail"
done Step 3 — Reject with output people can act on Jump to heading
The text a pre-receive hook prints appears in the pusher’s terminal prefixed with remote:. Make it short, list each commit, and give the fix.
if [ -s "$fail" ]; then
echo "──────────────────────────────────────────────"
echo " Push rejected: commit message policy"
echo "──────────────────────────────────────────────"
sed 's/^/ /' "$fail"
echo
echo " Format: type(scope): summary + an issue key such as PAY-812"
echo " Fix: git rebase -i <base> (mark the commits above 'reword')"
exit 1
fi
exit 0 # Verification: an offending push shows the list in the client terminal
git commit --allow-empty -m "updated stuff" && git push scratch HEAD:refs/heads/msg-test 2>&1 | grep remote: Step 4 — Decide exemptions deliberately Jump to heading
Every exemption is a hole, so keep the list short and explicit. Merge commits are exempt because Git writes their messages. Bot commits may be exempt if bots use their own fixed formats. Reverts generated by git revert start with Revert ", which the format rule above accepts through the revert type only if people reword them — decide which you want.
Step 5 — Roll it out in warn mode first Jump to heading
Turning on rejection for an active team without warning will block work. Ship the hook printing failures but exiting zero for a week or two, publish the list of offenders, then switch it to enforcing.
mode=$(git config --get hooks.messagePolicy || echo warn)
if [ -s "$fail" ]; then
...print as above...
[ "$mode" = enforce ] && exit 1
echo " (warning only — enforcement starts $(git config --get hooks.messagePolicyFrom))"
fi git config hooks.messagePolicy enforce # on the server, when ready The general pattern is the same as rolling out signature enforcement in warn-only mode.
Step 6 — Keep the rules identical everywhere Jump to heading
The server hook, the client hooks and CI should apply the same rules. Keep them in one script, vendored into the server’s hook directory and the repository, so a rule change cannot leave the three disagreeing.
Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Will this reject old commits when someone pushes a long-lived branch? Jump to heading
Only commits the server has never seen. A branch with months of unpushed commits will be checked in full on its first push, which is correct; the pusher can reword them with an interactive rebase before pushing again.
What about commits that arrive through the forge’s merge button? Jump to heading
Forge-created merge and squash commits are made on the server and may not pass through custom hooks at all, depending on the forge. Check the default squash message format in the forge settings, as described in writing a good squash commit message.
How long can a pre-receive hook take? Jump to heading
Pushers wait for it, so keep it to a second or two. Message checks are fast; avoid anything that makes network calls, such as looking up issue keys in a tracker, unless it is cached.
Related Jump to heading
- Server-Side Hook Enforcement — the parent topic.
- Enforcing Issue Keys in Commit Messages — the client-side key check.
- Mirroring Local Hook Checks in Server-Side Policy — keeping client and server in step.
- Writing an update Hook for Per-Branch Policy — per-ref rules alongside this one.