skip to content

Using Git hooks on the receiving repository, how would you reject a push containing a badly formatted commit message?

level: seniorimportance: should knowfreq 38%

answer

  1. Whole push, not one ref
  2. Never validate the whole history
  3. Watch the all-zeroes object names
  4. Report every offender at once
  5. Fixing it means rewriting the commits

basics

~20 s

Use a pre-receive hook: for each stdin line, list the commits the push actually introduces with git rev-list, check each subject against the format, print every offender to the pusher, and exit non-zero so no ref is updated.

solid answer

~50 s

Put it in `pre-receive`, because message format is a property of the commits rather than of one ref, and a non-zero exit there means **no** ref is updated — you never end up with half a validated push. For each `<old-oid> <new-oid> <ref-name>` line on stdin, skip deletions (all-zeroes new object name), then compute the commits this push actually introduces: `git rev-list <old>..<new>` normally, and `git rev-list <new> --not --all` when the branch is being created, so you do not re-validate the entire history. Read each subject with `git log -1 --format=%s` and test it against your pattern. Collect *all* failures rather than stopping at the first, print them to stdout — which `pre-receive` relays to the pusher — and exit non-zero at the end. Two things to accept up front: rewriting history is the only way to fix a rejected commit, so the message must say so, and the hook is on every contributor's critical path, so it must stay fast.

code

bash · 31 lines
bash
#!/bin/sh
# hooks/pre-receive — reject commits whose subject breaks the format
z40=0000000000000000000000000000000000000000
status=0

while read -r old new ref; do
  [ "$new" = "$z40" ] && continue                    # ref deletion

  if [ "$old" = "$z40" ]; then
    commits=$(git rev-list --no-merges "$new" --not --all)
  else
    commits=$(git rev-list --no-merges "$old..$new")
  fi

  for c in $commits; do
    subject=$(git log -1 --format=%s "$c")
    case "$subject" in
      feat:*|fix:*|docs:*|refactor:*|test:*|chore:*) ;;
      *)
        echo "rejected $(git rev-parse --short "$c") on $ref: $subject"
        status=1
        ;;
    esac
  done
done

if [ "$status" -ne 0 ]; then
  echo "subjects must start with feat:, fix:, docs:, refactor:, test: or chore:"
  echo "fix with: git rebase -i --exec 'git commit --amend' <base>"
fi
exit $status

go deeper

for a junior

Recall that a hook on the receiving repository can refuse a push, and that commit messages are checked by reading each new commit's subject. The details are not expected here.

for a middle

Explain why pre-receive is the right hook, how to derive the pushed commit range from the stdin line, and what the all-zeroes object names mean for creation and deletion.

for a senior

Demonstrate you have run one in anger: correct ranges on branch creation, all failures reported at once, an actionable message, bounded cost, and awareness that fixing a rejection means rewriting commits.

for a principal

Own the layering and the risk: one rule definition driving both the fast local check and the binding one, an explicit stance on merge commits, and a plan for the day the hook itself breaks and blocks every push.

## Why pre-receive and not update The subject of the rule is the commits, not the ref. A `update` hook would run once per ref and would let a push land partially — `main` accepted, `develop` refused — which leaves the repository in a state nobody intended. `pre-receive` sees the whole push at once, and Git guarantees that if it exits non-zero, none of the refs are updated. That all-or-nothing property is exactly what a content rule wants. It also has to be a receiving-side hook rather than a client `commit-msg` hook if the rule is meant to bind: client hooks are not distributed by clone and are skipped by `--no-verify`, whereas this one runs on infrastructure the pusher does not control. ## Getting the commit list right This is where most implementations are subtly wrong. - **Ordinary update.** `git rev-list "$old..$new"` gives the commits reachable from the new tip but not the old one — the ones this push introduces to that ref. - **Branch creation.** The old object name is all zeroes, so `$old..$new` is not usable. Naively falling back to `git rev-list "$new"` walks the entire history and rejects the push because of a message someone wrote three years ago. The correct form is `git rev-list "$new" --not --all`, which excludes everything already reachable from any existing ref. - **Branch deletion.** The new object name is all zeroes; there is nothing to validate, so skip the line. - **Repeats across refs.** Pushing a branch and a tag that point into the same history yields the same commits twice. Deduplicate if you print per-commit output. The objects are already readable when the hook runs — in recent Git they sit in a quarantine area, pointed at by `GIT_QUARANTINE_PATH`, and are only migrated into the repository if the push is accepted — so `git log` and `git rev-list` work normally on them and a rejected push leaves nothing behind. ## The check itself Read the subject with `git log -1 --format=%s "$c"`, and the body with `%b` if the rule extends to it. Keep the pattern strict but small: a type prefix, a length ceiling, a required issue reference. Every extra clause is another rejection a contributor has to decode from a terminal at 6pm. Decide deliberately how merge commits are treated. Generated merge subjects rarely match a hand-written convention, and a hook that rejects them makes ordinary integration painful; excluding them with `git rev-list --no-merges` is the common choice. ## Reporting well Both stdout and stderr of `pre-receive` are relayed to the pushing client, so the hook can talk to the human. A good rejection message names the offending commit's abbreviated object name, shows the subject, states the rule in one line, and says how to fix it — for these commits, an interactive rebase, since the message is baked into the commit's identity. Collect every failure before exiting instead of stopping at the first: making someone rebase four times because you reported one problem per attempt is the fastest way to get your hook deleted. ## Cost and blast radius Every push in the organisation waits for this hook, and it runs on shared infrastructure. Bounding the walk to newly introduced commits keeps it proportional to the push rather than to the repository. A guard for absurd cases — a push introducing tens of thousands of commits — is worth having so an import does not hang the receive path. Think also about the failure mode of the hook itself. A crash or a syntax error in `pre-receive` is a non-zero exit, which means *every* push in the repository is now rejected. Keep the script simple, make it exit zero on any condition it does not understand rather than on any error it did not anticipate, and have a way to disable it. ## Where this rule really belongs Be honest in the interview that a receiving-side hook is the mechanism Git itself gives you, and that hosted environments often express the same intent as configured repository rules instead. The reasoning to show is the layering: the same pattern as a fast local `commit-msg` hook for immediate feedback, and this as the binding check — with both derived from one definition so they cannot drift.

  • Why is git rev-list "$new" wrong when the pushed branch is new?
    Because it walks the branch's entire history, so the hook validates thousands of old commits that are already in the repository and rejects the push over a message written years ago. Use `git rev-list "$new" --not --all`, which excludes everything already reachable from existing refs and leaves only what this push genuinely introduces.
  • A contributor's commit is rejected for its message. What do they have to do?
    Rewrite it. The message is part of the commit object, so changing it produces new object names — `git commit --amend` for the tip, or an interactive rebase for anything deeper, then push again. That is why the rejection message should name the offending commits and suggest the command; a bare failure sends people to a colleague instead of to a fix.
  • What happens to the pushed objects when your pre-receive hook rejects the push?
    Nothing is added to the repository. The refs are untouched by definition, and in recent Git the incoming objects are held in a quarantine area — exposed as `GIT_QUARANTINE_PATH` — that is only migrated in when the push is accepted, so a rejected push does not leave loose objects to be garbage-collected later.
  • How would you avoid this hook becoming a repository-wide outage?
    Treat it as production code on everyone's critical path: keep it small, bound the commit walk, exit zero for conditions it does not understand rather than crashing, and make sure there is a way to disable it quickly. A `pre-receive` that errors exits non-zero, and a non-zero exit rejects every push in the repository.

saying these in an interview costs you the question

  • Validating the branch's whole history on a new branch
  • Rejecting on the first bad commit instead of listing all
  • Putting the binding check only in a client commit-msg hook
  • Using the update hook and accepting a partially applied push
  • Rejecting merge commits whose generated subjects cannot match

context