diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..93d9b70 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu + +repo_root=$(git rev-parse --show-toplevel) +exec "$repo_root/scripts/normalize-commit-message.sh" "$@" diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..a02f0f3 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu + +repo_root=$(git rev-parse --show-toplevel) +exec "$repo_root/scripts/pre-commit.sh" "$@" diff --git a/Makefile b/Makefile index 088be01..10e4ec9 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-message test-verbose test-race coverage coverage-html lint clean deps check install calibrate-providers install-hooks help # Binary name BINARY=nightshift @@ -18,9 +18,13 @@ calibrate-providers: go run ./cmd/provider-calibration --repo "$$(pwd)" --codex-originator codex_cli_rs --min-user-turns 2 # Run all tests -test: +test: test-commit-message go test ./... +# Run commit-message hook regression tests +test-commit-message: + ./tests/commit-message-normalizer.sh + # Run tests with verbose output test-verbose: go test -v ./... @@ -65,6 +69,7 @@ help: @echo "Available targets:" @echo " build - Build the binary" @echo " test - Run all tests" + @echo " test-commit-message - Run commit-message hook regression tests" @echo " test-verbose - Run tests with verbose output" @echo " test-race - Run tests with race detection" @echo " coverage - Run tests with coverage report" @@ -75,10 +80,10 @@ 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 - Enable repository-managed git hooks" @echo " help - Show this help" -# Install git pre-commit hook +# Enable repository-managed git hooks 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)" + @git config core.hooksPath .githooks + @echo "✓ repository hooks enabled (.githooks)" diff --git a/README.md b/README.md index b7c2b5d..3935b29 100644 --- a/README.md +++ b/README.md @@ -301,19 +301,52 @@ Each task has a default cooldown interval to prevent the same task from running ## Development -### Pre-commit hooks +### Git hooks and commit messages -Install the git pre-commit hook to catch formatting and vet issues before pushing: +Enable the repository-managed hooks before committing: ```bash make install-hooks +# Equivalent command: +git config core.hooksPath .githooks ``` -This symlinks `scripts/pre-commit.sh` into `.git/hooks/pre-commit`. The hook runs: +The `pre-commit` hook runs: - **gofmt** - flags any staged `.go` files that need formatting - **go vet** - catches common correctness issues - **go build** - ensures the project compiles +The `commit-msg` hook normalizes and validates Conventional Commit subjects in +the `type(scope)!: summary` format. The scope and `!` marker are optional. The +supported types are `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, +`refactor`, `style`, and `test`. + +The normalizer lowercases supported types, removes spacing around the optional +scope and breaking-change marker, collapses repeated subject whitespace, trims +leading and trailing blank lines, and removes trailing whitespace. Message body +paragraphs and Git trailers otherwise keep their original content. + +Examples: + +```text +feat(run): add pause command +fix(config)!: reject unknown provider keys +docs: explain hook installation +``` + +Merge and revert messages, `fixup!`, `squash!`, and `amend!` commits, stash +subjects, and comment-led messages generated by Git are preserved byte for byte. +Comment-led messages honor Git's configured `core.commentChar` or +`core.commentString` prefix, falling back to Git's default `#` prefix. +An empty message, unsupported type, missing summary, or malformed scope is +rejected without changing the message file. + +Run the shell regression suite directly with: + +```bash +make test-commit-message +``` + To bypass in a pinch: `git commit --no-verify` ## Uninstalling diff --git a/scripts/normalize-commit-message.sh b/scripts/normalize-commit-message.sh new file mode 100755 index 0000000..8cdfe19 --- /dev/null +++ b/scripts/normalize-commit-message.sh @@ -0,0 +1,170 @@ +#!/bin/sh + +# Normalize and validate a Conventional Commit message in place. +set -eu + +usage() { + echo "usage: $0 " >&2 +} + +if [ "$#" -ne 1 ]; then + usage + exit 2 +fi + +message_file=$1 +if [ ! -f "$message_file" ] || [ ! -r "$message_file" ] || [ ! -w "$message_file" ]; then + echo "commit message is not a readable, writable file: $message_file" >&2 + exit 2 +fi + +# Git creates these subjects itself. Rewriting them can break merges, reverts, +# autosquash, stashes, and interactive rebases, so preserve the entire file. +first_line=$(awk ' + { + line = $0 + sub(/\r$/, "", line) + if (line !~ /^[[:space:]]*$/) { + sub(/^[[:space:]]+/, "", line) + print line + exit + } + } +' "$message_file") + +comment_prefix=$( + git config --get-regexp '^core\.comment(char|string)$' 2>/dev/null | + awk ' + { + sub(/^[^[:space:]]+[[:space:]]+/, "") + prefix = $0 + } + END { print prefix } + ' +) +case "$comment_prefix" in + ""|auto) + comment_prefix="#" + ;; +esac + +case "$first_line" in + "$comment_prefix"*) + exit 0 + ;; +esac + +case "$first_line" in + Merge\ *|Revert\ *|fixup\!\ *|squash\!\ *|amend\!\ *|WIP\ on\ *|index\ on\ *) + exit 0 + ;; +esac + +tmp_file=$(mktemp "${TMPDIR:-/tmp}/nightshift-commit-msg.XXXXXX") || exit 2 +cleanup() { + rm -f "$tmp_file" +} +trap cleanup 0 +trap 'exit 1' 1 2 15 + +if ! awk ' + function trim(value) { + sub(/^[[:space:]]+/, "", value) + sub(/[[:space:]]+$/, "", value) + return value + } + + function collapse(value) { + value = trim(value) + gsub(/[[:space:]]+/, " ", value) + return value + } + + function supported(value) { + return value == "build" || value == "chore" || value == "ci" || + value == "docs" || value == "feat" || value == "fix" || + value == "perf" || value == "refactor" || value == "style" || + value == "test" + } + + { + line = $0 + sub(/[[:space:]]+$/, "", line) + lines[++count] = line + } + + END { + first = 1 + while (first <= count && lines[first] == "") { + first++ + } + + last = count + while (last >= first && lines[last] == "") { + last-- + } + + if (first > last) { + exit 1 + } + + subject = collapse(lines[first]) + colon = index(subject, ":") + if (colon == 0) { + exit 1 + } + + header = trim(substr(subject, 1, colon - 1)) + summary = collapse(substr(subject, colon + 1)) + if (summary == "") { + exit 1 + } + + breaking = "" + if (header ~ /![[:space:]]*$/) { + sub(/![[:space:]]*$/, "", header) + header = trim(header) + breaking = "!" + } + + open = index(header, "(") + scope = "" + if (open > 0) { + if (substr(header, length(header), 1) != ")") { + exit 1 + } + type = tolower(trim(substr(header, 1, open - 1))) + scope = collapse(substr(header, open + 1, length(header) - open - 1)) + if (scope == "" || scope !~ /^[[:alnum:]_.\/#-]+$/) { + exit 1 + } + } else { + type = tolower(trim(header)) + if (type ~ /[()]/) { + exit 1 + } + } + + if (!supported(type)) { + exit 1 + } + + normalized = type + if (scope != "") { + normalized = normalized "(" scope ")" + } + print normalized breaking ": " summary + + for (i = first + 1; i <= last; i++) { + print lines[i] + } + } +' "$message_file" > "$tmp_file"; then + cat >&2 <<'EOF' +invalid commit subject; expected type(scope)!: summary +supported types: build, chore, ci, docs, feat, fix, perf, refactor, style, test +EOF + exit 1 +fi + +cat "$tmp_file" > "$message_file" diff --git a/tests/commit-message-normalizer.sh b/tests/commit-message-normalizer.sh new file mode 100755 index 0000000..b08a14c --- /dev/null +++ b/tests/commit-message-normalizer.sh @@ -0,0 +1,182 @@ +#!/bin/sh +set -eu + +repo_root=$(CDPATH= cd "$(dirname "$0")/.." && pwd) +normalizer="$repo_root/scripts/normalize-commit-message.sh" +hook="$repo_root/.githooks/commit-msg" +test_dir=$(mktemp -d "${TMPDIR:-/tmp}/nightshift-commit-msg-test.XXXXXX") +cleanup() { + rm -rf "$test_dir" +} +trap cleanup 0 +trap 'exit 1' 1 2 15 + +tests_run=0 + +fail() { + echo "not ok - $1" >&2 + exit 1 +} + +assert_normalizes() { + name=$1 + input=$2 + expected=$3 + message="$test_dir/message" + expected_file="$test_dir/expected" + + printf '%b' "$input" > "$message" + printf '%b' "$expected" > "$expected_file" + "$normalizer" "$message" || fail "$name (normalizer failed)" + cmp -s "$expected_file" "$message" || { + diff -u "$expected_file" "$message" >&2 || true + fail "$name" + } + tests_run=$((tests_run + 1)) + echo "ok - $name" +} + +assert_preserved() { + name=$1 + input=$2 + assert_normalizes "$name" "$input" "$input" +} + +assert_preserved_with_comment_prefix() { + name=$1 + comment_key=$2 + comment_prefix=$3 + input=$4 + message="$test_dir/message" + expected_file="$test_dir/expected" + + printf '%b' "$input" > "$message" + printf '%b' "$input" > "$expected_file" + GIT_CONFIG_COUNT=1 \ + GIT_CONFIG_KEY_0="core.$comment_key" \ + GIT_CONFIG_VALUE_0="$comment_prefix" \ + "$normalizer" "$message" || fail "$name (normalizer failed)" + cmp -s "$expected_file" "$message" || { + diff -u "$expected_file" "$message" >&2 || true + fail "$name" + } + tests_run=$((tests_run + 1)) + echo "ok - $name" +} + +assert_rejected() { + name=$1 + input=$2 + message="$test_dir/message" + original="$test_dir/original" + + printf '%b' "$input" > "$message" + cp "$message" "$original" + if "$normalizer" "$message" >/dev/null 2>&1; then + fail "$name (unexpected success)" + fi + cmp -s "$original" "$message" || fail "$name (message changed)" + tests_run=$((tests_run + 1)) + echo "ok - $name" +} + +assert_normalizes \ + "already-valid subject" \ + 'feat(cli): add status output +' \ + 'feat(cli): add status output +' + +assert_normalizes \ + "capitalization and spacing" \ + ' + FEAT ( cli ) ! : add status output\040\040\040 + +' \ + 'feat(cli)!: add status output +' + +assert_normalizes \ + "scoped breaking change" \ + 'FIX (config)!: reject unknown keys +' \ + 'fix(config)!: reject unknown keys +' + +assert_normalizes \ + "body paragraphs and trailers" \ + 'DOCS : explain provider setup\040\040\040 + +Keep repeated body spacing.\040\040\040 + +Nightshift-Task: commit-normalize\040\040\040 +Nightshift-Ref: https://github.com/marcus/nightshift + +' \ + 'docs: explain provider setup + +Keep repeated body spacing. + +Nightshift-Task: commit-normalize +Nightshift-Ref: https://github.com/marcus/nightshift +' + +assert_rejected "blank input" ' +\040\040\040 +' +assert_rejected "unsupported type" 'release: prepare 1.0 +' + +assert_preserved "merge message" 'Merge branch '\''main'\'' into feature\040\040\040 + +Generated merge body.\040\040\040 +' +assert_preserved "revert message" 'Revert "feat: remove legacy API" + +This reverts commit abc123.\040\040\040 +' +assert_preserved "fixup commit" 'fixup! feat(cli): add status output\040\040\040 +' +assert_preserved "squash commit" 'squash! fix(config): reject unknown keys\040\040\040 +' +assert_preserved "generated rebase message" '# This is a combination of 2 commits. +# This is the 1st commit message: + +feat: first message\040\040\040 +' +assert_preserved_with_comment_prefix \ + "generated message with configured comment character" \ + "commentChar" \ + ";" \ + '; Please enter the commit message for your changes.\040\040\040 +; Lines starting with '\'';'\'' will be ignored. +' +assert_preserved_with_comment_prefix \ + "generated message with configured comment string" \ + "commentString" \ + "//" \ + '// Please enter the commit message for your changes.\040\040\040 +// Lines starting with '\''//'\'' will be ignored. +' + +message="$test_dir/message" +printf '%b' ' CHORE : normalize messages\040 +' > "$message" +"$hook" "$message" || fail "commit-msg hook" +printf '%s' 'chore: normalize messages +' > "$test_dir/expected" +cmp -s "$test_dir/expected" "$message" || fail "commit-msg hook" +if "$hook" "$test_dir/missing" >/dev/null 2>&1; then + fail "commit-msg hook propagates failures" +fi +tests_run=$((tests_run + 2)) +echo "ok - commit-msg hook" +echo "ok - commit-msg hook propagates failures" + +cp "$message" "$test_dir/once" +"$normalizer" "$message" || fail "idempotence (second run failed)" +cmp -s "$test_dir/once" "$message" || fail "idempotence" +tests_run=$((tests_run + 1)) +echo "ok - idempotence" + +echo "$tests_run tests passed"