diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..e4479e7 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# +# Normalize the commit message in place, then validate it. Fixable problems are +# corrected silently; only unfixable ones reject the commit. +set -euo pipefail + +repo_root=$(git rev-parse --show-toplevel) +"$repo_root/scripts/normalize-commit-msg.sh" "$1" +exec "$repo_root/scripts/validate-commit-msg.sh" "$1" 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/.github/workflows/commit-lint.yml b/.github/workflows/commit-lint.yml new file mode 100644 index 0000000..9c33a87 --- /dev/null +++ b/.github/workflows/commit-lint.yml @@ -0,0 +1,36 @@ +name: Commit Lint + +on: + pull_request: + +jobs: + commit-lint: + name: Commit messages + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Run commit-message test suite + run: ./tests/commit-msg-test.sh + + - name: Validate commit subjects added by this PR + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + status=0 + while IFS= read -r subject; do + [ -n "$subject" ] || continue + if ! ./scripts/validate-commit-msg.sh --subject "$subject"; then + status=1 + fi + done < <(git log --no-merges --pretty=%s "$BASE_SHA..$HEAD_SHA") + if [ "$status" -ne 0 ]; then + echo "::error::One or more commit subjects do not follow docs/commit-conventions.md" + exit 1 + fi + echo "All commit subjects follow docs/commit-conventions.md" diff --git a/Makefile b/Makefile index 088be01..e837b7d 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 @@ -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-msg go test ./... +# Run commit-message normalizer/validator regression tests +test-commit-msg: + ./tests/commit-msg-test.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-msg - Run commit-message normalizer/validator 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,9 @@ 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)" + @./scripts/install-hooks.sh diff --git a/README.md b/README.md index 84f92cd..b1d97c6 100644 --- a/README.md +++ b/README.md @@ -258,19 +258,63 @@ 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: +Hook installation is opt-in. Enable the repository-managed hooks with: ```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 the message in place and then validates it +against [Conventional Commits](docs/commit-conventions.md): + +```text +type(scope)!: summary +``` + +Supported types are `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, +`refactor`, `revert`, `style`, and `test`. The scope and the `!` breaking-change +marker are optional. Subjects are limited to 72 characters, use the imperative +mood, start lowercase, and carry no trailing period. + +Normalization only rewrites what is mechanically safe — whitespace, the casing +of a recognized type, a trailing period, and the blank line between subject and +body. It never invents a type unless you explicitly run +`scripts/normalize-commit-msg.sh --infer`. Anything it cannot fix is reported by +`scripts/validate-commit-msg.sh` with one line per violated rule. + +Merge and revert messages, `fixup!`, `squash!`, and `amend!` commits, stash +subjects, and comment-only templates are never rewritten or rejected. Comment +lines are recognized using your configured `core.commentString` or +`core.commentChar`, defaulting to `#`. Everything from git's scissors marker +(`git commit -v`) onward is left byte-for-byte untouched. + +Examples: + +```text +feat(run): add pause command +fix(config)!: reject unknown provider keys +docs: explain hook installation +``` + +Run the shell regression suite directly with: + +```bash +make test-commit-msg +``` + +CI enforces the same rules on every commit a pull request adds, so contributors +who skip the hooks still get the check. See +[docs/commit-conventions.md](docs/commit-conventions.md) for the full reference. + To bypass in a pinch: `git commit --no-verify` ## Uninstalling diff --git a/docs/commit-conventions.md b/docs/commit-conventions.md new file mode 100644 index 0000000..c4a9058 --- /dev/null +++ b/docs/commit-conventions.md @@ -0,0 +1,111 @@ +# Commit Conventions + +Nightshift uses [Conventional Commits](https://www.conventionalcommits.org/). +The format is enforced locally by an opt-in `commit-msg` hook and in CI for every +commit a pull request adds. + +## Format + +```text +type(scope)!: summary + +Optional body, wrapped at 72 columns. + +Optional-Trailer: value +``` + +- **type** — required, lowercase, one of: + `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, + `style`, `test` +- **scope** — optional, in parentheses. Letters, digits, and `_ . / # -` + (e.g. `run`, `config`, `.github/workflows`, `#19`). +- **!** — optional, marks a breaking change. Pair it with a + `BREAKING CHANGE:` footer explaining the migration. +- **summary** — required, imperative mood, starts lowercase (acronyms such as + `API` keep their case), no trailing period. +- **subject line** — 72 characters or fewer, including the type and scope. + GitHub appends ` (#123)` when squash-merging, so leaving headroom helps. +- **body** — optional, separated from the subject by exactly one blank line, + wrapped at 72 columns. +- **footers** — `BREAKING CHANGE: …` and issue references (`Fixes #21`) go last. + +### Examples + +```text +feat(run): add pause command +fix(config)!: reject unknown provider keys +docs: explain hook installation +ci(.github/workflows): pin action versions +``` + +## Messages that are never rewritten or rejected + +Git writes some subjects itself. Blocking those would break ordinary workflows, +so both the normalizer and the validator pass them through untouched: + +- `Merge …` and `Revert "…"` +- `fixup!`, `squash!`, and `amend!` (autosquash) +- `WIP on …` and `index on …` (stashes) +- comment-only messages produced by the commit template (comment lines are + detected using `core.commentString` / `core.commentChar`, defaulting to `#`) + +Under `git commit -v` (or `commit.verbose = true`) git appends a scissors +marker followed by a raw diff: + +```text +# ------------------------ >8 ------------------------ +diff --git a/main.go b/main.go +``` + +Git truncates the message at that marker. The scissors line and everything +below it are copied through byte for byte — nothing is moved above the marker +and nothing below it is validated, so the diff never leaks into the commit +body. + +## Tooling + +| Command | Purpose | +| --- | --- | +| `scripts/normalize-commit-msg.sh ` | Rewrites a message file in place | +| `scripts/normalize-commit-msg.sh --infer ` | Also prefixes `chore: ` when no type is present | +| `scripts/validate-commit-msg.sh ` | Validates a message file | +| `scripts/validate-commit-msg.sh --subject ""` | Validates a single subject line | +| `scripts/install-hooks.sh` (or `make install-hooks`) | Enables the repository hooks | +| `make test-commit-msg` | Runs `tests/commit-msg-test.sh` | + +### What the normalizer changes + +Normalization is deliberately conservative — it only rewrites what is +mechanically safe: + +- trims trailing whitespace and leading/trailing blank lines +- collapses repeated whitespace inside the subject +- lowercases a recognized type and removes spacing around the scope and `!` +- removes a single trailing period from the subject (`...` is preserved) +- lowercases a capitalized first word (`Add x` → `add x`), leaving acronyms and + mixed-case identifiers alone +- inserts the missing blank line between subject and body, and collapses runs of + blank lines in the body + +It never invents a type. If a subject has no recognized `type:` prefix, the +normalizer leaves it for the validator to report — unless you explicitly pass +`--infer`, which prefixes `chore: `. + +## Local setup + +```bash +make install-hooks # git config core.hooksPath .githooks +``` + +This is opt-in; contributors who skip it are only checked in CI. The +`commit-msg` hook normalizes the message in place first, so most problems are +fixed silently and only genuinely ambiguous ones reject the commit. Bypass a +single commit with `git commit --no-verify`, and disable the hooks entirely with +`git config --unset core.hooksPath`. + +## CI + +`.github/workflows/commit-lint.yml` runs the shell test suite and validates the +subject of every commit the pull request adds +(`git log --pretty=%s origin/..HEAD`). Commits already on the base branch +are not re-checked, so the pre-Conventional-Commits history stays as it is. diff --git a/scripts/commit-msg-lib.sh b/scripts/commit-msg-lib.sh new file mode 100644 index 0000000..40d8305 --- /dev/null +++ b/scripts/commit-msg-lib.sh @@ -0,0 +1,214 @@ +#!/usr/bin/env bash +# +# Shared parsing helpers for the commit-message normalizer and validator. +# Sourced, never executed directly. + +# Types allowed by docs/commit-conventions.md. +COMMIT_MSG_TYPES=(build chore ci docs feat fix perf refactor revert style test) + +COMMIT_MSG_MAX_SUBJECT=72 + +# Parsed pieces of the most recent commit_msg_split_subject call. +CM_TYPE="" +CM_SCOPE="" +CM_BANG="" +CM_SUMMARY="" +CM_ERR="" + +# Reads a file into the COMMIT_MSG_LINES array, stripping CR line endings. +# Written as a read loop rather than mapfile: macOS ships bash 3.2. +COMMIT_MSG_LINES=() +commit_msg_read_lines() { + local line + COMMIT_MSG_LINES=() + while IFS= read -r line || [ -n "$line" ]; do + COMMIT_MSG_LINES+=("${line%$'\r'}") + done < "$1" +} + +commit_msg_type_is_known() { + local candidate=$1 known + for known in "${COMMIT_MSG_TYPES[@]}"; do + [ "$candidate" = "$known" ] && return 0 + done + return 1 +} + +commit_msg_types_csv() { + local out="" type + for type in "${COMMIT_MSG_TYPES[@]}"; do + out="${out:+$out, }$type" + done + printf '%s' "$out" +} + +# Git's comment prefix, honoring core.commentChar / core.commentString. +# "auto" is reported as "#": git only picks another character when "#" would be +# ambiguous, and treating the message as uncommented is the safe fallback. +COMMIT_MSG_COMMENT_PREFIX="" +commit_msg_comment_prefix() { + if [ -z "$COMMIT_MSG_COMMENT_PREFIX" ]; then + local configured="" + configured=$(git config --get core.commentString 2>/dev/null || true) + if [ -z "$configured" ]; then + configured=$(git config --get core.commentChar 2>/dev/null || true) + fi + case "$configured" in + ""|auto) configured="#" ;; + esac + COMMIT_MSG_COMMENT_PREFIX=$configured + fi + printf '%s' "$COMMIT_MSG_COMMENT_PREFIX" +} + +commit_msg_is_comment() { + local prefix + prefix="$(commit_msg_comment_prefix)" + case "${1-}" in + "$prefix"*) return 0 ;; + esac + return 1 +} + +# Git's scissors line, written by `git commit -v` / `commit.verbose` and by the +# "scissors" cleanup mode. Git truncates the message here, and everything below +# is a raw diff rather than comment-prefixed text. The normalizer must not move +# it or hoist anything above it, or the diff lands in the commit body. +commit_msg_is_scissors() { + local prefix line=${1-} + prefix="$(commit_msg_comment_prefix)" + case "$line" in + "$prefix"*) ;; + *) return 1 ;; + esac + line="${line#"$prefix"}" + [[ $line =~ ^[[:space:]-]*(\>8|8\<)[[:space:]-]*$ ]] || return 1 + return 0 +} + +# Index of the first scissors line in the given lines, or the line count when +# there is none. Callers treat everything from that index on as untouchable. +COMMIT_MSG_CUT=0 +commit_msg_find_cut() { + local line i=0 + COMMIT_MSG_CUT=$# + for line in "$@"; do + if commit_msg_is_scissors "$line"; then + COMMIT_MSG_CUT=$i + return 0 + fi + i=$(( i + 1 )) + done + return 0 +} + +# Subjects that git writes on the author's behalf. Never rewritten, never +# rejected -- a hook that blocks `git merge` or `git rebase --autosquash` is +# worse than a slightly inconsistent history. +commit_msg_is_generated() { + commit_msg_is_comment "${1-}" && return 0 + case "${1-}" in + ""|"Merge "*|"Revert \""*|"fixup!"*|"squash!"*|"amend!"*|"WIP on "*|"index on "*) + return 0 ;; + esac + return 1 +} + +# First line that is neither blank nor a git comment, with outer space trimmed. +commit_msg_first_content_line() { + local line + for line in "$@"; do + line="${line#"${line%%[![:space:]]*}"}" + [ -z "$line" ] && continue + printf '%s' "$line" + return 0 + done + printf '' +} + +commit_msg_collapse_spaces() { + local value=$1 + value="${value//$'\t'/ }" + while [[ $value == *" "* ]]; do value="${value// / }"; done + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + printf '%s' "$value" +} + +# Strips exactly one sentence-ending period. "wip..." keeps its ellipsis. +commit_msg_strip_trailing_period() { + local value=$1 + if [[ $value == *. && $value != *.. ]]; then + value="${value%.}" + fi + printf '%s' "$value" +} + +# Lowercases a leading capitalized word ("Add x" -> "add x") but leaves +# acronyms and mixed-case identifiers ("API", "gRPC", "macOS") alone. +commit_msg_normalize_summary() { + local summary first rest + summary="$(commit_msg_strip_trailing_period "$(commit_msg_collapse_spaces "$1")")" + first="${summary%% *}" + if [[ $first =~ ^[A-Z][a-z]*[[:punct:]]?$ ]]; then + rest="${summary:1}" + summary="$(tr '[:upper:]' '[:lower:]' <<<"${summary:0:1}")$rest" + fi + printf '%s' "$summary" +} + +# Parses "type(scope)!: summary". Returns 0 and fills CM_* on success; +# returns 1 and sets CM_ERR to a human-readable reason otherwise. +commit_msg_split_subject() { + local subject header + subject="$(commit_msg_collapse_spaces "$1")" + CM_TYPE=""; CM_SCOPE=""; CM_BANG=""; CM_SUMMARY=""; CM_ERR="" + + if [[ $subject != *:* ]]; then + CM_ERR="missing \"type: \" prefix" + return 1 + fi + + header="${subject%%:*}" + CM_SUMMARY="$(commit_msg_collapse_spaces "${subject#*:}")" + + header="$(commit_msg_collapse_spaces "$header")" + if [[ $header == *"!" ]]; then + CM_BANG="!" + header="$(commit_msg_collapse_spaces "${header%!}")" + fi + + if [[ $header == *"("* || $header == *")"* ]]; then + if [[ ! $header =~ ^([^()]+)\((.+)\)$ ]]; then + CM_ERR="malformed scope; expected type(scope)" + return 1 + fi + header="$(commit_msg_collapse_spaces "${BASH_REMATCH[1]}")" + CM_SCOPE="$(commit_msg_collapse_spaces "${BASH_REMATCH[2]}")" + if [[ ! $CM_SCOPE =~ ^[A-Za-z0-9_./#-]+$ ]]; then + CM_ERR="invalid scope \"$CM_SCOPE\"" + return 1 + fi + fi + + CM_TYPE="$(tr '[:upper:]' '[:lower:]' <<<"$header")" + if ! commit_msg_type_is_known "$CM_TYPE"; then + CM_ERR="unknown type \"$header\" (allowed: $(commit_msg_types_csv))" + return 1 + fi + + if [ -z "$CM_SUMMARY" ]; then + CM_ERR="empty summary after \"$CM_TYPE:\"" + return 1 + fi + + return 0 +} + +# Canonical subject built from the last successful commit_msg_split_subject. +commit_msg_render_subject() { + local scope="" + [ -n "$CM_SCOPE" ] && scope="($CM_SCOPE)" + printf '%s%s%s: %s' "$CM_TYPE" "$scope" "$CM_BANG" \ + "$(commit_msg_normalize_summary "$CM_SUMMARY")" +} diff --git a/scripts/install-hooks.sh b/scripts/install-hooks.sh new file mode 100755 index 0000000..3c649da --- /dev/null +++ b/scripts/install-hooks.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# +# Opt in to the repository-managed git hooks in .githooks/. +set -euo pipefail + +repo_root=$(git rev-parse --show-toplevel) +cd "$repo_root" +git config core.hooksPath .githooks +echo "✓ repository hooks enabled (core.hooksPath=.githooks)" +echo " pre-commit : gofmt, go vet, go build" +echo " commit-msg : normalize + validate Conventional Commit subjects" +echo " disable with: git config --unset core.hooksPath" diff --git a/scripts/normalize-commit-msg.sh b/scripts/normalize-commit-msg.sh new file mode 100755 index 0000000..ec7afd6 --- /dev/null +++ b/scripts/normalize-commit-msg.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# +# Normalize a commit message file in place toward the Conventional Commits +# format documented in docs/commit-conventions.md. +# +# Normalization is deliberately conservative: it only rewrites things that are +# mechanically safe (whitespace, casing of a known type, a trailing period, +# subject/body separation). Semantic guesswork -- inserting a type that the +# author never wrote -- happens only behind --infer. +# +# usage: normalize-commit-msg.sh [--infer] +set -euo pipefail + +INFER=0 +FILE="" + +while [ "$#" -gt 0 ]; do + case "$1" in + --infer) INFER=1; shift ;; + -h|--help) + echo "usage: $0 [--infer] " + exit 0 + ;; + --) shift; FILE="${1:-}"; shift || true ;; + -*) + echo "normalize-commit-msg: unknown option: $1" >&2 + exit 2 + ;; + *) + if [ -n "$FILE" ]; then + echo "normalize-commit-msg: unexpected argument: $1" >&2 + exit 2 + fi + FILE=$1 + shift + ;; + esac +done + +if [ -z "$FILE" ]; then + echo "usage: $0 [--infer] " >&2 + exit 2 +fi + +if [ ! -f "$FILE" ] || [ ! -r "$FILE" ] || [ ! -w "$FILE" ]; then + echo "normalize-commit-msg: not a readable, writable file: $FILE" >&2 + exit 2 +fi + +# shellcheck source=scripts/commit-msg-lib.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/commit-msg-lib.sh" + +commit_msg_read_lines "$FILE" +ALL=(${COMMIT_MSG_LINES[@]+"${COMMIT_MSG_LINES[@]}"}) + +# Split off the scissors trailer. Under `git commit -v` everything from the +# scissors marker on is a raw, un-commented diff that git strips at commit +# time. It has to stay exactly where it is: hoisting comment lines above it +# would defeat git's stripping and commit the whole diff as the message body. +commit_msg_find_cut ${ALL[@]+"${ALL[@]}"} +CUT=$COMMIT_MSG_CUT + +RAW=() +TAIL=() +for (( i = 0; i < ${#ALL[@]}; i++ )); do + if [ "$i" -lt "$CUT" ]; then + RAW+=("${ALL[$i]}") + else + TAIL+=("${ALL[$i]}") + fi +done + +# Git generates these subjects itself. Rewriting them breaks merges, reverts, +# autosquash and stash messages, so the file is left byte for byte intact. +first_line="$(commit_msg_first_content_line ${RAW[@]+"${RAW[@]}"})" +if commit_msg_is_generated "$first_line"; then + exit 0 +fi + +content=() +comments=() +for line in ${RAW[@]+"${RAW[@]}"}; do + if commit_msg_is_comment "$line"; then + comments+=("$line") + else + content+=("${line%"${line##*[![:space:]]}"}") # drop trailing whitespace + fi +done + +# Trim leading and trailing blank lines from the content block. +start=0 +end=$(( ${#content[@]} - 1 )) +while [ "$start" -le "$end" ] && [ -z "${content[$start]}" ]; do start=$(( start + 1 )); done +while [ "$end" -ge "$start" ] && [ -z "${content[$end]}" ]; do end=$(( end - 1 )); done + +if [ "$start" -gt "$end" ]; then + exit 0 # nothing but blanks and comments; let git reject the empty message +fi + +subject="${content[$start]}" +subject="$(commit_msg_collapse_spaces "$subject")" + +if commit_msg_split_subject "$subject"; then + subject="$(commit_msg_render_subject)" +else + # Unrecognized shape: only mechanically safe cleanups, never a guessed type + # unless the caller explicitly asked for one. + subject="$(commit_msg_strip_trailing_period "$subject")" + if [ "$INFER" -eq 1 ]; then + subject="chore: $(commit_msg_normalize_summary "$subject")" + fi +fi + +# Rebuild: subject, blank line, body with runs of blank lines collapsed to one. +out=("$subject") +body=() +prev_blank=1 +for (( i = start + 1; i <= end; i++ )); do + line="${content[$i]}" + if [ -z "$line" ]; then + if [ "$prev_blank" -eq 0 ]; then + body+=("") + fi + prev_blank=1 + else + body+=("$line") + prev_blank=0 + fi +done + +# Drop a leading blank produced by trimming, then re-add exactly one separator. +if [ "${#body[@]}" -gt 0 ] && [ -z "${body[0]}" ]; then + body=("${body[@]:1}") +fi +if [ "${#body[@]}" -gt 0 ]; then + out+=("") + out+=("${body[@]}") +fi + +if [ "${#comments[@]}" -gt 0 ]; then + out+=("${comments[@]}") +fi + +# The scissors trailer is re-emitted verbatim, still last and still intact. +if [ "${#TAIL[@]}" -gt 0 ]; then + out+=("${TAIL[@]}") +fi + +printf '%s\n' "${out[@]}" > "$FILE" diff --git a/scripts/validate-commit-msg.sh b/scripts/validate-commit-msg.sh new file mode 100755 index 0000000..c1c2eec --- /dev/null +++ b/scripts/validate-commit-msg.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# +# Validate a commit message against docs/commit-conventions.md. +# +# usage: validate-commit-msg.sh +# validate-commit-msg.sh --subject "feat(run): add pause command" +# +# Exits 0 when the message conforms (or is a git-generated message that must +# never be blocked), 1 with one diagnostic line per violated rule otherwise. +set -euo pipefail + +# shellcheck source=scripts/commit-msg-lib.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/commit-msg-lib.sh" + +SUBJECT_ONLY=0 +INPUT="" + +while [ "$#" -gt 0 ]; do + case "$1" in + --subject) SUBJECT_ONLY=1; shift; INPUT="${1-}"; shift || true ;; + -h|--help) echo "usage: $0 | --subject "; exit 0 ;; + -*) echo "validate-commit-msg: unknown option: $1" >&2; exit 2 ;; + *) + if [ -n "$INPUT" ]; then + echo "validate-commit-msg: unexpected argument: $1" >&2 + exit 2 + fi + INPUT=$1; shift ;; + esac +done + +lines=() +if [ "$SUBJECT_ONLY" -eq 1 ]; then + [ -n "$INPUT" ] || { echo "validate-commit-msg: --subject requires text" >&2; exit 2; } + lines=("$INPUT") +else + if [ -z "$INPUT" ]; then + echo "usage: $0 | --subject " >&2 + exit 2 + fi + if [ ! -r "$INPUT" ]; then + echo "validate-commit-msg: not a readable file: $INPUT" >&2 + exit 2 + fi + commit_msg_read_lines "$INPUT" + lines=(${COMMIT_MSG_LINES[@]+"${COMMIT_MSG_LINES[@]}"}) +fi + +# Everything from git's scissors marker on is stripped by git (raw diff under +# `git commit -v`), so it is not part of the message being validated. +commit_msg_find_cut ${lines[@]+"${lines[@]}"} +cut=$COMMIT_MSG_CUT + +# Drop git comment lines; they are stripped before the message is stored. +content=() +for (( i = 0; i < cut; i++ )); do + line="${lines[$i]}" + commit_msg_is_comment "$line" && continue + content+=("$line") +done + +first_line="$(commit_msg_first_content_line ${content[@]+"${content[@]}"})" +if commit_msg_is_generated "$first_line"; then + exit 0 +fi + +errors=() +add_error() { errors+=("$1"); } + +subject_index=-1 +for i in "${!content[@]}"; do + if [ -n "${content[$i]//[[:space:]]/}" ]; then + subject_index=$i + break + fi +done + +if [ "$subject_index" -lt 0 ]; then + echo "commit message is empty" >&2 + exit 1 +fi + +subject="${content[$subject_index]}" + +if [ "$subject" != "$(commit_msg_collapse_spaces "$subject")" ]; then + add_error "subject has leading, trailing, or repeated whitespace" +fi + +collapsed="$(commit_msg_collapse_spaces "$subject")" + +if [ "${#collapsed}" -gt "$COMMIT_MSG_MAX_SUBJECT" ]; then + add_error "subject is ${#collapsed} characters; the limit is $COMMIT_MSG_MAX_SUBJECT" +fi + +if [[ $collapsed == *. && $collapsed != *.. ]]; then + add_error "subject ends with a period" +fi + +if commit_msg_split_subject "$collapsed"; then + if [ "$CM_TYPE" != "${collapsed%%[(:!]*}" ]; then + add_error "type must be lowercase: \"${collapsed%%[(:!]*}\" should be \"$CM_TYPE\"" + fi + summary_first="${CM_SUMMARY%% *}" + if [[ $summary_first =~ ^[A-Z][a-z]*[[:punct:]]?$ ]]; then + add_error "summary should start lowercase and imperative: \"$CM_SUMMARY\"" + fi +else + add_error "$CM_ERR" +fi + +# Exactly one blank line must separate the subject from the body. +body_index=$(( subject_index + 1 )) +if [ "$body_index" -lt "${#content[@]}" ]; then + has_body=0 + for (( i = body_index; i < ${#content[@]}; i++ )); do + if [ -n "${content[$i]//[[:space:]]/}" ]; then has_body=1; break; fi + done + if [ "$has_body" -eq 1 ] && [ -n "${content[$body_index]//[[:space:]]/}" ]; then + add_error "missing blank line between subject and body" + fi +fi + +if [ "${#errors[@]}" -eq 0 ]; then + exit 0 +fi + +{ + echo "invalid commit message:" + echo " subject: $subject" + for err in "${errors[@]}"; do + echo " - $err" + done + echo + echo "expected: type(scope)!: summary (see docs/commit-conventions.md)" + echo "types: $(commit_msg_types_csv)" +} >&2 +exit 1 diff --git a/tests/commit-msg-test.sh b/tests/commit-msg-test.sh new file mode 100755 index 0000000..29c2fd3 --- /dev/null +++ b/tests/commit-msg-test.sh @@ -0,0 +1,391 @@ +#!/usr/bin/env bash +# +# Table-driven regression tests for the commit-message normalizer and +# validator. No external test framework required. +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +NORMALIZE="$ROOT/scripts/normalize-commit-msg.sh" +VALIDATE="$ROOT/scripts/validate-commit-msg.sh" + +PASS=0 +FAIL=0 +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +fail() { + FAIL=$((FAIL + 1)) + echo "✗ $1" + shift + printf ' %s\n' "$@" +} + +pass() { + PASS=$((PASS + 1)) + echo "✓ $1" +} + +# normalize [--infer] +normalize_case() { + local name=$1 input=$2 expected=$3 infer=${4-} + local file="$WORK/msg" + printf '%s' "$input" > "$file" + if [ -n "$infer" ]; then + "$NORMALIZE" --infer "$file" >/dev/null 2>&1 + else + "$NORMALIZE" "$file" >/dev/null 2>&1 + fi + local actual + actual="$(cat "$file")" + if [ "$actual" = "$expected" ]; then + pass "normalize: $name" + else + fail "normalize: $name" "expected: $(printf '%q' "$expected")" "actual: $(printf '%q' "$actual")" + fi +} + +# normalize_with_comment_prefix +normalize_with_comment_prefix() { + local name=$1 key=$2 prefix=$3 input=$4 expected=$5 + local file="$WORK/msg" + printf '%s' "$input" > "$file" + GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0="core.$key" GIT_CONFIG_VALUE_0="$prefix" \ + "$NORMALIZE" "$file" >/dev/null 2>&1 + local actual + actual="$(cat "$file")" + if [ "$actual" = "$expected" ]; then + pass "normalize ($key=$prefix): $name" + else + fail "normalize ($key=$prefix): $name" "expected: $(printf '%q' "$expected")" "actual: $(printf '%q' "$actual")" + fi +} + +# valid +valid_case() { + local name=$1 msg=$2 + local file="$WORK/msg" + printf '%s\n' "$msg" > "$file" + local out + if out="$("$VALIDATE" "$file" 2>&1)"; then + pass "validate accepts: $name" + else + fail "validate accepts: $name" "$out" + fi +} + +# invalid +invalid_case() { + local name=$1 msg=$2 needle=$3 + local file="$WORK/msg" + printf '%s\n' "$msg" > "$file" + local out status + out="$("$VALIDATE" "$file" 2>&1)"; status=$? + if [ "$status" -eq 0 ]; then + fail "validate rejects: $name" "expected rejection, got exit 0" + elif [[ $out != *"$needle"* ]]; then + fail "validate rejects: $name" "expected diagnostic containing: $needle" "actual: $out" + else + pass "validate rejects: $name" + fi +} + +echo "commit-msg normalization" +normalize_case "trailing whitespace" \ + 'feat(run): add pause command ' \ + 'feat(run): add pause command' + +normalize_case "trailing period on subject" \ + 'fix: stop double-counting budget.' \ + 'fix: stop double-counting budget' + +normalize_case "ellipsis is preserved" \ + 'chore: work in progress...' \ + 'chore: work in progress...' + +normalize_case "uppercase type is lowercased" \ + 'Fix(config): reject unknown keys' \ + 'fix(config): reject unknown keys' + +normalize_case "capitalized summary is lowercased" \ + 'feat: Add pause command' \ + 'feat: add pause command' + +normalize_case "acronym summary is preserved" \ + 'feat: API key rotation' \ + 'feat: API key rotation' + +normalize_case "spacing around scope and bang" \ + 'feat ( run ) ! : add pause command' \ + 'feat(run)!: add pause command' + +normalize_case "blank line inserted between subject and body" \ + 'fix: guard nil provider +the provider can be nil after a failed load.' \ + 'fix: guard nil provider + +the provider can be nil after a failed load.' + +normalize_case "repeated blank lines collapse" \ + 'fix: guard nil provider + + +body line one + + +body line two' \ + 'fix: guard nil provider + +body line one + +body line two' + +normalize_case "leading blank lines trimmed" \ + ' + +docs: explain hooks' \ + 'docs: explain hooks' + +normalize_case "trailers are preserved verbatim" \ + 'chore: bump version + +Nightshift-Task: commit-normalize +Nightshift-Ref: https://github.com/marcus/nightshift' \ + 'chore: bump version + +Nightshift-Task: commit-normalize +Nightshift-Ref: https://github.com/marcus/nightshift' + +normalize_case "merge commits are untouched" \ + 'Merge pull request #17 from cedricfarinazzo/nightshift-lint-fixes ' \ + 'Merge pull request #17 from cedricfarinazzo/nightshift-lint-fixes ' + +normalize_case "revert commits are untouched" \ + 'Revert "feat: add pause command."' \ + 'Revert "feat: add pause command."' + +normalize_case "fixup commits are untouched" \ + 'fixup! feat: add pause command.' \ + 'fixup! feat: add pause command.' + +normalize_case "squash commits are untouched" \ + 'squash! feat: add pause command.' \ + 'squash! feat: add pause command.' + +normalize_case "comment-only template is untouched" \ + '# Please enter the commit message for your changes.' \ + '# Please enter the commit message for your changes.' + +normalize_case "git comments survive normalization" \ + 'feat: add pause command. +# Please enter the commit message for your changes.' \ + 'feat: add pause command +# Please enter the commit message for your changes.' + +normalize_case "no type is left alone without --infer" \ + 'add pause command' \ + 'add pause command' + +normalize_case "no type gains chore with --infer" \ + 'Add pause command.' \ + 'chore: add pause command' \ + --infer + +normalize_with_comment_prefix "comment-only template is untouched" \ + commentChar ';' \ + '; Please enter the commit message for your changes.' \ + '; Please enter the commit message for your changes.' + +normalize_with_comment_prefix "multi-character comment string is honored" \ + commentString '//' \ + 'feat: add pause command. +// Lines starting with // will be ignored.' \ + 'feat: add pause command +// Lines starting with // will be ignored.' + +normalize_with_comment_prefix "# is body text when another prefix is configured" \ + commentChar ';' \ + 'feat: add pause command. + +# not a comment here' \ + 'feat: add pause command + +# not a comment here' + +echo +echo "commit-msg validation" +valid_case "plain type" 'docs: explain hook installation' +valid_case "scoped type" 'feat(run): add pause command' +valid_case "breaking change" 'fix(config)!: reject unknown provider keys' +valid_case "subject and body" 'fix: guard nil provider + +The provider can be nil after a failed load.' +valid_case "merge commit" 'Merge pull request #17 from example/branch' +valid_case "revert commit" 'Revert "feat: add pause command."' +valid_case "fixup commit" 'fixup! feat: add pause command' +valid_case "path-like scope" 'ci(.github/workflows): pin action versions' + +invalid_case "unknown type" 'wip: half a feature' 'unknown type' +invalid_case "missing colon" 'add pause command' 'missing "type: " prefix' +invalid_case "empty summary" 'feat:' 'empty summary after "feat:"' +invalid_case "empty scoped summary" 'feat(run): ' 'empty summary' +invalid_case "trailing period" 'feat: add pause command.' 'ends with a period' +invalid_case "uppercase type" 'Feat: add pause command' 'must be lowercase' +invalid_case "capitalized summary" 'feat: Add pause command' 'start lowercase' +invalid_case "malformed scope" 'feat(run: add pause command' 'malformed scope' +invalid_case "over-length subject" \ + "feat(run): $(printf 'x%.0s' {1..70})" \ + 'the limit is 72' +invalid_case "missing blank line before body" 'fix: guard nil provider +the provider can be nil.' 'missing blank line between subject and body' + +echo +echo "scissors (git commit -v)" + +SCISSORS='# ------------------------ >8 ------------------------' + +# The diff below the scissors line must stay below it. Hoisting comment lines +# above the marker would defeat git's scissors stripping and commit the diff. +normalize_case "diff below scissors is left in place" \ + "feat: add pause command. + +# Please enter the commit message for your changes. +$SCISSORS +# Do not modify or remove the line above. +diff --git a/a.txt b/a.txt +index e69de29..7898192 100644 +--- a/a.txt ++++ b/a.txt +@@ -0,0 +1 @@ ++a +" \ + "feat: add pause command +# Please enter the commit message for your changes. +$SCISSORS +# Do not modify or remove the line above. +diff --git a/a.txt b/a.txt +index e69de29..7898192 100644 +--- a/a.txt ++++ b/a.txt +@@ -0,0 +1 @@ ++a" + +normalize_case "body above scissors is still normalized" \ + "Feat(run): Add pause command. +Explains why. + +$SCISSORS +diff --git a/a.txt b/a.txt +" \ + "feat(run): add pause command + +Explains why. +$SCISSORS +diff --git a/a.txt b/a.txt" + +normalize_case "template with only a scissors block is untouched" \ + " +# Please enter the commit message for your changes. +$SCISSORS +diff --git a/a.txt b/a.txt +" \ + " +# Please enter the commit message for your changes. +$SCISSORS +diff --git a/a.txt b/a.txt" + +normalize_with_comment_prefix "scissors honors a configured comment prefix" \ + commentChar ';' \ + "feat: add pause command. + +; ------------------------ >8 ------------------------ +diff --git a/a.txt b/a.txt +" \ + "feat: add pause command +; ------------------------ >8 ------------------------ +diff --git a/a.txt b/a.txt" + +# A body line that merely mentions ">8" is not a scissors marker. +normalize_case "lookalike body line is not treated as scissors" \ + "feat: add pause command. + +see >8 for details +" \ + "feat: add pause command + +see >8 for details" + +valid_case "message with a scissors diff below it" "feat: add pause command + +$SCISSORS +diff --git a/a.txt b/a.txt ++not a commit message body" + +echo +echo "end-to-end: git commit -v with the hook installed" + +# The regression this guards: `git commit -v` appends a scissors marker plus a +# raw diff to the message file. If the hook reorders anything across that +# marker, git stops stripping the diff and it lands in the commit body. +E2E="$WORK/e2e" +rm -rf "$E2E" +mkdir -p "$E2E/hooks" "$E2E/repo/scripts" +cp "$ROOT/.githooks/commit-msg" "$E2E/hooks/commit-msg" +cp "$ROOT/scripts/commit-msg-lib.sh" "$ROOT/scripts/normalize-commit-msg.sh" \ + "$ROOT/scripts/validate-commit-msg.sh" "$E2E/repo/scripts/" +chmod +x "$E2E/hooks/commit-msg" "$E2E/repo/scripts/"*.sh + +cat > "$E2E/editor.sh" <<'EDITOR' +#!/usr/bin/env bash +# Stand-in for the user's editor: type a subject above git's template. +set -eu +tmp="$1.typed" +printf 'Feat(run): Add pause command.\n' > "$tmp" +cat "$1" >> "$tmp" +mv "$tmp" "$1" +EDITOR +chmod +x "$E2E/editor.sh" + +e2e_log="" +if ( + cd "$E2E/repo" || exit 1 + git init -q . && + git config user.email test@example.com && + git config user.name "Test User" && + git config commit.gpgsign false && + git config core.hooksPath "$E2E/hooks" && + printf 'a\n' > a.txt && + git add a.txt scripts && + GIT_EDITOR="$E2E/editor.sh" git commit -v -q +) >"$WORK/e2e.log" 2>&1; then + e2e_log="$(git -C "$E2E/repo" log -1 --pretty=%B | sed -e '/^$/{$d;}')" + if [ "$e2e_log" = "feat(run): add pause command" ]; then + pass "git commit -v commits a clean subject and no diff" + else + fail "git commit -v commits a clean subject and no diff" \ + "expected: $(printf '%q' 'feat(run): add pause command')" \ + "actual: $(printf '%q' "$e2e_log")" + fi +else + fail "git commit -v commits a clean subject and no diff" \ + "scratch commit failed:" "$(cat "$WORK/e2e.log")" +fi + +echo +echo "--subject mode" +if "$VALIDATE" --subject 'feat(run): add pause command' >/dev/null 2>&1; then + pass "--subject accepts a valid subject" +else + fail "--subject accepts a valid subject" "unexpected rejection" +fi +if "$VALIDATE" --subject 'wip: nope' >/dev/null 2>&1; then + fail "--subject rejects an invalid subject" "expected rejection, got exit 0" +else + pass "--subject rejects an invalid subject" +fi + +echo +if [ "$FAIL" -gt 0 ]; then + echo "❌ $FAIL failed, $PASS passed" + exit 1 +fi +echo "✅ all $PASS checks passed"