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
3 changes: 3 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
# Draft notes from the latest tag before editing CHANGELOG.md
td release-notes --output markdown --include-files --include-stats

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

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

import (
"errors"
"fmt"
"strings"

"github.com/marcus/td/internal/git"
"github.com/marcus/td/internal/output"
"github.com/marcus/td/internal/release"
"github.com/spf13/cobra"
)

var releaseNotesCmd = &cobra.Command{
Use: "release-notes",
Short: "Draft release notes from git history",
Long: "Build a markdown-first release notes draft from commits since the latest tag, or an explicit git revision range.",
GroupID: "system",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
repo := git.NewRepo(getBaseDir())
if !repo.IsRepo() {
err := fmt.Errorf("release notes require a git repository")
output.Error("%v", err)
return err
}

from, _ := cmd.Flags().GetString("from")
to, _ := cmd.Flags().GetString("to")
rangeArg, _ := cmd.Flags().GetString("range")
outputMode, _ := cmd.Flags().GetString("output")
includeFiles, _ := cmd.Flags().GetBool("include-files")
includeStats, _ := cmd.Flags().GetBool("include-stats")
title, _ := cmd.Flags().GetString("title")

revisionRange, err := repo.ResolveRevisionRange(from, to, rangeArg)
if err != nil {
if errors.Is(err, git.ErrNoTagsFound) {
err = fmt.Errorf("no tags found; use --from <rev> --to <rev> or --range <expr> to draft release notes without tags")
}
output.Error("%v", err)
return err
}

commits, err := repo.ListCommits(revisionRange.Expr)
if err != nil {
output.Error("%v", err)
return err
}
if len(commits) == 0 {
err = fmt.Errorf("no commits found in range %s", revisionRange.Expr)
output.Error("%v", err)
return err
}

stats, err := repo.GetDiffStats(revisionRange.Expr)
if err != nil {
output.Error("%v", err)
return err
}

draft := release.Build(commits, stats, release.Options{
Title: title,
RevisionRange: revisionRange.Expr,
From: revisionRange.From,
To: revisionRange.To,
IncludeFiles: includeFiles,
IncludeDiffStats: includeStats,
})
markdown := release.RenderMarkdown(draft, includeFiles, includeStats)

switch strings.ToLower(strings.TrimSpace(outputMode)) {
case "", "terminal":
rendered, renderErr := output.RenderMarkdown(markdown)
if renderErr != nil {
fmt.Print(markdown)
return nil
}
fmt.Println(rendered)
return nil
case "markdown":
fmt.Print(markdown)
return nil
default:
err := fmt.Errorf("invalid output mode %q (expected terminal or markdown)", outputMode)
output.Error("%v", err)
return err
}
},
}

func init() {
releaseNotesCmd.Flags().String("from", "", "starting revision (defaults to latest tag)")
releaseNotesCmd.Flags().String("to", "HEAD", "ending revision")
releaseNotesCmd.Flags().String("range", "", "explicit git revision range (for example v0.9.0..HEAD)")
releaseNotesCmd.Flags().String("output", "terminal", "output mode: terminal or markdown")
releaseNotesCmd.Flags().Bool("include-files", false, "include changed files under each entry")
releaseNotesCmd.Flags().Bool("include-stats", false, "include diff summary stats near the top")
releaseNotesCmd.Flags().String("title", "Release Notes Draft", "document title")

rootCmd.AddCommand(releaseNotesCmd)
}
177 changes: 177 additions & 0 deletions cmd/release_notes_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
package cmd

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

func initReleaseNotesRepo(t *testing.T) string {
t.Helper()

dir := t.TempDir()
for _, args := range [][]string{
{"init"},
{"config", "user.email", "test@test.com"},
{"config", "user.name", "Test User"},
} {
cmd := exec.Command("git", args...)
cmd.Dir = dir
if err := cmd.Run(); err != nil {
t.Fatalf("git %v failed: %v", args, err)
}
}

writeAndCommitReleaseFile(t, dir, "README.md", "# Test\n", "chore: initial commit")
tagCmd := exec.Command("git", "tag", "v0.1.0")
tagCmd.Dir = dir
if err := tagCmd.Run(); err != nil {
t.Fatalf("git tag failed: %v", err)
}

writeAndCommitReleaseFile(t, dir, "cmd/release_notes.go", "package cmd\n", "feat: add release notes command")
writeAndCommitReleaseFile(t, dir, "docs/release.md", "# Release\n", "docs: add release docs")
writeAndCommitReleaseFile(t, dir, "internal/release/release.go", "package release\n", "fix: handle empty release range")

return dir
}

func writeAndCommitReleaseFile(t *testing.T, dir, path, contents, message string) {
t.Helper()

fullPath := filepath.Join(dir, path)
if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
t.Fatalf("mkdir %s: %v", path, err)
}
if err := os.WriteFile(fullPath, []byte(contents), 0644); err != nil {
t.Fatalf("write %s: %v", path, err)
}

addCmd := exec.Command("git", "add", path)
addCmd.Dir = dir
if err := addCmd.Run(); err != nil {
t.Fatalf("git add %s: %v", path, err)
}

commitCmd := exec.Command("git", "commit", "-m", message)
commitCmd.Dir = dir
if err := commitCmd.Run(); err != nil {
t.Fatalf("git commit %q: %v", message, err)
}
}

func runReleaseNotesCommand(t *testing.T, dir string, args ...string) (string, error) {
t.Helper()

saveAndRestoreGlobals(t)
baseDir := dir
baseDirOverride = &baseDir
_ = releaseNotesCmd.Flags().Set("from", "")
_ = releaseNotesCmd.Flags().Set("to", "HEAD")
_ = releaseNotesCmd.Flags().Set("range", "")
_ = releaseNotesCmd.Flags().Set("output", "markdown")
_ = releaseNotesCmd.Flags().Set("include-files", "false")
_ = releaseNotesCmd.Flags().Set("include-stats", "false")
_ = releaseNotesCmd.Flags().Set("title", "Release Notes Draft")

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 := releaseNotesCmd.RunE(releaseNotesCmd, args)

_ = w.Close()
os.Stdout = oldStdout
_, _ = io.Copy(&output, r)

return output.String(), runErr
}

func TestReleaseNotesCommandOutputsMarkdownDraft(t *testing.T) {
dir := initReleaseNotesRepo(t)

output, err := runReleaseNotesCommand(t, dir)
if err != nil {
t.Fatalf("RunE error: %v", err)
}

for _, want := range []string{
"# Release Notes Draft",
"## Features",
"- Add release notes command",
"## Bug Fixes",
"- Handle empty release range",
"## Documentation",
"- Add release docs",
} {
if !strings.Contains(output, want) {
t.Fatalf("output missing %q:\n%s", want, output)
}
}
}

func TestReleaseNotesCommandIncludesFilesAndStats(t *testing.T) {
dir := initReleaseNotesRepo(t)

saveAndRestoreGlobals(t)
baseDir := dir
baseDirOverride = &baseDir
_ = releaseNotesCmd.Flags().Set("include-files", "true")
_ = releaseNotesCmd.Flags().Set("include-stats", "true")
_ = releaseNotesCmd.Flags().Set("output", "markdown")

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 := releaseNotesCmd.RunE(releaseNotesCmd, nil)

_ = w.Close()
os.Stdout = oldStdout
_, _ = io.Copy(&output, r)

if runErr != nil {
t.Fatalf("RunE error: %v", runErr)
}

got := output.String()
if !strings.Contains(got, "Files: `cmd/release_notes.go`") {
t.Fatalf("expected file list in output:\n%s", got)
}
if !strings.Contains(got, "files changed") {
t.Fatalf("expected diff stats in output:\n%s", got)
}
}

func TestReleaseNotesCommandErrorsWithoutTags(t *testing.T) {
dir := t.TempDir()
for _, args := range [][]string{
{"init"},
{"config", "user.email", "test@test.com"},
{"config", "user.name", "Test User"},
} {
cmd := exec.Command("git", args...)
cmd.Dir = dir
if err := cmd.Run(); err != nil {
t.Fatalf("git %v failed: %v", args, err)
}
}
writeAndCommitReleaseFile(t, dir, "README.md", "# Test\n", "feat: initial release prep")

_, err := runReleaseNotesCommand(t, dir)
if err == nil || !strings.Contains(err.Error(), "no tags found") {
t.Fatalf("expected no-tags error, got %v", err)
}
}
14 changes: 14 additions & 0 deletions docs/guides/releasing-new-version.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,16 @@ git tag -l | sort -V | tail -1

### 2. Update CHANGELOG.md

Draft release notes from git history before editing the changelog:

```bash
# Default: latest tag..HEAD rendered in the terminal
td release-notes

# Raw markdown, plus file and diff context for maintainers
td release-notes --output markdown --include-files --include-stats > /tmp/release-notes.md
```

Add entry at the top of `CHANGELOG.md`:

```markdown
Expand Down Expand Up @@ -134,6 +144,9 @@ Replace `X.Y.Z` with actual version:
git status
go test ./...

# Draft release notes from latest tag..HEAD
td release-notes --output markdown --include-files --include-stats

# Update changelog
# (Edit CHANGELOG.md, add entry at top)
git add CHANGELOG.md
Expand All @@ -154,6 +167,7 @@ brew upgrade td && td version

- [ ] Tests pass (`go test ./...`)
- [ ] Working tree clean
- [ ] Draft release notes reviewed (`td release-notes`)
- [ ] CHANGELOG.md updated with new version entry
- [ ] Changelog committed to git
- [ ] Version number follows semver
Expand Down
Loading
Loading