changelog generator & diff tool
stormlightlabs.github.io/git-storm/
changelog
changeset
markdown
golang
git
1/*
2USAGE
3
4 storm unreleased <subcommand> [options]
5
6SUBCOMMANDS
7
8 add Add a new unreleased change entry
9 list List all unreleased changes
10 review Review unreleased changes interactively
11 partial Create entry linked to a specific commit
12
13USAGE
14
15 storm unreleased add [options]
16
17FLAGS
18
19 --type <type> Change type (added, changed, fixed, removed, security)
20 --scope <scope> Optional subsystem or module name
21 --summary <text> Short description of the change
22 --repo <path> Path to the repository (default: .)
23
24USAGE
25
26 storm unreleased list [options]
27
28FLAGS
29
30 --json Output as JSON
31 --repo <path> Path to the repository (default: .)
32
33USAGE
34
35 storm unreleased review [options]
36
37FLAGS
38
39 --repo <path> Path to the repository (default: .)
40 --output <file> Optional file to export reviewed notes
41
42USAGE
43
44 storm unreleased partial <commit-ref> [options]
45
46FLAGS
47
48 --type <type> Override change type (auto-detected from commit message)
49 --summary <text> Override summary (auto-detected from commit message)
50 --scope <scope> Optional subsystem or module name
51 --repo <path> Path to the repository (default: .)
52*/
53package main
54
55import (
56 "encoding/json"
57 "fmt"
58 "slices"
59 "strings"
60
61 tea "github.com/charmbracelet/bubbletea"
62 "github.com/go-git/go-git/v6"
63 "github.com/go-git/go-git/v6/plumbing"
64 "github.com/spf13/cobra"
65 "github.com/stormlightlabs/git-storm/internal/changeset"
66 "github.com/stormlightlabs/git-storm/internal/gitlog"
67 "github.com/stormlightlabs/git-storm/internal/style"
68 "github.com/stormlightlabs/git-storm/internal/tty"
69 "github.com/stormlightlabs/git-storm/internal/ui"
70)
71
72func unreleasedCmd() *cobra.Command {
73 var (
74 changeType string
75 scope string
76 summary string
77 outputJSON bool
78 )
79
80 changesDir := ".changes"
81 validTypes := []string{"added", "changed", "fixed", "removed", "security"}
82
83 add := &cobra.Command{
84 Use: "add",
85 Short: "Add a new unreleased change entry",
86 Long: `Creates a new .changes/<date>-<summary>.md file with the specified type,
87scope, and summary.`,
88 RunE: func(cmd *cobra.Command, args []string) error {
89 if !slices.Contains(validTypes, changeType) {
90 return fmt.Errorf("invalid type %q: must be one of %s", changeType, strings.Join(validTypes, ", "))
91 }
92
93 if filePath, err := changeset.Write(changesDir, changeset.Entry{
94 Type: changeType,
95 Scope: scope,
96 Summary: summary,
97 }); err != nil {
98 return fmt.Errorf("failed to create changelog entry: %w", err)
99 } else {
100 style.Addedf("Created %s", filePath)
101 return nil
102 }
103 },
104 }
105 add.Flags().StringVar(&changeType, "type", "", "Type of change (added, changed, fixed, removed, security)")
106 add.Flags().StringVar(&scope, "scope", "", "Optional scope or subsystem name")
107 add.Flags().StringVar(&summary, "summary", "", "Short summary of the change")
108 add.MarkFlagRequired("type")
109 add.MarkFlagRequired("summary")
110
111 list := &cobra.Command{
112 Use: "list",
113 Short: "List all unreleased changes",
114 Long: "Prints all pending .changes entries to stdout. Supports JSON output.",
115 RunE: func(cmd *cobra.Command, args []string) error {
116 entries, err := changeset.List(changesDir)
117 if err != nil {
118 return fmt.Errorf("failed to list changelog entries: %w", err)
119 }
120
121 if len(entries) == 0 {
122 style.Println("No unreleased changes found")
123 return nil
124 }
125
126 if outputJSON {
127 jsonBytes, err := json.MarshalIndent(entries, "", " ")
128 if err != nil {
129 return fmt.Errorf("failed to marshal entries to JSON: %w", err)
130 }
131 fmt.Println(string(jsonBytes))
132 return nil
133 }
134
135 style.Headlinef("Found %d unreleased change(s):", len(entries))
136 style.Newline()
137
138 for _, e := range entries {
139 displayEntry(e)
140 }
141
142 return nil
143 },
144 }
145 list.Flags().BoolVar(&outputJSON, "json", false, "Output results as JSON")
146
147 review := &cobra.Command{
148 Use: "review",
149 Short: "Review unreleased changes interactively",
150 Long: `Launches an interactive Bubble Tea TUI to review, edit, or categorize
151unreleased entries before final release.`,
152 RunE: func(cmd *cobra.Command, args []string) error {
153 if !tty.IsInteractive() {
154 return tty.ErrorInteractiveRequired("storm unreleased review", []string{
155 "Use 'storm unreleased list' to view entries in plain text",
156 "Use 'storm unreleased list --json' for JSON output",
157 })
158 }
159
160 entries, err := changeset.List(changesDir)
161 if err != nil {
162 return fmt.Errorf("failed to list changelog entries: %w", err)
163 }
164
165 if len(entries) == 0 {
166 style.Println("No unreleased changes found")
167 return nil
168 }
169
170 model := ui.NewChangesetReviewModel(entries)
171 p := tea.NewProgram(model, tea.WithAltScreen())
172
173 finalModel, err := p.Run()
174 if err != nil {
175 return fmt.Errorf("failed to run review TUI: %w", err)
176 }
177
178 reviewModel, ok := finalModel.(ui.ChangesetReviewModel)
179 if !ok {
180 return fmt.Errorf("unexpected model type")
181 }
182
183 if reviewModel.IsCancelled() {
184 style.Headline("Review cancelled")
185 return nil
186 }
187
188 items := reviewModel.GetReviewedItems()
189 deleteCount := 0
190 editCount := 0
191
192 for _, item := range items {
193 if item.Action == ui.ActionDelete {
194 if err := changeset.Delete(changesDir, item.Entry.Filename); err != nil {
195 return fmt.Errorf("failed to delete %s: %w", item.Entry.Filename, err)
196 }
197 deleteCount++
198 style.Successf("Deleted: %s", item.Entry.Filename)
199 }
200 }
201
202 for _, item := range items {
203 if item.Action == ui.ActionEdit {
204 editorModel := ui.NewEntryEditorModel(item.Entry)
205 p := tea.NewProgram(editorModel, tea.WithAltScreen())
206
207 finalModel, err := p.Run()
208 if err != nil {
209 return fmt.Errorf("failed to run editor TUI: %w", err)
210 }
211
212 editor, ok := finalModel.(ui.EntryEditorModel)
213 if !ok {
214 return fmt.Errorf("unexpected model type")
215 }
216
217 if editor.IsCancelled() {
218 style.Warningf("Skipped editing: %s", item.Entry.Filename)
219 continue
220 }
221
222 if editor.IsConfirmed() {
223 editedEntry := editor.GetEditedEntry()
224 if err := changeset.Update(changesDir, item.Entry.Filename, editedEntry); err != nil {
225 return fmt.Errorf("failed to update %s: %w", item.Entry.Filename, err)
226 }
227 editCount++
228 style.Successf("Updated: %s", item.Entry.Filename)
229 }
230 }
231 }
232
233 if deleteCount == 0 && editCount == 0 {
234 style.Headline("No changes requested")
235 return nil
236 }
237
238 style.Headlinef("Review completed: %d deleted, %d edited", deleteCount, editCount)
239 return nil
240 },
241 }
242
243 partial := &cobra.Command{
244 Use: "partial <commit-ref>",
245 Short: "Create entry linked to a specific commit",
246 Long: `Creates a new .changes/<sha7>.<type>.md file based on the specified commit.
247Auto-detects type and summary from conventional commit format, with optional overrides.`,
248 Args: cobra.ExactArgs(1),
249 RunE: func(cmd *cobra.Command, args []string) error {
250 commitRef := args[0]
251
252 repo, err := git.PlainOpen(repoPath)
253 if err != nil {
254 return fmt.Errorf("failed to open repository: %w", err)
255 }
256
257 hash, err := repo.ResolveRevision(plumbing.Revision(commitRef))
258 if err != nil {
259 return fmt.Errorf("failed to resolve commit ref %q: %w", commitRef, err)
260 }
261
262 commit, err := repo.CommitObject(*hash)
263 if err != nil {
264 return fmt.Errorf("failed to get commit object: %w", err)
265 }
266
267 parser := &gitlog.ConventionalParser{}
268 subject := commit.Message
269 body := ""
270 lines := strings.Split(commit.Message, "\n")
271 if len(lines) > 0 {
272 subject = lines[0]
273 if len(lines) > 1 {
274 body = strings.Join(lines[1:], "\n")
275 }
276 }
277
278 meta, err := parser.Parse(hash.String(), subject, body, commit.Author.When)
279 if err != nil {
280 return fmt.Errorf("failed to parse commit message: %w", err)
281 }
282
283 category := parser.Categorize(meta)
284
285 if changeType != "" {
286 if !slices.Contains(validTypes, changeType) {
287 return fmt.Errorf("invalid type %q: must be one of %s", changeType, strings.Join(validTypes, ", "))
288 }
289 category = changeType
290 } else if category == "" {
291 return fmt.Errorf("could not auto-detect change type from commit message, please specify --type")
292 }
293
294 entrySummary := meta.Description
295 if summary != "" {
296 entrySummary = summary
297 }
298
299 if scope != "" {
300 meta.Scope = scope
301 }
302
303 sha7 := hash.String()[:7]
304 filename := fmt.Sprintf("%s.%s.md", sha7, category)
305 filePath := changesDir + "/" + filename
306
307 entry := changeset.Entry{
308 Type: category,
309 Scope: meta.Scope,
310 Summary: entrySummary,
311 Breaking: meta.Breaking,
312 CommitHash: hash.String(),
313 }
314
315 if _, err := changeset.WritePartial(changesDir, filename, entry); err != nil {
316 return fmt.Errorf("failed to create changelog entry: %w", err)
317 }
318
319 style.Addedf("Created %s", filePath)
320 return nil
321 },
322 }
323 partial.Flags().StringVar(&changeType, "type", "", "Override change type (auto-detected from commit)")
324 partial.Flags().StringVar(&scope, "scope", "", "Optional scope or subsystem name")
325 partial.Flags().StringVar(&summary, "summary", "", "Override summary (auto-detected from commit)")
326
327 root := &cobra.Command{
328 Use: "unreleased",
329 Short: "Manage unreleased changes (.changes directory)",
330 Long: `Work with unreleased change notes. Supports adding, listing,
331and reviewing pending entries before release.`,
332 }
333 root.AddCommand(add, list, review, partial)
334 return root
335}
336
337// displayEntry formats and prints a single changelog entry with color-coded type.
338func displayEntry(e changeset.EntryWithFile) {
339 var typeLabel string
340 switch e.Entry.Type {
341 case "added":
342 typeLabel = style.StyleAdded.Render(fmt.Sprintf("[%s]", e.Entry.Type))
343 case "changed":
344 typeLabel = style.StyleChanged.Render(fmt.Sprintf("[%s]", e.Entry.Type))
345 case "fixed":
346 typeLabel = style.StyleFixed.Render(fmt.Sprintf("[%s]", e.Entry.Type))
347 case "removed":
348 typeLabel = style.StyleRemoved.Render(fmt.Sprintf("[%s]", e.Entry.Type))
349 case "security":
350 typeLabel = style.StyleSecurity.Render(fmt.Sprintf("[%s]", e.Entry.Type))
351 default:
352 typeLabel = fmt.Sprintf("[%s]", e.Entry.Type)
353 }
354
355 var scopePart string
356 if e.Entry.Scope != "" {
357 scopePart = fmt.Sprintf("(%s) ", e.Entry.Scope)
358 }
359
360 style.Println("%s %s%s", typeLabel, scopePart, e.Entry.Summary)
361 style.Println(" File: %s", e.Filename)
362 if e.Entry.Breaking {
363 style.Println(" Breaking: %s\n", style.StyleRemoved.Render("YES"))
364 }
365 style.Newline()
366}