From 6f52f4b0dbf2c613585953d4ac65da91c0d769fd Mon Sep 17 00:00:00 2001 From: Greg Gardner Date: Fri, 21 Aug 2026 02:08:04 -0700 Subject: [PATCH] feat: add commit message normalizer and validator Standardize commit messages on Conventional Commits with a dependency-free normalizer, a validator, an opt-in commit-msg hook, and a CI job that lints every commit in the PR range. No history is rewritten. Nightshift-Task: commit-normalize Nightshift-Ref: https://github.com/marcus/nightshift --- .github/workflows/commit-lint.yml | 44 ++++ CONTRIBUTING.md | 103 ++++++++ Makefile | 13 +- scripts/commit-msg.sh | 18 ++ scripts/normalize-commit-msg.sh | 188 +++++++++++++++ scripts/validate-commit-msg.sh | 183 ++++++++++++++ tests/run-commit-msg-tests.sh | 385 ++++++++++++++++++++++++++++++ 7 files changed, 931 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/commit-lint.yml create mode 100644 CONTRIBUTING.md create mode 100755 scripts/commit-msg.sh create mode 100755 scripts/normalize-commit-msg.sh create mode 100755 scripts/validate-commit-msg.sh create mode 100755 tests/run-commit-msg-tests.sh diff --git a/.github/workflows/commit-lint.yml b/.github/workflows/commit-lint.yml new file mode 100644 index 0000000..0a37ac7 --- /dev/null +++ b/.github/workflows/commit-lint.yml @@ -0,0 +1,44 @@ +name: Commit Lint + +on: + pull_request: + branches: [main] + +jobs: + commit-lint: + name: Commit messages + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Run normalizer test suite + run: bash tests/run-commit-msg-tests.sh + + - name: Validate commit messages in the PR range + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -uo pipefail + failed=0 + checked=0 + for sha in $(git rev-list "$BASE_SHA..$HEAD_SHA"); do + checked=$((checked + 1)) + message="$(git log -1 --format=%B "$sha")" + if ! printf '%s' "$message" | ./scripts/validate-commit-msg.sh - > /tmp/commit-lint.err 2>&1; then + echo "::error::$(git log -1 --format='%h %s' "$sha")" + sed 's/^/ /' /tmp/commit-lint.err + echo "" + failed=$((failed + 1)) + fi + done + echo "checked $checked commit(s) in $BASE_SHA..$HEAD_SHA" + if [ "$failed" -gt 0 ]; then + echo "❌ $failed commit message(s) do not follow the project format." + echo " Fix them with: git rebase -i $BASE_SHA (reword the offending commits)" + exit 1 + fi + echo "✅ all commit messages follow the project format" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..b4ccfd0 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,103 @@ +# Contributing to Nightshift + +## Commit messages + +Nightshift uses [Conventional Commits](https://www.conventionalcommits.org/). The +format was not invented for this document — it is what the existing history +already does, and the tooling below just makes it consistent. + +``` +(): + + + + +``` + +- **type** — one of `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, + `build`, `ci`, `chore`, `revert` +- **scope** — optional, lowercase, e.g. `(runner)`, `(tui)`, `(config)` +- **`!`** — marks a breaking change, e.g. `feat(api)!: drop v1 endpoints` +- **subject** — lowercase, imperative ("add", not "adds"/"Added"), no trailing + period, 72 characters or fewer +- **body** — separated from the subject by a blank line +- **trailers** — last, e.g. `Co-Authored-By:`, `Nightshift-Task:` + +### Good + +``` +feat(runner): add per-task timeout +fix: stop leaking the task context on cancel +docs: document the commit message format +feat(api)!: drop v1 endpoints +``` + +### Bad + +``` +Fix: Thing. → fix: thing +Update readme → docs: update readme +feat - add worker pool → feat: add worker pool +[feat] add worker pool → feat: add worker pool +feat: Add a really long subject that runs well past seventy-two characters +``` + +Merge, revert, `fixup!`, `squash!` and `amend!` messages are exempt — they are +passed through untouched by both the hook and CI. + +### Install the hook (opt-in) + +``` +make install-hooks +``` + +This symlinks `scripts/pre-commit.sh` and `scripts/commit-msg.sh` into +`.git/hooks/`. Nothing installs itself; your git config is only changed when you +run that target. + +The `commit-msg` hook first *normalizes* the message — it fixes case, trailing +punctuation, near-miss prefixes (`Fix:`, `feat -`, `[feat]`), missing space +after the colon, blank-line structure, and the blank line git needs before +a trailer block — and then *validates* the result. +Only what cannot be fixed unambiguously (unknown type, empty subject, +over-length subject) blocks the commit, and the error explains the fix. +Normalization is idempotent. + +The hook never deletes comment lines. Git already does that itself, *after* the +hook runs and only when it should: an editor-authored message loses its +generated template, while `git commit -m` keeps a body line that happens to +start with `#`. The hook leaves the trailing template block untouched and +respects `core.commentChar`/`core.commentString`, so a body such as +`#123 explains why` survives exactly as it would without the hook. The one +consequence is that a trailing `#` line is assumed to be a template and is not +validated — a missed warning rather than a rejected commit. + +### Bypass + +``` +NORMALIZE_COMMIT_MSG=0 git commit -m "..." # skip normalize + validate +git commit --no-verify -m "..." # skip all hooks +``` + +### How CI enforces it + +`.github/workflows/commit-lint.yml` runs on pull requests and validates every +commit subject in the PR range with `scripts/validate-commit-msg.sh`, reporting +all failures at once. It only looks at commits in the PR — existing history is +never rewritten. Fix failures with `git rebase -i ` and reword. + +### Tooling + +| Script | Purpose | +| --- | --- | +| `scripts/normalize-commit-msg.sh ` | Rewrite a commit message file in place | +| `scripts/validate-commit-msg.sh [--quiet] [\|-]` | Validate; non-zero on failure | +| `scripts/commit-msg.sh` | The git hook (normalize, then validate) | +| `tests/run-commit-msg-tests.sh` | Test suite (`make test-commit-msg`) | + +Both scripts are dependency-free POSIX shell — no Node, no commitlint. + +## Code + +Run `make check` before opening a PR (tests, lint, and the commit message +tests). See `AGENTS.md` for repository conventions. diff --git a/Makefile b/Makefile index 088be01..d3b60af 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build test test-verbose test-race coverage coverage-html lint clean deps check install calibrate-providers install-hooks help +.PHONY: build test test-commit-msg test-verbose test-race coverage coverage-html lint clean deps check install calibrate-providers install-hooks help # Binary name BINARY=nightshift @@ -57,8 +57,12 @@ deps: go mod download go mod tidy +# Run the commit message normalizer/validator tests +test-commit-msg: + @bash tests/run-commit-msg-tests.sh + # Run all checks (test + lint) -check: test lint +check: test lint test-commit-msg # Show help help: @@ -75,10 +79,13 @@ help: @echo " check - Run tests and lint" @echo " install - Build and install to Go bin directory" @echo " calibrate-providers - Compare local Claude/Codex session usage for calibration" - @echo " install-hooks - Install git pre-commit hook" + @echo " install-hooks - Install git pre-commit and commit-msg hooks" + @echo " test-commit-msg - Run the commit message normalizer tests" @echo " help - Show this help" # Install git pre-commit hook install-hooks: @ln -sf ../../scripts/pre-commit.sh .git/hooks/pre-commit @echo "✓ pre-commit hook installed (.git/hooks/pre-commit → scripts/pre-commit.sh)" + @ln -sf ../../scripts/commit-msg.sh .git/hooks/commit-msg + @echo "✓ commit-msg hook installed (.git/hooks/commit-msg → scripts/commit-msg.sh)" diff --git a/scripts/commit-msg.sh b/scripts/commit-msg.sh new file mode 100755 index 0000000..fa69c98 --- /dev/null +++ b/scripts/commit-msg.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# commit-msg hook for nightshift +# Install: make install-hooks (or: ln -sf ../../scripts/commit-msg.sh .git/hooks/commit-msg) +# +# Normalizes the commit message in place, then validates it. Set +# NORMALIZE_COMMIT_MSG=0 to bypass both steps for a single commit, or use +# `git commit --no-verify`. +set -euo pipefail + +if [[ "${NORMALIZE_COMMIT_MSG:-1}" == "0" ]]; then + exit 0 +fi + +MSG_FILE="$1" +ROOT="$(git rev-parse --show-toplevel)" + +"$ROOT/scripts/normalize-commit-msg.sh" "$MSG_FILE" +"$ROOT/scripts/validate-commit-msg.sh" "$MSG_FILE" diff --git a/scripts/normalize-commit-msg.sh b/scripts/normalize-commit-msg.sh new file mode 100755 index 0000000..277b1c0 --- /dev/null +++ b/scripts/normalize-commit-msg.sh @@ -0,0 +1,188 @@ +#!/usr/bin/env sh +# +# normalize-commit-msg.sh — rewrite a commit message file into the repo's +# Conventional Commits format, fixing only what can be fixed unambiguously. +# +# usage: scripts/normalize-commit-msg.sh +# +# Fixes applied: +# - strips trailing whitespace, leaving git's comment template alone +# - collapses runs of blank lines and trims leading/trailing blanks +# - ensures a blank line between the subject and the body +# - rewrites near-miss prefixes: "Fix: x", "feat - x", "[feat] x", "fix:x" +# - lowercases the type token and the first word of the description +# (acronyms such as "API" are left alone) +# - removes a trailing period from the subject +# +# Never touches merge, revert, fixup!, squash! or amend! messages, and never +# invents a type for a subject that does not already look conventional. +# Running it twice produces the same result as running it once. +set -eu + +TYPES="feat fix docs style refactor perf test build ci chore revert" + +if [ $# -ne 1 ]; then + echo "usage: $(basename "$0") " >&2 + exit 2 +fi + +MSG_FILE=$1 +if [ ! -f "$MSG_FILE" ]; then + echo "normalize-commit-msg: no such file: $MSG_FILE" >&2 + exit 2 +fi + +lower() { + printf '%s' "$1" | tr 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' 'abcdefghijklmnopqrstuvwxyz' +} + +is_type() { + for _t in $TYPES; do + [ "$1" = "$_t" ] && return 0 + done + return 1 +} + +# Git strips comment lines itself, after this hook runs, and only when it would +# have: an editor-authored message loses its generated template, but +# `git commit -m` uses cleanup=whitespace and keeps a body line that happens to +# start with the comment character. Deleting every such line here would +# silently drop user-authored body text, so instead the trailing run of +# blank/comment lines -- the template, when there is one -- is carved off, +# left completely untouched, and handed back to git verbatim. +comment_prefix() { + _cc=$(git config --get core.commentString 2>/dev/null) || _cc="" + [ -n "$_cc" ] || { _cc=$(git config --get core.commentChar 2>/dev/null) || _cc=""; } + case "$_cc" in + "" | auto) printf '#' ;; + *) printf '%s' "$_cc" ;; + esac +} + +# First line of that trailing run, or one past the end when there is none. +split=$(awk -v cc="$(comment_prefix)" ' + { line[NR] = $0 } + END { + i = NR + found = 0 + while (i >= 1) { + if (line[i] == "") { i--; continue } + if (substr(line[i], 1, length(cc)) == cc) { found = 1; i--; continue } + break + } + print found ? i + 1 : NR + 1 + } +' "$MSG_FILE") + +trailing=$(sed -n "${split},\$p" "$MSG_FILE") + +# Structural cleanup of everything above it: rstrip, collapse blank runs, +# trim edges. +if [ "$split" -gt 1 ]; then + cleaned=$(sed -n "1,$((split - 1))p" "$MSG_FILE" | awk ' + { sub(/[ \t\r]+$/, "") } + $0 == "" { blank = 1; next } + { + if (emitted && blank) print "" + blank = 0 + emitted = 1 + print + } + ') +else + cleaned="" +fi + +# Nothing but a template (or nothing at all): leave the file exactly as it is +# and let git report the empty message. +[ -n "$cleaned" ] || exit 0 + +subject=$(printf '%s\n' "$cleaned" | sed -n '1p') +body=$(printf '%s\n' "$cleaned" | awk 'NR > 1 { if (!seen && $0 == "") next; seen = 1; print }') + +# Messages git generates or that reference another commit are left verbatim. +case "$subject" in +Merge\ * | Revert\ * | fixup!* | squash!* | amend!*) + exit 0 + ;; +esac + +# Only rewrite subjects whose leading word is already a known type; anything +# else is left for the validator to reject with an explanation. +lead=$(printf '%s' "$subject" | sed -E 's/^[[:space:]]*\[?[[:space:]]*([A-Za-z]+).*/\1/') +if is_type "$(lower "$lead")"; then + s=$subject + + # "[feat] x" / "[feat(api)] x" -> "feat: x" / "feat(api): x" + s=$(printf '%s' "$s" | sed -E 's/^[[:space:]]*\[[[:space:]]*([A-Za-z]+)([^]]*)\][[:space:]]*:?[[:space:]]*/\1\2: /') + + # Normalize the separator and spacing: "feat - x", "fix:x" -> "feat: x" + s=$(printf '%s' "$s" | sed -E 's/^[[:space:]]*([A-Za-z]+)[[:space:]]*(\([^)]*\))?[[:space:]]*(!?)[[:space:]]*(:|-)[[:space:]]*/\1\2\3: /') + + # Only keep going if those two rewrites actually produced a conventional + # subject. "Fix the bug" starts with a type word but has no separator to + # work with, so there is nothing that can be fixed unambiguously -- leave + # it exactly as written for the validator to reject, rather than + # half-rewriting a message that is going to be refused anyway. + if printf '%s' "$s" | grep -Eq '^[A-Za-z]+(\([^()]+\))?!?: .+'; then + + # Lowercase the type token. + type_token=$(printf '%s' "$s" | sed -E 's/^([A-Za-z]+).*/\1/') + s="$(lower "$type_token")${s#"$type_token"}" + + # Drop a trailing period. + s=$(printf '%s' "$s" | sed -E 's/[[:space:]]*\.+$//') + + # Lowercase the first word of the description unless it looks like an + # acronym or CamelCase identifier (API, OAuth, HTTPStatus, ...). + case "$s" in + *": "*) + prefix=${s%%: *} + desc=${s#*: } + first_word=${desc%% *} + case "$(printf '%s' "$first_word" | cut -c2-)" in + *[A-Z]*) ;; + *) + head_char=$(printf '%s' "$desc" | cut -c1) + desc="$(lower "$head_char")$(printf '%s' "$desc" | cut -c2-)" + ;; + esac + s="$prefix: $desc" + ;; + esac + + s=$(printf '%s' "$s" | sed -E 's/[[:space:]]+$//') + subject=$s + fi +fi + +# Trailers must be their own paragraph or git will not parse them. Only a +# trailing run of hyphenated-key lines (Co-Authored-By, Nightshift-Task, ...) +# counts, so a body ending in "Note: ..." is left alone. +if [ -n "$body" ]; then + body=$(printf '%s\n' "$body" | awk ' + { line[NR] = $0 } + END { + start = NR + 1 + while (start - 1 >= 1 && line[start - 1] ~ /^[A-Za-z][A-Za-z0-9]*(-[A-Za-z0-9]+)+: .+$/) + start-- + if (start > 1 && start <= NR && line[start - 1] != "") + insert = start + for (i = 1; i <= NR; i++) { + if (i == insert) print "" + print line[i] + } + } + ') +fi + +{ + if [ -n "$body" ]; then + printf '%s\n\n%s\n' "$subject" "$body" + else + printf '%s\n' "$subject" + fi + if [ -n "$trailing" ]; then + printf '%s\n' "$trailing" + fi +} >"$MSG_FILE" diff --git a/scripts/validate-commit-msg.sh b/scripts/validate-commit-msg.sh new file mode 100755 index 0000000..36107e4 --- /dev/null +++ b/scripts/validate-commit-msg.sh @@ -0,0 +1,183 @@ +#!/usr/bin/env sh +# +# validate-commit-msg.sh — check a commit message against the repo's +# Conventional Commits format. Reports every problem it finds, not just the +# first, and suggests a corrected subject where one can be derived. +# +# usage: scripts/validate-commit-msg.sh [--quiet] [|-] +# +# Reads stdin when the file is "-" or omitted. Exits 0 when the message is +# acceptable, 1 when it is not, 2 on usage errors. +set -u + +TYPES="feat fix docs style refactor perf test build ci chore revert" +MAX_SUBJECT=72 +QUIET=0 +SOURCE="-" + +while [ $# -gt 0 ]; do + case "$1" in + --quiet | -q) + QUIET=1 + ;; + -h | --help) + sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + -) + SOURCE="-" + ;; + -*) + echo "validate-commit-msg: unknown option: $1" >&2 + exit 2 + ;; + *) + SOURCE=$1 + ;; + esac + shift +done + +if [ "$SOURCE" = "-" ]; then + raw=$(cat) +else + if [ ! -f "$SOURCE" ]; then + echo "validate-commit-msg: no such file: $SOURCE" >&2 + exit 2 + fi + raw=$(cat "$SOURCE") +fi + +# Only the trailing run of blank/comment lines is treated as git's generated +# template and ignored. Anything above it -- including a body line that merely +# starts with the comment character -- is real content that git keeps under +# `git commit -m`, so it has to be validated rather than quietly discarded. +comment_prefix() { + _cc=$(git config --get core.commentString 2>/dev/null) || _cc="" + [ -n "$_cc" ] || { _cc=$(git config --get core.commentChar 2>/dev/null) || _cc=""; } + case "$_cc" in + "" | auto) printf '#' ;; + *) printf '%s' "$_cc" ;; + esac +} + +message=$(printf '%s\n' "$raw" | awk -v cc="$(comment_prefix)" ' + { sub(/[ \t\r]+$/, ""); line[NR] = $0 } + END { + i = NR + found = 0 + while (i >= 1) { + if (line[i] == "") { i--; continue } + if (substr(line[i], 1, length(cc)) == cc) { found = 1; i--; continue } + break + } + last = found ? i : NR + for (j = 1; j <= last; j++) { + if (!emitted && line[j] == "") continue + emitted = 1 + print line[j] + } + } +') + +# A message that is nothing but a template is git's problem, not ours: it +# aborts the commit with its own message. +[ -n "$message" ] || exit 0 + +subject=$(printf '%s\n' "$message" | sed -n '1p') +second_line=$(printf '%s\n' "$message" | sed -n '2p') + +# Messages git generates or that reference another commit are exempt. +case "$subject" in +Merge\ * | Revert\ * | fixup!* | squash!* | amend!*) + exit 0 + ;; +esac + +ERRORS="" +add_error() { + ERRORS="${ERRORS} - $1 +" +} + +type_pattern=$(printf '%s' "$TYPES" | tr ' ' '|') + +if [ -z "$subject" ]; then + add_error "the commit subject is empty" +else + if printf '%s' "$subject" | grep -Eq "^($type_pattern)(\([^()]+\))?!?:[[:space:]]*$"; then + add_error "the commit subject is empty after the type prefix" + elif ! printf '%s' "$subject" | grep -Eq "^($type_pattern)(\([^()]+\))?!?: .+"; then + if printf '%s' "$subject" | grep -Eq "^[A-Za-z]+(\([^()]+\))?!?:"; then + bad_type=$(printf '%s' "$subject" | sed -E 's/^([A-Za-z]+).*/\1/') + add_error "unknown commit type '$bad_type'; use one of: $TYPES" + else + add_error "the subject must start with a type, e.g. 'fix(scope): do the thing'; allowed types: $TYPES" + fi + else + desc=${subject#*: } + case "$subject" in + *.) + add_error "the subject must not end with a period" + ;; + esac + first_word=${desc%% *} + case "$(printf '%s' "$first_word" | cut -c2-)" in + *[A-Z]*) ;; + *) + case "$first_word" in + [A-Z]*) + add_error "use a lowercase imperative subject (write 'add x', not 'Add x')" + ;; + esac + ;; + esac + fi + + # Count characters, not bytes: dropping UTF-8 continuation bytes (0x80-0xBF) + # leaves exactly one byte per character, which `wc -c` can then count + # regardless of the caller's locale. + length=$(printf '%s' "$subject" | LC_ALL=C tr -d '\200-\277' | wc -c | tr -d ' ') + if [ "$length" -gt "$MAX_SUBJECT" ]; then + add_error "the subject is $length characters; keep it to $MAX_SUBJECT or fewer" + fi +fi + +if [ -n "$second_line" ]; then + add_error "leave a blank line between the subject and the body" +fi + +[ -z "$ERRORS" ] && exit 0 + +if [ "$QUIET" -eq 0 ]; then + { + echo "✗ commit message does not follow the project format:" + echo "" + echo " $subject" + echo "" + printf '%s' "$ERRORS" + echo "" + suggestion="" + normalizer="$(dirname "$0")/normalize-commit-msg.sh" + if [ -x "$normalizer" ]; then + tmp=$(mktemp 2>/dev/null || echo "/tmp/validate-commit-msg.$$") + printf '%s\n' "$message" >"$tmp" + "$normalizer" "$tmp" >/dev/null 2>&1 || true + candidate=$(sed -n '1p' "$tmp") + rm -f "$tmp" + if [ -n "$candidate" ] && [ "$candidate" != "$subject" ] && + printf '%s' "$candidate" | grep -Eq "^($type_pattern)(\([^()]+\))?!?: .+"; then + suggestion=$candidate + fi + fi + if [ -n "$suggestion" ]; then + echo " try: $suggestion" + else + echo " format: (): " + echo " example: fix(runner): stop leaking the task context" + fi + echo "" + echo " See CONTRIBUTING.md. Install the hook with: make install-hooks" + } >&2 +fi +exit 1 diff --git a/tests/run-commit-msg-tests.sh b/tests/run-commit-msg-tests.sh new file mode 100755 index 0000000..8c00c25 --- /dev/null +++ b/tests/run-commit-msg-tests.sh @@ -0,0 +1,385 @@ +#!/usr/bin/env bash +# Tests for scripts/normalize-commit-msg.sh and scripts/validate-commit-msg.sh +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +NORMALIZE="$ROOT/scripts/normalize-commit-msg.sh" +VALIDATE="$ROOT/scripts/validate-commit-msg.sh" + +TMPDIR_TEST="$(mktemp -d)" +trap 'rm -rf "$TMPDIR_TEST"' EXIT + +PASS=0 +FAIL=0 + +fail() { + FAIL=$((FAIL + 1)) + echo " ✗ $1" + shift + for line in "$@"; do echo " $line"; done +} + +ok() { + PASS=$((PASS + 1)) + echo " ✓ $1" +} + +# Overrides core.commentChar for a single script run without touching any real +# git config, so the "honours core.commentChar" cases stay hermetic. +with_comment_char() { + local char="$1" + shift + GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.commentChar GIT_CONFIG_VALUE_0="$char" "$@" +} + +# normalize_of [commentchar] -> prints normalized message +normalize_of() { + local f="$TMPDIR_TEST/msg.$$" + printf '%s' "$1" > "$f" + if [[ "${2:-}" == "commentchar" ]]; then + with_comment_char ';' "$NORMALIZE" "$f" >/dev/null 2>&1 + else + "$NORMALIZE" "$f" >/dev/null 2>&1 + fi + cat "$f" + rm -f "$f" +} + +# assert_normalizes [commentchar] +assert_normalizes() { + local name="$1" input="$2" expected="$3" mode="${4:-}" actual + actual="$(normalize_of "$input" "$mode")" + if [[ "$actual" == "$expected" ]]; then + ok "$name" + else + fail "$name" "expected: $(printf '%q' "$expected")" "actual: $(printf '%q' "$actual")" + fi +} + +# assert_idempotent [commentchar] +assert_idempotent() { + local name="$1" input="$2" mode="${3:-}" once twice + once="$(normalize_of "$input" "$mode")" + twice="$(normalize_of "$once" "$mode")" + if [[ "$once" == "$twice" ]]; then + ok "$name" + else + fail "$name" "once: $(printf '%q' "$once")" "twice: $(printf '%q' "$twice")" + fi +} + +# assert_valid +assert_valid() { + local name="$1" msg="$2" out + out="$(printf '%s' "$msg" | "$VALIDATE" - 2>&1)" + if [[ $? -eq 0 ]]; then ok "$name"; else fail "$name" "validator rejected it:" "$out"; fi +} + +# assert_invalid [expected-substring] +assert_invalid() { + local name="$1" msg="$2" needle="${3:-}" out rc + out="$(printf '%s' "$msg" | "$VALIDATE" - 2>&1)"; rc=$? + if [[ $rc -eq 0 ]]; then + fail "$name" "validator accepted an invalid message" + elif [[ -n "$needle" && "$out" != *"$needle"* ]]; then + fail "$name" "message did not mention '$needle':" "$out" + else + ok "$name" + fi +} + +echo "commit-msg normalizer tests" +echo "" +echo "normalization:" + +assert_normalizes "already-valid message passes through" \ + 'feat(api): add rate limiting' \ + 'feat(api): add rate limiting' + +assert_normalizes "Fix: Thing. -> fix: thing" \ + 'Fix: Thing.' \ + 'fix: thing' + +assert_normalizes "breaking-change marker is preserved" \ + 'Feat(api)!: Drop v1 endpoints.' \ + 'feat(api)!: drop v1 endpoints' + +assert_normalizes "dash separator becomes colon" \ + 'feat(api) - add thing' \ + 'feat(api): add thing' + +assert_normalizes "bracket prefix becomes canonical" \ + '[feat] add thing' \ + 'feat: add thing' + +assert_normalizes "missing space after colon is fixed" \ + 'fix:tighten timeout' \ + 'fix: tighten timeout' + +# A subject whose leading word happens to be a type but which is not +# conventional at all cannot be fixed unambiguously, so it is left exactly as +# written rather than half-rewritten on a commit that is rejected anyway. +assert_normalizes "unfixable subject is left untouched" \ + 'Fix the bug' \ + 'Fix the bug' + +assert_normalizes "type word with no separator is left untouched" \ + 'Feat something entirely different' \ + 'Feat something entirely different' + +assert_normalizes "acronyms in the description are preserved" \ + 'feat: API key rotation' \ + 'feat: API key rotation' + +assert_normalizes "blank line is inserted between subject and body" \ + 'fix: tighten timeout +the old value was too generous.' \ + 'fix: tighten timeout + +the old value was too generous.' + +# Git does its own comment stripping *after* the commit-msg hook runs, and only +# when it would have: an editor-authored message loses its template, while +# `git commit -m` keeps a body line that happens to start with '#'. So the +# trailing template block is carved off and handed back untouched rather than +# deleted here, which would silently drop user-authored body text. +assert_normalizes "git template block is left intact for git to strip" \ + 'Fix: Tighten timeout. +# Please enter the commit message for your changes. +# On branch main' \ + 'fix: tighten timeout +# Please enter the commit message for your changes. +# On branch main' + +assert_normalizes "a body line starting with # is not deleted" \ + 'Fix: Thing. + +#123 explains why. Do not lose me.' \ + 'fix: thing + +#123 explains why. Do not lose me.' + +assert_normalizes "a # body line above a real template block survives" \ + 'Fix: Thing. + +#123 explains why. + +# Please enter the commit message for your changes.' \ + 'fix: thing + +#123 explains why. + +# Please enter the commit message for your changes.' + +assert_normalizes "core.commentChar is honoured over a hardcoded #" \ + 'Fix: Thing. + +#123 explains why. +; On branch main' \ + 'fix: thing + +#123 explains why. +; On branch main' \ + commentchar + +assert_normalizes "blank runs collapse and trailers survive" \ + 'feat: add worker pool + + +Runs tasks concurrently. + + + +Nightshift-Task: commit-normalize +Co-Authored-By: Someone ' \ + 'feat: add worker pool + +Runs tasks concurrently. + +Nightshift-Task: commit-normalize +Co-Authored-By: Someone ' + +assert_normalizes "blank line is inserted before the trailer block" \ + 'feat: add worker pool +Runs tasks concurrently. +Nightshift-Task: commit-normalize +Co-Authored-By: Someone ' \ + 'feat: add worker pool + +Runs tasks concurrently. + +Nightshift-Task: commit-normalize +Co-Authored-By: Someone ' + +assert_normalizes "a single-word key at the end of the body is not a trailer" \ + 'feat: add worker pool +Runs tasks concurrently. +Note: this is the last sentence.' \ + 'feat: add worker pool + +Runs tasks concurrently. +Note: this is the last sentence.' + +assert_normalizes "merge commits are left untouched" \ + 'Merge pull request #4 from marcus/feat/bus-factor-analyzer' \ + 'Merge pull request #4 from marcus/feat/bus-factor-analyzer' + +assert_normalizes "revert commits are left untouched" \ + 'Revert "feat: Add Thing."' \ + 'Revert "feat: Add Thing."' + +assert_normalizes "fixup! commits are left untouched" \ + 'fixup! feat: Add Thing.' \ + 'fixup! feat: Add Thing.' + +assert_normalizes "squash! commits are left untouched" \ + 'squash! feat: Add Thing.' \ + 'squash! feat: Add Thing.' + +assert_normalizes "unrecognized subjects are not mangled" \ + 'Update readme' \ + 'Update readme' + +echo "" +echo "idempotence:" +assert_idempotent "normalizing twice equals once (near-miss)" 'Fix: Thing.' +assert_idempotent "normalizing twice equals once (body + trailers)" \ + 'Feat(api)!: Drop v1. +Body text here. +Nightshift-Task: commit-normalize +Co-Authored-By: Someone ' +assert_idempotent "normalizing twice equals once (merge)" \ + 'Merge pull request #4 from marcus/x' + +assert_idempotent "message with a git template block" 'Fix: Thing. + +#123 explains why. + +# Please enter the commit message for your changes. +# On branch main' + +echo "" +echo "validation:" +assert_valid "canonical message" 'feat(api): add rate limiting' +assert_valid "breaking change" 'feat!: drop v1' +assert_valid "merge commit" 'Merge pull request #4 from marcus/x' +assert_valid "fixup commit" 'fixup! feat: add thing' +assert_valid "body and trailers" 'fix: tighten timeout + +Because it was too generous. + +Nightshift-Task: commit-normalize' + +assert_invalid "unknown type" 'update: readme' 'type' +assert_invalid "no type prefix" 'Update readme' 'type' +assert_invalid "empty subject" 'feat: ' 'subject' +assert_invalid "over-length subject" \ + "feat: $(printf 'x%.0s' {1..80})" '72' +# Subject length is a character count, not a byte count: an accented subject +# that is comfortably under the limit must not be rejected just because UTF-8 +# spends two bytes on each accented letter. +assert_valid "multibyte subject under the limit is accepted" \ + "feat: $(printf 'é%.0s' {1..60})" +assert_invalid "multibyte subject over the limit is rejected" \ + "feat: $(printf 'é%.0s' {1..80})" '72' + +assert_invalid "trailing period" 'feat: add thing.' 'period' +assert_invalid "capitalized subject" 'feat: Add thing' 'lowercase' +assert_invalid "missing blank line before body" 'feat: add thing +body starts immediately' 'blank line' + +assert_valid "trailing git template block is ignored" 'fix: tighten timeout +# Please enter the commit message for your changes. +# On branch main' + +# A '#' line with real content under it cannot be a git template, so it is +# body text and the missing blank line after the subject is still reported. +assert_invalid "a # body line is treated as body, not a comment" 'feat: add thing +#123 body starts immediately +and continues here' 'blank line' + +# The unavoidable blind spot: a trailing '#' line is indistinguishable from a +# git template, so validation ignores it. Erring this way costs a missed +# warning; erring the other way would reject every editor-authored commit. +assert_valid "a trailing # line is assumed to be a template" 'feat: add thing +#123 could be either' + +echo "" +echo "end-to-end (real git commit):" + +# The regression this guards: the hook must not eat a body that git itself +# would have kept. With `git commit -m` git only strips whitespace, so a body +# line beginning with '#' has to survive all the way into the commit object. +e2e_repo="$TMPDIR_TEST/e2e" +mkdir -p "$e2e_repo" +( + cd "$e2e_repo" || exit 1 + git init -q . + git config user.email tests@example.com + git config user.name tests + # The hook resolves its scripts through the repo root, so give the scratch + # repo a real copy of them and install the hook exactly as a developer would. + mkdir -p scripts .git/hooks + cp "$ROOT/scripts/normalize-commit-msg.sh" "$ROOT/scripts/validate-commit-msg.sh" \ + "$ROOT/scripts/commit-msg.sh" scripts/ + chmod +x scripts/*.sh + ln -sf ../../scripts/commit-msg.sh .git/hooks/commit-msg +) >/dev/null 2>&1 + +e2e_commit() { + ( + cd "$e2e_repo" || exit 1 + : > "file.$1" + git add -A + git commit -q "${@:2}" 2>&1 + ) +} + +e2e_message() { git -C "$e2e_repo" log -1 --pretty=%B; } + +if e2e_commit body -m 'Fix: Thing.' -m '#123 explains why. Do not lose me.' >/dev/null 2>&1; then + actual="$(e2e_message)" + expected='fix: thing + +#123 explains why. Do not lose me.' + if [[ "$actual" == "$expected"* ]]; then + ok "git commit -m keeps a # body line and still normalizes the subject" + else + fail "git commit -m keeps a # body line and still normalizes the subject" \ + "expected prefix: $(printf '%q' "$expected")" "actual: $(printf '%q' "$actual")" + fi +else + fail "git commit -m keeps a # body line and still normalizes the subject" "the commit failed" +fi + +e2e_editor_commit() { + ( + cd "$e2e_repo" || exit 1 + : > "file.$1" + git add -A + printf '%s' "$2" > "$TMPDIR_TEST/editor-msg" + GIT_EDITOR="cp $TMPDIR_TEST/editor-msg" git commit -q 2>&1 + ) +} + +if e2e_editor_commit editor 'Fix: Another thing. + +# Please enter the commit message for your changes. +# On branch main' >/dev/null 2>&1; then + actual="$(e2e_message)" + if [[ "$actual" == 'fix: another thing'* && "$actual" != *'Please enter'* ]]; then + ok "editor-authored template block is stripped by git, subject normalized" + else + fail "editor-authored template block is stripped by git, subject normalized" \ + "actual: $(printf '%q' "$actual")" + fi +else + fail "editor-authored template block is stripped by git, subject normalized" "the commit failed" +fi + +echo "" +if [[ $FAIL -gt 0 ]]; then + echo "❌ $FAIL failed, $PASS passed" + exit 1 +fi +echo "✅ all $PASS tests passed"