+2
.gitignore
+2
.gitignore
+5
-1
PROJECT.md
+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
+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
+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
+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
+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
+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
+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
+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
+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
-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
+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
+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
+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
+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
-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
+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
+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
+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
+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
+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
+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
+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
+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
+
}