diff --git a/README.md b/README.md index 684416ad..9b22b182 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/ # Markdown release-note rendering 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 | `td changelog --from v0.4.0 --to HEAD` | ### Boards diff --git a/cmd/changelog.go b/cmd/changelog.go new file mode 100644 index 00000000..693fe122 --- /dev/null +++ b/cmd/changelog.go @@ -0,0 +1,74 @@ +package cmd + +import ( + "fmt" + "time" + + "github.com/marcus/td/internal/changelog" + "github.com/marcus/td/internal/git" + "github.com/spf13/cobra" +) + +func newChangelogCmd() *cobra.Command { + var fromRef string + var toRef string + var version string + var date string + + cmd := &cobra.Command{ + Use: "changelog", + Short: "Generate markdown changelog from git commits", + GroupID: "system", + RunE: func(cmd *cobra.Command, args []string) error { + if date != "" { + if version == "" { + return fmt.Errorf("--date requires --version") + } + if _, err := time.Parse("2006-01-02", date); err != nil { + return fmt.Errorf("--date must use YYYY-MM-DD format") + } + } + + if _, err := git.ResolveRef(toRef); err != nil { + return err + } + if fromRef == "" { + tag, err := git.NearestReachableSemverTag(toRef) + if err != nil { + return fmt.Errorf("cannot determine default --from: %w; pass --from explicitly", err) + } + fromRef = tag + } + if _, err := git.ResolveRef(fromRef); err != nil { + return err + } + + commits, err := git.ListCommits(fromRef, toRef) + if err != nil { + return err + } + markdown, err := changelog.Render(commits, changelog.Options{ + Version: version, + Date: date, + }) + if err != nil { + return fmt.Errorf("%w in range %s..%s", err, fromRef, toRef) + } + fmt.Fprint(cmd.OutOrStdout(), markdown) + return nil + }, + } + + cmd.Flags().StringVar(&fromRef, "from", "", "Start ref (defaults to nearest reachable semver tag)") + cmd.Flags().StringVar(&toRef, "to", "HEAD", "End ref") + cmd.Flags().StringVar(&version, "version", "", "Release version heading") + cmd.Flags().StringVar(&date, "date", "", "Release date in YYYY-MM-DD format (requires --version)") + + return cmd +} + +var changelogCmd = newChangelogCmd() + +func init() { + rootCmd.AddCommand(changelogCmd) +} diff --git a/cmd/changelog_test.go b/cmd/changelog_test.go new file mode 100644 index 00000000..57bd4501 --- /dev/null +++ b/cmd/changelog_test.go @@ -0,0 +1,168 @@ +package cmd + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func initChangelogTestRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + runGitCmd(t, dir, "init") + runGitCmd(t, dir, "config", "user.email", "test@test.com") + runGitCmd(t, dir, "config", "user.name", "Test User") + changelogCommit(t, dir, "README.md", "# Test\n", "chore: initial") + return dir +} + +func runGitCmd(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\n%s", strings.Join(args, " "), err, string(out)) + } + return strings.TrimSpace(string(out)) +} + +func changelogCommit(t *testing.T, dir, file, content, subject string) string { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, file), []byte(content), 0644); err != nil { + t.Fatalf("write file: %v", err) + } + runGitCmd(t, dir, "add", ".") + runGitCmd(t, dir, "commit", "-m", subject) + return runGitCmd(t, dir, "rev-parse", "HEAD") +} + +func runChangelogCmd(t *testing.T, dir string, args ...string) (string, error) { + t.Helper() + origDir, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir: %v", err) + } + defer func() { _ = os.Chdir(origDir) }() + + cmd := newChangelogCmd() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs(args) + err = cmd.Execute() + return out.String(), err +} + +func TestChangelogDefaultLatestTagRange(t *testing.T) { + dir := initChangelogTestRepo(t) + runGitCmd(t, dir, "tag", "v1.0.0") + changelogCommit(t, dir, "one.txt", "one", "feat: first feature") + runGitCmd(t, dir, "tag", "v1.1.0") + changelogCommit(t, dir, "two.txt", "two", "fix: second fix") + + out, err := runChangelogCmd(t, dir) + if err != nil { + t.Fatalf("changelog failed: %v", err) + } + if strings.Contains(out, "First feature") { + t.Fatalf("default range included commit before latest tag:\n%s", out) + } + if !strings.Contains(out, "Second fix") { + t.Fatalf("default range missed commit after latest tag:\n%s", out) + } +} + +func TestChangelogExplicitFromToRange(t *testing.T) { + dir := initChangelogTestRepo(t) + runGitCmd(t, dir, "tag", "v1.0.0") + changelogCommit(t, dir, "one.txt", "one", "feat: first feature") + runGitCmd(t, dir, "tag", "v1.1.0") + changelogCommit(t, dir, "two.txt", "two", "fix: second fix") + + out, err := runChangelogCmd(t, dir, "--from", "v1.0.0", "--to", "v1.1.0") + if err != nil { + t.Fatalf("changelog failed: %v", err) + } + if !strings.Contains(out, "First feature") || strings.Contains(out, "Second fix") { + t.Fatalf("explicit range output wrong:\n%s", out) + } +} + +func TestChangelogNearestReachableTagSelection(t *testing.T) { + dir := initChangelogTestRepo(t) + mainBranch := runGitCmd(t, dir, "branch", "--show-current") + runGitCmd(t, dir, "tag", "v1.0.0") + runGitCmd(t, dir, "checkout", "-b", "side") + changelogCommit(t, dir, "side.txt", "side", "feat: side feature") + runGitCmd(t, dir, "tag", "v2.0.0") + runGitCmd(t, dir, "checkout", mainBranch) + changelogCommit(t, dir, "main.txt", "main", "feat: main feature") + + out, err := runChangelogCmd(t, dir) + if err != nil { + t.Fatalf("changelog failed: %v", err) + } + if !strings.Contains(out, "Main feature") { + t.Fatalf("expected main feature from reachable tag:\n%s", out) + } + if strings.Contains(out, "Side feature") { + t.Fatalf("included commit from unreachable tag:\n%s", out) + } +} + +func TestChangelogVersionDateHeading(t *testing.T) { + dir := initChangelogTestRepo(t) + runGitCmd(t, dir, "tag", "v1.0.0") + changelogCommit(t, dir, "one.txt", "one", "feat: release notes") + + out, err := runChangelogCmd(t, dir, "--version", "v1.1.0", "--date", "2026-05-01") + if err != nil { + t.Fatalf("changelog failed: %v", err) + } + if !strings.Contains(out, "## v1.1.0 - 2026-05-01") { + t.Fatalf("missing version/date heading:\n%s", out) + } +} + +func TestChangelogEmptyRangeError(t *testing.T) { + dir := initChangelogTestRepo(t) + runGitCmd(t, dir, "tag", "v1.0.0") + + _, err := runChangelogCmd(t, dir) + if err == nil || !strings.Contains(err.Error(), "no relevant commits") { + t.Fatalf("expected empty range error, got %v", err) + } +} + +func TestChangelogNoTagGuidance(t *testing.T) { + dir := initChangelogTestRepo(t) + changelogCommit(t, dir, "one.txt", "one", "feat: release notes") + + _, err := runChangelogCmd(t, dir) + if err == nil || !strings.Contains(err.Error(), "pass --from explicitly") { + t.Fatalf("expected no-tag guidance, got %v", err) + } +} + +func TestChangelogDateValidation(t *testing.T) { + dir := initChangelogTestRepo(t) + runGitCmd(t, dir, "tag", "v1.0.0") + changelogCommit(t, dir, "one.txt", "one", "feat: release notes") + + _, err := runChangelogCmd(t, dir, "--date", "05-01-2026") + if err == nil || !strings.Contains(err.Error(), "--date requires --version") { + t.Fatalf("expected date requires version error, got %v", err) + } + + _, err = runChangelogCmd(t, dir, "--version", "v1.1.0", "--date", "05-01-2026") + if err == nil || !strings.Contains(err.Error(), "YYYY-MM-DD") { + t.Fatalf("expected date format error, got %v", err) + } +} diff --git a/docs/guides/releasing-new-version.md b/docs/guides/releasing-new-version.md index ca98e527..ac2d2410 100644 --- a/docs/guides/releasing-new-version.md +++ b/docs/guides/releasing-new-version.md @@ -33,9 +33,21 @@ Check current version: git tag -l | sort -V | tail -1 ``` -### 2. Update CHANGELOG.md +### 2. Preview or Update Release Notes -Add entry at the top of `CHANGELOG.md`: +Generate markdown from commits since the latest reachable semver tag: + +```bash +td changelog --version vX.Y.Z --date YYYY-MM-DD +``` + +To inspect a specific range: + +```bash +td changelog --from vA.B.C --to HEAD --version vX.Y.Z --date YYYY-MM-DD +``` + +If maintaining `CHANGELOG.md`, add the generated entry at the top: ```markdown ## [vX.Y.Z] - YYYY-MM-DD @@ -134,8 +146,9 @@ Replace `X.Y.Z` with actual version: git status go test ./... -# Update changelog -# (Edit CHANGELOG.md, add entry at top) +# Preview/update release notes +td changelog --version vX.Y.Z --date YYYY-MM-DD +# If maintaining CHANGELOG.md, add the generated entry at top git add CHANGELOG.md git commit -m "docs: Update changelog for vX.Y.Z" @@ -154,8 +167,8 @@ brew upgrade td && td version - [ ] Tests pass (`go test ./...`) - [ ] Working tree clean -- [ ] CHANGELOG.md updated with new version entry -- [ ] Changelog committed to git +- [ ] Release notes previewed with `td changelog` +- [ ] CHANGELOG.md updated and committed if maintained - [ ] Version number follows semver - [ ] Commits pushed to main - [ ] Tag created with `-a` (annotated) diff --git a/internal/changelog/changelog.go b/internal/changelog/changelog.go new file mode 100644 index 00000000..0fd187f6 --- /dev/null +++ b/internal/changelog/changelog.go @@ -0,0 +1,142 @@ +// Package changelog renders markdown changelogs from git commits. +package changelog + +import ( + "fmt" + "regexp" + "strings" + "unicode" + "unicode/utf8" + + "github.com/marcus/td/internal/git" +) + +// Options controls changelog markdown rendering. +type Options struct { + Version string + Date string +} + +type section struct { + title string + items []string +} + +var conventionalPattern = regexp.MustCompile(`^([a-zA-Z]+)(?:\([^)]+\))?!?:\s*(.+)$`) + +// Render turns commits into markdown sections. +func Render(commits []git.Commit, opts Options) (string, error) { + sections := []section{ + {title: "Features"}, + {title: "Bug Fixes"}, + {title: "Documentation"}, + {title: "Improvements"}, + } + sectionIndex := map[string]int{ + "feature": 0, + "fix": 1, + "documentation": 2, + "improvement": 3, + } + + for _, commit := range commits { + category, text, ok := classify(commit.Subject) + if !ok { + continue + } + shortSHA := commit.SHA + if len(shortSHA) > 7 { + shortSHA = shortSHA[:7] + } + item := fmt.Sprintf("- %s (%s)", normalizeText(text), shortSHA) + sections[sectionIndex[category]].items = append(sections[sectionIndex[category]].items, item) + } + + hasItems := false + for _, section := range sections { + if len(section.items) > 0 { + hasItems = true + break + } + } + if !hasItems { + return "", fmt.Errorf("no relevant commits found for changelog") + } + + var b strings.Builder + heading := "Unreleased" + if opts.Version != "" { + heading = opts.Version + if opts.Date != "" { + heading += " - " + opts.Date + } + } + fmt.Fprintf(&b, "## %s\n", heading) + + for _, section := range sections { + if len(section.items) == 0 { + continue + } + fmt.Fprintf(&b, "\n### %s\n", section.title) + for _, item := range section.items { + fmt.Fprintf(&b, "%s\n", item) + } + } + + return b.String(), nil +} + +func classify(subject string) (category string, text string, ok bool) { + subject = strings.TrimSpace(subject) + lower := strings.ToLower(subject) + if subject == "" || strings.HasPrefix(lower, "merge ") || + strings.HasPrefix(lower, "fixup!") || strings.HasPrefix(lower, "squash!") { + return "", "", false + } + + if matches := conventionalPattern.FindStringSubmatch(subject); len(matches) == 3 { + switch strings.ToLower(matches[1]) { + case "feat", "feature": + return "feature", matches[2], true + case "fix", "bugfix": + return "fix", matches[2], true + case "doc", "docs": + return "documentation", matches[2], true + default: + return "improvement", matches[2], true + } + } + + words := strings.Fields(lower) + if len(words) == 0 { + return "", "", false + } + first := strings.Trim(words[0], ":,.") + switch first { + case "add", "adds", "added", "create", "creates", "created", "implement", "implements", "implemented", "introduce", "introduces", "introduced", "new", "support", "supports": + return "feature", subject, true + case "fix", "fixes", "fixed", "repair", "repairs", "repaired", "resolve", "resolves", "resolved", "address", "addresses", "addressed", "handle", "handles", "handled", "prevent", "prevents", "prevented", "correct", "corrects", "corrected": + return "fix", subject, true + case "doc", "docs", "document", "documents", "documented", "readme": + return "documentation", subject, true + default: + if strings.Contains(lower, "documentation") || strings.Contains(lower, "readme") { + return "documentation", subject, true + } + return "improvement", subject, true + } +} + +func normalizeText(text string) string { + text = strings.TrimSpace(text) + text = strings.TrimSuffix(text, ".") + if text == "" { + return text + } + + r, size := utf8.DecodeRuneInString(text) + if r == utf8.RuneError { + return text + } + return string(unicode.ToUpper(r)) + text[size:] +} diff --git a/internal/changelog/changelog_test.go b/internal/changelog/changelog_test.go new file mode 100644 index 00000000..4f0a7eb4 --- /dev/null +++ b/internal/changelog/changelog_test.go @@ -0,0 +1,134 @@ +package changelog + +import ( + "strings" + "testing" + "time" + + "github.com/marcus/td/internal/git" +) + +func testCommit(subject string) git.Commit { + return git.Commit{ + SHA: "1234567890abcdef", + Subject: subject, + Date: time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC), + } +} + +func TestRenderConventionalCommitGrouping(t *testing.T) { + commits := []git.Commit{ + testCommit("feat: add changelog command"), + testCommit("fix(cli): handle empty range"), + testCommit("docs: update release guide"), + testCommit("refactor: simplify renderer"), + } + + got, err := Render(commits, Options{}) + if err != nil { + t.Fatalf("Render failed: %v", err) + } + + wantParts := []string{ + "## Unreleased", + "### Features\n- Add changelog command (1234567)", + "### Bug Fixes\n- Handle empty range (1234567)", + "### Documentation\n- Update release guide (1234567)", + "### Improvements\n- Simplify renderer (1234567)", + } + for _, part := range wantParts { + if !strings.Contains(got, part) { + t.Fatalf("expected output to contain %q:\n%s", part, got) + } + } +} + +func TestRenderLooseSubjectGrouping(t *testing.T) { + commits := []git.Commit{ + testCommit("Add release notes preview"), + testCommit("Fix release note typo"), + testCommit("Document release workflow"), + testCommit("Polish command help"), + } + + got, err := Render(commits, Options{}) + if err != nil { + t.Fatalf("Render failed: %v", err) + } + + for _, part := range []string{ + "### Features\n- Add release notes preview", + "### Bug Fixes\n- Fix release note typo", + "### Documentation\n- Document release workflow", + "### Improvements\n- Polish command help", + } { + if !strings.Contains(got, part) { + t.Fatalf("expected output to contain %q:\n%s", part, got) + } + } +} + +func TestRenderFiltersMergeAndAutosquashCommits(t *testing.T) { + commits := []git.Commit{ + testCommit("Merge branch 'main'"), + testCommit("fixup! feat: add changelog"), + testCommit("squash! fix: handle range"), + testCommit("feat: add changelog"), + } + + got, err := Render(commits, Options{}) + if err != nil { + t.Fatalf("Render failed: %v", err) + } + + if strings.Contains(got, "Merge branch") || strings.Contains(got, "fixup!") || strings.Contains(got, "squash!") { + t.Fatalf("filtered commits appeared in output:\n%s", got) + } + if !strings.Contains(got, "- Add changelog") { + t.Fatalf("expected relevant commit in output:\n%s", got) + } +} + +func TestRenderCapitalizationAndNormalization(t *testing.T) { + got, err := Render([]git.Commit{testCommit("feat: add preview.")}, Options{ + Version: "v1.2.3", + Date: "2026-05-01", + }) + if err != nil { + t.Fatalf("Render failed: %v", err) + } + + if !strings.Contains(got, "## v1.2.3 - 2026-05-01") { + t.Fatalf("expected version/date heading:\n%s", got) + } + if !strings.Contains(got, "- Add preview (1234567)") { + t.Fatalf("expected normalized item:\n%s", got) + } +} + +func TestRenderFeatureFixPrecedenceOverDocumentationKeywords(t *testing.T) { + got, err := Render([]git.Commit{ + testCommit("Fix README rendering"), + testCommit("Add documentation export"), + }, Options{}) + if err != nil { + t.Fatalf("Render failed: %v", err) + } + + fixIndex := strings.Index(got, "### Bug Fixes") + featureIndex := strings.Index(got, "### Features") + docIndex := strings.Index(got, "### Documentation") + if fixIndex == -1 || featureIndex == -1 { + t.Fatalf("expected fix and feature sections:\n%s", got) + } + if docIndex != -1 { + t.Fatalf("expected README/docs keyword commits to keep feature/fix categories:\n%s", got) + } +} + +func TestRenderNoRelevantCommits(t *testing.T) { + _, err := Render([]git.Commit{testCommit("Merge branch 'main'")}, Options{}) + if err == nil { + t.Fatal("expected error for no relevant commits") + } +} diff --git a/internal/git/git.go b/internal/git/git.go index f42c3d39..b6e92a59 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -6,8 +6,10 @@ import ( "bytes" "fmt" "os/exec" + "regexp" "strconv" "strings" + "time" ) // State represents the current git state @@ -20,6 +22,14 @@ type State struct { DirtyFiles int } +// Commit represents a git commit in a changelog-friendly form. +type Commit struct { + SHA string + Subject string + Body string + Date time.Time +} + // GetState returns the current git state func GetState() (*State, error) { state := &State{} @@ -192,6 +202,117 @@ func GetRootDir() (string, error) { return strings.TrimSpace(output), nil } +// ResolveRef resolves a revision to its commit SHA. +func ResolveRef(ref string) (string, error) { + ref = strings.TrimSpace(ref) + if ref == "" { + return "", fmt.Errorf("empty git ref") + } + + output, err := runGit("rev-parse", "--verify", 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(ref string) (string, error) { + if _, err := ResolveRef(ref); err != nil { + return "", err + } + + output, err := runGit("tag", "--merged", ref, "--list") + if err != nil { + return "", err + } + + var bestTag string + var bestDistance int + for _, tag := range strings.Split(output, "\n") { + tag = strings.TrimSpace(tag) + if tag == "" || !isSemverTag(tag) { + continue + } + + countOutput, err := runGit("rev-list", "--count", tag+".."+ref) + if err != nil { + continue + } + distance, err := strconv.Atoi(strings.TrimSpace(countOutput)) + if err != nil { + continue + } + if bestTag == "" || distance < bestDistance || distance == bestDistance && tag > bestTag { + bestTag = tag + bestDistance = distance + } + } + + if bestTag == "" { + return "", fmt.Errorf("no reachable semver tag found from %q", ref) + } + return bestTag, nil +} + +var semverTagPattern = regexp.MustCompile(`^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$`) + +func isSemverTag(tag string) bool { + return semverTagPattern.MatchString(tag) +} + +// ListCommits returns commits in from..to order, oldest first. +func ListCommits(from, to string) ([]Commit, error) { + if _, err := ResolveRef(from); err != nil { + return nil, err + } + if _, err := ResolveRef(to); err != nil { + return nil, err + } + + output, err := runGit("log", "--reverse", "--format=%H%x1f%cI%x1f%s%x1f%b%x1e", from+".."+to) + if err != nil { + return nil, err + } + + return parseCommitLog(output) +} + +func parseCommitLog(output string) ([]Commit, error) { + output = strings.TrimSuffix(output, "\x1e\n") + output = strings.TrimSuffix(output, "\x1e") + if strings.TrimSpace(output) == "" { + return nil, nil + } + + records := strings.Split(output, "\x1e\n") + commits := make([]Commit, 0, len(records)) + for _, record := range records { + record = strings.TrimSuffix(record, "\x1e") + record = strings.TrimPrefix(record, "\n") + if record == "" { + continue + } + + parts := strings.SplitN(record, "\x1f", 4) + if len(parts) != 4 { + return nil, fmt.Errorf("unexpected git log record format") + } + date, err := time.Parse(time.RFC3339, strings.TrimSpace(parts[1])) + if err != nil { + return nil, fmt.Errorf("parse commit date: %w", err) + } + commits = append(commits, Commit{ + SHA: strings.TrimSpace(parts[0]), + Date: date, + Subject: strings.TrimSpace(parts[2]), + Body: strings.TrimSpace(parts[3]), + }) + } + + return commits, nil +} + func runGit(args ...string) (string, error) { cmd := exec.Command("git", args...) var stdout, stderr bytes.Buffer diff --git a/internal/git/git_test.go b/internal/git/git_test.go index 183eea79..9ba6f57b 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -4,6 +4,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "testing" ) @@ -45,6 +46,30 @@ func runCmd(dir string, name string, args ...string) error { return cmd.Run() } +func commitFile(t *testing.T, dir, file, content, subject string, body ...string) string { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, file), []byte(content), 0644); err != nil { + t.Fatalf("Failed to write file: %v", err) + } + if err := runCmd(dir, "git", "add", "."); err != nil { + t.Fatalf("Failed to git add: %v", err) + } + args := []string{"commit", "-m", subject} + for _, part := range body { + args = append(args, "-m", part) + } + if err := runCmd(dir, "git", args...); err != nil { + t.Fatalf("Failed to commit: %v", err) + } + cmd := exec.Command("git", "rev-parse", "HEAD") + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + t.Fatalf("Failed to read HEAD: %v", err) + } + return strings.TrimSpace(string(out)) +} + // TestParseStatOutputBasic tests parsing git diff --stat output func TestParseStatOutputBasic(t *testing.T) { output := ` file1.go | 10 ++++------ @@ -469,3 +494,144 @@ func TestStateBranchName(t *testing.T) { t.Logf("Branch name is %q (expected main/master/HEAD)", state.Branch) } } + +func TestNearestReachableSemverTagDetection(t *testing.T) { + dir := initTestRepo(t) + origDir, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatalf("Failed to chdir: %v", err) + } + defer func() { _ = os.Chdir(origDir) }() + + if err := runCmd(dir, "git", "tag", "release-candidate"); err != nil { + t.Fatalf("Failed to tag non-semver: %v", err) + } + if err := runCmd(dir, "git", "tag", "v1.2.3"); err != nil { + t.Fatalf("Failed to tag semver: %v", err) + } + commitFile(t, dir, "feature.txt", "feature", "feat: add feature") + + tag, err := NearestReachableSemverTag("HEAD") + if err != nil { + t.Fatalf("NearestReachableSemverTag failed: %v", err) + } + if tag != "v1.2.3" { + t.Fatalf("got %q, want v1.2.3", tag) + } +} + +func TestNearestReachableSemverTagBehavior(t *testing.T) { + dir := initTestRepo(t) + origDir, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatalf("Failed to chdir: %v", err) + } + defer func() { _ = os.Chdir(origDir) }() + + if err := runCmd(dir, "git", "tag", "v1.0.0"); err != nil { + t.Fatalf("Failed to tag v1.0.0: %v", err) + } + commitFile(t, dir, "one.txt", "one", "feat: one") + if err := runCmd(dir, "git", "tag", "v1.1.0"); err != nil { + t.Fatalf("Failed to tag v1.1.0: %v", err) + } + commitFile(t, dir, "two.txt", "two", "feat: two") + + tag, err := NearestReachableSemverTag("HEAD") + if err != nil { + t.Fatalf("NearestReachableSemverTag failed: %v", err) + } + if tag != "v1.1.0" { + t.Fatalf("got %q, want v1.1.0", tag) + } +} + +func TestListCommitsOrder(t *testing.T) { + dir := initTestRepo(t) + origDir, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatalf("Failed to chdir: %v", err) + } + defer func() { _ = os.Chdir(origDir) }() + + if err := runCmd(dir, "git", "tag", "v1.0.0"); err != nil { + t.Fatalf("Failed to tag: %v", err) + } + commitFile(t, dir, "one.txt", "one", "feat: first") + commitFile(t, dir, "two.txt", "two", "fix: second") + + commits, err := ListCommits("v1.0.0", "HEAD") + if err != nil { + t.Fatalf("ListCommits failed: %v", err) + } + if len(commits) != 2 { + t.Fatalf("got %d commits, want 2", len(commits)) + } + if commits[0].Subject != "feat: first" || commits[1].Subject != "fix: second" { + t.Fatalf("commits out of order: %#v", commits) + } +} + +func TestListCommitsParsesSubjectsBodiesAndDates(t *testing.T) { + dir := initTestRepo(t) + origDir, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatalf("Failed to chdir: %v", err) + } + defer func() { _ = os.Chdir(origDir) }() + + if err := runCmd(dir, "git", "tag", "v1.0.0"); err != nil { + t.Fatalf("Failed to tag: %v", err) + } + sha := commitFile(t, dir, "body.txt", "body", "feat: parse body", "Line one\n\nLine two") + + commits, err := ListCommits("v1.0.0", "HEAD") + if err != nil { + t.Fatalf("ListCommits failed: %v", err) + } + if len(commits) != 1 { + t.Fatalf("got %d commits, want 1", len(commits)) + } + if commits[0].SHA != sha { + t.Fatalf("SHA = %q, want %q", commits[0].SHA, sha) + } + if commits[0].Subject != "feat: parse body" { + t.Fatalf("Subject = %q", commits[0].Subject) + } + if commits[0].Body != "Line one\n\nLine two" { + t.Fatalf("Body = %q", commits[0].Body) + } + if commits[0].Date.IsZero() { + t.Fatal("Date should be parsed") + } +} + +func TestResolveRefInvalidRef(t *testing.T) { + dir := initTestRepo(t) + origDir, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatalf("Failed to chdir: %v", err) + } + defer func() { _ = os.Chdir(origDir) }() + + if _, err := ResolveRef("not-a-real-ref"); err == nil { + t.Fatal("expected invalid ref error") + } +} + +func TestListCommitsEmptyRange(t *testing.T) { + dir := initTestRepo(t) + origDir, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatalf("Failed to chdir: %v", err) + } + defer func() { _ = os.Chdir(origDir) }() + + commits, err := ListCommits("HEAD", "HEAD") + if err != nil { + t.Fatalf("ListCommits failed: %v", err) + } + if len(commits) != 0 { + t.Fatalf("got %d commits, want 0", len(commits)) + } +} diff --git a/website/docs/command-reference.md b/website/docs/command-reference.md index b81278c3..eb41f227 100644 --- a/website/docs/command-reference.md +++ b/website/docs/command-reference.md @@ -156,6 +156,7 @@ cat docs/acceptance.md | td update td-a1b2 --append --acceptance-file - | `td init` | Initialize project | | `td monitor` | Live TUI dashboard | | `td undo` | Undo last action | +| `td changelog [flags]` | Generate markdown changelog from git commits. Flags: `--from`, `--to`, `--version`, `--date` | | `td version` | Show version | | `td export` | Export database | | `td import` | Import issues |