Checking commit messages before push Jump to heading

A commit-msg hook checks each message as it is written. That leaves gaps. Messages created by git rebase, git cherry-pick and git merge bypass it in some workflows; commits made with --no-verify skip it entirely; and commits made before the hook was installed were never checked. The last chance to catch a bad message cheaply is before it leaves the machine. A pre-push check walks every commit about to be pushed and validates its message — format, issue key, length — and refuses the push if any fail, listing them all. It also catches the most common message problem a commit-msg hook cannot: fixup! and squash! commits that were meant to be squashed before sharing. This page builds that check, within pre-push validation rules.

When to use this approach Jump to heading

  • Your team enforces a message convention, and bad messages still reach pull requests.
  • People use --fixup and --autosquash, and unsquashed fixups sometimes get pushed.
  • Some commits are created by tools or rebases that do not run the commit-msg hook.
  • You want the same rules locally and in CI, alongside linting commit messages for forked pull requests.

Step 1 — Get the list of commits being pushed Jump to heading

Use the shared helper that turns pre-push input into a commit list, so new branches, updates and force-pushes are all handled; it is built in finding the new commits in a pre-push hook.

# .husky/pre-push
input=$(cat)
commits=$(printf '%s\n' "$input" | sh scripts/hooks/pushed-commits.sh "$1")
[ -n "$commits" ] || exit 0
printf '%s\n' "$commits" | sh scripts/hooks/check-pushed-messages.sh

Step 2 — Validate each message and collect failures Jump to heading

Check every commit and report all failures together, so one push attempt shows everything that needs fixing. Skip merge commits, whose messages Git generates.

#!/bin/sh
# scripts/hooks/check-pushed-messages.sh — reads commit IDs on stdin
set -u
fail=0
while read -r c; do
  [ "$(git rev-list --parents -n1 "$c" | wc -w)" -gt 2 ] && continue   # merge commit
  subject=$(git log -1 --format=%s "$c")
  short=$(git log -1 --format=%h "$c")
  case "$subject" in
    fixup!*|squash!*|amend!*)
      echo "✗ $short unsquashed: $subject"; fail=1; continue ;;
  esac
  printf '%s\n' "$subject" | grep -Eq '^(feat|fix|chore|docs|refactor|test|perf|build|ci)(\([a-z0-9-]+\))?!?: .+' ||
    { echo "✗ $short not conventional: $subject"; fail=1; }
  [ "${#subject}" -le 72 ] || { echo "✗ $short subject longer than 72 characters"; fail=1; }
  git log -1 --format=%B "$c" | grep -Eq '\b[A-Z][A-Z0-9]+-[0-9]+\b' ||
    { echo "✗ $short missing issue key (e.g. PAY-812)"; fail=1; }
done
[ "$fail" -eq 0 ] || echo "Fix with: git rebase -i --autosquash @{upstream}   (reword or squash the commits above)"
exit $fail
Message checks at push timeThe hook computes the commits being pushed, skips merge commits, and checks each message for leftover fixup prefixes, conventional format, subject length and an issue key. All failures are listed together with the command that fixes them.Pushed commitsfrom stdinSkip mergesgenerated messagesfixup!/squash!unsquashed?Format rulestype, length, keyReport all+ fix commandthe fixup check is the one commit-msg hooks structurally cannot do

Step 3 — Catch unsquashed fixups specifically Jump to heading

fixup! and squash! commits are a deliberate workflow: you create them during review and fold them in with an autosquash rebase before merging. Pushing them to a review branch can be fine; pushing them to a branch that is about to merge, or to main, is a mistake. Make the rule depend on the destination.

# Allow fixups on review branches, refuse them on protected destinations
case "$remote_ref" in
  refs/heads/main|refs/heads/release/*) refuse_fixups=1 ;;
  *) refuse_fixups=0 ;;
esac
Where fixup commits are acceptableOn a pull request branch under review, fixup commits are useful: reviewers can see what changed in response to comments. On main or a release branch they are clutter that should have been squashed, so the check refuses them there and only there.Review branchmain / release/*fixup! commitsallowedrefusedpurposeshow review changesclean historysquashed byautosquash before mergeshould already bethe same commit is good practice in one place and a mistake in another

The review workflow itself is covered in using fixup commits and autosquash during review.

Step 4 — Share the rules with commit-msg and CI Jump to heading

Three places check messages: the commit-msg hook, this pre-push hook, and CI. Keep the rules in one script that all three call, so they never disagree.

# scripts/hooks/message-rules.sh <message-file> — the single source of rules
# commit-msg:  sh scripts/hooks/message-rules.sh "$1"
# pre-push:    git log -1 --format=%B "$c" > "$tmp" && sh scripts/hooks/message-rules.sh "$tmp"
# CI:          the same loop over the pull request's commits
# CI: check every commit in the pull request with the same rules
      - run: |
          git rev-list "origin/${{ github.base_ref }}..HEAD" | sh scripts/hooks/check-pushed-messages.sh
Three places messages are checkedThe commit-msg hook checks a message as it is written. The pre-push hook re-checks every commit about to leave the machine, catching rebased, amended and --no-verify commits. CI checks every commit in the pull request and is the only layer that cannot be skipped.one rule script, three call sitescommit-msg hookas each message is writtenpre-push hookevery commit about to be sentCIevery commit in the PR — requiredif the three disagree, the shared script has been copied instead of called

Step 5 — Fix failures with a rebase Jump to heading

The hook’s message tells the developer what to do. Rewording and squashing are both rebase operations on commits that have not been shared yet, which is exactly the state they are in when the push is refused.

git rebase -i --autosquash @{upstream}     # fixups folded automatically; mark others 'reword'
git push --force-with-lease                # if the branch was pushed before

⚠️ SAFETY WARNING: Rewording or squashing rewrites the affected commits. If the branch was already pushed and someone else has fetched it, they will need to reset to the new version. Use --force-with-lease when pushing, and git reset --hard ORIG_HEAD immediately after the rebase if it went wrong.

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Isn’t a commit-msg hook enough? Jump to heading

It catches most problems at the moment of writing. It does not see commits created with --no-verify, commits from before the hook existed, or the unsquashed-fixup case. The pre-push check covers those cheaply.

What about messages generated by the forge, like squash merges? Jump to heading

Those are created on the server and never pass through local hooks. CI on the default branch, or a rule on the squash message format in the forge settings, covers them; see writing a good squash commit message.

Should revert commits be exempt? Jump to heading

Git’s default revert subject, Revert "...", does not match conventional format. Either allow ^Revert explicitly, or have people reword reverts to revert: .... Decide once and encode it in the shared rules.