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
From a push to a message verdictThe hook reads every ref update, collects commits the server does not already have, skips merges and bot commits, applies the format, length and issue-key rules to each, and rejects the push with a complete list of offending commits if any fail.Ref updatesstdinNew commitsrev-list --not --allExemptionsmerges, botsRulesformat, length, keyVerdictlist all, then rejectone rejection that lists every problem beats five rejections in a row

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.

Common exemptions and their trade-offsExempting merge commits is safe because their messages are generated. Exempting bots is safe if their messages follow a known format of their own. Exempting all reverts lets generated revert messages through but also any hand-written commit starting with Revert. Exempting by branch weakens the rule where history matters most.Exempt?Riskmerge commitsyesnone — generatedbot authorsusuallybots must self-formatgit revert defaultdecidehand-made 'Revert' slips inby branchavoidmain is where it matterswrite the exemptions into the hook's output so pushers know they exist

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.

One rule set, three enforcement pointsThe commit-msg hook gives instant feedback while writing. The pre-push hook re-checks before sending. The server pre-receive hook enforces for every push, including those that skipped client hooks. All three call the same rules script.feedback early, enforcement late, one rule setcommit-msg (client)instant, skippablepre-push (client)before sending, skippablepre-receive (server)every push, not skippablemessage-rules.shthe single source of rulesa change to the rules is one pull request, deployed to all three

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.