Use Git 'commit-msg' Hook for Better Commit Messages

by Tim Case

I wrote previously about my rules for writing Git commit messages. I like to follow the 50/72 rule and use the imperative mood in my commit subjects. While the rules are simple, and the benefits of following them are improved readability of project history, I don’t want to spend time enforcing git commit message rules.

Especially in current times when git commits may not be written by humans, but by AI tools. Now I could formalize my rules with instructions:

- Subject ≤ 50 chars, imperative mood, no period.
- No type prefixes — do not use `feat:`, `fix:`, `chore:`, `bug:`, or
  any other conventional-commit-style prefix.
- Examples: `Fix flaky ...`, `Remove dead code`, `Move ...`
- Separate subject from body with a blank line; wrap body at 72 chars.
- No AI-generated attribution in commit messages.

These are helpful and I would include them as instructions in an Agent file however, they will not be followed in all circumstances.

Therefore use a git commit-msg hook to enforce the rules. The hook will check the commit message and reject it if it does not follow the rules. If you install the git commit-msg hook in your repositiory, I can guarantee you will see that it frequently rejects commit messages that do not follow the rules.

#!/usr/bin/env bash

# Drop everything from the scissors line down (git commit -v diff),
# then strip comment lines
msg=$(sed '/^# -\+ >8 -\+$/,$d' "$1" | grep -v '^#')
subject=$(printf '%s' "$msg" | head -1)

# Skip autosquash targets and merge commits — not subject to normal formatting
if [[ "$subject" =~ ^(fixup|squash|amend)\!\  ]] || [[ "$subject" == Merge\ * ]]; then
  exit 0
fi

errors=()

# Subject ≤ 50 characters
len=${#subject}
if [ "$len" -gt 50 ]; then
  errors+=("Subject is $len chars (max 50)")
fi

# No trailing period
if [[ "$subject" == *. ]]; then
  errors+=("Subject must not end with a period")
fi

# Starts with a capital letter
if [[ ! "${subject:0:1}" =~ [A-Z] ]]; then
  errors+=("Subject must start with a capital letter")
fi

# If a body is present, it must be separated by a blank line
body=$(printf '%s' "$msg" | tail -n +2)
if [ -n "$body" ]; then
  second=$(printf '%s' "$msg" | sed -n '2p')
  if [ -n "$second" ]; then
    errors+=("Separate subject from body with a blank line")
  fi

  # Body lines ≤ 72 characters
  line_num=0
  while IFS= read -r line; do
    line_num=$((line_num + 1))
    [ $line_num -le 2 ] && continue  # subject and blank separator
    line_len=${#line}
    if [ "$line_len" -gt 72 ]; then
      errors+=("Line $line_num exceeds 72 chars ($line_len): \"$line\"")
    fi
  done <<< "$msg"
fi

# No AI attribution lines
if printf '%s\n' "$msg" | grep -qiE '^Co-Authored-By:.*(claude|anthropic|codex|openai|chatgpt|gpt-|copilot|gemini|cursor|devin)'; then
  errors+=("Remove AI-generated attribution line (Co-Authored-By)")
fi

if [ ${#errors[@]} -gt 0 ]; then
  echo "Commit message errors:"
  for e in "${errors[@]}"; do
    echo "  • $e"
  done
  exit 1
fi

exit 0

I also provide the hook as a gist for easy copy-paste:

https://gist.github.com/timcase/71dfd39ca756fb6303657b173ea80351