diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..dbe5a25 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,24 @@ +#!/bin/sh +# commit-msg hook — invoke the commit message normalizer. +# +# Enable for this repo: +# git config core.hooksPath .githooks +# Or run: scripts/setup-hooks.sh + +set -e + +REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) +NORMALIZER="$REPO_ROOT/scripts/commit-normalizer.sh" + +if [ ! -x "$NORMALIZER" ]; then + echo "commit-msg: normalizer not executable at $NORMALIZER" >&2 + echo " fix: chmod +x scripts/commit-normalizer.sh" >&2 + exit 1 +fi + +# Allow opt-out for emergencies: COMMIT_NORMALIZER=0 git commit ... +if [ "${COMMIT_NORMALIZER:-1}" = "0" ]; then + exit 0 +fi + +exec "$NORMALIZER" "$1" diff --git a/.gitmessage b/.gitmessage new file mode 100644 index 0000000..b5d8778 --- /dev/null +++ b/.gitmessage @@ -0,0 +1,36 @@ +# (): ── 72 chars or fewer; imperative mood +# +# Allowed types: +# feat ── new feature +# fix ── bug fix +# docs ── documentation only +# style ── formatting; no code change +# refactor ── code change that neither fixes a bug nor adds a feature +# perf ── performance improvement +# test ── adding or correcting tests +# build ── build system or dependency change +# ci ── CI configuration change +# chore ── housekeeping; no production code change +# revert ── reverts a previous commit +# +# Scope is optional; use it for the affected area (e.g. parser, daemon). +# Append `!` after the type/scope to mark a breaking change. +# +# Examples: +# feat(parser): allow optional trailing comma +# fix: handle empty config without panicking +# refactor(daemon)!: replace polling with channel-based notify +# +# ── Body ────────────────────────────────────────────────────────────── +# Wrap at 72 chars. Explain *why* the change is needed; the diff already +# shows *what* changed. Reference issues with `Fixes #123` or `Refs #45`. +# +# +# ── Trailers ────────────────────────────────────────────────────────── +# Use a single blank line before trailers. Common ones: +# Co-Authored-By: Name +# Fixes: #123 +# Refs: #45 +# Signed-off-by: Name +# Nightshift-Task: +# Nightshift-Ref: diff --git a/Makefile b/Makefile index 088be01..ad09fb6 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 coverage coverage-html lint clean deps check install calibrate-providers install-hooks setup-hooks test-commit-normalizer help # Binary name BINARY=nightshift @@ -76,9 +76,19 @@ help: @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 " setup-hooks - Configure core.hooksPath + commit.template (commit-msg normalizer)" + @echo " test-commit-normalizer - Run commit-msg normalizer fixture tests" @echo " help - Show this help" # Install git pre-commit hook install-hooks: @ln -sf ../../scripts/pre-commit.sh .git/hooks/pre-commit @echo "✓ pre-commit hook installed (.git/hooks/pre-commit → scripts/pre-commit.sh)" + +# Configure repo-tracked git hooks (.githooks) and commit template +setup-hooks: + @./scripts/setup-hooks.sh + +# Run commit-msg normalizer fixture tests +test-commit-normalizer: + @./tests/commit-normalizer/run-tests.sh diff --git a/docs/commit-conventions.md b/docs/commit-conventions.md new file mode 100644 index 0000000..752208f --- /dev/null +++ b/docs/commit-conventions.md @@ -0,0 +1,102 @@ +# Commit Message Conventions + +Nightshift uses [Conventional Commits](https://www.conventionalcommits.org/) for +all commit messages. A local git hook normalizes and validates each message, +so most contributors never need to think about formatting beyond following the +template. + +## Format + +``` +()!: + + + + +``` + +- **type** *(required)* — one of: `feat`, `fix`, `docs`, `style`, `refactor`, + `perf`, `test`, `build`, `ci`, `chore`, `revert`. +- **scope** *(optional)* — the area touched (`parser`, `daemon`, `config`). +- **`!`** *(optional)* — appended to the type/scope to mark a breaking change. +- **subject** *(required)* — imperative, no trailing period, ≤ 72 characters + including the type prefix. +- **body** *(optional)* — separated from the subject by a blank line. Explain + *why* the change is needed. +- **trailers** *(optional)* — `Key: value` lines after a blank line. Common + trailers: `Co-Authored-By`, `Fixes`, `Refs`, `Signed-off-by`, + `Nightshift-Task`, `Nightshift-Ref`. + +## Examples + +Valid: + +``` +feat(parser): allow optional trailing comma +``` + +``` +fix: handle empty config without panicking + +When NIGHTSHIFT_CONFIG points at an empty file the daemon crashed on +startup. Treat empty as "use defaults". + +Fixes: #123 +``` + +``` +refactor(daemon)!: replace polling with channel-based notify + +BREAKING CHANGE: the `--poll-interval` flag is removed. Configure +notifications via `notify.channel` in the config file instead. +``` + +Invalid (and how the normalizer reacts): + +| Message | What happens | +| -------------------------------------- | ------------------------------------------------- | +| `Fix typo` | Rejected — missing type prefix. | +| `FEAT: add thing` | Normalized → `feat: add thing` (lowercased type). | +| `feat: add thing ` | Normalized — extra whitespace trimmed. | +| `wibble: do stuff` | Rejected — `wibble` is not an allowed type. | +| `feat: ` *(empty description)* | Rejected — subject has no description. | +| 90-character `feat: …` subject | Rejected — exceeds 72-char limit. | + +## Skip rules + +The normalizer intentionally leaves these alone: + +- Merge commits (`Merge branch …`, `Merge pull request …`). +- Fixup / squash / amend commits (`fixup!`, `squash!`, `amend!`). +- Revert commits (`Revert "…"`). + +## Enabling the hook + +Run once per clone: + +```sh +scripts/setup-hooks.sh +``` + +This sets `core.hooksPath=.githooks` and `commit.template=.gitmessage`. After +that, every `git commit` runs through `scripts/commit-normalizer.sh`. + +To bypass for a single commit: + +```sh +COMMIT_NORMALIZER=0 git commit -m "..." +``` + +To disable entirely: + +```sh +git config --unset core.hooksPath +``` + +## Testing the normalizer + +```sh +tests/commit-normalizer/run-tests.sh +``` + +Fixture-driven; each case lives under `tests/commit-normalizer/cases/`. diff --git a/scripts/commit-normalizer.sh b/scripts/commit-normalizer.sh new file mode 100755 index 0000000..7692737 --- /dev/null +++ b/scripts/commit-normalizer.sh @@ -0,0 +1,184 @@ +#!/bin/sh +# commit-normalizer.sh — language-agnostic Conventional Commits normalizer. +# +# Reads a commit message file, validates and normalizes its first line to +# Conventional Commits format `()!: ` (scope and `!` +# optional), trims trailing whitespace, lowercases the type, and preserves +# the body and trailer block intact. +# +# Exit codes: +# 0 — message normalized (or intentionally skipped) +# 1 — unfixable violation (caller should reject the commit) +# 2 — usage error +# +# Skip rules: merge commits, fixup!/squash! commits, revert commits, and +# messages that already begin with "Revert " are left untouched. + +set -eu + +PROG=$(basename "$0") + +usage() { + cat >&2 < + +Reads the file, normalizes the commit subject to Conventional Commits, and +writes the result back in place. Exits non-zero on unfixable violations. +EOF + exit 2 +} + +[ "$#" -eq 1 ] || usage +MSG_FILE=$1 +[ -f "$MSG_FILE" ] || { echo "$PROG: not a file: $MSG_FILE" >&2; exit 2; } + +# Subject length limit (configurable via env). +MAX_SUBJECT_LEN=${COMMIT_NORMALIZER_MAX_SUBJECT:-72} + +# Allowed Conventional Commits types. +ALLOWED_TYPES="feat fix docs style refactor perf test build ci chore revert" + +# Read the file, stripping comment lines (git's scissor lines start with '#'). +RAW=$(awk '!/^#/' "$MSG_FILE") + +# Determine the first non-empty line — that's the subject candidate. +SUBJECT=$(printf '%s\n' "$RAW" | awk 'NF { print; exit }') + +# Strip CR (Windows line endings) and trailing whitespace from the subject. +SUBJECT=$(printf '%s' "$SUBJECT" | tr -d '\r' | sed 's/[[:space:]]*$//') + +# Skip rules — leave the message untouched. +case "$SUBJECT" in + "Merge "*|"Merge branch "*|"Merge remote-tracking "*|"Merge pull request "*) + exit 0 ;; + "fixup! "*|"squash! "*|"amend! "*) + exit 0 ;; + "Revert "*|"revert: "*) + # Revert commits keep their auto-generated subject. + exit 0 ;; + "") + echo "$PROG: empty commit message" >&2 + exit 1 ;; +esac + +# Parse the subject into type, scope (optional), breaking marker, body. +# Pattern: type(scope)!: subject | type!: subject | type: subject +PARSE=$(printf '%s' "$SUBJECT" | awk ' + { + line = $0 + # Capture optional leading type / scope / !. + if (match(line, /^[A-Za-z]+(\([^)]+\))?!?:[[:space:]]/)) { + header = substr(line, 1, RLENGTH) + rest = substr(line, RLENGTH + 1) + # Split header into parts. + hd = header + sub(/:[[:space:]]*$/, "", hd) + bang = "" + if (hd ~ /!$/) { bang = "!"; sub(/!$/, "", hd) } + scope = "" + if (match(hd, /\([^)]+\)$/)) { + scope = substr(hd, RSTART + 1, RLENGTH - 2) + hd = substr(hd, 1, RSTART - 1) + } + type = hd + printf "%s\t%s\t%s\t%s\n", type, scope, bang, rest + } else { + printf "\t\t\t%s\n", line + } + } +') + +TYPE=$(printf '%s' "$PARSE" | awk -F'\t' '{print $1}') +SCOPE=$(printf '%s' "$PARSE" | awk -F'\t' '{print $2}') +BANG=$(printf '%s' "$PARSE" | awk -F'\t' '{print $3}') +BODY=$(printf '%s' "$PARSE" | awk -F'\t' '{print $4}') + +if [ -z "$TYPE" ]; then + cat >&2 <()!: + types: $ALLOWED_TYPES + example: feat(parser): allow optional trailing comma +EOF + exit 1 +fi + +# Lowercase the type. +TYPE=$(printf '%s' "$TYPE" | tr '[:upper:]' '[:lower:]') + +# Validate type. +TYPE_OK=0 +for t in $ALLOWED_TYPES; do + if [ "$t" = "$TYPE" ]; then TYPE_OK=1; break; fi +done +if [ "$TYPE_OK" -ne 1 ]; then + cat >&2 <&2 + exit 1 +fi + +# Rebuild the normalized subject. +if [ -n "$SCOPE" ]; then + NORMALIZED="$TYPE($SCOPE)$BANG: $BODY" +else + NORMALIZED="$TYPE$BANG: $BODY" +fi + +# Enforce subject length. +SUBJECT_LEN=$(printf '%s' "$NORMALIZED" | awk '{print length}') +if [ "$SUBJECT_LEN" -gt "$MAX_SUBJECT_LEN" ]; then + cat >&2 < $MAX_SUBJECT_LEN chars) + subject: $NORMALIZED + tip: move detail into the body (blank line, then explanation) +EOF + exit 1 +fi + +# Reassemble the message: normalized subject + remaining lines after the +# original subject. We preserve the body and trailer block byte-for-byte +# (minus trailing whitespace per line). Comment lines from git templates +# are stripped — git would do this anyway. +TMP=$(mktemp "${TMPDIR:-/tmp}/commit-normalizer.XXXXXX") +trap 'rm -f "$TMP"' EXIT INT TERM + +awk -v normalized="$NORMALIZED" ' + BEGIN { subject_done = 0 } + /^#/ { next } + { + if (!subject_done && $0 ~ /[^[:space:]]/) { + print normalized + subject_done = 1 + next + } + # Strip trailing whitespace from every line. + sub(/[[:space:]]+$/, "", $0) + print + } +' "$MSG_FILE" > "$TMP" + +# Collapse trailing blank lines down to one terminating newline. +awk ' + { lines[NR] = $0 } + END { + n = NR + while (n > 0 && lines[n] == "") n-- + for (i = 1; i <= n; i++) print lines[i] + } +' "$TMP" > "$TMP.2" +mv "$TMP.2" "$MSG_FILE" +rm -f "$TMP" +trap - EXIT INT TERM + +exit 0 diff --git a/scripts/setup-hooks.sh b/scripts/setup-hooks.sh new file mode 100755 index 0000000..6829a04 --- /dev/null +++ b/scripts/setup-hooks.sh @@ -0,0 +1,31 @@ +#!/bin/sh +# setup-hooks.sh — configure local git to use repo-tracked hooks. +# +# Sets `core.hooksPath` to .githooks and `commit.template` to .gitmessage so +# that the commit-msg normalizer runs and contributors see the template when +# they `git commit` without `-m`. + +set -eu + +REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || { + echo "setup-hooks: not inside a git repository" >&2 + exit 1 +} + +cd "$REPO_ROOT" + +chmod +x .githooks/commit-msg scripts/commit-normalizer.sh 2>/dev/null || true + +git config core.hooksPath .githooks +git config commit.template .gitmessage + +cat < +Co-Authored-By: Bob +Nightshift-Task: commit-normalize +Nightshift-Ref: https://github.com/marcus/nightshift +Signed-off-by: Carol diff --git a/tests/commit-normalizer/cases/06-preserved-trailers/input b/tests/commit-normalizer/cases/06-preserved-trailers/input new file mode 100644 index 0000000..a3616a2 --- /dev/null +++ b/tests/commit-normalizer/cases/06-preserved-trailers/input @@ -0,0 +1,9 @@ +feat(daemon): add notify channel + +Switch from polling to channel-based notifications. + +Co-Authored-By: Alice +Co-Authored-By: Bob +Nightshift-Task: commit-normalize +Nightshift-Ref: https://github.com/marcus/nightshift +Signed-off-by: Carol diff --git a/tests/commit-normalizer/cases/07-merge-commit-skipped/expected b/tests/commit-normalizer/cases/07-merge-commit-skipped/expected new file mode 100644 index 0000000..2d5606d --- /dev/null +++ b/tests/commit-normalizer/cases/07-merge-commit-skipped/expected @@ -0,0 +1 @@ +Merge branch 'develop' into main diff --git a/tests/commit-normalizer/cases/07-merge-commit-skipped/input b/tests/commit-normalizer/cases/07-merge-commit-skipped/input new file mode 100644 index 0000000..2d5606d --- /dev/null +++ b/tests/commit-normalizer/cases/07-merge-commit-skipped/input @@ -0,0 +1 @@ +Merge branch 'develop' into main diff --git a/tests/commit-normalizer/cases/08-fixup-commit-skipped/expected b/tests/commit-normalizer/cases/08-fixup-commit-skipped/expected new file mode 100644 index 0000000..5b9fc7b --- /dev/null +++ b/tests/commit-normalizer/cases/08-fixup-commit-skipped/expected @@ -0,0 +1 @@ +fixup! feat(parser): allow optional trailing comma diff --git a/tests/commit-normalizer/cases/08-fixup-commit-skipped/input b/tests/commit-normalizer/cases/08-fixup-commit-skipped/input new file mode 100644 index 0000000..5b9fc7b --- /dev/null +++ b/tests/commit-normalizer/cases/08-fixup-commit-skipped/input @@ -0,0 +1 @@ +fixup! feat(parser): allow optional trailing comma diff --git a/tests/commit-normalizer/cases/09-unknown-type/exit b/tests/commit-normalizer/cases/09-unknown-type/exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tests/commit-normalizer/cases/09-unknown-type/exit @@ -0,0 +1 @@ +1 diff --git a/tests/commit-normalizer/cases/09-unknown-type/expected.err b/tests/commit-normalizer/cases/09-unknown-type/expected.err new file mode 100644 index 0000000..7bfcb44 --- /dev/null +++ b/tests/commit-normalizer/cases/09-unknown-type/expected.err @@ -0,0 +1 @@ +unknown commit type \ No newline at end of file diff --git a/tests/commit-normalizer/cases/09-unknown-type/input b/tests/commit-normalizer/cases/09-unknown-type/input new file mode 100644 index 0000000..3e41700 --- /dev/null +++ b/tests/commit-normalizer/cases/09-unknown-type/input @@ -0,0 +1 @@ +wibble: do mysterious things diff --git a/tests/commit-normalizer/cases/10-breaking-change/expected b/tests/commit-normalizer/cases/10-breaking-change/expected new file mode 100644 index 0000000..54c30ef --- /dev/null +++ b/tests/commit-normalizer/cases/10-breaking-change/expected @@ -0,0 +1,3 @@ +refactor(daemon)!: replace polling with channel notify + +BREAKING CHANGE: --poll-interval is removed. diff --git a/tests/commit-normalizer/cases/10-breaking-change/input b/tests/commit-normalizer/cases/10-breaking-change/input new file mode 100644 index 0000000..54c30ef --- /dev/null +++ b/tests/commit-normalizer/cases/10-breaking-change/input @@ -0,0 +1,3 @@ +refactor(daemon)!: replace polling with channel notify + +BREAKING CHANGE: --poll-interval is removed. diff --git a/tests/commit-normalizer/cases/11-strip-comments/expected b/tests/commit-normalizer/cases/11-strip-comments/expected new file mode 100644 index 0000000..c66289c --- /dev/null +++ b/tests/commit-normalizer/cases/11-strip-comments/expected @@ -0,0 +1,4 @@ +feat: support TOML config + + +Adds a parallel TOML loader alongside YAML. diff --git a/tests/commit-normalizer/cases/11-strip-comments/input b/tests/commit-normalizer/cases/11-strip-comments/input new file mode 100644 index 0000000..f369a0f --- /dev/null +++ b/tests/commit-normalizer/cases/11-strip-comments/input @@ -0,0 +1,8 @@ +feat: support TOML config + +# Please enter the commit message for your changes. Lines starting +# with '#' will be ignored, and an empty message aborts the commit. +# +# On branch feature/x + +Adds a parallel TOML loader alongside YAML. diff --git a/tests/commit-normalizer/cases/12-empty-description/exit b/tests/commit-normalizer/cases/12-empty-description/exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tests/commit-normalizer/cases/12-empty-description/exit @@ -0,0 +1 @@ +1 diff --git a/tests/commit-normalizer/cases/12-empty-description/expected.err b/tests/commit-normalizer/cases/12-empty-description/expected.err new file mode 100644 index 0000000..1ddd2d3 --- /dev/null +++ b/tests/commit-normalizer/cases/12-empty-description/expected.err @@ -0,0 +1 @@ +not Conventional Commits format \ No newline at end of file diff --git a/tests/commit-normalizer/cases/12-empty-description/input b/tests/commit-normalizer/cases/12-empty-description/input new file mode 100644 index 0000000..36353fd --- /dev/null +++ b/tests/commit-normalizer/cases/12-empty-description/input @@ -0,0 +1 @@ +feat: diff --git a/tests/commit-normalizer/run-tests.sh b/tests/commit-normalizer/run-tests.sh new file mode 100755 index 0000000..c0624e2 --- /dev/null +++ b/tests/commit-normalizer/run-tests.sh @@ -0,0 +1,89 @@ +#!/bin/sh +# run-tests.sh — fixture-driven tests for scripts/commit-normalizer.sh. +# +# Each case directory under tests/commit-normalizer/cases/ may contain: +# input — the commit message to feed the normalizer +# expected — the expected normalized output (if exit is 0) +# exit — expected exit code (default 0) +# expected.err — substring expected on stderr (optional) + +set -eu + +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) +REPO_ROOT=$(cd "$SCRIPT_DIR/../.." && pwd) +NORMALIZER="$REPO_ROOT/scripts/commit-normalizer.sh" +CASES_DIR="$SCRIPT_DIR/cases" + +[ -x "$NORMALIZER" ] || chmod +x "$NORMALIZER" + +PASS=0 +FAIL=0 +FAIL_NAMES="" + +for case_dir in "$CASES_DIR"/*/; do + name=$(basename "$case_dir") + input="$case_dir/input" + [ -f "$input" ] || { echo "skip: $name (no input)"; continue; } + + expected_exit=0 + [ -f "$case_dir/exit" ] && expected_exit=$(cat "$case_dir/exit") + + work=$(mktemp "${TMPDIR:-/tmp}/cn-test.XXXXXX") + err=$(mktemp "${TMPDIR:-/tmp}/cn-test-err.XXXXXX") + cp "$input" "$work" + + set +e + "$NORMALIZER" "$work" 2>"$err" + got_exit=$? + set -e + + ok=1 + msg="" + + if [ "$got_exit" -ne "$expected_exit" ]; then + ok=0 + msg="exit $got_exit, expected $expected_exit" + fi + + if [ "$ok" -eq 1 ] && [ "$expected_exit" -eq 0 ] && [ -f "$case_dir/expected" ]; then + if ! diff -u "$case_dir/expected" "$work" >/dev/null; then + ok=0 + msg="output mismatch" + DIFF=$(diff -u "$case_dir/expected" "$work" || true) + fi + fi + + if [ "$ok" -eq 1 ] && [ -f "$case_dir/expected.err" ]; then + needle=$(cat "$case_dir/expected.err") + if ! grep -qF "$needle" "$err"; then + ok=0 + msg="stderr missing: $needle" + fi + fi + + if [ "$ok" -eq 1 ]; then + printf " ✓ %s\n" "$name" + PASS=$((PASS + 1)) + else + printf " ✗ %s — %s\n" "$name" "$msg" + if [ -n "${DIFF:-}" ]; then + printf '%s\n' "$DIFF" | sed 's/^/ /' + DIFF="" + fi + if [ -s "$err" ]; then + printf " stderr:\n" + sed 's/^/ /' "$err" + fi + FAIL=$((FAIL + 1)) + FAIL_NAMES="$FAIL_NAMES $name" + fi + + rm -f "$work" "$err" +done + +echo +echo "passed: $PASS failed: $FAIL" +if [ "$FAIL" -gt 0 ]; then + echo "failures:$FAIL_NAMES" + exit 1 +fi