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

Configure Feed

Select the types of activity you want to include in your feed.

feat: add JSON output modes

+301 -24
+3 -3
ROADMAP.md
··· 100 100 - [x] Validate changelog entries exist for commits 101 101 - [x] Honor `[nochanges]` markers 102 102 - [x] Exit codes for CI integration 103 - - [ ] Add JSON output modes for all commands 103 + - [x] Add JSON output modes for all commands 104 104 - [x] `unreleased list --json` 105 - - [ ] `generate --output-json` 106 - - [ ] `release --output-json` 105 + - [x] `generate --output-json` 106 + - [x] `release --output-json` 107 107 - [x] Add `--dry-run` support 108 108 - [x] `release --dry-run` 109 109 - [x] Show what would be written without writing
+52
cmd/generate.go
··· 8 8 -i, --interactive Review generated entries in a TUI 9 9 --since <tag> Generate changes since the given tag 10 10 -o, --output <path> Write generated changelog to path 11 + --output-json Output results as JSON 11 12 --repo <path> Path to the Git repository (default: .) 12 13 */ 13 14 package main 14 15 15 16 import ( 17 + "encoding/json" 16 18 "fmt" 17 19 "strings" 18 20 ··· 29 31 var ( 30 32 interactive bool 31 33 sinceTag string 34 + outputJSON bool 32 35 ) 36 + 37 + // GenerateOutput represents the JSON output structure for the generate command. 38 + type 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. 47 + type GenerateStatistics struct { 48 + Created int `json:"created"` 49 + Skipped int `json:"skipped"` 50 + Duplicates int `json:"duplicates"` 51 + Rebased int `json:"rebased"` 52 + } 33 53 34 54 // TODO(determinism): Add deduplication logic using diff-based identity 35 55 // ··· 77 97 RunE: func(cmd *cobra.Command, args []string) error { 78 98 if interactive && !tty.IsInteractive() { 79 99 return tty.ErrorInteractiveFlag("--interactive") 100 + } 101 + 102 + if interactive && outputJSON { 103 + return fmt.Errorf("--interactive and --output-json cannot be used together") 80 104 } 81 105 82 106 var from, to string ··· 233 257 created++ 234 258 } 235 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 + 236 287 style.Newline() 237 288 style.Headlinef("Generated %d new changelog entries", created) 238 289 if duplicates > 0 { ··· 251 302 252 303 c.Flags().BoolVarP(&interactive, "interactive", "i", false, "Review changes interactively in a TUI") 253 304 c.Flags().StringVar(&sinceTag, "since", "", "Generate changes since the given tag") 305 + c.Flags().BoolVar(&outputJSON, "output-json", false, "Output results as JSON") 254 306 return c 255 307 }
+82
cmd/generate_test.go
··· 1 1 package main 2 2 3 3 import ( 4 + "os" 5 + "strings" 4 6 "testing" 5 7 6 8 "github.com/stormlightlabs/git-storm/internal/gitlog" ··· 59 61 t.Errorf("Expected error for invalid ref, got nil") 60 62 } 61 63 } 64 + 65 + func TestGenerateCmd_JSONOutput(t *testing.T) { 66 + repo := testutils.SetupTestRepo(t) 67 + worktree, err := repo.Worktree() 68 + if err != nil { 69 + t.Fatalf("Failed to get worktree: %v", err) 70 + } 71 + 72 + commits := testutils.GetCommitHistory(t, repo) 73 + if len(commits) < 2 { 74 + t.Fatalf("Expected at least 2 commits, got %d", len(commits)) 75 + } 76 + 77 + oldCommit := commits[len(commits)-2] 78 + if err := testutils.CreateTagAtCommit(t, repo, "v1.0.0", oldCommit.Hash.String()); err != nil { 79 + t.Fatalf("Failed to create tag: %v", err) 80 + } 81 + 82 + testutils.AddCommit(t, repo, "feat.txt", "content", "feat: add new feature") 83 + testutils.AddCommit(t, repo, "fix.txt", "content", "fix: fix bug") 84 + 85 + repoPath = worktree.Filesystem.Root() 86 + outputJSON = true 87 + 88 + oldWd, err := os.Getwd() 89 + if err != nil { 90 + t.Fatalf("Failed to get current directory: %v", err) 91 + } 92 + defer func() { 93 + os.Chdir(oldWd) 94 + outputJSON = false 95 + }() 96 + 97 + if err := os.Chdir(repoPath); err != nil { 98 + t.Fatalf("Failed to change to temp directory: %v", err) 99 + } 100 + 101 + cmd := generateCmd() 102 + cmd.SetArgs([]string{"v1.0.0", "HEAD"}) 103 + 104 + err = cmd.Execute() 105 + if err != nil { 106 + t.Fatalf("generateCmd() error = %v", err) 107 + } 108 + } 109 + 110 + func TestGenerateCmd_InteractiveAndJSONConflict(t *testing.T) { 111 + repo := testutils.SetupTestRepo(t) 112 + worktree, err := repo.Worktree() 113 + if err != nil { 114 + t.Fatalf("Failed to get worktree: %v", err) 115 + } 116 + 117 + repoPath = worktree.Filesystem.Root() 118 + 119 + cmd := generateCmd() 120 + cmd.SetArgs([]string{"--interactive", "--output-json", "HEAD~1", "HEAD"}) 121 + 122 + err = cmd.Execute() 123 + if err == nil { 124 + t.Error("Expected error when using --interactive and --output-json together, got nil") 125 + } 126 + 127 + if err != nil { 128 + validErrors := []string{ 129 + "--interactive and --output-json cannot be used together", 130 + "requires an interactive terminal", 131 + } 132 + foundValidError := false 133 + for _, validErr := range validErrors { 134 + if strings.Contains(err.Error(), validErr) { 135 + foundValidError = true 136 + break 137 + } 138 + } 139 + if !foundValidError { 140 + t.Errorf("Expected error about flags conflict or TTY requirement, got: %v", err) 141 + } 142 + } 143 + }
+80 -13
cmd/release.go
··· 12 12 --dry-run Preview changes without writing files 13 13 --tag Create an annotated Git tag with release notes 14 14 --toolchain <value> Update toolchain manifests (path/type or 'interactive') 15 + --output-json Output results as JSON 15 16 --repo <path> Path to the Git repository (default: .) 16 17 --output <path> Output changelog file path (default: CHANGELOG.md) 17 18 */ 18 19 package main 19 20 20 21 import ( 22 + "encoding/json" 21 23 "fmt" 22 24 "os" 23 25 "path/filepath" ··· 34 36 "github.com/stormlightlabs/git-storm/internal/versioning" 35 37 ) 36 38 39 + // ReleaseOutput represents the JSON output structure for the release command. 40 + type ReleaseOutput struct { 41 + Version string `json:"version"` 42 + Date string `json:"date"` 43 + EntriesCount int `json:"entries_count"` 44 + ChangelogPath string `json:"changelog_path"` 45 + TagCreated bool `json:"tag_created"` 46 + TagName string `json:"tag_name,omitempty"` 47 + ChangesCleared bool `json:"changes_cleared"` 48 + DeletedCount int `json:"deleted_count,omitempty"` 49 + ToolchainsUpdated []string `json:"toolchains_updated,omitempty"` 50 + DryRun bool `json:"dry_run"` 51 + VersionData *changelog.Version `json:"version_data"` 52 + } 53 + 37 54 func releaseCmd() *cobra.Command { 38 55 var ( 39 56 version string ··· 43 60 dryRun bool 44 61 tag bool 45 62 toolchains []string 63 + outputJSON bool 46 64 ) 47 65 48 66 c := &cobra.Command{ ··· 72 90 } 73 91 } 74 92 75 - style.Headlinef("Preparing release %s (%s)", version, releaseDate) 76 - style.Newline() 93 + if !outputJSON { 94 + style.Headlinef("Preparing release %s (%s)", version, releaseDate) 95 + style.Newline() 96 + } 77 97 78 98 changesDir := ".changes" 79 99 entries, err := changeset.List(changesDir) ··· 85 105 return fmt.Errorf("no unreleased changes found in %s", changesDir) 86 106 } 87 107 88 - style.Println("Found %d unreleased entries", len(entries)) 89 - style.Newline() 108 + if !outputJSON { 109 + style.Println("Found %d unreleased entries", len(entries)) 110 + style.Newline() 111 + } 90 112 91 113 var entryList []changeset.Entry 92 114 for _, e := range entries { ··· 100 122 101 123 changelog.Merge(existingChangelog, newVersion) 102 124 125 + releaseOutput := ReleaseOutput{ 126 + Version: version, 127 + Date: releaseDate, 128 + EntriesCount: len(entries), 129 + ChangelogPath: changelogPath, 130 + DryRun: dryRun, 131 + VersionData: newVersion, 132 + } 133 + 103 134 if dryRun { 135 + if outputJSON { 136 + jsonBytes, err := json.MarshalIndent(releaseOutput, "", " ") 137 + if err != nil { 138 + return fmt.Errorf("failed to marshal output to JSON: %w", err) 139 + } 140 + fmt.Println(string(jsonBytes)) 141 + return nil 142 + } 143 + 104 144 style.Headline("Dry-run mode: Preview of CHANGELOG.md") 105 145 style.Newline() 106 146 displayVersionPreview(newVersion) ··· 116 156 return fmt.Errorf("failed to write CHANGELOG.md: %w", err) 117 157 } 118 158 119 - style.Addedf("✓ Updated %s", changelogPath) 159 + if !outputJSON { 160 + style.Addedf("✓ Updated %s", changelogPath) 161 + } 120 162 121 163 if clearChanges { 122 164 deletedCount := 0 123 165 for _, entry := range entries { 124 166 filePath := filepath.Join(changesDir, entry.Filename) 125 167 if err := os.Remove(filePath); err != nil { 126 - style.Println("Warning: failed to delete %s: %v", filePath, err) 168 + if !outputJSON { 169 + style.Println("Warning: failed to delete %s: %v", filePath, err) 170 + } 127 171 continue 128 172 } 129 173 deletedCount++ 130 174 } 131 - style.Println("✓ Deleted %d entry files from %s", deletedCount, changesDir) 175 + releaseOutput.ChangesCleared = true 176 + releaseOutput.DeletedCount = deletedCount 177 + if !outputJSON { 178 + style.Println("✓ Deleted %d entry files from %s", deletedCount, changesDir) 179 + } 132 180 } 133 181 134 182 if len(toolchains) > 0 { ··· 136 184 if err != nil { 137 185 return err 138 186 } 187 + var updatedPaths []string 139 188 for _, manifest := range updated { 140 - style.Addedf("✓ Updated %s", manifest.RelPath) 189 + updatedPaths = append(updatedPaths, manifest.RelPath) 190 + if !outputJSON { 191 + style.Addedf("✓ Updated %s", manifest.RelPath) 192 + } 141 193 } 194 + releaseOutput.ToolchainsUpdated = updatedPaths 142 195 } 143 196 144 - style.Newline() 145 - style.Headlinef("Release %s completed successfully", version) 146 - 147 197 if tag { 148 - style.Newline() 149 198 if err := createReleaseTag(repoPath, version, newVersion); err != nil { 150 199 return fmt.Errorf("failed to create Git tag: %w", err) 151 200 } 152 201 tagName := fmt.Sprintf("v%s", version) 153 - style.Addedf("✓ Created Git tag %s", tagName) 202 + releaseOutput.TagCreated = true 203 + releaseOutput.TagName = tagName 204 + if !outputJSON { 205 + style.Newline() 206 + style.Addedf("✓ Created Git tag %s", tagName) 207 + } 208 + } 209 + 210 + if outputJSON { 211 + jsonBytes, err := json.MarshalIndent(releaseOutput, "", " ") 212 + if err != nil { 213 + return fmt.Errorf("failed to marshal output to JSON: %w", err) 214 + } 215 + fmt.Println(string(jsonBytes)) 216 + return nil 154 217 } 218 + 219 + style.Newline() 220 + style.Headlinef("Release %s completed successfully", version) 155 221 156 222 return nil 157 223 }, ··· 164 230 c.Flags().BoolVar(&dryRun, "dry-run", false, "Preview changes without writing files") 165 231 c.Flags().BoolVar(&tag, "tag", false, "Create an annotated Git tag with release notes") 166 232 c.Flags().StringSliceVar(&toolchains, "toolchain", nil, "Toolchain manifests to update (paths, types, or 'interactive')") 233 + c.Flags().BoolVar(&outputJSON, "output-json", false, "Output results as JSON") 167 234 168 235 return c 169 236 }
+73
cmd/release_test.go
··· 1 1 package main 2 2 3 3 import ( 4 + "encoding/json" 4 5 "strings" 5 6 "testing" 6 7 ··· 227 228 t.Fatalf("expected 0.0.1 for empty changelog, got %s", version) 228 229 } 229 230 } 231 + 232 + func TestReleaseOutput_JSONStructure(t *testing.T) { 233 + output := ReleaseOutput{ 234 + Version: "1.0.0", 235 + Date: "2024-01-15", 236 + EntriesCount: 3, 237 + ChangelogPath: "CHANGELOG.md", 238 + TagCreated: true, 239 + TagName: "v1.0.0", 240 + ChangesCleared: true, 241 + DeletedCount: 3, 242 + DryRun: false, 243 + VersionData: &changelog.Version{ 244 + Number: "1.0.0", 245 + Date: "2024-01-15", 246 + Sections: []changelog.Section{ 247 + { 248 + Type: "added", 249 + Entries: []string{"Feature 1"}, 250 + }, 251 + }, 252 + }, 253 + } 254 + 255 + jsonBytes, err := json.MarshalIndent(output, "", " ") 256 + if err != nil { 257 + t.Fatalf("Failed to marshal JSON: %v", err) 258 + } 259 + 260 + var unmarshaled ReleaseOutput 261 + err = json.Unmarshal(jsonBytes, &unmarshaled) 262 + if err != nil { 263 + t.Fatalf("Failed to unmarshal JSON: %v", err) 264 + } 265 + 266 + testutils.Expect.Equal(t, unmarshaled.Version, "1.0.0") 267 + testutils.Expect.Equal(t, unmarshaled.Date, "2024-01-15") 268 + testutils.Expect.Equal(t, unmarshaled.EntriesCount, 3) 269 + testutils.Expect.Equal(t, unmarshaled.TagCreated, true) 270 + testutils.Expect.Equal(t, unmarshaled.TagName, "v1.0.0") 271 + testutils.Expect.Equal(t, unmarshaled.ChangesCleared, true) 272 + testutils.Expect.Equal(t, unmarshaled.DeletedCount, 3) 273 + } 274 + 275 + func TestReleaseOutput_DryRunJSON(t *testing.T) { 276 + output := ReleaseOutput{ 277 + Version: "1.0.0", 278 + Date: "2024-01-15", 279 + EntriesCount: 2, 280 + ChangelogPath: "CHANGELOG.md", 281 + DryRun: true, 282 + VersionData: &changelog.Version{ 283 + Number: "1.0.0", 284 + Date: "2024-01-15", 285 + Sections: []changelog.Section{ 286 + { 287 + Type: "fixed", 288 + Entries: []string{"Bug fix"}, 289 + }, 290 + }, 291 + }, 292 + } 293 + 294 + jsonBytes, err := json.MarshalIndent(output, "", " ") 295 + if err != nil { 296 + t.Fatalf("Failed to marshal JSON: %v", err) 297 + } 298 + 299 + testutils.Expect.True(t, strings.Contains(string(jsonBytes), `"dry_run": true`)) 300 + testutils.Expect.True(t, strings.Contains(string(jsonBytes), `"tag_created": false`)) 301 + testutils.Expect.True(t, strings.Contains(string(jsonBytes), `"changes_cleared": false`)) 302 + }
+11 -8
docs/manual.md
··· 38 38 39 39 ##### Flags 40 40 41 - | Flag | Description | 42 - | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | 43 - | `--bump <type>` _(required)_ | Which semver component to increment. | 44 - | `--toolchain <value>` | Update language manifests (`Cargo.toml`, `pyproject.toml`, `package.json`, `deno.json`). Accepts explicit paths, type aliases like `cargo`/`npm`, or the literal `interactive` to launch a picker TUI. | 41 + | Flag | Description | 42 + | ---------------------------- | ------------------------------------------------------------------------------------------------------------- | 43 + | `--bump <type>` _(required)_ | Which semver component to increment. | 44 + | `--toolchain <value>` | Update language manifests (`Cargo.toml`, `pyproject.toml`, `package.json`, `deno.json`). | 45 + | | Accepts explicit paths, type aliases like `cargo`/`npm`, or the literal `interactive` to launch a picker TUI. | 45 46 46 47 #### `storm release` 47 48 ··· 62 63 | `--dry-run` | Render a preview without touching any files. | 63 64 | `--tag` | Create an annotated git tag containing the release notes. | 64 65 | `--toolchain <value>` | Update manifest files just like in `storm bump`. | 66 + | `--output-json` | Emit machine-readable JSON instead of styled text. | 65 67 66 68 #### `storm generate` 67 69 ··· 74 76 75 77 ##### Flags 76 78 77 - | Flag | Description | 78 - | --------------------- | ------------------------------------------------- | 79 - | `-i`, `--interactive` | Open a commit selector TUI for choosing entries. | 80 - | `--since <tag>` | Shortcut for `<from>`; defaults `<to>` to `HEAD`. | 79 + | Flag | Description | 80 + | --------------------- | -------------------------------------------------- | 81 + | `-i`, `--interactive` | Open a commit selector TUI for choosing entries. | 82 + | `--since <tag>` | Shortcut for `<from>`; defaults `<to>` to `HEAD`. | 83 + | `--output-json` | Emit machine-readable JSON instead of styled text. | 81 84 82 85 #### `storm diff` 83 86