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

feat(bump): add interactive toolchain based bump command

* filled out docs

+2
.gitignore
··· 35 35 tmp/ 36 36 node_modules/ 37 37 **/.vitepress/cache/ 38 + # Added by goreleaser init: 39 + dist/
+5 -1
PROJECT.md
··· 94 94 95 95 ```yaml 96 96 steps: 97 + - name: Compute next version 98 + run: | 99 + NEXT=$(storm bump --bump patch) 100 + echo "::set-output name=version::$NEXT" 97 101 - name: Generate changelog 98 102 run: | 99 103 go install ./cmd/storm 100 - storm release --version ${{ steps.bump.outputs.version }} 104 + storm release --version ${{ steps.bump.outputs.version }} --toolchain package.json 101 105 - name: Tag and push 102 106 run: | 103 107 git add CHANGELOG.md
+24 -147
README.md
··· 1 - # `storm` 1 + # storm 2 2 3 - > A Go-based changelog manager built for clarity, speed, and interaction. 3 + > Local-first changelog manager with TUIs for review and release. 4 4 5 - ## Goals 6 - 7 - - Use Git as a data source, not a dependency. 8 - - Store unreleased notes locally (`.changes/*.md`) in a simple, editable format. 9 - - Provide a terminal UI for reviewing commits and changes interactively. 10 - - Generate Markdown in strict [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format. 5 + ## Highlights 11 6 12 - ### Architecture 7 + - **Keep a Changelog native:** unreleased notes live in `.changes/*.md` until you promote them. 8 + - **Toolchain aware:** `storm bump`/`storm release` can update Cargo, npm, Python, and Deno manifests. 9 + - **TUI friendly:** commit selectors, diff viewers, and toolchain pickers reuse the same palette and key bindings. 10 + - **Scriptable CLI:** every subcommand prints concise status messages suitable for CI logs. 13 11 14 - - **Git integration:** Uses `go-git` for commit history and tag resolution — no shell calls. 15 - - **Diffing:** Custom lightweight diff engine for readable line-by-line output. 16 - - **Unreleased storage:** Simple Markdown files with YAML frontmatter (no external formats). 17 - - **Interactive mode:** Bubble Tea model for categorizing and confirming changes. 18 - - **Output:** Always produces Keep a Changelog–compliant Markdown. 19 - 20 - ## Core Packages 12 + ## Install 21 13 22 14 ```sh 23 - . 24 - ├── cmd 25 - ├── internal 26 - │ ├── gitlog # Parse and structure commit history via `go-git` 27 - │ ├── diff # Minimal line diff for display and review 28 - │ ├── changeset # Manage `.changes/*.md` files 29 - │ ├── changelog # Build and update `CHANGELOG.md` sections 30 - │ ├── ui # Bubble Tea–based interactive interface 31 - │ └── style # Centralized Lip Gloss palette and formatting 32 - ├── PROJECT.md 33 - └── README.md 15 + go install github.com/stormlightlabs/git-storm/cmd/storm@latest 34 16 ``` 35 17 36 - ## Command Model 18 + (Need Homebrew? Use the `storm.rb` formula template in this repo to build a tap.) 37 19 38 - ### Unreleased Changes 20 + ## Quick Start 39 21 40 22 ```sh 41 - storm unreleased add --type added --scope cli --summary "Add changelog command" 42 - storm unreleased list 23 + storm generate --since v1.2.0 --interactive 43 24 storm unreleased review 25 + storm release --bump patch --toolchain package.json --tag 44 26 ``` 45 27 46 - Adds or reviews pending `.changes/*.md` entries. 28 + ## Documentation 47 29 48 - ### Generate From Git 30 + - [Introduction](docs/introduction.md) 31 + - [Quickstart](docs/quickstart.md) 32 + - [Manual](docs/manual.md) 33 + - [Development Guide](docs/development.md) 49 34 50 - ```sh 51 - storm generate <from> <to> [--interactive] 52 - ``` 35 + For a deeper dive into release automation, see `PROJECT.md`. 53 36 54 - Pulls commits between refs, categorizes them by prefix, and optionally opens an interactive review. 37 + ## Contributing 55 38 56 - ### Release 39 + Run the full test suite before opening a PR: 57 40 58 41 ```sh 59 - storm release --version 1.3.0 [--tag] 42 + go test ./... 60 43 ``` 61 44 62 - Merges `.changes/*.md` into the changelog, writes a new section, and optionally tags the repository. 63 - 64 - ## Development Guidance 65 - 66 - 1. Composable 67 - Each subsystem (`diff`, `gitlog`, `tui`, etc.) should work standalone and be callable from tests or other Go programs. 68 - 2. Frontmatter 69 - 70 - ```yaml 71 - type: added 72 - scope: cli 73 - summary: Add changelog command 74 - ``` 75 - 76 - 3. Consistent Palette 77 - 78 - See package style for the color palette. 79 - 80 - 4. Commands should chain naturally and script cleanly: 81 - 82 - ```sh 83 - storm unreleased list --json 84 - storm generate --since v1.2.0 --interactive 85 - storm release --version 1.3.0 86 - ``` 87 - 88 - 5. Tests 89 - - Research testing bubbletea programs 90 - - Use golden files for diff/changelog output. 91 - - Use in-memory `go-git` repos in unit tests. 92 - 93 - ## Roadmap 94 - 95 - | Phase | Deliverable | 96 - | ----- | ---------------------------------------------- | 97 - | 1 | Core CLI (`generate`, `unreleased`, `release`) | 98 - | 2 | Git integration and commit parsing | 99 - | 3 | Diff engine and styling | 100 - | 4 | `.changes` storage and parsing | 101 - | 5 | Interactive TUI | 102 - | 6 | Keep a Changelog writer | 103 - | 7 | Git tagging and CI integration | 104 - 105 - ## Notes 106 - 107 - - No external dependencies beyond `cobra`, `go-git`, `bubbletea`, `lipgloss`, and `yaml.v3`. 108 - - Keep the workflow simple and reproducible so changelogs can be deterministically derived from local data. 109 - - Make sure interactive sessions degrade gracefully in non-TTY environments. 110 - 111 - ## Conventional Commits 112 - 113 - ### Structure 114 - 115 - | Element | Format | Description | 116 - | ------------------------- | ---------------------------------------- | ---------------------------------------- | 117 - | Header | `<type>(<scope>): <description>` | The main commit message line. | 118 - | Scope | Optional, e.g. `api`, `cli`, `deps` | Indicates part of the codebase affected. | 119 - | Breaking Change Indicator | `!` after type/scope, e.g. `feat(api)!:` | Marks a breaking API change. | 120 - | Body | (Optional) one blank line then body text | Explanation of what & why. | 121 - | Footer | (Optional) one blank line then meta info | Issue refs, `BREAKING CHANGE: …`, etc | 122 - 123 - ### Types 124 - 125 - | Type | Description | 126 - | ---------- | ----------------------------------------------------------------------- | 127 - | `feat` | A new feature. | 128 - | `fix` | A bug fix. | 129 - | `docs` | Documentation only changes. | 130 - | `style` | Code style changes (formatting, whitespace) that don’t affect behavior. | 131 - | `refactor` | Code changes that neither fix a bug nor add a feature. | 132 - | `perf` | Performance improvements. | 133 - | `test` | Adding or updating tests. | 134 - | `build` | Changes that affect the build system or dependencies. | 135 - | `ci` | Changes to CI configuration and scripts. | 136 - | `chore` | Other changes that don’t touch src/test (e.g., tooling, config). | 137 - | `revert` | Reverts a previous commit. | 138 - 139 - ### Examples 140 - 141 - ```text 142 - feat(api): add pagination endpoint 143 - 144 - fix(ui): correct button alignment issue 145 - 146 - docs: update README installation instructions 147 - 148 - perf(core): optimize user query performance 149 - 150 - refactor: restructure payment module for clarity 151 - 152 - style: apply consistent formatting 153 - 154 - test(auth): add integration tests for OAuth flow 155 - 156 - build(deps): bump dependencies to latest versions 157 - 158 - ci: add GitHub Actions workflow for CI 159 - 160 - chore: update .gitignore and clean up obsolete files 161 - 162 - feat(api)!: remove support for legacy endpoints 163 - 164 - BREAKING CHANGE: API no longer accepts XML-formatted requests. 165 - ``` 166 - 167 - ### Reference 168 - 169 - <https://www.conventionalcommits.org/en/v1.0.0/> "Conventional Commits" 45 + Issues and feature ideas are welcome—Storm is intentionally modular so new 46 + commands and TUIs can be added without touching the entire codebase.
+58
cmd/bump.go
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + "path/filepath" 6 + 7 + "github.com/spf13/cobra" 8 + "github.com/stormlightlabs/git-storm/internal/changelog" 9 + "github.com/stormlightlabs/git-storm/internal/style" 10 + "github.com/stormlightlabs/git-storm/internal/versioning" 11 + ) 12 + 13 + func bumpCmd() *cobra.Command { 14 + var bumpKind string 15 + var toolchainSelectors []string 16 + 17 + cmd := &cobra.Command{ 18 + Use: "bump", 19 + Short: "Calculate the next semantic version and optionally update toolchain manifests", 20 + RunE: func(cmd *cobra.Command, args []string) error { 21 + kind, err := versioning.ParseBumpType(bumpKind) 22 + if err != nil { 23 + return err 24 + } 25 + 26 + changelogPath := filepath.Join(repoPath, output) 27 + parsed, err := changelog.Parse(changelogPath) 28 + if err != nil { 29 + return fmt.Errorf("failed to parse changelog: %w", err) 30 + } 31 + 32 + current, _ := versioning.LatestVersion(parsed) 33 + nextVersion, err := versioning.Next(current, kind) 34 + if err != nil { 35 + return err 36 + } 37 + 38 + style.Headlinef("Next version: %s", nextVersion) 39 + 40 + updated, err := updateToolchainTargets(repoPath, nextVersion, toolchainSelectors) 41 + if err != nil { 42 + return err 43 + } 44 + for _, manifest := range updated { 45 + style.Addedf("✓ Updated %s", manifest.RelPath) 46 + } 47 + 48 + fmt.Fprintln(cmd.OutOrStdout(), nextVersion) 49 + return nil 50 + }, 51 + } 52 + 53 + cmd.Flags().StringVar(&bumpKind, "bump", "", "Which semver component to bump (major, minor, or patch)") 54 + cmd.Flags().StringSliceVar(&toolchainSelectors, "toolchain", nil, "Toolchain manifests to update (paths, types, or 'interactive')") 55 + cmd.MarkFlagRequired("bump") 56 + 57 + return cmd 58 + }
+86
cmd/bump_test.go
··· 1 + package main 2 + 3 + import ( 4 + "bytes" 5 + "os" 6 + "path/filepath" 7 + "strings" 8 + "testing" 9 + ) 10 + 11 + const sampleChangelog = `# Changelog 12 + 13 + ## [Unreleased] 14 + 15 + ## [1.2.3] - 2024-01-01 16 + ### Added 17 + - Initial release 18 + ` 19 + 20 + func TestBumpCommandPrintsVersion(t *testing.T) { 21 + dir := t.TempDir() 22 + writeFile(t, filepath.Join(dir, "CHANGELOG.md"), sampleChangelog) 23 + 24 + oldRepo := repoPath 25 + oldOutput := output 26 + repoPath = dir 27 + output = "CHANGELOG.md" 28 + t.Cleanup(func() { 29 + repoPath = oldRepo 30 + output = oldOutput 31 + }) 32 + 33 + cmd := bumpCmd() 34 + cmd.SetArgs([]string{"--bump", "minor"}) 35 + var out bytes.Buffer 36 + cmd.SetOut(&out) 37 + cmd.SetErr(&out) 38 + 39 + if err := cmd.Execute(); err != nil { 40 + t.Fatalf("bump command failed: %v", err) 41 + } 42 + 43 + if !strings.Contains(out.String(), "1.3.0") { 44 + t.Fatalf("expected output to contain 1.3.0, got %q", out.String()) 45 + } 46 + } 47 + 48 + func TestBumpCommandUpdatesPackageJSON(t *testing.T) { 49 + dir := t.TempDir() 50 + writeFile(t, filepath.Join(dir, "CHANGELOG.md"), sampleChangelog) 51 + writeFile(t, filepath.Join(dir, "package.json"), `{"name":"demo","version":"1.2.3"}`) 52 + 53 + oldRepo := repoPath 54 + oldOutput := output 55 + repoPath = dir 56 + output = "CHANGELOG.md" 57 + t.Cleanup(func() { 58 + repoPath = oldRepo 59 + output = oldOutput 60 + }) 61 + 62 + cmd := bumpCmd() 63 + cmd.SetArgs([]string{"--bump", "patch", "--toolchain", "package.json"}) 64 + var out bytes.Buffer 65 + cmd.SetOut(&out) 66 + cmd.SetErr(&out) 67 + 68 + if err := cmd.Execute(); err != nil { 69 + t.Fatalf("bump command failed: %v", err) 70 + } 71 + 72 + contents, err := os.ReadFile(filepath.Join(dir, "package.json")) 73 + if err != nil { 74 + t.Fatalf("failed to read package.json: %v", err) 75 + } 76 + 77 + if !strings.Contains(string(contents), "1.2.4") { 78 + t.Fatalf("expected package.json to contain bumped version, got %s", contents) 79 + } 80 + } 81 + 82 + func writeFile(t *testing.T, path, contents string) { 83 + if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { 84 + t.Fatalf("failed to write %s: %v", path, err) 85 + } 86 + }
+2 -2
cmd/main.go
··· 41 41 42 42 root.PersistentFlags().StringVar(&repoPath, "repo", ".", "Path to the Git repository") 43 43 root.PersistentFlags().StringVarP(&output, "output", "o", "CHANGELOG.md", "Output changelog file path") 44 - root.AddCommand(generateCmd(), unreleasedCmd(), releaseCmd(), diffCmd(), checkCmd(), versionCmd()) 44 + root.AddCommand(generateCmd(), unreleasedCmd(), releaseCmd(), bumpCmd(), diffCmd(), checkCmd(), versionCmd()) 45 45 46 - if err := fang.Execute(ctx, root, fang.WithColorSchemeFunc(style.NewColorScheme)); err != nil { 46 + if err := fang.Execute(ctx, root, fang.WithColorSchemeFunc(style.NewColorScheme), fang.WithoutCompletions()); err != nil { 47 47 log.Fatalf("Execution failed: %v", err) 48 48 } 49 49 }
+57 -8
cmd/release.go
··· 6 6 FLAGS 7 7 8 8 --version <X.Y.Z> Semantic version for the new release (required) 9 + --bump <type> Automatically bump the previous version (major|minor|patch) 9 10 --date <YYYY-MM-DD> Release date (default: today) 10 11 --clear-changes Delete .changes/*.md files after successful release 11 12 --dry-run Preview changes without writing files 12 13 --tag Create an annotated Git tag with release notes 14 + --toolchain <value> Update toolchain manifests (path/type or 'interactive') 13 15 --repo <path> Path to the Git repository (default: .) 14 16 --output <path> Output changelog file path (default: CHANGELOG.md) 15 17 */ ··· 29 31 "github.com/stormlightlabs/git-storm/internal/changeset" 30 32 "github.com/stormlightlabs/git-storm/internal/shared" 31 33 "github.com/stormlightlabs/git-storm/internal/style" 34 + "github.com/stormlightlabs/git-storm/internal/versioning" 32 35 ) 33 36 34 37 func releaseCmd() *cobra.Command { 35 38 var ( 36 39 version string 40 + bumpKind string 37 41 date string 38 42 clearChanges bool 39 43 dryRun bool 40 44 tag bool 45 + toolchains []string 41 46 ) 42 47 43 48 c := &cobra.Command{ ··· 46 51 Long: `Merges all .changes entries into CHANGELOG.md under a new version header. 47 52 Optionally creates a Git tag and clears the .changes directory.`, 48 53 RunE: func(cmd *cobra.Command, args []string) error { 49 - if err := changelog.ValidateVersion(version); err != nil { 54 + changelogPath := filepath.Join(repoPath, output) 55 + existingChangelog, err := changelog.Parse(changelogPath) 56 + if err != nil { 57 + return fmt.Errorf("failed to parse changelog: %w", err) 58 + } 59 + 60 + resolvedVersion, err := resolveReleaseVersion(version, bumpKind, existingChangelog) 61 + if err != nil { 50 62 return err 51 63 } 64 + version = resolvedVersion 52 65 53 66 releaseDate := date 54 67 if releaseDate == "" { ··· 85 98 return fmt.Errorf("failed to build version: %w", err) 86 99 } 87 100 88 - changelogPath := filepath.Join(repoPath, output) 89 - existingChangelog, err := changelog.Parse(changelogPath) 90 - if err != nil { 91 - return fmt.Errorf("failed to parse existing changelog: %w", err) 92 - } 93 - 94 101 changelog.Merge(existingChangelog, newVersion) 95 102 96 103 if dryRun { ··· 99 106 displayVersionPreview(newVersion) 100 107 style.Newline() 101 108 style.Println("No files were modified (--dry-run)") 109 + if len(toolchains) > 0 { 110 + style.Warningf("Skipping toolchain updates (--dry-run)") 111 + } 102 112 return nil 103 113 } 104 114 ··· 121 131 style.Println("✓ Deleted %d entry files from %s", deletedCount, changesDir) 122 132 } 123 133 134 + if len(toolchains) > 0 { 135 + updated, err := updateToolchainTargets(repoPath, version, toolchains) 136 + if err != nil { 137 + return err 138 + } 139 + for _, manifest := range updated { 140 + style.Addedf("✓ Updated %s", manifest.RelPath) 141 + } 142 + } 143 + 124 144 style.Newline() 125 145 style.Headlinef("Release %s completed successfully", version) 126 146 ··· 138 158 } 139 159 140 160 c.Flags().StringVar(&version, "version", "", "Semantic version for the new release (e.g., 1.3.0)") 161 + c.Flags().StringVar(&bumpKind, "bump", "", "Automatically bump the previous version (major, minor, or patch)") 141 162 c.Flags().StringVar(&date, "date", "", "Release date in YYYY-MM-DD format (default: today)") 142 163 c.Flags().BoolVar(&clearChanges, "clear-changes", false, "Delete .changes/*.md files after successful release") 143 164 c.Flags().BoolVar(&dryRun, "dry-run", false, "Preview changes without writing files") 144 165 c.Flags().BoolVar(&tag, "tag", false, "Create an annotated Git tag with release notes") 145 - c.MarkFlagRequired("version") 166 + c.Flags().StringSliceVar(&toolchains, "toolchain", nil, "Toolchain manifests to update (paths, types, or 'interactive')") 146 167 147 168 return c 169 + } 170 + 171 + func resolveReleaseVersion(versionFlag, bumpFlag string, existing *changelog.Changelog) (string, error) { 172 + if bumpFlag == "" { 173 + if versionFlag == "" { 174 + return "", fmt.Errorf("either --version or --bump must be provided") 175 + } 176 + if err := changelog.ValidateVersion(versionFlag); err != nil { 177 + return "", err 178 + } 179 + return versionFlag, nil 180 + } 181 + 182 + if versionFlag != "" { 183 + return "", fmt.Errorf("--version and --bump cannot be used together") 184 + } 185 + 186 + kind, err := versioning.ParseBumpType(bumpFlag) 187 + if err != nil { 188 + return "", err 189 + } 190 + 191 + var current string 192 + if v, ok := versioning.LatestVersion(existing); ok { 193 + current = v 194 + } 195 + 196 + return versioning.Next(current, kind) 148 197 } 149 198 150 199 // createReleaseTag creates an annotated Git tag for the release with changelog entries as the message.
+33
cmd/release_test.go
··· 194 194 195 195 testutils.Expect.True(t, strings.HasPrefix(message, "Release 1.0.0\n\n"), "Should still have release header even with no sections") 196 196 } 197 + 198 + func TestResolveReleaseVersion(t *testing.T) { 199 + existing := &changelog.Changelog{Versions: []changelog.Version{{Number: "Unreleased"}, {Number: "1.2.3"}}} 200 + 201 + version, err := resolveReleaseVersion("", "minor", existing) 202 + if err != nil { 203 + t.Fatalf("resolveReleaseVersion returned error: %v", err) 204 + } 205 + if version != "1.3.0" { 206 + t.Fatalf("expected 1.3.0, got %s", version) 207 + } 208 + 209 + version, err = resolveReleaseVersion("2.0.0", "", existing) 210 + if err != nil { 211 + t.Fatalf("resolveReleaseVersion returned error: %v", err) 212 + } 213 + if version != "2.0.0" { 214 + t.Fatalf("expected 2.0.0, got %s", version) 215 + } 216 + 217 + if _, err := resolveReleaseVersion("2.0.0", "patch", existing); err == nil { 218 + t.Fatal("expected error when both --version and --bump are set") 219 + } 220 + 221 + blankChangelog := &changelog.Changelog{} 222 + version, err = resolveReleaseVersion("", "patch", blankChangelog) 223 + if err != nil { 224 + t.Fatalf("resolveReleaseVersion returned error: %v", err) 225 + } 226 + if version != "0.0.1" { 227 + t.Fatalf("expected 0.0.1 for empty changelog, got %s", version) 228 + } 229 + }
+60
cmd/toolchain_helpers.go
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + 6 + "github.com/stormlightlabs/git-storm/internal/toolchain" 7 + ) 8 + 9 + func updateToolchainTargets(repoPath, newVersion string, selectors []string) ([]toolchain.Manifest, error) { 10 + if len(selectors) == 0 { 11 + return nil, nil 12 + } 13 + 14 + selected, interactive, available, err := toolchain.ResolveTargets(repoPath, selectors) 15 + if err != nil { 16 + return nil, err 17 + } 18 + 19 + if interactive { 20 + if len(available) == 0 { 21 + return nil, fmt.Errorf("no toolchain manifests detected for interactive selection") 22 + } 23 + chosen, err := toolchain.SelectManifests(available) 24 + if err != nil { 25 + return nil, err 26 + } 27 + selected = append(selected, chosen...) 28 + } 29 + 30 + selected = dedupeManifests(selected) 31 + if len(selected) == 0 { 32 + return nil, nil 33 + } 34 + 35 + for _, manifest := range selected { 36 + if err := toolchain.UpdateManifest(manifest, newVersion); err != nil { 37 + return nil, err 38 + } 39 + } 40 + 41 + return selected, nil 42 + } 43 + 44 + func dedupeManifests(manifests []toolchain.Manifest) []toolchain.Manifest { 45 + if len(manifests) == 0 { 46 + return nil 47 + } 48 + 49 + seen := make(map[string]struct{}) 50 + var result []toolchain.Manifest 51 + for _, manifest := range manifests { 52 + key := manifest.Path 53 + if _, ok := seen[key]; ok { 54 + continue 55 + } 56 + seen[key] = struct{}{} 57 + result = append(result, manifest) 58 + } 59 + return result 60 + }
+24 -10
docs/.vitepress/config.mts
··· 2 2 3 3 // https://vitepress.dev/reference/site-config 4 4 export default defineConfig({ 5 - title: "Git Storm", 6 - description: "A changelog manager", 5 + title: "Storm", 6 + description: "Local-first changelog manager for git repositories", 7 7 markdown: { 8 8 theme: { 9 9 light: "catppuccin-latte", ··· 13 13 themeConfig: { 14 14 // https://vitepress.dev/reference/default-theme-config 15 15 nav: [ 16 - { text: "Home", link: "/" }, 17 - { text: "Examples", link: "/markdown-examples" }, 16 + { text: "Introduction", link: "/introduction" }, 17 + { text: "Quickstart", link: "/quickstart" }, 18 + { text: "Manual", link: "/manual" }, 19 + { text: "Development", link: "/development" }, 18 20 ], 19 - 20 21 sidebar: [ 21 22 { 22 - text: "Examples", 23 + text: "Getting Started", 23 24 items: [ 24 - { text: "Markdown Examples", link: "/markdown-examples" }, 25 - { text: "Runtime API Examples", link: "/api-examples" }, 25 + { text: "Introduction", link: "/introduction" }, 26 + { text: "Quickstart", link: "/quickstart" }, 27 + ], 28 + }, 29 + { 30 + text: "Reference", 31 + items: [ 32 + { text: "Manual", link: "/manual" }, 33 + { text: "Development", link: "/development" }, 26 34 ], 27 35 }, 28 36 ], 29 - 30 37 socialLinks: [ 31 - { icon: "github", link: "https://github.com/vuejs/vitepress" }, 38 + { 39 + icon: "github", 40 + link: "https://github.com/stormlightlabs/git-storm", 41 + }, 42 + { 43 + icon: "bluesky", 44 + link: "http://bsky.app/profile/desertthunder.dev/", 45 + }, 32 46 ], 33 47 }, 34 48 });
-49
docs/api-examples.md
··· 1 - --- 2 - outline: deep 3 - --- 4 - 5 - # Runtime API Examples 6 - 7 - This page demonstrates usage of some of the runtime APIs provided by VitePress. 8 - 9 - The main `useData()` API can be used to access site, theme, and page data for the current page. It works in both `.md` and `.vue` files: 10 - 11 - ```md 12 - <script setup> 13 - import { useData } from 'vitepress' 14 - 15 - const { theme, page, frontmatter } = useData() 16 - </script> 17 - 18 - ## Results 19 - 20 - ### Theme Data 21 - <pre>{{ theme }}</pre> 22 - 23 - ### Page Data 24 - <pre>{{ page }}</pre> 25 - 26 - ### Page Frontmatter 27 - <pre>{{ frontmatter }}</pre> 28 - ``` 29 - 30 - <script setup> 31 - import { useData } from 'vitepress' 32 - 33 - const { site, theme, page, frontmatter } = useData() 34 - </script> 35 - 36 - ## Results 37 - 38 - ### Theme Data 39 - <pre>{{ theme }}</pre> 40 - 41 - ### Page Data 42 - <pre>{{ page }}</pre> 43 - 44 - ### Page Frontmatter 45 - <pre>{{ frontmatter }}</pre> 46 - 47 - ## More 48 - 49 - Check out the documentation for the [full list of runtime APIs](https://vitepress.dev/reference/runtime-api#usedata).
+105
docs/development.md
··· 1 + --- 2 + title: Development 3 + outline: deep 4 + --- 5 + 6 + # Development 7 + 8 + Storm is designed to be hackable: each package works on its own and can be 9 + composed in tests or other Go programs. This document contains the guidance that 10 + previously lived in the repository README. 11 + 12 + ## Guidance 13 + 14 + 1. **Composable:** packages such as `diff`, `gitlog`, and `tui` should expose 15 + standalone entry points that can be imported elsewhere. 16 + 2. **Frontmatter:** `.changes/*.md` entries follow this schema: 17 + 18 + ```yaml 19 + type: added 20 + scope: cli 21 + summary: Add changelog command 22 + ``` 23 + 24 + 3. **Palette:** all TUIs must use the colors defined in `internal/style`. 25 + 4. **Command chaining:** every command should behave well in pipelines, e.g. 26 + 27 + ```sh 28 + storm unreleased list --json 29 + storm generate --since v1.2.0 --interactive 30 + storm release --bump patch --toolchain package.json 31 + ``` 32 + 33 + 5. **Tests:** 34 + - Prefer teatest for Bubble Tea programs. 35 + - Use golden files for diff/changelog output when useful. 36 + - Spin up in-memory `go-git` repositories in unit tests. 37 + 38 + ## Notes 39 + 40 + - Keep the workflow deterministic so releases can be derived from local files 41 + alone. 42 + - TUIs should degrade gracefully when `stdin`/`stdout` are not TTYs. 43 + - The binary should not depend on external services beyond git data already in 44 + the repo. 45 + 46 + ## Roadmap 47 + 48 + | Phase | Deliverable | 49 + | ----- | ---------------------------------------------- | 50 + | 1 | Core CLI (`generate`, `unreleased`, `release`) | 51 + | 2 | Git integration and commit parsing | 52 + | 3 | Diff engine and styling | 53 + | 4 | `.changes` storage and parsing | 54 + | 5 | Interactive TUI | 55 + | 6 | Keep a Changelog writer | 56 + | 7 | Git tagging and CI integration | 57 + 58 + ## Conventional Commits 59 + 60 + Storm follows the [Conventional Commits](https://www.conventionalcommits.org) 61 + spec. Use the format `type(scope): summary` with optional body and footers. 62 + 63 + ### Structure 64 + 65 + | Element | Format | Description | 66 + | ------- | ------ | ----------- | 67 + | Header | `<type>(<scope>): <description>` | Main commit line. | 68 + | Scope | Optional, e.g. `api`, `cli`, `deps`. | Part of the codebase affected. | 69 + | Breaking indicator | `!` after type/scope, e.g. `feat(api)!:` | Marks breaking change. | 70 + | Body | Blank line then body text. | Explains what and why. | 71 + | Footer | Blank line then metadata. | Issue references, `BREAKING CHANGE`, etc. | 72 + 73 + ### Types 74 + 75 + | Type | Description | 76 + | ---- | ----------- | 77 + | `feat` | New feature. | 78 + | `fix` | Bug fix. | 79 + | `docs` | Documentation change. | 80 + | `style` | Formatting-only change. | 81 + | `refactor` | Structural change without new features or fixes. | 82 + | `perf` | Performance improvement. | 83 + | `test` | Adds or updates tests. | 84 + | `build` | Build system or dependency change. | 85 + | `ci` | CI config change. | 86 + | `chore` | Tooling or config change outside src/test. | 87 + | `revert` | Reverts a previous commit. | 88 + 89 + ### Examples 90 + 91 + ```text 92 + feat(api): add pagination endpoint 93 + fix(ui): correct button alignment issue 94 + docs: update README installation instructions 95 + perf(core): optimize user query performance 96 + refactor: restructure payment module for clarity 97 + style: apply consistent formatting 98 + test(auth): add integration tests for OAuth flow 99 + build(deps): bump dependencies to latest versions 100 + ci: add GitHub Actions workflow for CI 101 + chore: update .gitignore and clean up obsolete files 102 + feat(api)!: remove support for legacy endpoints 103 + 104 + BREAKING CHANGE: API no longer accepts XML-formatted requests. 105 + ```
+11 -18
docs/index.md
··· 1 1 --- 2 - # https://vitepress.dev/reference/default-theme-home-page 3 2 layout: home 4 - 5 3 hero: 6 - name: "Git Storm" 7 - text: "A changelog manager" 8 - tagline: My great project tagline 4 + name: "Storm" 5 + text: "Local-first changelog manager" 6 + tagline: "Collect unreleased notes, review them in TUIs, and publish semantic releases without leaving git" 9 7 actions: 10 8 - theme: brand 11 - text: Markdown Examples 12 - link: /markdown-examples 13 - - theme: alt 14 - text: API Examples 15 - link: /api-examples 16 - 9 + text: Quickstart 10 + link: /quickstart 17 11 features: 18 - - title: Feature A 19 - details: Lorem ipsum dolor sit amet, consectetur adipiscing elit 20 - - title: Feature B 21 - details: Lorem ipsum dolor sit amet, consectetur adipiscing elit 22 - - title: Feature C 23 - details: Lorem ipsum dolor sit amet, consectetur adipiscing elit 12 + - title: Keep a Changelog native 13 + details: Entries stay in `.changes/*.md` until you promote them, keeping releases reproducible and reviewable. 14 + - title: Toolchain aware 15 + details: The bump and release commands can update Cargo, npm, Python, and Deno manifests in lockstep. 16 + - title: Built for TUIs 17 + details: Commit selectors, unreleased reviews, and diff viewers are powered by Bubble Tea and share the same palette. 24 18 --- 25 -
+93
docs/introduction.md
··· 1 + --- 2 + title: Introduction 3 + outline: deep 4 + --- 5 + 6 + # Introduction 7 + 8 + Storm is a CLI that keeps changelog entries close to your code. It grew from a 9 + few principles: 10 + 11 + 1. **Plain text first.** Every unreleased change is a Markdown file that lives 12 + in your repo, making reviews and rebases simple. 13 + 2. **Deterministic releases.** Given the same `.changes` directory, Storm will 14 + always write the same `CHANGELOG.md` section. 15 + 3. **Interactive when helpful, scriptable everywhere.** TUIs exist for reviews 16 + and diffs, but every action prints machine-readable summaries for CI. 17 + 18 + ## Why Storm? 19 + 20 + Storm sits between `git log` and `CHANGELOG.md`. It understands conventional 21 + commits, keeps notes in version control, and prefers deterministic text files 22 + over generated blobs. The CLI is written in Go, so it ships as a single binary 23 + that runs anywhere your repository does. 24 + 25 + - **Local-first workflow:** no external services or databases. 26 + - **Deterministic releases:** `storm release` is idempotent and can run in CI. 27 + - **Composable commands:** each subcommand prints useful summaries for scripts. 28 + 29 + ## Quick Preview 30 + 31 + ```sh 32 + # Extract commits into .changes entries 33 + storm generate --since v1.2.0 --interactive 34 + 35 + # Review pending notes 36 + storm unreleased review 37 + 38 + # Cut a new release and update package.json 39 + storm release --bump minor --toolchain package.json --tag 40 + ``` 41 + 42 + Need the details? Head to the [Quickstart](/quickstart) for a guided flow or 43 + read the [manual](/manual) for every flag and exit code. 44 + 45 + ## Architecture Overview 46 + 47 + ```sh 48 + .git/ 49 + .changes/ 50 + CHANGELOG.md 51 + ``` 52 + 53 + - `storm generate` and `storm unreleased add` populate `.changes/`. 54 + - `storm check` and your CI ensure nothing merges without an entry. 55 + - `storm release` merges the queue into `CHANGELOG.md`, optionally creates a 56 + tag, and can update external manifests. 57 + 58 + ## Toolchain-aware versioning 59 + 60 + The bump and release commands understand common ecosystem manifests: 61 + 62 + | Manifest | Alias | Notes | 63 + | -------- | ----- | ----- | 64 + | `Cargo.toml` | `cargo`, `rust` | Updates `[package]` version. | 65 + | `pyproject.toml` | `pyproject`, `python`, `poetry` | Supports `[project]` and `[tool.poetry]`. | 66 + | `package.json` | `npm`, `node`, `package` | Edits the top-level `version` field. | 67 + | `deno.json` | `deno` | Updates the root `version`. | 68 + 69 + Pass specific paths or the literal `interactive` to launch the toolchain picker 70 + TUI. 71 + 72 + ## TUIs everywhere 73 + 74 + Storm shares a consistent palette (`internal/style`) across Bubble Tea 75 + experiences: 76 + 77 + - **Commit selector** for `storm generate --interactive`. 78 + - **Unreleased review** for curating `.changes` entries. 79 + - **Diff viewer** for `storm diff` with split/unified modes. 80 + - **Toolchain picker** accessible via `--toolchain interactive`. 81 + 82 + Each interface supports familiar Vim-style navigation (↑/↓, g/G, space to 83 + select, `q` to quit) and degrades gracefully when no TTY is available. 84 + 85 + ## Suggested Workflow 86 + 87 + 1. Developers add `.changes` entries alongside feature branches. 88 + 2. Pull requests run `storm check --since <last-release>`. 89 + 3. Release engineers run `storm generate` (if needed) then `storm release`. 90 + 4. CI tags the release and publishes artifacts. 91 + 92 + Need concrete steps? See the [Quickstart](/quickstart) or jump to the 93 + [manual](/manual).
+171
docs/manual.md
··· 1 + --- 2 + title: Storm CLI Manual 3 + --- 4 + 5 + # NAME 6 + 7 + **storm** is a git powered aware changelog manager for Go projects. 8 + 9 + ## SYNOPSIS 10 + 11 + ```text 12 + storm [--repo <path>] [--output <file>] <command> [flags] 13 + ``` 14 + 15 + ## DESCRIPTION 16 + 17 + Storm keeps unreleased notes in `.changes/*.md`, promotes them into 18 + `CHANGELOG.md`, and offers TUIs for reviewing diffs and entries. The binary 19 + is composed of self-contained subcommands that chain well inside scripts 20 + or CI jobs. 21 + 22 + ### GLOBAL FLAGS 23 + 24 + | Flag | Description | 25 + | ----------------------- | -------------------------------------------------------- | 26 + | `--repo <path>` | Working tree to operate on (default: current directory). | 27 + | `-o`, `--output <file>` | Target changelog (default: `CHANGELOG.md`). | 28 + 29 + ### COMMANDS 30 + 31 + #### `storm bump` 32 + 33 + Calculate the next semantic version by inspecting `CHANGELOG.md`. 34 + 35 + ```text 36 + storm bump --bump <major|minor|patch> [--toolchain value...] 37 + ``` 38 + 39 + ##### Flags 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. | 45 + 46 + #### `storm release` 47 + 48 + Promote `.changes/*.md` into the changelog and optionally tag the repo. 49 + 50 + ```text 51 + storm release (--version X.Y.Z | --bump <type>) [flags] 52 + ``` 53 + 54 + ##### Flags 55 + 56 + | Flag | Description | 57 + | --------------------- | ----------------------------------------------------------------------------------- | 58 + | `--version <X.Y.Z>` | Explicit version for the new changelog entry. | 59 + | `--bump <type>` | Derive the version from the previous release (mutually exclusive with `--version`). | 60 + | `--date <YYYY-MM-DD>` | Override the release date (default: today). | 61 + | `--clear-changes` | Remove `.changes/*.md` files after a successful release. | 62 + | `--dry-run` | Render a preview without touching any files. | 63 + | `--tag` | Create an annotated git tag containing the release notes. | 64 + | `--toolchain <value>` | Update manifest files just like in `storm bump`. | 65 + 66 + #### `storm generate` 67 + 68 + Create `.changes/*.md` files from commit history, with optional TUI review. 69 + 70 + ```text 71 + storm generate <from> <to> 72 + storm generate --since <tag> [to] 73 + ``` 74 + 75 + ##### Flags 76 + 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`. | 81 + 82 + #### `storm diff` 83 + 84 + Side-by-side or unified diff with TUI navigation. 85 + 86 + ```text 87 + storm diff <from>..<to> [flags] 88 + storm diff <from> <to> [flags] 89 + ``` 90 + 91 + | Flag | Description | 92 + | ------------------------------- | ----------------------------------------------------- | 93 + | `-f`, `--file <path>` | Restrict the diff to a single file. | 94 + | `-e`, `--expanded` | Show all unchanged lines instead of compressed hunks. | 95 + | `-v`, `--view <split\|unified>` | Rendering style (default: split). | 96 + 97 + #### `storm check` 98 + 99 + Verify every commit in a range has a corresponding unreleased entry. 100 + 101 + ```text 102 + storm check <from> <to> 103 + storm check --since <tag> [to] 104 + ``` 105 + 106 + | Flag | Description | 107 + | --------------- | ---------------------------------------------------------- | 108 + | `--since <tag>` | Start range at the provided tag and default end to `HEAD`. | 109 + 110 + Non-zero exit status indicates missing entries. Messages containing 111 + `[nochanges]` or `[skip changelog]` are ignored. 112 + 113 + #### `storm unreleased` 114 + 115 + Manage `.changes` entries directly. 116 + 117 + ##### `add` 118 + 119 + ```text 120 + storm unreleased add --type <kind> --summary <text> [--scope value] 121 + ``` 122 + 123 + | Flag | Description | 124 + | --------------------------------------------------- | ------------------------------------------- | 125 + | `--type <added\|changed\|fixed\|removed\|security>` | Entry category. | 126 + | `--summary <text>` | Short human readable note. | 127 + | `--scope <value>` | Optional component indicator (e.g., `cli`). | 128 + 129 + ##### `list` 130 + 131 + ```text 132 + storm unreleased list [--json] 133 + ``` 134 + 135 + | Flag | Description | 136 + | -------- | -------------------------------------------------- | 137 + | `--json` | Emit machine-readable JSON instead of styled text. | 138 + 139 + ##### `partial` 140 + 141 + ```text 142 + storm unreleased partial <commit-ref> [flags] 143 + ``` 144 + 145 + | Flag | Description | 146 + | ------------------ | --------------------------------------------------- | 147 + | `--type <value>` | Override the inferred type from the commit message. | 148 + | `--summary <text>` | Override the inferred summary. | 149 + | `--scope <value>` | Optional component indicator. | 150 + 151 + ##### `review` 152 + 153 + ```text 154 + storm unreleased review 155 + ``` 156 + 157 + Launch a Bubble Tea TUI for editing and deleting entries before release. 158 + Requires a TTY; fall back to `storm unreleased list` otherwise. 159 + 160 + #### `storm version` 161 + 162 + Print the current build’s version string. 163 + 164 + ## FILES 165 + 166 + - `.changes/` — queue of unreleased entries created by `storm generate` or `storm unreleased add`. 167 + - `CHANGELOG.md` — Keep a Changelog-compatible file updated by `storm release`. 168 + 169 + ## SEE ALSO 170 + 171 + `CHANGELOG.md`, [Keep a Changelog](https://keepachangelog.com), semantic versioning at [semver.org](https://semver.org).
-85
docs/markdown-examples.md
··· 1 - # Markdown Extension Examples 2 - 3 - This page demonstrates some of the built-in markdown extensions provided by VitePress. 4 - 5 - ## Syntax Highlighting 6 - 7 - VitePress provides Syntax Highlighting powered by [Shiki](https://github.com/shikijs/shiki), with additional features like line-highlighting: 8 - 9 - **Input** 10 - 11 - ````md 12 - ```js{4} 13 - export default { 14 - data () { 15 - return { 16 - msg: 'Highlighted!' 17 - } 18 - } 19 - } 20 - ``` 21 - ```` 22 - 23 - **Output** 24 - 25 - ```js{4} 26 - export default { 27 - data () { 28 - return { 29 - msg: 'Highlighted!' 30 - } 31 - } 32 - } 33 - ``` 34 - 35 - ## Custom Containers 36 - 37 - **Input** 38 - 39 - ```md 40 - ::: info 41 - This is an info box. 42 - ::: 43 - 44 - ::: tip 45 - This is a tip. 46 - ::: 47 - 48 - ::: warning 49 - This is a warning. 50 - ::: 51 - 52 - ::: danger 53 - This is a dangerous warning. 54 - ::: 55 - 56 - ::: details 57 - This is a details block. 58 - ::: 59 - ``` 60 - 61 - **Output** 62 - 63 - ::: info 64 - This is an info box. 65 - ::: 66 - 67 - ::: tip 68 - This is a tip. 69 - ::: 70 - 71 - ::: warning 72 - This is a warning. 73 - ::: 74 - 75 - ::: danger 76 - This is a dangerous warning. 77 - ::: 78 - 79 - ::: details 80 - This is a details block. 81 - ::: 82 - 83 - ## More 84 - 85 - Check out the documentation for the [full list of markdown extensions](https://vitepress.dev/guide/markdown).
+94
docs/quickstart.md
··· 1 + --- 2 + title: Quickstart 3 + outline: deep 4 + --- 5 + 6 + # Quickstart 7 + 8 + This walkthrough gets you from zero to a published changelog entry in a few 9 + minutes. It mirrors the default workflow baked into the CLI. 10 + 11 + ## 1. Install the CLI 12 + 13 + ```sh 14 + go install github.com/stormlightlabs/git-storm/cmd/storm@latest 15 + ``` 16 + 17 + Verify the binary is available: 18 + 19 + ```sh 20 + storm version 21 + ``` 22 + 23 + ## 2. Capture unreleased changes 24 + 25 + Create a `.changes` entry manually or generate them from commits. 26 + 27 + ### Option A — Manual entry 28 + 29 + ```sh 30 + storm unreleased add \ 31 + --type added \ 32 + --scope cli \ 33 + --summary "Add bump command" 34 + ``` 35 + 36 + ### Option B — From git history 37 + 38 + ```sh 39 + storm generate --since v1.2.0 --interactive 40 + ``` 41 + 42 + Use the commit selector TUI to pick which commits become entries. Storm writes 43 + Markdown files such as `.changes/2025-03-01-add-bump-command.md`. 44 + 45 + ## 3. Review pending entries 46 + 47 + ```sh 48 + storm unreleased review 49 + ``` 50 + 51 + The Bubble Tea UI lets you edit summaries, delete noise, or mark entries as 52 + ready. In non-interactive environments, fall back to 53 + `storm unreleased list --json`. 54 + 55 + ## 4. Dry-run a release 56 + 57 + ```sh 58 + storm release --bump patch --dry-run 59 + ``` 60 + 61 + This prints the new `CHANGELOG` section without modifying files. When the 62 + output looks right, re-run without `--dry-run`. 63 + 64 + ## 5. Publish and tag 65 + 66 + ```sh 67 + storm release --bump patch --toolchain package.json --tag 68 + ``` 69 + 70 + - `--bump patch` derives the next version from the previous release. 71 + - `--toolchain package.json` keeps your npm manifest in sync. 72 + - `--tag` creates an annotated git tag containing the release notes. 73 + 74 + Follow up with standard git commands: 75 + 76 + ```sh 77 + git add CHANGELOG.md package.json .changes 78 + git commit -m "Release v$(storm bump --bump patch)" 79 + git push origin main --tags 80 + ``` 81 + 82 + ## 6. Enforce entries in CI (optional) 83 + 84 + ```sh 85 + storm check --since v1.2.0 86 + ``` 87 + 88 + The command exits non-zero when commits are missing `.changes` files, making it 89 + ideal for pre-merge checks. 90 + 91 + ## Next steps 92 + 93 + - Skim the [Introduction](/introduction) to understand the design. 94 + - Explore every flag in the [manual](/manual).
+486
internal/toolchain/manifest.go
··· 1 + package toolchain 2 + 3 + import ( 4 + "bytes" 5 + "encoding/json" 6 + "fmt" 7 + "io/fs" 8 + "os" 9 + "path/filepath" 10 + "regexp" 11 + "sort" 12 + "strings" 13 + ) 14 + 15 + // ManifestType enumerates supported ecosystem manifests whose versions we can bump. 16 + type ManifestType string 17 + 18 + const ( 19 + ManifestCargo ManifestType = "cargo" 20 + ManifestPython ManifestType = "python" 21 + ManifestNode ManifestType = "node" 22 + ManifestDeno ManifestType = "deno" 23 + ) 24 + 25 + var manifestFilenames = map[string]ManifestType{ 26 + "cargo.toml": ManifestCargo, 27 + "pyproject.toml": ManifestPython, 28 + "package.json": ManifestNode, 29 + "deno.json": ManifestDeno, 30 + } 31 + 32 + var toolchainAliases = map[string]ManifestType{ 33 + "cargo": ManifestCargo, 34 + "rust": ManifestCargo, 35 + "cargo.toml": ManifestCargo, 36 + "pyproject": ManifestPython, 37 + "pyproject.toml": ManifestPython, 38 + "python": ManifestPython, 39 + "package": ManifestNode, 40 + "package.json": ManifestNode, 41 + "npm": ManifestNode, 42 + "node": ManifestNode, 43 + "deno": ManifestDeno, 44 + "deno.json": ManifestDeno, 45 + } 46 + 47 + var skipWalkDirs = map[string]struct{}{ 48 + ".git": {}, 49 + "node_modules": {}, 50 + "vendor": {}, 51 + "dist": {}, 52 + "target": {}, 53 + "tmp": {}, 54 + } 55 + 56 + // Manifest describes a discovered manifest file with its current version. 57 + type Manifest struct { 58 + Type ManifestType 59 + Path string 60 + RelPath string 61 + Version string 62 + Name string 63 + } 64 + 65 + // DisplayLabel returns a concise label for TUI listings. 66 + func (m Manifest) DisplayLabel() string { 67 + label := m.RelPath 68 + if m.Name != "" { 69 + label = fmt.Sprintf("%s · %s", label, m.Name) 70 + } 71 + if m.Version != "" { 72 + label = fmt.Sprintf("%s @ %s", label, m.Version) 73 + } 74 + return label 75 + } 76 + 77 + // Discover scans the repository tree for supported manifest files. 78 + func Discover(root string) ([]Manifest, error) { 79 + absRoot, err := filepath.Abs(root) 80 + if err != nil { 81 + return nil, err 82 + } 83 + 84 + var manifests []Manifest 85 + err = filepath.WalkDir(absRoot, func(path string, d fs.DirEntry, walkErr error) error { 86 + if walkErr != nil { 87 + return walkErr 88 + } 89 + if d.IsDir() { 90 + if path != absRoot { 91 + if _, skip := skipWalkDirs[strings.ToLower(d.Name())]; skip { 92 + return filepath.SkipDir 93 + } 94 + } 95 + return nil 96 + } 97 + 98 + kind, ok := manifestFilenames[strings.ToLower(d.Name())] 99 + if !ok { 100 + return nil 101 + } 102 + 103 + manifest, err := buildManifest(absRoot, path, kind) 104 + if err != nil { 105 + return err 106 + } 107 + manifests = append(manifests, manifest) 108 + return nil 109 + }) 110 + if err != nil { 111 + return nil, err 112 + } 113 + 114 + sort.Slice(manifests, func(i, j int) bool { 115 + return manifests[i].RelPath < manifests[j].RelPath 116 + }) 117 + return manifests, nil 118 + } 119 + 120 + // ResolveTargets resolves CLI selectors into manifest targets, optionally requesting a TUI selection. 121 + func ResolveTargets(root string, selectors []string) ([]Manifest, bool, []Manifest, error) { 122 + if len(selectors) == 0 { 123 + return nil, false, nil, nil 124 + } 125 + 126 + absRoot, err := filepath.Abs(root) 127 + if err != nil { 128 + return nil, false, nil, err 129 + } 130 + 131 + available, err := Discover(absRoot) 132 + if err != nil { 133 + return nil, false, nil, err 134 + } 135 + 136 + manifestByPath := make(map[string]Manifest) 137 + for _, manifest := range available { 138 + manifestByPath[filepath.Clean(manifest.Path)] = manifest 139 + } 140 + 141 + var selected []Manifest 142 + seen := make(map[string]struct{}) 143 + interactive := false 144 + 145 + for _, raw := range selectors { 146 + value := strings.TrimSpace(raw) 147 + if value == "" { 148 + continue 149 + } 150 + 151 + lower := strings.ToLower(value) 152 + switch lower { 153 + case "interactive", "tui", "select": 154 + interactive = true 155 + continue 156 + } 157 + 158 + if kind, ok := toolchainAliases[lower]; ok { 159 + matched := false 160 + for _, manifest := range available { 161 + if manifest.Type == kind { 162 + key := filepath.Clean(manifest.Path) 163 + if _, exists := seen[key]; !exists { 164 + selected = append(selected, manifest) 165 + seen[key] = struct{}{} 166 + } 167 + matched = true 168 + } 169 + } 170 + if !matched { 171 + return nil, false, nil, fmt.Errorf("no %s manifest found", value) 172 + } 173 + continue 174 + } 175 + 176 + target := value 177 + if !filepath.IsAbs(value) { 178 + target = filepath.Join(absRoot, value) 179 + } 180 + target = filepath.Clean(target) 181 + 182 + manifest, err := loadManifest(absRoot, target) 183 + if err != nil { 184 + return nil, false, nil, err 185 + } 186 + 187 + if _, exists := seen[target]; !exists { 188 + selected = append(selected, manifest) 189 + seen[target] = struct{}{} 190 + } 191 + } 192 + 193 + return selected, interactive, available, nil 194 + } 195 + 196 + // UpdateManifest rewrites the manifest on disk with the provided version. 197 + func UpdateManifest(manifest Manifest, newVersion string) error { 198 + switch manifest.Type { 199 + case ManifestCargo: 200 + return updateTomlVersion(manifest.Path, []string{"package"}, newVersion) 201 + case ManifestPython: 202 + return updateTomlVersion(manifest.Path, []string{"project", "tool.poetry"}, newVersion) 203 + case ManifestNode: 204 + return updateJSONVersion(manifest.Path, newVersion) 205 + case ManifestDeno: 206 + return updateJSONVersion(manifest.Path, newVersion) 207 + default: 208 + return fmt.Errorf("unsupported manifest type: %s", manifest.Type) 209 + } 210 + } 211 + 212 + func buildManifest(root, path string, kind ManifestType) (Manifest, error) { 213 + version, name, err := extractMetadata(path, kind) 214 + if err != nil { 215 + return Manifest{}, err 216 + } 217 + 218 + rel, err := filepath.Rel(root, path) 219 + if err != nil { 220 + rel = path 221 + } 222 + 223 + return Manifest{ 224 + Type: kind, 225 + Path: filepath.Clean(path), 226 + RelPath: filepath.Clean(rel), 227 + Version: version, 228 + Name: name, 229 + }, nil 230 + } 231 + 232 + func loadManifest(root, path string) (Manifest, error) { 233 + info, err := os.Stat(path) 234 + if err != nil { 235 + return Manifest{}, fmt.Errorf("unable to read %s: %w", path, err) 236 + } 237 + if info.IsDir() { 238 + return Manifest{}, fmt.Errorf("%s is a directory", path) 239 + } 240 + 241 + kind, ok := manifestFilenames[strings.ToLower(filepath.Base(path))] 242 + if !ok { 243 + return Manifest{}, fmt.Errorf("unsupported toolchain file: %s", filepath.Base(path)) 244 + } 245 + 246 + return buildManifest(root, path, kind) 247 + } 248 + 249 + func extractMetadata(path string, kind ManifestType) (string, string, error) { 250 + switch kind { 251 + case ManifestCargo: 252 + return parseTomlManifest(path, []string{"package"}) 253 + case ManifestPython: 254 + return parseTomlManifest(path, []string{"project", "tool.poetry"}) 255 + case ManifestNode: 256 + return parseJSONManifest(path) 257 + case ManifestDeno: 258 + return parseJSONManifest(path) 259 + default: 260 + return "", "", fmt.Errorf("unsupported manifest type: %s", kind) 261 + } 262 + } 263 + 264 + func parseJSONManifest(path string) (string, string, error) { 265 + data, err := os.ReadFile(path) 266 + if err != nil { 267 + return "", "", err 268 + } 269 + 270 + var payload map[string]any 271 + if err := json.Unmarshal(data, &payload); err != nil { 272 + return "", "", fmt.Errorf("failed to parse %s: %w", filepath.Base(path), err) 273 + } 274 + 275 + version, _ := payload["version"].(string) 276 + if version == "" { 277 + return "", "", fmt.Errorf("version not found in %s", filepath.Base(path)) 278 + } 279 + 280 + name, _ := payload["name"].(string) 281 + return version, name, nil 282 + } 283 + 284 + func parseTomlManifest(path string, sections []string) (string, string, error) { 285 + data, err := os.ReadFile(path) 286 + if err != nil { 287 + return "", "", err 288 + } 289 + 290 + sectionSet := make(map[string]struct{}) 291 + for _, section := range sections { 292 + sectionSet[section] = struct{}{} 293 + } 294 + 295 + var current string 296 + var version string 297 + var name string 298 + lines := strings.Split(string(data), "\n") 299 + 300 + for _, line := range lines { 301 + trimmed := strings.TrimSpace(line) 302 + if trimmed == "" || strings.HasPrefix(trimmed, "#") { 303 + continue 304 + } 305 + if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { 306 + current = strings.TrimSpace(strings.Trim(trimmed, "[]")) 307 + continue 308 + } 309 + if _, ok := sectionSet[current]; !ok { 310 + continue 311 + } 312 + 313 + if value, ok := parseTomlAssignment(line, "version"); ok && version == "" { 314 + version = value 315 + continue 316 + } 317 + if value, ok := parseTomlAssignment(line, "name"); ok && name == "" { 318 + name = value 319 + } 320 + } 321 + 322 + if version == "" { 323 + return "", "", fmt.Errorf("version not found in %s", filepath.Base(path)) 324 + } 325 + 326 + return version, name, nil 327 + } 328 + 329 + func parseTomlAssignment(line, key string) (string, bool) { 330 + withoutComment := strings.Split(line, "#")[0] 331 + parts := strings.SplitN(withoutComment, "=", 2) 332 + if len(parts) != 2 { 333 + return "", false 334 + } 335 + if strings.TrimSpace(parts[0]) != key { 336 + return "", false 337 + } 338 + 339 + value := strings.TrimSpace(parts[1]) 340 + if len(value) >= 2 { 341 + if (value[0] == '"' && value[len(value)-1] == '"') || (value[0] == '\'' && value[len(value)-1] == '\'') { 342 + value = value[1 : len(value)-1] 343 + } 344 + } 345 + return value, true 346 + } 347 + 348 + var tomlVersionPattern = regexp.MustCompile(`^(\s*version\s*=\s*)(['"])([^'"]*)(['"])(.*)$`) 349 + 350 + func updateTomlVersion(path string, sections []string, newVersion string) error { 351 + data, err := os.ReadFile(path) 352 + if err != nil { 353 + return err 354 + } 355 + 356 + sectionSet := make(map[string]struct{}) 357 + for _, section := range sections { 358 + sectionSet[section] = struct{}{} 359 + } 360 + 361 + lines := strings.Split(string(data), "\n") 362 + current := "" 363 + replaced := false 364 + 365 + for idx, line := range lines { 366 + trimmed := strings.TrimSpace(line) 367 + if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { 368 + current = strings.TrimSpace(strings.Trim(trimmed, "[]")) 369 + continue 370 + } 371 + if replaced { 372 + continue 373 + } 374 + if _, ok := sectionSet[current]; !ok { 375 + continue 376 + } 377 + if matches := tomlVersionPattern.FindStringSubmatch(line); matches != nil { 378 + lines[idx] = fmt.Sprintf("%s%s%s%s%s", matches[1], matches[2], newVersion, matches[4], matches[5]) 379 + replaced = true 380 + } 381 + } 382 + 383 + if !replaced { 384 + return fmt.Errorf("version not found in %s", filepath.Base(path)) 385 + } 386 + 387 + return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0644) 388 + } 389 + 390 + func updateJSONVersion(path string, newVersion string) error { 391 + data, err := os.ReadFile(path) 392 + if err != nil { 393 + return err 394 + } 395 + 396 + start, end, err := findRootJSONVersion(data) 397 + if err != nil { 398 + return fmt.Errorf("version not found in %s", filepath.Base(path)) 399 + } 400 + 401 + var buf bytes.Buffer 402 + buf.Grow(len(data) - (end - start) + len(newVersion)) 403 + buf.Write(data[:start]) 404 + buf.WriteString(newVersion) 405 + buf.Write(data[end:]) 406 + 407 + return os.WriteFile(path, buf.Bytes(), 0644) 408 + } 409 + 410 + func findRootJSONVersion(data []byte) (int, int, error) { 411 + depth := 0 412 + inString := false 413 + escape := false 414 + keyStart := -1 415 + 416 + for i := 0; i < len(data); i++ { 417 + b := data[i] 418 + if inString { 419 + if escape { 420 + escape = false 421 + continue 422 + } 423 + if b == '\\' { 424 + escape = true 425 + continue 426 + } 427 + if b == '"' { 428 + inString = false 429 + keyEnd := i 430 + if keyStart >= 0 { 431 + j := i + 1 432 + for j < len(data) && (data[j] == ' ' || data[j] == '\t' || data[j] == '\n' || data[j] == '\r') { 433 + j++ 434 + } 435 + if j < len(data) && data[j] == ':' { 436 + key := string(data[keyStart:keyEnd]) 437 + if key == "version" && depth == 1 { 438 + valueStart, valueEnd, err := locateJSONString(data, j+1) 439 + if err != nil { 440 + return -1, -1, err 441 + } 442 + return valueStart, valueEnd, nil 443 + } 444 + } 445 + } 446 + keyStart = -1 447 + } 448 + continue 449 + } 450 + 451 + switch b { 452 + case '"': 453 + inString = true 454 + keyStart = i + 1 455 + case '{', '[': 456 + depth++ 457 + case '}', ']': 458 + if depth > 0 { 459 + depth-- 460 + } 461 + } 462 + } 463 + 464 + return -1, -1, fmt.Errorf("version key not found") 465 + } 466 + 467 + func locateJSONString(data []byte, start int) (int, int, error) { 468 + i := start 469 + for i < len(data) && (data[i] == ' ' || data[i] == '\t' || data[i] == '\n' || data[i] == '\r') { 470 + i++ 471 + } 472 + if i >= len(data) || data[i] != '"' { 473 + return -1, -1, fmt.Errorf("version value must be a string") 474 + } 475 + valueStart := i + 1 476 + for j := valueStart; j < len(data); j++ { 477 + if data[j] == '\\' { 478 + j++ 479 + continue 480 + } 481 + if data[j] == '"' { 482 + return valueStart, j, nil 483 + } 484 + } 485 + return -1, -1, fmt.Errorf("unterminated version string") 486 + }
+145
internal/toolchain/manifest_test.go
··· 1 + package toolchain 2 + 3 + import ( 4 + "os" 5 + "path/filepath" 6 + "strings" 7 + "testing" 8 + ) 9 + 10 + func TestDiscover(t *testing.T) { 11 + dir := t.TempDir() 12 + 13 + writeFile(t, dir, "Cargo.toml", `[package] 14 + name = "demo" 15 + version = "0.1.0" 16 + 17 + [dependencies] 18 + serde = "1" 19 + `) 20 + 21 + subDir := filepath.Join(dir, "app") 22 + if err := os.MkdirAll(subDir, 0o755); err != nil { 23 + t.Fatalf("failed to create subdir: %v", err) 24 + } 25 + writeFile(t, subDir, "pyproject.toml", `[project] 26 + name = "demo-app" 27 + version = "0.2.0" 28 + `) 29 + 30 + writeFile(t, dir, "package.json", `{ 31 + "name": "demo-web", 32 + "version": "1.5.0" 33 + }`) 34 + 35 + writeFile(t, dir, "deno.json", `{ 36 + "version": "3.1.4" 37 + }`) 38 + 39 + manifests, err := Discover(dir) 40 + if err != nil { 41 + t.Fatalf("Discover returned error: %v", err) 42 + } 43 + if len(manifests) != 4 { 44 + t.Fatalf("expected 4 manifests, got %d", len(manifests)) 45 + } 46 + 47 + manifestByType := make(map[ManifestType]Manifest) 48 + for _, manifest := range manifests { 49 + manifestByType[manifest.Type] = manifest 50 + } 51 + 52 + if manifestByType[ManifestCargo].Version != "0.1.0" { 53 + t.Fatalf("cargo version mismatch: %#v", manifestByType[ManifestCargo]) 54 + } 55 + if manifestByType[ManifestPython].Version != "0.2.0" { 56 + t.Fatalf("python version mismatch: %#v", manifestByType[ManifestPython]) 57 + } 58 + if manifestByType[ManifestNode].Version != "1.5.0" { 59 + t.Fatalf("npm version mismatch: %#v", manifestByType[ManifestNode]) 60 + } 61 + if manifestByType[ManifestDeno].Version != "3.1.4" { 62 + t.Fatalf("deno version mismatch: %#v", manifestByType[ManifestDeno]) 63 + } 64 + } 65 + 66 + func TestResolveTargets(t *testing.T) { 67 + dir := t.TempDir() 68 + writeFile(t, dir, "Cargo.toml", `[package] 69 + name = "demo" 70 + version = "0.1.0" 71 + `) 72 + writeFile(t, dir, "package.json", `{"name":"demo","version":"1.0.0"}`) 73 + 74 + selected, interactive, available, err := ResolveTargets(dir, []string{"cargo"}) 75 + if err != nil { 76 + t.Fatalf("ResolveTargets returned error: %v", err) 77 + } 78 + if interactive { 79 + t.Fatal("expected interactive to be false") 80 + } 81 + if len(selected) != 1 || selected[0].Type != ManifestCargo { 82 + t.Fatalf("expected cargo manifest to be selected, got %#v", selected) 83 + } 84 + if len(available) != 2 { 85 + t.Fatalf("expected 2 discovered manifests, got %d", len(available)) 86 + } 87 + 88 + selected, interactive, _, err = ResolveTargets(dir, []string{"interactive"}) 89 + if err != nil { 90 + t.Fatalf("ResolveTargets interactive: %v", err) 91 + } 92 + if !interactive { 93 + t.Fatal("expected interactive mode to be true") 94 + } 95 + if len(selected) != 0 { 96 + t.Fatalf("interactive request should not preselect manifests: got %d", len(selected)) 97 + } 98 + 99 + selected, _, _, err = ResolveTargets(dir, []string{"package.json"}) 100 + if err != nil { 101 + t.Fatalf("ResolveTargets path selection: %v", err) 102 + } 103 + if len(selected) != 1 || selected[0].Type != ManifestNode { 104 + t.Fatalf("expected package.json selection, got %#v", selected) 105 + } 106 + } 107 + 108 + func TestUpdateManifest(t *testing.T) { 109 + dir := t.TempDir() 110 + writeFile(t, dir, "Cargo.toml", `[package] 111 + name = "demo" 112 + version = "0.1.0" 113 + `) 114 + writeFile(t, dir, "pyproject.toml", `[project] 115 + name = "demo" 116 + version = "0.2.0" 117 + `) 118 + writeFile(t, dir, "package.json", `{"name":"demo","version":"1.0.0"}`) 119 + writeFile(t, dir, "deno.json", `{"version":"1.1.0"}`) 120 + 121 + files := []string{"Cargo.toml", "pyproject.toml", "package.json", "deno.json"} 122 + for _, name := range files { 123 + manifest, err := loadManifest(dir, filepath.Join(dir, name)) 124 + if err != nil { 125 + t.Fatalf("loadManifest(%s) error: %v", name, err) 126 + } 127 + if err := UpdateManifest(manifest, "9.9.9"); err != nil { 128 + t.Fatalf("UpdateManifest(%s) error: %v", name, err) 129 + } 130 + data, err := os.ReadFile(filepath.Join(dir, name)) 131 + if err != nil { 132 + t.Fatalf("read updated %s: %v", name, err) 133 + } 134 + if !strings.Contains(string(data), "9.9.9") { 135 + t.Fatalf("%s was not updated: %s", name, string(data)) 136 + } 137 + } 138 + } 139 + 140 + func writeFile(t *testing.T, dir, name, content string) { 141 + t.Helper() 142 + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { 143 + t.Fatalf("failed to write %s: %v", name, err) 144 + } 145 + }
+147
internal/toolchain/selector.go
··· 1 + package toolchain 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "strings" 7 + 8 + tea "github.com/charmbracelet/bubbletea" 9 + "github.com/charmbracelet/lipgloss" 10 + "github.com/stormlightlabs/git-storm/internal/style" 11 + "golang.org/x/term" 12 + ) 13 + 14 + var ( 15 + cursorStyle = lipgloss.NewStyle().Foreground(style.AccentBlue).Bold(true) 16 + selectedStyle = lipgloss.NewStyle().Foreground(style.AddedColor) 17 + mutedStyle = lipgloss.NewStyle().Foreground(style.AccentSteel) 18 + ) 19 + 20 + // SelectManifests launches an interactive selector so users can pick which manifests to update. 21 + func SelectManifests(manifests []Manifest) ([]Manifest, error) { 22 + if len(manifests) == 0 { 23 + return nil, fmt.Errorf("no toolchain manifests detected") 24 + } 25 + if !isTTY() { 26 + return nil, fmt.Errorf("interactive selection requires a TTY; pass specific --toolchain paths instead") 27 + } 28 + 29 + program := tea.NewProgram(newSelectorModel(manifests)) 30 + finalModel, err := program.Run() 31 + if err != nil { 32 + return nil, err 33 + } 34 + 35 + model, ok := finalModel.(selectorModel) 36 + if !ok { 37 + return nil, fmt.Errorf("unexpected selector model type %T", finalModel) 38 + } 39 + if model.cancelled { 40 + return nil, fmt.Errorf("selection cancelled") 41 + } 42 + return model.selectedManifests(), nil 43 + } 44 + 45 + func isTTY() bool { 46 + return term.IsTerminal(int(os.Stdout.Fd())) && term.IsTerminal(int(os.Stdin.Fd())) 47 + } 48 + 49 + type selectorModel struct { 50 + manifests []Manifest 51 + cursor int 52 + selected map[int]struct{} 53 + done bool 54 + cancelled bool 55 + } 56 + 57 + func newSelectorModel(manifests []Manifest) selectorModel { 58 + return selectorModel{ 59 + manifests: manifests, 60 + selected: make(map[int]struct{}), 61 + } 62 + } 63 + 64 + func (m selectorModel) Init() tea.Cmd { return nil } 65 + 66 + func (m selectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 67 + switch msg := msg.(type) { 68 + case tea.KeyMsg: 69 + switch msg.String() { 70 + case "q", "esc", "ctrl+c": 71 + m.cancelled = true 72 + return m, tea.Quit 73 + case "enter": 74 + if len(m.selected) == 0 && len(m.manifests) > 0 { 75 + m.selected[m.cursor] = struct{}{} 76 + } 77 + m.done = true 78 + return m, tea.Quit 79 + case " ": 80 + if _, ok := m.selected[m.cursor]; ok { 81 + delete(m.selected, m.cursor) 82 + } else { 83 + m.selected[m.cursor] = struct{}{} 84 + } 85 + case "j", "down": 86 + if m.cursor < len(m.manifests)-1 { 87 + m.cursor++ 88 + } 89 + case "k", "up": 90 + if m.cursor > 0 { 91 + m.cursor-- 92 + } 93 + case "home", "g": 94 + m.cursor = 0 95 + case "end", "G": 96 + if len(m.manifests) > 0 { 97 + m.cursor = len(m.manifests) - 1 98 + } 99 + } 100 + } 101 + 102 + return m, nil 103 + } 104 + 105 + func (m selectorModel) View() string { 106 + if len(m.manifests) == 0 { 107 + return "No manifests available" 108 + } 109 + 110 + var view strings.Builder 111 + view.WriteString(style.StyleHeadline.Render("Select toolchain manifests to bump")) 112 + view.WriteString("\n\n") 113 + 114 + for i, manifest := range m.manifests { 115 + cursor := " " 116 + if i == m.cursor { 117 + cursor = cursorStyle.Render("›") 118 + } 119 + checkbox := "[ ]" 120 + if _, ok := m.selected[i]; ok { 121 + checkbox = selectedStyle.Render("[x]") 122 + } 123 + line := fmt.Sprintf("%s %s %s", cursor, checkbox, manifest.DisplayLabel()) 124 + view.WriteString(line) 125 + view.WriteString("\n") 126 + } 127 + 128 + view.WriteString("\n") 129 + view.WriteString(mutedStyle.Render("space: toggle • enter: confirm • q: cancel")) 130 + return view.String() 131 + } 132 + 133 + func (m selectorModel) selectedManifests() []Manifest { 134 + if len(m.manifests) == 0 { 135 + return nil 136 + } 137 + var chosen []Manifest 138 + for idx, manifest := range m.manifests { 139 + if _, ok := m.selected[idx]; ok { 140 + chosen = append(chosen, manifest) 141 + } 142 + } 143 + if len(chosen) == 0 && !m.cancelled && len(m.manifests) > 0 { 144 + return []Manifest{m.manifests[m.cursor]} 145 + } 146 + return chosen 147 + }
+35
internal/toolchain/selector_test.go
··· 1 + package toolchain 2 + 3 + import ( 4 + "testing" 5 + 6 + tea "github.com/charmbracelet/bubbletea" 7 + "github.com/stormlightlabs/git-storm/internal/testutils" 8 + ) 9 + 10 + func TestSelectorModel_DefaultSelection(t *testing.T) { 11 + manifests := []Manifest{{RelPath: "Cargo.toml"}, {RelPath: "package.json"}} 12 + model := newSelectorModel(manifests) 13 + final := testutils.RunModelWithInteraction(t, model, []tea.Msg{tea.KeyMsg{Type: tea.KeyEnter}}) 14 + selector := final.(selectorModel) 15 + selected := selector.selectedManifests() 16 + if len(selected) != 1 || selected[0].RelPath != "Cargo.toml" { 17 + t.Fatalf("expected first manifest to be selected, got %#v", selected) 18 + } 19 + } 20 + 21 + func TestSelectorModel_ToggleSelection(t *testing.T) { 22 + manifests := []Manifest{{RelPath: "Cargo.toml"}, {RelPath: "package.json"}} 23 + model := newSelectorModel(manifests) 24 + msgs := []tea.Msg{ 25 + tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}, 26 + tea.KeyMsg{Type: tea.KeySpace}, 27 + tea.KeyMsg{Type: tea.KeyEnter}, 28 + } 29 + final := testutils.RunModelWithInteraction(t, model, msgs) 30 + selector := final.(selectorModel) 31 + selected := selector.selectedManifests() 32 + if len(selected) != 1 || selected[0].RelPath != "package.json" { 33 + t.Fatalf("expected second manifest to be selected, got %#v", selected) 34 + } 35 + }
+3 -14
internal/ui/ui.go
··· 171 171 172 172 // renderHeader creates the header bar showing file paths. 173 173 func (m DiffModel) renderHeader() string { 174 - headerStyle := lipgloss.NewStyle(). 175 - Foreground(style.AccentBlue). 176 - Bold(true). 177 - Padding(0, 1) 174 + headerStyle := lipgloss.NewStyle().Foreground(style.AccentBlue).Bold(true).Padding(0, 1) 178 175 179 176 oldLabel := lipgloss.NewStyle().Foreground(style.RemovedColor).Render("−") 180 177 newLabel := lipgloss.NewStyle().Foreground(style.AddedColor).Render("+") ··· 199 196 totalWidth := m.viewport.Width 200 197 helpWidth := lipgloss.Width(helpText) 201 198 scrollWidth := lipgloss.Width(scrollInfo) 202 - padding := totalWidth - helpWidth - scrollWidth - 2 203 - 204 - if padding < 0 { 205 - padding = 0 206 - } 199 + padding := max(totalWidth-helpWidth-scrollWidth-2, 0) 207 200 208 201 return footerStyle.Render( 209 202 helpText + strings.Repeat(" ", padding) + scrollInfo, ··· 422 415 totalWidth := m.width 423 416 helpWidth := lipgloss.Width(helpText) 424 417 scrollWidth := lipgloss.Width(scrollInfo) 425 - padding := totalWidth - helpWidth - scrollWidth - 2 426 - 427 - if padding < 0 { 428 - padding = 0 429 - } 418 + padding := max(totalWidth-helpWidth-scrollWidth-2, 0) 430 419 431 420 return footerStyle.Render( 432 421 helpText + strings.Repeat(" ", padding) + scrollInfo,
+110
internal/versioning/versioning.go
··· 1 + package versioning 2 + 3 + import ( 4 + "fmt" 5 + "strconv" 6 + "strings" 7 + 8 + "github.com/stormlightlabs/git-storm/internal/changelog" 9 + ) 10 + 11 + // BumpType represents the semantic version component to increment. 12 + type BumpType string 13 + 14 + const ( 15 + BumpMajor BumpType = "major" 16 + BumpMinor BumpType = "minor" 17 + BumpPatch BumpType = "patch" 18 + ) 19 + 20 + // Version represents a semantic version split into numeric components. 21 + type Version struct { 22 + Major int 23 + Minor int 24 + Patch int 25 + } 26 + 27 + // Parse converts a semantic version string (X.Y.Z) into a Version structure. 28 + // An empty string returns 0.0.0 to simplify bump workflows. 29 + func Parse(version string) (Version, error) { 30 + if version == "" { 31 + return Version{}, nil 32 + } 33 + 34 + parts := strings.Split(version, ".") 35 + if len(parts) != 3 { 36 + return Version{}, fmt.Errorf("invalid semantic version: %s", version) 37 + } 38 + 39 + vals := make([]int, 3) 40 + for i, part := range parts { 41 + value, err := strconv.Atoi(part) 42 + if err != nil || value < 0 { 43 + return Version{}, fmt.Errorf("invalid semantic version: %s", version) 44 + } 45 + vals[i] = value 46 + } 47 + 48 + return Version{Major: vals[0], Minor: vals[1], Patch: vals[2]}, nil 49 + } 50 + 51 + // String formats the Version back into X.Y.Z form. 52 + func (v Version) String() string { 53 + return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) 54 + } 55 + 56 + // Bump increments the requested component following semver rules. 57 + func (v Version) Bump(kind BumpType) Version { 58 + switch kind { 59 + case BumpMajor: 60 + return Version{Major: v.Major + 1, Minor: 0, Patch: 0} 61 + case BumpMinor: 62 + return Version{Major: v.Major, Minor: v.Minor + 1, Patch: 0} 63 + case BumpPatch: 64 + return Version{Major: v.Major, Minor: v.Minor, Patch: v.Patch + 1} 65 + default: 66 + return v 67 + } 68 + } 69 + 70 + // Next returns the bumped version string for the provided semantic version. 71 + func Next(current string, kind BumpType) (string, error) { 72 + parsed, err := Parse(current) 73 + if err != nil { 74 + return "", err 75 + } 76 + return parsed.Bump(kind).String(), nil 77 + } 78 + 79 + // ParseBumpType validates user input into a BumpType. 80 + func ParseBumpType(value string) (BumpType, error) { 81 + switch strings.ToLower(value) { 82 + case string(BumpMajor): 83 + return BumpMajor, nil 84 + case string(BumpMinor): 85 + return BumpMinor, nil 86 + case string(BumpPatch): 87 + return BumpPatch, nil 88 + default: 89 + return "", fmt.Errorf("invalid bump type %q (expected major, minor, or patch)", value) 90 + } 91 + } 92 + 93 + // LatestVersion scans a parsed changelog for the most recent released version. 94 + // It skips the "Unreleased" section if present and validates with Keep a Changelog semantics. 95 + func LatestVersion(ch *changelog.Changelog) (string, bool) { 96 + if ch == nil { 97 + return "", false 98 + } 99 + 100 + for _, v := range ch.Versions { 101 + if strings.EqualFold(v.Number, "unreleased") { 102 + continue 103 + } 104 + if err := changelog.ValidateVersion(v.Number); err == nil { 105 + return v.Number, true 106 + } 107 + } 108 + 109 + return "", false 110 + }
+87
internal/versioning/versioning_test.go
··· 1 + package versioning 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/stormlightlabs/git-storm/internal/changelog" 7 + ) 8 + 9 + func TestParseAndBump(t *testing.T) { 10 + version, err := Parse("1.2.3") 11 + if err != nil { 12 + t.Fatalf("Parse returned error: %v", err) 13 + } 14 + 15 + if version.Major != 1 || version.Minor != 2 || version.Patch != 3 { 16 + t.Fatalf("unexpected parse result: %#v", version) 17 + } 18 + 19 + major := version.Bump(BumpMajor) 20 + if got := major.String(); got != "2.0.0" { 21 + t.Fatalf("major bump = %s, want 2.0.0", got) 22 + } 23 + 24 + minor := version.Bump(BumpMinor) 25 + if got := minor.String(); got != "1.3.0" { 26 + t.Fatalf("minor bump = %s, want 1.3.0", got) 27 + } 28 + 29 + patch := version.Bump(BumpPatch) 30 + if got := patch.String(); got != "1.2.4" { 31 + t.Fatalf("patch bump = %s, want 1.2.4", got) 32 + } 33 + } 34 + 35 + func TestParseBumpType(t *testing.T) { 36 + cases := map[string]BumpType{ 37 + "MAJOR": BumpMajor, 38 + "minor": BumpMinor, 39 + "Patch": BumpPatch, 40 + } 41 + 42 + for input, expected := range cases { 43 + kind, err := ParseBumpType(input) 44 + if err != nil { 45 + t.Fatalf("ParseBumpType(%s) returned error: %v", input, err) 46 + } 47 + if kind != expected { 48 + t.Fatalf("ParseBumpType(%s) = %s, want %s", input, kind, expected) 49 + } 50 + } 51 + 52 + if _, err := ParseBumpType("invalid"); err == nil { 53 + t.Fatal("expected error for invalid bump type") 54 + } 55 + } 56 + 57 + func TestNextHandlesEmptyVersion(t *testing.T) { 58 + got, err := Next("", BumpMinor) 59 + if err != nil { 60 + t.Fatalf("Next returned error: %v", err) 61 + } 62 + if got != "0.1.0" { 63 + t.Fatalf("Next = %s, want 0.1.0", got) 64 + } 65 + } 66 + 67 + func TestLatestVersion(t *testing.T) { 68 + ch := &changelog.Changelog{ 69 + Versions: []changelog.Version{ 70 + {Number: "Unreleased"}, 71 + {Number: "1.5.0"}, 72 + {Number: "0.9.1"}, 73 + }, 74 + } 75 + 76 + version, ok := LatestVersion(ch) 77 + if !ok { 78 + t.Fatal("LatestVersion returned false") 79 + } 80 + if version != "1.5.0" { 81 + t.Fatalf("LatestVersion = %s, want 1.5.0", version) 82 + } 83 + 84 + if _, ok := LatestVersion(&changelog.Changelog{}); ok { 85 + t.Fatal("expected LatestVersion to return false when no releases exist") 86 + } 87 + }