changelog generator & diff tool stormlightlabs.github.io/git-storm/
changelog changeset markdown golang git
at main 9.0 kB view raw
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}