Enforcing issue keys in commit messages Jump to heading

Requiring an issue key is the most commonly attempted commit rule and the most commonly abandoned one. It fails for a predictable reason: the first version rejects merge commits, reverts, and the release chore that has no ticket, so within a week people are passing --no-verify for legitimate work and the rule protects nothing. The version that survives states its exemptions up front. This recipe builds it, as part of commit message hooks and templates.

When to use this approach Jump to heading

  • Changes are traced back to work items during audits, incident reviews or release notes.
  • Your tracker keys have a stable, matchable shape such as ABC-123.
  • Branch names already carry the key, so it can usually be derived rather than typed.
  • You can enforce in the pipeline as well as locally — otherwise the rule covers only the people who installed it.
  • If nothing reads the keys, skip the rule; an unread convention is friction with no return.

Step 1 — Write down the exemptions before the rule Jump to heading

List every commit that legitimately has no ticket. Doing this first is what separates a rule people keep from one they route around.

# What proportion of recent history would the naive rule have rejected?
git log --format='%s' -n 300 | grep -cvE '[A-Z]{2,10}-[0-9]+'
git log --format='%s' -n 300 | grep -vE '[A-Z]{2,10}-[0-9]+' | head -20

The list almost always includes merge commits, reverts, fixup! and squash! commits during review, release version bumps, and initial scaffolding. Everything on it becomes an explicit exemption in the hook rather than a reason to bypass it.

Which commits the rule should even look atMerge and revert commits are generated by Git itself, and review fixups are temporary objects that disappear at squash time. Excluding them explicitly leaves the rule applying only to commits a person wrote deliberately.Did a person write this message deliberately?generatedMerge or revertexempt — Git wrote ittemporaryfixup! or squash!exempt — squashed awayauthoredApply the rulekey requiredthree exemptions remove almost every false rejection the naive rule produces

Step 2 — Implement the hook with the exemptions in it Jump to heading

#!/usr/bin/env sh
# .husky/commit-msg — require a tracker key on deliberately authored commits.
set -eu
msg_file="$1"
subject=$(head -n1 "$msg_file")
KEY_RE='[A-Z]{2,10}-[0-9]+'

case "$subject" in
  "Merge "*|"Revert "*|"fixup!"*|"squash!"*|"amend!"*) exit 0 ;;
  "chore(release):"*) exit 0 ;;
  '#'*|'') exit 0 ;;
esac

# The key may appear in the subject or in a Refs: trailer.
if printf '%s' "$subject" | grep -qE "$KEY_RE"; then exit 0; fi
if grep -qE "^Refs:.*$KEY_RE" "$msg_file"; then exit 0; fi

cat >&2 <<'MSG'
commit-msg: no tracker key found.

  Put it in the subject:     PAY-812: widen the refund window
  or in a trailer:           Refs: PAY-812

  Exempt: merges, reverts, fixup!/squash!, chore(release).
MSG
exit 1
# Verification: each branch of the rule, without committing
printf 'PAY-812: widen the refund window\n' > /tmp/m && sh .husky/commit-msg /tmp/m && echo ok
printf 'Merge branch main\n'                > /tmp/m && sh .husky/commit-msg /tmp/m && echo "merge exempt"
printf 'widen the refund window\n'          > /tmp/m && sh .husky/commit-msg /tmp/m || echo "rejected"

What changed: the rejection message now contains both accepted forms and the exemption list, so the reader does not have to open the hook to find out what is expected.

Step 3 — Derive the key so it rarely has to be typed Jump to heading

Rejecting is the last resort. If the branch name carries the key, fill it in before the author ever sees the editor — the mechanics are in generating a commit message template per branch.

# Branch naming that makes derivation reliable, checked at branch creation
git check-ref-format --branch "$(git symbolic-ref --short HEAD)" && \
  git symbolic-ref --short HEAD | grep -qE '[A-Z]{2,10}-[0-9]+' \
  || echo "note: branch name has no tracker key; the commit hook will ask for one"
Rejections per hundred commits as automation is addedA bare rule rejects a large share of commits, most of them legitimate. Adding the exemption list removes the false rejections, and deriving the key from the branch name removes most of the remainder, leaving a rule that fires only when something genuinely lacks a reference.commits rejected per hundredbare rule23with exemptions9plus derivation2the goal is a rule that almost never fires, not one that fires often

Step 4 — Re-check the rule where you control the machine Jump to heading

Local hooks do not run on forks, in the web interface, or after --no-verify. Repeat the check in the pipeline over the commits the change introduces.

# .github/workflows/commit-policy.yml
name: commit-policy
on: [pull_request]
jobs:
  keys:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - name: Every commit in the range carries a key
        run: |
          base=$(git merge-base origin/${{ github.base_ref }} HEAD)
          fail=0
          for sha in $(git rev-list "$base"..HEAD); do
            subject=$(git log -1 --format=%s "$sha")
            case "$subject" in "Merge "*|"Revert "*|"fixup!"*|"squash!"*) continue ;; esac
            git log -1 --format='%s%n%b' "$sha" | grep -qE '[A-Z]{2,10}-[0-9]+' \
              || { echo "::error::$sha has no tracker key: $subject"; fail=1; }
          done
          exit $fail
# Verification: run the same loop locally against your current branch
base=$(git merge-base origin/main HEAD); git rev-list "$base"..HEAD | wc -l

SAFETY WARNING — do not enforce this rule with a history rewrite over commits that already exist. Rewriting to add keys changes every commit id from that point forward, which breaks open branches, invalidates signatures and forces everyone to recover their work. Apply the rule from today and leave history alone; safe git rebase -i for shared branches explains why.

Where the key is checked when a fork contributesA contributor working from a fork never runs your hooks. The pull request pipeline is the first machine you control that sees the commits, so it is where the rule has to be repeated for the check to mean anything.fork authorfork clonepull requestpolicy jobcommit — your hooks absentopen pull requestrange of commitsmissing key on 2 commitsre-checked after fixupthe only enforcement point that covers every contributor

Step 5 — Decide what the squash message must contain Jump to heading

If you merge by squashing, the commit that lands is composed from the pull request title, not from the commits. Check the title with the same expression, or the rule applies only to objects that are discarded.

# Validate the title that will become the squashed subject
gh pr view --json title --jq '.title' | grep -qE '[A-Z]{2,10}-[0-9]+' \
  && echo "title carries a key" || echo "title needs one"

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

What about work that genuinely has no ticket? Jump to heading

Give it a stable exemption rather than an escape hatch. A chore(release): prefix or a documented NO-TICKET token both work, and either is better than --no-verify, because the exemption is visible in history and can be audited later.

Should the key be in the subject or a trailer? Jump to heading

Accept both. Subjects are what people read in git log --oneline; trailers are what tooling reads reliably via git log --format='%(trailers:key=Refs)'. Requiring exactly one location adds friction without adding information.

How do we handle a commit that references several tickets? Jump to heading

Match at least one key and allow more. A commit touching three tickets is usually a sign the change should have been split, but rejecting it at commit time punishes the wrong moment — flag it in review instead.