Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
74 changes: 74 additions & 0 deletions cmd/changelog.go
Original file line number Diff line number Diff line change
@@ -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)
}
168 changes: 168 additions & 0 deletions cmd/changelog_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
25 changes: 19 additions & 6 deletions docs/guides/releasing-new-version.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"

Expand All @@ -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)
Expand Down
Loading
Loading