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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,9 @@ make fmt
Releases are automated via GoReleaser. Pushing a version tag triggers GitHub Actions to build binaries and update the Homebrew formula.

```bash
# Preview release notes from commits since the nearest semver tag
td release-notes --version v0.2.0 --date 2026-04-29

# Create and push an annotated tag (triggers automated release)
make release VERSION=v0.2.0

Expand Down Expand Up @@ -422,6 +425,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` |
| Draft release notes | `td release-notes --version vX.Y.Z --date YYYY-MM-DD` |

### Boards

Expand Down
98 changes: 98 additions & 0 deletions cmd/release_notes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package cmd

import (
"fmt"
"os"
"strings"
"time"

tdgit "github.com/marcus/td/internal/git"
"github.com/marcus/td/internal/releasenotes"
"github.com/spf13/cobra"
)

var releaseNotesCmd = &cobra.Command{
Use: "release-notes",
Short: "Draft markdown release notes from git commits",
GroupID: "system",
Args: cobra.NoArgs,
Example: ` td release-notes
td release-notes --from v0.4.0 --to HEAD
td release-notes --version v0.5.0 --date 2026-04-29`,
RunE: func(cmd *cobra.Command, args []string) error {
from, _ := cmd.Flags().GetString("from")
to, _ := cmd.Flags().GetString("to")
version, _ := cmd.Flags().GetString("version")
date, _ := cmd.Flags().GetString("date")

if strings.TrimSpace(to) == "" {
return fmt.Errorf("--to cannot be empty")
}
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")
}
}

startDir, err := releaseNotesStartDir()
if err != nil {
return err
}
repoDir, err := tdgit.GetRootDirInDir(startDir)
if err != nil {
return fmt.Errorf("release-notes must run inside a git repository: %w", err)
}

if _, err := tdgit.ResolveRef(repoDir, to); err != nil {
return err
}

if strings.TrimSpace(from) == "" {
tag, err := tdgit.NearestSemverTag(repoDir, to)
if err != nil {
return fmt.Errorf("%w; pass --from <ref> to choose a release-note range explicitly", err)
}
from = tag
} else if _, err := tdgit.ResolveRef(repoDir, from); err != nil {
return err
}

commits, err := tdgit.ListCommits(repoDir, from, to)
if err != nil {
return fmt.Errorf("failed to list commits for %s..%s: %w", from, to, err)
}
if len(commits) == 0 {
return fmt.Errorf("no commits found in %s..%s", from, to)
}

if err := releasenotes.Render(cmd.OutOrStdout(), commits, releasenotes.Options{
Version: version,
Date: date,
}); err != nil {
return fmt.Errorf("%w in %s..%s", err, from, to)
}

return nil
},
}

func init() {
releaseNotesCmd.Flags().String("from", "", "start ref for the release-note range (defaults to nearest reachable semver tag)")
releaseNotesCmd.Flags().String("to", "HEAD", "end ref for the release-note range")
releaseNotesCmd.Flags().String("version", "", "version heading to render, such as v0.5.0")
releaseNotesCmd.Flags().String("date", "", "release date heading in YYYY-MM-DD format (requires --version)")
rootCmd.AddCommand(releaseNotesCmd)
}

func releaseNotesStartDir() (string, error) {
if workDirFlag != "" {
return normalizeWorkDir(workDirFlag), nil
}
if envDir := os.Getenv("TD_WORK_DIR"); envDir != "" {
return normalizeWorkDir(envDir), nil
}
return os.Getwd()
}
225 changes: 225 additions & 0 deletions cmd/release_notes_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
package cmd

import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)

func TestReleaseNotesDefaultRangeSelection(t *testing.T) {
repo := initReleaseNotesRepo(t)
gitCmd(t, repo, "tag", "v1.0.0")
commitReleaseNoteFile(t, repo, "feature.txt", "feat: add release notes")

out, err := runReleaseNotesCommand(t, repo, nil)
if err != nil {
t.Fatalf("release-notes failed: %v", err)
}
if !strings.Contains(out, "### Features\n- Add release notes") {
t.Fatalf("expected feature markdown, got:\n%s", out)
}
}

func TestReleaseNotesExplicitRange(t *testing.T) {
repo := initReleaseNotesRepo(t)
gitCmd(t, repo, "tag", "v1.0.0")
commitReleaseNoteFile(t, repo, "feature.txt", "feat: add release notes")
gitCmd(t, repo, "tag", "v1.1.0")
commitReleaseNoteFile(t, repo, "fix.txt", "fix: repair later bug")

out, err := runReleaseNotesCommand(t, repo, map[string]string{
"from": "v1.0.0",
"to": "v1.1.0",
})
if err != nil {
t.Fatalf("release-notes failed: %v", err)
}
if !strings.Contains(out, "Add release notes") {
t.Fatalf("expected first range commit, got:\n%s", out)
}
if strings.Contains(out, "Repair later bug") {
t.Fatalf("did not expect commit after --to ref, got:\n%s", out)
}
}

func TestReleaseNotesValidationFailures(t *testing.T) {
repo := initReleaseNotesRepo(t)
commitReleaseNoteFile(t, repo, "feature.txt", "feat: add release notes")

tests := []struct {
name string
flags map[string]string
wantErr string
}{
{
name: "date requires version",
flags: map[string]string{"date": "2026-04-29"},
wantErr: "--date requires --version",
},
{
name: "date format",
flags: map[string]string{"version": "v1.0.0", "date": "04-29-2026"},
wantErr: "--date must use YYYY-MM-DD format",
},
{
name: "missing default tag",
flags: nil,
wantErr: "pass --from <ref>",
},
{
name: "invalid from ref",
flags: map[string]string{"from": "missing-ref"},
wantErr: "invalid git ref",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := runReleaseNotesCommand(t, repo, tt.flags)
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
}
})
}
}

func TestReleaseNotesStdoutMarkdownWithHeading(t *testing.T) {
repo := initReleaseNotesRepo(t)
gitCmd(t, repo, "tag", "v1.0.0")
commitReleaseNoteFile(t, repo, "fix.txt", "fix: repair output")

out, err := runReleaseNotesCommand(t, repo, map[string]string{
"version": "v1.1.0",
"date": "2026-04-29",
})
if err != nil {
t.Fatalf("release-notes failed: %v", err)
}

if !strings.HasPrefix(out, "## v1.1.0 - 2026-04-29\n\n") {
t.Fatalf("expected version/date heading, got:\n%s", out)
}
if !strings.Contains(out, "### Bug Fixes\n- Repair output") {
t.Fatalf("expected bug fix section, got:\n%s", out)
}
}

func TestReleaseNotesNoRelevantCommits(t *testing.T) {
repo := initReleaseNotesRepo(t)
gitCmd(t, repo, "tag", "v1.0.0")
commitReleaseNoteFile(t, repo, "merge.txt", "fixup! feat: add hidden note")

_, err := runReleaseNotesCommand(t, repo, nil)
if err == nil {
t.Fatal("expected no relevant commits error")
}
if !strings.Contains(err.Error(), "no release-note-worthy commits") {
t.Fatalf("unexpected error: %v", err)
}
}

func TestReleaseNotesUsesActiveGitWorktree(t *testing.T) {
mainRepo := initReleaseNotesRepo(t)
if err := os.Mkdir(filepath.Join(mainRepo, ".todos"), 0755); err != nil {
t.Fatalf("mkdir .todos: %v", err)
}
gitCmd(t, mainRepo, "tag", "v1.0.0")

worktree := filepath.Join(t.TempDir(), "release-worktree")
gitCmd(t, mainRepo, "worktree", "add", "-b", "release-work", worktree, "HEAD")
commitReleaseNoteFile(t, worktree, "feature.txt", "feat: add worktree-only note")

oldBaseDirOverride := baseDirOverride
baseDirOverride = &mainRepo
t.Cleanup(func() {
baseDirOverride = oldBaseDirOverride
})

out, err := runReleaseNotesCommand(t, worktree, nil)
if err != nil {
t.Fatalf("release-notes failed: %v", err)
}
if !strings.Contains(out, "Add worktree-only note") {
t.Fatalf("expected worktree branch commit, got:\n%s", out)
}
}

func runReleaseNotesCommand(t *testing.T, repo string, flags map[string]string) (string, error) {
t.Helper()
saveAndRestoreCommandFlags(t, releaseNotesCmd, "from", "to", "version", "date")

oldWorkDirFlag := workDirFlag
workDirFlag = ""
t.Cleanup(func() {
workDirFlag = oldWorkDirFlag
})
t.Setenv("TD_WORK_DIR", "")

origDir, err := os.Getwd()
if err != nil {
t.Fatalf("get cwd: %v", err)
}
if err := os.Chdir(repo); err != nil {
t.Fatalf("chdir to repo: %v", err)
}
t.Cleanup(func() {
_ = os.Chdir(origDir)
})

var out bytes.Buffer
releaseNotesCmd.SetOut(&out)
t.Cleanup(func() {
releaseNotesCmd.SetOut(nil)
})

for name, value := range flags {
if err := releaseNotesCmd.Flags().Set(name, value); err != nil {
t.Fatalf("set flag %s=%s: %v", name, value, err)
}
}

err = releaseNotesCmd.RunE(releaseNotesCmd, nil)
return out.String(), err
}

func initReleaseNotesRepo(t *testing.T) string {
t.Helper()
dir := t.TempDir()
gitCmd(t, dir, "init")
gitCmd(t, dir, "config", "user.email", "test@example.com")
gitCmd(t, dir, "config", "user.name", "Test User")
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("# test\n"), 0644); err != nil {
t.Fatalf("write README: %v", err)
}
gitCmd(t, dir, "add", ".")
gitCmd(t, dir, "commit", "-m", "Initial commit")
return dir
}

func commitReleaseNoteFile(t *testing.T, repo, name, subject string) {
t.Helper()
path := filepath.Join(repo, name)
content := fmt.Sprintf("%s\n", subject)
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("write %s: %v", name, err)
}
gitCmd(t, repo, "add", ".")
gitCmd(t, repo, "commit", "-m", subject)
}

func gitCmd(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %s failed: %v\n%s", strings.Join(args, " "), err, output)
}
}
Loading
Loading