#!/usr/bin/env bash # commit-msg hook: reject style tells in commit messages. # # Enforces github-discipline.md Rule 5.1 (no AI attribution) and # Rule 5.2 (no em dashes U+2014) at the commit boundary. # # Install (per repo): git config core.hooksPath .githooks # # Portable: uses only BSD-compatible grep (no -P, no \x escapes in patterns). set -e MSG_FILE="$1" [ -z "$MSG_FILE" ] && exit 0 [ ! -f "$MSG_FILE" ] && exit 0 # Strip comment lines (lines starting with #) and the diff scissors block. CONTENT=$(awk ' /^# ------------------------ >8 ------------------------$/ { exit } /^[[:space:]]*#/ { next } { print } ' "$MSG_FILE") FAIL=0 # Em dash U+2014 = UTF-8 bytes E2 80 94. Build the literal as a variable so the # script source itself contains no em-dash character. EMDASH=$(printf '\xe2\x80\x94') if LC_ALL=C printf '%s' "$CONTENT" | grep -qF -- "$EMDASH"; then printf 'commit-msg: forbidden em dash (U+2014) in commit message.\n' >&2 printf ' Replace with a comma, period, colon, semicolon, or restructure.\n' >&2 printf ' Rule: github-discipline.md Rule 5.2\n' >&2 printf ' Locations (line numbers in the cleaned message):\n' >&2 LC_ALL=C printf '%s\n' "$CONTENT" | grep -nF -- "$EMDASH" | sed 's/^/ /' >&2 FAIL=1 fi # Rule 5.1: literal AI-attribution phrases. LITERAL_PATTERNS=( 'Co-Authored-By: Claude' 'Co-authored-by: Claude' 'Generated with Claude Code' 'Generated with Claude' 'Generated by Claude' 'with help from Claude' 'Powered by Claude' 'powered by Claude' 'as an AI' 'As an AI' 'this commit was generated' 'This commit was generated' 'this change was generated' 'This change was generated' ) for p in "${LITERAL_PATTERNS[@]}"; do if printf '%s' "$CONTENT" | grep -qF -- "$p"; then printf 'commit-msg: forbidden AI-attribution phrase: %s\n' "$p" >&2 printf ' Rule: github-discipline.md Rule 5.1\n' >&2 FAIL=1 fi done # Rule 5.1: AI-tell signature emojis used as flourish at end of line. # Build emoji byte sequences in a variable so the script source stays clean. EMOJI_TELLS=$(printf '\xf0\x9f\xa4\x96\n\xe2\x9c\xa8\n\xf0\x9f\xaa\x84\n\xf0\x9f\xa7\xa0\n\xf0\x9f\xa6\xbe\n\xf0\x9f\xa4\x9d') while IFS= read -r emoji; do [ -z "$emoji" ] && continue if LC_ALL=C printf '%s' "$CONTENT" | grep -qF -- "$emoji"; then printf 'commit-msg: forbidden AI-signature emoji in commit message.\n' >&2 printf ' Rule: github-discipline.md Rule 5.1\n' >&2 FAIL=1 break fi done <<<"$EMOJI_TELLS" # Rule 5.1: AI-tell preamble phrasings at start of any line. PREAMBLE_PATTERNS=( "I'll now" "Let me " "Here is a quick summary" "Here's a quick summary" ) for p in "${PREAMBLE_PATTERNS[@]}"; do if printf '%s' "$CONTENT" | grep -qE -- "^${p}"; then printf 'commit-msg: forbidden AI-tell preamble at line start: %s\n' "$p" >&2 printf ' Rule: github-discipline.md Rule 5.1\n' >&2 FAIL=1 fi done # Rule 5.1: Cursor attribution. Reject Cursor co-author trailers, the agent # email, any @cursor.com address, and "generated with Cursor" style tells. # Case-insensitive on purpose: cursoragent@cursor.com, Co-authored-by: Cursor. CURSOR_LITERALS=( 'Co-Authored-By: Cursor' 'Co-authored-by: Cursor' 'Generated with Cursor' 'Generated by Cursor' 'with help from Cursor' 'Powered by Cursor' ) for p in "${CURSOR_LITERALS[@]}"; do if printf '%s' "$CONTENT" | grep -qiF -- "$p"; then printf 'commit-msg: forbidden Cursor attribution: %s\n' "$p" >&2 printf ' Rule: github-discipline.md Rule 5.1\n' >&2 FAIL=1 fi done if printf '%s' "$CONTENT" | grep -qiE -- '@cursor\.com'; then printf 'commit-msg: forbidden Cursor email (@cursor.com) in commit message.\n' >&2 printf ' Rule: github-discipline.md Rule 5.1\n' >&2 FAIL=1 fi # Author / committer identity must not be Cursor. git var resolves the exact # identity (including --author / GIT_*_IDENT overrides) this commit will record. for ident in "$(git var GIT_AUTHOR_IDENT 2>/dev/null)" "$(git var GIT_COMMITTER_IDENT 2>/dev/null)"; do [ -z "$ident" ] && continue if printf '%s' "$ident" | grep -qiE -- '@cursor\.com|(^|[^[:alpha:]])cursor(agent)?([^[:alpha:]]|$)'; then printf 'commit-msg: forbidden Cursor identity as author/committer: %s\n' "$ident" >&2 printf ' Fix: set git config user.name / user.email to your own identity.\n' >&2 FAIL=1 fi done # commits.md Rule 1/2: subject must follow Conventional Commits (lowercase type # from the allowed set, optional scope, optional ! for breaking) and be at most # 72 characters. Merge/revert/fixup/squash subjects are tooling-generated and # exempt. SUBJECT=$(printf '%s\n' "$CONTENT" | sed '/^[[:space:]]*$/d' | head -1) case "$SUBJECT" in "Merge "*|"Revert "*|"fixup! "*|"squash! "*|"") ;; *) if [ "${#SUBJECT}" -gt 72 ]; then printf 'commit-msg: subject is %s chars (max 72).\n' "${#SUBJECT}" >&2 printf ' Rule: commits.md Rule 1\n' >&2 FAIL=1 fi if ! printf '%s' "$SUBJECT" | grep -qE '^(feat|fix|critic-fix|docs|style|refactor|perf|test|build|ci|chore)(\([a-z0-9._-]+\))?!?: .+'; then printf 'commit-msg: subject must be Conventional Commits: "type(scope): summary".\n' >&2 printf ' Allowed types: feat fix critic-fix docs style refactor perf test build ci chore\n' >&2 printf ' Rule: commits.md Rule 1, Rule 2\n' >&2 FAIL=1 fi ;; esac exit $FAIL