changelog generator & diff tool
stormlightlabs.github.io/git-storm/
changelog
changeset
markdown
golang
git
1/*
2USAGE
3
4 storm release --version <X.Y.Z> [options]
5
6FLAGS
7
8 --version <X.Y.Z> Semantic version for the new release (required)
9 --bump <type> Automatically bump the previous version (major|minor|patch)
10 --date <YYYY-MM-DD> Release date (default: today)
11 --clear-changes Delete .changes/*.md files after successful release
12 --dry-run Preview changes without writing files
13 --tag Create an annotated Git tag with release notes
14 --toolchain <value> Update toolchain manifests (path/type or 'interactive')
15 --output-json Output results as JSON
16 --repo <path> Path to the Git repository (default: .)
17 --output <path> Output changelog file path (default: CHANGELOG.md)
18*/
19package main
20
21import (
22 "encoding/json"
23 "fmt"
24 "os"
25 "path/filepath"
26 "strings"
27 "time"
28
29 "github.com/go-git/go-git/v6"
30 "github.com/go-git/go-git/v6/plumbing/object"
31 "github.com/spf13/cobra"
32 "github.com/stormlightlabs/git-storm/internal/changelog"
33 "github.com/stormlightlabs/git-storm/internal/changeset"
34 "github.com/stormlightlabs/git-storm/internal/shared"
35 "github.com/stormlightlabs/git-storm/internal/style"
36 "github.com/stormlightlabs/git-storm/internal/versioning"
37)
38
39// ReleaseOutput represents the JSON output structure for the release command.
40type 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
54func releaseCmd() *cobra.Command {
55 var (
56 version string
57 bumpKind string
58 date string
59 clearChanges bool
60 dryRun bool
61 tag bool
62 toolchains []string
63 outputJSON bool
64 )
65
66 c := &cobra.Command{
67 Use: "release",
68 Short: "Promote unreleased changes into a new changelog version",
69 Long: `Merges all .changes entries into CHANGELOG.md under a new version header.
70Optionally creates a Git tag and clears the .changes directory.`,
71 RunE: func(cmd *cobra.Command, args []string) error {
72 changelogPath := filepath.Join(repoPath, output)
73 existingChangelog, err := changelog.Parse(changelogPath)
74 if err != nil {
75 return fmt.Errorf("failed to parse changelog: %w", err)
76 }
77
78 resolvedVersion, err := resolveReleaseVersion(version, bumpKind, existingChangelog)
79 if err != nil {
80 return err
81 }
82 version = resolvedVersion
83
84 releaseDate := date
85 if releaseDate == "" {
86 releaseDate = time.Now().Format("2006-01-02")
87 } else {
88 if err := changelog.ValidateDate(releaseDate); err != nil {
89 return err
90 }
91 }
92
93 if !outputJSON {
94 style.Headlinef("Preparing release %s (%s)", version, releaseDate)
95 style.Newline()
96 }
97
98 changesDir := ".changes"
99 entries, err := changeset.List(changesDir)
100 if err != nil {
101 return fmt.Errorf("failed to read .changes directory: %w", err)
102 }
103
104 if len(entries) == 0 {
105 return fmt.Errorf("no unreleased changes found in %s", changesDir)
106 }
107
108 if !outputJSON {
109 style.Println("Found %d unreleased entries", len(entries))
110 style.Newline()
111 }
112
113 var entryList []changeset.Entry
114 for _, e := range entries {
115 entryList = append(entryList, e.Entry)
116 }
117
118 newVersion, err := changelog.Build(entryList, version, releaseDate)
119 if err != nil {
120 return fmt.Errorf("failed to build version: %w", err)
121 }
122
123 changelog.Merge(existingChangelog, newVersion)
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
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
144 style.Headline("Dry-run mode: Preview of CHANGELOG.md")
145 style.Newline()
146 displayVersionPreview(newVersion)
147 style.Newline()
148 style.Println("No files were modified (--dry-run)")
149 if len(toolchains) > 0 {
150 style.Warningf("Skipping toolchain updates (--dry-run)")
151 }
152 return nil
153 }
154
155 if err := changelog.Write(changelogPath, existingChangelog, repoPath); err != nil {
156 return fmt.Errorf("failed to write CHANGELOG.md: %w", err)
157 }
158
159 if !outputJSON {
160 style.Addedf("✓ Updated %s", changelogPath)
161 }
162
163 if clearChanges {
164 deletedCount := 0
165 for _, entry := range entries {
166 filePath := filepath.Join(changesDir, entry.Filename)
167 if err := os.Remove(filePath); err != nil {
168 if !outputJSON {
169 style.Println("Warning: failed to delete %s: %v", filePath, err)
170 }
171 continue
172 }
173 deletedCount++
174 }
175 releaseOutput.ChangesCleared = true
176 releaseOutput.DeletedCount = deletedCount
177 if !outputJSON {
178 style.Println("✓ Deleted %d entry files from %s", deletedCount, changesDir)
179 }
180 }
181
182 if len(toolchains) > 0 {
183 updated, err := updateToolchainTargets(repoPath, version, toolchains)
184 if err != nil {
185 return err
186 }
187 var updatedPaths []string
188 for _, manifest := range updated {
189 updatedPaths = append(updatedPaths, manifest.RelPath)
190 if !outputJSON {
191 style.Addedf("✓ Updated %s", manifest.RelPath)
192 }
193 }
194 releaseOutput.ToolchainsUpdated = updatedPaths
195 }
196
197 if tag {
198 if err := createReleaseTag(repoPath, version, newVersion); err != nil {
199 return fmt.Errorf("failed to create Git tag: %w", err)
200 }
201 tagName := fmt.Sprintf("v%s", version)
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
217 }
218
219 style.Newline()
220 style.Headlinef("Release %s completed successfully", version)
221
222 return nil
223 },
224 }
225
226 c.Flags().StringVar(&version, "version", "", "Semantic version for the new release (e.g., 1.3.0)")
227 c.Flags().StringVar(&bumpKind, "bump", "", "Automatically bump the previous version (major, minor, or patch)")
228 c.Flags().StringVar(&date, "date", "", "Release date in YYYY-MM-DD format (default: today)")
229 c.Flags().BoolVar(&clearChanges, "clear-changes", false, "Delete .changes/*.md files after successful release")
230 c.Flags().BoolVar(&dryRun, "dry-run", false, "Preview changes without writing files")
231 c.Flags().BoolVar(&tag, "tag", false, "Create an annotated Git tag with release notes")
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")
234
235 return c
236}
237
238func resolveReleaseVersion(versionFlag, bumpFlag string, existing *changelog.Changelog) (string, error) {
239 if bumpFlag == "" {
240 if versionFlag == "" {
241 return "", fmt.Errorf("either --version or --bump must be provided")
242 }
243 if err := changelog.ValidateVersion(versionFlag); err != nil {
244 return "", err
245 }
246 return versionFlag, nil
247 }
248
249 if versionFlag != "" {
250 return "", fmt.Errorf("--version and --bump cannot be used together")
251 }
252
253 kind, err := versioning.ParseBumpType(bumpFlag)
254 if err != nil {
255 return "", err
256 }
257
258 var current string
259 if v, ok := versioning.LatestVersion(existing); ok {
260 current = v
261 }
262
263 return versioning.Next(current, kind)
264}
265
266// createReleaseTag creates an annotated Git tag for the release with changelog entries as the message.
267func createReleaseTag(repoPath, version string, versionData *changelog.Version) error {
268 repo, err := git.PlainOpen(repoPath)
269 if err != nil {
270 return fmt.Errorf("failed to open repository: %w", err)
271 }
272
273 head, err := repo.Head()
274 if err != nil {
275 return fmt.Errorf("failed to get HEAD: %w", err)
276 }
277
278 tagName := fmt.Sprintf("v%s", version)
279
280 _, err = repo.Tag(tagName)
281 if err == nil {
282 return fmt.Errorf("tag %s already exists", tagName)
283 }
284
285 tagMessage := buildTagMessage(version, versionData)
286
287 _, err = repo.CreateTag(tagName, head.Hash(), &git.CreateTagOptions{
288 Message: tagMessage,
289 Tagger: &object.Signature{
290 Name: "storm",
291 Email: "noreply@storm",
292 When: time.Now(),
293 },
294 })
295 if err != nil {
296 return fmt.Errorf("failed to create tag: %w", err)
297 }
298 return nil
299}
300
301// buildTagMessage formats the version's changelog entries into a tag message.
302func buildTagMessage(version string, versionData *changelog.Version) string {
303 var builder strings.Builder
304
305 builder.WriteString(fmt.Sprintf("Release %s\n\n", version))
306
307 for i, section := range versionData.Sections {
308 if i > 0 {
309 builder.WriteString("\n")
310 }
311
312 sectionTitle := shared.TitleCase(section.Type)
313 builder.WriteString(fmt.Sprintf("%s:\n", sectionTitle))
314
315 for _, entry := range section.Entries {
316 builder.WriteString(fmt.Sprintf("- %s\n", entry))
317 }
318 }
319
320 return builder.String()
321}
322
323// displayVersionPreview shows a formatted preview of the version being released.
324func displayVersionPreview(version *changelog.Version) {
325 fmt.Printf("## [%s] - %s\n\n", version.Number, version.Date)
326
327 for i, section := range version.Sections {
328 if i > 0 {
329 fmt.Println()
330 }
331
332 var sectionTitle string
333 switch section.Type {
334 case "added":
335 sectionTitle = style.StyleAdded.Render("### Added")
336 case "changed":
337 sectionTitle = style.StyleChanged.Render("### Changed")
338 case "deprecated":
339 sectionTitle = "### Deprecated"
340 case "removed":
341 sectionTitle = style.StyleRemoved.Render("### Removed")
342 case "fixed":
343 sectionTitle = style.StyleFixed.Render("### Fixed")
344 case "security":
345 sectionTitle = style.StyleSecurity.Render("### Security")
346 default:
347 sectionTitle = fmt.Sprintf("### %s", section.Type)
348 }
349 fmt.Println(sectionTitle)
350 fmt.Println()
351
352 for _, entry := range section.Entries {
353 fmt.Printf("- %s\n", entry)
354 }
355 }
356}