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