diff --git a/CLAUDE.md b/CLAUDE.md index cd0ab66..07a2d2e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,20 +27,20 @@ Use `td usage -q` after first read. ```bash go build -o td . # Build locally go test ./... # Test all +make install-hooks # Install pre-commit + commit-msg hooks ``` ## Version & Release ```bash -# Commit changes with proper message -git add . -git commit -m "feat: description of changes - -Details here +# Install the local hooks once per clone +make install-hooks -🤖 Generated with Claude Code - -Co-Authored-By: Claude Haiku 4.5 " +# Commit changes with the normalized td subject +git add . +git commit \ + -m "$(td commit-message 'describe changes')" \ + -m "Details here" # 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..a347ad8 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 + 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" + @common_dir=$$(git rev-parse --git-common-dir); \ + repo_root=$$(cd "$$common_dir/.." && pwd -P); \ + hooks_dir=$$(git rev-parse --git-path hooks); \ + 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/pre-commit and $$hooks_dir/commit-msg" diff --git a/README.md b/README.md index db67f9f..5ffe96b 100644 --- a/README.md +++ b/README.md @@ -189,10 +189,32 @@ 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 Messages + +Install the hooks once per clone: + +```bash +make install-hooks +``` + +Generate a canonical subject for the focused issue, or pass `--issue td-abc123` +explicitly: + +```bash +git commit \ + -m "$(td commit-message 'normalize commit message workflow')" \ + -m "Optional body text" +``` + +The `commit-msg` hook rewrites only the first line, preserves commit bodies and +trailers, and leaves Git-generated merge/revert/autosquash subjects untouched. +If no issue is focused, only typed `docs`, `test`, `chore`, and `ci` subjects +such as `docs: Update changelog for v0.43.0` stay no-issue commits. + ## Tests & Quality Checks ```bash @@ -422,6 +444,7 @@ Analytics are stored locally and help identify workflow patterns. Disable with ` | Reject | `td reject --reason "..."` | | Link files | `td link ` | | Check file changes | `td files ` | +| Normalize commit subject | `td commit-message "summary"` | | Draft release notes | `td release-notes --version v0.2.0` | | Undo last action | `td undo` | | New named session | `td session --new "feature-work"` | diff --git a/cmd/commit_message.go b/cmd/commit_message.go new file mode 100644 index 0000000..b842f63 --- /dev/null +++ b/cmd/commit_message.go @@ -0,0 +1,188 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + + "github.com/marcus/td/internal/config" + "github.com/marcus/td/internal/db" + "github.com/marcus/td/internal/git" + "github.com/marcus/td/internal/models" + "github.com/marcus/td/internal/output" + "github.com/spf13/cobra" +) + +var commitMessageCmd = &cobra.Command{ + Use: "commit-message [summary]", + Aliases: []string{"commit-msg"}, + Short: "Normalize a commit subject for the current td issue", + Long: `Normalize a commit subject to td's conventional format: + : (td-) + +The issue ID comes from --issue, a trailing (td-) suffix already present in +the subject, or the focused issue. When no issue is available, only typed +docs/test/chore/ci subjects can stay no-issue commits. When +--file is set, td rewrites only the first line of the commit message file in +place and preserves the body/trailers. Git-generated merge, revert, and +autosquash subjects are left unchanged.`, + Example: ` td commit-message "normalize commit hook docs" + td commit-message --issue td-a1b2 "normalize commit hook docs" + td commit-message --type docs "Update changelog for v0.43.0" + td commit-message --file .git/COMMIT_EDITMSG`, + GroupID: "system", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + baseDir := getBaseDir() + + filePath, _ := cmd.Flags().GetString("file") + if filePath != "" && len(args) > 0 { + return fmt.Errorf("summary argument cannot be used with --file") + } + if filePath == "" && len(args) == 0 { + return fmt.Errorf(`summary required. Use: td commit-message [--issue ] "summary"`) + } + + subject, err := commitMessageSubject(args, filePath) + if err != nil { + output.Error("%v", err) + return err + } + + if git.ShouldSkipCommitMessageNormalization(subject) { + if filePath == "" { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), strings.TrimSpace(subject)) + } + return nil + } + + issueID, issueType, err := resolveCommitMessageContext(baseDir, cmd, subject) + if err != nil { + output.Error("%v", err) + return err + } + + explicitType, _ := cmd.Flags().GetString("type") + opts := git.CommitMessageOptions{ + IssueID: issueID, + IssueType: issueType, + Type: git.CommitType(explicitType), + } + + if filePath != "" { + if err := git.RewriteCommitMessageFile(filePath, opts); err != nil { + output.Error("%v", err) + return err + } + return nil + } + + normalized, err := git.NormalizeCommitSubject(subject, opts) + if err != nil { + output.Error("%v", err) + return err + } + + _, _ = fmt.Fprintln(cmd.OutOrStdout(), normalized) + return nil + }, +} + +func commitMessageSubject(args []string, filePath string) (string, error) { + if filePath == "" { + return args[0], nil + } + + data, err := os.ReadFile(filePath) + if err != nil { + return "", err + } + + message := string(data) + if idx := strings.Index(message, "\n"); idx >= 0 { + return strings.TrimSuffix(message[:idx], "\r"), nil + } + + return message, nil +} + +func resolveCommitMessageContext(baseDir string, cmd *cobra.Command, subject string) (string, models.Type, error) { + issueFlag, _ := cmd.Flags().GetString("issue") + issueID, err := normalizeCommitMessageIssueRef(baseDir, issueFlag) + if err != nil { + return "", "", err + } + + if issueID == "" { + issueID, err = git.ExtractCommitIssueID(subject) + if err != nil { + return "", "", err + } + } + + explicitType, _ := cmd.Flags().GetString("type") + if issueID == "" && strings.TrimSpace(explicitType) != "" { + commitType, err := git.NormalizeCommitType(explicitType) + if err != nil { + return "", "", err + } + if git.CommitTypeAllowsNoIssue(commitType) { + return "", "", nil + } + } + + if issueID == "" { + focusedID, err := config.GetFocus(baseDir) + if err != nil { + return "", "", err + } + issueID, err = git.NormalizeCommitIssueID(focusedID) + if err != nil { + return "", "", err + } + } + + if issueID == "" { + return "", "", nil + } + + database, err := db.Open(baseDir) + if err != nil { + return "", "", err + } + defer database.Close() + + issue, err := database.GetIssue(issueID) + if err != nil { + return "", "", err + } + + return issueID, issue.Type, nil +} + +func normalizeCommitMessageIssueRef(baseDir, raw string) (string, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "", nil + } + if trimmed == "." { + focusedID, err := config.GetFocus(baseDir) + if err != nil { + return "", err + } + if strings.TrimSpace(focusedID) == "" { + return "", fmt.Errorf("no focused issue available for --issue .") + } + trimmed = focusedID + } + + return git.NormalizeCommitIssueID(trimmed) +} + +func init() { + rootCmd.AddCommand(commitMessageCmd) + + commitMessageCmd.Flags().StringP("issue", "i", "", "Issue ID (default: subject suffix or focused issue)") + commitMessageCmd.Flags().StringP("type", "t", "", "Commit type override (feat, fix, docs, test, chore, ci)") + commitMessageCmd.Flags().StringP("file", "f", "", "Rewrite a commit message file in place") +} diff --git a/cmd/commit_message_test.go b/cmd/commit_message_test.go new file mode 100644 index 0000000..aeefaf5 --- /dev/null +++ b/cmd/commit_message_test.go @@ -0,0 +1,510 @@ +package cmd + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/marcus/td/internal/config" + "github.com/marcus/td/internal/db" + "github.com/marcus/td/internal/models" +) + +func runCommitMessageCommand(t *testing.T, dir string, args []string, issueFlag, typeFlag, fileFlag string) (string, error) { + t.Helper() + + saveAndRestoreGlobals(t) + + baseDir := dir + baseDirOverride = &baseDir + + _ = commitMessageCmd.Flags().Set("issue", "") + _ = commitMessageCmd.Flags().Set("type", "") + _ = commitMessageCmd.Flags().Set("file", "") + + if issueFlag != "" { + _ = commitMessageCmd.Flags().Set("issue", issueFlag) + } + if typeFlag != "" { + _ = commitMessageCmd.Flags().Set("type", typeFlag) + } + if fileFlag != "" { + _ = commitMessageCmd.Flags().Set("file", fileFlag) + } + + var output bytes.Buffer + commitMessageCmd.SetOut(&output) + + runErr := commitMessageCmd.RunE(commitMessageCmd, args) + return strings.TrimSpace(output.String()), runErr +} + +func TestCommitMessageCommandPrintsNormalizedSubject(t *testing.T) { + dir := t.TempDir() + + database, err := db.Initialize(dir) + if err != nil { + t.Fatalf("Initialize failed: %v", err) + } + defer database.Close() + + issue := &models.Issue{ + Title: "Normalize commit hook docs", + Type: models.TypeFeature, + } + if err := database.CreateIssue(issue); err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + if err := config.SetFocus(dir, issue.ID); err != nil { + t.Fatalf("SetFocus failed: %v", err) + } + + got, err := runCommitMessageCommand(t, dir, []string{"Normalize commit hook docs"}, "", "", "") + if err != nil { + t.Fatalf("commitMessageCmd.RunE returned error: %v", err) + } + + want := "feat: Normalize commit hook docs (" + issue.ID + ")" + if got != want { + t.Fatalf("output = %q, want %q", got, want) + } +} + +func TestCommitMessageCommandUsesSubjectSuffixIssue(t *testing.T) { + dir := t.TempDir() + + database, err := db.Initialize(dir) + if err != nil { + t.Fatalf("Initialize failed: %v", err) + } + defer database.Close() + + issue := &models.Issue{ + Title: "Fix retry regression", + Type: models.TypeBug, + } + if err := database.CreateIssue(issue); err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + + got, err := runCommitMessageCommand(t, dir, []string{"Fix retry regression (" + strings.ToUpper(issue.ID) + ")"}, "", "", "") + if err != nil { + t.Fatalf("commitMessageCmd.RunE returned error: %v", err) + } + + want := "fix: Fix retry regression (" + issue.ID + ")" + if got != want { + t.Fatalf("output = %q, want %q", got, want) + } +} + +func TestCommitMessageCommandAllowsTypedNoIssueSubjectWithoutFocus(t *testing.T) { + dir := t.TempDir() + + got, err := runCommitMessageCommand(t, dir, []string{" DoCs : Update changelog for v0.43.0 "}, "", "", "") + if err != nil { + t.Fatalf("commitMessageCmd.RunE returned error: %v", err) + } + + want := "docs: Update changelog for v0.43.0" + if got != want { + t.Fatalf("output = %q, want %q", got, want) + } +} + +func TestCommitMessageCommandRejectsNoIssueFeatureAndFixSubjectsWithoutFocus(t *testing.T) { + tests := []struct { + name string + args []string + typeFlag string + }{ + {name: "typed feat subject", args: []string{"feat: add release notes command"}}, + {name: "typed fix subject", args: []string{"fix: patch nil pointer in sync loop"}}, + {name: "explicit feat override", args: []string{"Add release notes command"}, typeFlag: "feat"}, + {name: "explicit fix override", args: []string{"Patch nil pointer in sync loop"}, typeFlag: "fix"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + + _, err := runCommitMessageCommand(t, dir, tt.args, "", tt.typeFlag, "") + if err == nil { + t.Fatal("expected missing issue error") + } + if !strings.Contains(err.Error(), "requires a td issue") { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestCommitMessageCommandAllowsExplicitTypeWithoutIssueEvenWhenFocused(t *testing.T) { + dir := t.TempDir() + + database, err := db.Initialize(dir) + if err != nil { + t.Fatalf("Initialize failed: %v", err) + } + defer database.Close() + + issue := &models.Issue{ + Title: "Normalize commit hook docs", + Type: models.TypeFeature, + } + if err := database.CreateIssue(issue); err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + if err := config.SetFocus(dir, issue.ID); err != nil { + t.Fatalf("SetFocus failed: %v", err) + } + + got, err := runCommitMessageCommand(t, dir, []string{"Update changelog for v0.43.0"}, "", "docs", "") + if err != nil { + t.Fatalf("commitMessageCmd.RunE returned error: %v", err) + } + + want := "docs: Update changelog for v0.43.0" + if got != want { + t.Fatalf("output = %q, want %q", got, want) + } +} + +func TestCommitMessageCommandUsesFocusedIssueWithExplicitFixType(t *testing.T) { + dir := t.TempDir() + + database, err := db.Initialize(dir) + if err != nil { + t.Fatalf("Initialize failed: %v", err) + } + defer database.Close() + + issue := &models.Issue{ + Title: "Fix retry regression", + Type: models.TypeTask, + } + if err := database.CreateIssue(issue); err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + if err := config.SetFocus(dir, issue.ID); err != nil { + t.Fatalf("SetFocus failed: %v", err) + } + + got, err := runCommitMessageCommand(t, dir, []string{"Patch retry regression"}, "", "fix", "") + if err != nil { + t.Fatalf("commitMessageCmd.RunE returned error: %v", err) + } + + want := "fix: Patch retry regression (" + issue.ID + ")" + if got != want { + t.Fatalf("output = %q, want %q", got, want) + } +} + +func TestCommitMessageCommandNormalizesHumanAuthoredMergeSubject(t *testing.T) { + dir := t.TempDir() + + database, err := db.Initialize(dir) + if err != nil { + t.Fatalf("Initialize failed: %v", err) + } + defer database.Close() + + issue := &models.Issue{ + Title: "Merge support docs", + Type: models.TypeFeature, + } + if err := database.CreateIssue(issue); err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + if err := config.SetFocus(dir, issue.ID); err != nil { + t.Fatalf("SetFocus failed: %v", err) + } + + got, err := runCommitMessageCommand(t, dir, []string{"Merge support docs"}, "", "", "") + if err != nil { + t.Fatalf("commitMessageCmd.RunE returned error: %v", err) + } + + want := "feat: Merge support docs (" + issue.ID + ")" + if got != want { + t.Fatalf("output = %q, want %q", got, want) + } +} + +func TestCommitMessageCommandRewritesFileInPlace(t *testing.T) { + dir := t.TempDir() + + database, err := db.Initialize(dir) + if err != nil { + t.Fatalf("Initialize failed: %v", err) + } + defer database.Close() + + issue := &models.Issue{ + Title: "Fix retry regression", + Type: models.TypeBug, + } + if err := database.CreateIssue(issue); err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + + messagePath := filepath.Join(dir, "COMMIT_EDITMSG") + initial := " Fix : Fix retry regression (" + strings.ToUpper(issue.ID) + ") \n\nBody line\n\nNightshift-Task: commit-normalize\n" + if err := os.WriteFile(messagePath, []byte(initial), 0o644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + + if _, err := runCommitMessageCommand(t, dir, nil, "", "", messagePath); err != nil { + t.Fatalf("commitMessageCmd.RunE returned error: %v", err) + } + + got, err := os.ReadFile(messagePath) + if err != nil { + t.Fatalf("ReadFile failed: %v", err) + } + + want := "fix: Fix retry regression (" + issue.ID + ")\n\nBody line\n\nNightshift-Task: commit-normalize\n" + if string(got) != want { + t.Fatalf("commit message = %q, want %q", string(got), want) + } +} + +func TestCommitMessageCommandFileRewriteIsIdempotent(t *testing.T) { + dir := t.TempDir() + + database, err := db.Initialize(dir) + if err != nil { + t.Fatalf("Initialize failed: %v", err) + } + defer database.Close() + + issue := &models.Issue{ + Title: "Normalize commit hook docs", + Type: models.TypeTask, + } + if err := database.CreateIssue(issue); err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + if err := config.SetFocus(dir, issue.ID); err != nil { + t.Fatalf("SetFocus failed: %v", err) + } + + messagePath := filepath.Join(dir, "COMMIT_EDITMSG") + want := "chore: Normalize commit hook docs (" + issue.ID + ")\n\nBody line\n" + if err := os.WriteFile(messagePath, []byte(want), 0o644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + + if _, err := runCommitMessageCommand(t, dir, nil, "", "", messagePath); err != nil { + t.Fatalf("first run returned error: %v", err) + } + if _, err := runCommitMessageCommand(t, dir, nil, "", "", messagePath); err != nil { + t.Fatalf("second run returned error: %v", err) + } + + got, err := os.ReadFile(messagePath) + if err != nil { + t.Fatalf("ReadFile failed: %v", err) + } + if string(got) != want { + t.Fatalf("commit message = %q, want %q", string(got), want) + } +} + +func TestCommitMessageCommandSkipsSpecialGitSubjectsInFileMode(t *testing.T) { + tests := []struct { + name string + message string + }{ + { + name: "fixup autosquash subject", + message: "fixup! feat: normalize commit hook docs (td-a1b2)\n\nbody\n", + }, + { + name: "merge subject", + message: "Merge branch 'feat/commit-message-normalizer'\n\nbody\n", + }, + { + name: "revert subject", + message: "Revert \"feat: add commit message normalizer (td-a1b2)\"\n\nThis reverts commit 0123456789abcdef.\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + messagePath := filepath.Join(dir, "COMMIT_EDITMSG") + if err := os.WriteFile(messagePath, []byte(tt.message), 0o644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + + if _, err := runCommitMessageCommand(t, dir, nil, "", "", messagePath); err != nil { + t.Fatalf("commitMessageCmd.RunE returned error: %v", err) + } + + got, err := os.ReadFile(messagePath) + if err != nil { + t.Fatalf("ReadFile failed: %v", err) + } + if string(got) != tt.message { + t.Fatalf("commit message = %q, want %q", string(got), tt.message) + } + }) + } +} + +func TestCommitMessageCommandReturnsClearErrorsForMalformedInput(t *testing.T) { + dir := t.TempDir() + + database, err := db.Initialize(dir) + if err != nil { + t.Fatalf("Initialize failed: %v", err) + } + defer database.Close() + + issue := &models.Issue{ + Title: "Normalize commit hook docs", + Type: models.TypeTask, + } + if err := database.CreateIssue(issue); err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + if err := config.SetFocus(dir, issue.ID); err != nil { + t.Fatalf("SetFocus failed: %v", err) + } + + _, err = runCommitMessageCommand(t, dir, nil, "", "", "") + if err == nil { + t.Fatal("expected missing summary error") + } + if !strings.Contains(err.Error(), "summary required") { + t.Fatalf("unexpected missing summary error: %v", err) + } + + _, err = runCommitMessageCommand(t, dir, []string{"build: ship release"}, "", "", "") + if err == nil { + t.Fatal("expected unsupported prefix error") + } + if !strings.Contains(err.Error(), `unsupported commit type "build"`) { + t.Fatalf("unexpected malformed input error: %v", err) + } + + _, err = runCommitMessageCommand(t, dir, []string{"Normalize commit hook docs"}, ".", "", "") + if err != nil { + t.Fatalf("expected --issue . to resolve focused issue: %v", err) + } +} + +func TestCommitMessageCommandIssueDotWithoutFocusReturnsClearError(t *testing.T) { + dir := t.TempDir() + + _, err := runCommitMessageCommand(t, dir, []string{"Normalize commit hook docs"}, ".", "", "") + if err == nil { + t.Fatal("expected no focus error") + } + if !strings.Contains(err.Error(), "no focused issue available for --issue .") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestInstallHooksUsesSharedRepoRootInLinkedWorktree(t *testing.T) { + if _, err := exec.LookPath("make"); err != nil { + t.Skip("make not available") + } + + repo := t.TempDir() + copyRepoFile(t, "Makefile", filepath.Join(repo, "Makefile")) + copyRepoFile(t, "scripts/pre-commit.sh", filepath.Join(repo, "scripts", "pre-commit.sh")) + copyRepoFile(t, "scripts/commit-msg.sh", filepath.Join(repo, "scripts", "commit-msg.sh")) + + runGit(t, repo, "init") + runGit(t, repo, "config", "user.email", "test@example.com") + runGit(t, repo, "config", "user.name", "Test User") + runGit(t, repo, "add", "Makefile", "scripts/pre-commit.sh", "scripts/commit-msg.sh") + runGit(t, repo, "commit", "-m", "fixture") + + wtPath := filepath.Join(t.TempDir(), "wt") + runGit(t, repo, "worktree", "add", wtPath, "-b", "feature/hooks") + + cmd := exec.Command("make", "install-hooks") + cmd.Dir = wtPath + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("make install-hooks failed: %v (%s)", err, strings.TrimSpace(string(out))) + } + + hooksDir := strings.TrimSpace(runGitOutput(t, wtPath, "rev-parse", "--git-path", "hooks")) + if !filepath.IsAbs(hooksDir) { + hooksDir = filepath.Join(wtPath, hooksDir) + } + + preCommitTarget, err := os.Readlink(filepath.Join(hooksDir, "pre-commit")) + if err != nil { + t.Fatalf("Readlink pre-commit failed: %v", err) + } + commitMsgTarget, err := os.Readlink(filepath.Join(hooksDir, "commit-msg")) + if err != nil { + t.Fatalf("Readlink commit-msg failed: %v", err) + } + + wantPreCommit := filepath.Join(repo, "scripts", "pre-commit.sh") + wantCommitMsg := filepath.Join(repo, "scripts", "commit-msg.sh") + assertSamePath(t, wantPreCommit, preCommitTarget) + assertSamePath(t, wantCommitMsg, commitMsgTarget) + if strings.Contains(preCommitTarget, wtPath) || strings.Contains(commitMsgTarget, wtPath) { + t.Fatalf("hook targets should not point at worktree path %q", wtPath) + } + + if err := os.RemoveAll(wtPath); err != nil { + t.Fatalf("RemoveAll worktree failed: %v", err) + } + + resolvedPreCommit, err := filepath.EvalSymlinks(filepath.Join(hooksDir, "pre-commit")) + if err != nil { + t.Fatalf("EvalSymlinks pre-commit failed after removing worktree: %v", err) + } + resolvedCommitMsg, err := filepath.EvalSymlinks(filepath.Join(hooksDir, "commit-msg")) + if err != nil { + t.Fatalf("EvalSymlinks commit-msg failed after removing worktree: %v", err) + } + assertSamePath(t, wantPreCommit, resolvedPreCommit) + assertSamePath(t, wantCommitMsg, resolvedCommitMsg) +} + +func copyRepoFile(t *testing.T, repoRelativePath, dst string) { + t.Helper() + + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + repoRoot := filepath.Dir(filepath.Dir(thisFile)) + src := filepath.Join(repoRoot, repoRelativePath) + + data, err := os.ReadFile(src) + if err != nil { + t.Fatalf("ReadFile %s failed: %v", src, err) + } + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + t.Fatalf("MkdirAll failed: %v", err) + } + if err := os.WriteFile(dst, data, 0o755); err != nil { + t.Fatalf("WriteFile %s failed: %v", dst, err) + } +} + +func runGitOutput(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %s failed: %v (%s)", strings.Join(args, " "), err, strings.TrimSpace(string(out))) + } + return string(out) +} diff --git a/docs/guides/releasing-new-version.md b/docs/guides/releasing-new-version.md index da12999..8b0c617 100644 --- a/docs/guides/releasing-new-version.md +++ b/docs/guides/releasing-new-version.md @@ -61,7 +61,7 @@ Review the draft, edit it for clarity, and then add the final entry at the top o Commit the changelog: ```bash git add CHANGELOG.md -git commit -m "docs: Update changelog for vX.Y.Z" +git commit -m "$(td commit-message --type docs 'Update changelog for vX.Y.Z')" ``` ### 3. Verify Tests Pass @@ -148,7 +148,7 @@ td release-notes --version vX.Y.Z > /tmp/td-release-notes.md # Update changelog # (Review /tmp/td-release-notes.md, then edit CHANGELOG.md and add the final entry at top) git add CHANGELOG.md -git commit -m "docs: Update changelog for vX.Y.Z" +git commit -m "$(td commit-message --type docs 'Update changelog for vX.Y.Z')" # Push commits, then tag (tag push triggers automated release) git push origin main diff --git a/internal/git/commit_message.go b/internal/git/commit_message.go new file mode 100644 index 0000000..8b4415d --- /dev/null +++ b/internal/git/commit_message.go @@ -0,0 +1,386 @@ +package git + +import ( + "fmt" + "os" + "regexp" + "strings" + + "github.com/marcus/td/internal/models" +) + +// CommitType is the normalized conventional commit type accepted by td. +type CommitType string + +const ( + CommitTypeFeat CommitType = "feat" + CommitTypeFix CommitType = "fix" + CommitTypeDocs CommitType = "docs" + CommitTypeTest CommitType = "test" + CommitTypeChore CommitType = "chore" + CommitTypeCI CommitType = "ci" +) + +// CommitMessageOptions control how commit subjects are normalized. +type CommitMessageOptions struct { + IssueID string + IssueType models.Type + Type CommitType +} + +var ( + commitSubjectPrefixPattern = regexp.MustCompile(`^\s*([A-Za-z]+)\s*(?:\(\s*([^)]+?)\s*\))?\s*!?\s*:\s*(.*)$`) + commitSubjectIDSuffixPattern = regexp.MustCompile(`(?i)\s*\(\s*(td-[0-9a-f]{4,8})\s*\)\s*$`) + commitSubjectAnyIDSuffix = regexp.MustCompile(`(?i)\s*\(\s*(td-[^)\s]+)\s*\)\s*$`) + validCommitIssueIDPattern = regexp.MustCompile(`(?i)^td-[0-9a-f]{4,8}$`) + bareCommitIssueIDPattern = regexp.MustCompile(`(?i)^[0-9a-f]{4,8}$`) + autosquashSubjectPattern = regexp.MustCompile(`^(fixup|squash|amend)!\s+`) + mergeSubjectPattern = regexp.MustCompile(`^Merge (?:(?:branch|branches)|(?:remote-tracking branch|remote-tracking branches)|tag|commit) '`) + revertSubjectPattern = regexp.MustCompile(`^Revert\s+"`) +) + +var commitTypeAliases = map[string]CommitType{ + "feat": CommitTypeFeat, + "feature": CommitTypeFeat, + "fix": CommitTypeFix, + "bug": CommitTypeFix, + "bugfix": CommitTypeFix, + "docs": CommitTypeDocs, + "doc": CommitTypeDocs, + "test": CommitTypeTest, + "tests": CommitTypeTest, + "chore": CommitTypeChore, + "ci": CommitTypeCI, +} + +type parsedCommitSubject struct { + Type CommitType + Scope string + Summary string + IssueID string +} + +// NormalizeCommitType returns a canonical lowercase commit type. +func NormalizeCommitType(raw string) (CommitType, error) { + normalized := strings.ToLower(strings.TrimSpace(raw)) + if normalized == "" { + return "", nil + } + + if canonical, ok := commitTypeAliases[normalized]; ok { + return canonical, nil + } + + return "", fmt.Errorf("unsupported commit type %q: use %s", strings.TrimSpace(raw), supportedCommitTypes()) +} + +// CommitTypeAllowsNoIssue reports whether a commit type may omit a td issue suffix. +func CommitTypeAllowsNoIssue(commitType CommitType) bool { + switch commitType { + case CommitTypeDocs, CommitTypeTest, CommitTypeChore, CommitTypeCI: + return true + default: + return false + } +} + +// DefaultCommitType maps td issue types to the closest supported commit type. +func DefaultCommitType(issueType models.Type) (CommitType, error) { + switch issueType { + case models.TypeFeature: + return CommitTypeFeat, nil + case models.TypeBug: + return CommitTypeFix, nil + case models.TypeTask, models.TypeChore, models.TypeEpic: + return CommitTypeChore, nil + default: + return "", fmt.Errorf("cannot infer commit type from issue type %q: use --type %s", issueType, supportedCommitTypes()) + } +} + +// NormalizeCommitIssueID returns a canonical lowercase td issue ID. +func NormalizeCommitIssueID(raw string) (string, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "", nil + } + + if bareCommitIssueIDPattern.MatchString(trimmed) { + trimmed = "td-" + trimmed + } + + trimmed = strings.ToLower(trimmed) + if !validCommitIssueIDPattern.MatchString(trimmed) { + return "", fmt.Errorf("invalid issue ID %q: expected td-", raw) + } + + return trimmed, nil +} + +// ExtractCommitIssueID returns the trailing td issue ID referenced by a subject. +func ExtractCommitIssueID(subject string) (string, error) { + parsed, err := parseCommitSubject(subject) + if err != nil { + return "", err + } + return parsed.IssueID, nil +} + +// ShouldSkipCommitMessageNormalization reports whether a subject should be left +// alone because Git generated it for a special flow. +func ShouldSkipCommitMessageNormalization(subject string) bool { + trimmed := strings.TrimSpace(subject) + if trimmed == "" { + return false + } + + return autosquashSubjectPattern.MatchString(trimmed) || + mergeSubjectPattern.MatchString(trimmed) || + revertSubjectPattern.MatchString(trimmed) +} + +// NormalizeCommitSubject rewrites a subject into a canonical conventional +// commit line. When an issue ID is available, it appends (td-). +func NormalizeCommitSubject(subject string, opts CommitMessageOptions) (string, error) { + parsed, err := parseCommitSubject(subject) + if err != nil { + return "", err + } + + issueID, err := resolveCommitIssueID(parsed.IssueID, opts.IssueID) + if err != nil { + return "", err + } + + commitType, err := resolveCommitType(parsed.Type, opts) + if err != nil { + return "", err + } + if issueID == "" && !CommitTypeAllowsNoIssue(commitType) { + return "", fmt.Errorf("commit type %q requires a td issue: use --issue, add (td-), or choose docs|test|chore|ci for no-issue commits", commitType) + } + + return formatCommitSubject(commitType, parsed.Scope, parsed.Summary, issueID), nil +} + +// NormalizeCommitMessage rewrites only the first line of a full commit message. +func NormalizeCommitMessage(message string, opts CommitMessageOptions) (string, error) { + subject, remainder := splitCommitMessage(message) + if ShouldSkipCommitMessageNormalization(subject) { + return message, nil + } + + normalizedSubject, err := NormalizeCommitSubject(subject, opts) + if err != nil { + return "", err + } + + return normalizedSubject + remainder, nil +} + +// RewriteCommitMessageFile normalizes the first line of a commit message file. +func RewriteCommitMessageFile(path string, opts CommitMessageOptions) error { + info, err := os.Stat(path) + if err != nil { + return err + } + + data, err := os.ReadFile(path) + if err != nil { + return err + } + + normalized, err := NormalizeCommitMessage(string(data), opts) + if err != nil { + return err + } + + if normalized == string(data) { + return nil + } + + return os.WriteFile(path, []byte(normalized), info.Mode()) +} + +func resolveCommitType(parsedType CommitType, opts CommitMessageOptions) (CommitType, error) { + if opts.Type != "" { + return NormalizeCommitType(string(opts.Type)) + } + if parsedType != "" { + return parsedType, nil + } + if opts.IssueType == "" { + return "", fmt.Errorf("missing commit type: use a conventional subject, pass --type %s, or focus an issue", supportedCommitTypes()) + } + return DefaultCommitType(opts.IssueType) +} + +func resolveCommitIssueID(parsedIssueID, optionIssueID string) (string, error) { + normalizedOptionID, err := NormalizeCommitIssueID(optionIssueID) + if err != nil { + return "", err + } + + if normalizedOptionID == "" { + return parsedIssueID, nil + } + if parsedIssueID == "" || parsedIssueID == normalizedOptionID { + return normalizedOptionID, nil + } + + return "", fmt.Errorf("commit subject references %s but resolved issue is %s", parsedIssueID, normalizedOptionID) +} + +func parseCommitSubject(subject string) (parsedCommitSubject, error) { + remaining := strings.TrimSpace(subject) + if remaining == "" { + return parsedCommitSubject{}, fmt.Errorf("missing commit subject") + } + + issueID, stripped, err := stripTrailingIssueIDs(remaining) + if err != nil { + return parsedCommitSubject{}, err + } + remaining = stripped + + commitType := CommitType("") + scope := "" + if matches := commitSubjectPrefixPattern.FindStringSubmatch(remaining); matches != nil { + commitType, err = NormalizeCommitType(matches[1]) + if err != nil { + return parsedCommitSubject{}, err + } + scope = cleanCommitScope(matches[2]) + remaining = matches[3] + } + + summary := cleanCommitSummary(remaining) + if summary == "" { + return parsedCommitSubject{}, fmt.Errorf("missing commit summary") + } + + return parsedCommitSubject{ + Type: commitType, + Scope: scope, + Summary: summary, + IssueID: issueID, + }, nil +} + +func stripTrailingIssueIDs(subject string) (string, string, error) { + var ids []string + remaining := strings.TrimSpace(subject) + + for { + if matches := commitSubjectIDSuffixPattern.FindStringSubmatchIndex(remaining); matches != nil { + id, err := NormalizeCommitIssueID(remaining[matches[2]:matches[3]]) + if err != nil { + return "", "", err + } + + ids = append(ids, id) + remaining = strings.TrimSpace(remaining[:matches[0]]) + continue + } + + if invalidMatches := commitSubjectAnyIDSuffix.FindStringSubmatchIndex(remaining); invalidMatches != nil { + _, err := NormalizeCommitIssueID(remaining[invalidMatches[2]:invalidMatches[3]]) + if err != nil { + return "", "", err + } + } + + break + } + + issueID, err := dedupeCommitIssueIDs(ids) + if err != nil { + return "", "", err + } + + return issueID, remaining, nil +} + +func dedupeCommitIssueIDs(ids []string) (string, error) { + if len(ids) == 0 { + return "", nil + } + + var first string + seen := make(map[string]struct{}, len(ids)) + for _, id := range ids { + if first == "" { + first = id + } + seen[id] = struct{}{} + } + + if len(seen) > 1 { + ordered := make([]string, 0, len(seen)) + for _, id := range ids { + if len(ordered) > 0 && ordered[len(ordered)-1] == id { + continue + } + if containsString(ordered, id) { + continue + } + ordered = append(ordered, id) + } + return "", fmt.Errorf("commit subject references multiple issue IDs: %s", strings.Join(ordered, ", ")) + } + + return first, nil +} + +func formatCommitSubject(commitType CommitType, scope, summary, issueID string) string { + var subject strings.Builder + subject.WriteString(string(commitType)) + if scope != "" { + subject.WriteString("(") + subject.WriteString(scope) + subject.WriteString(")") + } + subject.WriteString(": ") + subject.WriteString(summary) + if issueID != "" { + subject.WriteString(" (") + subject.WriteString(issueID) + subject.WriteString(")") + } + return subject.String() +} + +func cleanCommitSummary(summary string) string { + return strings.Join(strings.Fields(strings.TrimSpace(summary)), " ") +} + +func cleanCommitScope(scope string) string { + return strings.Join(strings.Fields(strings.TrimSpace(scope)), " ") +} + +func splitCommitMessage(message string) (string, string) { + idx := strings.Index(message, "\n") + if idx == -1 { + return message, "" + } + + lineEnd := idx + if idx > 0 && message[idx-1] == '\r' { + lineEnd = idx - 1 + } + + return message[:lineEnd], message[lineEnd:] +} + +func supportedCommitTypes() string { + return "feat|fix|docs|test|chore|ci" +} + +func containsString(items []string, want string) bool { + for _, item := range items { + if item == want { + return true + } + } + return false +} diff --git a/internal/git/commit_message_test.go b/internal/git/commit_message_test.go new file mode 100644 index 0000000..64af68b --- /dev/null +++ b/internal/git/commit_message_test.go @@ -0,0 +1,327 @@ +package git + +import ( + "strings" + "testing" + + "github.com/marcus/td/internal/models" +) + +func TestNormalizeCommitSubjectRawSummary(t *testing.T) { + got, err := NormalizeCommitSubject(" Normalize commit hook docs ", CommitMessageOptions{ + IssueID: "td-a1b2", + IssueType: models.TypeFeature, + }) + if err != nil { + t.Fatalf("NormalizeCommitSubject returned error: %v", err) + } + + want := "feat: Normalize commit hook docs (td-a1b2)" + if got != want { + t.Fatalf("NormalizeCommitSubject = %q, want %q", got, want) + } +} + +func TestNormalizeCommitSubjectMixedCasePrefix(t *testing.T) { + got, err := NormalizeCommitSubject(" DoCs : Update changelog ", CommitMessageOptions{}) + if err != nil { + t.Fatalf("NormalizeCommitSubject returned error: %v", err) + } + + want := "docs: Update changelog" + if got != want { + t.Fatalf("NormalizeCommitSubject = %q, want %q", got, want) + } +} + +func TestNormalizeCommitSubjectNormalizesFeatureAliasAndScope(t *testing.T) { + got, err := NormalizeCommitSubject(" FeAtUrE ( serve ) : add API ", CommitMessageOptions{ + IssueID: "td-a1b2", + }) + if err != nil { + t.Fatalf("NormalizeCommitSubject returned error: %v", err) + } + + want := "feat(serve): add API (td-a1b2)" + if got != want { + t.Fatalf("NormalizeCommitSubject = %q, want %q", got, want) + } +} + +func TestNormalizeCommitSubjectAlreadyNormalizedIsIdempotent(t *testing.T) { + want := "chore: Normalize commit hook docs (td-a1b2)" + + got, err := NormalizeCommitSubject(want, CommitMessageOptions{}) + if err != nil { + t.Fatalf("NormalizeCommitSubject returned error: %v", err) + } + if got != want { + t.Fatalf("NormalizeCommitSubject = %q, want %q", got, want) + } +} + +func TestNormalizeCommitSubjectDuplicateTaskSuffixes(t *testing.T) { + got, err := NormalizeCommitSubject("fix: normalize commit hook docs (TD-A1B2) (td-a1b2)", CommitMessageOptions{}) + if err != nil { + t.Fatalf("NormalizeCommitSubject returned error: %v", err) + } + + want := "fix: normalize commit hook docs (td-a1b2)" + if got != want { + t.Fatalf("NormalizeCommitSubject = %q, want %q", got, want) + } +} + +func TestNormalizeCommitSubjectMultipleDistinctIssueSuffixes(t *testing.T) { + _, err := NormalizeCommitSubject("fix: normalize commit hook docs (td-a1b2) (td-c3d4)", CommitMessageOptions{}) + if err == nil { + t.Fatal("expected multiple issue IDs error") + } + if !strings.Contains(err.Error(), "multiple issue IDs") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestNormalizeCommitSubjectMissingSummary(t *testing.T) { + _, err := NormalizeCommitSubject("feat: (td-a1b2)", CommitMessageOptions{}) + if err == nil { + t.Fatal("expected missing summary error") + } + if !strings.Contains(err.Error(), "missing commit summary") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestNormalizeCommitSubjectUnsupportedPrefix(t *testing.T) { + _, err := NormalizeCommitSubject("build: ship release", CommitMessageOptions{}) + if err == nil { + t.Fatal("expected unsupported prefix error") + } + if !strings.Contains(err.Error(), `unsupported commit type "build"`) { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestNormalizeCommitSubjectInvalidIssueIDSuffix(t *testing.T) { + _, err := NormalizeCommitSubject("feat: update README (td-nothex)", CommitMessageOptions{}) + if err == nil { + t.Fatal("expected invalid issue ID error") + } + if !strings.Contains(err.Error(), `invalid issue ID "td-nothex"`) { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestNormalizeCommitSubjectAllowsNoIssueWithExplicitType(t *testing.T) { + tests := []struct { + name string + subject string + commitType CommitType + want string + }{ + {name: "docs", subject: "Update changelog for v0.43.0", commitType: CommitTypeDocs, want: "docs: Update changelog for v0.43.0"}, + {name: "test", subject: "Refresh golden fixtures", commitType: CommitTypeTest, want: "test: Refresh golden fixtures"}, + {name: "chore", subject: "Bump local toolchain", commitType: CommitTypeChore, want: "chore: Bump local toolchain"}, + {name: "ci", subject: "Tighten release workflow", commitType: CommitTypeCI, want: "ci: Tighten release workflow"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := NormalizeCommitSubject(tt.subject, CommitMessageOptions{ + Type: tt.commitType, + }) + if err != nil { + t.Fatalf("NormalizeCommitSubject returned error: %v", err) + } + if got != tt.want { + t.Fatalf("NormalizeCommitSubject = %q, want %q", got, tt.want) + } + }) + } +} + +func TestNormalizeCommitSubjectRequiresIssueForFeatureAndFixCommits(t *testing.T) { + tests := []struct { + name string + subject string + opts CommitMessageOptions + }{ + { + name: "typed feat subject without issue", + subject: "feat: add release notes command", + }, + { + name: "typed fix subject without issue", + subject: "fix: patch nil pointer in sync loop", + }, + { + name: "explicit feat override without issue", + subject: "Add release notes command", + opts: CommitMessageOptions{Type: CommitTypeFeat}, + }, + { + name: "explicit fix override without issue", + subject: "Patch nil pointer in sync loop", + opts: CommitMessageOptions{Type: CommitTypeFix}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := NormalizeCommitSubject(tt.subject, tt.opts) + if err == nil { + t.Fatal("expected missing issue error") + } + if !strings.Contains(err.Error(), "requires a td issue") { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestNormalizeCommitSubjectMissingTypeWithoutIssueContext(t *testing.T) { + _, err := NormalizeCommitSubject("Update changelog for v0.43.0", CommitMessageOptions{}) + if err == nil { + t.Fatal("expected missing commit type error") + } + if !strings.Contains(err.Error(), "missing commit type") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestNormalizeCommitMessagePreservesBodyAndTrailers(t *testing.T) { + message := " normalize commit hook docs \n\nBody line 1\nBody line 2\n\nNightshift-Task: commit-normalize\nNightshift-Ref: https://github.com/marcus/nightshift\n" + + got, err := NormalizeCommitMessage(message, CommitMessageOptions{ + IssueID: "td-a1b2", + IssueType: models.TypeTask, + }) + if err != nil { + t.Fatalf("NormalizeCommitMessage returned error: %v", err) + } + + want := "chore: normalize commit hook docs (td-a1b2)\n\nBody line 1\nBody line 2\n\nNightshift-Task: commit-normalize\nNightshift-Ref: https://github.com/marcus/nightshift\n" + if got != want { + t.Fatalf("NormalizeCommitMessage = %q, want %q", got, want) + } +} + +func TestNormalizeCommitMessageSkipsSpecialGitSubjects(t *testing.T) { + tests := []struct { + name string + message string + }{ + { + name: "fixup autosquash commit", + message: "fixup! feat: normalize commit hook docs (td-a1b2)\n\nbody\n", + }, + { + name: "squash autosquash commit", + message: "squash! feat: normalize commit hook docs (td-a1b2)\n", + }, + { + name: "merge commit", + message: "Merge branch 'feat/commit-message-normalizer'\n\n# Conflicts:\n", + }, + { + name: "merge remote tracking branch", + message: "Merge remote-tracking branch 'origin/main'\n", + }, + { + name: "merge branches into integration branch", + message: "Merge branches 'topic-a' and 'topic-b' into next\n", + }, + { + name: "merge tag", + message: "Merge tag 'v0.43.0' into release\n", + }, + { + name: "revert commit", + message: "Revert \"feat: add commit message normalizer (td-a1b2)\"\n\nThis reverts commit 0123456789abcdef.\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := NormalizeCommitMessage(tt.message, CommitMessageOptions{ + IssueID: "td-a1b2", + IssueType: models.TypeFeature, + }) + if err != nil { + t.Fatalf("NormalizeCommitMessage returned error: %v", err) + } + if got != tt.message { + t.Fatalf("NormalizeCommitMessage = %q, want %q", got, tt.message) + } + }) + } +} + +func TestShouldSkipCommitMessageNormalizationOnlyForGitGeneratedMergeSubjects(t *testing.T) { + tests := []struct { + name string + subject string + want bool + }{ + { + name: "generated merge branch subject", + subject: "Merge branch 'feature/docs-cleanup'", + want: true, + }, + { + name: "generated merge remote tracking subject", + subject: "Merge remote-tracking branch 'origin/main' into release", + want: true, + }, + { + name: "generated merge tag subject", + subject: "Merge tag 'v0.43.0'", + want: true, + }, + { + name: "human authored merge summary", + subject: "Merge support docs", + want: false, + }, + { + name: "human authored merge prefix with colon", + subject: "Merge: support docs", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ShouldSkipCommitMessageNormalization(tt.subject) + if got != tt.want { + t.Fatalf("ShouldSkipCommitMessageNormalization(%q) = %v, want %v", tt.subject, got, tt.want) + } + }) + } +} + +func TestDefaultCommitTypeFromIssueMetadata(t *testing.T) { + tests := []struct { + name string + issueType models.Type + want CommitType + }{ + {name: "feature maps to feat", issueType: models.TypeFeature, want: CommitTypeFeat}, + {name: "bug maps to fix", issueType: models.TypeBug, want: CommitTypeFix}, + {name: "task maps to chore", issueType: models.TypeTask, want: CommitTypeChore}, + {name: "chore maps to chore", issueType: models.TypeChore, want: CommitTypeChore}, + {name: "epic maps to chore", issueType: models.TypeEpic, want: CommitTypeChore}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := DefaultCommitType(tt.issueType) + if err != nil { + t.Fatalf("DefaultCommitType returned error: %v", err) + } + if got != tt.want { + t.Fatalf("DefaultCommitType(%q) = %q, want %q", tt.issueType, got, tt.want) + } + }) + } +} diff --git a/scripts/commit-msg.sh b/scripts/commit-msg.sh new file mode 100755 index 0000000..284e7e1 --- /dev/null +++ b/scripts/commit-msg.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# commit-msg hook for td +# Install: make install-hooks +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "usage: commit-msg " >&2 + exit 1 +fi + +repo_root=$(git rev-parse --show-toplevel) +td_bin=${TD_BIN:-td} + +if ! command -v "$td_bin" >/dev/null 2>&1 && [[ ! -x "$td_bin" ]]; then + echo "td commit-msg hook requires '$td_bin' in PATH. Run 'make install' first." >&2 + exit 1 +fi + +"$td_bin" --work-dir "$repo_root" commit-message --file "$1" diff --git a/scripts/loop-prompt.md b/scripts/loop-prompt.md index 7f84db2..92f8cfb 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 "$(td commit-message 'brief summary')" td review ``` @@ -169,9 +169,10 @@ Use `td review`, not `td close` — self-closing is blocked. - **ONE task per iteration.** Complete it, verify it, commit it, mark it done, then exit. - **Tests are mandatory.** Every change needs tests. `go test ./...` must pass. - **Quality gates before every commit.** `go build` and `go test ./...` must pass. +- **Install hooks once per clone.** `make install-hooks` adds pre-commit checks and commit subject normalization. - **Don't break the action log.** All mutations through `*Logged()` functions. - **Don't break migrations.** Never modify existing migrations, only append new ones. - **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.** Use `td commit-message 'brief summary'`, or let the `commit-msg` hook normalize the first line to `feat|fix|docs|test|chore|ci: (td-)`. diff --git a/scripts/pre-commit.sh b/scripts/pre-commit.sh index 2b563f2..b1ec33c 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 set -euo pipefail PASS=0 diff --git a/website/docs/ai-integration.md b/website/docs/ai-integration.md index 834ee8b..6837422 100644 --- a/website/docs/ai-integration.md +++ b/website/docs/ai-integration.md @@ -65,6 +65,27 @@ td review # 5. Submit for review Steps 3-4 are critical for multi-context work. Logs and handoffs persist across context windows, so the next agent picks up exactly where you left off. +## Commit Message Workflow + +Install the hooks once per clone: + +```bash +make install-hooks +``` + +Then commit against the focused issue with: + +```bash +git commit \ + -m "$(td commit-message 'implement feature X')" \ + -m "Optional body" +``` + +The `commit-msg` hook rewrites only the first line, preserves bodies and +trailers, and leaves Git-generated merge/revert/autosquash subjects untouched. +If no issue is focused, only typed `docs`, `test`, `chore`, and `ci` subjects +such as `docs: Update changelog for v0.43.0` remain no-issue commits. + ## Session Isolation for Agents Each agent instance (terminal, context window) gets a unique session ID. This ensures: @@ -119,4 +140,5 @@ To disable and revert to strict mode: `td feature set balanced_review_policy fal - **Log frequently** -- short, hyper-concise messages. These survive context resets. - **Handoff before stopping** -- if work is incomplete, `td handoff` captures state for the next agent. - **Don't start new sessions mid-work** -- sessions track implementers. A new session mid-task bypasses review enforcement. +- **Use `td commit-message` for git subjects** -- it defaults from the focused issue, and the `commit-msg` hook enforces the same format in editors without rewriting merge/revert/autosquash commits. - **Use quiet mode after first read** -- `td usage -q` avoids repeating workflow instructions every time. diff --git a/website/docs/command-reference.md b/website/docs/command-reference.md index 7260e3e..fc11533 100644 --- a/website/docs/command-reference.md +++ b/website/docs/command-reference.md @@ -139,6 +139,7 @@ cat docs/acceptance.md | td update td-a1b2 --append --acceptance-file - | Command | Description | |---------|-------------| | `td init` | Initialize project | +| `td commit-message "summary"` | Print or rewrite a normalized commit subject. Flags: `--issue`, `--type`, `--file` | | `td monitor` | Live TUI dashboard | | `td release-notes [flags]` | Draft markdown release notes from committed git history. Flags: `--from`, `--to`, `--version` | | `td undo` | Undo last action | @@ -146,3 +147,10 @@ cat docs/acceptance.md | td update td-a1b2 --append --acceptance-file - | `td export` | Export database | | `td import` | Import issues | | `td stats [subcommand]` | Usage statistics | + +For repository checkouts, run `make install-hooks` once to install the matching +`pre-commit` and `commit-msg` hooks. The `commit-msg` hook calls +`td commit-message --file ...`, only rewrites the first line, and preserves +Git-generated merge/revert/autosquash subjects. If no issue is focused, only +typed `docs`, `test`, `chore`, and `ci` subjects such as +`docs: Update changelog for v0.43.0` remain no-issue commits.