changelog generator & diff tool
stormlightlabs.github.io/git-storm/
changelog
changeset
markdown
golang
git
1/*
2USAGE
3
4 storm generate [from] [to] [options]
5
6FLAGS
7
8 -i, --interactive Review generated entries in a TUI
9 --since <tag> Generate changes since the given tag
10 -o, --output <path> Write generated changelog to path
11 --output-json Output results as JSON
12 --repo <path> Path to the Git repository (default: .)
13*/
14package main
15
16import (
17 "encoding/json"
18 "fmt"
19 "strings"
20
21 tea "github.com/charmbracelet/bubbletea"
22 "github.com/go-git/go-git/v6"
23 "github.com/spf13/cobra"
24 "github.com/stormlightlabs/git-storm/internal/changeset"
25 "github.com/stormlightlabs/git-storm/internal/gitlog"
26 "github.com/stormlightlabs/git-storm/internal/style"
27 "github.com/stormlightlabs/git-storm/internal/tty"
28 "github.com/stormlightlabs/git-storm/internal/ui"
29)
30
31var (
32 interactive bool
33 sinceTag string
34 outputJSON bool
35)
36
37// GenerateOutput represents the JSON output structure for the generate command.
38type GenerateOutput struct {
39 From string `json:"from"`
40 To string `json:"to"`
41 TotalCommits int `json:"total_commits"`
42 Statistics GenerateStatistics `json:"statistics"`
43 Entries []changeset.EntryWithFile `json:"entries,omitempty"`
44}
45
46// GenerateStatistics holds counts of generated, skipped, duplicate, and rebased entries.
47type GenerateStatistics struct {
48 Created int `json:"created"`
49 Skipped int `json:"skipped"`
50 Duplicates int `json:"duplicates"`
51 Rebased int `json:"rebased"`
52}
53
54// TODO(determinism): Add deduplication logic using diff-based identity
55//
56// Currently generates duplicate .changes/*.md files when:
57// 1. Running generate multiple times on the same range
58// 2. History is rewritten (rebase/amend) but commit content unchanged
59//
60// Implementation:
61//
62// 1. Before processing commits, load existing entries:
63// existingEntries := changeset.LoadExisting(".changes/data")
64// // Returns map[diffHash]Metadata for O(1) lookups
65//
66// 2. For each selected commit:
67// a. Compute diff hash: diffHash := changeset.ComputeDiffHash(repo, commit)
68// b. Check if exists: if meta, exists := existingEntries[diffHash]; exists {
69// - Same commit hash → true duplicate, skip
70// - Different commit hash → rebased/cherry-picked
71// * If --update-rebased: update metadata.CommitHash in JSON
72// * If --skip-rebased: skip (default)
73// * If --warn-rebased: print warning and skip
74// }
75// c. If not exists: create new entry with diff hash as filename
76//
77// 3. Report statistics:
78// - N new entries created
79// - M duplicates skipped (same commit)
80// - K rebased commits detected (same diff, different commit)
81//
82// Flags to add:
83// --update-rebased Update commit hash for rebased entries
84// --skip-rebased Skip rebased commits (default)
85// --warn-rebased Print warnings for rebased commits
86// --force Regenerate all entries (ignore existing)
87//
88// Related: See internal/changeset/changeset.go TODO for implementation details
89func generateCmd() *cobra.Command {
90 c := &cobra.Command{
91 Use: "generate [from] [to]",
92 Short: "Generate changelog entries from Git commits",
93 Long: `Scans commits between two Git refs (tags or hashes) and outputs draft
94entries in .changes/. Supports conventional commit parsing and
95interactive review mode.`,
96 Args: cobra.MaximumNArgs(2),
97 RunE: func(cmd *cobra.Command, args []string) error {
98 if interactive && !tty.IsInteractive() {
99 return tty.ErrorInteractiveFlag("--interactive")
100 }
101
102 if interactive && outputJSON {
103 return fmt.Errorf("--interactive and --output-json cannot be used together")
104 }
105
106 var from, to string
107
108 if sinceTag != "" {
109 from = sinceTag
110 if len(args) > 0 {
111 to = args[0]
112 } else {
113 to = "HEAD"
114 }
115 } else if len(args) == 0 {
116 return fmt.Errorf("must specify either --since flag or [from] [to] arguments")
117 } else {
118 from, to = gitlog.ParseRefArgs(args)
119 }
120
121 repo, err := git.PlainOpen(repoPath)
122 if err != nil {
123 return fmt.Errorf("failed to open repository: %w", err)
124 }
125
126 commits, err := gitlog.GetCommitRange(repo, from, to)
127 if err != nil {
128 return err
129 }
130
131 if len(commits) == 0 {
132 style.Headlinef("No commits found between %s and %s", from, to)
133 return nil
134 }
135
136 parser := &gitlog.ConventionalParser{}
137 var selectedItems []ui.CommitItem
138
139 if interactive {
140 model := ui.NewCommitSelectorModel(commits, from, to, parser)
141 p := tea.NewProgram(model, tea.WithAltScreen())
142
143 finalModel, err := p.Run()
144 if err != nil {
145 return fmt.Errorf("failed to run interactive selector: %w", err)
146 }
147
148 selectorModel, ok := finalModel.(ui.CommitSelectorModel)
149 if !ok {
150 return fmt.Errorf("unexpected model type")
151 }
152
153 if selectorModel.IsCancelled() {
154 style.Headline("Operation cancelled")
155 return nil
156 }
157
158 selectedItems = selectorModel.GetSelectedItems()
159
160 if len(selectedItems) == 0 {
161 style.Headline("No commits selected")
162 return nil
163 }
164
165 style.Headlinef("Generating entries for %d selected commits", len(selectedItems))
166 } else {
167 style.Headlinef("Found %d commits between %s and %s", len(commits), from, to)
168
169 for _, commit := range commits {
170 subject := commit.Message
171 body := ""
172 lines := strings.Split(commit.Message, "\n")
173 if len(lines) > 0 {
174 subject = lines[0]
175 if len(lines) > 1 {
176 body = strings.Join(lines[1:], "\n")
177 }
178 }
179
180 meta, err := parser.Parse(commit.Hash.String(), subject, body, commit.Author.When)
181 if err != nil {
182 style.Println("Warning: failed to parse commit %s: %v", commit.Hash.String()[:gitlog.ShaLen], err)
183 continue
184 }
185
186 category := parser.Categorize(meta)
187 if category == "" {
188 continue
189 }
190
191 selectedItems = append(selectedItems, ui.CommitItem{
192 Commit: commit,
193 Meta: meta,
194 Category: category,
195 Selected: true,
196 })
197 }
198 }
199
200 changesDir := ".changes"
201 existingMetadata, err := changeset.LoadExistingMetadata(changesDir)
202 if err != nil {
203 return fmt.Errorf("failed to load existing metadata: %w", err)
204 }
205
206 created := 0
207 skipped := 0
208 duplicates := 0
209 rebased := 0
210
211 for _, item := range selectedItems {
212 if item.Category == "" {
213 skipped++
214 continue
215 }
216
217 diffHash, err := changeset.ComputeDiffHash(item.Commit)
218 if err != nil {
219 style.Println("Warning: failed to compute diff hash for commit %s: %v", item.Commit.Hash.String()[:7], err)
220 skipped++
221 continue
222 }
223
224 if existing, exists := existingMetadata[diffHash]; exists {
225 if existing.CommitHash == item.Commit.Hash.String() {
226 duplicates++
227 continue
228 } else {
229 if err := changeset.UpdateMetadata(changesDir, diffHash, item.Commit.Hash.String()); err != nil {
230 style.Println("Warning: failed to update metadata for rebased commit: %v", err)
231 continue
232 }
233 style.Println(" Updated rebased commit %s (was %s)", item.Commit.Hash.String()[:7], existing.CommitHash[:7])
234 rebased++
235 continue
236 }
237 }
238
239 meta := changeset.Metadata{
240 CommitHash: item.Commit.Hash.String(),
241 DiffHash: diffHash,
242 Type: item.Category,
243 Scope: item.Meta.Scope,
244 Summary: item.Meta.Description,
245 Breaking: item.Meta.Breaking,
246 Author: item.Commit.Author.Name,
247 Date: item.Commit.Author.When,
248 }
249
250 filePath, err := changeset.WriteWithMetadata(changesDir, meta)
251 if err != nil {
252 fmt.Printf("Error: failed to write entry: %v\n", err)
253 skipped++
254 continue
255 }
256 style.Addedf("✓ Created %s", filePath)
257 created++
258 }
259
260 if outputJSON {
261 entries, err := changeset.List(changesDir)
262 if err != nil {
263 return fmt.Errorf("failed to list generated entries: %w", err)
264 }
265
266 output := GenerateOutput{
267 From: from,
268 To: to,
269 TotalCommits: len(commits),
270 Statistics: GenerateStatistics{
271 Created: created,
272 Skipped: skipped,
273 Duplicates: duplicates,
274 Rebased: rebased,
275 },
276 Entries: entries,
277 }
278
279 jsonBytes, err := json.MarshalIndent(output, "", " ")
280 if err != nil {
281 return fmt.Errorf("failed to marshal output to JSON: %w", err)
282 }
283 fmt.Println(string(jsonBytes))
284 return nil
285 }
286
287 style.Newline()
288 style.Headlinef("Generated %d new changelog entries", created)
289 if duplicates > 0 {
290 style.Println(" Skipped %d duplicates", duplicates)
291 }
292 if rebased > 0 {
293 style.Println(" Updated %d rebased commits", rebased)
294 }
295 if skipped > 0 {
296 style.Println(" Skipped %d commits (reverts or non-matching types)", skipped)
297 }
298
299 return nil
300 },
301 }
302
303 c.Flags().BoolVarP(&interactive, "interactive", "i", false, "Review changes interactively in a TUI")
304 c.Flags().StringVar(&sinceTag, "since", "", "Generate changes since the given tag")
305 c.Flags().BoolVar(&outputJSON, "output-json", false, "Output results as JSON")
306 return c
307}