diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 456b816e..c90add8b 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: bump Homebrew formula to ${{ steps.version.outputs.version }}" git push diff --git a/CLAUDE.md b/CLAUDE.md index cd0ab661..cbfbee55 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,7 +34,7 @@ go test ./... # Test all ```bash # Commit changes with proper message git add . -git commit -m "feat: description of changes +git commit -m "feat: normalize commit messages (td-a1b2) Details here @@ -56,6 +56,9 @@ go install -ldflags "-X main.Version=v0.3.0" ./... td version ``` +Task-linked commits should use `type: summary (td-)`. +Automation-only commits should use `type: summary`, for example `chore: bump Homebrew formula to v0.3.0`. + ## Architecture - `cmd/` - Cobra commands diff --git a/Makefile b/Makefile index 18da511f..01694826 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 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)" \ @@ -52,6 +52,10 @@ 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); \ + repo_root=$$(git rev-parse --show-toplevel); \ + mkdir -p "$$hooks_dir"; \ + echo "Installing git hooks into $$hooks_dir..."; \ + ln -sf "$$repo_root/scripts/pre-commit.sh" "$$hooks_dir/pre-commit"; \ + ln -sf "$$repo_root/scripts/commit-msg.sh" "$$hooks_dir/commit-msg"; \ + echo "Done. Hooks installed at $$hooks_dir" diff --git a/README.md b/README.md index 684416ad..465fb4b2 100644 --- a/README.md +++ b/README.md @@ -189,10 +189,15 @@ 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 runs gofmt, go vet, and go build +# - commit-msg normalizes to type: summary or type: summary (td-) make install-hooks ``` +For task-linked development commits, use `type: summary (td-)` such as `feat: normalize commit messages (td-a1b2)`. +For automation or release commits without a td task, use `type: summary` such as `chore: bump Homebrew formula to v0.3.0`. + ## Tests & Quality Checks ```bash @@ -543,8 +548,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 work, or `type: summary` when no td task applies +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..6eee17c7 --- /dev/null +++ b/commit_msg_hook_test.go @@ -0,0 +1,173 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestCommitMsgHookNormalizesAndRejects(t *testing.T) { + t.Parallel() + + repoRoot, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + + tests := []struct { + name string + input string + want string + wantErr bool + wantStderr string + }{ + { + name: "accepts task linked subject", + input: "feat: normalize commit messages (td-a1b2)\n", + want: "feat: normalize commit messages (td-a1b2)\n", + }, + { + name: "accepts automation subject without task id", + input: "chore: bump Homebrew formula to v1.2.3\n", + want: "chore: bump Homebrew formula to v1.2.3\n", + }, + { + name: "normalizes casing and spacing", + input: " Feat normalize commit messages (td-a1b2) \n\nBody line\nNightshift-Task: commit-normalize\n", + want: "feat: normalize commit messages (td-a1b2)\n\nBody line\nNightshift-Task: commit-normalize\n", + }, + { + name: "rejects invalid trailing parenthetical suffix", + input: "feat: normalize commit messages (jira-123)\n", + want: "feat: normalize commit messages (jira-123)\n", + wantErr: true, + wantStderr: "only allowed trailing parenthetical suffix", + }, + { + name: "rejects invalid suffix after normalizing prefix", + input: "Feat normalize commit messages (foo)\n", + want: "Feat normalize commit messages (foo)\n", + wantErr: true, + wantStderr: "only allowed trailing parenthetical suffix", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + messageFile := filepath.Join(tempDir, "COMMIT_EDITMSG") + if err := os.WriteFile(messageFile, []byte(tt.input), 0o644); err != nil { + t.Fatalf("write message file: %v", err) + } + + cmd := exec.Command("bash", filepath.Join(repoRoot, "scripts/commit-msg.sh"), messageFile) + cmd.Dir = repoRoot + output, err := cmd.CombinedOutput() + got := string(output) + + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got success with output: %s", got) + } + if !strings.Contains(got, tt.wantStderr) { + t.Fatalf("expected output to contain %q, got %q", tt.wantStderr, got) + } + } else if err != nil { + t.Fatalf("hook failed: %v\n%s", err, got) + } + + contents, readErr := os.ReadFile(messageFile) + if readErr != nil { + t.Fatalf("read message file: %v", readErr) + } + if string(contents) != tt.want { + t.Fatalf("unexpected message file contents:\nwant: %q\ngot: %q", tt.want, string(contents)) + } + }) + } +} + +func TestInstallHooksWorksInGitWorktree(t *testing.T) { + t.Parallel() + + repoRoot, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + + tempDir := t.TempDir() + sourceRepo := filepath.Join(tempDir, "source") + worktreeDir := filepath.Join(tempDir, "worktree") + + if err := os.MkdirAll(filepath.Join(sourceRepo, "scripts"), 0o755); err != nil { + t.Fatalf("mkdir scripts: %v", err) + } + + copyFile := func(src, dst string, mode os.FileMode) { + t.Helper() + data, readErr := os.ReadFile(src) + if readErr != nil { + t.Fatalf("read %s: %v", src, readErr) + } + if writeErr := os.WriteFile(dst, data, mode); writeErr != nil { + t.Fatalf("write %s: %v", dst, writeErr) + } + } + + copyFile(filepath.Join(repoRoot, "Makefile"), filepath.Join(sourceRepo, "Makefile"), 0o644) + copyFile(filepath.Join(repoRoot, "scripts/pre-commit.sh"), filepath.Join(sourceRepo, "scripts/pre-commit.sh"), 0o755) + copyFile(filepath.Join(repoRoot, "scripts/commit-msg.sh"), filepath.Join(sourceRepo, "scripts/commit-msg.sh"), 0o755) + + run := func(dir string, args ...string) string { + t.Helper() + cmd := exec.Command(args[0], args[1:]...) + cmd.Dir = dir + output, runErr := cmd.CombinedOutput() + if runErr != nil { + t.Fatalf("%s failed: %v\n%s", strings.Join(args, " "), runErr, output) + } + return string(output) + } + + run(sourceRepo, "git", "init", "-b", "main") + run(sourceRepo, "git", "config", "user.name", "Test User") + run(sourceRepo, "git", "config", "user.email", "test@example.com") + run(sourceRepo, "git", "add", "Makefile", "scripts/pre-commit.sh", "scripts/commit-msg.sh") + run(sourceRepo, "git", "commit", "-m", "chore: seed worktree fixture") + run(sourceRepo, "git", "worktree", "add", "-b", "feature/test-hooks", worktreeDir) + + run(worktreeDir, "make", "install-hooks") + + hooksDir := strings.TrimSpace(run(worktreeDir, "git", "rev-parse", "--git-path", "hooks")) + preCommitTarget := strings.TrimSpace(run(worktreeDir, "readlink", filepath.Join(hooksDir, "pre-commit"))) + commitMsgTarget := strings.TrimSpace(run(worktreeDir, "readlink", filepath.Join(hooksDir, "commit-msg"))) + + wantPreCommit, err := filepath.EvalSymlinks(filepath.Join(worktreeDir, "scripts/pre-commit.sh")) + if err != nil { + t.Fatalf("eval symlink pre-commit target: %v", err) + } + wantCommitMsg, err := filepath.EvalSymlinks(filepath.Join(worktreeDir, "scripts/commit-msg.sh")) + if err != nil { + t.Fatalf("eval symlink commit-msg target: %v", err) + } + preCommitTarget, err = filepath.EvalSymlinks(preCommitTarget) + if err != nil { + t.Fatalf("eval installed pre-commit target: %v", err) + } + commitMsgTarget, err = filepath.EvalSymlinks(commitMsgTarget) + if err != nil { + t.Fatalf("eval installed commit-msg target: %v", err) + } + + if preCommitTarget != wantPreCommit { + t.Fatalf("unexpected pre-commit target: want %q, got %q", wantPreCommit, preCommitTarget) + } + if commitMsgTarget != wantCommitMsg { + t.Fatalf("unexpected commit-msg target: want %q, got %q", wantCommitMsg, commitMsgTarget) + } +} diff --git a/docs/guides/releasing-new-version.md b/docs/guides/releasing-new-version.md index ca98e527..a0b631de 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 subjects use `type: summary` or `type: summary (td-)` - [ ] 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..08885bac --- /dev/null +++ b/scripts/commit-msg.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# commit-msg hook for td +# Install: make install-hooks (preferred) +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "commit-msg hook expects the commit message file path" >&2 + exit 1 +fi + +message_file=$1 + +if [[ ! -f "$message_file" ]]; then + echo "commit-msg hook could not find commit message file: $message_file" >&2 + exit 1 +fi + +trim() { + local value=$1 + value=${value#"${value%%[![:space:]]*}"} + value=${value%"${value##*[![:space:]]}"} + printf '%s' "$value" +} + +lines=() +while IFS= read -r line || [[ -n $line ]]; do + lines+=("$line") +done < "$message_file" + +first_line_index=-1 +for i in "${!lines[@]}"; do + trimmed_line=$(trim "${lines[$i]%$'\r'}") + if [[ -n "$trimmed_line" ]]; then + first_line_index=$i + break + fi +done + +if (( first_line_index < 0 )); then + cat >&2 <<'EOF' +Invalid commit message. + +Use one of: + type: summary + type: summary (td-) +EOF + exit 1 +fi + +subject=$(trim "${lines[$first_line_index]%$'\r'}") + +if [[ ! $subject =~ ^([A-Za-z][A-Za-z0-9-]*)[[:space:]]*:?[[:space:]]*(.+)$ ]]; then + cat >&2 <) + +Examples: + feat: normalize commit messages (td-a1b2) + chore: bump Homebrew formula to v1.2.3 +EOF + exit 1 +fi + +type_part=$(printf '%s' "${BASH_REMATCH[1]}" | tr '[:upper:]' '[:lower:]') +rest=$(trim "${BASH_REMATCH[2]}") + +if [[ -z $rest ]]; then + cat >&2 <<'EOF' +Invalid commit subject. + +The summary cannot be empty. +Use one of: + type: summary + type: summary (td-) +EOF + exit 1 +fi + +task_suffix="" +summary=$rest + +if [[ $rest == *" ("*")" ]]; then + task_suffix="(${rest##* (}" + summary=$(trim "${rest% $task_suffix}") + if ! printf '%s\n' "$task_suffix" | grep -Eq '^\(td-[a-z0-9][a-z0-9-]*\)$'; then + cat >&2 <). +Use one of: + type: summary + type: summary (td-) +EOF + exit 1 + fi +fi + +if [[ -z $summary ]]; then + cat >&2 <<'EOF' +Invalid commit subject. + +The summary cannot be empty. +Use one of: + type: summary + type: summary (td-) +EOF + exit 1 +fi + +normalized_subject="$type_part: $summary" +if [[ -n $task_suffix ]]; then + normalized_subject+=" $task_suffix" +fi + +last_non_empty_index=$(( ${#lines[@]} - 1 )) +while (( last_non_empty_index >= 0 )); do + trimmed_line=$(trim "${lines[$last_non_empty_index]%$'\r'}") + if [[ -n "$trimmed_line" ]]; then + break + fi + ((last_non_empty_index--)) +done + +{ + printf '%s\n' "$normalized_subject" + if (( first_line_index < last_non_empty_index )); then + for ((i = first_line_index + 1; i <= last_non_empty_index; i++)); do + printf '%s\n' "${lines[$i]%$'\r'}" + done + fi +} > "$message_file" diff --git a/scripts/loop-prompt.md b/scripts/loop-prompt.md index 7f84db2e..d3aaab88 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 use the canonical format.** For task-linked work use `type: (td-)`. For automation-only commits use `type: `. diff --git a/scripts/pre-commit.sh b/scripts/pre-commit.sh index 2b563f26..fb5384f4 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 (preferred) set -euo pipefail PASS=0