diff --git a/README.md b/README.md index 684416ad..db67f9f2 100644 --- a/README.md +++ b/README.md @@ -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 a changelog entry from the latest tag through HEAD +td release-notes --version v0.2.0 + # Create and push an annotated tag (triggers automated release) make release VERSION=v0.2.0 @@ -419,6 +422,7 @@ Analytics are stored locally and help identify workflow patterns. Disable with ` | Reject | `td reject --reason "..."` | | Link files | `td link ` | | Check file changes | `td files ` | +| Draft release notes | `td release-notes --version v0.2.0` | | Undo last action | `td undo` | | New named session | `td session --new "feature-work"` | | Live dashboard | `td monitor` | diff --git a/cmd/release_notes.go b/cmd/release_notes.go new file mode 100644 index 00000000..337f1dcb --- /dev/null +++ b/cmd/release_notes.go @@ -0,0 +1,84 @@ +package cmd + +import ( + "errors" + "fmt" + "os" + "time" + + gitutil "github.com/marcus/td/internal/git" + "github.com/marcus/td/internal/releasenotes" + "github.com/spf13/cobra" +) + +var releaseNotesNow = time.Now + +var releaseNotesCmd = &cobra.Command{ + Use: "release-notes", + Short: "Draft release notes from committed git history", + GroupID: "system", + Args: cobra.NoArgs, + Long: `Draft release notes from committed git history. + +By default, td uses the latest reachable semver tag through HEAD as the +baseline and prints a markdown block you can review before updating +CHANGELOG.md manually.`, + Example: ` td release-notes + td release-notes --version v0.44.0 + td release-notes --from v0.43.0 --to HEAD --version v0.44.0`, + RunE: func(cmd *cobra.Command, args []string) error { + fromRef, _ := cmd.Flags().GetString("from") + toRef, _ := cmd.Flags().GetString("to") + versionLabel, _ := cmd.Flags().GetString("version") + + draft, err := releasenotes.Generate(releaseNotesRepoDir(), releasenotes.Options{ + FromRef: fromRef, + ToRef: toRef, + Version: versionLabel, + Date: releaseNotesNow(), + }) + if err != nil { + switch { + case errors.Is(err, gitutil.ErrNotRepository): + return fmt.Errorf("release notes require a git repository") + case errors.Is(err, gitutil.ErrNoSemverTag): + return fmt.Errorf("no reachable semver tag found; use --from to set the starting ref") + default: + return err + } + } + + _, err = fmt.Fprint(cmd.OutOrStdout(), draft.Markdown()) + return err + }, +} + +// releaseNotesRepoDir uses the active worktree instead of td's resolved +// database root so repo-scoped git history is drafted from the branch the user +// is actually on. +func releaseNotesRepoDir() 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(releaseNotesCmd) + releaseNotesCmd.Flags().String("from", "", "Start the range at this git ref or tag") + releaseNotesCmd.Flags().String("to", "HEAD", "End the range at this git ref") + releaseNotesCmd.Flags().String("version", "", "Version label for the markdown header") +} diff --git a/cmd/release_notes_test.go b/cmd/release_notes_test.go new file mode 100644 index 00000000..7e076d94 --- /dev/null +++ b/cmd/release_notes_test.go @@ -0,0 +1,361 @@ +package cmd + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func initReleaseNotesRepo(t *testing.T) string { + t.Helper() + + dir := initGitRepo(t) + runGit(t, dir, "config", "user.email", "test@example.com") + runGit(t, dir, "config", "user.name", "Test User") + commitReleaseNotesFile(t, dir, "README.md", "# Test\n", "chore: initial commit") + return dir +} + +func commitReleaseNotesFile(t *testing.T, dir, path, content, message 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", message) + + out, err := execGit(dir, "rev-parse", "HEAD") + if err != nil { + t.Fatalf("git rev-parse failed: %v", err) + } + return strings.TrimSpace(out) +} + +func tagReleaseNotesRepo(t *testing.T, dir, tag string) { + t.Helper() + runGit(t, dir, "tag", "-a", tag, "-m", "Release "+tag) +} + +func execGit(dir string, args ...string) (string, error) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + return string(out), err +} + +func saveAndRestoreReleaseNotesState(t *testing.T, now time.Time) { + t.Helper() + + saveAndRestoreGlobals(t) + origBaseDirOverride := baseDirOverride + origNow := releaseNotesNow + + releaseNotesNow = func() time.Time { return now } + t.Cleanup(func() { + baseDirOverride = origBaseDirOverride + releaseNotesNow = origNow + }) +} + +func runReleaseNotesCommand(t *testing.T, dir string, flagPairs ...string) (string, error) { + t.Helper() + + baseDir := dir + baseDirOverride = &baseDir + + _ = releaseNotesCmd.Flags().Set("from", "") + _ = releaseNotesCmd.Flags().Set("to", "HEAD") + _ = releaseNotesCmd.Flags().Set("version", "") + + for i := 0; i+1 < len(flagPairs); i += 2 { + if err := releaseNotesCmd.Flags().Set(flagPairs[i], flagPairs[i+1]); err != nil { + t.Fatalf("failed to set --%s: %v", flagPairs[i], err) + } + } + + var output bytes.Buffer + releaseNotesCmd.SetOut(&output) + + err := releaseNotesCmd.RunE(releaseNotesCmd, nil) + return output.String(), err +} + +func runReleaseNotesCommandFromCWD(t *testing.T, cwd string, flagPairs ...string) (string, error) { + t.Helper() + + baseDirOverride = nil + + _ = releaseNotesCmd.Flags().Set("from", "") + _ = releaseNotesCmd.Flags().Set("to", "HEAD") + _ = releaseNotesCmd.Flags().Set("version", "") + + for i := 0; i+1 < len(flagPairs); i += 2 { + if err := releaseNotesCmd.Flags().Set(flagPairs[i], flagPairs[i+1]); err != nil { + t.Fatalf("failed to set --%s: %v", flagPairs[i], err) + } + } + + origWD, err := os.Getwd() + if err != nil { + t.Fatalf("getwd failed: %v", err) + } + if err := os.Chdir(cwd); err != nil { + t.Fatalf("chdir failed: %v", err) + } + t.Cleanup(func() { + _ = os.Chdir(origWD) + }) + + var output bytes.Buffer + releaseNotesCmd.SetOut(&output) + + err = releaseNotesCmd.RunE(releaseNotesCmd, nil) + return output.String(), err +} + +func TestReleaseNotesCommandFormatsMarkdownWithOrderedSections(t *testing.T) { + saveAndRestoreReleaseNotesState(t, time.Date(2026, 4, 10, 9, 0, 0, 0, time.UTC)) + dir := initReleaseNotesRepo(t) + + tagReleaseNotesRepo(t, dir, "v0.1.0") + commitReleaseNotesFile(t, dir, "cmd/release_notes.go", "package cmd\n", "feat: add release notes command") + commitReleaseNotesFile(t, dir, "internal/releasenotes/draft.go", "package releasenotes\n", "fix(parser): handle empty release range") + commitReleaseNotesFile(t, dir, "docs/guides/releasing-new-version.md", "# Release\n", "refresh release guide") + commitReleaseNotesFile(t, dir, "cmd/release_notes_test.go", "package cmd\n", "test: cover release notes command") + + output, err := runReleaseNotesCommand( + t, + dir, + "from", "v0.1.0", + "to", "HEAD", + "version", "v0.2.0", + ) + if err != nil { + t.Fatalf("releaseNotesCmd.RunE returned error: %v", err) + } + + expected := `## [v0.2.0] - 2026-04-10 + +### Features +- add release notes command + +### Bug Fixes +- parser: handle empty release range + +### Documentation +- refresh release guide + +### Other Changes +- cover release notes command +` + + if output != expected { + t.Fatalf("unexpected markdown output:\n%s", output) + } +} + +func TestReleaseNotesCommandDefaultsToLatestTag(t *testing.T) { + saveAndRestoreReleaseNotesState(t, time.Date(2026, 4, 10, 9, 0, 0, 0, time.UTC)) + dir := initReleaseNotesRepo(t) + + tagReleaseNotesRepo(t, dir, "v0.1.0") + commitReleaseNotesFile(t, dir, "feature.txt", "feature\n", "feat: add old feature") + tagReleaseNotesRepo(t, dir, "v0.2.0") + commitReleaseNotesFile(t, dir, "fix.txt", "fix\n", "fix: patch release") + + output, err := runReleaseNotesCommand(t, dir, "version", "v0.2.1") + if err != nil { + t.Fatalf("releaseNotesCmd.RunE returned error: %v", err) + } + + if strings.Contains(output, "add old feature") { + t.Fatalf("expected output to use latest tag only, got:\n%s", output) + } + if !strings.Contains(output, "patch release") { + t.Fatalf("expected output to include latest fix, got:\n%s", output) + } +} + +func TestReleaseNotesCommandHonorsToFlag(t *testing.T) { + saveAndRestoreReleaseNotesState(t, time.Date(2026, 4, 10, 9, 0, 0, 0, time.UTC)) + dir := initReleaseNotesRepo(t) + + tagReleaseNotesRepo(t, dir, "v0.1.0") + featureSHA := commitReleaseNotesFile(t, dir, "feature.txt", "feature\n", "feat: add release notes command") + commitReleaseNotesFile(t, dir, "fix.txt", "fix\n", "fix: patch release") + + output, err := runReleaseNotesCommand( + t, + dir, + "from", "v0.1.0", + "to", featureSHA, + "version", "v0.2.0", + ) + if err != nil { + t.Fatalf("releaseNotesCmd.RunE returned error: %v", err) + } + + if !strings.Contains(output, "add release notes command") { + t.Fatalf("expected output to include feature commit, got:\n%s", output) + } + if strings.Contains(output, "patch release") { + t.Fatalf("expected output to stop at --to commit, got:\n%s", output) + } +} + +func TestReleaseNotesCommandDefaultsFromPreviousTagWhenToIsReleaseTag(t *testing.T) { + saveAndRestoreReleaseNotesState(t, time.Date(2026, 4, 10, 9, 0, 0, 0, time.UTC)) + dir := initReleaseNotesRepo(t) + + tagReleaseNotesRepo(t, dir, "v0.1.0") + commitReleaseNotesFile(t, dir, "feature.txt", "feature\n", "feat: add release notes command") + tagReleaseNotesRepo(t, dir, "v0.2.0") + commitReleaseNotesFile(t, dir, "fix.txt", "fix\n", "fix: patch release") + + output, err := runReleaseNotesCommand( + t, + dir, + "to", "v0.2.0", + "version", "v0.2.0", + ) + if err != nil { + t.Fatalf("releaseNotesCmd.RunE returned error: %v", err) + } + + if !strings.Contains(output, "add release notes command") { + t.Fatalf("expected output to include tagged release commit, got:\n%s", output) + } + if strings.Contains(output, "patch release") { + t.Fatalf("expected output to stop at the requested release tag, got:\n%s", output) + } +} + +func TestReleaseNotesCommandDefaultsToLatestRetaggedVersion(t *testing.T) { + saveAndRestoreReleaseNotesState(t, time.Date(2026, 4, 10, 9, 0, 0, 0, time.UTC)) + dir := initReleaseNotesRepo(t) + + tagReleaseNotesRepo(t, dir, "v0.1.0") + commitReleaseNotesFile(t, dir, "feature.txt", "feature\n", "feat: add initial feature") + tagReleaseNotesRepo(t, dir, "v0.2.0") + tagReleaseNotesRepo(t, dir, "v0.2.1") + commitReleaseNotesFile(t, dir, "fix.txt", "fix\n", "fix: patch release") + + output, err := runReleaseNotesCommand(t, dir, "version", "v0.2.2") + if err != nil { + t.Fatalf("releaseNotesCmd.RunE returned error: %v", err) + } + + if strings.Contains(output, "add initial feature") { + t.Fatalf("expected default range to start from the latest retagged version, got:\n%s", output) + } + if !strings.Contains(output, "patch release") { + t.Fatalf("expected output to include unreleased patch, got:\n%s", output) + } +} + +func TestReleaseNotesCommandTreatsRetaggedReleaseAsEmptyRange(t *testing.T) { + saveAndRestoreReleaseNotesState(t, time.Date(2026, 4, 10, 9, 0, 0, 0, time.UTC)) + dir := initReleaseNotesRepo(t) + + tagReleaseNotesRepo(t, dir, "v0.1.0") + commitReleaseNotesFile(t, dir, "feature.txt", "feature\n", "feat: add initial feature") + tagReleaseNotesRepo(t, dir, "v0.2.0") + tagReleaseNotesRepo(t, dir, "v0.2.1") + + output, err := runReleaseNotesCommand( + t, + dir, + "to", "v0.2.1", + "version", "v0.2.1", + ) + if err != nil { + t.Fatalf("releaseNotesCmd.RunE returned error: %v", err) + } + + expected := `## [v0.2.1] - 2026-04-10 + +_No committed changes found between v0.2.0 and v0.2.1._ +` + + if output != expected { + t.Fatalf("unexpected markdown output:\n%s", output) + } +} + +func TestReleaseNotesCommandShowsEmptyRangeFallback(t *testing.T) { + saveAndRestoreReleaseNotesState(t, time.Date(2026, 4, 10, 9, 0, 0, 0, time.UTC)) + dir := initReleaseNotesRepo(t) + + tagReleaseNotesRepo(t, dir, "v0.1.0") + + output, err := runReleaseNotesCommand(t, dir, "from", "v0.1.0") + if err != nil { + t.Fatalf("releaseNotesCmd.RunE returned error: %v", err) + } + + expected := `## [Unreleased] - 2026-04-10 + +_No committed changes found between v0.1.0 and HEAD._ +` + + if output != expected { + t.Fatalf("unexpected markdown output:\n%s", output) + } +} + +func TestReleaseNotesCommandPrefersCurrentWorktreeOverEnvVar(t *testing.T) { + saveAndRestoreReleaseNotesState(t, time.Date(2026, 4, 10, 9, 0, 0, 0, time.UTC)) + dir := initReleaseNotesRepo(t) + + tagReleaseNotesRepo(t, dir, "v0.1.0") + + wtPath := filepath.Join(t.TempDir(), "wt") + runGit(t, dir, "worktree", "add", wtPath, "-b", "release-notes-worktree") + commitReleaseNotesFile(t, wtPath, "feature.txt", "feature\n", "feat: add worktree feature") + + t.Setenv("TD_WORK_DIR", dir) + + output, err := runReleaseNotesCommandFromCWD(t, wtPath, "version", "v0.2.0") + if err != nil { + t.Fatalf("releaseNotesCmd.RunE returned error: %v", err) + } + + if !strings.Contains(output, "add worktree feature") { + t.Fatalf("expected output to use cwd worktree history, got:\n%s", output) + } + if strings.Contains(output, "_No committed changes found") { + t.Fatalf("expected non-empty worktree draft, got:\n%s", output) + } +} + +func TestReleaseNotesCommandFallsBackToEnvVarWhenCWDIsNotRepo(t *testing.T) { + saveAndRestoreReleaseNotesState(t, time.Date(2026, 4, 10, 9, 0, 0, 0, time.UTC)) + dir := initReleaseNotesRepo(t) + + tagReleaseNotesRepo(t, dir, "v0.1.0") + commitReleaseNotesFile(t, dir, "feature.txt", "feature\n", "feat: add env-backed release notes") + + t.Setenv("TD_WORK_DIR", dir) + + output, err := runReleaseNotesCommandFromCWD(t, t.TempDir(), "version", "v0.2.0") + if err != nil { + t.Fatalf("releaseNotesCmd.RunE returned error: %v", err) + } + + if !strings.Contains(output, "add env-backed release notes") { + t.Fatalf("expected output to use TD_WORK_DIR fallback repo, got:\n%s", output) + } + if strings.Contains(output, "_No committed changes found") { + t.Fatalf("expected non-empty env fallback draft, got:\n%s", output) + } +} diff --git a/docs/guides/releasing-new-version.md b/docs/guides/releasing-new-version.md index ca98e527..ccfc5441 100644 --- a/docs/guides/releasing-new-version.md +++ b/docs/guides/releasing-new-version.md @@ -35,7 +35,21 @@ git tag -l | sort -V | tail -1 ### 2. Update CHANGELOG.md -Add entry at the top of `CHANGELOG.md`: +Generate a starting draft from committed history: + +```bash +td release-notes --version vX.Y.Z > /tmp/td-release-notes.md +``` + +If you need to override the default git range, pass `--from` and `--to`. +For an already-tagged or retagged release, point `--to` at the tag and `td` +will use the prior semver tag automatically: + +```bash +td release-notes --to vX.Y.Z --version vX.Y.Z > /tmp/td-release-notes.md +``` + +Review the draft, edit it for clarity, and then add the final entry at the top of `CHANGELOG.md`: ```markdown ## [vX.Y.Z] - YYYY-MM-DD @@ -134,8 +148,11 @@ Replace `X.Y.Z` with actual version: git status go test ./... +# Draft release notes +td release-notes --version vX.Y.Z > /tmp/td-release-notes.md + # Update changelog -# (Edit CHANGELOG.md, add entry at top) +# (Review /tmp/td-release-notes.md, then edit CHANGELOG.md and add the final entry at top) git add CHANGELOG.md git commit -m "docs: Update changelog for vX.Y.Z" @@ -154,6 +171,7 @@ brew upgrade td && td version - [ ] Tests pass (`go test ./...`) - [ ] Working tree clean +- [ ] `td release-notes --version vX.Y.Z` used to draft the changelog entry - [ ] CHANGELOG.md updated with new version entry - [ ] Changelog committed to git - [ ] Version number follows semver diff --git a/internal/git/git.go b/internal/git/git.go index f42c3d39..a46fd56a 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -4,13 +4,25 @@ package git import ( "bytes" + "errors" "fmt" "os/exec" + "regexp" + "sort" "strconv" "strings" ) -// State represents the current git state +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. type State struct { CommitSHA string Branch string @@ -20,25 +32,44 @@ type State struct { DirtyFiles int } -// GetState returns the current git state +// FileChange represents changes to a file. +type FileChange struct { + Path string + Additions int + Deletions int + IsNew bool +} + +// DiffStats summarizes git diff statistics. +type DiffStats struct { + FilesChanged int + Additions int + Deletions int +} + +// Commit captures the metadata needed for release-note drafting. +type Commit struct { + SHA string + Subject string + Files []string +} + +// GetState returns the current git state. func GetState() (*State, error) { state := &State{} - // Get current commit SHA sha, err := runGit("rev-parse", "HEAD") if err != nil { return nil, fmt.Errorf("not a git repository") } state.CommitSHA = strings.TrimSpace(sha) - // Get current branch branch, err := runGit("rev-parse", "--abbrev-ref", "HEAD") if err != nil { branch = "HEAD" } state.Branch = strings.TrimSpace(branch) - // Get status status, _ := runGit("status", "--porcelain") lines := strings.Split(strings.TrimSpace(status), "\n") @@ -49,7 +80,6 @@ func GetState() (*State, error) { if len(line) < 2 { continue } - // Check first two characters for status if line[0] == '?' && line[1] == '?' { state.Untracked++ } else { @@ -62,7 +92,7 @@ func GetState() (*State, error) { return state, nil } -// GetCommitsSince returns the number of commits since a given SHA +// GetCommitsSince returns the number of commits since a given SHA. func GetCommitsSince(sha string) (int, error) { output, err := runGit("rev-list", "--count", sha+"..HEAD") if err != nil { @@ -75,7 +105,7 @@ func GetCommitsSince(sha string) (int, error) { return count, nil } -// GetChangedFilesSince returns changed files since a given SHA +// GetChangedFilesSince returns changed files since a given SHA. func GetChangedFilesSince(sha string) ([]FileChange, error) { output, err := runGit("diff", "--stat", sha+"..HEAD") if err != nil { @@ -85,14 +115,6 @@ func GetChangedFilesSince(sha string) ([]FileChange, error) { return parseStatOutput(output), nil } -// FileChange represents changes to a file -type FileChange struct { - Path string - Additions int - Deletions int - IsNew bool -} - func parseStatOutput(output string) []FileChange { var changes []FileChange lines := strings.Split(output, "\n") @@ -103,7 +125,6 @@ func parseStatOutput(output string) []FileChange { continue } - // Parse format: file.go | 10 ++++---- parts := strings.Split(line, "|") if len(parts) != 2 { continue @@ -113,8 +134,6 @@ func parseStatOutput(output string) []FileChange { stats := strings.TrimSpace(parts[1]) change := FileChange{Path: path} - - // Count + and - for _, c := range stats { if c == '+' { change.Additions++ @@ -129,14 +148,7 @@ func parseStatOutput(output string) []FileChange { return changes } -// DiffStats summarizes git diff statistics -type DiffStats struct { - FilesChanged int - Additions int - Deletions int -} - -// GetDiffStatsSince returns diff statistics since a given SHA +// GetDiffStatsSince returns diff statistics since a given SHA. func GetDiffStatsSince(sha string) (*DiffStats, error) { output, err := runGit("diff", "--shortstat", sha+"..HEAD") if err != nil { @@ -144,8 +156,6 @@ func GetDiffStatsSince(sha string) (*DiffStats, error) { } stats := &DiffStats{} - - // Parse format: "3 files changed, 45 insertions(+), 12 deletions(-)" output = strings.TrimSpace(output) if output == "" { return stats, nil @@ -177,23 +187,300 @@ func GetDiffStatsSince(sha string) (*DiffStats, error) { return stats, nil } -// IsRepo checks if we're in a git repository +// 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 +// 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 "", fmt.Errorf("%w", ErrNotRepository) + } + return strings.TrimSpace(output), nil +} + +// GetLatestSemverTag returns the latest reachable semver tag for ref. +func GetLatestSemverTag(dir, ref string) (string, error) { + root, err := GetRootDirFrom(dir) + if err != nil { + return "", err + } + + ref = strings.TrimSpace(ref) + if ref == "" { + ref = "HEAD" + } + + if err := verifyCommitRef(root, ref); err != nil { + return "", err + } + + tags, err := listSemverTagsMerged(root, ref) + if err != nil { + return "", err + } + + for _, tag := range tags { + return tag, nil + } + + return "", ErrNoSemverTag +} + +// GetPreviousSemverTag returns the previous reachable semver tag before ref. +// If ref is itself a semver tag, the next lower version is returned even when +// it points at the same commit (for example, a retagged release). +func GetPreviousSemverTag(dir, ref string) (string, error) { + root, err := GetRootDirFrom(dir) + if err != nil { + return "", err + } + + ref = strings.TrimSpace(ref) + if ref == "" { + ref = "HEAD" + } + + if err := verifyCommitRef(root, ref); err != nil { + return "", err + } + + tags, err := listSemverTagsMerged(root, ref) + if err != nil { + return "", err + } + + targetTag, err := currentSemverTagForRef(root, ref) + if err != nil { + return "", err + } + + if targetTag == "" { + for _, tag := range tags { + return tag, nil + } + return "", ErrNoSemverTag + } + + foundTarget := false + for _, tag := range tags { + if !foundTarget { + if tag == targetTag { + foundTarget = true + } + continue + } + return tag, nil + } + + return "", ErrNoSemverTag +} + +// GetSemverTagsPointingAt returns semver tags that point at ref, sorted from +// newest to oldest version. +func GetSemverTagsPointingAt(dir, ref string) ([]string, error) { + root, err := GetRootDirFrom(dir) + if err != nil { + return nil, err + } + + ref = strings.TrimSpace(ref) + if ref == "" { + ref = "HEAD" + } + + if err := verifyCommitRef(root, ref); err != nil { + return nil, err + } + + return listSemverTagsPointingAt(root, ref) +} + +// RefPointsToSemverTag reports whether ref resolves to a commit with at least +// one semver tag pointing at it. +func RefPointsToSemverTag(dir, ref string) (bool, error) { + tags, err := GetSemverTagsPointingAt(dir, ref) + if err != nil { + return false, err + } + + return len(tags) > 0, nil +} + +// ListCommitsInRange returns non-merge commits in chronological order with +// each commit's touched files. +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 == "" { + toRef = "HEAD" + } + + if err := verifyCommitRef(root, fromRef); err != nil { + return nil, err + } + if err := verifyCommitRef(root, toRef); err != nil { + return nil, err + } + + output, err := runGitInDir(root, "log", "--no-merges", "--reverse", "--format=%H%x00%s", fromRef+".."+toRef) + if err != nil { + return nil, err + } + + output = strings.TrimSpace(output) + if output == "" { + return []Commit{}, nil + } + + lines := strings.Split(output, "\n") + commits := make([]Commit, 0, len(lines)) + for _, line := range lines { + parts := strings.SplitN(line, "\x00", 2) + if len(parts) != 2 { + continue + } + + sha := strings.TrimSpace(parts[0]) + files, err := listFilesForCommit(root, sha) + if err != nil { + return nil, err + } + + commits = append(commits, Commit{ + SHA: sha, + Subject: strings.TrimSpace(parts[1]), + Files: files, + }) + } + + return commits, nil +} + +func verifyCommitRef(dir, ref string) error { + _, err := resolveCommitRef(dir, ref) + return err +} + +func listFilesForCommit(dir, sha string) ([]string, error) { + output, err := runGitInDir(dir, "show", "--pretty=format:", "--name-only", "--diff-filter=ACDMRT", sha) + if err != nil { + return nil, err + } + return splitUniqueLines(output), nil +} + +func splitUniqueLines(output string) []string { + seen := make(map[string]struct{}) + var lines []string + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if _, ok := seen[line]; ok { + continue + } + seen[line] = struct{}{} + lines = append(lines, line) + } + sort.Strings(lines) + return lines +} + +func currentSemverTagForRef(dir, ref string) (string, error) { + if semverTagPattern.MatchString(ref) { + return ref, nil + } + + tags, err := listSemverTagsPointingAt(dir, ref) if err != nil { return "", err } + + for _, tag := range tags { + return tag, nil + } + + return "", nil +} + +func listSemverTagsMerged(dir, ref string) ([]string, error) { + output, err := runGitInDir(dir, "tag", "--merged", ref, "--sort=-version:refname") + if err != nil { + return nil, err + } + + tags := make([]string, 0) + for _, line := range strings.Split(output, "\n") { + tag := strings.TrimSpace(line) + if tag == "" || !semverTagPattern.MatchString(tag) { + continue + } + tags = append(tags, tag) + } + + return tags, nil +} + +func listSemverTagsPointingAt(dir, ref string) ([]string, error) { + output, err := runGitInDir(dir, "tag", "--points-at", ref, "--sort=-version:refname") + if err != nil { + return nil, err + } + + tags := make([]string, 0) + for _, line := range strings.Split(output, "\n") { + tag := strings.TrimSpace(line) + if tag == "" || !semverTagPattern.MatchString(tag) { + continue + } + tags = append(tags, tag) + } + + return tags, nil +} + +func resolveCommitRef(dir, ref string) (string, error) { + output, err := runGitInDir(dir, "rev-parse", "--verify", ref+"^{commit}") + if err != nil { + return "", fmt.Errorf("invalid git ref %q: %w", ref, err) + } return strings.TrimSpace(output), 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..69a650e6 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,44 @@ 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 commitFile(t *testing.T, dir, path, content, message 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) + } + if err := runCmd(dir, "git", "commit", "-m", message); err != nil { + t.Fatalf("Failed to commit %q: %v", message, err) + } + + sha, err := runCmdOutput(dir, "git", "rev-parse", "HEAD") + if err != nil { + t.Fatalf("Failed to get HEAD sha: %v", err) + } + return sha +} + +func tagHeadAnnotated(t *testing.T, dir, tag string) { + t.Helper() + if err := runCmd(dir, "git", "tag", "-a", tag, "-m", "Release "+tag); err != nil { + t.Fatalf("Failed to create annotated tag %s: %v", tag, err) + } +} + // TestParseStatOutputBasic tests parsing git diff --stat output func TestParseStatOutputBasic(t *testing.T) { output := ` file1.go | 10 ++++------ @@ -469,3 +509,164 @@ func TestStateBranchName(t *testing.T) { t.Logf("Branch name is %q (expected main/master/HEAD)", state.Branch) } } + +func TestGetRootDirFromReturnsErrNotRepository(t *testing.T) { + dir := t.TempDir() + + _, err := GetRootDirFrom(dir) + if !errors.Is(err, ErrNotRepository) { + t.Fatalf("expected ErrNotRepository, got %v", err) + } +} + +func TestGetLatestSemverTagSelectsLatestReachableAnnotatedTag(t *testing.T) { + dir := initTestRepo(t) + + tagHeadAnnotated(t, dir, "v0.1.0") + commitFile(t, dir, "feature.txt", "feature\n", "feat: add initial feature") + tagHeadAnnotated(t, dir, "v0.2.0") + commitFile(t, dir, "patch.txt", "patch\n", "fix: patch release") + if err := runCmd(dir, "git", "tag", "-a", "release-candidate", "-m", "non semver"); err != nil { + t.Fatalf("Failed to create non-semver tag: %v", err) + } + + tag, err := GetLatestSemverTag(dir, "HEAD") + if err != nil { + t.Fatalf("GetLatestSemverTag failed: %v", err) + } + if tag != "v0.2.0" { + t.Fatalf("expected v0.2.0, got %q", tag) + } +} + +func TestGetLatestSemverTagReturnsErrNoSemverTag(t *testing.T) { + dir := initTestRepo(t) + + _, err := GetLatestSemverTag(dir, "HEAD") + if !errors.Is(err, ErrNoSemverTag) { + t.Fatalf("expected ErrNoSemverTag, got %v", err) + } +} + +func TestGetPreviousSemverTagSkipsTargetReleaseTag(t *testing.T) { + dir := initTestRepo(t) + + tagHeadAnnotated(t, dir, "v0.1.0") + commitFile(t, dir, "feature.txt", "feature\n", "feat: add initial feature") + tagHeadAnnotated(t, dir, "v0.2.0") + + tag, err := GetPreviousSemverTag(dir, "v0.2.0") + if err != nil { + t.Fatalf("GetPreviousSemverTag failed: %v", err) + } + if tag != "v0.1.0" { + t.Fatalf("expected v0.1.0, got %q", tag) + } +} + +func TestGetPreviousSemverTagReturnsPreviousVersionOnRetaggedRelease(t *testing.T) { + dir := initTestRepo(t) + + tagHeadAnnotated(t, dir, "v0.1.0") + commitFile(t, dir, "feature.txt", "feature\n", "feat: add initial feature") + tagHeadAnnotated(t, dir, "v0.2.0") + tagHeadAnnotated(t, dir, "v0.2.1") + + tag, err := GetPreviousSemverTag(dir, "v0.2.1") + if err != nil { + t.Fatalf("GetPreviousSemverTag failed: %v", err) + } + if tag != "v0.2.0" { + t.Fatalf("expected v0.2.0, got %q", tag) + } +} + +func TestGetSemverTagsPointingAtReturnsSortedSemverTags(t *testing.T) { + dir := initTestRepo(t) + + commitSHA := commitFile(t, dir, "feature.txt", "feature\n", "feat: add initial feature") + tagHeadAnnotated(t, dir, "v0.2.0") + tagHeadAnnotated(t, dir, "v0.2.1") + if err := runCmd(dir, "git", "tag", "-a", "release-candidate", "-m", "non semver"); err != nil { + t.Fatalf("Failed to create non-semver tag: %v", err) + } + + tags, err := GetSemverTagsPointingAt(dir, commitSHA) + if err != nil { + t.Fatalf("GetSemverTagsPointingAt failed: %v", err) + } + + if len(tags) != 2 { + t.Fatalf("expected 2 semver tags, got %d", len(tags)) + } + if tags[0] != "v0.2.1" || tags[1] != "v0.2.0" { + t.Fatalf("unexpected semver tags: %v", tags) + } +} + +func TestRefPointsToSemverTagReturnsTrueForTaggedCommit(t *testing.T) { + dir := initTestRepo(t) + + releaseSHA := commitFile(t, dir, "feature.txt", "feature\n", "feat: add initial feature") + tagHeadAnnotated(t, dir, "v0.2.0") + + tagged, err := RefPointsToSemverTag(dir, "v0.2.0") + if err != nil { + t.Fatalf("RefPointsToSemverTag(tag) failed: %v", err) + } + if !tagged { + t.Fatal("expected semver tag ref to report true") + } + + tagged, err = RefPointsToSemverTag(dir, releaseSHA) + if err != nil { + t.Fatalf("RefPointsToSemverTag(sha) failed: %v", err) + } + if !tagged { + t.Fatal("expected tagged commit sha to report true") + } +} + +func TestListCommitsInRangeReturnsCommitsWithFiles(t *testing.T) { + dir := initTestRepo(t) + + tagHeadAnnotated(t, dir, "v0.1.0") + firstSHA := commitFile(t, dir, "cmd/release_notes.go", "package cmd\n", "feat: add release notes command") + secondSHA := commitFile(t, dir, "docs/guides/releasing-new-version.md", "# Release\n", "refresh release guide") + + commits, err := ListCommitsInRange(dir, "v0.1.0", "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].SHA != firstSHA || commits[0].Subject != "feat: add release notes command" { + t.Fatalf("unexpected first commit: %+v", commits[0]) + } + if len(commits[0].Files) != 1 || commits[0].Files[0] != "cmd/release_notes.go" { + t.Fatalf("unexpected first commit files: %+v", commits[0].Files) + } + + if commits[1].SHA != secondSHA || commits[1].Subject != "refresh release guide" { + t.Fatalf("unexpected second commit: %+v", commits[1]) + } + if len(commits[1].Files) != 1 || commits[1].Files[0] != "docs/guides/releasing-new-version.md" { + t.Fatalf("unexpected second commit files: %+v", commits[1].Files) + } +} + +func TestListCommitsInRangeReturnsEmptyWhenNoChanges(t *testing.T) { + dir := initTestRepo(t) + + tagHeadAnnotated(t, dir, "v0.1.0") + + commits, err := ListCommitsInRange(dir, "v0.1.0", "HEAD") + if err != nil { + t.Fatalf("ListCommitsInRange failed: %v", err) + } + if len(commits) != 0 { + t.Fatalf("expected no commits, got %d", len(commits)) + } +} diff --git a/internal/releasenotes/draft.go b/internal/releasenotes/draft.go new file mode 100644 index 00000000..c09cc07a --- /dev/null +++ b/internal/releasenotes/draft.go @@ -0,0 +1,241 @@ +package releasenotes + +import ( + "fmt" + "regexp" + "strings" + "time" + + gitutil "github.com/marcus/td/internal/git" +) + +const ( + sectionFeatures = "Features" + sectionBugFixes = "Bug Fixes" + sectionDocumentation = "Documentation" + sectionOtherChanges = "Other Changes" +) + +var ( + conventionalCommitPattern = regexp.MustCompile(`(?i)^(feat(?:ure)?|fix|bugfix|bug|docs?|doc|test(?:s)?|refactor|perf|chore|build|ci|style|revert)(?:\(([^)]+)\))?!?:\s*(.+)$`) + tdRefPrefixPattern = regexp.MustCompile(`^\[td-[0-9a-f]+\]\s*`) + tdRefSuffixPattern = regexp.MustCompile(`\s+\(td-[0-9a-f]+\)$`) + prSuffixPattern = regexp.MustCompile(`\s+\(#\d+\)$`) + sectionOrder = []string{ + sectionFeatures, + sectionBugFixes, + sectionDocumentation, + sectionOtherChanges, + } +) + +// Options configures release-note generation. +type Options struct { + FromRef string + ToRef string + Version string + Date time.Time +} + +// Draft is changelog-ready release note content plus range metadata. +type Draft struct { + RepoRoot string + FromRef string + ToRef string + Version string + Date time.Time + Sections []Section +} + +// Section is one markdown section in the generated draft. +type Section struct { + Title string + Entries []string +} + +// Generate drafts release notes from committed git history. +func Generate(repoDir string, opts Options) (*Draft, error) { + root, err := gitutil.GetRootDirFrom(repoDir) + if err != nil { + return nil, err + } + + rawToRef := strings.TrimSpace(opts.ToRef) + toRef := rawToRef + if toRef == "" { + toRef = "HEAD" + } + + fromRef := strings.TrimSpace(opts.FromRef) + if fromRef == "" { + usePreviousTag := rawToRef != "" && !strings.EqualFold(toRef, "HEAD") + if usePreviousTag { + taggedReleaseTarget, err := gitutil.RefPointsToSemverTag(root, toRef) + if err != nil { + return nil, err + } + if taggedReleaseTarget { + fromRef, err = gitutil.GetPreviousSemverTag(root, toRef) + } else { + fromRef, err = gitutil.GetLatestSemverTag(root, toRef) + } + } else { + fromRef, err = gitutil.GetLatestSemverTag(root, toRef) + } + if err != nil { + return nil, err + } + } + + commits, err := gitutil.ListCommitsInRange(root, fromRef, toRef) + if err != nil { + return nil, err + } + + date := opts.Date + if date.IsZero() { + date = time.Now() + } + + return buildDraft(commits, draftOptions{ + RepoRoot: root, + FromRef: fromRef, + ToRef: toRef, + Version: opts.Version, + Date: date, + }), nil +} + +// Markdown renders the draft as a CHANGELOG-ready markdown block. +func (d *Draft) Markdown() string { + var b strings.Builder + fmt.Fprintf(&b, "## [%s] - %s\n\n", d.Version, d.Date.Format("2006-01-02")) + + if len(d.Sections) == 0 { + fmt.Fprintf(&b, "_No committed changes found between %s and %s._\n", d.FromRef, d.ToRef) + return b.String() + } + + for _, section := range d.Sections { + fmt.Fprintf(&b, "### %s\n", section.Title) + for _, entry := range section.Entries { + fmt.Fprintf(&b, "- %s\n", entry) + } + b.WriteString("\n") + } + + return strings.TrimRight(b.String(), "\n") + "\n" +} + +type draftOptions struct { + RepoRoot string + FromRef string + ToRef string + Version string + Date time.Time +} + +func buildDraft(commits []gitutil.Commit, opts draftOptions) *Draft { + sectionsByTitle := make(map[string][]string) + for _, commit := range commits { + title, entry := classifyCommit(commit) + sectionsByTitle[title] = append(sectionsByTitle[title], entry) + } + + 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, + }) + } + + version := strings.TrimSpace(opts.Version) + if version == "" { + version = "Unreleased" + } + + return &Draft{ + RepoRoot: opts.RepoRoot, + FromRef: opts.FromRef, + ToRef: opts.ToRef, + Version: version, + Date: opts.Date, + Sections: sections, + } +} + +func classifyCommit(commit gitutil.Commit) (string, string) { + subject := cleanSubject(commit.Subject) + if subject == "" { + shortSHA := commit.SHA + if len(shortSHA) > 7 { + shortSHA = shortSHA[:7] + } + return sectionOtherChanges, fmt.Sprintf("commit %s", shortSHA) + } + + if matches := conventionalCommitPattern.FindStringSubmatch(subject); len(matches) == 4 { + title := sectionForPrefix(strings.ToLower(matches[1])) + scope := strings.TrimSpace(matches[2]) + description := strings.TrimSpace(matches[3]) + if scope != "" { + return title, fmt.Sprintf("%s: %s", scope, description) + } + return title, description + } + + if documentationOnly(commit.Files) { + return sectionDocumentation, subject + } + + return sectionOtherChanges, subject +} + +func sectionForPrefix(prefix string) string { + switch prefix { + case "feat", "feature": + return sectionFeatures + case "fix", "bugfix", "bug": + return sectionBugFixes + case "docs", "doc": + return sectionDocumentation + default: + return sectionOtherChanges + } +} + +func cleanSubject(subject string) string { + subject = strings.TrimSpace(subject) + subject = tdRefPrefixPattern.ReplaceAllString(subject, "") + subject = prSuffixPattern.ReplaceAllString(subject, "") + subject = tdRefSuffixPattern.ReplaceAllString(subject, "") + subject = strings.Join(strings.Fields(subject), " ") + return subject +} + +func documentationOnly(files []string) bool { + if len(files) == 0 { + return false + } + for _, file := range files { + if !isDocumentationFile(file) { + return false + } + } + return true +} + +func isDocumentationFile(path string) bool { + return strings.HasPrefix(path, "docs/") || + strings.HasPrefix(path, "website/docs/") || + strings.HasSuffix(path, ".md") || + strings.HasSuffix(path, ".mdx") || + path == "README" || + path == "README.md" || + path == "CHANGELOG.md" +} diff --git a/internal/releasenotes/draft_test.go b/internal/releasenotes/draft_test.go new file mode 100644 index 00000000..3db94eca --- /dev/null +++ b/internal/releasenotes/draft_test.go @@ -0,0 +1,73 @@ +package releasenotes + +import ( + "testing" + "time" + + gitutil "github.com/marcus/td/internal/git" +) + +func TestBuildDraftClassifiesAndOrdersSections(t *testing.T) { + draft := buildDraft([]gitutil.Commit{ + {SHA: "aaaaaaa", Subject: "feat: add release notes command", Files: []string{"cmd/release_notes.go"}}, + {SHA: "bbbbbbb", Subject: "fix(parser): handle empty release range", Files: []string{"internal/releasenotes/draft.go"}}, + {SHA: "ccccccc", Subject: "refresh release guide", Files: []string{"docs/guides/releasing-new-version.md"}}, + {SHA: "ddddddd", Subject: "test: cover release notes command", Files: []string{"cmd/release_notes_test.go"}}, + }, draftOptions{ + RepoRoot: "/tmp/repo", + FromRef: "v0.1.0", + ToRef: "HEAD", + Version: "v0.2.0", + Date: time.Date(2026, 4, 10, 9, 30, 0, 0, time.UTC), + }) + + if len(draft.Sections) != 4 { + t.Fatalf("expected 4 sections, got %d", len(draft.Sections)) + } + + if draft.Sections[0].Title != sectionFeatures || draft.Sections[0].Entries[0] != "add release notes command" { + t.Fatalf("unexpected features section: %+v", draft.Sections[0]) + } + if draft.Sections[1].Title != sectionBugFixes || draft.Sections[1].Entries[0] != "parser: handle empty release range" { + t.Fatalf("unexpected bug fixes section: %+v", draft.Sections[1]) + } + if draft.Sections[2].Title != sectionDocumentation || draft.Sections[2].Entries[0] != "refresh release guide" { + t.Fatalf("unexpected documentation section: %+v", draft.Sections[2]) + } + if draft.Sections[3].Title != sectionOtherChanges || draft.Sections[3].Entries[0] != "cover release notes command" { + t.Fatalf("unexpected other changes section: %+v", draft.Sections[3]) + } +} + +func TestClassifyCommitStripsCommonNoise(t *testing.T) { + title, entry := classifyCommit(gitutil.Commit{ + SHA: "abcdef1", + Subject: "[td-527bd4] refresh release guide (td-527bd4) (#91)", + Files: []string{"docs/guides/releasing-new-version.md"}, + }) + + if title != sectionDocumentation { + t.Fatalf("expected documentation section, got %q", title) + } + if entry != "refresh release guide" { + t.Fatalf("expected cleaned entry, got %q", entry) + } +} + +func TestDraftMarkdownHandlesEmptyRange(t *testing.T) { + draft := buildDraft(nil, draftOptions{ + RepoRoot: "/tmp/repo", + FromRef: "v0.1.0", + ToRef: "HEAD", + Date: time.Date(2026, 4, 10, 9, 30, 0, 0, time.UTC), + }) + + expected := `## [Unreleased] - 2026-04-10 + +_No committed changes found between v0.1.0 and HEAD._ +` + + if got := draft.Markdown(); got != expected { + t.Fatalf("unexpected markdown:\n%s", got) + } +} diff --git a/website/docs/command-reference.md b/website/docs/command-reference.md index 28e9da1c..7260e3e2 100644 --- a/website/docs/command-reference.md +++ b/website/docs/command-reference.md @@ -140,6 +140,7 @@ cat docs/acceptance.md | td update td-a1b2 --append --acceptance-file - |---------|-------------| | `td init` | Initialize project | | `td monitor` | Live TUI dashboard | +| `td release-notes [flags]` | Draft markdown release notes from committed git history. Flags: `--from`, `--to`, `--version` | | `td undo` | Undo last action | | `td version` | Show version | | `td export` | Export database |