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.
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" 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.
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.
Related Jump to heading
- Commit Message Hooks & Templates — the parent topic and the hook ordering this relies on.
- Generating a Commit Message Template Per Branch — deriving the key so the rule rarely fires.
- Linting Commit Messages for Forked Pull Requests — enforcing the same rule on contributions you do not control.