From ca1f1c4b3c8ddc305e873c729a9b92e7927c9799 Mon Sep 17 00:00:00 2001 From: Greg Gardner Date: Sat, 15 Aug 2026 02:12:11 -0700 Subject: [PATCH 1/3] chore: add a conventional commit message hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a dependency-free POSIX shell commit-msg hook. The normalizer fixes only what it can fix with certainty — trailing whitespace and periods, a capitalized type or leading imperative verb, spacing after the colon, and the blank line before the body — and copies the body and trailers through verbatim. Anything it cannot parse is left alone for the validator to reject with a specific error. Installation stays opt-in: make install-hooks symlinks both hooks into .git/hooks, matching how the existing pre-commit hook is installed, and make uninstall-hooks removes them. No git config is changed implicitly. Nightshift-Task: commit-normalize Nightshift-Ref: https://github.com/marcus/nightshift --- Makefile | 21 ++- scripts/commit-msg-lib.sh | 92 ++++++++++ scripts/commit-msg.sh | 31 ++++ scripts/normalize-commit-msg.sh | 161 +++++++++++++++++ scripts/tests/commit-msg.test.sh | 300 +++++++++++++++++++++++++++++++ scripts/validate-commit-msg.sh | 151 ++++++++++++++++ 6 files changed, 752 insertions(+), 4 deletions(-) create mode 100644 scripts/commit-msg-lib.sh create mode 100755 scripts/commit-msg.sh create mode 100755 scripts/normalize-commit-msg.sh create mode 100755 scripts/tests/commit-msg.test.sh create mode 100755 scripts/validate-commit-msg.sh diff --git a/Makefile b/Makefile index 088be01..2ef27ba 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-verbose test-race test-commit-msg coverage coverage-html lint clean deps check install calibrate-providers install-hooks uninstall-hooks help # Binary name BINARY=nightshift @@ -29,6 +29,10 @@ test-verbose: test-race: go test -race ./... +# Run the commit message normalizer/validator shell tests +test-commit-msg: + @sh scripts/tests/commit-msg.test.sh + # Run tests with coverage report coverage: go test -coverprofile=coverage.out ./... @@ -58,7 +62,7 @@ deps: go mod tidy # Run all checks (test + lint) -check: test lint +check: test test-commit-msg lint # Show help help: @@ -67,6 +71,7 @@ help: @echo " test - Run all tests" @echo " test-verbose - Run tests with verbose output" @echo " test-race - Run tests with race detection" + @echo " test-commit-msg - Run the commit message hook tests" @echo " coverage - Run tests with coverage report" @echo " coverage-html - Generate HTML coverage report" @echo " lint - Run golangci-lint" @@ -75,10 +80,18 @@ 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 " uninstall-hooks - Remove the hooks installed by install-hooks" @echo " help - Show this help" -# Install git pre-commit hook +# Install git hooks. Opt-in: nothing installs these for you. 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)" + +# Remove the hooks installed by install-hooks +uninstall-hooks: + @rm -f .git/hooks/pre-commit .git/hooks/commit-msg + @echo "✓ pre-commit and commit-msg hooks removed" diff --git a/scripts/commit-msg-lib.sh b/scripts/commit-msg-lib.sh new file mode 100644 index 0000000..fcf1d25 --- /dev/null +++ b/scripts/commit-msg-lib.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env sh +# Shared helpers for the commit message normalizer and validator. +# POSIX sh, no external dependencies beyond awk/sed/tr and git. +# shellcheck shell=sh + +# Allowed Conventional Commits types. Derived from the types this repo's own +# history already uses (feat, fix, docs, chore, test, refactor) plus the +# remaining standard set so the vocabulary is not artificially narrow. +CM_TYPES="build chore ci docs feat fix perf refactor revert style test" + +# Maximum subject length, matching the git convention of a short summary that +# survives `git log --oneline` and GitHub's UI without truncation. +CM_MAX_SUBJECT=72 + +# Imperative verbs the normalizer is willing to lowercase automatically. +# Deliberately an allowlist: lowercasing anything else risks mangling a proper +# noun ("Nightshift", "GitHub", "API") that is legitimately capitalized. +CM_VERBS="add allow apply avoid bump clean correct document drop ensure \ +fix guard handle harden ignore improve make move prevent refactor remove \ +rename replace restore skip stop support switch update use verify wire" + +# Print the effective git comment character (default '#'). +cm_comment_char() { + cc=$(git config --get core.commentChar 2>/dev/null || true) + if [ -z "$cc" ] || [ "$cc" = "auto" ]; then + cc='#' + fi + printf '%s' "$cc" +} + +# Print the 1-based line number of the subject line in a commit message file, +# or 0 if the message has no subject (empty or comments only). +# Usage: cm_subject_lineno +cm_subject_lineno() { + awk -v c="$2" ' + { + if (substr($0, 1, 1) == c) next + stripped = $0 + gsub(/^[ \t]+|[ \t]+$/, "", stripped) + if (stripped == "") next + print NR + found = 1 + exit + } + END { if (!found) print 0 } + ' "$1" +} + +# True when the subject belongs to a commit git generates or rewrites itself. +# These are exempt from both normalization and validation. +cm_is_exempt() { + case "$1" in + "Merge "* | "Revert \""* | "fixup! "* | "squash! "* | "amend! "*) return 0 ;; + esac + return 1 +} + +# True when the subject starts with a well-formed `type`, `type(scope)`, +# `type!` or `type(scope)!` prefix followed by a colon. Case-insensitive on the +# type so the normalizer can repair "Fix: ..." before validation runs. +cm_has_type_prefix() { + case "$1" in + *:*) ;; + *) return 1 ;; + esac + prefix=${1%%:*} + printf '%s' "$prefix" | grep -Eq '^[A-Za-z]+(\([^()]+\))?!?$' || return 1 + t=$(cm_type_of "$1") + cm_is_known_type "$t" +} + +# Print the lowercased type token of a subject with a `type...:` prefix. +cm_type_of() { + prefix=${1%%:*} + prefix=${prefix%%(*} + prefix=${prefix%!} + printf '%s' "$prefix" | tr '[:upper:]' '[:lower:]' +} + +cm_is_known_type() { + for t in $CM_TYPES; do + [ "$1" = "$t" ] && return 0 + done + return 1 +} + +cm_is_known_verb() { + for v in $CM_VERBS; do + [ "$1" = "$v" ] && return 0 + done + return 1 +} diff --git a/scripts/commit-msg.sh b/scripts/commit-msg.sh new file mode 100755 index 0000000..79796f2 --- /dev/null +++ b/scripts/commit-msg.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env sh +# 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. Safe deviations +# (trailing period, capitalized type or verb, spacing) are fixed silently; +# anything the normalizer cannot fix is rejected with an explanation. +set -eu + +# Resolve $0 through any symlinks so the script finds its siblings when it is +# installed as .git/hooks/commit-msg. +cm_self=$0 +while [ -L "$cm_self" ]; do + cm_link=$(readlink "$cm_self") + case "$cm_link" in + /*) cm_self=$cm_link ;; + *) cm_self=$(dirname -- "$cm_self")/$cm_link ;; + esac +done +SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "$cm_self")" && pwd) +MSG_FILE=$1 + +BEFORE=$(cat "$MSG_FILE") +"$SCRIPT_DIR/normalize-commit-msg.sh" "$MSG_FILE" +AFTER=$(cat "$MSG_FILE") + +if [ "$BEFORE" != "$AFTER" ]; then + echo "🪡 commit-msg: normalized commit message" +fi + +exec "$SCRIPT_DIR/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..abb0f2e --- /dev/null +++ b/scripts/normalize-commit-msg.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env sh +# Normalize a commit message file in place toward the Conventional Commits +# standard documented in docs/commit-messages.md. +# +# Usage: scripts/normalize-commit-msg.sh +# +# The normalizer is conservative by design. It only applies mechanical fixes it +# can make with certainty: +# +# - strips trailing whitespace and trailing periods from the subject +# - lowercases a known type token ("Fix:" -> "fix:") +# - normalizes spacing after the colon ("fix:add x" -> "fix: add x") +# - lowercases the first word of the description when it is a known +# imperative verb ("fix: Add x" -> "fix: add x") +# - ensures exactly one blank line between the subject and the body +# +# It never touches the body or trailers, never edits merge/revert/fixup/squash +# commits, and leaves anything it cannot parse confidently alone so the +# validator can report a precise error instead. Exit status is 0 whenever the +# file is readable, whether or not anything changed. +set -eu + +# Resolve $0 through any symlinks so the script finds its siblings when it is +# installed as .git/hooks/commit-msg. +cm_self=$0 +while [ -L "$cm_self" ]; do + cm_link=$(readlink "$cm_self") + case "$cm_link" in + /*) cm_self=$cm_link ;; + *) cm_self=$(dirname -- "$cm_self")/$cm_link ;; + esac +done +SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "$cm_self")" && pwd) +# shellcheck source=scripts/commit-msg-lib.sh +. "$SCRIPT_DIR/commit-msg-lib.sh" + +MSG_FILE=${1:-} +if [ -z "$MSG_FILE" ]; then + echo "usage: $(basename "$0") " >&2 + exit 2 +fi +if [ ! -f "$MSG_FILE" ]; then + echo "$(basename "$0"): no such file: $MSG_FILE" >&2 + exit 2 +fi + +COMMENT_CHAR=$(cm_comment_char) +SUBJECT_LINE=$(cm_subject_lineno "$MSG_FILE" "$COMMENT_CHAR") + +# Nothing to normalize: empty message, or comments only. +[ "$SUBJECT_LINE" -eq 0 ] && exit 0 + +subject=$(sed -n "${SUBJECT_LINE}p" "$MSG_FILE") + +# Leave git's own generated commits untouched. +cm_is_exempt "$subject" && exit 0 + +original_subject=$subject + +# 1. Trailing whitespace. +subject=$(printf '%s' "$subject" | sed 's/[[:space:]]*$//') + +# 2. Trailing periods. Ellipses are stripped too; a subject ending in a period +# carries no meaning the description does not already carry. +while :; do + case "$subject" in + *.) subject=${subject%.} ;; + *) break ;; + esac +done + +# 3..5 only apply when the subject confidently parses as `type(scope)!: desc`. +if cm_has_type_prefix "$subject"; then + prefix=${subject%%:*} + desc=${subject#*:} + + # 3. Lowercase the type token, preserving any scope and the `!` marker. + type=$(cm_type_of "$subject") + scope="" + case "$prefix" in + *\(*\)*) scope=$(printf '%s' "$prefix" | sed -n 's/^[^(]*\(([^()]*)\).*$/\1/p') ;; + esac + bang="" + case "$prefix" in + *!) bang="!" ;; + esac + prefix="${type}${scope}${bang}" + + # 4. Exactly one space after the colon. + while :; do + case "$desc" in + " "* | " "*) desc=${desc#?} ;; + *) break ;; + esac + done + + # 5. Lowercase a capitalized leading imperative verb, and only that. + first_word=${desc%% *} + if printf '%s' "$first_word" | grep -Eq '^[A-Z][a-z]+$'; then + lowered=$(printf '%s' "$first_word" | tr '[:upper:]' '[:lower:]') + if cm_is_known_verb "$lowered"; then + desc="${lowered}${desc#"$first_word"}" + fi + fi + + if [ -n "$desc" ]; then + subject="${prefix}: ${desc}" + fi +fi + +# Rewrite the file only when something actually changed, so an already +# conforming message keeps its exact bytes (including mtime-sensitive tooling +# behaviour downstream). +blank_run=$(awk -v start="$((SUBJECT_LINE + 1))" ' + NR < start { next } + { + stripped = $0 + gsub(/^[ \t]+|[ \t]+$/, "", stripped) + if (stripped != "") exit + n++ + } + END { print n + 0 } +' "$MSG_FILE") +has_body=$(awk -v start="$((SUBJECT_LINE + 1))" ' + NR < start { next } + { + stripped = $0 + gsub(/^[ \t]+|[ \t]+$/, "", stripped) + if (stripped != "") { print 1; found = 1; exit } + } + END { if (!found) print 0 } +' "$MSG_FILE") + +# 6. Exactly one blank line between subject and body. Body content, including +# interior blank lines and trailers, is copied through verbatim. +want_blank=0 +if [ "$has_body" -eq 1 ]; then + want_blank=1 +fi + +if [ "$subject" = "$original_subject" ] && [ "$blank_run" -eq "$want_blank" ]; then + exit 0 +fi + +tmp="${MSG_FILE}.normalized.$$" +{ + if [ "$SUBJECT_LINE" -gt 1 ]; then + sed -n "1,$((SUBJECT_LINE - 1))p" "$MSG_FILE" + fi + printf '%s\n' "$subject" + if [ "$want_blank" -eq 1 ]; then + printf '\n' + fi + rest_start=$((SUBJECT_LINE + 1 + blank_run)) + sed -n "${rest_start},\$p" "$MSG_FILE" +} >"$tmp" + +# Preserve the original file rather than replacing it, so git's own file handle +# and any permissions/ownership on the message file survive. +cat "$tmp" >"$MSG_FILE" +rm -f "$tmp" diff --git a/scripts/tests/commit-msg.test.sh b/scripts/tests/commit-msg.test.sh new file mode 100755 index 0000000..f936635 --- /dev/null +++ b/scripts/tests/commit-msg.test.sh @@ -0,0 +1,300 @@ +#!/usr/bin/env sh +# Tests for scripts/normalize-commit-msg.sh and scripts/validate-commit-msg.sh. +# Run: make test-commit-msg (or: sh scripts/tests/commit-msg.test.sh) +set -eu + +TEST_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) +SCRIPTS=$(CDPATH='' cd -- "$TEST_DIR/.." && pwd) +NORMALIZE="$SCRIPTS/normalize-commit-msg.sh" +VALIDATE="$SCRIPTS/validate-commit-msg.sh" + +WORK=$(mktemp -d "${TMPDIR:-/tmp}/commit-msg-tests.XXXXXX") +trap 'rm -rf "$WORK"' EXIT + +PASS=0 +FAIL=0 + +msg_file() { + f="$WORK/msg" + # %b so tests can express trailing whitespace with an explicit \n + printf '%b' "$1" >"$f" + printf '%s' "$f" +} + +# normalizes +normalizes() { + name=$1 + f=$(msg_file "$2") + if ! "$NORMALIZE" "$f" >"$WORK/out" 2>&1; then + FAIL=$((FAIL + 1)) + echo "✗ $name — normalizer exited non-zero" + sed 's/^/ /' "$WORK/out" + return 0 + fi + got=$(cat "$f") + want=$(printf '%b' "$3") + if [ "$got" = "$want" ]; then + PASS=$((PASS + 1)) + echo "✓ $name" + else + FAIL=$((FAIL + 1)) + echo "✗ $name" + echo " want: $(printf '%s' "$want" | sed -n '1,20p' | sed 's/^/ /' | tr '\n' '@')" + echo " got: $(printf '%s' "$got" | sed -n '1,20p' | sed 's/^/ /' | tr '\n' '@')" + fi +} + +# accepts +accepts() { + f=$(msg_file "$2") + if "$VALIDATE" "$f" >"$WORK/out" 2>&1; then + PASS=$((PASS + 1)) + echo "✓ $1" + else + FAIL=$((FAIL + 1)) + echo "✗ $1 — expected accept, got reject" + sed 's/^/ /' "$WORK/out" + fi +} + +# rejects +rejects() { + f=$(msg_file "$2") + if "$VALIDATE" "$f" >"$WORK/out" 2>&1; then + FAIL=$((FAIL + 1)) + echo "✗ $1 — expected reject, got accept" + return 0 + fi + if grep -qF "$3" "$WORK/out"; then + PASS=$((PASS + 1)) + echo "✓ $1" + else + FAIL=$((FAIL + 1)) + echo "✗ $1 — rejected, but the message did not mention '$3'" + sed 's/^/ /' "$WORK/out" + fi +} + +# hook_accepts — full hook path: normalize then validate. +hook_accepts() { + f=$(msg_file "$2") + if "$NORMALIZE" "$f" >"$WORK/out" 2>&1 && "$VALIDATE" "$f" >>"$WORK/out" 2>&1; then + PASS=$((PASS + 1)) + echo "✓ $1" + else + FAIL=$((FAIL + 1)) + echo "✗ $1 — hook rejected a message it should have normalized" + sed 's/^/ /' "$WORK/out" + fi +} + +echo "commit message normalizer" + +normalizes "conforming message is untouched" \ + 'feat(runner): add a retry budget +' \ + 'feat(runner): add a retry budget +' + +normalizes "trailing period is stripped" \ + 'fix: guard against a nil provider. +' \ + 'fix: guard against a nil provider +' + +normalizes "capitalized verb is lowercased" \ + 'fix: Guard against a nil provider +' \ + 'fix: guard against a nil provider +' + +normalizes "capitalized type is lowercased" \ + 'Fix: guard against a nil provider +' \ + 'fix: guard against a nil provider +' + +normalizes "missing space after colon is inserted" \ + 'fix:guard against a nil provider +' \ + 'fix: guard against a nil provider +' + +normalizes "scope and breaking marker survive normalization" \ + 'Feat(config)!: Drop the legacy schema. +' \ + 'feat(config)!: drop the legacy schema +' + +normalizes "proper noun after the type is left alone" \ + 'docs: Nightshift now documents its hooks +' \ + 'docs: Nightshift now documents its hooks +' + +normalizes "trailing whitespace is stripped" \ + 'chore: tidy the makefile \n' \ + 'chore: tidy the makefile\n' + +normalizes "blank line is inserted before the body" \ + 'fix: guard against a nil provider +the provider map was read before it was populated. +' \ + 'fix: guard against a nil provider + +the provider map was read before it was populated. +' + +normalizes "extra blank lines before the body collapse to one" \ + 'fix: guard against a nil provider + + + +the provider map was read before it was populated. +' \ + 'fix: guard against a nil provider + +the provider map was read before it was populated. +' + +normalizes "body, interior blank lines and trailers are preserved verbatim" \ + 'fix: Guard against a nil provider. + +The provider map was read before it was populated. + +Details: + + - one + - two + +Nightshift-Task: commit-normalize +Nightshift-Ref: https://github.com/marcus/nightshift +Co-Authored-By: Someone +' \ + 'fix: guard against a nil provider + +The provider map was read before it was populated. + +Details: + + - one + - two + +Nightshift-Task: commit-normalize +Nightshift-Ref: https://github.com/marcus/nightshift +Co-Authored-By: Someone +' + +normalizes "merge commits are left untouched" \ + 'Merge pull request #46 from marcus/fix/navbar. +' \ + 'Merge pull request #46 from marcus/fix/navbar. +' + +normalizes "fixup commits are left untouched" \ + 'fixup! feat: Add a retry budget. +' \ + 'fixup! feat: Add a retry budget. +' + +normalizes "git-generated reverts are left untouched" \ + 'Revert "feat: add a retry budget." +' \ + 'Revert "feat: add a retry budget." +' + +normalizes "unparseable subjects are left alone apart from safe fixes" \ + 'Update makefile, selector +' \ + 'Update makefile, selector +' + +normalizes "git comments are preserved" \ + 'fix: Guard against a nil provider. + +# Please enter the commit message for your changes. +# On branch main +' \ + 'fix: guard against a nil provider + +# Please enter the commit message for your changes. +# On branch main +' + +echo "" +echo "commit message validator" + +accepts "conforming subject" 'feat(runner): add a retry budget +' +accepts "breaking change marker" 'feat(config)!: drop the legacy schema +' +accepts "subject with body and trailers" 'fix: guard against a nil provider + +The provider map was read before it was populated. + +Nightshift-Task: commit-normalize +' +accepts "72-character subject is at the limit" 'feat: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +' +accepts "merge commit is exempt" 'Merge pull request #46 from marcus/fix/navbar +' +accepts "fixup commit is exempt" 'fixup! feat: add a retry budget +' +accepts "squash commit is exempt" 'squash! feat: add a retry budget +' +accepts "git-generated revert is exempt" 'Revert "feat: add a retry budget" +' +accepts "revert type is allowed" 'revert: feat: add a retry budget +' + +rejects "missing type" 'Update makefile, selector +' "no type prefix" + +rejects "unknown type" 'chores: tidy the makefile +' "unknown type 'chores'" + +rejects "uppercase type survives as malformed" 'Fix: guard against a nil provider +' "malformed type prefix" + +rejects "empty description" 'fix: +' "empty description" + +rejects "description is only whitespace" 'fix: +' "empty description" + +accepts "squash-merge PR suffix does not count toward the limit" 'feat: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa (#123) +' + +rejects "73-character subject" 'feat: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +' "limit is 72" + +rejects "trailing period" 'fix: guard against a nil provider. +' "ends with a period" + +rejects "missing space after the colon" 'fix:guard against a nil provider +' "missing space after the colon" + +rejects "body not separated by a blank line" 'fix: guard against a nil provider +the provider map was read before it was populated. +' "no blank line" + +rejects "empty message" '' "the message is empty" + +rejects "comments only" '# On branch main +' "the message is empty" + +echo "" +echo "hook (normalize, then validate)" + +hook_accepts "a fixable message passes the hook" 'Fix: Guard against a nil provider. +the provider map was read before it was populated. +' +hook_accepts "an already conforming message passes the hook" 'feat(runner): add a retry budget +' + +echo "" +if [ "$FAIL" -gt 0 ]; then + echo "❌ $FAIL failed, $PASS passed" + exit 1 +fi +echo "✅ all $PASS checks passed" diff --git a/scripts/validate-commit-msg.sh b/scripts/validate-commit-msg.sh new file mode 100755 index 0000000..8e17f17 --- /dev/null +++ b/scripts/validate-commit-msg.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env sh +# Validate a commit message against the standard in docs/commit-messages.md. +# +# Usage: +# scripts/validate-commit-msg.sh +# git log -1 --format=%B | scripts/validate-commit-msg.sh - +# +# Exits 0 when the message conforms (or is an exempt merge/revert/fixup/squash +# commit), and non-zero with an actionable message on stderr otherwise. +set -eu + +# Resolve $0 through any symlinks so the script finds its siblings when it is +# installed as .git/hooks/commit-msg. +cm_self=$0 +while [ -L "$cm_self" ]; do + cm_link=$(readlink "$cm_self") + case "$cm_link" in + /*) cm_self=$cm_link ;; + *) cm_self=$(dirname -- "$cm_self")/$cm_link ;; + esac +done +SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "$cm_self")" && pwd) +# shellcheck source=scripts/commit-msg-lib.sh +. "$SCRIPT_DIR/commit-msg-lib.sh" + +MSG_FILE=${1:-} +if [ -z "$MSG_FILE" ]; then + echo "usage: $(basename "$0") " >&2 + exit 2 +fi + +CLEANUP="" +if [ "$MSG_FILE" = "-" ]; then + CLEANUP="${TMPDIR:-/tmp}/commit-msg-stdin.$$" + cat >"$CLEANUP" + MSG_FILE=$CLEANUP +elif [ ! -f "$MSG_FILE" ]; then + echo "$(basename "$0"): no such file: $MSG_FILE" >&2 + exit 2 +fi + +finish() { + [ -n "$CLEANUP" ] && rm -f "$CLEANUP" + return 0 +} +trap finish EXIT + +fail() { + echo "commit message rejected: $1" >&2 + shift + for hint in "$@"; do + echo " $hint" >&2 + done + echo "" >&2 + echo " subject: $SUBJECT" >&2 + echo " see docs/commit-messages.md, or bypass with: git commit --no-verify" >&2 + exit 1 +} + +COMMENT_CHAR=$(cm_comment_char) +SUBJECT_LINE=$(cm_subject_lineno "$MSG_FILE" "$COMMENT_CHAR") + +if [ "$SUBJECT_LINE" -eq 0 ]; then + SUBJECT="(empty)" + fail "the message is empty" \ + "write a subject in the form: type(scope): description" +fi + +SUBJECT=$(sed -n "${SUBJECT_LINE}p" "$MSG_FILE") + +# Merge, revert, fixup, squash and amend commits are generated by git itself +# and are exempt from the standard. +cm_is_exempt "$SUBJECT" && exit 0 + +case "$SUBJECT" in +*:*) ;; +*) + fail "no type prefix" \ + "expected 'type: description', e.g. 'fix: guard against a nil provider'" \ + "allowed types: $CM_TYPES" + ;; +esac + +PREFIX=${SUBJECT%%:*} +DESC=${SUBJECT#*:} + +if ! printf '%s' "$PREFIX" | grep -Eq '^[a-z]+(\([a-z0-9._/-]+\))?!?$'; then + fail "malformed type prefix '$PREFIX'" \ + "expected lowercase 'type', 'type(scope)', 'type!' or 'type(scope)!'" \ + "allowed types: $CM_TYPES" +fi + +TYPE=$(cm_type_of "$SUBJECT") +if ! cm_is_known_type "$TYPE"; then + fail "unknown type '$TYPE'" \ + "allowed types: $CM_TYPES" +fi + +if [ -z "$(printf '%s' "$DESC" | tr -d '[:space:]')" ]; then + fail "empty description" \ + "describe the change in the imperative mood, e.g. '$TYPE: add a retry budget'" +fi + +case "$DESC" in +" "*) ;; +*) + fail "missing space after the colon" \ + "write '$TYPE: ', not '$TYPE:'" + ;; +esac + +DESC=${DESC# } +case "$DESC" in +" "*) + fail "more than one space after the colon" \ + "write '$TYPE: ' with exactly one space" + ;; +esac + +case "$DESC" in +*.) + fail "the subject ends with a period" \ + "drop the trailing '.' — subjects are titles, not sentences" + ;; +esac + +# GitHub appends " (#123)" to the subject when it squash-merges a pull request, +# which can push an otherwise fine subject over the limit. Authors never type +# it, so it is excluded from the measurement — this only matters when the +# validator is run over already-merged history. +MEASURED=$(printf '%s' "$SUBJECT" | sed -E 's/ \(#[0-9]+\)$//') +LEN=$(printf '%s' "$MEASURED" | awk '{ print length($0) }') +if [ "$LEN" -gt "$CM_MAX_SUBJECT" ]; then + fail "subject is $LEN characters (limit is $CM_MAX_SUBJECT)" \ + "shorten the subject and move the detail into the body" +fi + +# A body, when present, must be separated from the subject by a blank line so +# that git, GitHub and `git log --oneline` all agree on where the subject ends. +SECOND=$(sed -n "$((SUBJECT_LINE + 1))p" "$MSG_FILE") +if [ -n "$SECOND" ]; then + case "$SECOND" in + "$COMMENT_CHAR"*) ;; + *) + fail "no blank line between the subject and the body" \ + "leave line $((SUBJECT_LINE + 1)) blank before the body starts" + ;; + esac +fi + +exit 0 From d0a3499b8be70ef4f7126766ae32052f3a1ab519 Mon Sep 17 00:00:00 2001 From: Greg Gardner Date: Sat, 15 Aug 2026 02:12:23 -0700 Subject: [PATCH 2/3] ci: validate commit messages on pull requests Runs the validator over every commit in the pull request range so a message committed with --no-verify still has to be reworded before the branch is mergeable, and runs the hook's own shell tests. Nightshift-Task: commit-normalize Nightshift-Ref: https://github.com/marcus/nightshift --- .github/workflows/commit-lint.yml | 45 +++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .github/workflows/commit-lint.yml diff --git a/.github/workflows/commit-lint.yml b/.github/workflows/commit-lint.yml new file mode 100644 index 0000000..62f54fb --- /dev/null +++ b/.github/workflows/commit-lint.yml @@ -0,0 +1,45 @@ +name: Commit Lint + +on: + pull_request: + branches: [main] + +jobs: + commit-messages: + name: Commit messages + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Validate commit messages in this pull request + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -eu + failed=0 + commits=$(git rev-list "$BASE_SHA".."$HEAD_SHA") + if [ -z "$commits" ]; then + echo "No commits to check." + exit 0 + fi + for sha in $commits; do + if git log -1 --format=%B "$sha" | scripts/validate-commit-msg.sh -; then + echo "✓ $(git log -1 --format='%h %s' "$sha")" + else + echo " ↳ in commit $(git log -1 --format='%h' "$sha")" + failed=$((failed + 1)) + fi + done + echo "" + if [ "$failed" -gt 0 ]; then + echo "❌ $failed commit message(s) do not follow docs/commit-messages.md" + exit 1 + fi + echo "✅ all commit messages conform" + + - name: Run the commit message hook tests + run: make test-commit-msg From d78893fcec7ec9c1db1fc613139fc502bfbea526 Mon Sep 17 00:00:00 2001 From: Greg Gardner Date: Sat, 15 Aug 2026 02:12:24 -0700 Subject: [PATCH 3/3] docs: document the commit message standard Spells out the format, the allowed type list and where it came from, the exemptions, worked good and bad examples, exactly what the normalizer will and will not rewrite, and when --no-verify is legitimate. Adds a CONTRIBUTING.md, which the repository did not have, and links it from the README alongside the updated hook instructions. Existing history is deliberately not rewritten. Nightshift-Task: commit-normalize Nightshift-Ref: https://github.com/marcus/nightshift --- CONTRIBUTING.md | 56 ++++++++++++++++ README.md | 21 ++++-- docs/commit-messages.md | 144 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 217 insertions(+), 4 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 docs/commit-messages.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4cd19cf --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,56 @@ +# Contributing to Nightshift + +Thanks for helping out. This file covers the mechanics; see +[README.md](README.md) for what Nightshift is and how to run it. + +## Getting set up + +```sh +go build ./... +make test # go test ./... +make install-hooks # opt-in git hooks (pre-commit + commit-msg) +``` + +`make install-hooks` symlinks the hooks in `scripts/` into `.git/hooks`. It is +explicit on purpose — nothing installs hooks or edits your git config for you. +Remove them with `make uninstall-hooks`. + +## Commit messages + +Commit subjects follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/): + +``` +type(scope)!: description +``` + +- allowed types: `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, + `refactor`, `revert`, `style`, `test` +- imperative mood, no trailing period, 72 characters or fewer +- a body, if present, is separated from the subject by a blank line +- merge, revert, fixup and squash commits are exempt + +The full standard, including what the normalizer will and will not rewrite and +when bypassing the hook is legitimate, is in +[docs/commit-messages.md](docs/commit-messages.md). + +The `commit-msg` hook fixes safe deviations (trailing period, capitalized type +or leading verb, spacing) in place and rejects anything it cannot fix. The +**Commit Lint** CI job checks every commit in a pull request, so `--no-verify` +defers the problem rather than avoiding it. + +Existing history is not rewritten; the standard applies to new commits. + +## Before opening a pull request + +```sh +make check # go test ./... + commit message tests + golangci-lint +``` + +`make test-commit-msg` runs the commit message hook tests on their own. They are +dependency-free POSIX shell and run under `dash`, `bash`, and `zsh`. + +## Pull requests + +- keep the change focused; one concern per pull request +- update `README.md` and `docs/` when behaviour or configuration changes +- note anything intentionally left out of scope in the pull request description diff --git a/README.md b/README.md index 84f92cd..a61a50d 100644 --- a/README.md +++ b/README.md @@ -258,21 +258,34 @@ Each task has a default cooldown interval to prevent the same task from running ## Development -### Pre-commit hooks +### Git hooks -Install the git pre-commit hook to catch formatting and vet issues before pushing: +Install the git hooks to catch formatting, vet, and commit message issues before pushing: ```bash -make install-hooks +make install-hooks # install +make uninstall-hooks # remove ``` -This symlinks `scripts/pre-commit.sh` into `.git/hooks/pre-commit`. The hook runs: +This symlinks `scripts/pre-commit.sh` into `.git/hooks/pre-commit` and +`scripts/commit-msg.sh` into `.git/hooks/commit-msg`. + +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 the commit message toward +[Conventional Commits](docs/commit-messages.md) and rejects what it cannot +safely fix. The same rules are checked in CI for every commit in a pull request. + To bypass in a pinch: `git commit --no-verify` +### Commit messages + +Commit subjects follow `type(scope): description` — see +[docs/commit-messages.md](docs/commit-messages.md) and [CONTRIBUTING.md](CONTRIBUTING.md). + ## Uninstalling ```bash diff --git a/docs/commit-messages.md b/docs/commit-messages.md new file mode 100644 index 0000000..228417c --- /dev/null +++ b/docs/commit-messages.md @@ -0,0 +1,144 @@ +# Commit Messages + +Nightshift uses [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) +for commit subjects. The format is machine-parseable, which keeps the door open +for generated changelogs and release notes, and it is already what most of this +repository's history looks like. + +Two things enforce it, and both are opt-in or advisory rather than magic: + +- a local `commit-msg` hook that normalizes what it safely can and rejects what + it cannot (`make install-hooks`) +- a CI job that validates every commit in a pull request + (`.github/workflows/commit-lint.yml`) + +Existing history is **not** rewritten. The standard applies going forward. + +## The format + +``` +type(scope)!: description + +Optional body, wrapped at 72 columns. + +Trailer-Key: value +``` + +Rules: + +| Rule | Detail | +|------|--------| +| Type | One of `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, `test`. Lowercase. | +| Scope | Optional, in parentheses, lowercase: `feat(runner)`, `fix(config)`. | +| Breaking | Optional `!` before the colon: `feat(config)!: drop the legacy schema`. | +| Separator | Exactly one space after the colon. | +| Description | Imperative mood ("add", not "added" or "adds"). No trailing period. | +| Subject length | 72 characters or fewer, including the type prefix. A trailing ` (#123)` that GitHub appends on squash merge is not counted. | +| Body | Optional. Separated from the subject by one blank line. | +| Trailers | Last, one per line: `Co-Authored-By:`, `Nightshift-Task:`, and so on. | + +The allowed type list is derived from the types this repository's own history +already uses (`feat`, `fix`, `docs`, `chore`, `test`, `refactor`) plus the rest +of the standard Conventional Commits set, so the vocabulary is not artificially +narrow. + +### Exempt commits + +Commits git generates or rewrites itself are never normalized and never +rejected: + +- `Merge ...` +- `Revert "..."` +- `fixup! ...`, `squash! ...`, `amend! ...` + +## Examples + +Good: + +``` +feat(runner): add a per-provider retry budget +fix: guard against a nil provider map +docs: document the commit message standard +refactor(config)!: drop the v1 schema loader +chore: bump golangci-lint to v1.62 +``` + +Rejected, and why: + +| Subject | Problem | +|---------|---------| +| `Update the makefile` | No type prefix. | +| `chores: tidy the makefile` | `chores` is not an allowed type. | +| `Fix: guard against a nil provider` | Type must be lowercase (the hook fixes this for you). | +| `fix:guard against a nil provider` | Missing space after the colon (the hook fixes this for you). | +| `fix: guard against a nil provider.` | Trailing period (the hook fixes this for you). | +| `feat: <73+ characters>` | Subject exceeds 72 characters. | +| subject immediately followed by body | Missing blank line after the subject. | + +## What the normalizer will and will not do + +`scripts/normalize-commit-msg.sh` is deliberately conservative. It applies only +mechanical fixes it can make with certainty: + +- strips trailing whitespace from the subject +- strips trailing periods from the subject +- lowercases a recognized type token (`Fix:` → `fix:`) +- inserts the missing space after the colon (`fix:add x` → `fix: add x`) +- lowercases the first word of the description **only** when that word is a + known imperative verb (`fix: Add x` → `fix: add x`) +- ensures exactly one blank line between the subject and the body + +It will not: + +- touch the body, interior blank lines, or trailers — those are copied verbatim +- guess a type for a subject that has none +- lowercase a leading word it does not recognize, so `docs: Nightshift now …` + keeps its proper noun +- edit merge, revert, fixup or squash commits + +When it cannot parse a subject confidently it leaves the message alone and lets +`scripts/validate-commit-msg.sh` explain the problem. The normalizer can never +turn a valid message into an invalid one. + +## Installing the hook + +Hook installation is explicit. Nothing changes your git configuration on clone, +build, or test. + +```sh +make install-hooks # installs .git/hooks/pre-commit and .git/hooks/commit-msg +make uninstall-hooks # removes both +``` + +Both hooks are symlinks into `scripts/`, so they track the checked-out branch. + +## Bypassing the hook + +```sh +git commit --no-verify -m "…" +``` + +Legitimate reasons to bypass: + +- you are mid-rebase or scripting a mechanical history operation +- a vendored or generated commit message must be preserved byte-for-byte +- the hook itself is broken and you are committing the fix + +Bypassing the local hook does not bypass CI. The Commit Lint job validates every +commit in a pull request, so a bypassed commit still has to be reworded (with +`git commit --amend` or an interactive rebase) before the pull request is +mergeable. + +## Files + +| Path | Purpose | +|------|---------| +| `scripts/commit-msg-lib.sh` | Shared type list, limits, and helpers. | +| `scripts/normalize-commit-msg.sh` | In-place safe normalization of a message file. | +| `scripts/validate-commit-msg.sh` | Validation; accepts a file path or `-` for stdin. | +| `scripts/commit-msg.sh` | The hook: normalize, then validate. | +| `scripts/tests/commit-msg.test.sh` | Shell tests (`make test-commit-msg`). | +| `.github/workflows/commit-lint.yml` | CI validation of every commit in a PR. | + +Everything is dependency-free POSIX shell — no Node, no commitlint, no husky. +The tests run under `dash`, `bash`, and `zsh`.