From 2df85b7731d06da2476aacc6c41cf2aac6601c83 Mon Sep 17 00:00:00 2001 From: Greg Gardner Date: Wed, 15 Jul 2026 02:10:49 -0700 Subject: [PATCH 1/5] chore: normalize commit messages Nightshift-Task: commit-normalize Nightshift-Ref: https://github.com/marcus/nightshift --- .githooks/commit-msg | 5 + .githooks/pre-commit | 5 + Makefile | 17 ++-- README.md | 37 ++++++- scripts/normalize-commit-message.sh | 148 ++++++++++++++++++++++++++++ tests/commit-message-normalizer.sh | 146 +++++++++++++++++++++++++++ 6 files changed, 349 insertions(+), 9 deletions(-) create mode 100755 .githooks/commit-msg create mode 100755 .githooks/pre-commit create mode 100755 scripts/normalize-commit-message.sh create mode 100755 tests/commit-message-normalizer.sh diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..93d9b70 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu + +repo_root=$(git rev-parse --show-toplevel) +exec "$repo_root/scripts/normalize-commit-message.sh" "$@" diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..a02f0f3 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu + +repo_root=$(git rev-parse --show-toplevel) +exec "$repo_root/scripts/pre-commit.sh" "$@" diff --git a/Makefile b/Makefile index 088be01..10e4ec9 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build test test-verbose test-race coverage coverage-html lint clean deps check install calibrate-providers install-hooks help +.PHONY: build test test-commit-message test-verbose test-race coverage coverage-html lint clean deps check install calibrate-providers install-hooks help # Binary name BINARY=nightshift @@ -18,9 +18,13 @@ calibrate-providers: go run ./cmd/provider-calibration --repo "$$(pwd)" --codex-originator codex_cli_rs --min-user-turns 2 # Run all tests -test: +test: test-commit-message go test ./... +# Run commit-message hook regression tests +test-commit-message: + ./tests/commit-message-normalizer.sh + # Run tests with verbose output test-verbose: go test -v ./... @@ -65,6 +69,7 @@ help: @echo "Available targets:" @echo " build - Build the binary" @echo " test - Run all tests" + @echo " test-commit-message - Run commit-message hook regression tests" @echo " test-verbose - Run tests with verbose output" @echo " test-race - Run tests with race detection" @echo " coverage - Run tests with coverage report" @@ -75,10 +80,10 @@ help: @echo " check - Run tests and lint" @echo " install - Build and install to Go bin directory" @echo " calibrate-providers - Compare local Claude/Codex session usage for calibration" - @echo " install-hooks - Install git pre-commit hook" + @echo " install-hooks - Enable repository-managed git hooks" @echo " help - Show this help" -# Install git pre-commit hook +# Enable repository-managed git hooks install-hooks: - @ln -sf ../../scripts/pre-commit.sh .git/hooks/pre-commit - @echo "✓ pre-commit hook installed (.git/hooks/pre-commit → scripts/pre-commit.sh)" + @git config core.hooksPath .githooks + @echo "✓ repository hooks enabled (.githooks)" diff --git a/README.md b/README.md index b7c2b5d..2e780f9 100644 --- a/README.md +++ b/README.md @@ -301,19 +301,50 @@ Each task has a default cooldown interval to prevent the same task from running ## Development -### Pre-commit hooks +### Git hooks and commit messages -Install the git pre-commit hook to catch formatting and vet issues before pushing: +Enable the repository-managed hooks before committing: ```bash make install-hooks +# Equivalent command: +git config core.hooksPath .githooks ``` -This symlinks `scripts/pre-commit.sh` into `.git/hooks/pre-commit`. The hook runs: +The `pre-commit` hook runs: - **gofmt** - flags any staged `.go` files that need formatting - **go vet** - catches common correctness issues - **go build** - ensures the project compiles +The `commit-msg` hook normalizes and validates Conventional Commit subjects in +the `type(scope)!: summary` format. The scope and `!` marker are optional. The +supported types are `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, +`refactor`, `style`, and `test`. + +The normalizer lowercases supported types, removes spacing around the optional +scope and breaking-change marker, collapses repeated subject whitespace, trims +leading and trailing blank lines, and removes trailing whitespace. Message body +paragraphs and Git trailers otherwise keep their original content. + +Examples: + +```text +feat(run): add pause command +fix(config)!: reject unknown provider keys +docs: explain hook installation +``` + +Merge and revert messages, `fixup!`, `squash!`, and `amend!` commits, stash +subjects, and comment-led messages generated by Git are preserved byte for byte. +An empty message, unsupported type, missing summary, or malformed scope is +rejected without changing the message file. + +Run the shell regression suite directly with: + +```bash +make test-commit-message +``` + To bypass in a pinch: `git commit --no-verify` ## Uninstalling diff --git a/scripts/normalize-commit-message.sh b/scripts/normalize-commit-message.sh new file mode 100755 index 0000000..fc02102 --- /dev/null +++ b/scripts/normalize-commit-message.sh @@ -0,0 +1,148 @@ +#!/bin/sh + +# Normalize and validate a Conventional Commit message in place. +set -eu + +usage() { + echo "usage: $0 " >&2 +} + +if [ "$#" -ne 1 ]; then + usage + exit 2 +fi + +message_file=$1 +if [ ! -f "$message_file" ] || [ ! -r "$message_file" ] || [ ! -w "$message_file" ]; then + echo "commit message is not a readable, writable file: $message_file" >&2 + exit 2 +fi + +# Git creates these subjects itself. Rewriting them can break merges, reverts, +# autosquash, stashes, and interactive rebases, so preserve the entire file. +first_line=$(awk ' + { + line = $0 + sub(/\r$/, "", line) + if (line !~ /^[[:space:]]*$/) { + sub(/^[[:space:]]+/, "", line) + print line + exit + } + } +' "$message_file") + +case "$first_line" in + \#*|Merge\ *|Revert\ *|fixup\!\ *|squash\!\ *|amend\!\ *|WIP\ on\ *|index\ on\ *) + exit 0 + ;; +esac + +tmp_file=$(mktemp "${TMPDIR:-/tmp}/nightshift-commit-msg.XXXXXX") || exit 2 +cleanup() { + rm -f "$tmp_file" +} +trap cleanup 0 +trap 'exit 1' 1 2 15 + +if ! awk ' + function trim(value) { + sub(/^[[:space:]]+/, "", value) + sub(/[[:space:]]+$/, "", value) + return value + } + + function collapse(value) { + value = trim(value) + gsub(/[[:space:]]+/, " ", value) + return value + } + + function supported(value) { + return value == "build" || value == "chore" || value == "ci" || + value == "docs" || value == "feat" || value == "fix" || + value == "perf" || value == "refactor" || value == "style" || + value == "test" + } + + { + line = $0 + sub(/[[:space:]]+$/, "", line) + lines[++count] = line + } + + END { + first = 1 + while (first <= count && lines[first] == "") { + first++ + } + + last = count + while (last >= first && lines[last] == "") { + last-- + } + + if (first > last) { + exit 1 + } + + subject = collapse(lines[first]) + colon = index(subject, ":") + if (colon == 0) { + exit 1 + } + + header = trim(substr(subject, 1, colon - 1)) + summary = collapse(substr(subject, colon + 1)) + if (summary == "") { + exit 1 + } + + breaking = "" + if (header ~ /![[:space:]]*$/) { + sub(/![[:space:]]*$/, "", header) + header = trim(header) + breaking = "!" + } + + open = index(header, "(") + scope = "" + if (open > 0) { + if (substr(header, length(header), 1) != ")") { + exit 1 + } + type = tolower(trim(substr(header, 1, open - 1))) + scope = collapse(substr(header, open + 1, length(header) - open - 1)) + if (scope == "" || scope !~ /^[[:alnum:]_.\/#-]+$/) { + exit 1 + } + } else { + type = tolower(trim(header)) + if (type ~ /[()]/) { + exit 1 + } + } + + if (!supported(type)) { + exit 1 + } + + normalized = type + if (scope != "") { + normalized = normalized "(" scope ")" + } + print normalized breaking ": " summary + + for (i = first + 1; i <= last; i++) { + print lines[i] + } + } +' "$message_file" > "$tmp_file"; then + cat >&2 <<'EOF' +invalid commit subject; expected type(scope)!: summary +supported types: build, chore, ci, docs, feat, fix, perf, refactor, style, test +EOF + exit 1 +fi + +cat "$tmp_file" > "$message_file" diff --git a/tests/commit-message-normalizer.sh b/tests/commit-message-normalizer.sh new file mode 100755 index 0000000..3bf6191 --- /dev/null +++ b/tests/commit-message-normalizer.sh @@ -0,0 +1,146 @@ +#!/bin/sh +set -eu + +repo_root=$(CDPATH= cd "$(dirname "$0")/.." && pwd) +normalizer="$repo_root/scripts/normalize-commit-message.sh" +hook="$repo_root/.githooks/commit-msg" +test_dir=$(mktemp -d "${TMPDIR:-/tmp}/nightshift-commit-msg-test.XXXXXX") +cleanup() { + rm -rf "$test_dir" +} +trap cleanup 0 +trap 'exit 1' 1 2 15 + +tests_run=0 + +fail() { + echo "not ok - $1" >&2 + exit 1 +} + +assert_normalizes() { + name=$1 + input=$2 + expected=$3 + message="$test_dir/message" + expected_file="$test_dir/expected" + + printf '%b' "$input" > "$message" + printf '%b' "$expected" > "$expected_file" + "$normalizer" "$message" || fail "$name (normalizer failed)" + cmp -s "$expected_file" "$message" || { + diff -u "$expected_file" "$message" >&2 || true + fail "$name" + } + tests_run=$((tests_run + 1)) + echo "ok - $name" +} + +assert_preserved() { + name=$1 + input=$2 + assert_normalizes "$name" "$input" "$input" +} + +assert_rejected() { + name=$1 + input=$2 + message="$test_dir/message" + original="$test_dir/original" + + printf '%b' "$input" > "$message" + cp "$message" "$original" + if "$normalizer" "$message" >/dev/null 2>&1; then + fail "$name (unexpected success)" + fi + cmp -s "$original" "$message" || fail "$name (message changed)" + tests_run=$((tests_run + 1)) + echo "ok - $name" +} + +assert_normalizes \ + "already-valid subject" \ + 'feat(cli): add status output +' \ + 'feat(cli): add status output +' + +assert_normalizes \ + "capitalization and spacing" \ + ' + FEAT ( cli ) ! : add status output\040\040\040 + +' \ + 'feat(cli)!: add status output +' + +assert_normalizes \ + "scoped breaking change" \ + 'FIX (config)!: reject unknown keys +' \ + 'fix(config)!: reject unknown keys +' + +assert_normalizes \ + "body paragraphs and trailers" \ + 'DOCS : explain provider setup\040\040\040 + +Keep repeated body spacing.\040\040\040 + +Nightshift-Task: commit-normalize\040\040\040 +Nightshift-Ref: https://github.com/marcus/nightshift + +' \ + 'docs: explain provider setup + +Keep repeated body spacing. + +Nightshift-Task: commit-normalize +Nightshift-Ref: https://github.com/marcus/nightshift +' + +assert_rejected "blank input" ' +\040\040\040 +' +assert_rejected "unsupported type" 'release: prepare 1.0 +' + +assert_preserved "merge message" 'Merge branch '\''main'\'' into feature\040\040\040 + +Generated merge body.\040\040\040 +' +assert_preserved "revert message" 'Revert "feat: remove legacy API" + +This reverts commit abc123.\040\040\040 +' +assert_preserved "fixup commit" 'fixup! feat(cli): add status output\040\040\040 +' +assert_preserved "squash commit" 'squash! fix(config): reject unknown keys\040\040\040 +' +assert_preserved "generated rebase message" '# This is a combination of 2 commits. +# This is the 1st commit message: + +feat: first message\040\040\040 +' + +message="$test_dir/message" +printf '%b' ' CHORE : normalize messages\040 +' > "$message" +"$hook" "$message" || fail "commit-msg hook" +printf '%s' 'chore: normalize messages +' > "$test_dir/expected" +cmp -s "$test_dir/expected" "$message" || fail "commit-msg hook" +if "$hook" "$test_dir/missing" >/dev/null 2>&1; then + fail "commit-msg hook propagates failures" +fi +tests_run=$((tests_run + 2)) +echo "ok - commit-msg hook" +echo "ok - commit-msg hook propagates failures" + +cp "$message" "$test_dir/once" +"$normalizer" "$message" || fail "idempotence (second run failed)" +cmp -s "$test_dir/once" "$message" || fail "idempotence" +tests_run=$((tests_run + 1)) +echo "ok - idempotence" + +echo "$tests_run tests passed" From 2e6f2ac4819b77cc9f1c21710096a80498116ed2 Mon Sep 17 00:00:00 2001 From: Greg Gardner Date: Wed, 15 Jul 2026 02:28:08 -0700 Subject: [PATCH 2/5] fix: preserve configured Git comments Nightshift-Task: commit-normalize Nightshift-Ref: https://github.com/marcus/nightshift --- README.md | 2 ++ scripts/normalize-commit-message.sh | 24 ++++++++++++++++++- tests/commit-message-normalizer.sh | 36 +++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2e780f9..3935b29 100644 --- a/README.md +++ b/README.md @@ -336,6 +336,8 @@ docs: explain hook installation Merge and revert messages, `fixup!`, `squash!`, and `amend!` commits, stash subjects, and comment-led messages generated by Git are preserved byte for byte. +Comment-led messages honor Git's configured `core.commentChar` or +`core.commentString` prefix, falling back to Git's default `#` prefix. An empty message, unsupported type, missing summary, or malformed scope is rejected without changing the message file. diff --git a/scripts/normalize-commit-message.sh b/scripts/normalize-commit-message.sh index fc02102..8cdfe19 100755 --- a/scripts/normalize-commit-message.sh +++ b/scripts/normalize-commit-message.sh @@ -32,8 +32,30 @@ first_line=$(awk ' } ' "$message_file") +comment_prefix=$( + git config --get-regexp '^core\.comment(char|string)$' 2>/dev/null | + awk ' + { + sub(/^[^[:space:]]+[[:space:]]+/, "") + prefix = $0 + } + END { print prefix } + ' +) +case "$comment_prefix" in + ""|auto) + comment_prefix="#" + ;; +esac + +case "$first_line" in + "$comment_prefix"*) + exit 0 + ;; +esac + case "$first_line" in - \#*|Merge\ *|Revert\ *|fixup\!\ *|squash\!\ *|amend\!\ *|WIP\ on\ *|index\ on\ *) + Merge\ *|Revert\ *|fixup\!\ *|squash\!\ *|amend\!\ *|WIP\ on\ *|index\ on\ *) exit 0 ;; esac diff --git a/tests/commit-message-normalizer.sh b/tests/commit-message-normalizer.sh index 3bf6191..b08a14c 100755 --- a/tests/commit-message-normalizer.sh +++ b/tests/commit-message-normalizer.sh @@ -42,6 +42,28 @@ assert_preserved() { assert_normalizes "$name" "$input" "$input" } +assert_preserved_with_comment_prefix() { + name=$1 + comment_key=$2 + comment_prefix=$3 + input=$4 + message="$test_dir/message" + expected_file="$test_dir/expected" + + printf '%b' "$input" > "$message" + printf '%b' "$input" > "$expected_file" + GIT_CONFIG_COUNT=1 \ + GIT_CONFIG_KEY_0="core.$comment_key" \ + GIT_CONFIG_VALUE_0="$comment_prefix" \ + "$normalizer" "$message" || fail "$name (normalizer failed)" + cmp -s "$expected_file" "$message" || { + diff -u "$expected_file" "$message" >&2 || true + fail "$name" + } + tests_run=$((tests_run + 1)) + echo "ok - $name" +} + assert_rejected() { name=$1 input=$2 @@ -122,6 +144,20 @@ assert_preserved "generated rebase message" '# This is a combination of 2 commit feat: first message\040\040\040 ' +assert_preserved_with_comment_prefix \ + "generated message with configured comment character" \ + "commentChar" \ + ";" \ + '; Please enter the commit message for your changes.\040\040\040 +; Lines starting with '\'';'\'' will be ignored. +' +assert_preserved_with_comment_prefix \ + "generated message with configured comment string" \ + "commentString" \ + "//" \ + '// Please enter the commit message for your changes.\040\040\040 +// Lines starting with '\''//'\'' will be ignored. +' message="$test_dir/message" printf '%b' ' CHORE : normalize messages\040 From e7b9137cfaf5a350d03aa1fbbc1d3aed0ab35783 Mon Sep 17 00:00:00 2001 From: Greg Gardner Date: Wed, 22 Jul 2026 02:07:42 -0700 Subject: [PATCH 3/5] chore: add safe hook installer Nightshift-Task: commit-normalize Nightshift-Ref: https://github.com/marcus/nightshift --- Makefile | 3 +-- README.md | 19 ++++++++++++-- scripts/install-git-hooks.sh | 15 +++++++++++ tests/commit-message-normalizer.sh | 40 ++++++++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 4 deletions(-) create mode 100755 scripts/install-git-hooks.sh diff --git a/Makefile b/Makefile index 10e4ec9..b19b178 100644 --- a/Makefile +++ b/Makefile @@ -85,5 +85,4 @@ help: # Enable repository-managed git hooks install-hooks: - @git config core.hooksPath .githooks - @echo "✓ repository hooks enabled (.githooks)" + @./scripts/install-git-hooks.sh diff --git a/README.md b/README.md index 3935b29..7738fd5 100644 --- a/README.md +++ b/README.md @@ -307,10 +307,13 @@ Enable the repository-managed hooks before committing: ```bash make install-hooks -# Equivalent command: -git config core.hooksPath .githooks +# Equivalent: +./scripts/install-git-hooks.sh ``` +The installer writes `core.hooksPath` to this repository's local `.git/config`. +It does not change system- or user-level Git configuration. + The `pre-commit` hook runs: - **gofmt** - flags any staged `.go` files that need formatting - **go vet** - catches common correctness issues @@ -347,6 +350,18 @@ Run the shell regression suite directly with: make test-commit-message ``` +To check and preview normalization for a message manually: + +```bash +message_file=$(mktemp) +printf '%s\n' ' FEAT (cli) : add status output ' > "$message_file" +./scripts/normalize-commit-message.sh "$message_file" && cat "$message_file" +rm "$message_file" +``` + +The command exits nonzero and leaves the file unchanged when the subject cannot +be normalized unambiguously. + To bypass in a pinch: `git commit --no-verify` ## Uninstalling diff --git a/scripts/install-git-hooks.sh b/scripts/install-git-hooks.sh new file mode 100755 index 0000000..35d9a7f --- /dev/null +++ b/scripts/install-git-hooks.sh @@ -0,0 +1,15 @@ +#!/bin/sh + +# Enable this repository's version-controlled hooks without changing global Git +# configuration. +set -eu + +repo_root=$(CDPATH= cd "$(dirname "$0")/.." && pwd) + +if ! git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "not a Git worktree: $repo_root" >&2 + exit 1 +fi + +git -C "$repo_root" config --local core.hooksPath .githooks +echo "repository hooks enabled (.githooks)" diff --git a/tests/commit-message-normalizer.sh b/tests/commit-message-normalizer.sh index b08a14c..dbacb31 100755 --- a/tests/commit-message-normalizer.sh +++ b/tests/commit-message-normalizer.sh @@ -4,6 +4,7 @@ set -eu repo_root=$(CDPATH= cd "$(dirname "$0")/.." && pwd) normalizer="$repo_root/scripts/normalize-commit-message.sh" hook="$repo_root/.githooks/commit-msg" +installer="$repo_root/scripts/install-git-hooks.sh" test_dir=$(mktemp -d "${TMPDIR:-/tmp}/nightshift-commit-msg-test.XXXXXX") cleanup() { rm -rf "$test_dir" @@ -121,11 +122,30 @@ Nightshift-Task: commit-normalize Nightshift-Ref: https://github.com/marcus/nightshift ' +assert_normalizes \ + "Git comments after subject" \ + ' CHORE : normalize messages + +# Please enter the commit message for your changes. +# Lines starting with '\''#'\'' will be ignored. +' \ + 'chore: normalize messages + +# Please enter the commit message for your changes. +# Lines starting with '\''#'\'' will be ignored. +' + assert_rejected "blank input" ' \040\040\040 ' assert_rejected "unsupported type" 'release: prepare 1.0 ' +assert_rejected "missing separator" 'feat add status output +' +assert_rejected "empty scope" 'feat(): add status output +' +assert_rejected "missing summary" 'feat(cli): +' assert_preserved "merge message" 'Merge branch '\''main'\'' into feature\040\040\040 @@ -179,4 +199,24 @@ cmp -s "$test_dir/once" "$message" || fail "idempotence" tests_run=$((tests_run + 1)) echo "ok - idempotence" +installer_repo="$test_dir/installer-repo" +mkdir -p "$installer_repo/scripts" "$installer_repo/.githooks" +cp "$installer" "$installer_repo/scripts/install-git-hooks.sh" +git -C "$installer_repo" init -q +global_config="$test_dir/global-gitconfig" +GIT_CONFIG_GLOBAL="$global_config" git config --global core.hooksPath /user/hooks +GIT_CONFIG_GLOBAL="$global_config" "$installer_repo/scripts/install-git-hooks.sh" >/dev/null || + fail "hook installer" +installed_path=$(GIT_CONFIG_GLOBAL="$global_config" git -C "$installer_repo" config --local --get core.hooksPath) +[ "$installed_path" = ".githooks" ] || fail "hook installer writes local config" +global_path=$(GIT_CONFIG_GLOBAL="$global_config" git config --global --get core.hooksPath) +[ "$global_path" = "/user/hooks" ] || fail "hook installer preserves user config" +GIT_CONFIG_GLOBAL="$global_config" "$installer_repo/scripts/install-git-hooks.sh" >/dev/null || + fail "hook installer idempotence" +tests_run=$((tests_run + 4)) +echo "ok - hook installer" +echo "ok - hook installer writes local config" +echo "ok - hook installer preserves user config" +echo "ok - hook installer idempotence" + echo "$tests_run tests passed" From 85be299cd21d82a1b220a9c1f311858758eb2c5e Mon Sep 17 00:00:00 2001 From: Greg Gardner Date: Wed, 22 Jul 2026 02:21:08 -0700 Subject: [PATCH 4/5] fix: harden commit hook setup Nightshift-Task: commit-normalize Nightshift-Ref: https://github.com/marcus/nightshift --- README.md | 16 +++++--- scripts/install-git-hooks.sh | 13 +++++++ scripts/normalize-commit-message.sh | 18 +++++++-- tests/commit-message-normalizer.sh | 57 +++++++++++++++++++++++++++-- 4 files changed, 91 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 7738fd5..97f872c 100644 --- a/README.md +++ b/README.md @@ -312,7 +312,10 @@ make install-hooks ``` The installer writes `core.hooksPath` to this repository's local `.git/config`. -It does not change system- or user-level Git configuration. +It does not change system- or user-level Git configuration. If the repository +already has a different local hooks path, the installer stops without changing +it; merge those hooks into `.githooks` or remove the local setting before +installing. The `pre-commit` hook runs: - **gofmt** - flags any staged `.go` files that need formatting @@ -338,11 +341,12 @@ docs: explain hook installation ``` Merge and revert messages, `fixup!`, `squash!`, and `amend!` commits, stash -subjects, and comment-led messages generated by Git are preserved byte for byte. -Comment-led messages honor Git's configured `core.commentChar` or -`core.commentString` prefix, falling back to Git's default `#` prefix. -An empty message, unsupported type, missing summary, or malformed scope is -rejected without changing the message file. +subjects, and Git's comment-led combined-commit messages are preserved byte for +byte. Combined-commit detection honors Git's configured `core.commentChar` or +`core.commentString` prefix, falling back to Git's default `#` prefix. Other +comment-led subjects are validated normally, so a manual subject such as +`# invalid subject` is rejected. An empty message, unsupported type, missing +summary, or malformed scope is rejected without changing the message file. Run the shell regression suite directly with: diff --git a/scripts/install-git-hooks.sh b/scripts/install-git-hooks.sh index 35d9a7f..76daec2 100755 --- a/scripts/install-git-hooks.sh +++ b/scripts/install-git-hooks.sh @@ -11,5 +11,18 @@ if ! git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1; then exit 1 fi +if existing_hooks_path=$(git -C "$repo_root" config --local --get core.hooksPath 2>/dev/null); then + if [ "$existing_hooks_path" != ".githooks" ]; then + cat >&2 < "$message" + cp "$message" "$original" + if GIT_CONFIG_COUNT=1 \ + GIT_CONFIG_KEY_0="core.$comment_key" \ + GIT_CONFIG_VALUE_0="$comment_prefix" \ + "$normalizer" "$message" >/dev/null 2>&1; then + fail "$name (unexpected success)" + fi + cmp -s "$original" "$message" || fail "$name (message changed)" + tests_run=$((tests_run + 1)) + echo "ok - $name" +} + assert_normalizes \ "already-valid subject" \ 'feat(cli): add status output @@ -146,6 +167,16 @@ assert_rejected "empty scope" 'feat(): add status output ' assert_rejected "missing summary" 'feat(cli): ' +assert_rejected "manual comment subject" '# invalid manual subject +' +assert_rejected "lookalike generated comment subject" '# This is a combination of banana commits. +' +assert_rejected_with_comment_prefix \ + "manual subject with configured comment string" \ + "commentString" \ + "//" \ + '// invalid manual subject +' assert_preserved "merge message" 'Merge branch '\''main'\'' into feature\040\040\040 @@ -168,15 +199,19 @@ assert_preserved_with_comment_prefix \ "generated message with configured comment character" \ "commentChar" \ ";" \ - '; Please enter the commit message for your changes.\040\040\040 -; Lines starting with '\'';'\'' will be ignored. + '; This is a combination of 2 commits. +; This is the 1st commit message: + +feat: first message\040\040\040 ' assert_preserved_with_comment_prefix \ "generated message with configured comment string" \ "commentString" \ "//" \ - '// Please enter the commit message for your changes.\040\040\040 -// Lines starting with '\''//'\'' will be ignored. + '// This is a combination of 2 commits. +// This is the 1st commit message: + +feat: first message\040\040\040 ' message="$test_dir/message" @@ -219,4 +254,18 @@ echo "ok - hook installer writes local config" echo "ok - hook installer preserves user config" echo "ok - hook installer idempotence" +conflict_repo="$test_dir/conflict-repo" +mkdir -p "$conflict_repo/scripts" "$conflict_repo/.githooks" +cp "$installer" "$conflict_repo/scripts/install-git-hooks.sh" +git -C "$conflict_repo" init -q +git -C "$conflict_repo" config --local core.hooksPath .custom-hooks +if "$conflict_repo/scripts/install-git-hooks.sh" >/dev/null 2>&1; then + fail "hook installer rejects conflicting local config" +fi +conflict_path=$(git -C "$conflict_repo" config --local --get core.hooksPath) +[ "$conflict_path" = ".custom-hooks" ] || fail "hook installer preserves conflicting local config" +tests_run=$((tests_run + 2)) +echo "ok - hook installer rejects conflicting local config" +echo "ok - hook installer preserves conflicting local config" + echo "$tests_run tests passed" From 25b0da7863cf84f673dcf85258ec9a2afeaaaeff Mon Sep 17 00:00:00 2001 From: Greg Gardner Date: Wed, 22 Jul 2026 02:35:35 -0700 Subject: [PATCH 5/5] test: isolate commit hook regressions Nightshift-Task: commit-normalize Nightshift-Ref: https://github.com/marcus/nightshift --- tests/commit-message-normalizer.sh | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/commit-message-normalizer.sh b/tests/commit-message-normalizer.sh index a64e211..7c92e78 100755 --- a/tests/commit-message-normalizer.sh +++ b/tests/commit-message-normalizer.sh @@ -12,6 +12,17 @@ cleanup() { trap cleanup 0 trap 'exit 1' 1 2 15 +# Keep the default-prefix fixtures independent of system, user, repository, and +# inherited command-scope Git configuration. Individual tests override this +# command-scope value when exercising core.commentChar or core.commentString. +GIT_CONFIG_NOSYSTEM=1 +GIT_CONFIG_GLOBAL="$test_dir/empty-gitconfig" +GIT_CONFIG_COUNT=1 +GIT_CONFIG_KEY_0=core.commentChar +GIT_CONFIG_VALUE_0='#' +export GIT_CONFIG_NOSYSTEM GIT_CONFIG_GLOBAL GIT_CONFIG_COUNT +export GIT_CONFIG_KEY_0 GIT_CONFIG_VALUE_0 + tests_run=0 fail() {