diff --git a/README.md b/README.md index 684416ad..324bb0fc 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,7 @@ td/ │ ├── models/ # Issue, Log, Handoff, WorkSession domain types │ ├── session/ # Session ID management (.todos/session file) │ ├── git/ # Git state tracking (SHA, branch, dirty files) +│ ├── changelog/ # Changelog entry synthesis from git commits │ ├── output/ # Formatters for terminal output │ └── tui/ # Bubble Tea monitor dashboard └── .todos/ # Local SQLite database + session state @@ -422,6 +423,7 @@ Analytics are stored locally and help identify workflow patterns. Disable with ` | Undo last action | `td undo` | | New named session | `td session --new "feature-work"` | | Live dashboard | `td monitor` | +| Generate changelog entry | `td changelog --version vX.Y.Z` | ### Boards diff --git a/cmd/changelog.go b/cmd/changelog.go new file mode 100644 index 00000000..57550cc2 --- /dev/null +++ b/cmd/changelog.go @@ -0,0 +1,122 @@ +package cmd + +import ( + "errors" + "fmt" + "os" + "regexp" + "strings" + "time" + + changelogpkg "github.com/marcus/td/internal/changelog" + gitutil "github.com/marcus/td/internal/git" + "github.com/spf13/cobra" +) + +var ( + changelogNow = time.Now + changelogDatePattern = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) +) + +var changelogCmd = &cobra.Command{ + Use: "changelog", + Short: "Generate a CHANGELOG.md entry from git commits", + GroupID: "system", + Args: cobra.NoArgs, + Long: `Generate a paste-ready CHANGELOG.md entry from committed git history. + +By default, td uses the nearest reachable semver tag through HEAD as the +starting point and prints markdown to stdout for review. It never edits +CHANGELOG.md automatically.`, + Example: ` td changelog + td changelog --version v0.5.0 + td changelog --version v0.5.0 --date 2026-05-09 + td changelog --from v0.4.0 --to HEAD --version v0.5.0`, + RunE: func(cmd *cobra.Command, args []string) error { + fromRef, _ := cmd.Flags().GetString("from") + toRef, _ := cmd.Flags().GetString("to") + versionLabel, _ := cmd.Flags().GetString("version") + dateValue, _ := cmd.Flags().GetString("date") + + fromRef = strings.TrimSpace(fromRef) + toRef = strings.TrimSpace(toRef) + versionLabel = strings.TrimSpace(versionLabel) + dateValue = strings.TrimSpace(dateValue) + + if cmd.Flags().Changed("from") && fromRef == "" { + return fmt.Errorf("--from cannot be empty") + } + if toRef == "" { + return fmt.Errorf("--to cannot be empty") + } + + var releaseDate time.Time + if dateValue != "" { + if versionLabel == "" { + return fmt.Errorf("--version is required when --date is supplied") + } + if !changelogDatePattern.MatchString(dateValue) { + return fmt.Errorf("invalid --date %q: expected YYYY-MM-DD", dateValue) + } + parsedDate, err := time.Parse("2006-01-02", dateValue) + if err != nil { + return fmt.Errorf("invalid --date %q: expected YYYY-MM-DD", dateValue) + } + releaseDate = parsedDate + } else if versionLabel != "" { + releaseDate = changelogNow() + } + + draft, err := changelogpkg.Generate(gitHistoryRepoDir(), changelogpkg.Options{ + FromRef: fromRef, + ToRef: toRef, + Version: versionLabel, + Date: releaseDate, + }) + if err != nil { + switch { + case errors.Is(err, gitutil.ErrNotRepository): + return fmt.Errorf("changelog requires a git repository") + case errors.Is(err, gitutil.ErrNoSemverTag): + return fmt.Errorf("no reachable semver tag found for %s; pass --from to set the starting ref", toRef) + case errors.Is(err, changelogpkg.ErrNoRelevantCommits): + return err + default: + return err + } + } + + _, err = fmt.Fprint(cmd.OutOrStdout(), draft.Markdown()) + return err + }, +} + +// gitHistoryRepoDir uses the active worktree instead of td's resolved database +// root when possible, so changelogs follow the branch the user is actually on. +func gitHistoryRepoDir() string { + if baseDirOverride != nil { + return *baseDirOverride + } + if workDirFlag != "" { + return normalizeWorkDir(workDirFlag) + } + cwd, err := os.Getwd() + if err == nil && gitutil.IsRepoAt(cwd) { + return cwd + } + if envDir := os.Getenv("TD_WORK_DIR"); envDir != "" { + return normalizeWorkDir(envDir) + } + if err == nil { + return cwd + } + return getBaseDir() +} + +func init() { + rootCmd.AddCommand(changelogCmd) + changelogCmd.Flags().String("from", "", "Start the range at this git ref or tag (default: nearest reachable semver tag)") + changelogCmd.Flags().String("to", "HEAD", "End the range at this git ref") + changelogCmd.Flags().String("version", "", "Version label for the markdown header") + changelogCmd.Flags().String("date", "", "Release date for the markdown header (YYYY-MM-DD; requires --version)") +} diff --git a/cmd/changelog_test.go b/cmd/changelog_test.go new file mode 100644 index 00000000..42543319 --- /dev/null +++ b/cmd/changelog_test.go @@ -0,0 +1,249 @@ +package cmd + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func saveAndRestoreChangelogState(t *testing.T, now time.Time) { + t.Helper() + saveAndRestoreGlobals(t) + + origBaseDirOverride := baseDirOverride + origNow := changelogNow + t.Cleanup(func() { + baseDirOverride = origBaseDirOverride + changelogNow = origNow + changelogCmd.SetOut(nil) + }) + + changelogNow = func() time.Time { return now } +} + +func resetChangelogFlags(t *testing.T) { + t.Helper() + defaults := map[string]string{ + "from": "", + "to": "HEAD", + "version": "", + "date": "", + } + for name, value := range defaults { + if err := changelogCmd.Flags().Set(name, value); err != nil { + t.Fatalf("failed to reset --%s: %v", name, err) + } + changelogCmd.Flags().Lookup(name).Changed = false + } +} + +func runChangelogCommand(t *testing.T, dir string, flagPairs ...string) (string, error) { + t.Helper() + resetChangelogFlags(t) + + baseDir := dir + baseDirOverride = &baseDir + + for i := 0; i+1 < len(flagPairs); i += 2 { + if err := changelogCmd.Flags().Set(flagPairs[i], flagPairs[i+1]); err != nil { + t.Fatalf("failed to set --%s: %v", flagPairs[i], err) + } + } + + var output bytes.Buffer + changelogCmd.SetOut(&output) + err := changelogCmd.RunE(changelogCmd, nil) + return output.String(), err +} + +func initChangelogRepo(t *testing.T) string { + t.Helper() + dir := initGitRepo(t) + runGit(t, dir, "config", "user.email", "test@test.com") + runGit(t, dir, "config", "user.name", "Test User") + commitChangelogFile(t, dir, "README.md", "# Test\n", "Initial commit") + return dir +} + +func commitChangelogFile(t *testing.T, dir, path, content, subject string) string { + t.Helper() + fullPath := filepath.Join(dir, path) + if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { + t.Fatalf("mkdir failed: %v", err) + } + if err := os.WriteFile(fullPath, []byte(content), 0644); err != nil { + t.Fatalf("write failed: %v", err) + } + runGit(t, dir, "add", path) + runGit(t, dir, "commit", "-m", subject) + + var sha bytes.Buffer + cmd := execCommand(t, dir, "git", "rev-parse", "HEAD") + cmd.Stdout = &sha + if err := cmd.Run(); err != nil { + t.Fatalf("rev-parse HEAD failed: %v", err) + } + return strings.TrimSpace(sha.String()) +} + +func tagChangelogHead(t *testing.T, dir, tag string) { + t.Helper() + runGit(t, dir, "tag", "-a", tag, "-m", "Release "+tag) +} + +func execCommand(t *testing.T, dir, name string, args ...string) *exec.Cmd { + t.Helper() + cmd := exec.Command(name, args...) + cmd.Dir = dir + return cmd +} + +func TestChangelogCommandDefaultsToNearestReachableTag(t *testing.T) { + saveAndRestoreChangelogState(t, time.Date(2026, 5, 9, 12, 0, 0, 0, time.UTC)) + dir := initChangelogRepo(t) + + tagChangelogHead(t, dir, "v0.1.0") + commitChangelogFile(t, dir, "old.txt", "old\n", "feat: add old feature") + tagChangelogHead(t, dir, "v0.2.0") + commitChangelogFile(t, dir, "fix.txt", "fix\n", "fix: patch release") + + output, err := runChangelogCommand(t, dir, "version", "v0.2.1") + if err != nil { + t.Fatalf("changelog command failed: %v", err) + } + if strings.Contains(output, "Add old feature") { + t.Fatalf("expected latest reachable tag range, got:\n%s", output) + } + if !strings.Contains(output, "Patch release") { + t.Fatalf("expected patch release entry, got:\n%s", output) + } +} + +func TestChangelogCommandUsesExplicitFromTo(t *testing.T) { + saveAndRestoreChangelogState(t, time.Date(2026, 5, 9, 12, 0, 0, 0, time.UTC)) + dir := initChangelogRepo(t) + + tagChangelogHead(t, dir, "v0.1.0") + commitChangelogFile(t, dir, "feature.txt", "feature\n", "feat: add feature") + mid := commitChangelogFile(t, dir, "fix.txt", "fix\n", "fix: patch feature") + commitChangelogFile(t, dir, "later.txt", "later\n", "feat: add later work") + + output, err := runChangelogCommand(t, dir, "from", "v0.1.0", "to", mid, "version", "v0.2.0") + if err != nil { + t.Fatalf("changelog command failed: %v", err) + } + if !strings.Contains(output, "Add feature") || !strings.Contains(output, "Patch feature") { + t.Fatalf("expected explicit range entries, got:\n%s", output) + } + if strings.Contains(output, "Add later work") { + t.Fatalf("explicit --to should exclude later work, got:\n%s", output) + } +} + +func TestChangelogCommandVersionDateOutput(t *testing.T) { + saveAndRestoreChangelogState(t, time.Date(2026, 5, 9, 12, 0, 0, 0, time.UTC)) + dir := initChangelogRepo(t) + + tagChangelogHead(t, dir, "v0.1.0") + commitChangelogFile(t, dir, "feature.txt", "feature\n", "feat: add changelog command") + + output, err := runChangelogCommand(t, dir, "version", "v0.2.0", "date", "2026-05-08") + if err != nil { + t.Fatalf("changelog command failed: %v", err) + } + if !strings.HasPrefix(output, "## [v0.2.0] - 2026-05-08\n\n") { + t.Fatalf("unexpected heading:\n%s", output) + } +} + +func TestChangelogCommandShowsNoTagGuidance(t *testing.T) { + saveAndRestoreChangelogState(t, time.Date(2026, 5, 9, 12, 0, 0, 0, time.UTC)) + dir := initChangelogRepo(t) + commitChangelogFile(t, dir, "feature.txt", "feature\n", "feat: add changelog command") + + _, err := runChangelogCommand(t, dir) + if err == nil || !strings.Contains(err.Error(), "no reachable semver tag found for HEAD; pass --from") { + t.Fatalf("expected no-tag guidance, got %v", err) + } +} + +func TestChangelogCommandEmptyRangeReturnsUsefulError(t *testing.T) { + saveAndRestoreChangelogState(t, time.Date(2026, 5, 9, 12, 0, 0, 0, time.UTC)) + dir := initChangelogRepo(t) + tagChangelogHead(t, dir, "v0.1.0") + + _, err := runChangelogCommand(t, dir, "from", "HEAD", "to", "HEAD") + if err == nil || !strings.Contains(err.Error(), "no changelog-worthy commits found between HEAD and HEAD") { + t.Fatalf("expected empty-range error, got %v", err) + } +} + +func TestChangelogCommandRejectsInvalidDate(t *testing.T) { + saveAndRestoreChangelogState(t, time.Date(2026, 5, 9, 12, 0, 0, 0, time.UTC)) + dir := initChangelogRepo(t) + tagChangelogHead(t, dir, "v0.1.0") + commitChangelogFile(t, dir, "feature.txt", "feature\n", "feat: add changelog command") + + _, err := runChangelogCommand(t, dir, "version", "v0.2.0", "date", "2026/05/09") + if err == nil || !strings.Contains(err.Error(), `invalid --date "2026/05/09"`) { + t.Fatalf("expected invalid date error, got %v", err) + } +} + +func TestChangelogCommandRequiresVersionWhenDateSupplied(t *testing.T) { + saveAndRestoreChangelogState(t, time.Date(2026, 5, 9, 12, 0, 0, 0, time.UTC)) + dir := initChangelogRepo(t) + tagChangelogHead(t, dir, "v0.1.0") + commitChangelogFile(t, dir, "feature.txt", "feature\n", "feat: add changelog command") + + _, err := runChangelogCommand(t, dir, "date", "2026-05-09") + if err == nil || !strings.Contains(err.Error(), "--version is required when --date is supplied") { + t.Fatalf("expected missing version error, got %v", err) + } +} + +func TestChangelogCommandRejectsInvalidRefs(t *testing.T) { + saveAndRestoreChangelogState(t, time.Date(2026, 5, 9, 12, 0, 0, 0, time.UTC)) + dir := initChangelogRepo(t) + tagChangelogHead(t, dir, "v0.1.0") + commitChangelogFile(t, dir, "feature.txt", "feature\n", "feat: add changelog command") + + _, err := runChangelogCommand(t, dir, "from", "missing-ref") + if err == nil || !strings.Contains(err.Error(), `invalid git ref "missing-ref"`) { + t.Fatalf("expected invalid ref error, got %v", err) + } + _, err = runChangelogCommand(t, dir, "to", "") + if err == nil || !strings.Contains(err.Error(), "--to cannot be empty") { + t.Fatalf("expected empty --to error, got %v", err) + } +} + +func TestChangelogCommandDefaultRangeUsesTargetRef(t *testing.T) { + saveAndRestoreChangelogState(t, time.Date(2026, 5, 9, 12, 0, 0, 0, time.UTC)) + dir := initChangelogRepo(t) + initial := strings.TrimSpace(func() string { + var out bytes.Buffer + cmd := execCommand(t, dir, "git", "rev-parse", "HEAD") + cmd.Stdout = &out + if err := cmd.Run(); err != nil { + t.Fatalf("rev-parse HEAD failed: %v", err) + } + return out.String() + }()) + + runGit(t, dir, "checkout", "-b", "target", initial) + commitChangelogFile(t, dir, "target.txt", "target\n", "feat: add target feature") + + runGit(t, dir, "checkout", "-B", "main", initial) + commitChangelogFile(t, dir, "main.txt", "main\n", "feat: add main feature") + tagChangelogHead(t, dir, "v0.1.0") + + _, err := runChangelogCommand(t, dir, "to", "target") + if err == nil || !strings.Contains(err.Error(), "no reachable semver tag found for target") { + t.Fatalf("expected target-ref no-tag error, got %v", err) + } +} diff --git a/docs/guides/releasing-new-version.md b/docs/guides/releasing-new-version.md index ca98e527..94adc676 100644 --- a/docs/guides/releasing-new-version.md +++ b/docs/guides/releasing-new-version.md @@ -33,7 +33,20 @@ Check current version: git tag -l | sort -V | tail -1 ``` -### 2. Update CHANGELOG.md +### 2. Preview and Update CHANGELOG.md + +Generate a draft entry from commits since the nearest reachable semver tag: + +```bash +td changelog --version vX.Y.Z --date YYYY-MM-DD +``` + +Review the stdout carefully, edit wording as needed, then manually add the +entry at the top of `CHANGELOG.md`. To override the detected range: + +```bash +td changelog --from vA.B.C --to HEAD --version vX.Y.Z --date YYYY-MM-DD +``` Add entry at the top of `CHANGELOG.md`: @@ -134,8 +147,8 @@ Replace `X.Y.Z` with actual version: git status go test ./... -# Update changelog -# (Edit CHANGELOG.md, add entry at top) +# Preview changelog, then edit CHANGELOG.md and add entry at top +td changelog --version vX.Y.Z --date YYYY-MM-DD git add CHANGELOG.md git commit -m "docs: Update changelog for vX.Y.Z" @@ -154,6 +167,7 @@ brew upgrade td && td version - [ ] Tests pass (`go test ./...`) - [ ] Working tree clean +- [ ] Changelog preview generated with `td changelog` - [ ] CHANGELOG.md updated with new version entry - [ ] Changelog committed to git - [ ] Version number follows semver diff --git a/internal/changelog/changelog.go b/internal/changelog/changelog.go new file mode 100644 index 00000000..70d9225e --- /dev/null +++ b/internal/changelog/changelog.go @@ -0,0 +1,275 @@ +// Package changelog renders paste-ready changelog entries from git commits. +package changelog + +import ( + "errors" + "fmt" + "regexp" + "strings" + "time" + "unicode" + "unicode/utf8" + + gitutil "github.com/marcus/td/internal/git" +) + +const ( + sectionFeatures = "Features" + sectionBugFixes = "Bug Fixes" + sectionDocumentation = "Documentation" + sectionImprovements = "Improvements" +) + +var ( + // ErrNoRelevantCommits indicates the selected range contains no entries + // after changelog filters are applied. + ErrNoRelevantCommits = errors.New("no changelog-worthy commits found") + + conventionalCommitPattern = regexp.MustCompile(`(?i)^(feat(?:ure)?|fix|bugfix|bug|docs?|doc|refactor|perf|style|build|ci|chore|test(?:s)?)(?:\(([^)]+)\))?!?:\s*(.+)$`) + tdRefPrefixPattern = regexp.MustCompile(`^(?:(?:\[(?:td|task)-[^\]]+\])\s*)+`) + tdRefSuffixPattern = regexp.MustCompile(`\s+\((?:td|task)-[^)]+\)\.?$`) + taskPrefixPattern = regexp.MustCompile(`(?i)^task(?:\([^)]+\))?:\s*`) + + sectionOrder = []string{ + sectionFeatures, + sectionBugFixes, + sectionDocumentation, + sectionImprovements, + } +) + +// Options configures changelog generation. +type Options struct { + FromRef string + ToRef string + Version string + Date time.Time +} + +// Draft is a paste-ready changelog entry. +type Draft struct { + FromRef string + ToRef string + Version string + Date time.Time + Sections []Section +} + +// Section is one markdown section in the generated changelog entry. +type Section struct { + Title string + Entries []Entry +} + +// Entry is one changelog bullet. +type Entry struct { + Text string + ShortSHA string +} + +// Generate builds a changelog draft from committed git history. +func Generate(repoDir string, opts Options) (*Draft, error) { + toRef := strings.TrimSpace(opts.ToRef) + if toRef == "" { + toRef = "HEAD" + } + + fromRef := strings.TrimSpace(opts.FromRef) + if fromRef == "" { + tag, err := gitutil.NearestReachableSemverTag(repoDir, toRef) + if err != nil { + return nil, err + } + fromRef = tag + } + + commits, err := gitutil.ListCommitsInRange(repoDir, fromRef, toRef) + if err != nil { + return nil, err + } + + draft, err := Build(commits, Options{ + FromRef: fromRef, + ToRef: toRef, + Version: strings.TrimSpace(opts.Version), + Date: opts.Date, + }) + if err != nil { + return nil, fmt.Errorf("%w between %s and %s", err, fromRef, toRef) + } + return draft, nil +} + +// Build renders a changelog draft from already-loaded commits. +func Build(commits []gitutil.Commit, opts Options) (*Draft, error) { + sectionsByTitle := make(map[string][]Entry) + for _, commit := range commits { + entry, ok := classifyCommit(commit) + if !ok { + continue + } + sectionsByTitle[entry.section] = append(sectionsByTitle[entry.section], Entry{ + Text: entry.text, + ShortSHA: shortSHA(commit), + }) + } + + sections := make([]Section, 0, len(sectionOrder)) + for _, title := range sectionOrder { + entries := sectionsByTitle[title] + if len(entries) == 0 { + continue + } + sections = append(sections, Section{Title: title, Entries: entries}) + } + if len(sections) == 0 { + return nil, ErrNoRelevantCommits + } + + return &Draft{ + FromRef: strings.TrimSpace(opts.FromRef), + ToRef: strings.TrimSpace(opts.ToRef), + Version: strings.TrimSpace(opts.Version), + Date: opts.Date, + Sections: sections, + }, nil +} + +// Markdown renders the draft as markdown suitable for CHANGELOG.md. +func (d *Draft) Markdown() string { + var b strings.Builder + switch { + case d.Version != "" && !d.Date.IsZero(): + fmt.Fprintf(&b, "## [%s] - %s\n\n", d.Version, d.Date.Format("2006-01-02")) + case d.Version != "": + fmt.Fprintf(&b, "## [%s]\n\n", d.Version) + default: + b.WriteString("## Unreleased\n\n") + } + + for _, section := range d.Sections { + fmt.Fprintf(&b, "### %s\n", section.Title) + for _, entry := range section.Entries { + fmt.Fprintf(&b, "- %s (%s)\n", entry.Text, entry.ShortSHA) + } + b.WriteString("\n") + } + + return strings.TrimRight(b.String(), "\n") + "\n" +} + +type classifiedEntry struct { + section string + text string +} + +func classifyCommit(commit gitutil.Commit) (classifiedEntry, bool) { + subject := cleanSubject(commit.Subject) + if subject == "" || isFilteredSubject(subject) { + return classifiedEntry{}, false + } + + if matches := conventionalCommitPattern.FindStringSubmatch(subject); len(matches) == 4 { + kind := strings.ToLower(strings.TrimSpace(matches[1])) + scope := strings.TrimSpace(matches[2]) + description := strings.TrimSpace(matches[3]) + return classifyConventional(kind, scope, description), true + } + + section := classifyLoose(subject) + return classifiedEntry{section: section, text: cleanBulletText(subject)}, true +} + +func classifyConventional(kind, scope, description string) classifiedEntry { + text := strings.TrimSpace(description) + if scope != "" { + text = scope + ": " + text + } + text = cleanBulletText(text) + + switch kind { + case "feat", "feature": + return classifiedEntry{section: sectionFeatures, text: text} + case "fix", "bugfix", "bug": + return classifiedEntry{section: sectionBugFixes, text: text} + case "docs", "doc": + return classifiedEntry{section: sectionDocumentation, text: text} + default: + return classifiedEntry{section: sectionImprovements, text: text} + } +} + +func classifyLoose(subject string) string { + lower := strings.ToLower(subject) + candidates := []string{lower} + if _, tail, ok := strings.Cut(lower, ": "); ok && strings.TrimSpace(tail) != "" { + candidates = append(candidates, strings.TrimSpace(tail)) + } + + switch { + case hasLeadingVerb(candidates, "add", "introduce", "support", "enable", "implement", "show"): + return sectionFeatures + case hasLeadingVerb(candidates, "fix", "resolve", "correct", "prevent", "stabilize", "restore", "handle"): + return sectionBugFixes + case hasLeadingVerb(candidates, "document", "docs", "doc", "readme", "documentation"): + return sectionDocumentation + case hasLeadingVerb(candidates, "polish", "clean up", "clarify", "align", "reduce", "increase", "improve", "simplify", "update", "upgrade", "optimize", "refine", "expose", "refactor"): + return sectionImprovements + default: + return sectionImprovements + } +} + +func hasLeadingVerb(subjects []string, verbs ...string) bool { + for _, subject := range subjects { + for _, verb := range verbs { + if subject == verb || strings.HasPrefix(subject, verb+" ") || strings.HasPrefix(subject, verb+":") { + return true + } + } + } + return false +} + +func cleanSubject(subject string) string { + subject = strings.TrimSpace(subject) + subject = tdRefPrefixPattern.ReplaceAllString(subject, "") + subject = taskPrefixPattern.ReplaceAllString(subject, "") + subject = tdRefSuffixPattern.ReplaceAllString(subject, "") + return strings.Join(strings.Fields(subject), " ") +} + +func cleanBulletText(text string) string { + text = strings.TrimSpace(text) + text = strings.TrimSuffix(text, ".") + text = strings.Join(strings.Fields(text), " ") + if text == "" { + return text + } + + r, size := utf8.DecodeRuneInString(text) + if r == utf8.RuneError && size == 0 { + return text + } + if unicode.IsLower(r) { + return string(unicode.ToUpper(r)) + text[size:] + } + return text +} + +func isFilteredSubject(subject string) bool { + lower := strings.ToLower(subject) + return strings.HasPrefix(lower, "merge ") || + strings.HasPrefix(lower, "fixup!") || + strings.HasPrefix(lower, "squash!") +} + +func shortSHA(commit gitutil.Commit) string { + if commit.ShortSHA != "" { + return commit.ShortSHA + } + if len(commit.SHA) >= 7 { + return commit.SHA[:7] + } + return commit.SHA +} diff --git a/internal/changelog/changelog_test.go b/internal/changelog/changelog_test.go new file mode 100644 index 00000000..a0a4c5df --- /dev/null +++ b/internal/changelog/changelog_test.go @@ -0,0 +1,158 @@ +package changelog + +import ( + "errors" + "strings" + "testing" + "time" + + gitutil "github.com/marcus/td/internal/git" +) + +func testCommit(sha, subject string) gitutil.Commit { + return gitutil.Commit{ + SHA: sha, + ShortSHA: sha[:7], + Subject: subject, + } +} + +func TestBuildGroupsConventionalCommits(t *testing.T) { + draft, err := Build([]gitutil.Commit{ + testCommit("1111111111111111111111111111111111111111", "feat: add changelog command"), + testCommit("2222222222222222222222222222222222222222", "fix(parser): handle empty ranges."), + testCommit("3333333333333333333333333333333333333333", "docs: document release flow"), + testCommit("4444444444444444444444444444444444444444", "refactor: simplify rendering"), + }, Options{ + FromRef: "v0.1.0", + ToRef: "HEAD", + Version: "v0.2.0", + Date: time.Date(2026, 5, 9, 0, 0, 0, 0, time.UTC), + }) + if err != nil { + t.Fatalf("Build returned error: %v", err) + } + + gotSections := make([]string, 0, len(draft.Sections)) + for _, section := range draft.Sections { + gotSections = append(gotSections, section.Title) + } + wantSections := []string{sectionFeatures, sectionBugFixes, sectionDocumentation, sectionImprovements} + if strings.Join(gotSections, ",") != strings.Join(wantSections, ",") { + t.Fatalf("sections = %v, want %v", gotSections, wantSections) + } + + if got := draft.Sections[0].Entries[0].Text; got != "Add changelog command" { + t.Fatalf("unexpected feature text: %q", got) + } + if got := draft.Sections[1].Entries[0].Text; got != "Parser: handle empty ranges" { + t.Fatalf("unexpected fix text: %q", got) + } +} + +func TestBuildClassifiesLooseSubjects(t *testing.T) { + draft, err := Build([]gitutil.Commit{ + testCommit("1111111111111111111111111111111111111111", "Add changelog docs export"), + testCommit("2222222222222222222222222222222222222222", "Fix README rendering"), + testCommit("3333333333333333333333333333333333333333", "Document command reference"), + testCommit("4444444444444444444444444444444444444444", "Polish release preview"), + }, Options{}) + if err != nil { + t.Fatalf("Build returned error: %v", err) + } + + if got := draft.Sections[0].Title; got != sectionFeatures { + t.Fatalf("first section = %q, want features", got) + } + if got := draft.Sections[0].Entries[0].Text; got != "Add changelog docs export" { + t.Fatalf("feature entry = %q", got) + } + if got := draft.Sections[1].Title; got != sectionBugFixes { + t.Fatalf("second section = %q, want bug fixes", got) + } + if got := draft.Sections[1].Entries[0].Text; got != "Fix README rendering" { + t.Fatalf("fix entry = %q", got) + } + if got := draft.Sections[2].Title; got != sectionDocumentation { + t.Fatalf("third section = %q, want documentation", got) + } + if got := draft.Sections[3].Title; got != sectionImprovements { + t.Fatalf("fourth section = %q, want improvements", got) + } +} + +func TestBuildFiltersMergeAndAutosquashCommits(t *testing.T) { + draft, err := Build([]gitutil.Commit{ + testCommit("1111111111111111111111111111111111111111", "Merge pull request #1 from branch"), + testCommit("2222222222222222222222222222222222222222", "fixup! feat: add changelog command"), + testCommit("3333333333333333333333333333333333333333", "squash! fix: patch range"), + testCommit("4444444444444444444444444444444444444444", "feat: add changelog command"), + }, Options{}) + if err != nil { + t.Fatalf("Build returned error: %v", err) + } + + if len(draft.Sections) != 1 || len(draft.Sections[0].Entries) != 1 { + t.Fatalf("expected only one entry, got %+v", draft.Sections) + } + if got := draft.Sections[0].Entries[0].Text; got != "Add changelog command" { + t.Fatalf("unexpected remaining entry: %q", got) + } +} + +func TestBuildVersionDateHeadingAndShortSHA(t *testing.T) { + draft, err := Build([]gitutil.Commit{ + testCommit("abcdef1234567890abcdef1234567890abcdef12", "feat: add changelog command."), + }, Options{ + Version: "v0.2.0", + Date: time.Date(2026, 5, 9, 0, 0, 0, 0, time.UTC), + }) + if err != nil { + t.Fatalf("Build returned error: %v", err) + } + + expected := `## [v0.2.0] - 2026-05-09 + +### Features +- Add changelog command (abcdef1) +` + if got := draft.Markdown(); got != expected { + t.Fatalf("unexpected markdown:\n%s", got) + } +} + +func TestBuildUnreleasedHeading(t *testing.T) { + draft, err := Build([]gitutil.Commit{ + testCommit("abcdef1234567890abcdef1234567890abcdef12", "fix: handle changelog range"), + }, Options{}) + if err != nil { + t.Fatalf("Build returned error: %v", err) + } + + if !strings.HasPrefix(draft.Markdown(), "## Unreleased\n\n") { + t.Fatalf("expected unreleased heading, got:\n%s", draft.Markdown()) + } +} + +func TestBuildReturnsNoRelevantCommitError(t *testing.T) { + _, err := Build([]gitutil.Commit{ + testCommit("1111111111111111111111111111111111111111", "fixup! feat: add changelog command"), + testCommit("2222222222222222222222222222222222222222", "squash! fix: patch range"), + }, Options{}) + if !errors.Is(err, ErrNoRelevantCommits) { + t.Fatalf("expected ErrNoRelevantCommits, got %v", err) + } +} + +func TestCleanSubjectRemovesTaskRefs(t *testing.T) { + draft, err := Build([]gitutil.Commit{ + testCommit("abcdef1234567890abcdef1234567890abcdef12", "[td-a7ff5e] task: feat: add rich text input (td-a7ff5e)."), + }, Options{}) + if err != nil { + t.Fatalf("Build returned error: %v", err) + } + + if got := draft.Sections[0].Entries[0].Text; got != "Add rich text input" { + t.Fatalf("unexpected cleaned entry: %q", got) + } +} diff --git a/internal/git/git.go b/internal/git/git.go index f42c3d39..e89a8e2b 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -4,10 +4,23 @@ package git import ( "bytes" + "errors" "fmt" "os/exec" + "regexp" + "sort" "strconv" "strings" + "time" +) + +var ( + // ErrNotRepository indicates the target directory is not inside a git repo. + ErrNotRepository = errors.New("not a git repository") + // ErrNoSemverTag indicates no reachable semver tag was found. + ErrNoSemverTag = errors.New("no reachable semver tag found") + + semverTagPattern = regexp.MustCompile(`^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$`) ) // State represents the current git state @@ -20,6 +33,15 @@ type State struct { DirtyFiles int } +// Commit captures the git commit fields needed for changelog generation. +type Commit struct { + SHA string + ShortSHA string + Subject string + Body string + Date time.Time +} + // GetState returns the current git state func GetState() (*State, error) { state := &State{} @@ -179,21 +201,172 @@ func GetDiffStatsSince(sha string) (*DiffStats, error) { // IsRepo checks if we're in a git repository func IsRepo() bool { - _, err := runGit("rev-parse", "--git-dir") - return err == nil + return IsRepoAt("") } // GetRootDir returns the git repository root directory func GetRootDir() (string, error) { - output, err := runGit("rev-parse", "--show-toplevel") + return GetRootDirFrom("") +} + +// IsRepoAt checks whether dir is inside a git repository. +func IsRepoAt(dir string) bool { + _, err := runGitInDir(dir, "rev-parse", "--git-dir") + return err == nil +} + +// GetRootDirFrom returns the git repository root for dir. +func GetRootDirFrom(dir string) (string, error) { + output, err := runGitInDir(dir, "rev-parse", "--show-toplevel") + if err != nil { + return "", ErrNotRepository + } + return strings.TrimSpace(output), nil +} + +// ResolveRef resolves ref to a commit SHA and rejects empty or invalid refs. +func ResolveRef(dir, ref string) (string, error) { + root, err := GetRootDirFrom(dir) if err != nil { return "", err } + + ref = strings.TrimSpace(ref) + if ref == "" { + return "", fmt.Errorf("git ref is required") + } + + output, err := runGitInDir(root, "rev-parse", "--verify", "--end-of-options", ref+"^{commit}") + if err != nil { + return "", fmt.Errorf("invalid git ref %q: %w", ref, err) + } return strings.TrimSpace(output), nil } +// NearestReachableSemverTag returns the nearest semver tag reachable from ref. +func NearestReachableSemverTag(dir, ref string) (string, error) { + root, err := GetRootDirFrom(dir) + if err != nil { + return "", err + } + + ref = strings.TrimSpace(ref) + if ref == "" { + ref = "HEAD" + } + + targetSHA, err := ResolveRef(root, ref) + if err != nil { + return "", err + } + + output, err := runGitInDir(root, "tag", "--merged", targetSHA, "--sort=-version:refname") + if err != nil { + return "", err + } + + var tags []string + for _, line := range strings.Split(output, "\n") { + tag := strings.TrimSpace(line) + if tag != "" && semverTagPattern.MatchString(tag) { + tags = append(tags, tag) + } + } + if len(tags) == 0 { + return "", ErrNoSemverTag + } + + type candidate struct { + tag string + distance int + } + candidates := make([]candidate, 0, len(tags)) + for _, tag := range tags { + countOutput, err := runGitInDir(root, "rev-list", "--count", tag+".."+targetSHA) + if err != nil { + return "", err + } + count, err := strconv.Atoi(strings.TrimSpace(countOutput)) + if err != nil { + return "", err + } + candidates = append(candidates, candidate{tag: tag, distance: count}) + } + + sort.SliceStable(candidates, func(i, j int) bool { + return candidates[i].distance < candidates[j].distance + }) + return candidates[0].tag, nil +} + +// ListCommitsInRange returns commits in oldest-first order for fromRef..toRef. +func ListCommitsInRange(dir, fromRef, toRef string) ([]Commit, error) { + root, err := GetRootDirFrom(dir) + if err != nil { + return nil, err + } + + fromRef = strings.TrimSpace(fromRef) + if fromRef == "" { + return nil, fmt.Errorf("start git ref is required") + } + toRef = strings.TrimSpace(toRef) + if toRef == "" { + return nil, fmt.Errorf("end git ref is required") + } + + fromSHA, err := ResolveRef(root, fromRef) + if err != nil { + return nil, err + } + toSHA, err := ResolveRef(root, toRef) + if err != nil { + return nil, err + } + + output, err := runGitInDir(root, "log", "-z", "--reverse", "--format=%H%x00%h%x00%aI%x00%s%x00%b", fromSHA+".."+toSHA) + if err != nil { + return nil, err + } + if output == "" { + return []Commit{}, nil + } + + fields := strings.Split(output, "\x00") + if len(fields) > 0 && fields[len(fields)-1] == "" { + fields = fields[:len(fields)-1] + } + if len(fields)%5 != 0 { + return nil, fmt.Errorf("unexpected git log output for range %s..%s", fromRef, toRef) + } + + commits := make([]Commit, 0, len(fields)/5) + for i := 0; i < len(fields); i += 5 { + date, err := time.Parse(time.RFC3339, strings.TrimSpace(fields[i+2])) + if err != nil { + return nil, fmt.Errorf("parse commit date for %s: %w", fields[i], err) + } + commits = append(commits, Commit{ + SHA: strings.TrimSpace(fields[i]), + ShortSHA: strings.TrimSpace(fields[i+1]), + Date: date, + Subject: strings.TrimSpace(fields[i+3]), + Body: strings.TrimSpace(fields[i+4]), + }) + } + + return commits, nil +} + func runGit(args ...string) (string, error) { + return runGitInDir("", args...) +} + +func runGitInDir(dir string, args ...string) (string, error) { cmd := exec.Command("git", args...) + if dir != "" { + cmd.Dir = dir + } var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr diff --git a/internal/git/git_test.go b/internal/git/git_test.go index 183eea79..77cda60e 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -1,9 +1,11 @@ package git import ( + "errors" "os" "os/exec" "path/filepath" + "strings" "testing" ) @@ -45,6 +47,49 @@ func runCmd(dir string, name string, args ...string) error { return cmd.Run() } +func runCmdOutput(dir string, name string, args ...string) (string, error) { + cmd := exec.Command(name, args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + return strings.TrimSpace(string(out)), err +} + +func commitGitFile(t *testing.T, dir, path, content, subject string, body ...string) string { + t.Helper() + + fullPath := filepath.Join(dir, path) + if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { + t.Fatalf("Failed to create parent dir: %v", err) + } + if err := os.WriteFile(fullPath, []byte(content), 0644); err != nil { + t.Fatalf("Failed to write file: %v", err) + } + if err := runCmd(dir, "git", "add", path); err != nil { + t.Fatalf("Failed to git add %s: %v", path, err) + } + + args := []string{"commit", "-m", subject} + for _, paragraph := range body { + args = append(args, "-m", paragraph) + } + if out, err := runCmdOutput(dir, "git", args...); err != nil { + t.Fatalf("Failed to commit %q: %v\n%s", subject, err, out) + } + + sha, err := runCmdOutput(dir, "git", "rev-parse", "HEAD") + if err != nil { + t.Fatalf("Failed to resolve HEAD: %v", err) + } + return sha +} + +func tagHead(t *testing.T, dir, tag string) { + t.Helper() + if out, err := runCmdOutput(dir, "git", "tag", "-a", tag, "-m", "Release "+tag); err != nil { + t.Fatalf("Failed to tag HEAD as %s: %v\n%s", tag, err, out) + } +} + // TestParseStatOutputBasic tests parsing git diff --stat output func TestParseStatOutputBasic(t *testing.T) { output := ` file1.go | 10 ++++------ @@ -469,3 +514,171 @@ func TestStateBranchName(t *testing.T) { t.Logf("Branch name is %q (expected main/master/HEAD)", state.Branch) } } + +func TestGetRootDirFromReturnsErrNotRepository(t *testing.T) { + _, err := GetRootDirFrom(t.TempDir()) + if !errors.Is(err, ErrNotRepository) { + t.Fatalf("expected ErrNotRepository, got %v", err) + } +} + +func TestNearestReachableSemverTagReturnsLatestReachableTag(t *testing.T) { + dir := initTestRepo(t) + + tagHead(t, dir, "v0.1.0") + commitGitFile(t, dir, "feature.txt", "feature\n", "feat: add feature") + tagHead(t, dir, "v0.2.0") + commitGitFile(t, dir, "fix.txt", "fix\n", "fix: patch release") + if out, err := runCmdOutput(dir, "git", "tag", "-a", "release-candidate", "-m", "not semver"); err != nil { + t.Fatalf("Failed to tag non-semver: %v\n%s", err, out) + } + + tag, err := NearestReachableSemverTag(dir, "HEAD") + if err != nil { + t.Fatalf("NearestReachableSemverTag failed: %v", err) + } + if tag != "v0.2.0" { + t.Fatalf("expected v0.2.0, got %q", tag) + } +} + +func TestNearestReachableSemverTagReturnsErrNoSemverTag(t *testing.T) { + dir := initTestRepo(t) + + _, err := NearestReachableSemverTag(dir, "HEAD") + if !errors.Is(err, ErrNoSemverTag) { + t.Fatalf("expected ErrNoSemverTag, got %v", err) + } +} + +func TestNearestReachableSemverTagExcludesUnreachableSideBranchTags(t *testing.T) { + dir := initTestRepo(t) + + tagHead(t, dir, "v0.1.0") + mainHead, err := runCmdOutput(dir, "git", "rev-parse", "HEAD") + if err != nil { + t.Fatalf("Failed to resolve main HEAD: %v", err) + } + if out, err := runCmdOutput(dir, "git", "checkout", "-b", "side"); err != nil { + t.Fatalf("Failed to create side branch: %v\n%s", err, out) + } + commitGitFile(t, dir, "side.txt", "side\n", "feat: side-only feature") + tagHead(t, dir, "v9.9.9") + if out, err := runCmdOutput(dir, "git", "checkout", "-B", "main", mainHead); err != nil { + t.Fatalf("Failed to return to main: %v\n%s", err, out) + } + commitGitFile(t, dir, "main.txt", "main\n", "fix: main patch") + + tag, err := NearestReachableSemverTag(dir, "HEAD") + if err != nil { + t.Fatalf("NearestReachableSemverTag failed: %v", err) + } + if tag != "v0.1.0" { + t.Fatalf("expected v0.1.0, got %q", tag) + } +} + +func TestResolveRefRejectsEmptyAndInvalidRefs(t *testing.T) { + dir := initTestRepo(t) + + if _, err := ResolveRef(dir, " "); err == nil || !strings.Contains(err.Error(), "git ref is required") { + t.Fatalf("expected empty ref error, got %v", err) + } + if _, err := ResolveRef(dir, "missing-ref"); err == nil || !strings.Contains(err.Error(), "invalid git ref") { + t.Fatalf("expected invalid ref error, got %v", err) + } +} + +func TestListCommitsInRangeReturnsOldestFirst(t *testing.T) { + dir := initTestRepo(t) + base, err := runCmdOutput(dir, "git", "rev-parse", "HEAD") + if err != nil { + t.Fatalf("Failed to resolve base: %v", err) + } + + commitGitFile(t, dir, "one.txt", "one\n", "feat: add one") + commitGitFile(t, dir, "two.txt", "two\n", "fix: add two") + + commits, err := ListCommitsInRange(dir, base, "HEAD") + if err != nil { + t.Fatalf("ListCommitsInRange failed: %v", err) + } + if len(commits) != 2 { + t.Fatalf("expected 2 commits, got %d", len(commits)) + } + if commits[0].Subject != "feat: add one" || commits[1].Subject != "fix: add two" { + t.Fatalf("commits not oldest-first: %+v", commits) + } +} + +func TestListCommitsInRangeReturnsEmptyRange(t *testing.T) { + dir := initTestRepo(t) + + commits, err := ListCommitsInRange(dir, "HEAD", "HEAD") + if err != nil { + t.Fatalf("ListCommitsInRange failed: %v", err) + } + if len(commits) != 0 { + t.Fatalf("expected empty range, got %+v", commits) + } +} + +func TestListCommitsInRangeParsesSubjectBodyAndDate(t *testing.T) { + dir := initTestRepo(t) + base, err := runCmdOutput(dir, "git", "rev-parse", "HEAD") + if err != nil { + t.Fatalf("Failed to resolve base: %v", err) + } + + sha := commitGitFile(t, dir, "feature.txt", "feature\n", "feat(parser): add parsing", "Body line one\nBody line two") + + commits, err := ListCommitsInRange(dir, base, "HEAD") + if err != nil { + t.Fatalf("ListCommitsInRange failed: %v", err) + } + if len(commits) != 1 { + t.Fatalf("expected 1 commit, got %d", len(commits)) + } + commit := commits[0] + if commit.SHA != sha { + t.Fatalf("SHA = %q, want %q", commit.SHA, sha) + } + if commit.ShortSHA != sha[:7] { + t.Fatalf("ShortSHA = %q, want %q", commit.ShortSHA, sha[:7]) + } + if commit.Subject != "feat(parser): add parsing" { + t.Fatalf("Subject = %q", commit.Subject) + } + if !strings.Contains(commit.Body, "Body line one\nBody line two") { + t.Fatalf("Body not parsed correctly: %q", commit.Body) + } + if commit.Date.IsZero() { + t.Fatal("Date should not be zero") + } +} + +func TestListCommitsInRangeUsesNULDelimiters(t *testing.T) { + dir := initTestRepo(t) + base, err := runCmdOutput(dir, "git", "rev-parse", "HEAD") + if err != nil { + t.Fatalf("Failed to resolve base: %v", err) + } + + subject := "feat: add parser | with pipes and %% markers" + body := "Body with newlines\n---\nand punctuation | that should stay intact" + commitGitFile(t, dir, "feature.txt", "feature\n", subject, body) + + commits, err := ListCommitsInRange(dir, base, "HEAD") + if err != nil { + t.Fatalf("ListCommitsInRange failed: %v", err) + } + if len(commits) != 1 { + t.Fatalf("expected 1 commit, got %d", len(commits)) + } + if commits[0].Subject != subject { + t.Fatalf("Subject = %q, want %q", commits[0].Subject, subject) + } + if commits[0].Body != body { + t.Fatalf("Body = %q, want %q", commits[0].Body, body) + } +} diff --git a/website/docs/command-reference.md b/website/docs/command-reference.md index b81278c3..d1e0c728 100644 --- a/website/docs/command-reference.md +++ b/website/docs/command-reference.md @@ -157,6 +157,7 @@ cat docs/acceptance.md | td update td-a1b2 --append --acceptance-file - | `td monitor` | Live TUI dashboard | | `td undo` | Undo last action | | `td version` | Show version | +| `td changelog [flags]` | Generate a paste-ready changelog entry from git commits. Flags: `--from`, `--to`, `--version`, `--date` | | `td export` | Export database | | `td import` | Import issues | | `td stats [subcommand]` | Usage statistics |