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 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/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/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`. 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