changelog generator & diff tool stormlightlabs.github.io/git-storm/
changelog changeset markdown golang git

feat(unreleased): commit selector for unreleased section of CHANGELOG

Changed files
+370 -67
cmd
internal
+41 -6
cmd/generate.go
··· 30 30 sinceTag string 31 31 ) 32 32 33 + // TODO(determinism): Add deduplication logic using diff-based identity 34 + // 35 + // Currently generates duplicate .changes/*.md files when: 36 + // 1. Running generate multiple times on the same range 37 + // 2. History is rewritten (rebase/amend) but commit content unchanged 38 + // 39 + // Implementation: 40 + // 41 + // 1. Before processing commits, load existing entries: 42 + // existingEntries := changeset.LoadExisting(".changes/data") 43 + // // Returns map[diffHash]Metadata for O(1) lookups 44 + // 45 + // 2. For each selected commit: 46 + // a. Compute diff hash: diffHash := changeset.ComputeDiffHash(repo, commit) 47 + // b. Check if exists: if meta, exists := existingEntries[diffHash]; exists { 48 + // - Same commit hash → true duplicate, skip 49 + // - Different commit hash → rebased/cherry-picked 50 + // * If --update-rebased: update metadata.CommitHash in JSON 51 + // * If --skip-rebased: skip (default) 52 + // * If --warn-rebased: print warning and skip 53 + // } 54 + // c. If not exists: create new entry with diff hash as filename 55 + // 56 + // 3. Report statistics: 57 + // - N new entries created 58 + // - M duplicates skipped (same commit) 59 + // - K rebased commits detected (same diff, different commit) 60 + // 61 + // Flags to add: 62 + // --update-rebased Update commit hash for rebased entries 63 + // --skip-rebased Skip rebased commits (default) 64 + // --warn-rebased Print warnings for rebased commits 65 + // --force Regenerate all entries (ignore existing) 66 + // 67 + // Related: See internal/changeset/changeset.go TODO for implementation details 33 68 func generateCmd() *cobra.Command { 34 69 c := &cobra.Command{ 35 70 Use: "generate [from] [to]", ··· 65 100 } 66 101 67 102 if len(commits) == 0 { 68 - style.Headline(fmt.Sprintf("No commits found between %s and %s", from, to)) 103 + style.Headlinef("No commits found between %s and %s", from, to) 69 104 return nil 70 105 } 71 106 ··· 98 133 return nil 99 134 } 100 135 101 - style.Headline(fmt.Sprintf("Generating entries for %d selected commits", len(selectedItems))) 136 + style.Headlinef("Generating entries for %d selected commits", len(selectedItems)) 102 137 } else { 103 - style.Headline(fmt.Sprintf("Found %d commits between %s and %s", len(commits), from, to)) 138 + style.Headlinef("Found %d commits between %s and %s", len(commits), from, to) 104 139 105 140 for _, commit := range commits { 106 141 subject := commit.Message ··· 115 150 116 151 meta, err := parser.Parse(commit.Hash.String(), subject, body, commit.Author.When) 117 152 if err != nil { 118 - fmt.Printf("Warning: failed to parse commit %s: %v\n", commit.Hash.String()[:7], err) 153 + style.Println("Warning: failed to parse commit %s: %v", commit.Hash.String()[:gitlog.ShaLen], err) 119 154 continue 120 155 } 121 156 ··· 164 199 created++ 165 200 } 166 201 167 - fmt.Println() 168 - style.Headline(fmt.Sprintf("Generated %d changelog entries", created)) 202 + style.Newline() 203 + style.Headlinef("Generated %d changelog entries", created) 169 204 if skipped > 0 { 170 205 style.Println("Skipped %d commits (reverts or non-matching types)", skipped) 171 206 }
+2 -53
cmd/main.go
··· 7 7 "github.com/charmbracelet/fang" 8 8 "github.com/charmbracelet/log" 9 9 "github.com/spf13/cobra" 10 + "github.com/stormlightlabs/git-storm/internal/style" 10 11 ) 11 12 12 13 var ( ··· 61 62 return c 62 63 } 63 64 64 - func unreleasedCmd() *cobra.Command { 65 - add := &cobra.Command{ 66 - Use: "add", 67 - Short: "Add a new unreleased change entry", 68 - Long: `Creates a new .changes/<date>-<summary>.md file with the specified type, 69 - scope, and summary.`, 70 - RunE: func(cmd *cobra.Command, args []string) error { 71 - fmt.Println("unreleased add not implemented") 72 - fmt.Printf("type=%q scope=%q summary=%q\n", changeType, scope, summary) 73 - return nil 74 - }, 75 - } 76 - add.Flags().StringVar(&changeType, "type", "", "Type of change (added, changed, fixed, removed, security)") 77 - add.Flags().StringVar(&scope, "scope", "", "Optional scope or subsystem name") 78 - add.Flags().StringVar(&summary, "summary", "", "Short summary of the change") 79 - add.MarkFlagRequired("type") 80 - add.MarkFlagRequired("summary") 81 - 82 - list := &cobra.Command{ 83 - Use: "list", 84 - Short: "List all unreleased changes", 85 - Long: "Prints all pending .changes entries to stdout. Supports JSON output.", 86 - RunE: func(cmd *cobra.Command, args []string) error { 87 - fmt.Println("unreleased list not implemented") 88 - fmt.Printf("outputJSON=%v\n", outputJSON) 89 - return nil 90 - }, 91 - } 92 - list.Flags().BoolVar(&outputJSON, "json", false, "Output results as JSON") 93 - 94 - review := &cobra.Command{ 95 - Use: "review", 96 - Short: "Review unreleased changes interactively", 97 - Long: `Launches an interactive Bubble Tea TUI to review, edit, or categorize 98 - unreleased entries before final release.`, 99 - RunE: func(cmd *cobra.Command, args []string) error { 100 - fmt.Println("unreleased review not implemented (TUI)") 101 - return nil 102 - }, 103 - } 104 - 105 - root := &cobra.Command{ 106 - Use: "unreleased", 107 - Short: "Manage unreleased changes (.changes directory)", 108 - Long: `Work with unreleased change notes. Supports adding, listing, 109 - and reviewing pending entries before release.`, 110 - } 111 - root.AddCommand(add, list, review) 112 - 113 - return root 114 - } 115 - 116 65 func main() { 117 66 ctx := context.Background() 118 67 root := &cobra.Command{ ··· 127 76 root.PersistentFlags().StringVarP(&output, "output", "o", "CHANGELOG.md", "Output changelog file path") 128 77 root.AddCommand(generateCmd(), unreleasedCmd(), releaseCmd(), diffCmd(), versionCmd()) 129 78 130 - if err := fang.Execute(ctx, root); err != nil { 79 + if err := fang.Execute(ctx, root, fang.WithColorSchemeFunc(style.NewColorScheme)); err != nil { 131 80 log.Fatalf("Execution failed: %v", err) 132 81 } 133 82 }
+135
cmd/unreleased.go
··· 39 39 --output <file> Optional file to export reviewed notes 40 40 */ 41 41 package main 42 + 43 + import ( 44 + "encoding/json" 45 + "fmt" 46 + "slices" 47 + "strings" 48 + 49 + "github.com/spf13/cobra" 50 + "github.com/stormlightlabs/git-storm/internal/changeset" 51 + "github.com/stormlightlabs/git-storm/internal/style" 52 + ) 53 + 54 + func unreleasedCmd() *cobra.Command { 55 + add := &cobra.Command{ 56 + Use: "add", 57 + Short: "Add a new unreleased change entry", 58 + Long: `Creates a new .changes/<date>-<summary>.md file with the specified type, 59 + scope, and summary.`, 60 + RunE: func(cmd *cobra.Command, args []string) error { 61 + validTypes := []string{"added", "changed", "fixed", "removed", "security"} 62 + if !slices.Contains(validTypes, changeType) { 63 + return fmt.Errorf("invalid type %q: must be one of %s", changeType, strings.Join(validTypes, ", ")) 64 + } 65 + 66 + entry := changeset.Entry{ 67 + Type: changeType, 68 + Scope: scope, 69 + Summary: summary, 70 + } 71 + 72 + changesDir := ".changes" 73 + filePath, err := changeset.Write(changesDir, entry) 74 + if err != nil { 75 + return fmt.Errorf("failed to create changelog entry: %w", err) 76 + } 77 + 78 + style.Addedf("Created %s", filePath) 79 + return nil 80 + }, 81 + } 82 + add.Flags().StringVar(&changeType, "type", "", "Type of change (added, changed, fixed, removed, security)") 83 + add.Flags().StringVar(&scope, "scope", "", "Optional scope or subsystem name") 84 + add.Flags().StringVar(&summary, "summary", "", "Short summary of the change") 85 + add.MarkFlagRequired("type") 86 + add.MarkFlagRequired("summary") 87 + 88 + list := &cobra.Command{ 89 + Use: "list", 90 + Short: "List all unreleased changes", 91 + Long: "Prints all pending .changes entries to stdout. Supports JSON output.", 92 + RunE: func(cmd *cobra.Command, args []string) error { 93 + changesDir := ".changes" 94 + entries, err := changeset.List(changesDir) 95 + if err != nil { 96 + return fmt.Errorf("failed to list changelog entries: %w", err) 97 + } 98 + 99 + if len(entries) == 0 { 100 + style.Println("No unreleased changes found") 101 + return nil 102 + } 103 + 104 + if outputJSON { 105 + jsonBytes, err := json.MarshalIndent(entries, "", " ") 106 + if err != nil { 107 + return fmt.Errorf("failed to marshal entries to JSON: %w", err) 108 + } 109 + fmt.Println(string(jsonBytes)) 110 + return nil 111 + } 112 + 113 + style.Headlinef("Found %d unreleased change(s):", len(entries)) 114 + style.Newline() 115 + 116 + for _, e := range entries { 117 + displayEntry(e) 118 + } 119 + 120 + return nil 121 + }, 122 + } 123 + list.Flags().BoolVar(&outputJSON, "json", false, "Output results as JSON") 124 + 125 + review := &cobra.Command{ 126 + Use: "review", 127 + Short: "Review unreleased changes interactively", 128 + Long: `Launches an interactive Bubble Tea TUI to review, edit, or categorize 129 + unreleased entries before final release.`, 130 + RunE: func(cmd *cobra.Command, args []string) error { 131 + fmt.Println("unreleased review not implemented (TUI)") 132 + return nil 133 + }, 134 + } 135 + 136 + root := &cobra.Command{ 137 + Use: "unreleased", 138 + Short: "Manage unreleased changes (.changes directory)", 139 + Long: `Work with unreleased change notes. Supports adding, listing, 140 + and reviewing pending entries before release.`, 141 + } 142 + root.AddCommand(add, list, review) 143 + 144 + return root 145 + } 146 + 147 + // displayEntry formats and prints a single changelog entry with color-coded type. 148 + func displayEntry(e changeset.EntryWithFile) { 149 + var typeLabel string 150 + switch e.Entry.Type { 151 + case "added": 152 + typeLabel = style.StyleAdded.Render(fmt.Sprintf("[%s]", e.Entry.Type)) 153 + case "changed": 154 + typeLabel = style.StyleChanged.Render(fmt.Sprintf("[%s]", e.Entry.Type)) 155 + case "fixed": 156 + typeLabel = style.StyleFixed.Render(fmt.Sprintf("[%s]", e.Entry.Type)) 157 + case "removed": 158 + typeLabel = style.StyleRemoved.Render(fmt.Sprintf("[%s]", e.Entry.Type)) 159 + case "security": 160 + typeLabel = style.StyleSecurity.Render(fmt.Sprintf("[%s]", e.Entry.Type)) 161 + default: 162 + typeLabel = fmt.Sprintf("[%s]", e.Entry.Type) 163 + } 164 + 165 + var scopePart string 166 + if e.Entry.Scope != "" { 167 + scopePart = fmt.Sprintf("(%s) ", e.Entry.Scope) 168 + } 169 + 170 + style.Println("%s %s%s", typeLabel, scopePart, e.Entry.Summary) 171 + style.Println(" File: %s", e.Filename) 172 + if e.Entry.Breaking { 173 + style.Println(" Breaking: %s\n", style.StyleRemoved.Render("YES")) 174 + } 175 + style.Newline() 176 + }
+1 -1
go.mod
··· 27 27 github.com/aymanbagabas/go-udiff v0.3.1 // indirect 28 28 github.com/charmbracelet/bubbletea v1.3.10 29 29 github.com/charmbracelet/colorprofile v0.3.3 // indirect 30 - github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250917201909-41ff0bf215ea // indirect 30 + github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250917201909-41ff0bf215ea 31 31 github.com/charmbracelet/ultraviolet v0.0.0-20250915111650-81d4262876ef // indirect 32 32 github.com/charmbracelet/x/ansi v0.10.3 // indirect 33 33 github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
+101 -3
internal/changeset/changeset.go
··· 1 + // TODO(determinism): Make changeset file generation deterministic using diff-based identity 2 + // 3 + // Current implementation uses [time.Now] for filenames, causing duplicate entries 4 + // when generate is run multiple times on the same commit range. 5 + // 6 + // Store commit metadata in .changes/data/<diff-hash>.json: 7 + // - Compute hash of git diff content (not commit message) 8 + // - Use diff hash as stable identifier across rebases 9 + // - Store JSON metadata: {commit_hash, diff_hash, type, scope, summary, breaking, author, date} 10 + // - Generate .changes/<diff-hash-7>-<slug>.md from metadata 11 + // 12 + // Implementation: 13 + // 1. Add DiffHash field to Entry struct 14 + // 2. Add CommitHash field for tracking source (optional, for reference) 15 + // 3. Create ComputeDiffHash(commit) function: 16 + // - Get commit.Tree() and parent.Tree() 17 + // - Compute diff between trees 18 + // - Hash the diff content (files changed + line changes) 19 + // - Return hex string 20 + // 21 + // 4. Update Write() to: 22 + // - Accept diff hash as parameter 23 + // - Use format: .changes/<diff-hash-7>-<slug>.md 24 + // - Write JSON to .changes/data/<diff-hash>.json 25 + // - Check if diff hash exists before writing (deduplication) 26 + // 27 + // 5. Add Read() function to parse existing entries by diff hash 28 + // 29 + // Directory structure: 30 + // 31 + // .changes/ 32 + // a1b2c3d-add-authentication.md # Human-readable entry 33 + // e5f6a7b-fix-memory-leak.md 34 + // data/ 35 + // a1b2c3d4e5f6...json # Full metadata 36 + // e5f6a7b8c9d0...json 37 + // 38 + // Related: See cmd/generate.go TODO for deduplication logic 1 39 package changeset 2 40 3 41 import ( 42 + "bytes" 4 43 "fmt" 5 44 "os" 6 45 "path/filepath" ··· 36 75 if _, err := os.Stat(filePath); os.IsNotExist(err) { 37 76 break 38 77 } 39 - // File exists, add counter 40 78 filename = fmt.Sprintf("%s-%s-%d.md", timestamp, slug, counter) 41 79 filePath = filepath.Join(dir, filename) 42 80 counter++ ··· 56 94 return filePath, nil 57 95 } 58 96 59 - // slugify converts a string into a URL-friendly slug. 60 - // Converts to lowercase, replaces spaces and special chars with hyphens. 97 + // slugify converts a string into a URL-friendly slug by converting to lowercase, 98 + // replaces spaces and special chars with hyphens. 61 99 func slugify(input string) string { 62 100 s := strings.ToLower(input) 63 101 reg := regexp.MustCompile(`[^a-z0-9]+`) ··· 72 110 73 111 return s 74 112 } 113 + 114 + // EntryWithFile pairs an Entry with its source filename for display/processing. 115 + type EntryWithFile struct { 116 + Entry Entry 117 + Filename string 118 + } 119 + 120 + // List reads all .changes/*.md files and returns their parsed entries. 121 + func List(dir string) ([]EntryWithFile, error) { 122 + entries, err := os.ReadDir(dir) 123 + if err != nil { 124 + if os.IsNotExist(err) { 125 + return []EntryWithFile{}, nil 126 + } 127 + return nil, fmt.Errorf("failed to read directory %s: %w", dir, err) 128 + } 129 + 130 + var results []EntryWithFile 131 + 132 + for _, entry := range entries { 133 + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") { 134 + continue 135 + } 136 + 137 + filePath := filepath.Join(dir, entry.Name()) 138 + content, err := os.ReadFile(filePath) 139 + if err != nil { 140 + return nil, fmt.Errorf("failed to read file %s: %w", filePath, err) 141 + } 142 + 143 + parsed, err := parseEntry(content) 144 + if err != nil { 145 + return nil, fmt.Errorf("failed to parse %s: %w", entry.Name(), err) 146 + } 147 + 148 + results = append(results, EntryWithFile{ 149 + Entry: parsed, 150 + Filename: entry.Name(), 151 + }) 152 + } 153 + 154 + return results, nil 155 + } 156 + 157 + // parseEntry extracts YAML frontmatter from a markdown file and unmarshals it into an Entry. 158 + func parseEntry(content []byte) (Entry, error) { 159 + var entry Entry 160 + 161 + parts := bytes.Split(content, []byte("---")) 162 + if len(parts) < 3 { 163 + return entry, fmt.Errorf("invalid frontmatter format: expected ---...--- delimiters") 164 + } 165 + 166 + yamlContent := parts[1] 167 + if err := yaml.Unmarshal(yamlContent, &entry); err != nil { 168 + return entry, fmt.Errorf("failed to unmarshal YAML: %w", err) 169 + } 170 + 171 + return entry, nil 172 + }
+10 -3
internal/gitlog/gitlog.go
··· 10 10 "github.com/go-git/go-git/v6/plumbing/object" 11 11 ) 12 12 13 + const ShaLen = 7 14 + 13 15 // CommitKind represents the kind of commit according to Conventional Commits. 14 16 type CommitKind int 15 17 ··· 91 93 type ConventionalParser struct{} 92 94 93 95 // Parse parses a conventional commit message into structured metadata. 94 - // Format: type(scope): description or type(scope)!: description 96 + // 97 + // Format: 98 + // 99 + // type(scope): description or type(scope)!: description 100 + // 95 101 // Breaking changes can also be indicated by BREAKING CHANGE: in footer. 96 102 func (p *ConventionalParser) Parse(hash, subject, body string, date time.Time) (CommitMeta, error) { 97 103 meta := CommitMeta{ ··· 239 245 case "docs", "style", "test", "build", "ci", "chore": 240 246 return "changed" 241 247 case "revert": 242 - return "" // Skip reverts 248 + return "" 243 249 default: 244 - return "" // Unknown types are skipped 250 + return "" 245 251 } 246 252 } 247 253 ··· 271 277 } 272 278 273 279 // ParseRefArgs parses command arguments to extract from/to refs. 280 + // 274 281 // Supports both "from..to" and "from to" syntax. 275 282 // If only one arg, treats it as from with to=HEAD. 276 283 func ParseRefArgs(args []string) (from, to string) {
+79
internal/style/style.go
··· 3 3 4 4 import ( 5 5 "fmt" 6 + "image/color" 6 7 8 + "github.com/charmbracelet/fang" 7 9 "github.com/charmbracelet/lipgloss" 10 + lg "github.com/charmbracelet/lipgloss/v2" 8 11 ) 9 12 10 13 var ( ··· 38 41 fmt.Println(v) 39 42 } 40 43 44 + func Headlinef(format string, args ...any) { 45 + s := fmt.Sprintf(format, args...) 46 + v := StyleHeadline.Render(s) 47 + fmt.Println(v) 48 + } 49 + 41 50 func Added(s string) { 42 51 v := StyleAdded.Render(s) 43 52 fmt.Println(v) ··· 49 58 fmt.Println(v) 50 59 } 51 60 61 + func Newline() { fmt.Println() } 62 + 52 63 func Fixed(s string) { 53 64 v := StyleFixed.Render(s) 54 65 fmt.Println(v) ··· 67 78 msg := fmt.Sprintf(format, args...) 68 79 fmt.Println(msg) 69 80 } 81 + 82 + var darkTheme = fang.ColorScheme{ 83 + Base: color.RGBA{25, 28, 35, 255}, 84 + Title: color.RGBA{129, 161, 193, 255}, 85 + Description: color.RGBA{180, 198, 211, 255}, 86 + Codeblock: color.RGBA{46, 52, 64, 255}, 87 + Program: color.RGBA{94, 129, 172, 255}, 88 + DimmedArgument: color.RGBA{110, 115, 125, 255}, 89 + Comment: color.RGBA{76, 86, 106, 255}, 90 + Flag: color.RGBA{143, 188, 187, 255}, 91 + FlagDefault: color.RGBA{163, 190, 140, 255}, 92 + Command: color.RGBA{208, 135, 112, 255}, 93 + QuotedString: color.RGBA{136, 192, 208, 255}, 94 + Argument: color.RGBA{191, 97, 106, 255}, 95 + Help: color.RGBA{143, 188, 187, 255}, 96 + Dash: color.RGBA{216, 222, 233, 255}, 97 + ErrorHeader: [2]color.Color{ 98 + color.RGBA{236, 239, 244, 255}, 99 + color.RGBA{191, 97, 106, 255}, 100 + }, 101 + ErrorDetails: color.RGBA{255, 203, 107, 255}, 102 + } 103 + 104 + var lightTheme = fang.ColorScheme{ 105 + Base: color.RGBA{245, 247, 250, 255}, 106 + Title: color.RGBA{52, 73, 94, 255}, 107 + Description: color.RGBA{88, 110, 117, 255}, 108 + Codeblock: color.RGBA{230, 235, 240, 255}, 109 + Program: color.RGBA{70, 106, 145, 255}, 110 + DimmedArgument: color.RGBA{140, 145, 155, 255}, 111 + Comment: color.RGBA{150, 160, 170, 255}, 112 + Flag: color.RGBA{0, 114, 178, 255}, 113 + FlagDefault: color.RGBA{106, 153, 85, 255}, 114 + Command: color.RGBA{217, 95, 2, 255}, 115 + QuotedString: color.RGBA{38, 139, 210, 255}, 116 + Argument: color.RGBA{203, 75, 22, 255}, 117 + Help: color.RGBA{0, 114, 178, 255}, 118 + Dash: color.RGBA{120, 130, 140, 255}, 119 + ErrorHeader: [2]color.Color{ 120 + color.RGBA{255, 255, 255, 255}, 121 + color.RGBA{203, 75, 22, 255}, 122 + }, 123 + ErrorDetails: color.RGBA{230, 150, 50, 255}, 124 + } 125 + 126 + func NewColorScheme(c lg.LightDarkFunc) fang.ColorScheme { 127 + return fang.ColorScheme{ 128 + Base: c(lightTheme.Base, darkTheme.Base), 129 + Title: c(lightTheme.Title, darkTheme.Title), 130 + Description: c(lightTheme.Description, darkTheme.Description), 131 + Codeblock: c(lightTheme.Codeblock, darkTheme.Codeblock), 132 + Program: c(lightTheme.Program, darkTheme.Program), 133 + DimmedArgument: c(lightTheme.DimmedArgument, darkTheme.DimmedArgument), 134 + Comment: c(lightTheme.Comment, darkTheme.Comment), 135 + Flag: c(lightTheme.Flag, darkTheme.Flag), 136 + FlagDefault: c(lightTheme.FlagDefault, darkTheme.FlagDefault), 137 + Command: c(lightTheme.Command, darkTheme.Command), 138 + QuotedString: c(lightTheme.QuotedString, darkTheme.QuotedString), 139 + Argument: c(lightTheme.Argument, darkTheme.Argument), 140 + Help: c(lightTheme.Help, darkTheme.Help), 141 + Dash: c(lightTheme.Dash, darkTheme.Dash), 142 + ErrorHeader: [2]color.Color{ 143 + c(lightTheme.ErrorHeader[0], darkTheme.ErrorHeader[0]), 144 + c(lightTheme.ErrorHeader[1], darkTheme.ErrorHeader[1]), 145 + }, 146 + ErrorDetails: c(lightTheme.ErrorDetails, darkTheme.ErrorDetails), 147 + } 148 + }
+1 -1
internal/ui/commit_selector.go
··· 314 314 checkbox = "[✓]" 315 315 } 316 316 317 - shortHash := item.Commit.Hash.String()[:7] 317 + shortHash := item.Commit.Hash.String()[:gitlog.ShaLen] 318 318 subject := item.Meta.Description 319 319 if subject == "" { 320 320 subject = strings.Split(item.Commit.Message, "\n")[0]