···11+/*
22+USAGE
33+44+ storm generate [from] [to] [options]
55+66+FLAGS
77+88+ -i, --interactive Review generated entries in a TUI
99+ --since <tag> Generate changes since the given tag
1010+ -o, --output <path> Write generated changelog to path
1111+ --repo <path> Path to the Git repository (default: .)
1212+*/
1313+package main
1414+1515+import (
1616+ "fmt"
1717+ "strings"
1818+1919+ "github.com/go-git/go-git/v6"
2020+ "github.com/go-git/go-git/v6/plumbing"
2121+ "github.com/go-git/go-git/v6/plumbing/object"
2222+ "github.com/spf13/cobra"
2323+ "github.com/stormlightlabs/git-storm/internal/changeset"
2424+ "github.com/stormlightlabs/git-storm/internal/gitlog"
2525+ "github.com/stormlightlabs/git-storm/internal/style"
2626+)
2727+2828+var (
2929+ interactive bool
3030+ sinceTag string
3131+)
3232+3333+// getCommitRange returns commits reachable from toRef but not from fromRef.
3434+// This implements the git log from..to range semantics.
3535+func getCommitRange(repo *git.Repository, fromRef, toRef string) ([]*object.Commit, error) {
3636+ fromHash, err := repo.ResolveRevision(plumbing.Revision(fromRef))
3737+ if err != nil {
3838+ return nil, fmt.Errorf("failed to resolve %s: %w", fromRef, err)
3939+ }
4040+4141+ toHash, err := repo.ResolveRevision(plumbing.Revision(toRef))
4242+ if err != nil {
4343+ return nil, fmt.Errorf("failed to resolve %s: %w", toRef, err)
4444+ }
4545+4646+ toCommits := make(map[plumbing.Hash]bool)
4747+ toIter, err := repo.Log(&git.LogOptions{From: *toHash})
4848+ if err != nil {
4949+ return nil, fmt.Errorf("failed to get commits from %s: %w", toRef, err)
5050+ }
5151+5252+ err = toIter.ForEach(func(c *object.Commit) error {
5353+ toCommits[c.Hash] = true
5454+ return nil
5555+ })
5656+ if err != nil {
5757+ return nil, fmt.Errorf("failed to iterate commits from %s: %w", toRef, err)
5858+ }
5959+6060+ fromCommits := make(map[plumbing.Hash]bool)
6161+ fromIter, err := repo.Log(&git.LogOptions{From: *fromHash})
6262+ if err != nil {
6363+ return nil, fmt.Errorf("failed to get commits from %s: %w", fromRef, err)
6464+ }
6565+6666+ err = fromIter.ForEach(func(c *object.Commit) error {
6767+ fromCommits[c.Hash] = true
6868+ return nil
6969+ })
7070+ if err != nil {
7171+ return nil, fmt.Errorf("failed to iterate commits from %s: %w", fromRef, err)
7272+ }
7373+7474+ // Collect commits that are in toCommits but not in fromCommits
7575+ result := []*object.Commit{}
7676+ toIter, err = repo.Log(&git.LogOptions{From: *toHash})
7777+ if err != nil {
7878+ return nil, fmt.Errorf("failed to get commits from %s: %w", toRef, err)
7979+ }
8080+8181+ err = toIter.ForEach(func(c *object.Commit) error {
8282+ if !fromCommits[c.Hash] {
8383+ result = append(result, c)
8484+ }
8585+ return nil
8686+ })
8787+ if err != nil {
8888+ return nil, fmt.Errorf("failed to collect commit range: %w", err)
8989+ }
9090+9191+ // Reverse to get chronological order (oldest first)
9292+ for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 {
9393+ result[i], result[j] = result[j], result[i]
9494+ }
9595+9696+ return result, nil
9797+}
9898+9999+func generateCmd() *cobra.Command {
100100+ c := &cobra.Command{
101101+ Use: "generate [from] [to]",
102102+ Short: "Generate changelog entries from Git commits",
103103+ Long: `Scans commits between two Git refs (tags or hashes) and outputs draft
104104+entries in .changes/. Supports conventional commit parsing and
105105+interactive review mode.`,
106106+ Args: cobra.MaximumNArgs(2),
107107+ RunE: func(cmd *cobra.Command, args []string) error {
108108+ var from, to string
109109+110110+ if sinceTag != "" {
111111+ from = sinceTag
112112+ if len(args) > 0 {
113113+ to = args[0]
114114+ } else {
115115+ to = "HEAD"
116116+ }
117117+ } else if len(args) == 0 {
118118+ return fmt.Errorf("must specify either --since flag or [from] [to] arguments")
119119+ } else if len(args) == 1 {
120120+ parts := strings.Split(args[0], "..")
121121+ if len(parts) == 2 {
122122+ from, to = parts[0], parts[1]
123123+ } else {
124124+ from, to = args[0], "HEAD"
125125+ }
126126+ } else {
127127+ from, to = args[0], args[1]
128128+ }
129129+130130+ if interactive {
131131+ style.Headline("Interactive mode not yet implemented")
132132+ fmt.Println("Will generate entries in non-interactive mode...")
133133+ }
134134+135135+ repo, err := git.PlainOpen(repoPath)
136136+ if err != nil {
137137+ return fmt.Errorf("failed to open repository: %w", err)
138138+ }
139139+140140+ commits, err := getCommitRange(repo, from, to)
141141+ if err != nil {
142142+ return err
143143+ }
144144+145145+ if len(commits) == 0 {
146146+ style.Headline(fmt.Sprintf("No commits found between %s and %s", from, to))
147147+ return nil
148148+ }
149149+150150+ style.Headline(fmt.Sprintf("Found %d commits between %s and %s", len(commits), from, to))
151151+152152+ parser := &gitlog.ConventionalParser{}
153153+ entries := []changeset.Entry{}
154154+ skipped := 0
155155+156156+ for _, commit := range commits {
157157+ subject := commit.Message
158158+ body := ""
159159+ lines := strings.Split(commit.Message, "\n")
160160+ if len(lines) > 0 {
161161+ subject = lines[0]
162162+ if len(lines) > 1 {
163163+ body = strings.Join(lines[1:], "\n")
164164+ }
165165+ }
166166+167167+ meta, err := parser.Parse(commit.Hash.String(), subject, body, commit.Author.When)
168168+ if err != nil {
169169+ fmt.Printf("Warning: failed to parse commit %s: %v\n", commit.Hash.String()[:7], err)
170170+ continue
171171+ }
172172+173173+ category := parser.Categorize(meta)
174174+ if category == "" {
175175+ skipped++
176176+ continue
177177+ }
178178+179179+ entry := changeset.Entry{
180180+ Type: category,
181181+ Scope: meta.Scope,
182182+ Summary: meta.Description,
183183+ Breaking: meta.Breaking,
184184+ }
185185+186186+ entries = append(entries, entry)
187187+ }
188188+189189+ changesDir := ".changes"
190190+ created := 0
191191+ for _, entry := range entries {
192192+ filePath, err := changeset.Write(changesDir, entry)
193193+ if err != nil {
194194+ fmt.Printf("Error: failed to write entry: %v\n", err)
195195+ continue
196196+ }
197197+ style.Addedf("✓ Created %s", filePath)
198198+ created++
199199+ }
200200+201201+ fmt.Println()
202202+ style.Headline(fmt.Sprintf("Generated %d changelog entries", created))
203203+ if skipped > 0 {
204204+ style.Println("Skipped %d commits (reverts or non-matching types)", skipped)
205205+ }
206206+207207+ return nil
208208+ },
209209+ }
210210+211211+ c.Flags().BoolVarP(&interactive, "interactive", "i", false, "Review changes interactively in a TUI")
212212+ c.Flags().StringVar(&sinceTag, "since", "", "Generate changes since the given tag")
213213+214214+ return c
215215+}
+70
cmd/generate_test.go
···11+package main
22+33+import (
44+ "testing"
55+66+ "github.com/stormlightlabs/git-storm/internal/testutils"
77+)
88+99+func TestGetCommitRange(t *testing.T) {
1010+ repo := testutils.SetupTestRepo(t)
1111+1212+ // Initial setup creates 3 commits (a.txt, b.txt, c.txt)
1313+ // Let's add a tag at the second commit
1414+ commits := testutils.GetCommitHistory(t, repo)
1515+ if len(commits) < 3 {
1616+ t.Fatalf("Expected at least 3 commits, got %d", len(commits))
1717+ }
1818+1919+ // Tag the oldest commit (which is at index len-1 due to reverse order)
2020+ oldCommit := commits[len(commits)-2] // Second oldest commit
2121+ if err := testutils.CreateTagAtCommit(t, repo, "v1.0.0", oldCommit.Hash.String()); err != nil {
2222+ t.Fatalf("Failed to create tag: %v", err)
2323+ }
2424+2525+ // Add more commits after the tag
2626+ testutils.AddCommit(t, repo, "d.txt", "content d", "feat: add d feature")
2727+ testutils.AddCommit(t, repo, "e.txt", "content e", "fix: fix e bug")
2828+2929+ // Get commits between tag and HEAD
3030+ rangeCommits, err := getCommitRange(repo, "v1.0.0", "HEAD")
3131+ if err != nil {
3232+ t.Fatalf("getCommitRange() error = %v", err)
3333+ }
3434+3535+ // Should include the new commits (d.txt, e.txt) and commits after the tagged one
3636+ if len(rangeCommits) < 2 {
3737+ t.Errorf("Expected at least 2 commits in range, got %d", len(rangeCommits))
3838+ }
3939+4040+ // Verify commits are in chronological order (oldest first)
4141+ for i := 1; i < len(rangeCommits); i++ {
4242+ if rangeCommits[i].Author.When.Before(rangeCommits[i-1].Author.When) {
4343+ t.Errorf("Commits are not in chronological order")
4444+ }
4545+ }
4646+}
4747+4848+func TestGetCommitRange_SameRef(t *testing.T) {
4949+ repo := testutils.SetupTestRepo(t)
5050+5151+ // Get commits between HEAD and HEAD (should be empty)
5252+ rangeCommits, err := getCommitRange(repo, "HEAD", "HEAD")
5353+ if err != nil {
5454+ t.Fatalf("getCommitRange() error = %v", err)
5555+ }
5656+5757+ if len(rangeCommits) != 0 {
5858+ t.Errorf("Expected 0 commits when from and to are the same, got %d", len(rangeCommits))
5959+ }
6060+}
6161+6262+func TestGetCommitRange_InvalidRef(t *testing.T) {
6363+ repo := testutils.SetupTestRepo(t)
6464+6565+ // Try to get commits with invalid ref
6666+ _, err := getCommitRange(repo, "invalid-ref", "HEAD")
6767+ if err == nil {
6868+ t.Errorf("Expected error for invalid ref, got nil")
6969+ }
7070+}
-28
cmd/main.go
···2121)
22222323var (
2424- fromRef string
2525- toRef string
2626- interactive bool
2727- sinceTag string
2828-)
2929-3030-var (
3124 changeType string
3225 scope string
3326 summary string
···307300 root.AddCommand(add, list, review)
308301309302 return root
310310-}
311311-312312-func generateCmd() *cobra.Command {
313313- c := &cobra.Command{
314314- Use: "generate [from] [to]",
315315- Short: "Generate changelog entries from Git commits",
316316- Long: `Scans commits between two Git refs (tags or hashes) and outputs draft
317317-entries in .changes/. Supports conventional commit parsing and
318318-interactive review mode.`,
319319- Args: cobra.MaximumNArgs(2),
320320- RunE: func(cmd *cobra.Command, args []string) error {
321321- fmt.Println("generate command not implemented")
322322- fmt.Printf("from=%v to=%v interactive=%v sinceTag=%v\n", fromRef, toRef, interactive, sinceTag)
323323- return nil
324324- },
325325- }
326326-327327- c.Flags().BoolVarP(&interactive, "interactive", "i", false, "Review changes interactively in a TUI")
328328- c.Flags().StringVar(&sinceTag, "since", "", "Generate changes since the given tag")
329329-330330- return c
331303}
332304333305func main() {
+41
cmd/unreleased.go
···11+/*
22+USAGE
33+44+ storm unreleased <subcommand> [options]
55+66+SUBCOMMANDS
77+88+ add Add a new unreleased change entry
99+ list List all unreleased changes
1010+ review Review unreleased changes interactively
1111+1212+USAGE
1313+1414+ storm unreleased add [options]
1515+1616+FLAGS
1717+1818+ --type <type> Change type (added, changed, fixed, removed, security)
1919+ --scope <scope> Optional subsystem or module name
2020+ --summary <text> Short description of the change
2121+ --repo <path> Path to the repository (default: .)
2222+2323+USAGE
2424+2525+ storm unreleased list [options]
2626+2727+FLAGS
2828+2929+ --json Output as JSON
3030+ --repo <path> Path to the repository (default: .)
3131+3232+USAGE
3333+3434+ storm unreleased review [options]
3535+3636+FLAGS
3737+3838+ --repo <path> Path to the repository (default: .)
3939+ --output <file> Optional file to export reviewed notes
4040+*/
4141+package main
···77 "time"
8899 "github.com/go-git/go-git/v6"
1010+ "github.com/go-git/go-git/v6/plumbing"
1011 "github.com/go-git/go-git/v6/plumbing/object"
1112)
12131313-// SetupTestRepo creates a git repository in a temporary directory with sample commits.
1414-// The repository contains multiple commits with different types of changes to support
1515-// testing diff algorithms, changelog generation, and git log parsing.
1414+// SetupTestRepo creates a git repository in a temporary directory with sample
1515+// commits. The repository contains multiple commits with different types of
1616+// changes to support testing diff algorithms, changelog generation, and git log
1717+// parsing.
1618func SetupTestRepo(t *testing.T) *git.Repository {
1719 t.Helper()
1820 dir := t.TempDir()
···2729 t.Fatalf("failed to get worktree: %v", err)
2830 }
29313030- // Create commits with varied content for diff testing
3132 commits := []struct {
3233 name, content, message string
3334 }{
···7273 if err != nil {
7374 t.Fatalf("failed to create tag %s: %v", tagName, err)
7475 }
7676+}
7777+7878+// CreateTagAtCommit creates a lightweight tag at a specific commit hash.
7979+func CreateTagAtCommit(t *testing.T, repo *git.Repository, tagName, commitHash string) error {
8080+ t.Helper()
8181+ hash, err := repo.ResolveRevision(plumbing.Revision(commitHash))
8282+ if err != nil {
8383+ return err
8484+ }
8585+8686+ _, err = repo.CreateTag(tagName, *hash, nil)
8787+ return err
7588}
76897790// GetCommitHistory returns all commits in the repository from HEAD backwards.