From 8f39ffb94124b28b6e06c5ee864e5e78d8b1e005 Mon Sep 17 00:00:00 2001 From: Review Bot Date: Sat, 11 Apr 2026 03:09:21 -0700 Subject: [PATCH] feat: add commit message normalizer (td-6faff9) Nightshift-Task: commit-normalize Nightshift-Ref: https://github.com/marcus/nightshift --- CLAUDE.md | 16 +- Makefile | 11 +- README.md | 26 ++- cmd/commit_message.go | 168 +++++++++++++++ cmd/commit_message_test.go | 201 ++++++++++++++++++ internal/git/commit_message.go | 306 ++++++++++++++++++++++++++++ internal/git/commit_message_test.go | 122 +++++++++++ scripts/commit-msg.sh | 19 ++ scripts/loop-prompt.md | 5 +- scripts/pre-commit.sh | 2 +- website/docs/ai-integration.md | 19 ++ website/docs/command-reference.md | 3 + 12 files changed, 880 insertions(+), 18 deletions(-) create mode 100644 cmd/commit_message.go create mode 100644 cmd/commit_message_test.go create mode 100644 internal/git/commit_message.go create mode 100644 internal/git/commit_message_test.go create mode 100755 scripts/commit-msg.sh diff --git a/CLAUDE.md b/CLAUDE.md index cd0ab661..07a2d2e1 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 18da511f..77a106b3 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,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" + @repo_root=$$(git rev-parse --show-toplevel); \ + 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 684416ad..a6a85e58 100644 --- a/README.md +++ b/README.md @@ -189,10 +189,28 @@ 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 normalizes only the first line to `: (td-)` and leaves commit bodies and trailers untouched. + ## Tests & Quality Checks ```bash @@ -543,8 +561,10 @@ 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. **Install hooks once per clone**: `make install-hooks` adds the pre-commit checks and commit subject normalizer +5. **Use td commit subjects**: `git commit -m "$(td commit-message 'short summary')"` +6. **PR review**: One reviewer approval required +7. **Session isolation respected**: PRs should follow td's own handoff patterns where applicable ## Support diff --git a/cmd/commit_message.go b/cmd/commit_message.go new file mode 100644 index 00000000..9c3136cf --- /dev/null +++ b/cmd/commit_message.go @@ -0,0 +1,168 @@ +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 the canonical td format: + : (td-) + +The issue ID comes from --issue, a trailing (td-) suffix already present in +the subject, or the focused issue. When --file is set, td rewrites only the +first line of the commit message file in place and preserves the body/trailers.`, + Example: ` td commit-message "normalize commit hook docs" + td commit-message --issue td-a1b2 "normalize commit hook docs" + td commit-message --type fix "handle retry regression" + 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"`) + } + + database, err := db.Open(baseDir) + if err != nil { + output.Error("%v", err) + return err + } + defer database.Close() + + subject, err := commitMessageSubject(args, filePath) + if err != nil { + output.Error("%v", err) + return err + } + + issue, issueID, err := resolveCommitMessageIssue(database, baseDir, cmd, subject) + if err != nil { + output.Error("%v", err) + return err + } + + explicitType, _ := cmd.Flags().GetString("type") + opts := git.CommitMessageOptions{ + IssueID: issueID, + IssueType: issue.Type, + 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.Println(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 resolveCommitMessageIssue(database *db.DB, baseDir string, cmd *cobra.Command, subject string) (*models.Issue, string, error) { + issueFlag, _ := cmd.Flags().GetString("issue") + issueID, err := normalizeCommitMessageIssueRef(baseDir, issueFlag) + if err != nil { + return nil, "", err + } + + if issueID == "" { + issueID, err = git.ExtractCommitIssueID(subject) + if err != nil { + return nil, "", err + } + } + + if issueID == "" { + focusedID, err := config.GetFocus(baseDir) + if err != nil { + return nil, "", err + } + issueID, err = git.NormalizeCommitIssueID(focusedID) + if err != nil { + return nil, "", err + } + } + + if issueID == "" { + return nil, "", fmt.Errorf("no issue specified and no focused issue; use --issue, add (td-), or run td start ") + } + + issue, err := database.GetIssue(issueID) + if err != nil { + return nil, "", err + } + + return issue, issueID, 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, chore)") + 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 00000000..e780cd35 --- /dev/null +++ b/cmd/commit_message_test.go @@ -0,0 +1,201 @@ +package cmd + +import ( + "bytes" + "io" + "os" + "path/filepath" + "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 + oldStdout := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe failed: %v", err) + } + os.Stdout = w + + runErr := commitMessageCmd.RunE(commitMessageCmd, args) + + _ = w.Close() + os.Stdout = oldStdout + _, _ = io.Copy(&output, r) + + 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 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), 0644); 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), 0644); 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 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{"docs: update README"}, "", "", "") + if err == nil { + t.Fatal("expected unsupported prefix error") + } + if !strings.Contains(err.Error(), `unsupported commit type "docs"`) { + t.Fatalf("unexpected malformed input error: %v", err) + } +} diff --git a/internal/git/commit_message.go b/internal/git/commit_message.go new file mode 100644 index 00000000..18e37802 --- /dev/null +++ b/internal/git/commit_message.go @@ -0,0 +1,306 @@ +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" + CommitTypeChore CommitType = "chore" +) + +// 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*(.*)$`) + 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}$`) +) + +type parsedCommitSubject struct { + Type CommitType + Summary string + IssueID string +} + +// NormalizeCommitType returns a canonical lowercase commit type. +func NormalizeCommitType(raw string) (CommitType, error) { + normalized := CommitType(strings.ToLower(strings.TrimSpace(raw))) + + switch normalized { + case CommitTypeFeat, CommitTypeFix, CommitTypeChore: + return normalized, nil + case "": + return "", nil + default: + return "", fmt.Errorf("unsupported commit type %q: use feat, fix, or chore", strings.TrimSpace(raw)) + } +} + +// 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 feat|fix|chore", issueType) + } +} + +// 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 +} + +// NormalizeCommitSubject rewrites a subject into : (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 + } + if issueID == "" { + return "", fmt.Errorf("missing issue ID: include (td-) or pass --issue") + } + + commitType, err := resolveCommitType(parsed.Type, opts) + if err != nil { + return "", err + } + + return fmt.Sprintf("%s: %s (%s)", commitType, 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) + + 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 + } + 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("") + if matches := commitSubjectPrefixPattern.FindStringSubmatch(remaining); matches != nil { + commitType, err = NormalizeCommitType(matches[1]) + if err != nil { + return parsedCommitSubject{}, err + } + remaining = matches[2] + } + + summary := cleanCommitSummary(remaining) + if summary == "" { + return parsedCommitSubject{}, fmt.Errorf("missing commit summary") + } + + return parsedCommitSubject{ + Type: commitType, + 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 cleanCommitSummary(summary string) string { + return strings.Join(strings.Fields(strings.TrimSpace(summary)), " ") +} + +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 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 00000000..70e9cdf0 --- /dev/null +++ b/internal/git/commit_message_test.go @@ -0,0 +1,122 @@ +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(" FeAt : Normalize commit hook docs ", CommitMessageOptions{ + IssueID: "td-a1b2", + }) + 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 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 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("docs: update README (td-a1b2)", CommitMessageOptions{}) + if err == nil { + t.Fatal("expected unsupported prefix error") + } + if !strings.Contains(err.Error(), `unsupported commit type "docs"`) { + 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 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 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 00000000..93ac21a3 --- /dev/null +++ b/scripts/commit-msg.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# commit-msg hook for td +# Install: make install-hooks (or: ln -sf "$(git rev-parse --show-toplevel)/scripts/commit-msg.sh" "$(git rev-parse --git-path hooks/commit-msg)") +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 7f84db2e..e67521bb 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 to `feat|fix|chore: (td-)`. diff --git a/scripts/pre-commit.sh b/scripts/pre-commit.sh index 2b563f26..222bd3b8 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 (or: ln -sf "$(git rev-parse --show-toplevel)/scripts/pre-commit.sh" "$(git rev-parse --git-path hooks/pre-commit)") set -euo pipefail PASS=0 diff --git a/website/docs/ai-integration.md b/website/docs/ai-integration.md index 834ee8b4..467919ad 100644 --- a/website/docs/ai-integration.md +++ b/website/docs/ai-integration.md @@ -65,6 +65,24 @@ 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 to `feat|fix|chore: (td-)`, so manual edits in `COMMIT_EDITMSG` stay consistent while bodies and trailers remain untouched. + ## Session Isolation for Agents Each agent instance (terminal, context window) gets a unique session ID. This ensures: @@ -119,4 +137,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. - **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 28e9da1c..f234332a 100644 --- a/website/docs/command-reference.md +++ b/website/docs/command-reference.md @@ -139,9 +139,12 @@ 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 undo` | Undo last action | | `td version` | Show version | | `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 ...` and only rewrites the first line of the commit message.