From e123bb4dfa15686b0fc68f57c5e200a7cfeb18a8 Mon Sep 17 00:00:00 2001 From: Review Bot Date: Mon, 20 Apr 2026 02:44:24 -0700 Subject: [PATCH] feat: add commit message normalizer (td-c7ad32) Add a commit-msg hook, test coverage, and docs/install updates for the canonical commit subject format. Nightshift-Task: commit-normalize Nightshift-Ref: https://github.com/marcus/nightshift --- .github/workflows/release.yml | 1 + CLAUDE.md | 11 ++-- Makefile | 16 +++-- README.md | 12 +++- docs/guides/releasing-new-version.md | 5 +- scripts/commit-msg.sh | 91 ++++++++++++++++++++++++++++ scripts/test_commit-msg.sh | 86 ++++++++++++++++++++++++++ 7 files changed, 207 insertions(+), 15 deletions(-) create mode 100755 scripts/commit-msg.sh create mode 100755 scripts/test_commit-msg.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 456b816e..910edb5a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -74,5 +74,6 @@ jobs: git config user.email "github-actions[bot]@users.noreply.github.com" git add Formula/td.rb git diff --cached --quiet && echo "No changes" && exit 0 + # Allowed commit-msg hook exception for release automation. git commit -m "td: bump to ${{ steps.version.outputs.version }}" git push diff --git a/CLAUDE.md b/CLAUDE.md index cd0ab661..56c346a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,15 +32,14 @@ go test ./... # Test all ## Version & Release ```bash -# Commit changes with proper message +# Commit changes with the canonical subject format git add . -git commit -m "feat: description of changes +git commit -m "feat: normalize commit messages (td-a1b2) Details here -🤖 Generated with Claude Code - -Co-Authored-By: Claude Haiku 4.5 " +Nightshift-Task: commit-normalize +Nightshift-Ref: https://github.com/marcus/nightshift" # Create version tag (bump from current version, e.g., v0.2.0 → v0.3.0) git tag -a v0.3.0 -m "Release v0.3.0: description" @@ -56,6 +55,8 @@ go install -ldflags "-X main.Version=v0.3.0" ./... td version ``` +Human-authored commits should use `type: summary (td-)`. The `commit-msg` hook normalizes spacing and casing automatically. Automated release bump commits may use `td: bump to vX.Y.Z`. + ## Architecture - `cmd/` - Cobra commands diff --git a/Makefile b/Makefile index 18da511f..5f45f0f0 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help fmt test install tag release check-clean check-version install-hooks +.PHONY: help fmt test test-hooks install tag release check-clean check-version install-hooks SHELL := /bin/sh @@ -13,8 +13,9 @@ help: @printf "%s\n" \ "Targets:" \ " make fmt # gofmt -w ." \ - " make install-hooks # install git pre-commit hook" \ - " make test # go test ./..." \ + " make install-hooks # install git pre-commit + commit-msg hooks" \ + " make test # go test ./... + hook tests" \ + " make test-hooks # run commit-msg hook tests" \ " make install # build and install with version from git" \ " make tag VERSION=vX.Y.Z # create annotated git tag (requires clean tree)" \ " make release VERSION=vX.Y.Z # tag + push (triggers GoReleaser via GitHub Actions)" @@ -24,6 +25,10 @@ fmt: test: go test ./... + bash ./scripts/test_commit-msg.sh + +test-hooks: + bash ./scripts/test_commit-msg.sh install: @V="$(GIT_DESCRIBE)"; V=$${V:-dev}; \ @@ -52,6 +57,7 @@ release: tag git push origin "$(VERSION)" install-hooks: - @echo "Installing git pre-commit hook..." + @echo "Installing git hooks..." @ln -sf ../../scripts/pre-commit.sh .git/hooks/pre-commit - @echo "Done. Hook installed at .git/hooks/pre-commit" + @ln -sf ../../scripts/commit-msg.sh .git/hooks/commit-msg + @echo "Done. Hooks installed at .git/hooks/pre-commit and .git/hooks/commit-msg" diff --git a/README.md b/README.md index 684416ad..0ebaae10 100644 --- a/README.md +++ b/README.md @@ -189,14 +189,14 @@ make install-dev # Format code make fmt -# Install git pre-commit hook (gofmt, go vet, go build on staged files) +# Install git hooks (pre-commit checks + commit subject normalization) make install-hooks ``` ## Tests & Quality Checks ```bash -# Run all tests (114 tests across cmd/, internal/db/, internal/models/, etc.) +# Run all tests (Go packages + commit-msg hook coverage) make test # Expected output: ok for each package, ~2s total runtime @@ -207,7 +207,13 @@ make test # Format code (runs gofmt) make fmt -# No linter configured yet — clean gofmt is current quality bar +# Commit messages are normalized by the git commit-msg hook to: +# type: summary (td-) +# Example: +# feat: add sync audit trail (td-a1b2) +# +# Allowed exceptions: +# td: bump to vX.Y.Z # automated release/homebrew bump commits ``` ## Release diff --git a/docs/guides/releasing-new-version.md b/docs/guides/releasing-new-version.md index ca98e527..983c0ddb 100644 --- a/docs/guides/releasing-new-version.md +++ b/docs/guides/releasing-new-version.md @@ -53,7 +53,7 @@ Add entry at the top of `CHANGELOG.md`: Commit the changelog: ```bash git add CHANGELOG.md -git commit -m "docs: Update changelog for vX.Y.Z" +git commit -m "docs: update changelog for vX.Y.Z (td-a1b2)" ``` ### 3. Verify Tests Pass @@ -74,6 +74,7 @@ git push origin vX.Y.Z ``` Pushing the tag triggers `.github/workflows/release.yml`, which runs GoReleaser to build binaries, create the GitHub release, and update the Homebrew tap. +The repo's `commit-msg` hook enforces `type: summary (td-)` for human-authored commits. The Homebrew automation commit `td: bump to vX.Y.Z` is an approved exception. ### 5. Verify @@ -137,7 +138,7 @@ go test ./... # Update changelog # (Edit CHANGELOG.md, add entry at top) git add CHANGELOG.md -git commit -m "docs: Update changelog for vX.Y.Z" +git commit -m "docs: update changelog for vX.Y.Z (td-a1b2)" # Push commits, then tag (tag push triggers automated release) git push origin main diff --git a/scripts/commit-msg.sh b/scripts/commit-msg.sh new file mode 100755 index 00000000..61140a71 --- /dev/null +++ b/scripts/commit-msg.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# commit-msg hook for td +# Install: make install-hooks (or: ln -sf ../../scripts/commit-msg.sh .git/hooks/commit-msg) +set -euo pipefail + +MSG_FILE=${1:?commit message file required} + +trim() { + local value=${1-} + value=$(printf '%s' "$value" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//') + printf '%s' "$value" +} + +squeeze_spaces() { + local value=${1-} + value=$(printf '%s' "$value" | tr '\t' ' ' | sed -E 's/[[:space:]]+/ /g') + trim "$value" +} + +lowercase() { + printf '%s' "${1-}" | tr '[:upper:]' '[:lower:]' +} + +fail_invalid() { + cat >&2 <<'EOF' +td commit-msg: commit subject must look like: + type: summary (td-) + +Examples: + feat: add commit message hook (td-a1b2) + fix: preserve commit trailers (td-c3d4) + +Allowed exceptions: + - merge/revert/fixup/squash commits generated by git + - automated release bump commits: td: bump to vX.Y.Z +EOF + exit 1 +} + +if [[ ! -f "$MSG_FILE" ]]; then + echo "td commit-msg: message file not found: $MSG_FILE" >&2 + exit 1 +fi + +subject=$(sed -n '1p' "$MSG_FILE") +rest=$(tail -n +2 "$MSG_FILE" 2>/dev/null || true) +subject=$(squeeze_spaces "$subject") + +if [[ -z "$subject" ]]; then + fail_invalid +fi + +case "$subject" in + Merge\ *|Revert\ *|fixup!\ *|squash!\ *) + exit 0 + ;; +esac + +if [[ "$subject" =~ ^[Tt][Dd][[:space:]]*:[[:space:]]*bump[[:space:]]+to[[:space:]]+(v[0-9]+(\.[0-9]+){2}([-.][[:alnum:]]+)*)$ ]]; then + normalized_subject="td: bump to ${BASH_REMATCH[1]}" +else + if [[ ! "$subject" =~ ^([[:alpha:]][[:alnum:]-]*)[[:space:]]*:[[:space:]]*(.+)$ ]]; then + fail_invalid + fi + + commit_type=$(lowercase "${BASH_REMATCH[1]}") + remainder=$(squeeze_spaces "${BASH_REMATCH[2]}") + token="" + summary="" + + if [[ "$remainder" =~ ^(.*[^[:space:]])[[:space:]]*\(([Tt][Dd]-[[:alnum:]][[:alnum:]-]*)\)[[:space:]]*$ ]]; then + summary=$(squeeze_spaces "${BASH_REMATCH[1]}") + token=$(lowercase "${BASH_REMATCH[2]}") + elif [[ "$remainder" =~ ^(.*[^[:space:]])[[:space:]]+([Tt][Dd]-[[:alnum:]][[:alnum:]-]*)[[:space:]]*$ ]]; then + summary=$(squeeze_spaces "${BASH_REMATCH[1]}") + token=$(lowercase "${BASH_REMATCH[2]}") + fi + + if [[ -z "$token" || -z "$summary" ]]; then + fail_invalid + fi + + normalized_subject="${commit_type}: ${summary} (${token})" +fi + +tmp=$(mktemp) +printf '%s\n' "$normalized_subject" > "$tmp" +if [[ -n "$rest" ]]; then + printf '%s\n' "$rest" >> "$tmp" +fi +mv "$tmp" "$MSG_FILE" diff --git a/scripts/test_commit-msg.sh b/scripts/test_commit-msg.sh new file mode 100755 index 00000000..128fb21f --- /dev/null +++ b/scripts/test_commit-msg.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +HOOK="$ROOT/scripts/commit-msg.sh" +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +pass_count=0 + +run_success_case() { + local name=$1 + local input=$2 + local expected=$3 + local file="$TMPDIR/${name}.txt" + + printf '%s' "$input" > "$file" + "$HOOK" "$file" + + local actual + actual=$(cat "$file") + if [[ "$actual" != "$expected" ]]; then + echo "FAIL: $name" + echo "expected:" + printf '%s\n' "$expected" + echo "actual:" + printf '%s\n' "$actual" + exit 1 + fi + + pass_count=$((pass_count + 1)) +} + +run_failure_case() { + local name=$1 + local input=$2 + local file="$TMPDIR/${name}.txt" + + printf '%s' "$input" > "$file" + if "$HOOK" "$file" >/dev/null 2>&1; then + echo "FAIL: $name" + echo "expected hook failure" + exit 1 + fi + + pass_count=$((pass_count + 1)) +} + +run_success_case \ + canonical \ + $'feat: add commit hook (td-a1b2)\n\nNightshift-Task: commit-normalize\nNightshift-Ref: https://github.com/marcus/nightshift\n' \ + $'feat: add commit hook (td-a1b2)\n\nNightshift-Task: commit-normalize\nNightshift-Ref: https://github.com/marcus/nightshift' + +run_success_case \ + normalize_subject \ + $' Feat : add commit hook td-A1B2 \n' \ + $'feat: add commit hook (td-a1b2)' + +run_success_case \ + preserve_body_and_trailers \ + $'fix: preserve trailers td-c3d4\n\nBody line\n\nNightshift-Task: commit-normalize\nNightshift-Ref: https://github.com/marcus/nightshift\n' \ + $'fix: preserve trailers (td-c3d4)\n\nBody line\n\nNightshift-Task: commit-normalize\nNightshift-Ref: https://github.com/marcus/nightshift' + +run_success_case \ + keep_summary_task_mentions \ + $'feat: mention td-a1b2 parsing before final ref (td-c3d4)\n' \ + $'feat: mention td-a1b2 parsing before final ref (td-c3d4)' + +run_success_case \ + release_exception \ + $'TD: bump to v1.2.3\n' \ + $'td: bump to v1.2.3' + +run_failure_case \ + missing_task_id \ + $'feat: add commit hook\n' + +run_failure_case \ + missing_summary \ + $'feat: td-a1b2\n' + +run_failure_case \ + ambiguous_non_trailing_task_id \ + $'feat: mention td-a1b2 parsing before final ref\n' + +echo "commit-msg hook tests passed: $pass_count"