diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 456b816e..0c4f0768 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: update td formula for ${{ steps.version.outputs.version }}" git push diff --git a/CLAUDE.md b/CLAUDE.md index cd0ab661..e36b1ab4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,13 +34,10 @@ 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 " +# Release or automation-only commits omit the td suffix +git commit -m "docs: update changelog for v0.3.0" # 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 18da511f..1000df3a 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ help: @printf "%s\n" \ "Targets:" \ " make fmt # gofmt -w ." \ - " make install-hooks # install git pre-commit hook" \ + " make install-hooks # install git hooks (pre-commit, commit-msg)" \ " 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)" \ @@ -52,6 +52,9 @@ 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)"; \ + mkdir -p "$$HOOKS_DIR"; \ + echo "Installing git hooks into $$HOOKS_DIR..."; \ + install -m 0755 scripts/pre-commit.sh "$$HOOKS_DIR/pre-commit"; \ + install -m 0755 scripts/commit-msg.sh "$$HOOKS_DIR/commit-msg"; \ + echo "Done. Hooks installed at $$HOOKS_DIR/pre-commit and $$HOOKS_DIR/commit-msg" diff --git a/README.md b/README.md index 684416ad..37ba7b78 100644 --- a/README.md +++ b/README.md @@ -189,10 +189,12 @@ 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 ``` +Commit subjects should use `type: summary (td-)` for task-linked development work and `type: summary` for release or automation commits. + ## Tests & Quality Checks ```bash @@ -543,8 +545,9 @@ Contributions welcome! Process: 1. **Fork and branch**: Work on feature branches 2. **Tests required**: Add tests for new features/fixes (see `cmd/*_test.go` for patterns) 3. **Run `make test` and `make fmt`** before submitting -4. **PR review**: One reviewer approval required -5. **Session isolation respected**: PRs should follow td's own handoff patterns where applicable +4. **Commit format**: Use `type: summary (td-)` for task-linked commits; automation/release commits may omit the td suffix +5. **PR review**: One reviewer approval required +6. **Session isolation respected**: PRs should follow td's own handoff patterns where applicable ## Support diff --git a/commit_msg_hook_test.go b/commit_msg_hook_test.go new file mode 100644 index 00000000..260f5c77 --- /dev/null +++ b/commit_msg_hook_test.go @@ -0,0 +1,122 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func runCommitMsgHook(t *testing.T, initial string) (string, string, error) { + t.Helper() + + tmpDir := t.TempDir() + msgFile := filepath.Join(tmpDir, "COMMIT_EDITMSG") + if err := os.WriteFile(msgFile, []byte(initial), 0o644); err != nil { + t.Fatalf("write commit message: %v", err) + } + + cmd := exec.Command("bash", "scripts/commit-msg.sh", msgFile) + cmd.Dir = "." + output, err := cmd.CombinedOutput() + + finalContent, readErr := os.ReadFile(msgFile) + if readErr != nil { + t.Fatalf("read commit message: %v", readErr) + } + + return string(finalContent), string(output), err +} + +func TestCommitMsgHookNormalizesCanonicalSubjects(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + initial string + expected string + }{ + { + name: "task linked subject", + initial: "Feat : normalize commit messages (td-2b41b2)\n\nNightshift-Task: commit-normalize\n" + + "Nightshift-Ref: https://github.com/marcus/nightshift\n", + expected: "feat: normalize commit messages (td-2b41b2)\n\nNightshift-Task: commit-normalize\n" + + "Nightshift-Ref: https://github.com/marcus/nightshift\n", + }, + { + name: "automation subject without td suffix", + initial: "Docs:update changelog for v0.40.0\n", + expected: "docs: update changelog for v0.40.0\n", + }, + { + name: "internal parentheses remain valid", + initial: "Fix: handle foo (bar) safely (td-2b41b2)\n", + expected: "fix: handle foo (bar) safely (td-2b41b2)\n", + }, + { + name: "git workflow subjects bypass normalization", + initial: "fixup! feat: normalize commit messages (td-2b41b2)\n", + expected: "fixup! feat: normalize commit messages (td-2b41b2)\n", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + finalContent, output, err := runCommitMsgHook(t, tt.initial) + if err != nil { + t.Fatalf("hook failed: %v\noutput:\n%s", err, output) + } + if finalContent != tt.expected { + t.Fatalf("unexpected commit message\nwant:\n%s\ngot:\n%s", tt.expected, finalContent) + } + }) + } +} + +func TestCommitMsgHookRejectsNonCanonicalSubjects(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + initial string + }{ + { + name: "missing colon is rejected instead of inferred", + initial: "feat normalize commit messages (td-2b41b2)\n", + }, + { + name: "non td suffix is rejected", + initial: "feat: normalize commit messages (jira-123)\n", + }, + { + name: "extra trailing parenthetical is rejected", + initial: "feat: normalize commit messages (td-2b41b2) (extra)\n", + }, + { + name: "empty summary is rejected", + initial: "feat:\n", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + finalContent, output, err := runCommitMsgHook(t, tt.initial) + if err == nil { + t.Fatalf("expected hook to fail, output:\n%s", output) + } + if finalContent != tt.initial { + t.Fatalf("hook should leave rejected message unchanged\nwant:\n%s\ngot:\n%s", tt.initial, finalContent) + } + if !strings.Contains(output, "type: summary") { + t.Fatalf("expected remediation output, got:\n%s", output) + } + }) + } +} diff --git a/docs/guides/releasing-new-version.md b/docs/guides/releasing-new-version.md index ca98e527..9cea96b0 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" ``` ### 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" # Push commits, then tag (tag push triggers automated release) git push origin main @@ -156,6 +156,7 @@ brew upgrade td && td version - [ ] Working tree clean - [ ] CHANGELOG.md updated with new version entry - [ ] Changelog committed to git +- [ ] Commit subject uses `type: summary` - [ ] Version number follows semver - [ ] Commits pushed to main - [ ] Tag created with `-a` (annotated) diff --git a/scripts/commit-msg.sh b/scripts/commit-msg.sh new file mode 100755 index 00000000..53546c75 --- /dev/null +++ b/scripts/commit-msg.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# commit-msg hook for td +# Install: make install-hooks +# or: install -m 0755 scripts/commit-msg.sh "$(git rev-parse --git-path hooks)/commit-msg" +set -euo pipefail + +msg_file=${1:?usage: commit-msg.sh } + +trim() { + sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//' +} + +fail() { + cat >&2 <<'EOF' +Commit subject must use one of these canonical formats: + type: summary + type: summary (td-) + +The hook only normalizes safe prefix inconsistencies such as type casing or +spacing around the colon. Any other trailing parenthetical suffix is rejected. + +Examples: + feat: normalize commit messages (td-2b41b2) + docs: update changelog for v0.40.0 +EOF + exit 1 +} + +if [[ ! -f "$msg_file" ]]; then + echo "commit-msg hook could not read $msg_file" >&2 + exit 1 +fi + +if IFS= read -r subject <"$msg_file"; then + : +else + subject="" +fi + +trimmed_subject=$(printf '%s' "$subject" | trim) + +if [[ -z "$trimmed_subject" ]]; then + fail +fi + +# Preserve Git workflow subjects that rely on special prefixes. +if [[ "$trimmed_subject" =~ ^(fixup\!\ |squash\!\ |Merge |Revert ) ]]; then + exit 0 +fi + +if [[ ! "$trimmed_subject" =~ ^([[:alpha:]][[:alnum:]-]*)[[:space:]]*:[[:space:]]*(.+)$ ]]; then + fail +fi + +type_part=${BASH_REMATCH[1]} +summary_part=${BASH_REMATCH[2]} + +normalized_type=$(printf '%s' "$type_part" | tr '[:upper:]' '[:lower:]') +normalized_summary=$(printf '%s' "$summary_part" | trim) + +if [[ -z "$normalized_summary" ]]; then + fail +fi + +if [[ "$normalized_summary" =~ ^(.*)[[:space:]]+\(([^()]*)\)$ ]]; then + summary_without_suffix=$(printf '%s' "${BASH_REMATCH[1]}" | trim) + suffix=${BASH_REMATCH[2]} + + if [[ ! "$suffix" =~ ^td-[a-f0-9]+$ ]]; then + fail + fi + if [[ -z "$summary_without_suffix" ]]; then + fail + fi + + normalized_summary="${summary_without_suffix} (${suffix})" +fi + +normalized_subject="${normalized_type}: ${normalized_summary}" + +if [[ "$normalized_subject" == "$subject" ]]; then + exit 0 +fi + +tmp_file=$(mktemp) +trap 'rm -f "$tmp_file"' EXIT + +printf '%s\n' "$normalized_subject" >"$tmp_file" +if [[ $(wc -l <"$msg_file") -gt 1 ]]; then + tail -n +2 "$msg_file" >>"$tmp_file" +fi + +mv "$tmp_file" "$msg_file" +trap - EXIT diff --git a/scripts/loop-prompt.md b/scripts/loop-prompt.md index 7f84db2e..aff5f51e 100644 --- a/scripts/loop-prompt.md +++ b/scripts/loop-prompt.md @@ -163,6 +163,7 @@ td review ``` Use `td review`, not `td close` — self-closing is blocked. +Release and automation-only commits should use `type: ` without the td suffix. ## Rules @@ -174,4 +175,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 use the canonical format.** Task-linked commits: `type: (td-)`; automation/release commits: `type: ` diff --git a/scripts/pre-commit.sh b/scripts/pre-commit.sh index 2b563f26..ef9388d3 100755 --- a/scripts/pre-commit.sh +++ b/scripts/pre-commit.sh @@ -1,6 +1,7 @@ #!/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 +# or: install -m 0755 scripts/pre-commit.sh "$(git rev-parse --git-path hooks)/pre-commit" set -euo pipefail PASS=0