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
--fixupand--autosquash, and unsquashed fixups sometimes get pushed. - Some commits are created by tools or rebases that do not run the
commit-msghook. - 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 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 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 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-leasewhen pushing, andgit reset --hard ORIG_HEADimmediately 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.
Related Jump to heading
- Pre-Push Validation Rules — the parent topic.
- Rejecting WIP and Fixup Commits Before Merge — the CI counterpart for pull requests.
- Enforcing Subject Line Length and Format — the rules themselves in more depth.
- Enforcing Issue Keys in Commit Messages — issue-key conventions and exceptions.