diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 456b816..be5c8a4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -74,5 +74,5 @@ 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 - git commit -m "td: bump to ${{ steps.version.outputs.version }}" + git commit -m "chore(homebrew): bump td to ${{ steps.version.outputs.version }}" git push diff --git a/CLAUDE.md b/CLAUDE.md index cd0ab66..a3b35dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,13 +34,12 @@ go test ./... # Test all ```bash # Commit changes with proper message git add . -git commit -m "feat: description of changes +git commit -m "feat: describe changes (td-) Details here -🤖 Generated with Claude Code - -Co-Authored-By: Claude Haiku 4.5 " +Nightshift-Task: +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" diff --git a/Makefile b/Makefile index 18da511..c3c1d0e 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 install-hooks # install git pre-commit and commit-msg hooks" \ " make test # go test ./..." \ + " make test-hooks # run hook regression 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)" @@ -25,6 +26,9 @@ fmt: test: go test ./... +test-hooks: + ./scripts/test-commit-msg.sh + install: @V="$(GIT_DESCRIBE)"; V=$${V:-dev}; \ echo "Installing td $$V"; \ @@ -52,6 +56,18 @@ release: tag git push origin "$(VERSION)" install-hooks: - @echo "Installing git pre-commit hook..." - @ln -sf ../../scripts/pre-commit.sh .git/hooks/pre-commit - @echo "Done. Hook installed at .git/hooks/pre-commit" + @hooks_dir=$$(git rev-parse --git-path hooks); \ + echo "Installing git hooks in $$hooks_dir..."; \ + mkdir -p "$$hooks_dir"; \ + for hook in pre-commit commit-msg; do \ + hook_path="$$hooks_dir/$$hook"; \ + rm -f "$$hook_path"; \ + printf '%s\n' \ + '#!/bin/sh' \ + 'set -eu' \ + 'repo_root=$$(git rev-parse --show-toplevel)' \ + "exec \"\$$repo_root/scripts/$$hook.sh\" \"\$$@\"" \ + > "$$hook_path"; \ + chmod +x "$$hook_path"; \ + done; \ + echo "Done. Hooks installed at $$hooks_dir/pre-commit and $$hooks_dir/commit-msg" diff --git a/README.md b/README.md index 684416a..83861a4 100644 --- a/README.md +++ b/README.md @@ -189,10 +189,16 @@ 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 -> gofmt, go vet, go build +# commit-msg -> normalize commit subjects make install-hooks ``` +`make install-hooks` installs wrappers into the repo's resolved git hooks directory, so the same setup works from linked worktrees too. + +Commit subjects are normalized to `type: summary` or `type(scope): summary`, with an optional trailing ` (td-)`. The `commit-msg` hook rewrites obvious cases like `Docs - Update changelog` to `docs: update changelog`, preserves commit bodies and trailers, leaves Git-generated merge/fixup/squash/revert subjects alone, and stops the commit with guidance when the subject cannot be safely interpreted. + ## Tests & Quality Checks ```bash diff --git a/docs/guides/releasing-new-version.md b/docs/guides/releasing-new-version.md index ca98e52..e0f0c5b 100644 --- a/docs/guides/releasing-new-version.md +++ b/docs/guides/releasing-new-version.md @@ -53,9 +53,11 @@ 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" ``` +If you have run `make install-hooks`, the local `commit-msg` hook will normalize obvious subject formatting issues, preserve the commit body and trailers, and reject subjects it cannot safely interpret. Use `type: summary` or `type(scope): summary`, plus an optional trailing ` (td-)` when the release prep is tied to a task. + ### 3. Verify Tests Pass ```bash @@ -137,7 +139,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" # 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 0000000..29b885a --- /dev/null +++ b/scripts/commit-msg.sh @@ -0,0 +1,233 @@ +#!/usr/bin/env bash +# commit-msg hook for td +# Install: make install-hooks (recommended; resolves the active hooks path for linked worktrees too) +set -euo pipefail + +message_file=${1:-} + +trim() { + local value=${1-} + value=${value#"${value%%[![:space:]]*}"} + value=${value%"${value##*[![:space:]]}"} + printf '%s' "$value" +} + +normalize_type() { + local raw normalized + + raw=$(trim "${1-}") + raw=$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]') + raw=${raw//_/-} + + case "$raw" in + feat|feature|features) + normalized="feat" + ;; + fix|bug|bugfix|bug-fix|hotfix) + normalized="fix" + ;; + docs|doc|documentation) + normalized="docs" + ;; + chore|chores) + normalized="chore" + ;; + build|ci|perf|refactor|revert|style) + normalized="$raw" + ;; + test|tests|testing) + normalized="test" + ;; + *) + return 1 + ;; + esac + + printf '%s' "$normalized" +} + +normalize_scope() { + local raw normalized + + raw=$(trim "${1-}") + raw=${raw#[} + raw=${raw%]} + raw=$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]') + normalized=$(printf '%s' "$raw" | sed -E 's/[[:space:]_]+/-/g; s/^-+//; s/-+$//; s/-+/-/g') + + if [[ -z "$normalized" || ! "$normalized" =~ ^[a-z0-9][a-z0-9._/-]*$ ]]; then + return 1 + fi + + printf '%s' "$normalized" +} + +normalize_summary() { + local summary first_char + + summary=$(trim "${1-}") + summary=$(printf '%s' "$summary" | sed -E 's/[[:space:]]+/ /g') + + if [[ "$summary" == *"." && "$summary" != *"..." ]]; then + summary=${summary%.} + fi + + if [[ "$summary" =~ ^([A-Z])([a-z].*)$ ]]; then + first_char=$(printf '%s' "${BASH_REMATCH[1]}" | tr '[:upper:]' '[:lower:]') + summary="${first_char}${BASH_REMATCH[2]}" + fi + + printf '%s' "$summary" +} + +print_error() { + local subject=${1-} + + cat >&2 <&2 + exit 1 +fi + +lines=() +while IFS= read -r line || [[ -n "$line" ]]; do + lines+=("$line") +done < "$message_file" +subject_index=-1 + +for i in "${!lines[@]}"; do + line=${lines[$i]} + if [[ -z $(trim "$line") || "$line" =~ ^# ]]; then + continue + fi + subject_index=$i + break +done + +if (( subject_index < 0 )); then + exit 0 +fi + +original_subject=${lines[$subject_index]} +normalized_subject=$(normalize_subject "$original_subject") + +if [[ "$normalized_subject" != "$(trim "$original_subject")" ]]; then + lines[$subject_index]=$normalized_subject + printf '%s\n' "${lines[@]}" > "$message_file" + echo "commit-msg: normalized subject to '$normalized_subject'" >&2 +fi diff --git a/scripts/loop-prompt.md b/scripts/loop-prompt.md index 7f84db2..f9883b0 100644 --- a/scripts/loop-prompt.md +++ b/scripts/loop-prompt.md @@ -174,4 +174,4 @@ Use `td review`, not `td close` — self-closing is blocked. - **Don't break sync.** Deterministic IDs, proper event logging, no hard deletes. - **Session isolation is sacred.** Don't bypass review guards. - **If stuck, log and skip.** `td log "Blocked: "` then `td block `. -- **Commit messages reference td.** Format: `feat|fix|chore: (td-)` +- **Commit messages follow the repo hook.** Format: `type: ` or `type(scope): `, with optional ` (td-)`. diff --git a/scripts/pre-commit.sh b/scripts/pre-commit.sh index 2b563f2..ddc8085 100755 --- a/scripts/pre-commit.sh +++ b/scripts/pre-commit.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # pre-commit hook for td -# Install: make install-hooks (or: ln -sf ../../scripts/pre-commit.sh .git/hooks/pre-commit) +# Install: make install-hooks (recommended; resolves the active hooks path for linked worktrees too) set -euo pipefail PASS=0 diff --git a/scripts/test-commit-msg.sh b/scripts/test-commit-msg.sh new file mode 100755 index 0000000..ed44c80 --- /dev/null +++ b/scripts/test-commit-msg.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(git rev-parse --show-toplevel) +hook_script="$repo_root/scripts/commit-msg.sh" +tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/td-commit-msg-test.XXXXXX") + +cleanup() { + rm -rf "$tmp_dir" +} + +fail() { + printf 'test-commit-msg: %s\n' "$1" >&2 + exit 1 +} + +write_message() { + local path=$1 + shift + + printf '%s\n' "$@" > "$path" +} + +assert_file_equals() { + local path=$1 + local expected=$2 + local actual + + actual=$(cat "$path") + if [[ "$actual" != "$expected" ]]; then + printf 'Expected:\n%s\n\nActual:\n%s\n' "$expected" "$actual" >&2 + fail "file contents did not match for $path" + fi +} + +run_success_case() { + local name=$1 + local expected=$2 + shift 2 + + local message_file="$tmp_dir/$name.txt" + local stderr_file="$tmp_dir/$name.stderr" + + write_message "$message_file" "$@" + + if ! "$hook_script" "$message_file" > /dev/null 2> "$stderr_file"; then + cat "$stderr_file" >&2 + fail "expected success for $name" + fi + + assert_file_equals "$message_file" "$expected" +} + +run_failure_case() { + local name=$1 + local subject=$2 + local message_file="$tmp_dir/$name.txt" + local stderr_file="$tmp_dir/$name.stderr" + + write_message "$message_file" "$subject" + + if "$hook_script" "$message_file" > /dev/null 2> "$stderr_file"; then + cat "$stderr_file" >&2 + fail "expected failure for $name" + fi + + grep -F "type: summary" "$stderr_file" > /dev/null || fail "expected guidance output for $name" + assert_file_equals "$message_file" "$subject" +} + +trap cleanup EXIT + +run_success_case \ + valid-subject \ + "feat: add commit message normalizer (td-527bd4-0006)" \ + "feat: add commit message normalizer (td-527bd4-0006)" + +run_success_case \ + normalize-simple \ + "docs: update changelog" \ + "Docs - Update changelog" + +run_success_case \ + preserve-body \ + $'fix(sync): preserve trailers (td-527bd4-0006)\n\nBody line\n\nNightshift-Task: commit-normalize\nNightshift-Ref: https://github.com/marcus/nightshift' \ + "Fix sync - Preserve trailers (td-527bd4-0006)" \ + "" \ + "Body line" \ + "" \ + "Nightshift-Task: commit-normalize" \ + "Nightshift-Ref: https://github.com/marcus/nightshift" + +run_success_case \ + normalize-release \ + "chore(homebrew): bump td to v1.2.3" \ + "Chore homebrew - Bump td to v1.2.3" + +run_failure_case "invalid-td-empty" "feat: add normalizer (td-)" +run_failure_case "invalid-td-space" "feat: add normalizer (td 527bd4)" +run_failure_case "invalid-td-symbols" "feat: add normalizer (Td-???)" + +printf 'commit-msg regression tests passed\n'