diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 456b816e..76a99373 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 by scripts/commit-msg.sh as an automated release exception. git commit -m "td: bump to ${{ steps.version.outputs.version }}" git push diff --git a/CLAUDE.md b/CLAUDE.md index cd0ab661..32bd8d47 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,14 +33,8 @@ go test ./... # Test all ```bash # Commit changes with proper message -git add . -git commit -m "feat: description of changes - -Details here - -🤖 Generated with Claude Code - -Co-Authored-By: Claude Haiku 4.5 " +git add +git commit -m "feat(scope): concise summary (td-a1b2)" # 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 +50,9 @@ go install -ldflags "-X main.Version=v0.3.0" ./... td version ``` +Human-authored task commits should use `type[(scope)]: summary (td-)`. +The automated Homebrew tap release commit is the approved exception: `td: bump to vX.Y.Z`. + ## Architecture - `cmd/` - Cobra commands diff --git a/Makefile b/Makefile index 18da511f..48581050 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,7 +13,8 @@ help: @printf "%s\n" \ "Targets:" \ " make fmt # gofmt -w ." \ - " make install-hooks # install git pre-commit hook" \ + " make test-hooks # test git hook scripts" \ + " make install-hooks # install git pre-commit and commit-msg hooks" \ " make test # go test ./..." \ " make install # build and install with version from git" \ " make tag VERSION=vX.Y.Z # create annotated git tag (requires clean tree)" \ @@ -25,6 +26,9 @@ fmt: test: go test ./... +test-hooks: + bash ./scripts/test_commit-msg.sh + install: @V="$(GIT_DESCRIBE)"; V=$${V:-dev}; \ echo "Installing td $$V"; \ @@ -52,6 +56,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..364396ac 100644 --- a/README.md +++ b/README.md @@ -189,16 +189,22 @@ 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 message normalization) make install-hooks ``` +Commit subjects for task work use the canonical format `type[(scope)]: summary (td-)`. +Example: `feat(sync): persist per-device cursor state (td-a1b2)`. + ## Tests & Quality Checks ```bash # Run all tests (114 tests across cmd/, internal/db/, internal/models/, etc.) make test +# Test git hook scripts +make test-hooks + # Expected output: ok for each package, ~2s total runtime # Example: # ok github.com/marcus/td/cmd 1.994s diff --git a/docs/guides/releasing-new-version.md b/docs/guides/releasing-new-version.md index ca98e527..bc0da5fa 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-)" ``` ### 3. Verify Tests Pass @@ -137,7 +137,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-)" # Push commits, then tag (tag push triggers automated release) git push origin main @@ -164,3 +164,19 @@ brew upgrade td && td version - [ ] Homebrew tap updated in `marcus/homebrew-tap` (automated) - [ ] `brew install marcus/tap/td` works - [ ] `td version` shows correct version + +## Commit Message Format + +Human-authored task commits use `type[(scope)]: summary (td-)`. +Examples: + +```bash +git commit -m "docs: update changelog for vX.Y.Z (td-a1b2)" +git commit -m "fix(release): tighten release guide wording (td-c3d4)" +``` + +The automated Homebrew tap update created by `.github/workflows/release.yml` is the approved exception: + +```text +td: bump to vX.Y.Z +``` diff --git a/scripts/commit-msg.sh b/scripts/commit-msg.sh new file mode 100755 index 00000000..89b28897 --- /dev/null +++ b/scripts/commit-msg.sh @@ -0,0 +1,101 @@ +#!/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 + +if [[ $# -ne 1 ]]; then + echo "usage: $0 " >&2 + exit 1 +fi + +msg_file=$1 +subject_pattern='^([[:alpha:]][[:alnum:]-]*)(\([^()]+\))?:[[:space:]]*(.+)$' +paren_task_pattern='^(.*[^[:space:]])[[:space:]]+\((td-[[:alnum:]]+)\)$' +bare_task_pattern='^(.*[^[:space:]])[[:space:]]+(td-[[:alnum:]]+)$' + +trim() { + local value=$1 + value=${value#"${value%%[![:space:]]*}"} + value=${value%"${value##*[![:space:]]}"} + printf '%s' "$value" +} + +subject="" +if IFS= read -r subject <"$msg_file"; then + : +fi +subject=$(trim "$subject") + +if [[ -z "$subject" ]]; then + echo "commit message subject is required" >&2 + exit 1 +fi + +# Preserve Git's generated subjects and the automated Homebrew tap bump commit. +if [[ "$subject" =~ ^(Merge|Revert|fixup\!|squash\!)\ ]]; then + exit 0 +fi +if [[ "$subject" =~ ^td:\ bump\ to\ v[0-9]+\.[0-9]+\.[0-9]+([.-][[:alnum:]]+)*$ ]]; then + exit 0 +fi + +if [[ ! "$subject" =~ $subject_pattern ]]; then + cat >&2 <<'EOF' +invalid commit message subject + +Expected format: + type: summary (td-) + type(scope): summary (td-) + +Example: + feat(sync): persist cursor handling (td-a1b2) +EOF + exit 1 +fi + +prefix=${BASH_REMATCH[1]} +scope=${BASH_REMATCH[2]:-} +rest=$(trim "${BASH_REMATCH[3]}") + +summary= +task_ref= +if [[ "$rest" =~ $paren_task_pattern ]]; then + summary=$(trim "${BASH_REMATCH[1]}") + task_ref=${BASH_REMATCH[2]} +elif [[ "$rest" =~ $bare_task_pattern ]]; then + summary=$(trim "${BASH_REMATCH[1]}") + task_ref=${BASH_REMATCH[2]} +else + cat >&2 <<'EOF' +missing trailing task reference in commit message subject + +Expected format: + type: summary (td-) + type(scope): summary (td-) + +Example: + fix(db): preserve audit trail ordering (td-a1b2) +EOF + exit 1 +fi + +if [[ -z "$summary" ]]; then + echo "commit message summary cannot be empty" >&2 + exit 1 +fi + +normalized="${prefix}${scope}: ${summary} (${task_ref})" + +if [[ "$normalized" == "$subject" ]]; then + exit 0 +fi + +tmp_file=$(mktemp) +trap 'rm -f "$tmp_file"' EXIT + +printf '%s\n' "$normalized" >"$tmp_file" +if [[ $(wc -l <"$msg_file") -gt 1 ]]; then + tail -n +2 "$msg_file" >>"$tmp_file" +fi + +mv "$tmp_file" "$msg_file" diff --git a/scripts/loop-prompt.md b/scripts/loop-prompt.md index 7f84db2e..d847b20f 100644 --- a/scripts/loop-prompt.md +++ b/scripts/loop-prompt.md @@ -158,7 +158,7 @@ Batch review loops: ```bash git add -git commit -m "feat: (td-)" +git commit -m "feat(scope): (td-)" td review ``` @@ -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 reference td.** Format: `type[(scope)]: (td-)` diff --git a/scripts/test_commit-msg.sh b/scripts/test_commit-msg.sh new file mode 100755 index 00000000..30d7b852 --- /dev/null +++ b/scripts/test_commit-msg.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +HOOK="$ROOT/scripts/commit-msg.sh" + +pass_count=0 + +make_msg_file() { + local path=$1 + printf '%s' "$2" >"$path" +} + +run_success_case() { + local name=$1 + local input=$2 + local expected=$3 + local msg_file + msg_file=$(mktemp) + make_msg_file "$msg_file" "$input" + + "$HOOK" "$msg_file" + + local actual + actual=$(cat "$msg_file") + if [[ "$actual" != "$expected" ]]; then + echo "FAIL: $name" + echo "expected:" + printf '%s\n' "$expected" + echo "actual:" + printf '%s\n' "$actual" + rm -f "$msg_file" + exit 1 + fi + + rm -f "$msg_file" + pass_count=$((pass_count + 1)) +} + +run_failure_case() { + local name=$1 + local input=$2 + local expected_stderr=$3 + local msg_file + local err_file + msg_file=$(mktemp) + err_file=$(mktemp) + make_msg_file "$msg_file" "$input" + + if "$HOOK" "$msg_file" 2>"$err_file"; then + echo "FAIL: $name" + echo "expected hook to fail" + rm -f "$msg_file" "$err_file" + exit 1 + fi + + if ! grep -Fq "$expected_stderr" "$err_file"; then + echo "FAIL: $name" + echo "expected stderr to contain: $expected_stderr" + echo "actual stderr:" + cat "$err_file" + rm -f "$msg_file" "$err_file" + exit 1 + fi + + rm -f "$msg_file" "$err_file" + pass_count=$((pass_count + 1)) +} + +run_success_case \ + "normalizes plain subject" \ + $'feat: normalize hook output td-a1b2\n\nBody stays put.\n' \ + $'feat: normalize hook output (td-a1b2)\n\nBody stays put.' + +run_success_case \ + "accepts canonical subject" \ + $'fix: preserve body content (td-a1b2)\n\nNightshift-Task: commit-normalize\n' \ + $'fix: preserve body content (td-a1b2)\n\nNightshift-Task: commit-normalize' + +run_success_case \ + "normalizes scoped subject" \ + $'feat(sync): persist cursor td-a1b2' \ + $'feat(sync): persist cursor (td-a1b2)' + +run_success_case \ + "uses trailing task token when summary mentions another td id" \ + $'feat: mention td-a1b2 parsing before final ref (td-c3d4)' \ + $'feat: mention td-a1b2 parsing before final ref (td-c3d4)' + +run_success_case \ + "normalizes trailing task token when summary mentions another td id" \ + $'feat(sync): mention td-a1b2 parsing before final ref td-c3d4' \ + $'feat(sync): mention td-a1b2 parsing before final ref (td-c3d4)' + +run_success_case \ + "allows automated release bump commits" \ + 'td: bump to v1.2.3' \ + 'td: bump to v1.2.3' + +run_success_case \ + "allows merge subjects" \ + 'Merge branch '\''feature/example'\''' \ + 'Merge branch '\''feature/example'\''' + +run_failure_case \ + "rejects missing task reference" \ + 'feat: missing task reference' \ + 'missing trailing task reference' + +run_failure_case \ + "rejects malformed prefix" \ + 'feat sync: malformed prefix td-a1b2' \ + 'invalid commit message subject' + +echo "commit-msg hook tests passed ($pass_count cases)"