+4
.gitignore
+4
.gitignore
+68
.goreleaser.yaml
+68
.goreleaser.yaml
···
1
+
version: 2
2
+
3
+
before:
4
+
hooks:
5
+
- go mod tidy
6
+
- go generate ./...
7
+
- task gen:completions
8
+
- task gen:manpage
9
+
10
+
builds:
11
+
- id: storm
12
+
main: ./cmd
13
+
binary: storm
14
+
env: [CGO_ENABLED=0]
15
+
goos: [linux, darwin, windows]
16
+
goarch: [amd64, arm64, "386"]
17
+
18
+
archives:
19
+
- id: default
20
+
formats: [tar.gz]
21
+
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
22
+
format_overrides:
23
+
- goos: windows
24
+
formats: [zip]
25
+
files:
26
+
# modern syntax: each item can have src/dst pairs
27
+
- src: completions/**
28
+
dst: completions
29
+
- src: manpages/**
30
+
dst: manpages
31
+
32
+
changelog:
33
+
sort: asc
34
+
filters:
35
+
exclude:
36
+
- "^docs:"
37
+
- "^test:"
38
+
39
+
release:
40
+
footer: |
41
+
---
42
+
Made by [Owais](https://github.com/desertthunder/desertthunder).
43
+
44
+
homebrew_casks:
45
+
- name: storm
46
+
repository:
47
+
owner: stormlightlabs
48
+
name: homebrew-tap
49
+
branch: main
50
+
homepage: "https://github.com/stormlightlabs/git-storm"
51
+
description: "A changelog manager with TUIs for review and release"
52
+
license: MIT
53
+
url:
54
+
template: "https://github.com/stormlightlabs/git-storm/releases/download/{{ .Tag }}/{{ .ArtifactName }}"
55
+
verified: "github.com/stormlightlabs/git-storm/"
56
+
binaries:
57
+
- storm
58
+
manpages:
59
+
- manpages/storm.1
60
+
completions:
61
+
bash: completions/storm.bash
62
+
zsh: completions/storm.zsh
63
+
fish: completions/storm.fish
64
+
directory: Casks
65
+
commit_msg_template: "Brew cask update for Storm version {{ .Tag }}"
66
+
skip_upload: false
67
+
ids:
68
+
- default
+7
-3
PROJECT.md
+7
-3
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
···
118
122
119
123
#### Create a tap repo
120
124
121
-
Make a repo: `github.com/stormlightlabs/homebrew-tools`.
125
+
Make a repo: `github.com/stormlightlabs/homebrew-tap`.
122
126
123
127
#### Formula template (`storm.rb`)
124
128
···
149
153
150
154
```yaml
151
155
brews:
152
-
- tap: stormlightlabs/homebrew-tools
156
+
- tap: stormlightlabs/homebrew-tap
153
157
name: storm
154
158
folder: Formula
155
159
commit_author:
+30
-144
README.md
+30
-144
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.
12
+
## Install
19
13
20
-
## Core Packages
14
+
### Homebrew (macOS / Linux)
21
15
22
16
```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
17
+
brew install stormlightlabs/tap/storm
34
18
```
35
19
36
-
## Command Model
20
+
The goreleaser workflow keeps the [`stormlightlabs/homebrew-tap`](https://github.com/stormlightlabs/homebrew-tap)
21
+
formula up to date.
37
22
38
-
### Unreleased Changes
23
+
### Go toolchain
39
24
40
25
```sh
41
-
storm unreleased add --type added --scope cli --summary "Add changelog command"
42
-
storm unreleased list
43
-
storm unreleased review
26
+
go install github.com/stormlightlabs/git-storm/cmd/storm@latest
44
27
```
45
28
46
-
Adds or reviews pending `.changes/*.md` entries.
47
-
48
-
### Generate From Git
49
-
50
-
```sh
51
-
storm generate <from> <to> [--interactive]
52
-
```
53
-
54
-
Pulls commits between refs, categorizes them by prefix, and optionally opens an interactive review.
55
-
56
-
### Release
29
+
## Quick Start
57
30
58
31
```sh
59
-
storm release --version 1.3.0 [--tag]
32
+
storm generate --since v1.2.0 --interactive
33
+
storm unreleased review
34
+
storm release --bump patch --toolchain package.json --tag
60
35
```
61
36
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.
37
+
## Documentation
110
38
111
-
## Conventional Commits
39
+
- [Introduction](docs/introduction.md)
40
+
- [Quickstart](docs/quickstart.md)
41
+
- [Manual](docs/manual.md)
42
+
- [Development Guide](docs/development.md)
112
43
113
-
### Structure
44
+
For a deeper dive into release automation, see `PROJECT.md`.
114
45
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 |
46
+
## Contributing
122
47
123
-
### Types
48
+
Run the full test suite before opening a PR:
124
49
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.
50
+
```sh
51
+
go test ./...
165
52
```
166
53
167
-
### Reference
168
-
169
-
<https://www.conventionalcommits.org/en/v1.0.0/> "Conventional Commits"
54
+
Issues and feature ideas are welcomeโStorm is intentionally modular so new
55
+
commands and TUIs can be added without touching the entire codebase.
+149
ROADMAP.md
+149
ROADMAP.md
···
1
+
# Roadmap
2
+
3
+
## Core CLI
4
+
5
+
The foundation CLI structure with core commands.
6
+
7
+
### Commands
8
+
9
+
- [x] `storm version` - Print version information
10
+
- [x] `storm generate` - Generate changelog entries from Git commits
11
+
- [x] Parse commit range (from/to refs)
12
+
- [x] Support `--since` flag
13
+
- [x] Support `--interactive` flag for TUI selection
14
+
- [x] Parse conventional commits
15
+
- [x] Write entries to `.changes/`
16
+
- [ ] Deduplication logic (see TODO in generate.go)
17
+
- [ ] Add --output-json for machine use
18
+
- [x] `storm unreleased` - Manage unreleased changes
19
+
- [x] `unreleased add` - Create new entry
20
+
- [x] `unreleased list` - Display entries (text and JSON)
21
+
- [x] `unreleased review` - Interactive TUI review
22
+
- [x] `unreleased partial` - Create entry linked to specific commit
23
+
- [x] Filename format: `<sha7>.<type>.md`
24
+
- [x] Auto-detect type from conventional commit format
25
+
- [x] Optional `--type`, `--scope`, `--summary` override flags
26
+
- [ ] Optional `--issue` flag (TODO: see issue-linking task below)
27
+
- [x] Implement delete action from review
28
+
- [x] Implement edit action from review
29
+
- [x] `storm check` - Validate that changes include unreleased partials
30
+
- [x] Detect missing partials for changed code paths
31
+
- [x] Honor `[nochanges]` and `[skip changelog]` markers in commit messages
32
+
- [x] Exit non-zero for CI enforcement
33
+
- [x] Support `--since` flag for checking since a tag
34
+
- [x] `storm release` - Promote unreleased changes to CHANGELOG
35
+
- [x] Read all `.changes/*.md` files
36
+
- [x] Merge into `CHANGELOG.md`
37
+
- [x] Create version header with date
38
+
- [x] Clear `.changes/` directory with `--clear-changes` flag
39
+
- [x] Optional date override with `--date` flag
40
+
- [x] Generate GitHub comparison links automatically
41
+
- [x] Dry-run mode
42
+
- [x] Optional Git tag creation
43
+
- [x] `storm diff`: display inline diffs between refs with support for file filtering,
44
+
context expansion, and multiple view modes.
45
+
46
+
## Git Integration and Commit Parsing
47
+
48
+
- [x] Core gitlog utilities for parsing refs, retrieving commits and file contents, and
49
+
categorizing conventional commits by type and change significance.
50
+
51
+
## Diff Engine and Styling
52
+
53
+
- [x] Diff package implements the Myers diff algorithm with split and unified rendering,
54
+
compressed unchanged sections, and an iceberg-themed color palette for styled visual
55
+
output.
56
+
57
+
## `.changes` Storage and Parsing
58
+
59
+
- [x] Provides a local .changes/ store that writes and lists YAML-frontmatter entries,
60
+
auto-creates the directory, and deduplicates rebased commits by diff hash to keep
61
+
unreleased changelog items clean.
62
+
63
+
## TUI
64
+
65
+
- [x] Delivered Bubble Tea UIs for selecting commits, reviewing unreleased changes, and
66
+
interactively viewing multi-file diffs with full keyboard-driven navigation and view
67
+
toggles.
68
+
69
+
## Keep a Changelog Writer
70
+
71
+
- [x] Adds a full changelog pipeline that parses the existing file, builds and writes
72
+
new releases, and validates dates/sections to strictly match the Keep a Changelog
73
+
[spec](https://keepachangelog.com/en/1.1.0/), including autogenerated comparison links.
74
+
- [ ] Ensure deterministic sorting by category and filename timestamp
75
+
76
+
## Issue Linking
77
+
78
+
Add support for linking changelog entries to issue/PR numbers.
79
+
80
+
### Tasks
81
+
82
+
- [ ] Add `--issue` flag to `unreleased add` and `unreleased partial`
83
+
- [ ] Add `issue` field to Entry struct
84
+
- [ ] Include issue number in YAML frontmatter
85
+
- [ ] Support issue validation in `check` command
86
+
- [ ] Format issue links in generated CHANGELOG (e.g., #123, owner/repo#123)
87
+
88
+
## Phase 7: Git Tagging and CI Integration
89
+
90
+
Repository tagging and automation-friendly features.
91
+
92
+
### Tasks
93
+
94
+
- [x] Implement Git tagging in `release` command
95
+
- [x] Create annotated tag with version
96
+
- [x] Include release notes in tag message
97
+
- [x] Validate tag doesn't already exist
98
+
- [x] Support `--tag` flag
99
+
- [x] Implement CI validation with `check` command
100
+
- [x] Validate changelog entries exist for commits
101
+
- [x] Honor `[nochanges]` markers
102
+
- [x] Exit codes for CI integration
103
+
- [x] Add JSON output modes for all commands
104
+
- [x] `unreleased list --json`
105
+
- [x] `generate --output-json`
106
+
- [x] `release --output-json`
107
+
- [x] Add `--dry-run` support
108
+
- [x] `release --dry-run`
109
+
- [x] Show what would be written without writing
110
+
- [x] Display preview of CHANGELOG changes with styled output
111
+
- [x] Non-TTY environment handling
112
+
- [x] Detect TTY availability
113
+
- [x] Fallback to non-interactive mode
114
+
- [x] CI-friendly error messages
115
+
- [ ] Add pre-commit hook examples
116
+
- [ ] Validate commit message format
117
+
- [ ] Ensure `.changes/` entries exist for features
118
+
- [ ] Create GitHub Actions workflow examples
119
+
- [ ] Auto-release on version tag
120
+
- [ ] Validate CHANGELOG on PR
121
+
122
+
## Testing Strategy
123
+
124
+
### Current Status
125
+
126
+
- [x] Test utilities package - internal/testutils/
127
+
- [x] Unit tests for changelog package - internal/changelog/changelog_test.go
128
+
- [x] Unit tests for diff engine
129
+
- [x] Unit tests for Git integration (in-memory repos)
130
+
- [ ] Golden files for diff output
131
+
- [ ] Golden files for changelog output
132
+
- [ ] Bubble Tea program testing
133
+
134
+
### Planned Test Coverage
135
+
136
+
- [x] `internal/diff` - Myers algorithm correctness
137
+
- [ ] `internal/gitlog` - Commit parsing and range queries
138
+
- [x] `internal/changeset` - File I/O and YAML parsing
139
+
- [x] `internal/changelog` - Keep a Changelog formatting (13 test cases, all passing)
140
+
- [ ] `cmd/generate` - End-to-end commit to entry flow
141
+
- [ ] `cmd/unreleased` - Entry management
142
+
- [ ] `cmd/release` - Changelog generation and tagging
143
+
144
+
## Notes
145
+
146
+
- No shell calls to `git` - all operations via `go-git`
147
+
- Conventional commits are parsed but not enforced
148
+
- TUI sessions degrade gracefully in non-TTY environments
149
+
- All output follows Keep a Changelog v1.1.0 specification
+16
Taskfile.yml
+16
Taskfile.yml
···
93
93
- task: tidy
94
94
- task: build
95
95
- echo "Setup complete."
96
+
97
+
gen:completions:
98
+
desc: Generate shell completion files
99
+
cmds:
100
+
- rm -rf completions
101
+
- mkdir -p completions
102
+
- for sh in bash zsh fish; do
103
+
go run ./cmd completion "$sh" > completions/storm."$sh";
104
+
done
105
+
106
+
gen:manpage:
107
+
desc: Generate man page file
108
+
cmds:
109
+
- rm -rf manpages
110
+
- mkdir -p manpages
111
+
- go run ./cmd man > manpages/storm.1
+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
+
}
+144
cmd/check.go
+144
cmd/check.go
···
1
+
/*
2
+
USAGE
3
+
4
+
storm check [from] [to] [options]
5
+
6
+
FLAGS
7
+
8
+
--since <tag> Check changes since the given tag
9
+
--repo <path> Path to the Git repository (default: .)
10
+
11
+
# DESCRIPTION
12
+
13
+
Validates that all commits in the specified range have corresponding unreleased
14
+
changelog entries. This is useful for CI enforcement to ensure developers
15
+
document their changes.
16
+
17
+
Commits containing [nochanges] or [skip changelog] in the message are skipped.
18
+
19
+
Exit codes:
20
+
21
+
0 - All commits have changelog entries
22
+
1 - One or more commits are missing changelog entries
23
+
2 - Command execution error
24
+
25
+
TODO(issue-linking): Support checking for issue numbers in entries when --issue flag is implemented in `unreleased partial`.
26
+
27
+
- This requires integrating with at Gitea/Forgejo, Github, Gitlab, and Tangled
28
+
*/
29
+
package main
30
+
31
+
import (
32
+
"fmt"
33
+
"strings"
34
+
35
+
"github.com/go-git/go-git/v6"
36
+
"github.com/spf13/cobra"
37
+
"github.com/stormlightlabs/git-storm/internal/changeset"
38
+
"github.com/stormlightlabs/git-storm/internal/gitlog"
39
+
"github.com/stormlightlabs/git-storm/internal/style"
40
+
)
41
+
42
+
// checkCmd validates that all commits in a range have corresponding changelog entries.
43
+
func checkCmd() *cobra.Command {
44
+
var sinceTag string
45
+
46
+
c := &cobra.Command{
47
+
Use: "check [from] [to]",
48
+
Short: "Validate changelog entries exist for all commits",
49
+
Long: `Checks that all commits in the specified range have corresponding
50
+
.changes/*.md entries. Useful for CI enforcement.
51
+
52
+
Commits with [nochanges] or [skip changelog] in their message are skipped.`,
53
+
Args: cobra.MaximumNArgs(2),
54
+
RunE: func(cmd *cobra.Command, args []string) error {
55
+
var from, to string
56
+
57
+
if sinceTag != "" {
58
+
from = sinceTag
59
+
if len(args) > 0 {
60
+
to = args[0]
61
+
} else {
62
+
to = "HEAD"
63
+
}
64
+
} else if len(args) == 0 {
65
+
return fmt.Errorf("must specify either --since flag or [from] [to] arguments")
66
+
} else {
67
+
from, to = gitlog.ParseRefArgs(args)
68
+
}
69
+
70
+
repo, err := git.PlainOpen(repoPath)
71
+
if err != nil {
72
+
return fmt.Errorf("failed to open repository: %w", err)
73
+
}
74
+
75
+
commits, err := gitlog.GetCommitRange(repo, from, to)
76
+
if err != nil {
77
+
return err
78
+
}
79
+
80
+
if len(commits) == 0 {
81
+
style.Headlinef("No commits found between %s and %s", from, to)
82
+
return nil
83
+
}
84
+
85
+
changesDir := ".changes"
86
+
existingMetadata, err := changeset.LoadExistingMetadata(changesDir)
87
+
if err != nil {
88
+
return fmt.Errorf("failed to load existing metadata: %w", err)
89
+
}
90
+
91
+
style.Headlinef("Checking %d commits between %s and %s", len(commits), from, to)
92
+
style.Newline()
93
+
94
+
var missingEntries []string
95
+
skippedCount := 0
96
+
97
+
for _, commit := range commits {
98
+
message := strings.ToLower(commit.Message)
99
+
if strings.Contains(message, "[nochanges]") || strings.Contains(message, "[skip changelog]") {
100
+
skippedCount++
101
+
continue
102
+
}
103
+
104
+
diffHash, err := changeset.ComputeDiffHash(commit)
105
+
if err != nil {
106
+
style.Println("Warning: failed to compute diff hash for commit %s: %v", commit.Hash.String()[:7], err)
107
+
continue
108
+
}
109
+
110
+
if _, exists := existingMetadata[diffHash]; !exists {
111
+
sha7 := commit.Hash.String()[:7]
112
+
subject := strings.Split(commit.Message, "\n")[0]
113
+
missingEntries = append(missingEntries, fmt.Sprintf("%s - %s", sha7, subject))
114
+
}
115
+
}
116
+
117
+
if len(missingEntries) == 0 {
118
+
style.Addedf("โ All commits have changelog entries")
119
+
if skippedCount > 0 {
120
+
style.Println(" Skipped %d commits with [nochanges] marker", skippedCount)
121
+
}
122
+
return nil
123
+
}
124
+
125
+
style.Println("%s", style.StyleRemoved.Render(fmt.Sprintf("โ %d commits missing changelog entries:", len(missingEntries))))
126
+
style.Newline()
127
+
128
+
for _, entry := range missingEntries {
129
+
style.Println(" - %s", entry)
130
+
}
131
+
132
+
style.Newline()
133
+
style.Println("To create entries, run:")
134
+
style.Println(" storm generate %s %s --interactive", from, to)
135
+
style.Println("Or manually create entries with:")
136
+
style.Println(" storm unreleased partial <commit-ref>")
137
+
138
+
return fmt.Errorf("changelog validation failed")
139
+
},
140
+
}
141
+
142
+
c.Flags().StringVar(&sinceTag, "since", "", "Check changes since the given tag")
143
+
return c
144
+
}
+44
cmd/diff.go
+44
cmd/diff.go
···
34
34
"github.com/spf13/cobra"
35
35
"github.com/stormlightlabs/git-storm/internal/diff"
36
36
"github.com/stormlightlabs/git-storm/internal/gitlog"
37
+
"github.com/stormlightlabs/git-storm/internal/tty"
37
38
"github.com/stormlightlabs/git-storm/internal/ui"
38
39
)
39
40
···
124
125
})
125
126
}
126
127
128
+
if !tty.IsInteractive() {
129
+
return outputPlainDiff(allDiffs, expanded, view)
130
+
}
131
+
127
132
model := ui.NewMultiFileDiffModel(allDiffs, expanded, view)
128
133
129
134
p := tea.NewProgram(model, tea.WithAltScreen())
···
144
149
return 0, fmt.Errorf("invalid view %q: expected one of split, unified", viewName)
145
150
}
146
151
}
152
+
153
+
// outputPlainDiff outputs diffs in plain text format for non-interactive environments.
154
+
//
155
+
// TODO: move this to package [diff]
156
+
func outputPlainDiff(allDiffs []ui.FileDiff, expanded bool, view diff.DiffViewKind) error {
157
+
for i, fileDiff := range allDiffs {
158
+
fmt.Printf("=== File %d/%d ===\n", i+1, len(allDiffs))
159
+
fmt.Printf("--- %s\n", fileDiff.OldPath)
160
+
fmt.Printf("+++ %s\n", fileDiff.NewPath)
161
+
fmt.Println()
162
+
163
+
var formatter diff.Formatter
164
+
switch view {
165
+
case diff.ViewUnified:
166
+
formatter = &diff.UnifiedFormatter{
167
+
TerminalWidth: 80,
168
+
ShowLineNumbers: true,
169
+
Expanded: expanded,
170
+
EnableWordWrap: false,
171
+
}
172
+
default:
173
+
formatter = &diff.SideBySideFormatter{
174
+
TerminalWidth: 80,
175
+
ShowLineNumbers: true,
176
+
Expanded: expanded,
177
+
EnableWordWrap: false,
178
+
}
179
+
}
180
+
181
+
output := formatter.Format(fileDiff.Edits)
182
+
fmt.Println(output)
183
+
184
+
if i < len(allDiffs)-1 {
185
+
fmt.Println()
186
+
}
187
+
}
188
+
189
+
return nil
190
+
}
+106
-15
cmd/generate.go
+106
-15
cmd/generate.go
···
8
8
-i, --interactive Review generated entries in a TUI
9
9
--since <tag> Generate changes since the given tag
10
10
-o, --output <path> Write generated changelog to path
11
+
--output-json Output results as JSON
11
12
--repo <path> Path to the Git repository (default: .)
12
13
*/
13
14
package main
14
15
15
16
import (
17
+
"encoding/json"
16
18
"fmt"
17
19
"strings"
18
20
···
22
24
"github.com/stormlightlabs/git-storm/internal/changeset"
23
25
"github.com/stormlightlabs/git-storm/internal/gitlog"
24
26
"github.com/stormlightlabs/git-storm/internal/style"
27
+
"github.com/stormlightlabs/git-storm/internal/tty"
25
28
"github.com/stormlightlabs/git-storm/internal/ui"
26
29
)
27
30
28
31
var (
29
32
interactive bool
30
33
sinceTag string
34
+
outputJSON bool
31
35
)
32
36
37
+
// GenerateOutput represents the JSON output structure for the generate command.
38
+
type GenerateOutput struct {
39
+
From string `json:"from"`
40
+
To string `json:"to"`
41
+
TotalCommits int `json:"total_commits"`
42
+
Statistics GenerateStatistics `json:"statistics"`
43
+
Entries []changeset.EntryWithFile `json:"entries,omitempty"`
44
+
}
45
+
46
+
// GenerateStatistics holds counts of generated, skipped, duplicate, and rebased entries.
47
+
type GenerateStatistics struct {
48
+
Created int `json:"created"`
49
+
Skipped int `json:"skipped"`
50
+
Duplicates int `json:"duplicates"`
51
+
Rebased int `json:"rebased"`
52
+
}
53
+
33
54
// TODO(determinism): Add deduplication logic using diff-based identity
34
55
//
35
56
// Currently generates duplicate .changes/*.md files when:
···
74
95
interactive review mode.`,
75
96
Args: cobra.MaximumNArgs(2),
76
97
RunE: func(cmd *cobra.Command, args []string) error {
98
+
if interactive && !tty.IsInteractive() {
99
+
return tty.ErrorInteractiveFlag("--interactive")
100
+
}
101
+
102
+
if interactive && outputJSON {
103
+
return fmt.Errorf("--interactive and --output-json cannot be used together")
104
+
}
105
+
77
106
var from, to string
78
107
79
108
if sinceTag != "" {
···
168
197
}
169
198
}
170
199
171
-
entries := []changeset.Entry{}
200
+
changesDir := ".changes"
201
+
existingMetadata, err := changeset.LoadExistingMetadata(changesDir)
202
+
if err != nil {
203
+
return fmt.Errorf("failed to load existing metadata: %w", err)
204
+
}
205
+
206
+
created := 0
172
207
skipped := 0
208
+
duplicates := 0
209
+
rebased := 0
173
210
174
211
for _, item := range selectedItems {
175
212
if item.Category == "" {
···
177
214
continue
178
215
}
179
216
180
-
entry := changeset.Entry{
181
-
Type: item.Category,
182
-
Scope: item.Meta.Scope,
183
-
Summary: item.Meta.Description,
184
-
Breaking: item.Meta.Breaking,
217
+
diffHash, err := changeset.ComputeDiffHash(item.Commit)
218
+
if err != nil {
219
+
style.Println("Warning: failed to compute diff hash for commit %s: %v", item.Commit.Hash.String()[:7], err)
220
+
skipped++
221
+
continue
222
+
}
223
+
224
+
if existing, exists := existingMetadata[diffHash]; exists {
225
+
if existing.CommitHash == item.Commit.Hash.String() {
226
+
duplicates++
227
+
continue
228
+
} else {
229
+
if err := changeset.UpdateMetadata(changesDir, diffHash, item.Commit.Hash.String()); err != nil {
230
+
style.Println("Warning: failed to update metadata for rebased commit: %v", err)
231
+
continue
232
+
}
233
+
style.Println(" Updated rebased commit %s (was %s)", item.Commit.Hash.String()[:7], existing.CommitHash[:7])
234
+
rebased++
235
+
continue
236
+
}
185
237
}
186
238
187
-
entries = append(entries, entry)
188
-
}
239
+
meta := changeset.Metadata{
240
+
CommitHash: item.Commit.Hash.String(),
241
+
DiffHash: diffHash,
242
+
Type: item.Category,
243
+
Scope: item.Meta.Scope,
244
+
Summary: item.Meta.Description,
245
+
Breaking: item.Meta.Breaking,
246
+
Author: item.Commit.Author.Name,
247
+
Date: item.Commit.Author.When,
248
+
}
189
249
190
-
changesDir := ".changes"
191
-
created := 0
192
-
for _, entry := range entries {
193
-
filePath, err := changeset.Write(changesDir, entry)
250
+
filePath, err := changeset.WriteWithMetadata(changesDir, meta)
194
251
if err != nil {
195
252
fmt.Printf("Error: failed to write entry: %v\n", err)
253
+
skipped++
196
254
continue
197
255
}
198
256
style.Addedf("โ Created %s", filePath)
199
257
created++
200
258
}
201
259
260
+
if outputJSON {
261
+
entries, err := changeset.List(changesDir)
262
+
if err != nil {
263
+
return fmt.Errorf("failed to list generated entries: %w", err)
264
+
}
265
+
266
+
output := GenerateOutput{
267
+
From: from,
268
+
To: to,
269
+
TotalCommits: len(commits),
270
+
Statistics: GenerateStatistics{
271
+
Created: created,
272
+
Skipped: skipped,
273
+
Duplicates: duplicates,
274
+
Rebased: rebased,
275
+
},
276
+
Entries: entries,
277
+
}
278
+
279
+
jsonBytes, err := json.MarshalIndent(output, "", " ")
280
+
if err != nil {
281
+
return fmt.Errorf("failed to marshal output to JSON: %w", err)
282
+
}
283
+
fmt.Println(string(jsonBytes))
284
+
return nil
285
+
}
286
+
202
287
style.Newline()
203
-
style.Headlinef("Generated %d changelog entries", created)
288
+
style.Headlinef("Generated %d new changelog entries", created)
289
+
if duplicates > 0 {
290
+
style.Println(" Skipped %d duplicates", duplicates)
291
+
}
292
+
if rebased > 0 {
293
+
style.Println(" Updated %d rebased commits", rebased)
294
+
}
204
295
if skipped > 0 {
205
-
style.Println("Skipped %d commits (reverts or non-matching types)", skipped)
296
+
style.Println(" Skipped %d commits (reverts or non-matching types)", skipped)
206
297
}
207
298
208
299
return nil
···
211
302
212
303
c.Flags().BoolVarP(&interactive, "interactive", "i", false, "Review changes interactively in a TUI")
213
304
c.Flags().StringVar(&sinceTag, "since", "", "Generate changes since the given tag")
214
-
305
+
c.Flags().BoolVar(&outputJSON, "output-json", false, "Output results as JSON")
215
306
return c
216
307
}
+82
cmd/generate_test.go
+82
cmd/generate_test.go
···
1
1
package main
2
2
3
3
import (
4
+
"os"
5
+
"strings"
4
6
"testing"
5
7
6
8
"github.com/stormlightlabs/git-storm/internal/gitlog"
···
59
61
t.Errorf("Expected error for invalid ref, got nil")
60
62
}
61
63
}
64
+
65
+
func TestGenerateCmd_JSONOutput(t *testing.T) {
66
+
repo := testutils.SetupTestRepo(t)
67
+
worktree, err := repo.Worktree()
68
+
if err != nil {
69
+
t.Fatalf("Failed to get worktree: %v", err)
70
+
}
71
+
72
+
commits := testutils.GetCommitHistory(t, repo)
73
+
if len(commits) < 2 {
74
+
t.Fatalf("Expected at least 2 commits, got %d", len(commits))
75
+
}
76
+
77
+
oldCommit := commits[len(commits)-2]
78
+
if err := testutils.CreateTagAtCommit(t, repo, "v1.0.0", oldCommit.Hash.String()); err != nil {
79
+
t.Fatalf("Failed to create tag: %v", err)
80
+
}
81
+
82
+
testutils.AddCommit(t, repo, "feat.txt", "content", "feat: add new feature")
83
+
testutils.AddCommit(t, repo, "fix.txt", "content", "fix: fix bug")
84
+
85
+
repoPath = worktree.Filesystem.Root()
86
+
outputJSON = true
87
+
88
+
oldWd, err := os.Getwd()
89
+
if err != nil {
90
+
t.Fatalf("Failed to get current directory: %v", err)
91
+
}
92
+
defer func() {
93
+
os.Chdir(oldWd)
94
+
outputJSON = false
95
+
}()
96
+
97
+
if err := os.Chdir(repoPath); err != nil {
98
+
t.Fatalf("Failed to change to temp directory: %v", err)
99
+
}
100
+
101
+
cmd := generateCmd()
102
+
cmd.SetArgs([]string{"v1.0.0", "HEAD"})
103
+
104
+
err = cmd.Execute()
105
+
if err != nil {
106
+
t.Fatalf("generateCmd() error = %v", err)
107
+
}
108
+
}
109
+
110
+
func TestGenerateCmd_InteractiveAndJSONConflict(t *testing.T) {
111
+
repo := testutils.SetupTestRepo(t)
112
+
worktree, err := repo.Worktree()
113
+
if err != nil {
114
+
t.Fatalf("Failed to get worktree: %v", err)
115
+
}
116
+
117
+
repoPath = worktree.Filesystem.Root()
118
+
119
+
cmd := generateCmd()
120
+
cmd.SetArgs([]string{"--interactive", "--output-json", "HEAD~1", "HEAD"})
121
+
122
+
err = cmd.Execute()
123
+
if err == nil {
124
+
t.Error("Expected error when using --interactive and --output-json together, got nil")
125
+
}
126
+
127
+
if err != nil {
128
+
validErrors := []string{
129
+
"--interactive and --output-json cannot be used together",
130
+
"requires an interactive terminal",
131
+
}
132
+
foundValidError := false
133
+
for _, validErr := range validErrors {
134
+
if strings.Contains(err.Error(), validErr) {
135
+
foundValidError = true
136
+
break
137
+
}
138
+
}
139
+
if !foundValidError {
140
+
t.Errorf("Expected error about flags conflict or TTY requirement, got: %v", err)
141
+
}
142
+
}
143
+
}
+2
-35
cmd/main.go
+2
-35
cmd/main.go
···
15
15
output string
16
16
)
17
17
18
-
var (
19
-
changeType string
20
-
scope string
21
-
summary string
22
-
outputJSON bool
23
-
)
24
-
25
-
var (
26
-
releaseVersion string
27
-
tagRelease bool
28
-
dryRun bool
29
-
)
30
-
18
+
// TODO: use ldflags
31
19
const versionString string = "0.1.0-dev"
32
20
33
21
func versionCmd() *cobra.Command {
···
41
29
}
42
30
}
43
31
44
-
func releaseCmd() *cobra.Command {
45
-
c := &cobra.Command{
46
-
Use: "release",
47
-
Short: "Promote unreleased changes into a new changelog version",
48
-
Long: `Merges all .changes entries into CHANGELOG.md under a new version header.
49
-
Optionally creates a Git tag and clears the .changes directory.`,
50
-
RunE: func(cmd *cobra.Command, args []string) error {
51
-
fmt.Println("release command not implemented")
52
-
fmt.Printf("version=%v tag=%v dry-run=%v\n", releaseVersion, tagRelease, dryRun)
53
-
return nil
54
-
},
55
-
}
56
-
57
-
c.Flags().StringVar(&releaseVersion, "version", "", "Semantic version for the new release (e.g., 1.3.0)")
58
-
c.Flags().BoolVar(&tagRelease, "tag", false, "Create a Git tag after release")
59
-
c.Flags().BoolVar(&dryRun, "dry-run", false, "Preview changes without writing files")
60
-
c.MarkFlagRequired("version")
61
-
62
-
return c
63
-
}
64
-
65
32
func main() {
66
33
ctx := context.Background()
67
34
root := &cobra.Command{
···
74
41
75
42
root.PersistentFlags().StringVar(&repoPath, "repo", ".", "Path to the Git repository")
76
43
root.PersistentFlags().StringVarP(&output, "output", "o", "CHANGELOG.md", "Output changelog file path")
77
-
root.AddCommand(generateCmd(), unreleasedCmd(), releaseCmd(), diffCmd(), versionCmd())
44
+
root.AddCommand(generateCmd(), unreleasedCmd(), releaseCmd(), bumpCmd(), diffCmd(), checkCmd(), versionCmd())
78
45
79
46
if err := fang.Execute(ctx, root, fang.WithColorSchemeFunc(style.NewColorScheme)); err != nil {
80
47
log.Fatalf("Execution failed: %v", err)
+356
cmd/release.go
+356
cmd/release.go
···
1
+
/*
2
+
USAGE
3
+
4
+
storm release --version <X.Y.Z> [options]
5
+
6
+
FLAGS
7
+
8
+
--version <X.Y.Z> Semantic version for the new release (required)
9
+
--bump <type> Automatically bump the previous version (major|minor|patch)
10
+
--date <YYYY-MM-DD> Release date (default: today)
11
+
--clear-changes Delete .changes/*.md files after successful release
12
+
--dry-run Preview changes without writing files
13
+
--tag Create an annotated Git tag with release notes
14
+
--toolchain <value> Update toolchain manifests (path/type or 'interactive')
15
+
--output-json Output results as JSON
16
+
--repo <path> Path to the Git repository (default: .)
17
+
--output <path> Output changelog file path (default: CHANGELOG.md)
18
+
*/
19
+
package main
20
+
21
+
import (
22
+
"encoding/json"
23
+
"fmt"
24
+
"os"
25
+
"path/filepath"
26
+
"strings"
27
+
"time"
28
+
29
+
"github.com/go-git/go-git/v6"
30
+
"github.com/go-git/go-git/v6/plumbing/object"
31
+
"github.com/spf13/cobra"
32
+
"github.com/stormlightlabs/git-storm/internal/changelog"
33
+
"github.com/stormlightlabs/git-storm/internal/changeset"
34
+
"github.com/stormlightlabs/git-storm/internal/shared"
35
+
"github.com/stormlightlabs/git-storm/internal/style"
36
+
"github.com/stormlightlabs/git-storm/internal/versioning"
37
+
)
38
+
39
+
// ReleaseOutput represents the JSON output structure for the release command.
40
+
type ReleaseOutput struct {
41
+
Version string `json:"version"`
42
+
Date string `json:"date"`
43
+
EntriesCount int `json:"entries_count"`
44
+
ChangelogPath string `json:"changelog_path"`
45
+
TagCreated bool `json:"tag_created"`
46
+
TagName string `json:"tag_name,omitempty"`
47
+
ChangesCleared bool `json:"changes_cleared"`
48
+
DeletedCount int `json:"deleted_count,omitempty"`
49
+
ToolchainsUpdated []string `json:"toolchains_updated,omitempty"`
50
+
DryRun bool `json:"dry_run"`
51
+
VersionData *changelog.Version `json:"version_data"`
52
+
}
53
+
54
+
func releaseCmd() *cobra.Command {
55
+
var (
56
+
version string
57
+
bumpKind string
58
+
date string
59
+
clearChanges bool
60
+
dryRun bool
61
+
tag bool
62
+
toolchains []string
63
+
outputJSON bool
64
+
)
65
+
66
+
c := &cobra.Command{
67
+
Use: "release",
68
+
Short: "Promote unreleased changes into a new changelog version",
69
+
Long: `Merges all .changes entries into CHANGELOG.md under a new version header.
70
+
Optionally creates a Git tag and clears the .changes directory.`,
71
+
RunE: func(cmd *cobra.Command, args []string) error {
72
+
changelogPath := filepath.Join(repoPath, output)
73
+
existingChangelog, err := changelog.Parse(changelogPath)
74
+
if err != nil {
75
+
return fmt.Errorf("failed to parse changelog: %w", err)
76
+
}
77
+
78
+
resolvedVersion, err := resolveReleaseVersion(version, bumpKind, existingChangelog)
79
+
if err != nil {
80
+
return err
81
+
}
82
+
version = resolvedVersion
83
+
84
+
releaseDate := date
85
+
if releaseDate == "" {
86
+
releaseDate = time.Now().Format("2006-01-02")
87
+
} else {
88
+
if err := changelog.ValidateDate(releaseDate); err != nil {
89
+
return err
90
+
}
91
+
}
92
+
93
+
if !outputJSON {
94
+
style.Headlinef("Preparing release %s (%s)", version, releaseDate)
95
+
style.Newline()
96
+
}
97
+
98
+
changesDir := ".changes"
99
+
entries, err := changeset.List(changesDir)
100
+
if err != nil {
101
+
return fmt.Errorf("failed to read .changes directory: %w", err)
102
+
}
103
+
104
+
if len(entries) == 0 {
105
+
return fmt.Errorf("no unreleased changes found in %s", changesDir)
106
+
}
107
+
108
+
if !outputJSON {
109
+
style.Println("Found %d unreleased entries", len(entries))
110
+
style.Newline()
111
+
}
112
+
113
+
var entryList []changeset.Entry
114
+
for _, e := range entries {
115
+
entryList = append(entryList, e.Entry)
116
+
}
117
+
118
+
newVersion, err := changelog.Build(entryList, version, releaseDate)
119
+
if err != nil {
120
+
return fmt.Errorf("failed to build version: %w", err)
121
+
}
122
+
123
+
changelog.Merge(existingChangelog, newVersion)
124
+
125
+
releaseOutput := ReleaseOutput{
126
+
Version: version,
127
+
Date: releaseDate,
128
+
EntriesCount: len(entries),
129
+
ChangelogPath: changelogPath,
130
+
DryRun: dryRun,
131
+
VersionData: newVersion,
132
+
}
133
+
134
+
if dryRun {
135
+
if outputJSON {
136
+
jsonBytes, err := json.MarshalIndent(releaseOutput, "", " ")
137
+
if err != nil {
138
+
return fmt.Errorf("failed to marshal output to JSON: %w", err)
139
+
}
140
+
fmt.Println(string(jsonBytes))
141
+
return nil
142
+
}
143
+
144
+
style.Headline("Dry-run mode: Preview of CHANGELOG.md")
145
+
style.Newline()
146
+
displayVersionPreview(newVersion)
147
+
style.Newline()
148
+
style.Println("No files were modified (--dry-run)")
149
+
if len(toolchains) > 0 {
150
+
style.Warningf("Skipping toolchain updates (--dry-run)")
151
+
}
152
+
return nil
153
+
}
154
+
155
+
if err := changelog.Write(changelogPath, existingChangelog, repoPath); err != nil {
156
+
return fmt.Errorf("failed to write CHANGELOG.md: %w", err)
157
+
}
158
+
159
+
if !outputJSON {
160
+
style.Addedf("โ Updated %s", changelogPath)
161
+
}
162
+
163
+
if clearChanges {
164
+
deletedCount := 0
165
+
for _, entry := range entries {
166
+
filePath := filepath.Join(changesDir, entry.Filename)
167
+
if err := os.Remove(filePath); err != nil {
168
+
if !outputJSON {
169
+
style.Println("Warning: failed to delete %s: %v", filePath, err)
170
+
}
171
+
continue
172
+
}
173
+
deletedCount++
174
+
}
175
+
releaseOutput.ChangesCleared = true
176
+
releaseOutput.DeletedCount = deletedCount
177
+
if !outputJSON {
178
+
style.Println("โ Deleted %d entry files from %s", deletedCount, changesDir)
179
+
}
180
+
}
181
+
182
+
if len(toolchains) > 0 {
183
+
updated, err := updateToolchainTargets(repoPath, version, toolchains)
184
+
if err != nil {
185
+
return err
186
+
}
187
+
var updatedPaths []string
188
+
for _, manifest := range updated {
189
+
updatedPaths = append(updatedPaths, manifest.RelPath)
190
+
if !outputJSON {
191
+
style.Addedf("โ Updated %s", manifest.RelPath)
192
+
}
193
+
}
194
+
releaseOutput.ToolchainsUpdated = updatedPaths
195
+
}
196
+
197
+
if tag {
198
+
if err := createReleaseTag(repoPath, version, newVersion); err != nil {
199
+
return fmt.Errorf("failed to create Git tag: %w", err)
200
+
}
201
+
tagName := fmt.Sprintf("v%s", version)
202
+
releaseOutput.TagCreated = true
203
+
releaseOutput.TagName = tagName
204
+
if !outputJSON {
205
+
style.Newline()
206
+
style.Addedf("โ Created Git tag %s", tagName)
207
+
}
208
+
}
209
+
210
+
if outputJSON {
211
+
jsonBytes, err := json.MarshalIndent(releaseOutput, "", " ")
212
+
if err != nil {
213
+
return fmt.Errorf("failed to marshal output to JSON: %w", err)
214
+
}
215
+
fmt.Println(string(jsonBytes))
216
+
return nil
217
+
}
218
+
219
+
style.Newline()
220
+
style.Headlinef("Release %s completed successfully", version)
221
+
222
+
return nil
223
+
},
224
+
}
225
+
226
+
c.Flags().StringVar(&version, "version", "", "Semantic version for the new release (e.g., 1.3.0)")
227
+
c.Flags().StringVar(&bumpKind, "bump", "", "Automatically bump the previous version (major, minor, or patch)")
228
+
c.Flags().StringVar(&date, "date", "", "Release date in YYYY-MM-DD format (default: today)")
229
+
c.Flags().BoolVar(&clearChanges, "clear-changes", false, "Delete .changes/*.md files after successful release")
230
+
c.Flags().BoolVar(&dryRun, "dry-run", false, "Preview changes without writing files")
231
+
c.Flags().BoolVar(&tag, "tag", false, "Create an annotated Git tag with release notes")
232
+
c.Flags().StringSliceVar(&toolchains, "toolchain", nil, "Toolchain manifests to update (paths, types, or 'interactive')")
233
+
c.Flags().BoolVar(&outputJSON, "output-json", false, "Output results as JSON")
234
+
235
+
return c
236
+
}
237
+
238
+
func resolveReleaseVersion(versionFlag, bumpFlag string, existing *changelog.Changelog) (string, error) {
239
+
if bumpFlag == "" {
240
+
if versionFlag == "" {
241
+
return "", fmt.Errorf("either --version or --bump must be provided")
242
+
}
243
+
if err := changelog.ValidateVersion(versionFlag); err != nil {
244
+
return "", err
245
+
}
246
+
return versionFlag, nil
247
+
}
248
+
249
+
if versionFlag != "" {
250
+
return "", fmt.Errorf("--version and --bump cannot be used together")
251
+
}
252
+
253
+
kind, err := versioning.ParseBumpType(bumpFlag)
254
+
if err != nil {
255
+
return "", err
256
+
}
257
+
258
+
var current string
259
+
if v, ok := versioning.LatestVersion(existing); ok {
260
+
current = v
261
+
}
262
+
263
+
return versioning.Next(current, kind)
264
+
}
265
+
266
+
// createReleaseTag creates an annotated Git tag for the release with changelog entries as the message.
267
+
func createReleaseTag(repoPath, version string, versionData *changelog.Version) error {
268
+
repo, err := git.PlainOpen(repoPath)
269
+
if err != nil {
270
+
return fmt.Errorf("failed to open repository: %w", err)
271
+
}
272
+
273
+
head, err := repo.Head()
274
+
if err != nil {
275
+
return fmt.Errorf("failed to get HEAD: %w", err)
276
+
}
277
+
278
+
tagName := fmt.Sprintf("v%s", version)
279
+
280
+
_, err = repo.Tag(tagName)
281
+
if err == nil {
282
+
return fmt.Errorf("tag %s already exists", tagName)
283
+
}
284
+
285
+
tagMessage := buildTagMessage(version, versionData)
286
+
287
+
_, err = repo.CreateTag(tagName, head.Hash(), &git.CreateTagOptions{
288
+
Message: tagMessage,
289
+
Tagger: &object.Signature{
290
+
Name: "storm",
291
+
Email: "noreply@storm",
292
+
When: time.Now(),
293
+
},
294
+
})
295
+
if err != nil {
296
+
return fmt.Errorf("failed to create tag: %w", err)
297
+
}
298
+
return nil
299
+
}
300
+
301
+
// buildTagMessage formats the version's changelog entries into a tag message.
302
+
func buildTagMessage(version string, versionData *changelog.Version) string {
303
+
var builder strings.Builder
304
+
305
+
builder.WriteString(fmt.Sprintf("Release %s\n\n", version))
306
+
307
+
for i, section := range versionData.Sections {
308
+
if i > 0 {
309
+
builder.WriteString("\n")
310
+
}
311
+
312
+
sectionTitle := shared.TitleCase(section.Type)
313
+
builder.WriteString(fmt.Sprintf("%s:\n", sectionTitle))
314
+
315
+
for _, entry := range section.Entries {
316
+
builder.WriteString(fmt.Sprintf("- %s\n", entry))
317
+
}
318
+
}
319
+
320
+
return builder.String()
321
+
}
322
+
323
+
// displayVersionPreview shows a formatted preview of the version being released.
324
+
func displayVersionPreview(version *changelog.Version) {
325
+
fmt.Printf("## [%s] - %s\n\n", version.Number, version.Date)
326
+
327
+
for i, section := range version.Sections {
328
+
if i > 0 {
329
+
fmt.Println()
330
+
}
331
+
332
+
var sectionTitle string
333
+
switch section.Type {
334
+
case "added":
335
+
sectionTitle = style.StyleAdded.Render("### Added")
336
+
case "changed":
337
+
sectionTitle = style.StyleChanged.Render("### Changed")
338
+
case "deprecated":
339
+
sectionTitle = "### Deprecated"
340
+
case "removed":
341
+
sectionTitle = style.StyleRemoved.Render("### Removed")
342
+
case "fixed":
343
+
sectionTitle = style.StyleFixed.Render("### Fixed")
344
+
case "security":
345
+
sectionTitle = style.StyleSecurity.Render("### Security")
346
+
default:
347
+
sectionTitle = fmt.Sprintf("### %s", section.Type)
348
+
}
349
+
fmt.Println(sectionTitle)
350
+
fmt.Println()
351
+
352
+
for _, entry := range section.Entries {
353
+
fmt.Printf("- %s\n", entry)
354
+
}
355
+
}
356
+
}
+302
cmd/release_test.go
+302
cmd/release_test.go
···
1
+
package main
2
+
3
+
import (
4
+
"encoding/json"
5
+
"strings"
6
+
"testing"
7
+
8
+
"github.com/stormlightlabs/git-storm/internal/changelog"
9
+
"github.com/stormlightlabs/git-storm/internal/testutils"
10
+
)
11
+
12
+
func TestCreateReleaseTag(t *testing.T) {
13
+
repo := testutils.SetupTestRepo(t)
14
+
worktree, err := repo.Worktree()
15
+
if err != nil {
16
+
t.Fatalf("Failed to get worktree: %v", err)
17
+
}
18
+
19
+
repoPath := worktree.Filesystem.Root()
20
+
version := &changelog.Version{
21
+
Number: "1.0.0",
22
+
Date: "2024-01-15",
23
+
Sections: []changelog.Section{
24
+
{
25
+
Type: "added",
26
+
Entries: []string{
27
+
"New authentication system",
28
+
"User profile management",
29
+
},
30
+
},
31
+
{
32
+
Type: "fixed",
33
+
Entries: []string{
34
+
"Memory leak in database connection pool",
35
+
},
36
+
},
37
+
},
38
+
}
39
+
40
+
err = createReleaseTag(repoPath, "1.0.0", version)
41
+
if err != nil {
42
+
t.Fatalf("createReleaseTag() error = %v", err)
43
+
}
44
+
45
+
tagRef, err := repo.Tag("v1.0.0")
46
+
if err != nil {
47
+
t.Fatalf("Tag v1.0.0 should exist, got error: %v", err)
48
+
}
49
+
50
+
tagObj, err := repo.TagObject(tagRef.Hash())
51
+
if err != nil {
52
+
t.Fatalf("Tag should be annotated, got error: %v", err)
53
+
}
54
+
55
+
head, err := repo.Head()
56
+
if err != nil {
57
+
t.Fatalf("Failed to get HEAD: %v", err)
58
+
}
59
+
60
+
testutils.Expect.Equal(t, tagObj.Target, head.Hash(), "Tag should point to HEAD")
61
+
62
+
message := tagObj.Message
63
+
testutils.Expect.True(t, strings.Contains(message, "Release 1.0.0"), "Tag message should contain version")
64
+
testutils.Expect.True(t, strings.Contains(message, "Added:"), "Tag message should contain Added section")
65
+
testutils.Expect.True(t, strings.Contains(message, "Fixed:"), "Tag message should contain Fixed section")
66
+
testutils.Expect.True(t, strings.Contains(message, "New authentication system"), "Tag message should contain entry")
67
+
testutils.Expect.True(t, strings.Contains(message, "Memory leak"), "Tag message should contain entry")
68
+
}
69
+
70
+
func TestCreateReleaseTag_DuplicateTag(t *testing.T) {
71
+
repo := testutils.SetupTestRepo(t)
72
+
worktree, err := repo.Worktree()
73
+
if err != nil {
74
+
t.Fatalf("Failed to get worktree: %v", err)
75
+
}
76
+
77
+
repoPath := worktree.Filesystem.Root()
78
+
version := &changelog.Version{
79
+
Number: "1.0.0",
80
+
Date: "2024-01-15",
81
+
Sections: []changelog.Section{
82
+
{
83
+
Type: "added",
84
+
Entries: []string{"Feature 1"},
85
+
},
86
+
},
87
+
}
88
+
89
+
err = createReleaseTag(repoPath, "1.0.0", version)
90
+
if err != nil {
91
+
t.Fatalf("First createReleaseTag() error = %v", err)
92
+
}
93
+
94
+
err = createReleaseTag(repoPath, "1.0.0", version)
95
+
if err == nil {
96
+
t.Error("Expected error when creating duplicate tag, got nil")
97
+
}
98
+
99
+
testutils.Expect.True(t, strings.Contains(err.Error(), "already exists"), "Error should indicate tag already exists")
100
+
}
101
+
102
+
func TestCreateReleaseTag_TagNameFormat(t *testing.T) {
103
+
tests := []struct {
104
+
version string
105
+
expectedTag string
106
+
}{
107
+
{"1.0.0", "v1.0.0"},
108
+
{"2.5.3", "v2.5.3"},
109
+
{"0.1.0", "v0.1.0"},
110
+
}
111
+
112
+
for _, tt := range tests {
113
+
t.Run(tt.version, func(t *testing.T) {
114
+
repo := testutils.SetupTestRepo(t)
115
+
worktree, err := repo.Worktree()
116
+
if err != nil {
117
+
t.Fatalf("Failed to get worktree: %v", err)
118
+
}
119
+
120
+
repoPath := worktree.Filesystem.Root()
121
+
version := &changelog.Version{
122
+
Number: tt.version,
123
+
Date: "2024-01-15",
124
+
Sections: []changelog.Section{
125
+
{
126
+
Type: "added",
127
+
Entries: []string{"Feature"},
128
+
},
129
+
},
130
+
}
131
+
132
+
err = createReleaseTag(repoPath, tt.version, version)
133
+
if err != nil {
134
+
t.Fatalf("createReleaseTag() error = %v", err)
135
+
}
136
+
137
+
_, err = repo.Tag(tt.expectedTag)
138
+
if err != nil {
139
+
t.Errorf("Tag %s should exist, got error: %v", tt.expectedTag, err)
140
+
}
141
+
})
142
+
}
143
+
}
144
+
145
+
func TestBuildTagMessage(t *testing.T) {
146
+
version := &changelog.Version{
147
+
Number: "1.2.3",
148
+
Date: "2024-01-15",
149
+
Sections: []changelog.Section{
150
+
{
151
+
Type: "added",
152
+
Entries: []string{
153
+
"Feature A",
154
+
"Feature B",
155
+
},
156
+
},
157
+
{
158
+
Type: "changed",
159
+
Entries: []string{"Updated API"},
160
+
},
161
+
{
162
+
Type: "fixed",
163
+
Entries: []string{
164
+
"Bug 1",
165
+
"Bug 2",
166
+
},
167
+
},
168
+
},
169
+
}
170
+
message := buildTagMessage("1.2.3", version)
171
+
172
+
testutils.Expect.True(t, strings.HasPrefix(message, "Release 1.2.3\n\n"), "Message should start with release header")
173
+
174
+
testutils.Expect.True(t, strings.Contains(message, "Added:\n"), "Should contain Added section")
175
+
testutils.Expect.True(t, strings.Contains(message, "Changed:\n"), "Should contain Changed section")
176
+
testutils.Expect.True(t, strings.Contains(message, "Fixed:\n"), "Should contain Fixed section")
177
+
178
+
testutils.Expect.True(t, strings.Contains(message, "- Feature A\n"), "Should contain entry")
179
+
testutils.Expect.True(t, strings.Contains(message, "- Feature B\n"), "Should contain entry")
180
+
testutils.Expect.True(t, strings.Contains(message, "- Updated API\n"), "Should contain entry")
181
+
testutils.Expect.True(t, strings.Contains(message, "- Bug 1\n"), "Should contain entry")
182
+
testutils.Expect.True(t, strings.Contains(message, "- Bug 2\n"), "Should contain entry")
183
+
184
+
sections := strings.Split(message, "\n\n")
185
+
testutils.Expect.True(t, len(sections) >= 3, "Sections should be separated by blank lines")
186
+
}
187
+
188
+
func TestBuildTagMessage_EmptyVersion(t *testing.T) {
189
+
version := &changelog.Version{
190
+
Number: "1.0.0",
191
+
Date: "2024-01-15",
192
+
Sections: []changelog.Section{},
193
+
}
194
+
message := buildTagMessage("1.0.0", version)
195
+
196
+
testutils.Expect.True(t, strings.HasPrefix(message, "Release 1.0.0\n\n"), "Should still have release header even with no sections")
197
+
}
198
+
199
+
func TestResolveReleaseVersion(t *testing.T) {
200
+
existing := &changelog.Changelog{Versions: []changelog.Version{{Number: "Unreleased"}, {Number: "1.2.3"}}}
201
+
202
+
version, err := resolveReleaseVersion("", "minor", existing)
203
+
if err != nil {
204
+
t.Fatalf("resolveReleaseVersion returned error: %v", err)
205
+
}
206
+
if version != "1.3.0" {
207
+
t.Fatalf("expected 1.3.0, got %s", version)
208
+
}
209
+
210
+
version, err = resolveReleaseVersion("2.0.0", "", existing)
211
+
if err != nil {
212
+
t.Fatalf("resolveReleaseVersion returned error: %v", err)
213
+
}
214
+
if version != "2.0.0" {
215
+
t.Fatalf("expected 2.0.0, got %s", version)
216
+
}
217
+
218
+
if _, err := resolveReleaseVersion("2.0.0", "patch", existing); err == nil {
219
+
t.Fatal("expected error when both --version and --bump are set")
220
+
}
221
+
222
+
blankChangelog := &changelog.Changelog{}
223
+
version, err = resolveReleaseVersion("", "patch", blankChangelog)
224
+
if err != nil {
225
+
t.Fatalf("resolveReleaseVersion returned error: %v", err)
226
+
}
227
+
if version != "0.0.1" {
228
+
t.Fatalf("expected 0.0.1 for empty changelog, got %s", version)
229
+
}
230
+
}
231
+
232
+
func TestReleaseOutput_JSONStructure(t *testing.T) {
233
+
output := ReleaseOutput{
234
+
Version: "1.0.0",
235
+
Date: "2024-01-15",
236
+
EntriesCount: 3,
237
+
ChangelogPath: "CHANGELOG.md",
238
+
TagCreated: true,
239
+
TagName: "v1.0.0",
240
+
ChangesCleared: true,
241
+
DeletedCount: 3,
242
+
DryRun: false,
243
+
VersionData: &changelog.Version{
244
+
Number: "1.0.0",
245
+
Date: "2024-01-15",
246
+
Sections: []changelog.Section{
247
+
{
248
+
Type: "added",
249
+
Entries: []string{"Feature 1"},
250
+
},
251
+
},
252
+
},
253
+
}
254
+
255
+
jsonBytes, err := json.MarshalIndent(output, "", " ")
256
+
if err != nil {
257
+
t.Fatalf("Failed to marshal JSON: %v", err)
258
+
}
259
+
260
+
var unmarshaled ReleaseOutput
261
+
err = json.Unmarshal(jsonBytes, &unmarshaled)
262
+
if err != nil {
263
+
t.Fatalf("Failed to unmarshal JSON: %v", err)
264
+
}
265
+
266
+
testutils.Expect.Equal(t, unmarshaled.Version, "1.0.0")
267
+
testutils.Expect.Equal(t, unmarshaled.Date, "2024-01-15")
268
+
testutils.Expect.Equal(t, unmarshaled.EntriesCount, 3)
269
+
testutils.Expect.Equal(t, unmarshaled.TagCreated, true)
270
+
testutils.Expect.Equal(t, unmarshaled.TagName, "v1.0.0")
271
+
testutils.Expect.Equal(t, unmarshaled.ChangesCleared, true)
272
+
testutils.Expect.Equal(t, unmarshaled.DeletedCount, 3)
273
+
}
274
+
275
+
func TestReleaseOutput_DryRunJSON(t *testing.T) {
276
+
output := ReleaseOutput{
277
+
Version: "1.0.0",
278
+
Date: "2024-01-15",
279
+
EntriesCount: 2,
280
+
ChangelogPath: "CHANGELOG.md",
281
+
DryRun: true,
282
+
VersionData: &changelog.Version{
283
+
Number: "1.0.0",
284
+
Date: "2024-01-15",
285
+
Sections: []changelog.Section{
286
+
{
287
+
Type: "fixed",
288
+
Entries: []string{"Bug fix"},
289
+
},
290
+
},
291
+
},
292
+
}
293
+
294
+
jsonBytes, err := json.MarshalIndent(output, "", " ")
295
+
if err != nil {
296
+
t.Fatalf("Failed to marshal JSON: %v", err)
297
+
}
298
+
299
+
testutils.Expect.True(t, strings.Contains(string(jsonBytes), `"dry_run": true`))
300
+
testutils.Expect.True(t, strings.Contains(string(jsonBytes), `"tag_created": false`))
301
+
testutils.Expect.True(t, strings.Contains(string(jsonBytes), `"changes_cleared": false`))
302
+
}
+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
+
}
+159
-20
cmd/unreleased.go
+159
-20
cmd/unreleased.go
···
8
8
add Add a new unreleased change entry
9
9
list List all unreleased changes
10
10
review Review unreleased changes interactively
11
+
partial Create entry linked to a specific commit
11
12
12
13
USAGE
13
14
···
37
38
38
39
--repo <path> Path to the repository (default: .)
39
40
--output <file> Optional file to export reviewed notes
41
+
42
+
USAGE
43
+
44
+
storm unreleased partial <commit-ref> [options]
45
+
46
+
FLAGS
47
+
48
+
--type <type> Override change type (auto-detected from commit message)
49
+
--summary <text> Override summary (auto-detected from commit message)
50
+
--scope <scope> Optional subsystem or module name
51
+
--repo <path> Path to the repository (default: .)
40
52
*/
41
53
package main
42
54
···
47
59
"strings"
48
60
49
61
tea "github.com/charmbracelet/bubbletea"
62
+
"github.com/go-git/go-git/v6"
63
+
"github.com/go-git/go-git/v6/plumbing"
50
64
"github.com/spf13/cobra"
51
65
"github.com/stormlightlabs/git-storm/internal/changeset"
66
+
"github.com/stormlightlabs/git-storm/internal/gitlog"
52
67
"github.com/stormlightlabs/git-storm/internal/style"
68
+
"github.com/stormlightlabs/git-storm/internal/tty"
53
69
"github.com/stormlightlabs/git-storm/internal/ui"
54
70
)
55
71
56
72
func unreleasedCmd() *cobra.Command {
73
+
var (
74
+
changeType string
75
+
scope string
76
+
summary string
77
+
outputJSON bool
78
+
)
79
+
80
+
changesDir := ".changes"
81
+
validTypes := []string{"added", "changed", "fixed", "removed", "security"}
82
+
57
83
add := &cobra.Command{
58
84
Use: "add",
59
85
Short: "Add a new unreleased change entry",
60
86
Long: `Creates a new .changes/<date>-<summary>.md file with the specified type,
61
87
scope, and summary.`,
62
88
RunE: func(cmd *cobra.Command, args []string) error {
63
-
validTypes := []string{"added", "changed", "fixed", "removed", "security"}
64
89
if !slices.Contains(validTypes, changeType) {
65
90
return fmt.Errorf("invalid type %q: must be one of %s", changeType, strings.Join(validTypes, ", "))
66
91
}
67
92
68
-
entry := changeset.Entry{
93
+
if filePath, err := changeset.Write(changesDir, changeset.Entry{
69
94
Type: changeType,
70
95
Scope: scope,
71
96
Summary: summary,
72
-
}
73
-
74
-
changesDir := ".changes"
75
-
filePath, err := changeset.Write(changesDir, entry)
76
-
if err != nil {
97
+
}); err != nil {
77
98
return fmt.Errorf("failed to create changelog entry: %w", err)
99
+
} else {
100
+
style.Addedf("Created %s", filePath)
101
+
return nil
78
102
}
79
-
80
-
style.Addedf("Created %s", filePath)
81
-
return nil
82
103
},
83
104
}
84
105
add.Flags().StringVar(&changeType, "type", "", "Type of change (added, changed, fixed, removed, security)")
···
92
113
Short: "List all unreleased changes",
93
114
Long: "Prints all pending .changes entries to stdout. Supports JSON output.",
94
115
RunE: func(cmd *cobra.Command, args []string) error {
95
-
changesDir := ".changes"
96
116
entries, err := changeset.List(changesDir)
97
117
if err != nil {
98
118
return fmt.Errorf("failed to list changelog entries: %w", err)
···
130
150
Long: `Launches an interactive Bubble Tea TUI to review, edit, or categorize
131
151
unreleased entries before final release.`,
132
152
RunE: func(cmd *cobra.Command, args []string) error {
133
-
changesDir := ".changes"
153
+
if !tty.IsInteractive() {
154
+
return tty.ErrorInteractiveRequired("storm unreleased review", []string{
155
+
"Use 'storm unreleased list' to view entries in plain text",
156
+
"Use 'storm unreleased list --json' for JSON output",
157
+
})
158
+
}
159
+
134
160
entries, err := changeset.List(changesDir)
135
161
if err != nil {
136
162
return fmt.Errorf("failed to list changelog entries: %w", err)
···
164
190
editCount := 0
165
191
166
192
for _, item := range items {
167
-
switch item.Action {
168
-
case ui.ActionDelete:
193
+
if item.Action == ui.ActionDelete {
194
+
if err := changeset.Delete(changesDir, item.Entry.Filename); err != nil {
195
+
return fmt.Errorf("failed to delete %s: %w", item.Entry.Filename, err)
196
+
}
169
197
deleteCount++
170
-
case ui.ActionEdit:
171
-
editCount++
198
+
style.Successf("Deleted: %s", item.Entry.Filename)
199
+
}
200
+
}
201
+
202
+
for _, item := range items {
203
+
if item.Action == ui.ActionEdit {
204
+
editorModel := ui.NewEntryEditorModel(item.Entry)
205
+
p := tea.NewProgram(editorModel, tea.WithAltScreen())
206
+
207
+
finalModel, err := p.Run()
208
+
if err != nil {
209
+
return fmt.Errorf("failed to run editor TUI: %w", err)
210
+
}
211
+
212
+
editor, ok := finalModel.(ui.EntryEditorModel)
213
+
if !ok {
214
+
return fmt.Errorf("unexpected model type")
215
+
}
216
+
217
+
if editor.IsCancelled() {
218
+
style.Warningf("Skipped editing: %s", item.Entry.Filename)
219
+
continue
220
+
}
221
+
222
+
if editor.IsConfirmed() {
223
+
editedEntry := editor.GetEditedEntry()
224
+
if err := changeset.Update(changesDir, item.Entry.Filename, editedEntry); err != nil {
225
+
return fmt.Errorf("failed to update %s: %w", item.Entry.Filename, err)
226
+
}
227
+
editCount++
228
+
style.Successf("Updated: %s", item.Entry.Filename)
229
+
}
172
230
}
173
231
}
174
232
···
177
235
return nil
178
236
}
179
237
180
-
style.Headlinef("Review completed: %d to delete, %d to edit", deleteCount, editCount)
181
-
style.Println("Note: Delete and edit actions are not yet implemented")
238
+
style.Headlinef("Review completed: %d deleted, %d edited", deleteCount, editCount)
239
+
return nil
240
+
},
241
+
}
242
+
243
+
partial := &cobra.Command{
244
+
Use: "partial <commit-ref>",
245
+
Short: "Create entry linked to a specific commit",
246
+
Long: `Creates a new .changes/<sha7>.<type>.md file based on the specified commit.
247
+
Auto-detects type and summary from conventional commit format, with optional overrides.`,
248
+
Args: cobra.ExactArgs(1),
249
+
RunE: func(cmd *cobra.Command, args []string) error {
250
+
commitRef := args[0]
251
+
252
+
repo, err := git.PlainOpen(repoPath)
253
+
if err != nil {
254
+
return fmt.Errorf("failed to open repository: %w", err)
255
+
}
256
+
257
+
hash, err := repo.ResolveRevision(plumbing.Revision(commitRef))
258
+
if err != nil {
259
+
return fmt.Errorf("failed to resolve commit ref %q: %w", commitRef, err)
260
+
}
261
+
262
+
commit, err := repo.CommitObject(*hash)
263
+
if err != nil {
264
+
return fmt.Errorf("failed to get commit object: %w", err)
265
+
}
266
+
267
+
parser := &gitlog.ConventionalParser{}
268
+
subject := commit.Message
269
+
body := ""
270
+
lines := strings.Split(commit.Message, "\n")
271
+
if len(lines) > 0 {
272
+
subject = lines[0]
273
+
if len(lines) > 1 {
274
+
body = strings.Join(lines[1:], "\n")
275
+
}
276
+
}
277
+
278
+
meta, err := parser.Parse(hash.String(), subject, body, commit.Author.When)
279
+
if err != nil {
280
+
return fmt.Errorf("failed to parse commit message: %w", err)
281
+
}
282
+
283
+
category := parser.Categorize(meta)
182
284
285
+
if changeType != "" {
286
+
if !slices.Contains(validTypes, changeType) {
287
+
return fmt.Errorf("invalid type %q: must be one of %s", changeType, strings.Join(validTypes, ", "))
288
+
}
289
+
category = changeType
290
+
} else if category == "" {
291
+
return fmt.Errorf("could not auto-detect change type from commit message, please specify --type")
292
+
}
293
+
294
+
entrySummary := meta.Description
295
+
if summary != "" {
296
+
entrySummary = summary
297
+
}
298
+
299
+
if scope != "" {
300
+
meta.Scope = scope
301
+
}
302
+
303
+
sha7 := hash.String()[:7]
304
+
filename := fmt.Sprintf("%s.%s.md", sha7, category)
305
+
filePath := changesDir + "/" + filename
306
+
307
+
entry := changeset.Entry{
308
+
Type: category,
309
+
Scope: meta.Scope,
310
+
Summary: entrySummary,
311
+
Breaking: meta.Breaking,
312
+
CommitHash: hash.String(),
313
+
}
314
+
315
+
if _, err := changeset.WritePartial(changesDir, filename, entry); err != nil {
316
+
return fmt.Errorf("failed to create changelog entry: %w", err)
317
+
}
318
+
319
+
style.Addedf("Created %s", filePath)
183
320
return nil
184
321
},
185
322
}
323
+
partial.Flags().StringVar(&changeType, "type", "", "Override change type (auto-detected from commit)")
324
+
partial.Flags().StringVar(&scope, "scope", "", "Optional scope or subsystem name")
325
+
partial.Flags().StringVar(&summary, "summary", "", "Override summary (auto-detected from commit)")
186
326
187
327
root := &cobra.Command{
188
328
Use: "unreleased",
···
190
330
Long: `Work with unreleased change notes. Supports adding, listing,
191
331
and reviewing pending entries before release.`,
192
332
}
193
-
root.AddCommand(add, list, review)
194
-
333
+
root.AddCommand(add, list, review, partial)
195
334
return root
196
335
}
197
336
+192
cmd/unreleased_test.go
+192
cmd/unreleased_test.go
···
1
+
package main
2
+
3
+
import (
4
+
"os"
5
+
"path/filepath"
6
+
"testing"
7
+
8
+
"github.com/stormlightlabs/git-storm/internal/changeset"
9
+
"github.com/stormlightlabs/git-storm/internal/testutils"
10
+
)
11
+
12
+
func TestUnreleasedReviewWorkflow_Delete(t *testing.T) {
13
+
tmpDir := t.TempDir()
14
+
changesDir := filepath.Join(tmpDir, ".changes")
15
+
16
+
entry1 := changeset.Entry{
17
+
Type: "added",
18
+
Scope: "test",
19
+
Summary: "Entry to keep",
20
+
}
21
+
entry2 := changeset.Entry{
22
+
Type: "fixed",
23
+
Scope: "test",
24
+
Summary: "Entry to delete",
25
+
}
26
+
27
+
filePath1, err := changeset.Write(changesDir, entry1)
28
+
if err != nil {
29
+
t.Fatalf("Failed to create entry1: %v", err)
30
+
}
31
+
filePath2, err := changeset.Write(changesDir, entry2)
32
+
if err != nil {
33
+
t.Fatalf("Failed to create entry2: %v", err)
34
+
}
35
+
36
+
filename2 := filepath.Base(filePath2)
37
+
38
+
err = changeset.Delete(changesDir, filename2)
39
+
if err != nil {
40
+
t.Fatalf("Delete action failed: %v", err)
41
+
}
42
+
43
+
if _, err := os.Stat(filePath1); os.IsNotExist(err) {
44
+
t.Error("Entry1 should still exist")
45
+
}
46
+
47
+
if _, err := os.Stat(filePath2); !os.IsNotExist(err) {
48
+
t.Error("Entry2 should have been deleted")
49
+
}
50
+
51
+
entries, err := changeset.List(changesDir)
52
+
if err != nil {
53
+
t.Fatalf("Failed to list entries: %v", err)
54
+
}
55
+
56
+
testutils.Expect.Equal(t, len(entries), 1, "Should have 1 entry remaining")
57
+
testutils.Expect.Equal(t, entries[0].Entry.Summary, "Entry to keep")
58
+
}
59
+
60
+
func TestUnreleasedReviewWorkflow_Edit(t *testing.T) {
61
+
tmpDir := t.TempDir()
62
+
changesDir := filepath.Join(tmpDir, ".changes")
63
+
64
+
originalEntry := changeset.Entry{
65
+
Type: "added",
66
+
Scope: "cli",
67
+
Summary: "Original summary",
68
+
Breaking: false,
69
+
CommitHash: "abc123",
70
+
}
71
+
72
+
filePath, err := changeset.Write(changesDir, originalEntry)
73
+
if err != nil {
74
+
t.Fatalf("Failed to create entry: %v", err)
75
+
}
76
+
77
+
filename := filepath.Base(filePath)
78
+
79
+
editedEntry := changeset.Entry{
80
+
Type: "changed",
81
+
Scope: "api",
82
+
Summary: "Updated summary",
83
+
Breaking: true,
84
+
CommitHash: "abc123",
85
+
}
86
+
87
+
err = changeset.Update(changesDir, filename, editedEntry)
88
+
if err != nil {
89
+
t.Fatalf("Update action failed: %v", err)
90
+
}
91
+
92
+
entries, err := changeset.List(changesDir)
93
+
if err != nil {
94
+
t.Fatalf("Failed to list entries: %v", err)
95
+
}
96
+
97
+
testutils.Expect.Equal(t, len(entries), 1, "Should still have 1 entry")
98
+
testutils.Expect.Equal(t, entries[0].Entry.Type, "changed", "Type should be updated")
99
+
testutils.Expect.Equal(t, entries[0].Entry.Scope, "api", "Scope should be updated")
100
+
testutils.Expect.Equal(t, entries[0].Entry.Summary, "Updated summary", "Summary should be updated")
101
+
testutils.Expect.Equal(t, entries[0].Entry.Breaking, true, "Breaking should be updated")
102
+
testutils.Expect.Equal(t, entries[0].Entry.CommitHash, "abc123", "CommitHash should be preserved")
103
+
}
104
+
105
+
func TestUnreleasedReviewWorkflow_DeleteAndEdit(t *testing.T) {
106
+
tmpDir := t.TempDir()
107
+
changesDir := filepath.Join(tmpDir, ".changes")
108
+
109
+
entry1 := changeset.Entry{
110
+
Type: "added",
111
+
Summary: "Entry to delete",
112
+
}
113
+
entry2 := changeset.Entry{
114
+
Type: "fixed",
115
+
Summary: "Entry to edit",
116
+
}
117
+
entry3 := changeset.Entry{
118
+
Type: "changed",
119
+
Summary: "Entry to keep",
120
+
}
121
+
122
+
filePath1, err := changeset.Write(changesDir, entry1)
123
+
if err != nil {
124
+
t.Fatalf("Failed to create entry1: %v", err)
125
+
}
126
+
filePath2, err := changeset.Write(changesDir, entry2)
127
+
if err != nil {
128
+
t.Fatalf("Failed to create entry2: %v", err)
129
+
}
130
+
_, err = changeset.Write(changesDir, entry3)
131
+
if err != nil {
132
+
t.Fatalf("Failed to create entry3: %v", err)
133
+
}
134
+
135
+
filename1 := filepath.Base(filePath1)
136
+
filename2 := filepath.Base(filePath2)
137
+
138
+
err = changeset.Delete(changesDir, filename1)
139
+
if err != nil {
140
+
t.Fatalf("Delete action failed: %v", err)
141
+
}
142
+
143
+
editedEntry := changeset.Entry{
144
+
Type: "security",
145
+
Scope: "auth",
146
+
Summary: "Updated security fix",
147
+
}
148
+
err = changeset.Update(changesDir, filename2, editedEntry)
149
+
if err != nil {
150
+
t.Fatalf("Update action failed: %v", err)
151
+
}
152
+
153
+
entries, err := changeset.List(changesDir)
154
+
if err != nil {
155
+
t.Fatalf("Failed to list entries: %v", err)
156
+
}
157
+
158
+
testutils.Expect.Equal(t, len(entries), 2, "Should have 2 entries remaining")
159
+
160
+
var found bool
161
+
for _, e := range entries {
162
+
if e.Entry.Type == "security" {
163
+
testutils.Expect.Equal(t, e.Entry.Scope, "auth")
164
+
testutils.Expect.Equal(t, e.Entry.Summary, "Updated security fix")
165
+
found = true
166
+
}
167
+
}
168
+
169
+
if !found {
170
+
t.Error("Edited entry not found in results")
171
+
}
172
+
173
+
if _, err := os.Stat(filePath1); !os.IsNotExist(err) {
174
+
t.Error("Deleted entry should not exist")
175
+
}
176
+
}
177
+
178
+
func TestUnreleasedReviewWorkflow_EmptyChanges(t *testing.T) {
179
+
tmpDir := t.TempDir()
180
+
changesDir := filepath.Join(tmpDir, ".changes")
181
+
182
+
if err := os.MkdirAll(changesDir, 0755); err != nil {
183
+
t.Fatalf("Failed to create directory: %v", err)
184
+
}
185
+
186
+
entries, err := changeset.List(changesDir)
187
+
if err != nil {
188
+
t.Fatalf("List should not error on empty directory: %v", err)
189
+
}
190
+
191
+
testutils.Expect.Equal(t, len(entries), 0, "Should have no entries")
192
+
}
+48
docs/.vitepress/config.mts
+48
docs/.vitepress/config.mts
···
1
+
import { defineConfig } from "vitepress";
2
+
3
+
// https://vitepress.dev/reference/site-config
4
+
export default defineConfig({
5
+
title: "Storm",
6
+
description: "Local-first changelog manager for git repositories",
7
+
markdown: {
8
+
theme: {
9
+
light: "catppuccin-latte",
10
+
dark: "catppuccin-macchiato",
11
+
},
12
+
},
13
+
themeConfig: {
14
+
// https://vitepress.dev/reference/default-theme-config
15
+
nav: [
16
+
{ text: "Introduction", link: "/introduction" },
17
+
{ text: "Quickstart", link: "/quickstart" },
18
+
{ text: "Manual", link: "/manual" },
19
+
{ text: "Development", link: "/development" },
20
+
],
21
+
sidebar: [
22
+
{
23
+
text: "Getting Started",
24
+
items: [
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" },
34
+
],
35
+
},
36
+
],
37
+
socialLinks: [
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
+
},
46
+
],
47
+
},
48
+
});
+15
docs/.vitepress/theme/index.ts
+15
docs/.vitepress/theme/index.ts
···
1
+
// https://vitepress.dev/guide/custom-theme
2
+
import { h } from "vue";
3
+
import type { Theme } from "vitepress";
4
+
import DefaultTheme from "vitepress/theme";
5
+
import "@catppuccin/vitepress/theme/mocha/sapphire.css";
6
+
7
+
export default {
8
+
extends: DefaultTheme,
9
+
Layout: () => {
10
+
return h(DefaultTheme.Layout, null, {
11
+
// https://vitepress.dev/guide/extending-default-theme#layout-slots
12
+
});
13
+
},
14
+
enhanceApp({ app, router, siteData }) {},
15
+
} satisfies Theme;
+139
docs/.vitepress/theme/style.css
+139
docs/.vitepress/theme/style.css
···
1
+
/**
2
+
* Customize default theme styling by overriding CSS variables:
3
+
* https://github.com/vuejs/vitepress/blob/main/src/client/theme-default/styles/vars.css
4
+
*/
5
+
6
+
/**
7
+
* Colors
8
+
*
9
+
* Each colors have exact same color scale system with 3 levels of solid
10
+
* colors with different brightness, and 1 soft color.
11
+
*
12
+
* - `XXX-1`: The most solid color used mainly for colored text. It must
13
+
* satisfy the contrast ratio against when used on top of `XXX-soft`.
14
+
*
15
+
* - `XXX-2`: The color used mainly for hover state of the button.
16
+
*
17
+
* - `XXX-3`: The color for solid background, such as bg color of the button.
18
+
* It must satisfy the contrast ratio with pure white (#ffffff) text on
19
+
* top of it.
20
+
*
21
+
* - `XXX-soft`: The color used for subtle background such as custom container
22
+
* or badges. It must satisfy the contrast ratio when putting `XXX-1` colors
23
+
* on top of it.
24
+
*
25
+
* The soft color must be semi transparent alpha channel. This is crucial
26
+
* because it allows adding multiple "soft" colors on top of each other
27
+
* to create an accent, such as when having inline code block inside
28
+
* custom containers.
29
+
*
30
+
* - `default`: The color used purely for subtle indication without any
31
+
* special meanings attached to it such as bg color for menu hover state.
32
+
*
33
+
* - `brand`: Used for primary brand colors, such as link text, button with
34
+
* brand theme, etc.
35
+
*
36
+
* - `tip`: Used to indicate useful information. The default theme uses the
37
+
* brand color for this by default.
38
+
*
39
+
* - `warning`: Used to indicate warning to the users. Used in custom
40
+
* container, badges, etc.
41
+
*
42
+
* - `danger`: Used to show error, or dangerous message to the users. Used
43
+
* in custom container, badges, etc.
44
+
* -------------------------------------------------------------------------- */
45
+
46
+
:root {
47
+
--vp-c-default-1: var(--vp-c-gray-1);
48
+
--vp-c-default-2: var(--vp-c-gray-2);
49
+
--vp-c-default-3: var(--vp-c-gray-3);
50
+
--vp-c-default-soft: var(--vp-c-gray-soft);
51
+
52
+
--vp-c-brand-1: var(--vp-c-indigo-1);
53
+
--vp-c-brand-2: var(--vp-c-indigo-2);
54
+
--vp-c-brand-3: var(--vp-c-indigo-3);
55
+
--vp-c-brand-soft: var(--vp-c-indigo-soft);
56
+
57
+
--vp-c-tip-1: var(--vp-c-brand-1);
58
+
--vp-c-tip-2: var(--vp-c-brand-2);
59
+
--vp-c-tip-3: var(--vp-c-brand-3);
60
+
--vp-c-tip-soft: var(--vp-c-brand-soft);
61
+
62
+
--vp-c-warning-1: var(--vp-c-yellow-1);
63
+
--vp-c-warning-2: var(--vp-c-yellow-2);
64
+
--vp-c-warning-3: var(--vp-c-yellow-3);
65
+
--vp-c-warning-soft: var(--vp-c-yellow-soft);
66
+
67
+
--vp-c-danger-1: var(--vp-c-red-1);
68
+
--vp-c-danger-2: var(--vp-c-red-2);
69
+
--vp-c-danger-3: var(--vp-c-red-3);
70
+
--vp-c-danger-soft: var(--vp-c-red-soft);
71
+
}
72
+
73
+
/**
74
+
* Component: Button
75
+
* -------------------------------------------------------------------------- */
76
+
77
+
:root {
78
+
--vp-button-brand-border: transparent;
79
+
--vp-button-brand-text: var(--vp-c-white);
80
+
--vp-button-brand-bg: var(--vp-c-brand-3);
81
+
--vp-button-brand-hover-border: transparent;
82
+
--vp-button-brand-hover-text: var(--vp-c-white);
83
+
--vp-button-brand-hover-bg: var(--vp-c-brand-2);
84
+
--vp-button-brand-active-border: transparent;
85
+
--vp-button-brand-active-text: var(--vp-c-white);
86
+
--vp-button-brand-active-bg: var(--vp-c-brand-1);
87
+
}
88
+
89
+
/**
90
+
* Component: Home
91
+
* -------------------------------------------------------------------------- */
92
+
93
+
:root {
94
+
--vp-home-hero-name-color: transparent;
95
+
--vp-home-hero-name-background: -webkit-linear-gradient(
96
+
120deg,
97
+
#bd34fe 30%,
98
+
#41d1ff
99
+
);
100
+
101
+
--vp-home-hero-image-background-image: linear-gradient(
102
+
-45deg,
103
+
#bd34fe 50%,
104
+
#47caff 50%
105
+
);
106
+
--vp-home-hero-image-filter: blur(44px);
107
+
}
108
+
109
+
@media (min-width: 640px) {
110
+
:root {
111
+
--vp-home-hero-image-filter: blur(56px);
112
+
}
113
+
}
114
+
115
+
@media (min-width: 960px) {
116
+
:root {
117
+
--vp-home-hero-image-filter: blur(68px);
118
+
}
119
+
}
120
+
121
+
/**
122
+
* Component: Custom Block
123
+
* -------------------------------------------------------------------------- */
124
+
125
+
:root {
126
+
--vp-custom-block-tip-border: transparent;
127
+
--vp-custom-block-tip-text: var(--vp-c-text-1);
128
+
--vp-custom-block-tip-bg: var(--vp-c-brand-soft);
129
+
--vp-custom-block-tip-code-bg: var(--vp-c-brand-soft);
130
+
}
131
+
132
+
/**
133
+
* Component: Algolia
134
+
* -------------------------------------------------------------------------- */
135
+
136
+
.DocSearch {
137
+
--docsearch-primary-color: var(--vp-c-brand-1) !important;
138
+
}
139
+
+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
+
```
+18
docs/index.md
+18
docs/index.md
···
1
+
---
2
+
layout: home
3
+
hero:
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"
7
+
actions:
8
+
- theme: brand
9
+
text: Quickstart
10
+
link: /quickstart
11
+
features:
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.
18
+
---
+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).
+174
docs/manual.md
+174
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`). |
45
+
| | Accepts explicit paths, type aliases like `cargo`/`npm`, or the literal `interactive` to launch a picker TUI. |
46
+
47
+
#### `storm release`
48
+
49
+
Promote `.changes/*.md` into the changelog and optionally tag the repo.
50
+
51
+
```text
52
+
storm release (--version X.Y.Z | --bump <type>) [flags]
53
+
```
54
+
55
+
##### Flags
56
+
57
+
| Flag | Description |
58
+
| --------------------- | ----------------------------------------------------------------------------------- |
59
+
| `--version <X.Y.Z>` | Explicit version for the new changelog entry. |
60
+
| `--bump <type>` | Derive the version from the previous release (mutually exclusive with `--version`). |
61
+
| `--date <YYYY-MM-DD>` | Override the release date (default: today). |
62
+
| `--clear-changes` | Remove `.changes/*.md` files after a successful release. |
63
+
| `--dry-run` | Render a preview without touching any files. |
64
+
| `--tag` | Create an annotated git tag containing the release notes. |
65
+
| `--toolchain <value>` | Update manifest files just like in `storm bump`. |
66
+
| `--output-json` | Emit machine-readable JSON instead of styled text. |
67
+
68
+
#### `storm generate`
69
+
70
+
Create `.changes/*.md` files from commit history, with optional TUI review.
71
+
72
+
```text
73
+
storm generate <from> <to>
74
+
storm generate --since <tag> [to]
75
+
```
76
+
77
+
##### Flags
78
+
79
+
| Flag | Description |
80
+
| --------------------- | -------------------------------------------------- |
81
+
| `-i`, `--interactive` | Open a commit selector TUI for choosing entries. |
82
+
| `--since <tag>` | Shortcut for `<from>`; defaults `<to>` to `HEAD`. |
83
+
| `--output-json` | Emit machine-readable JSON instead of styled text. |
84
+
85
+
#### `storm diff`
86
+
87
+
Side-by-side or unified diff with TUI navigation.
88
+
89
+
```text
90
+
storm diff <from>..<to> [flags]
91
+
storm diff <from> <to> [flags]
92
+
```
93
+
94
+
| Flag | Description |
95
+
| ------------------------------- | ----------------------------------------------------- |
96
+
| `-f`, `--file <path>` | Restrict the diff to a single file. |
97
+
| `-e`, `--expanded` | Show all unchanged lines instead of compressed hunks. |
98
+
| `-v`, `--view <split\|unified>` | Rendering style (default: split). |
99
+
100
+
#### `storm check`
101
+
102
+
Verify every commit in a range has a corresponding unreleased entry.
103
+
104
+
```text
105
+
storm check <from> <to>
106
+
storm check --since <tag> [to]
107
+
```
108
+
109
+
| Flag | Description |
110
+
| --------------- | ---------------------------------------------------------- |
111
+
| `--since <tag>` | Start range at the provided tag and default end to `HEAD`. |
112
+
113
+
Non-zero exit status indicates missing entries. Messages containing
114
+
`[nochanges]` or `[skip changelog]` are ignored.
115
+
116
+
#### `storm unreleased`
117
+
118
+
Manage `.changes` entries directly.
119
+
120
+
##### `add`
121
+
122
+
```text
123
+
storm unreleased add --type <kind> --summary <text> [--scope value]
124
+
```
125
+
126
+
| Flag | Description |
127
+
| --------------------------------------------------- | ------------------------------------------- |
128
+
| `--type <added\|changed\|fixed\|removed\|security>` | Entry category. |
129
+
| `--summary <text>` | Short human readable note. |
130
+
| `--scope <value>` | Optional component indicator (e.g., `cli`). |
131
+
132
+
##### `list`
133
+
134
+
```text
135
+
storm unreleased list [--json]
136
+
```
137
+
138
+
| Flag | Description |
139
+
| -------- | -------------------------------------------------- |
140
+
| `--json` | Emit machine-readable JSON instead of styled text. |
141
+
142
+
##### `partial`
143
+
144
+
```text
145
+
storm unreleased partial <commit-ref> [flags]
146
+
```
147
+
148
+
| Flag | Description |
149
+
| ------------------ | --------------------------------------------------- |
150
+
| `--type <value>` | Override the inferred type from the commit message. |
151
+
| `--summary <text>` | Override the inferred summary. |
152
+
| `--scope <value>` | Optional component indicator. |
153
+
154
+
##### `review`
155
+
156
+
```text
157
+
storm unreleased review
158
+
```
159
+
160
+
Launch a Bubble Tea TUI for editing and deleting entries before release.
161
+
Requires a TTY; fall back to `storm unreleased list` otherwise.
162
+
163
+
#### `storm version`
164
+
165
+
Print the current buildโs version string.
166
+
167
+
## FILES
168
+
169
+
- `.changes/` โ queue of unreleased entries created by `storm generate` or `storm unreleased add`.
170
+
- `CHANGELOG.md` โ Keep a Changelog-compatible file updated by `storm release`.
171
+
172
+
## SEE ALSO
173
+
174
+
`CHANGELOG.md`, [Keep a Changelog](https://keepachangelog.com), semantic versioning at [semver.org](https://semver.org).
+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).
+7
-2
go.mod
+7
-2
go.mod
···
11
11
github.com/spf13/cobra v1.10.1
12
12
)
13
13
14
-
require github.com/goccy/go-yaml v1.18.0
14
+
require (
15
+
github.com/goccy/go-yaml v1.18.0
16
+
golang.org/x/term v0.36.0
17
+
)
18
+
19
+
require github.com/atotto/clipboard v0.1.4 // indirect
15
20
16
21
require (
17
22
github.com/clipperhouse/displaywidth v0.4.1 // indirect
···
69
74
golang.org/x/net v0.46.0 // indirect
70
75
golang.org/x/sync v0.17.0 // indirect
71
76
golang.org/x/sys v0.37.0 // indirect
72
-
golang.org/x/text v0.30.0 // indirect
77
+
golang.org/x/text v0.30.0
73
78
)
+2
go.sum
+2
go.sum
···
6
6
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
7
7
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
8
8
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
9
+
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
10
+
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
9
11
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
10
12
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
11
13
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
+406
internal/changelog/changelog.go
+406
internal/changelog/changelog.go
···
1
+
// Package changelog implements Keep a Changelog parsing, building, and writing.
2
+
//
3
+
// It generates CHANGELOG.md files compliant with https://keepachangelog.com/en/1.1.0/
4
+
package changelog
5
+
6
+
import (
7
+
"bufio"
8
+
"fmt"
9
+
"os"
10
+
"path/filepath"
11
+
"regexp"
12
+
"sort"
13
+
"strings"
14
+
"time"
15
+
16
+
"github.com/go-git/go-git/v6"
17
+
"github.com/stormlightlabs/git-storm/internal/changeset"
18
+
)
19
+
20
+
// Changelog represents the entire CHANGELOG.md file structure.
21
+
type Changelog struct {
22
+
Header string // Preamble text before versions
23
+
Versions []Version // All versions in chronological order (newest first)
24
+
Links []string // Version comparison links at the bottom
25
+
}
26
+
27
+
// Version represents a single version section in the changelog.
28
+
type Version struct {
29
+
Number string // Semantic version (e.g., "1.2.0")
30
+
Date string // ISO date (YYYY-MM-DD) or "Unreleased"
31
+
Sections []Section // Category sections (Added, Changed, etc.)
32
+
}
33
+
34
+
// Section represents a category section within a version.
35
+
type Section struct {
36
+
Type string // added, changed, deprecated, removed, fixed, security
37
+
Entries []string // Individual entries without leading dashes
38
+
}
39
+
40
+
// sectionOrder defines the Keep a Changelog section ordering.
41
+
var sectionOrder = []string{"added", "changed", "deprecated", "removed", "fixed", "security"}
42
+
43
+
// sectionTitles maps internal types to Keep a Changelog titles.
44
+
var sectionTitles = map[string]string{
45
+
"added": "Added",
46
+
"changed": "Changed",
47
+
"deprecated": "Deprecated",
48
+
"removed": "Removed",
49
+
"fixed": "Fixed",
50
+
"security": "Security",
51
+
}
52
+
53
+
// versionHeaderRegex matches version headers like "## [1.2.0] - 2025-01-15" or "## [Unreleased]"
54
+
var versionHeaderRegex = regexp.MustCompile(`^##\s+\[([^\]]+)\](?:\s+-\s+(.+))?$`)
55
+
56
+
// sectionHeaderRegex matches section headers like "### Added"
57
+
var sectionHeaderRegex = regexp.MustCompile(`^###\s+(.+)$`)
58
+
59
+
// entryRegex matches changelog entries like "- Entry text"
60
+
var entryRegex = regexp.MustCompile(`^-\s+(.+)$`)
61
+
62
+
// semanticVersionRegex validates semantic versioning (X.Y.Z)
63
+
var semanticVersionRegex = regexp.MustCompile(`^\d+\.\d+\.\d+$`)
64
+
65
+
// linkRegex matches comparison links like "[1.2.0]: https://..."
66
+
var linkRegex = regexp.MustCompile(`^\[([^\]]+)\]:\s+(.+)$`)
67
+
68
+
// Parse reads and parses an existing CHANGELOG.md file.
69
+
// Returns an empty Changelog with default header if the file doesn't exist.
70
+
func Parse(path string) (*Changelog, error) {
71
+
file, err := os.Open(path)
72
+
if os.IsNotExist(err) {
73
+
return newEmptyChangelog(), nil
74
+
}
75
+
if err != nil {
76
+
return nil, fmt.Errorf("failed to open changelog: %w", err)
77
+
}
78
+
defer file.Close()
79
+
80
+
changelog := &Changelog{}
81
+
scanner := bufio.NewScanner(file)
82
+
83
+
var headerLines []string
84
+
var currentVersion *Version
85
+
var currentSection *Section
86
+
inLinks := false
87
+
88
+
for scanner.Scan() {
89
+
line := scanner.Text()
90
+
91
+
if linkMatch := linkRegex.FindStringSubmatch(line); linkMatch != nil {
92
+
inLinks = true
93
+
changelog.Links = append(changelog.Links, line)
94
+
continue
95
+
}
96
+
97
+
if inLinks {
98
+
if strings.TrimSpace(line) != "" {
99
+
changelog.Links = append(changelog.Links, line)
100
+
}
101
+
continue
102
+
}
103
+
104
+
if versionMatch := versionHeaderRegex.FindStringSubmatch(line); versionMatch != nil {
105
+
if currentVersion != nil {
106
+
if currentSection != nil && len(currentSection.Entries) > 0 {
107
+
currentVersion.Sections = append(currentVersion.Sections, *currentSection)
108
+
}
109
+
changelog.Versions = append(changelog.Versions, *currentVersion)
110
+
}
111
+
112
+
currentVersion = &Version{
113
+
Number: versionMatch[1],
114
+
}
115
+
if len(versionMatch) > 2 && versionMatch[2] != "" {
116
+
currentVersion.Date = versionMatch[2]
117
+
} else {
118
+
currentVersion.Date = "Unreleased"
119
+
}
120
+
currentSection = nil
121
+
continue
122
+
}
123
+
124
+
if sectionMatch := sectionHeaderRegex.FindStringSubmatch(line); sectionMatch != nil {
125
+
if currentVersion != nil {
126
+
if currentSection != nil && len(currentSection.Entries) > 0 {
127
+
currentVersion.Sections = append(currentVersion.Sections, *currentSection)
128
+
}
129
+
130
+
sectionTitle := sectionMatch[1]
131
+
sectionType := findSectionType(sectionTitle)
132
+
currentSection = &Section{
133
+
Type: sectionType,
134
+
Entries: []string{},
135
+
}
136
+
}
137
+
continue
138
+
}
139
+
140
+
if entryMatch := entryRegex.FindStringSubmatch(line); entryMatch != nil {
141
+
if currentSection != nil {
142
+
currentSection.Entries = append(currentSection.Entries, entryMatch[1])
143
+
}
144
+
continue
145
+
}
146
+
147
+
if currentVersion == nil {
148
+
headerLines = append(headerLines, line)
149
+
}
150
+
}
151
+
152
+
if currentVersion != nil {
153
+
if currentSection != nil && len(currentSection.Entries) > 0 {
154
+
currentVersion.Sections = append(currentVersion.Sections, *currentSection)
155
+
}
156
+
changelog.Versions = append(changelog.Versions, *currentVersion)
157
+
}
158
+
159
+
if err := scanner.Err(); err != nil {
160
+
return nil, fmt.Errorf("failed to read changelog: %w", err)
161
+
}
162
+
163
+
changelog.Header = strings.TrimSpace(strings.Join(headerLines, "\n"))
164
+
if changelog.Header == "" {
165
+
changelog.Header = defaultHeader()
166
+
}
167
+
168
+
return changelog, nil
169
+
}
170
+
171
+
// Build creates a new Version from changeset entries.
172
+
//
173
+
// Entries are grouped by type, sorted, and formatted with breaking change prefixes.
174
+
func Build(entries []changeset.Entry, version, date string) (*Version, error) {
175
+
if err := ValidateVersion(version); err != nil {
176
+
return nil, err
177
+
}
178
+
179
+
if err := ValidateDate(date); err != nil {
180
+
return nil, err
181
+
}
182
+
183
+
grouped := make(map[string][]string)
184
+
for _, entry := range entries {
185
+
text := entry.Summary
186
+
if entry.Scope != "" {
187
+
text = fmt.Sprintf("**%s:** %s", entry.Scope, text)
188
+
}
189
+
if entry.Breaking {
190
+
text = fmt.Sprintf("**BREAKING:** %s", text)
191
+
}
192
+
193
+
grouped[entry.Type] = append(grouped[entry.Type], text)
194
+
}
195
+
196
+
for typ := range grouped {
197
+
sort.Strings(grouped[typ])
198
+
}
199
+
200
+
// Build sections in Keep a Changelog order
201
+
var sections []Section
202
+
for _, typ := range sectionOrder {
203
+
if entryList, exists := grouped[typ]; exists && len(entryList) > 0 {
204
+
sections = append(sections, Section{
205
+
Type: typ,
206
+
Entries: entryList,
207
+
})
208
+
}
209
+
}
210
+
211
+
return &Version{
212
+
Number: version,
213
+
Date: date,
214
+
Sections: sections,
215
+
}, nil
216
+
}
217
+
218
+
// Merge inserts a new version into the changelog at the top (below Unreleased if present).
219
+
func Merge(changelog *Changelog, version *Version) {
220
+
insertIndex := 0
221
+
if len(changelog.Versions) > 0 && strings.ToLower(changelog.Versions[0].Number) == "unreleased" {
222
+
insertIndex = 1
223
+
}
224
+
225
+
versions := make([]Version, 0, len(changelog.Versions)+1)
226
+
versions = append(versions, changelog.Versions[:insertIndex]...)
227
+
versions = append(versions, *version)
228
+
versions = append(versions, changelog.Versions[insertIndex:]...)
229
+
changelog.Versions = versions
230
+
}
231
+
232
+
// Write writes the changelog to a file with proper Keep a Changelog formatting.
233
+
//
234
+
// Generates version comparison links if a git remote is available.
235
+
func Write(path string, changelog *Changelog, repoPath string) error {
236
+
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
237
+
return fmt.Errorf("failed to create directory: %w", err)
238
+
}
239
+
240
+
file, err := os.Create(path)
241
+
if err != nil {
242
+
return fmt.Errorf("failed to create changelog: %w", err)
243
+
}
244
+
defer file.Close()
245
+
246
+
w := bufio.NewWriter(file)
247
+
defer w.Flush()
248
+
249
+
if changelog.Header != "" {
250
+
fmt.Fprintf(w, "%s\n\n", changelog.Header)
251
+
}
252
+
253
+
for i, version := range changelog.Versions {
254
+
if i > 0 {
255
+
fmt.Fprintln(w)
256
+
}
257
+
258
+
if version.Date == "" || strings.ToLower(version.Date) == "unreleased" {
259
+
fmt.Fprintf(w, "## [%s]\n\n", version.Number)
260
+
} else {
261
+
fmt.Fprintf(w, "## [%s] - %s\n\n", version.Number, version.Date)
262
+
}
263
+
264
+
for j, section := range version.Sections {
265
+
if j > 0 {
266
+
fmt.Fprintln(w)
267
+
}
268
+
269
+
title := sectionTitles[section.Type]
270
+
if title == "" {
271
+
if len(section.Type) > 0 {
272
+
title = strings.ToUpper(section.Type[:1]) + section.Type[1:]
273
+
} else {
274
+
title = section.Type
275
+
}
276
+
}
277
+
fmt.Fprintf(w, "### %s\n\n", title)
278
+
279
+
for _, entry := range section.Entries {
280
+
fmt.Fprintf(w, "- %s\n", entry)
281
+
}
282
+
}
283
+
}
284
+
285
+
links, err := GenerateLinks(repoPath, changelog.Versions)
286
+
if err == nil && len(links) > 0 {
287
+
fmt.Fprintln(w)
288
+
for _, link := range links {
289
+
fmt.Fprintln(w, link)
290
+
}
291
+
} else if len(changelog.Links) > 0 {
292
+
fmt.Fprintln(w)
293
+
for _, link := range changelog.Links {
294
+
fmt.Fprintln(w, link)
295
+
}
296
+
}
297
+
298
+
return nil
299
+
}
300
+
301
+
// GenerateLinks creates version comparison links for GitHub repositories.
302
+
func GenerateLinks(repoPath string, versions []Version) ([]string, error) {
303
+
repo, err := git.PlainOpen(repoPath)
304
+
if err != nil {
305
+
return nil, fmt.Errorf("failed to open repository: %w", err)
306
+
}
307
+
308
+
remote, err := repo.Remote("origin")
309
+
if err != nil {
310
+
return nil, fmt.Errorf("no origin remote configured: %w", err)
311
+
}
312
+
313
+
if len(remote.Config().URLs) == 0 {
314
+
return nil, fmt.Errorf("no remote URL configured")
315
+
}
316
+
317
+
remoteURL := remote.Config().URLs[0]
318
+
baseURL := parseGitHubURL(remoteURL)
319
+
if baseURL == "" {
320
+
return nil, fmt.Errorf("not a GitHub repository")
321
+
}
322
+
323
+
var links []string
324
+
for i, version := range versions {
325
+
var link string
326
+
if strings.ToLower(version.Number) == "unreleased" {
327
+
if len(versions) > 1 {
328
+
link = fmt.Sprintf("[Unreleased]: %s/compare/v%s...HEAD", baseURL, versions[1].Number)
329
+
} else {
330
+
link = fmt.Sprintf("[Unreleased]: %s/compare/HEAD", baseURL)
331
+
}
332
+
} else {
333
+
if i+1 < len(versions) && strings.ToLower(versions[i+1].Number) != "unreleased" {
334
+
link = fmt.Sprintf("[%s]: %s/compare/v%s...v%s", version.Number, baseURL, versions[i+1].Number, version.Number)
335
+
} else {
336
+
link = fmt.Sprintf("[%s]: %s/releases/tag/v%s", version.Number, baseURL, version.Number)
337
+
}
338
+
}
339
+
links = append(links, link)
340
+
}
341
+
342
+
return links, nil
343
+
}
344
+
345
+
// ValidateVersion checks if a version string follows semantic versioning (X.Y.Z).
346
+
func ValidateVersion(version string) error {
347
+
if !semanticVersionRegex.MatchString(version) {
348
+
return fmt.Errorf("invalid semantic version '%s': must be X.Y.Z format (e.g., 1.2.0)", version)
349
+
}
350
+
return nil
351
+
}
352
+
353
+
// ValidateDate checks if a date string follows ISO 8601 format (YYYY-MM-DD).
354
+
func ValidateDate(date string) error {
355
+
_, err := time.Parse("2006-01-02", date)
356
+
if err != nil {
357
+
return fmt.Errorf("invalid date '%s': must be YYYY-MM-DD format", date)
358
+
}
359
+
return nil
360
+
}
361
+
362
+
// parseGitHubURL extracts the base GitHub URL from a git remote URL.
363
+
//
364
+
// Handles both HTTPS and SSH formats.
365
+
func parseGitHubURL(remoteURL string) string {
366
+
remoteURL = strings.TrimSuffix(remoteURL, ".git")
367
+
368
+
if strings.HasPrefix(remoteURL, "https://github.com/") {
369
+
return remoteURL
370
+
}
371
+
372
+
if parts, ok := strings.CutPrefix(remoteURL, "git@github.com:"); ok {
373
+
return "https://github.com/" + parts
374
+
}
375
+
return ""
376
+
}
377
+
378
+
// findSectionType converts a section title to its internal type.
379
+
func findSectionType(title string) string {
380
+
titleLower := strings.ToLower(strings.TrimSpace(title))
381
+
for typ, standardTitle := range sectionTitles {
382
+
if strings.ToLower(standardTitle) == titleLower {
383
+
return typ
384
+
}
385
+
}
386
+
return titleLower
387
+
}
388
+
389
+
// newEmptyChangelog creates a changelog with default header and empty versions.
390
+
func newEmptyChangelog() *Changelog {
391
+
return &Changelog{
392
+
Header: defaultHeader(),
393
+
Versions: []Version{},
394
+
Links: []string{},
395
+
}
396
+
}
397
+
398
+
// defaultHeader returns the standard Keep a Changelog header.
399
+
func defaultHeader() string {
400
+
return `# Changelog
401
+
402
+
All notable changes to this project will be documented in this file.
403
+
404
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
405
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).`
406
+
}
+536
internal/changelog/changelog_test.go
+536
internal/changelog/changelog_test.go
···
1
+
package changelog
2
+
3
+
import (
4
+
"os"
5
+
"path/filepath"
6
+
"strings"
7
+
"testing"
8
+
9
+
"github.com/stormlightlabs/git-storm/internal/changeset"
10
+
)
11
+
12
+
func TestParse(t *testing.T) {
13
+
tests := []struct {
14
+
name string
15
+
content string
16
+
wantVersionCount int
17
+
wantFirstVersion string
18
+
wantFirstDate string
19
+
}{
20
+
{
21
+
name: "empty file returns default header",
22
+
content: `# Changelog
23
+
24
+
All notable changes to this project will be documented in this file.`,
25
+
wantVersionCount: 0,
26
+
},
27
+
{
28
+
name: "single version with sections",
29
+
content: `# Changelog
30
+
31
+
## [1.0.0] - 2025-01-15
32
+
33
+
### Added
34
+
- New feature A
35
+
- New feature B
36
+
37
+
### Fixed
38
+
- Bug fix C
39
+
`,
40
+
wantVersionCount: 1,
41
+
wantFirstVersion: "1.0.0",
42
+
wantFirstDate: "2025-01-15",
43
+
},
44
+
{
45
+
name: "multiple versions",
46
+
content: `# Changelog
47
+
48
+
## [Unreleased]
49
+
50
+
## [1.2.0] - 2025-01-15
51
+
52
+
### Added
53
+
- Feature X
54
+
55
+
## [1.1.0] - 2025-01-10
56
+
57
+
### Fixed
58
+
- Bug Y
59
+
`,
60
+
wantVersionCount: 3,
61
+
wantFirstVersion: "Unreleased",
62
+
wantFirstDate: "Unreleased",
63
+
},
64
+
{
65
+
name: "version with comparison links",
66
+
content: `# Changelog
67
+
68
+
## [1.0.0] - 2025-01-15
69
+
70
+
### Added
71
+
- Feature A
72
+
73
+
[1.0.0]: https://github.com/user/repo/releases/tag/v1.0.0
74
+
`,
75
+
wantVersionCount: 1,
76
+
wantFirstVersion: "1.0.0",
77
+
wantFirstDate: "2025-01-15",
78
+
},
79
+
}
80
+
81
+
for _, tt := range tests {
82
+
t.Run(tt.name, func(t *testing.T) {
83
+
tmpDir := t.TempDir()
84
+
changelogPath := filepath.Join(tmpDir, "CHANGELOG.md")
85
+
86
+
if err := os.WriteFile(changelogPath, []byte(tt.content), 0644); err != nil {
87
+
t.Fatalf("Failed to write test file: %v", err)
88
+
}
89
+
90
+
changelog, err := Parse(changelogPath)
91
+
if err != nil {
92
+
t.Fatalf("Parse() error = %v", err)
93
+
}
94
+
95
+
if len(changelog.Versions) != tt.wantVersionCount {
96
+
t.Errorf("Version count = %d, want %d", len(changelog.Versions), tt.wantVersionCount)
97
+
}
98
+
99
+
if tt.wantVersionCount > 0 {
100
+
if changelog.Versions[0].Number != tt.wantFirstVersion {
101
+
t.Errorf("First version = %s, want %s", changelog.Versions[0].Number, tt.wantFirstVersion)
102
+
}
103
+
if changelog.Versions[0].Date != tt.wantFirstDate {
104
+
t.Errorf("First date = %s, want %s", changelog.Versions[0].Date, tt.wantFirstDate)
105
+
}
106
+
}
107
+
})
108
+
}
109
+
}
110
+
111
+
func TestParseNonExistent(t *testing.T) {
112
+
tmpDir := t.TempDir()
113
+
changelogPath := filepath.Join(tmpDir, "NONEXISTENT.md")
114
+
115
+
changelog, err := Parse(changelogPath)
116
+
if err != nil {
117
+
t.Fatalf("Parse() should not error on non-existent file: %v", err)
118
+
}
119
+
120
+
if len(changelog.Versions) != 0 {
121
+
t.Errorf("Empty changelog should have 0 versions, got %d", len(changelog.Versions))
122
+
}
123
+
124
+
if !strings.Contains(changelog.Header, "Keep a Changelog") {
125
+
t.Errorf("Default header should contain 'Keep a Changelog'")
126
+
}
127
+
}
128
+
129
+
func TestBuild(t *testing.T) {
130
+
tests := []struct {
131
+
name string
132
+
entries []changeset.Entry
133
+
version string
134
+
date string
135
+
wantSectionCnt int
136
+
wantFirstType string
137
+
wantBreaking bool
138
+
}{
139
+
{
140
+
name: "single entry",
141
+
entries: []changeset.Entry{
142
+
{Type: "added", Summary: "New feature"},
143
+
},
144
+
version: "1.0.0",
145
+
date: "2025-01-15",
146
+
wantSectionCnt: 1,
147
+
wantFirstType: "added",
148
+
wantBreaking: false,
149
+
},
150
+
{
151
+
name: "multiple types in correct order",
152
+
entries: []changeset.Entry{
153
+
{Type: "fixed", Summary: "Bug fix"},
154
+
{Type: "added", Summary: "New feature"},
155
+
{Type: "changed", Summary: "Updated API"},
156
+
},
157
+
version: "2.0.0",
158
+
date: "2025-01-20",
159
+
wantSectionCnt: 3,
160
+
wantFirstType: "added",
161
+
},
162
+
{
163
+
name: "entry with scope",
164
+
entries: []changeset.Entry{
165
+
{Type: "added", Scope: "cli", Summary: "New command"},
166
+
},
167
+
version: "1.1.0",
168
+
date: "2025-01-18",
169
+
wantSectionCnt: 1,
170
+
wantFirstType: "added",
171
+
},
172
+
{
173
+
name: "breaking change",
174
+
entries: []changeset.Entry{
175
+
{Type: "changed", Summary: "API change", Breaking: true},
176
+
},
177
+
version: "2.0.0",
178
+
date: "2025-02-01",
179
+
wantSectionCnt: 1,
180
+
wantFirstType: "changed",
181
+
wantBreaking: true,
182
+
},
183
+
}
184
+
185
+
for _, tt := range tests {
186
+
t.Run(tt.name, func(t *testing.T) {
187
+
version, err := Build(tt.entries, tt.version, tt.date)
188
+
if err != nil {
189
+
t.Fatalf("Build() error = %v", err)
190
+
}
191
+
192
+
if version.Number != tt.version {
193
+
t.Errorf("Version number = %s, want %s", version.Number, tt.version)
194
+
}
195
+
196
+
if version.Date != tt.date {
197
+
t.Errorf("Version date = %s, want %s", version.Date, tt.date)
198
+
}
199
+
200
+
if len(version.Sections) != tt.wantSectionCnt {
201
+
t.Errorf("Section count = %d, want %d", len(version.Sections), tt.wantSectionCnt)
202
+
}
203
+
204
+
if tt.wantSectionCnt > 0 {
205
+
if version.Sections[0].Type != tt.wantFirstType {
206
+
t.Errorf("First section type = %s, want %s", version.Sections[0].Type, tt.wantFirstType)
207
+
}
208
+
209
+
if tt.wantBreaking {
210
+
firstEntry := version.Sections[0].Entries[0]
211
+
if !strings.Contains(firstEntry, "**BREAKING:**") {
212
+
t.Errorf("Breaking change should have **BREAKING:** prefix, got: %s", firstEntry)
213
+
}
214
+
}
215
+
}
216
+
})
217
+
}
218
+
}
219
+
220
+
func TestBuildInvalidVersion(t *testing.T) {
221
+
entries := []changeset.Entry{{Type: "added", Summary: "Test"}}
222
+
223
+
invalidVersions := []string{
224
+
"v1.0.0",
225
+
"1.0",
226
+
"1.0.0.0",
227
+
"abc",
228
+
}
229
+
230
+
for _, version := range invalidVersions {
231
+
t.Run("invalid_version_"+version, func(t *testing.T) {
232
+
_, err := Build(entries, version, "2025-01-15")
233
+
if err == nil {
234
+
t.Errorf("Build() should error for invalid version %s", version)
235
+
}
236
+
})
237
+
}
238
+
}
239
+
240
+
func TestBuildInvalidDate(t *testing.T) {
241
+
entries := []changeset.Entry{{Type: "added", Summary: "Test"}}
242
+
243
+
invalidDates := []string{
244
+
"2025-13-01",
245
+
"2025-01-32",
246
+
"01-15-2025",
247
+
"2025/01/15",
248
+
"not-a-date",
249
+
}
250
+
251
+
for _, date := range invalidDates {
252
+
t.Run("invalid_date_"+date, func(t *testing.T) {
253
+
_, err := Build(entries, "1.0.0", date)
254
+
if err == nil {
255
+
t.Errorf("Build() should error for invalid date %s", date)
256
+
}
257
+
})
258
+
}
259
+
}
260
+
261
+
func TestMerge(t *testing.T) {
262
+
tests := []struct {
263
+
name string
264
+
existingVersions []Version
265
+
newVersion Version
266
+
wantPositionIndex int
267
+
}{
268
+
{
269
+
name: "merge into empty changelog",
270
+
existingVersions: []Version{},
271
+
newVersion: Version{Number: "1.0.0", Date: "2025-01-15"},
272
+
wantPositionIndex: 0,
273
+
},
274
+
{
275
+
name: "merge below unreleased",
276
+
existingVersions: []Version{
277
+
{Number: "Unreleased", Date: "Unreleased"},
278
+
{Number: "1.0.0", Date: "2025-01-10"},
279
+
},
280
+
newVersion: Version{Number: "1.1.0", Date: "2025-01-15"},
281
+
wantPositionIndex: 1,
282
+
},
283
+
{
284
+
name: "merge at top when no unreleased",
285
+
existingVersions: []Version{
286
+
{Number: "1.0.0", Date: "2025-01-10"},
287
+
},
288
+
newVersion: Version{Number: "1.1.0", Date: "2025-01-15"},
289
+
wantPositionIndex: 0,
290
+
},
291
+
}
292
+
293
+
for _, tt := range tests {
294
+
t.Run(tt.name, func(t *testing.T) {
295
+
changelog := &Changelog{
296
+
Versions: tt.existingVersions,
297
+
}
298
+
299
+
Merge(changelog, &tt.newVersion)
300
+
301
+
if changelog.Versions[tt.wantPositionIndex].Number != tt.newVersion.Number {
302
+
t.Errorf("Version at position %d = %s, want %s",
303
+
tt.wantPositionIndex,
304
+
changelog.Versions[tt.wantPositionIndex].Number,
305
+
tt.newVersion.Number)
306
+
}
307
+
})
308
+
}
309
+
}
310
+
311
+
func TestWrite(t *testing.T) {
312
+
tmpDir := t.TempDir()
313
+
changelogPath := filepath.Join(tmpDir, "CHANGELOG.md")
314
+
315
+
changelog := &Changelog{
316
+
Header: "# Changelog\n\nTest changelog",
317
+
Versions: []Version{
318
+
{
319
+
Number: "1.0.0",
320
+
Date: "2025-01-15",
321
+
Sections: []Section{
322
+
{
323
+
Type: "added",
324
+
Entries: []string{"New feature A", "New feature B"},
325
+
},
326
+
{
327
+
Type: "fixed",
328
+
Entries: []string{"Bug fix C"},
329
+
},
330
+
},
331
+
},
332
+
},
333
+
}
334
+
335
+
err := Write(changelogPath, changelog, tmpDir)
336
+
if err != nil {
337
+
t.Fatalf("Write() error = %v", err)
338
+
}
339
+
340
+
if _, err := os.Stat(changelogPath); os.IsNotExist(err) {
341
+
t.Fatalf("CHANGELOG.md was not created")
342
+
}
343
+
344
+
content, err := os.ReadFile(changelogPath)
345
+
if err != nil {
346
+
t.Fatalf("Failed to read CHANGELOG.md: %v", err)
347
+
}
348
+
349
+
contentStr := string(content)
350
+
351
+
if !strings.Contains(contentStr, "# Changelog") {
352
+
t.Errorf("Missing header")
353
+
}
354
+
if !strings.Contains(contentStr, "## [1.0.0] - 2025-01-15") {
355
+
t.Errorf("Missing version header")
356
+
}
357
+
if !strings.Contains(contentStr, "### Added") {
358
+
t.Errorf("Missing Added section")
359
+
}
360
+
if !strings.Contains(contentStr, "### Fixed") {
361
+
t.Errorf("Missing Fixed section")
362
+
}
363
+
if !strings.Contains(contentStr, "- New feature A") {
364
+
t.Errorf("Missing entry: New feature A")
365
+
}
366
+
if !strings.Contains(contentStr, "- Bug fix C") {
367
+
t.Errorf("Missing entry: Bug fix C")
368
+
}
369
+
}
370
+
371
+
func TestValidateVersion(t *testing.T) {
372
+
tests := []struct {
373
+
version string
374
+
wantErr bool
375
+
}{
376
+
{"1.0.0", false},
377
+
{"0.1.0", false},
378
+
{"10.20.30", false},
379
+
{"v1.0.0", true},
380
+
{"1.0", true},
381
+
{"1.0.0.0", true},
382
+
{"1.x.0", true},
383
+
{"", true},
384
+
}
385
+
386
+
for _, tt := range tests {
387
+
t.Run(tt.version, func(t *testing.T) {
388
+
err := ValidateVersion(tt.version)
389
+
if (err != nil) != tt.wantErr {
390
+
t.Errorf("ValidateVersion(%s) error = %v, wantErr %v", tt.version, err, tt.wantErr)
391
+
}
392
+
})
393
+
}
394
+
}
395
+
396
+
func TestValidateDate(t *testing.T) {
397
+
tests := []struct {
398
+
date string
399
+
wantErr bool
400
+
}{
401
+
{"2025-01-15", false},
402
+
{"2024-12-31", false},
403
+
{"2025-13-01", true},
404
+
{"2025-01-32", true},
405
+
{"01-15-2025", true},
406
+
{"2025/01/15", true},
407
+
{"not-a-date", true},
408
+
{"", true},
409
+
}
410
+
411
+
for _, tt := range tests {
412
+
t.Run(tt.date, func(t *testing.T) {
413
+
err := ValidateDate(tt.date)
414
+
if (err != nil) != tt.wantErr {
415
+
t.Errorf("ValidateDate(%s) error = %v, wantErr %v", tt.date, err, tt.wantErr)
416
+
}
417
+
})
418
+
}
419
+
}
420
+
421
+
func TestParseGitHubURL(t *testing.T) {
422
+
tests := []struct {
423
+
name string
424
+
remoteURL string
425
+
want string
426
+
}{
427
+
{
428
+
name: "https format",
429
+
remoteURL: "https://github.com/user/repo.git",
430
+
want: "https://github.com/user/repo",
431
+
},
432
+
{
433
+
name: "https without .git",
434
+
remoteURL: "https://github.com/user/repo",
435
+
want: "https://github.com/user/repo",
436
+
},
437
+
{
438
+
name: "ssh format",
439
+
remoteURL: "git@github.com:user/repo.git",
440
+
want: "https://github.com/user/repo",
441
+
},
442
+
{
443
+
name: "ssh without .git",
444
+
remoteURL: "git@github.com:user/repo",
445
+
want: "https://github.com/user/repo",
446
+
},
447
+
{
448
+
name: "non-github url",
449
+
remoteURL: "https://gitlab.com/user/repo.git",
450
+
want: "",
451
+
},
452
+
}
453
+
454
+
for _, tt := range tests {
455
+
t.Run(tt.name, func(t *testing.T) {
456
+
got := parseGitHubURL(tt.remoteURL)
457
+
if got != tt.want {
458
+
t.Errorf("parseGitHubURL(%s) = %s, want %s", tt.remoteURL, got, tt.want)
459
+
}
460
+
})
461
+
}
462
+
}
463
+
464
+
func TestSectionOrdering(t *testing.T) {
465
+
entries := []changeset.Entry{
466
+
{Type: "security", Summary: "Security fix"},
467
+
{Type: "removed", Summary: "Removed feature"},
468
+
{Type: "fixed", Summary: "Bug fix"},
469
+
{Type: "changed", Summary: "Changed behavior"},
470
+
{Type: "added", Summary: "New feature"},
471
+
}
472
+
473
+
version, err := Build(entries, "1.0.0", "2025-01-15")
474
+
if err != nil {
475
+
t.Fatalf("Build() error = %v", err)
476
+
}
477
+
478
+
expectedOrder := []string{"added", "changed", "removed", "fixed", "security"}
479
+
if len(version.Sections) != len(expectedOrder) {
480
+
t.Fatalf("Expected %d sections, got %d", len(expectedOrder), len(version.Sections))
481
+
}
482
+
483
+
for i, expectedType := range expectedOrder {
484
+
if version.Sections[i].Type != expectedType {
485
+
t.Errorf("Section %d: got type %s, want %s", i, version.Sections[i].Type, expectedType)
486
+
}
487
+
}
488
+
}
489
+
490
+
func TestEntrySorting(t *testing.T) {
491
+
entries := []changeset.Entry{
492
+
{Type: "added", Summary: "Zebra feature"},
493
+
{Type: "added", Summary: "Apple feature"},
494
+
{Type: "added", Summary: "Mango feature"},
495
+
}
496
+
497
+
version, err := Build(entries, "1.0.0", "2025-01-15")
498
+
if err != nil {
499
+
t.Fatalf("Build() error = %v", err)
500
+
}
501
+
502
+
if len(version.Sections) != 1 {
503
+
t.Fatalf("Expected 1 section, got %d", len(version.Sections))
504
+
}
505
+
506
+
sortedEntries := version.Sections[0].Entries
507
+
if len(sortedEntries) != 3 {
508
+
t.Fatalf("Expected 3 entries, got %d", len(sortedEntries))
509
+
}
510
+
511
+
if !strings.Contains(sortedEntries[0], "Apple") {
512
+
t.Errorf("First entry should contain 'Apple', got: %s", sortedEntries[0])
513
+
}
514
+
if !strings.Contains(sortedEntries[1], "Mango") {
515
+
t.Errorf("Second entry should contain 'Mango', got: %s", sortedEntries[1])
516
+
}
517
+
if !strings.Contains(sortedEntries[2], "Zebra") {
518
+
t.Errorf("Third entry should contain 'Zebra', got: %s", sortedEntries[2])
519
+
}
520
+
}
521
+
522
+
func TestScopeFormatting(t *testing.T) {
523
+
entries := []changeset.Entry{
524
+
{Type: "added", Scope: "cli", Summary: "New command"},
525
+
}
526
+
527
+
version, err := Build(entries, "1.0.0", "2025-01-15")
528
+
if err != nil {
529
+
t.Fatalf("Build() error = %v", err)
530
+
}
531
+
532
+
entry := version.Sections[0].Entries[0]
533
+
if !strings.Contains(entry, "**cli:**") {
534
+
t.Errorf("Entry should contain formatted scope, got: %s", entry)
535
+
}
536
+
}
+271
-4
internal/changeset/changeset.go
+271
-4
internal/changeset/changeset.go
···
40
40
41
41
import (
42
42
"bytes"
43
+
"context"
44
+
"crypto/sha256"
45
+
"encoding/hex"
46
+
"encoding/json"
43
47
"fmt"
44
48
"os"
45
49
"path/filepath"
46
50
"regexp"
51
+
"sort"
47
52
"strings"
48
53
"time"
49
54
55
+
"github.com/go-git/go-git/v6/plumbing/object"
50
56
"github.com/goccy/go-yaml"
51
57
)
52
58
53
59
// Entry represents a single changelog entry to be written to .changes/*.md
54
60
type Entry struct {
55
-
Type string `yaml:"type"` // added, changed, fixed, removed, security
56
-
Scope string `yaml:"scope"` // optional scope
57
-
Summary string `yaml:"summary"` // description
58
-
Breaking bool `yaml:"breaking"` // true if breaking change
61
+
Type string `yaml:"type"` // added, changed, fixed, removed, security
62
+
Scope string `yaml:"scope"` // optional scope
63
+
Summary string `yaml:"summary"` // description
64
+
Breaking bool `yaml:"breaking"` // true if breaking change
65
+
CommitHash string `yaml:"commit_hash,omitempty"` // source commit hash (for reference)
66
+
DiffHash string `yaml:"diff_hash,omitempty"` // hash of git diff content (for deduplication)
67
+
}
68
+
69
+
// Metadata stores complete entry information in .changes/data/*.json for deduplication
70
+
type Metadata struct {
71
+
CommitHash string `json:"commit_hash"` // current commit hash
72
+
DiffHash string `json:"diff_hash"` // stable diff content hash
73
+
Filename string `json:"filename"` // relative path to .md file
74
+
Type string `json:"type"`
75
+
Scope string `json:"scope"`
76
+
Summary string `json:"summary"`
77
+
Breaking bool `json:"breaking"`
78
+
Author string `json:"author"`
79
+
Date time.Time `json:"date"`
59
80
}
60
81
61
82
// Write creates a new .changes/<timestamp>-<slug>.md file with YAML frontmatter.
···
94
115
return filePath, nil
95
116
}
96
117
118
+
// WritePartial creates a .changes/<filename> file with the specified name and YAML frontmatter.
119
+
// This is used by the `unreleased partial` command to create entries with commit-hash based names.
120
+
// Creates the .changes directory if it doesn't exist.
121
+
func WritePartial(dir string, filename string, entry Entry) (string, error) {
122
+
if err := os.MkdirAll(dir, 0755); err != nil {
123
+
return "", fmt.Errorf("failed to create directory %s: %w", dir, err)
124
+
}
125
+
126
+
filePath := filepath.Join(dir, filename)
127
+
128
+
if _, err := os.Stat(filePath); err == nil {
129
+
return "", fmt.Errorf("file %s already exists", filename)
130
+
}
131
+
132
+
yamlBytes, err := yaml.Marshal(entry)
133
+
if err != nil {
134
+
return "", fmt.Errorf("failed to marshal entry to YAML: %w", err)
135
+
}
136
+
137
+
content := fmt.Sprintf("---\n%s---\n", string(yamlBytes))
138
+
139
+
if err := os.WriteFile(filePath, []byte(content), 0644); err != nil {
140
+
return "", fmt.Errorf("failed to write file %s: %w", filePath, err)
141
+
}
142
+
143
+
return filePath, nil
144
+
}
145
+
146
+
// WriteWithMetadata creates a new .changes/<diffHash7>-<slug>.md file with YAML
147
+
// frontmatter and saves corresponding metadata to .changes/data/<diffHash>.json.
148
+
//
149
+
// The filename uses the first 7 characters of the diff hash for human-readable
150
+
// identification, while the JSON metadata file uses the full hash for
151
+
// deduplication lookups.
152
+
func WriteWithMetadata(dir string, meta Metadata) (string, error) {
153
+
if err := os.MkdirAll(dir, 0755); err != nil {
154
+
return "", fmt.Errorf("failed to create directory %s: %w", dir, err)
155
+
}
156
+
157
+
diffHashShort := meta.DiffHash[:7]
158
+
slug := slugify(meta.Summary)
159
+
filename := fmt.Sprintf("%s-%s.md", diffHashShort, slug)
160
+
filePath := filepath.Join(dir, filename)
161
+
162
+
entry := Entry{
163
+
Type: meta.Type,
164
+
Scope: meta.Scope,
165
+
Summary: meta.Summary,
166
+
Breaking: meta.Breaking,
167
+
CommitHash: meta.CommitHash,
168
+
DiffHash: meta.DiffHash,
169
+
}
170
+
171
+
yamlBytes, err := yaml.Marshal(entry)
172
+
if err != nil {
173
+
return "", fmt.Errorf("failed to marshal entry to YAML: %w", err)
174
+
}
175
+
176
+
content := fmt.Sprintf("---\n%s---\n", string(yamlBytes))
177
+
if err := os.WriteFile(filePath, []byte(content), 0644); err != nil {
178
+
return "", fmt.Errorf("failed to write file %s: %w", filePath, err)
179
+
}
180
+
181
+
meta.Filename = filename
182
+
if err := SaveMetadata(dir, meta); err != nil {
183
+
return "", fmt.Errorf("failed to save metadata: %w", err)
184
+
}
185
+
return filePath, nil
186
+
}
187
+
97
188
// slugify converts a string into a URL-friendly slug by converting to lowercase,
98
189
// replaces spaces and special chars with hyphens.
99
190
func slugify(input string) string {
···
170
261
171
262
return entry, nil
172
263
}
264
+
265
+
// ComputeDiffHash calculates a stable hash of the commit's diff content. This
266
+
// hash is independent of the commit hash, so rebased commits with identical
267
+
// diffs will produce the same hash.
268
+
//
269
+
// The hash is computed from:
270
+
// - Sorted list of changed file paths
271
+
// - For each file: the full diff content (additions and deletions)
272
+
func ComputeDiffHash(commit *object.Commit) (string, error) {
273
+
tree, err := commit.Tree()
274
+
if err != nil {
275
+
return "", fmt.Errorf("failed to get commit tree: %w", err)
276
+
}
277
+
278
+
var parentTree *object.Tree
279
+
if commit.NumParents() > 0 {
280
+
parent, err := commit.Parent(0)
281
+
if err != nil {
282
+
return "", fmt.Errorf("failed to get parent commit: %w", err)
283
+
}
284
+
parentTree, err = parent.Tree()
285
+
if err != nil {
286
+
return "", fmt.Errorf("failed to get parent tree: %w", err)
287
+
}
288
+
}
289
+
290
+
var changes object.Changes
291
+
if parentTree != nil {
292
+
changes, err = parentTree.Diff(tree)
293
+
if err != nil {
294
+
return "", fmt.Errorf("failed to compute diff: %w", err)
295
+
}
296
+
} else {
297
+
emptyTree := &object.Tree{}
298
+
changes, err = object.DiffTreeWithOptions(context.TODO(), emptyTree, tree, &object.DiffTreeOptions{})
299
+
if err != nil {
300
+
return "", fmt.Errorf("failed to compute diff for initial commit: %w", err)
301
+
}
302
+
}
303
+
304
+
var diffParts []string
305
+
for _, change := range changes {
306
+
patch, err := change.Patch()
307
+
if err != nil {
308
+
return "", fmt.Errorf("failed to get patch for %s: %w", change.To.Name, err)
309
+
}
310
+
311
+
diffParts = append(diffParts, fmt.Sprintf("FILE:%s\n%s", change.To.Name, patch.String()))
312
+
}
313
+
314
+
sort.Strings(diffParts)
315
+
316
+
hasher := sha256.New()
317
+
for _, part := range diffParts {
318
+
hasher.Write([]byte(part))
319
+
}
320
+
321
+
return hex.EncodeToString(hasher.Sum(nil)), nil
322
+
}
323
+
324
+
// SaveMetadata writes metadata to .changes/data/<diffHash>.json
325
+
func SaveMetadata(dir string, meta Metadata) error {
326
+
dataDir := filepath.Join(dir, "data")
327
+
if err := os.MkdirAll(dataDir, 0755); err != nil {
328
+
return fmt.Errorf("failed to create data directory: %w", err)
329
+
}
330
+
331
+
filePath := filepath.Join(dataDir, meta.DiffHash+".json")
332
+
data, err := json.MarshalIndent(meta, "", " ")
333
+
if err != nil {
334
+
return fmt.Errorf("failed to marshal metadata: %w", err)
335
+
}
336
+
337
+
if err := os.WriteFile(filePath, data, 0644); err != nil {
338
+
return fmt.Errorf("failed to write metadata file: %w", err)
339
+
}
340
+
341
+
return nil
342
+
}
343
+
344
+
// LoadExistingMetadata reads all metadata files from .changes/data/*.json
345
+
// and creates a map of diff hash -> metadata for O(1) lookups.
346
+
func LoadExistingMetadata(dir string) (map[string]Metadata, error) {
347
+
dataDir := filepath.Join(dir, "data")
348
+
result := make(map[string]Metadata)
349
+
entries, err := os.ReadDir(dataDir)
350
+
if err != nil {
351
+
if os.IsNotExist(err) {
352
+
return result, nil
353
+
}
354
+
return nil, fmt.Errorf("failed to read data directory: %w", err)
355
+
}
356
+
357
+
for _, entry := range entries {
358
+
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
359
+
continue
360
+
}
361
+
362
+
filePath := filepath.Join(dataDir, entry.Name())
363
+
data, err := os.ReadFile(filePath)
364
+
if err != nil {
365
+
return nil, fmt.Errorf("failed to read metadata file %s: %w", entry.Name(), err)
366
+
}
367
+
368
+
var meta Metadata
369
+
if err := json.Unmarshal(data, &meta); err != nil {
370
+
return nil, fmt.Errorf("failed to unmarshal metadata from %s: %w", entry.Name(), err)
371
+
}
372
+
373
+
result[meta.DiffHash] = meta
374
+
}
375
+
return result, nil
376
+
}
377
+
378
+
// UpdateMetadata updates an existing metadata file with a new commit hash when
379
+
// a rebased commit is detected (same diff, different commit hash).
380
+
func UpdateMetadata(dir string, diffHash string, newCommitHash string) error {
381
+
dataDir := filepath.Join(dir, "data")
382
+
filePath := filepath.Join(dataDir, diffHash+".json")
383
+
data, err := os.ReadFile(filePath)
384
+
if err != nil {
385
+
return fmt.Errorf("failed to read existing metadata: %w", err)
386
+
}
387
+
388
+
var meta Metadata
389
+
if err := json.Unmarshal(data, &meta); err != nil {
390
+
return fmt.Errorf("failed to unmarshal metadata: %w", err)
391
+
}
392
+
393
+
meta.CommitHash = newCommitHash
394
+
395
+
updatedData, err := json.MarshalIndent(meta, "", " ")
396
+
if err != nil {
397
+
return fmt.Errorf("failed to marshal updated metadata: %w", err)
398
+
}
399
+
400
+
if err := os.WriteFile(filePath, updatedData, 0644); err != nil {
401
+
return fmt.Errorf("failed to write updated metadata: %w", err)
402
+
}
403
+
return nil
404
+
}
405
+
406
+
// Delete removes a changelog entry file from the .changes/ directory.
407
+
func Delete(dir, filename string) error {
408
+
filePath := filepath.Join(dir, filename)
409
+
if _, err := os.Stat(filePath); os.IsNotExist(err) {
410
+
return fmt.Errorf("file %s does not exist", filename)
411
+
}
412
+
413
+
if err := os.Remove(filePath); err != nil {
414
+
return fmt.Errorf("failed to delete file %s: %w", filename, err)
415
+
}
416
+
return nil
417
+
}
418
+
419
+
// Update modifies an existing changelog entry file with new values.
420
+
func Update(dir, filename string, entry Entry) error {
421
+
filePath := filepath.Join(dir, filename)
422
+
423
+
if _, err := os.Stat(filePath); os.IsNotExist(err) {
424
+
return fmt.Errorf("file %s does not exist", filename)
425
+
}
426
+
427
+
yamlBytes, err := yaml.Marshal(entry)
428
+
if err != nil {
429
+
return fmt.Errorf("failed to marshal entry to YAML: %w", err)
430
+
}
431
+
432
+
content := fmt.Sprintf("---\n%s---\n", string(yamlBytes))
433
+
434
+
if err := os.WriteFile(filePath, []byte(content), 0644); err != nil {
435
+
return fmt.Errorf("failed to update file %s: %w", filename, err)
436
+
}
437
+
438
+
return nil
439
+
}
+573
-1
internal/changeset/changeset_test.go
+573
-1
internal/changeset/changeset_test.go
···
1
1
package changeset
2
2
3
3
import (
4
+
"encoding/json"
4
5
"os"
5
6
"path/filepath"
6
7
"strings"
7
8
"testing"
9
+
"time"
8
10
9
11
"github.com/goccy/go-yaml"
12
+
"github.com/stormlightlabs/git-storm/internal/testutils"
10
13
)
11
14
12
15
func TestWrite(t *testing.T) {
13
16
tmpDir := t.TempDir()
14
-
15
17
tests := []struct {
16
18
name string
17
19
entry Entry
···
200
202
t.Errorf("File was not created: %s", filePath)
201
203
}
202
204
}
205
+
206
+
func TestComputeDiffHash_Stability(t *testing.T) {
207
+
repo := testutils.SetupTestRepo(t)
208
+
commits := testutils.GetCommitHistory(t, repo)
209
+
210
+
if len(commits) == 0 {
211
+
t.Fatal("Expected at least one commit in test repo")
212
+
}
213
+
214
+
commit := commits[0]
215
+
hash1, err := ComputeDiffHash(commit)
216
+
if err != nil {
217
+
t.Fatalf("ComputeDiffHash() error = %v", err)
218
+
}
219
+
220
+
hash2, err := ComputeDiffHash(commit)
221
+
if err != nil {
222
+
t.Fatalf("ComputeDiffHash() second call error = %v", err)
223
+
}
224
+
225
+
testutils.Expect.Equal(t, hash1, hash2, "Diff hash should be stable across multiple calls")
226
+
testutils.Expect.Equal(t, len(hash1), 64, "Diff hash should be 64 characters (SHA256 hex)")
227
+
}
228
+
229
+
func TestComputeDiffHash_DifferentCommits(t *testing.T) {
230
+
repo := testutils.SetupTestRepo(t)
231
+
232
+
testutils.AddCommit(t, repo, "file1.txt", "content1", "Add file1")
233
+
testutils.AddCommit(t, repo, "file2.txt", "content2", "Add file2")
234
+
235
+
commits := testutils.GetCommitHistory(t, repo)
236
+
if len(commits) < 2 {
237
+
t.Fatal("Expected at least 2 commits")
238
+
}
239
+
240
+
hash1, err := ComputeDiffHash(commits[0])
241
+
if err != nil {
242
+
t.Fatalf("ComputeDiffHash() for commit 1 error = %v", err)
243
+
}
244
+
245
+
hash2, err := ComputeDiffHash(commits[1])
246
+
if err != nil {
247
+
t.Fatalf("ComputeDiffHash() for commit 2 error = %v", err)
248
+
}
249
+
250
+
testutils.Expect.NotEqual(t, hash1, hash2, "Different commits should have different diff hashes")
251
+
}
252
+
253
+
func TestWriteWithMetadata(t *testing.T) {
254
+
tmpDir := t.TempDir()
255
+
256
+
meta := Metadata{
257
+
CommitHash: "abc123def456",
258
+
DiffHash: "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
259
+
Type: "added",
260
+
Scope: "cli",
261
+
Summary: "Add new feature",
262
+
Breaking: false,
263
+
Author: "Test User",
264
+
Date: time.Now(),
265
+
Filename: "",
266
+
}
267
+
268
+
filePath, err := WriteWithMetadata(tmpDir, meta)
269
+
if err != nil {
270
+
t.Fatalf("WriteWithMetadata() error = %v", err)
271
+
}
272
+
273
+
testutils.Expect.True(t, strings.HasSuffix(filePath, ".md"), "File path should have .md extension")
274
+
if _, err := os.Stat(filePath); os.IsNotExist(err) {
275
+
t.Errorf("Markdown file was not created: %s", filePath)
276
+
}
277
+
278
+
filename := filepath.Base(filePath)
279
+
testutils.Expect.True(t, strings.HasPrefix(filename, meta.DiffHash[:7]), "Filename should start with first 7 chars of diff hash")
280
+
281
+
content, err := os.ReadFile(filePath)
282
+
if err != nil {
283
+
t.Fatalf("Failed to read markdown file: %v", err)
284
+
}
285
+
286
+
var parsedEntry Entry
287
+
parts := strings.SplitN(string(content), "---\n", 3)
288
+
if len(parts) < 3 {
289
+
t.Fatal("Invalid YAML frontmatter format")
290
+
}
291
+
292
+
if err := yaml.Unmarshal([]byte(parts[1]), &parsedEntry); err != nil {
293
+
t.Fatalf("Failed to parse YAML: %v", err)
294
+
}
295
+
296
+
testutils.Expect.Equal(t, parsedEntry.Type, meta.Type)
297
+
testutils.Expect.Equal(t, parsedEntry.Summary, meta.Summary)
298
+
testutils.Expect.Equal(t, parsedEntry.CommitHash, meta.CommitHash)
299
+
testutils.Expect.Equal(t, parsedEntry.DiffHash, meta.DiffHash)
300
+
301
+
jsonPath := filepath.Join(tmpDir, "data", meta.DiffHash+".json")
302
+
if _, err := os.Stat(jsonPath); os.IsNotExist(err) {
303
+
t.Errorf("JSON metadata file was not created: %s", jsonPath)
304
+
}
305
+
306
+
jsonContent, err := os.ReadFile(jsonPath)
307
+
if err != nil {
308
+
t.Fatalf("Failed to read JSON metadata: %v", err)
309
+
}
310
+
311
+
var parsedMeta Metadata
312
+
if err := json.Unmarshal(jsonContent, &parsedMeta); err != nil {
313
+
t.Fatalf("Failed to parse JSON metadata: %v", err)
314
+
}
315
+
316
+
testutils.Expect.Equal(t, parsedMeta.CommitHash, meta.CommitHash)
317
+
testutils.Expect.Equal(t, parsedMeta.DiffHash, meta.DiffHash)
318
+
testutils.Expect.Equal(t, parsedMeta.Type, meta.Type)
319
+
testutils.Expect.Equal(t, parsedMeta.Summary, meta.Summary)
320
+
}
321
+
322
+
func TestLoadExistingMetadata(t *testing.T) {
323
+
tmpDir := t.TempDir()
324
+
325
+
meta1 := Metadata{
326
+
CommitHash: "abc123",
327
+
DiffHash: "hash1111111111111111111111111111111111111111111111111111111111111",
328
+
Type: "added",
329
+
Summary: "Feature 1",
330
+
Author: "User1",
331
+
Date: time.Now(),
332
+
}
333
+
334
+
meta2 := Metadata{
335
+
CommitHash: "def456",
336
+
DiffHash: "hash2222222222222222222222222222222222222222222222222222222222222",
337
+
Type: "fixed",
338
+
Summary: "Fix 1",
339
+
Author: "User2",
340
+
Date: time.Now(),
341
+
}
342
+
343
+
_, err := WriteWithMetadata(tmpDir, meta1)
344
+
if err != nil {
345
+
t.Fatalf("Failed to write meta1: %v", err)
346
+
}
347
+
348
+
_, err = WriteWithMetadata(tmpDir, meta2)
349
+
if err != nil {
350
+
t.Fatalf("Failed to write meta2: %v", err)
351
+
}
352
+
353
+
loaded, err := LoadExistingMetadata(tmpDir)
354
+
if err != nil {
355
+
t.Fatalf("LoadExistingMetadata() error = %v", err)
356
+
}
357
+
358
+
testutils.Expect.Equal(t, len(loaded), 2, "Should load 2 metadata entries")
359
+
360
+
if m, exists := loaded[meta1.DiffHash]; exists {
361
+
testutils.Expect.Equal(t, m.CommitHash, meta1.CommitHash)
362
+
testutils.Expect.Equal(t, m.Type, meta1.Type)
363
+
} else {
364
+
t.Errorf("meta1 not found in loaded metadata")
365
+
}
366
+
367
+
if m, exists := loaded[meta2.DiffHash]; exists {
368
+
testutils.Expect.Equal(t, m.CommitHash, meta2.CommitHash)
369
+
testutils.Expect.Equal(t, m.Type, meta2.Type)
370
+
} else {
371
+
t.Errorf("meta2 not found in loaded metadata")
372
+
}
373
+
}
374
+
375
+
func TestLoadExistingMetadata_EmptyDirectory(t *testing.T) {
376
+
tmpDir := t.TempDir()
377
+
378
+
loaded, err := LoadExistingMetadata(tmpDir)
379
+
if err != nil {
380
+
t.Fatalf("LoadExistingMetadata() error = %v", err)
381
+
}
382
+
383
+
testutils.Expect.Equal(t, len(loaded), 0, "Should return empty map for non-existent data directory")
384
+
}
385
+
386
+
func TestUpdateMetadata(t *testing.T) {
387
+
tmpDir := t.TempDir()
388
+
389
+
meta := Metadata{
390
+
CommitHash: "original123",
391
+
DiffHash: "diffhash111111111111111111111111111111111111111111111111111111111",
392
+
Type: "added",
393
+
Summary: "Feature",
394
+
Author: "User",
395
+
Date: time.Now(),
396
+
}
397
+
398
+
_, err := WriteWithMetadata(tmpDir, meta)
399
+
if err != nil {
400
+
t.Fatalf("Failed to write metadata: %v", err)
401
+
}
402
+
403
+
newCommitHash := "rebased456"
404
+
err = UpdateMetadata(tmpDir, meta.DiffHash, newCommitHash)
405
+
if err != nil {
406
+
t.Fatalf("UpdateMetadata() error = %v", err)
407
+
}
408
+
409
+
loaded, err := LoadExistingMetadata(tmpDir)
410
+
if err != nil {
411
+
t.Fatalf("LoadExistingMetadata() error = %v", err)
412
+
}
413
+
414
+
updated, exists := loaded[meta.DiffHash]
415
+
if !exists {
416
+
t.Fatal("Updated metadata not found")
417
+
}
418
+
419
+
testutils.Expect.Equal(t, updated.CommitHash, newCommitHash, "CommitHash should be updated")
420
+
testutils.Expect.Equal(t, updated.Type, meta.Type, "Other fields should remain unchanged")
421
+
testutils.Expect.Equal(t, updated.Summary, meta.Summary, "Other fields should remain unchanged")
422
+
}
423
+
424
+
func TestDeduplication_SameCommit(t *testing.T) {
425
+
tmpDir := t.TempDir()
426
+
repo := testutils.SetupTestRepo(t)
427
+
commits := testutils.GetCommitHistory(t, repo)
428
+
if len(commits) == 0 {
429
+
t.Fatal("Expected at least one commit")
430
+
}
431
+
432
+
commit := commits[0]
433
+
diffHash, err := ComputeDiffHash(commit)
434
+
if err != nil {
435
+
t.Fatalf("ComputeDiffHash() error = %v", err)
436
+
}
437
+
438
+
meta := Metadata{
439
+
CommitHash: commit.Hash.String(),
440
+
DiffHash: diffHash,
441
+
Type: "added",
442
+
Summary: "Test feature",
443
+
Author: commit.Author.Name,
444
+
Date: commit.Author.When,
445
+
}
446
+
447
+
_, err = WriteWithMetadata(tmpDir, meta)
448
+
if err != nil {
449
+
t.Fatalf("First WriteWithMetadata() error = %v", err)
450
+
}
451
+
452
+
existing, err := LoadExistingMetadata(tmpDir)
453
+
if err != nil {
454
+
t.Fatalf("LoadExistingMetadata() error = %v", err)
455
+
}
456
+
457
+
if existingMeta, exists := existing[diffHash]; exists {
458
+
testutils.Expect.Equal(t, existingMeta.CommitHash, commit.Hash.String(), "Should detect exact duplicate")
459
+
} else {
460
+
t.Error("Metadata should exist in loaded entries")
461
+
}
462
+
}
463
+
464
+
func TestDeduplication_RebasedCommit(t *testing.T) {
465
+
tmpDir := t.TempDir()
466
+
repo := testutils.SetupTestRepo(t)
467
+
468
+
commits := testutils.GetCommitHistory(t, repo)
469
+
if len(commits) == 0 {
470
+
t.Fatal("Expected at least one commit")
471
+
}
472
+
473
+
commit := commits[0]
474
+
diffHash, err := ComputeDiffHash(commit)
475
+
if err != nil {
476
+
t.Fatalf("ComputeDiffHash() error = %v", err)
477
+
}
478
+
479
+
originalMeta := Metadata{
480
+
CommitHash: "original_commit_hash_123",
481
+
DiffHash: diffHash,
482
+
Type: "added",
483
+
Summary: "Test feature",
484
+
Author: commit.Author.Name,
485
+
Date: commit.Author.When,
486
+
}
487
+
488
+
_, err = WriteWithMetadata(tmpDir, originalMeta)
489
+
if err != nil {
490
+
t.Fatalf("WriteWithMetadata() error = %v", err)
491
+
}
492
+
493
+
existing, err := LoadExistingMetadata(tmpDir)
494
+
if err != nil {
495
+
t.Fatalf("LoadExistingMetadata() error = %v", err)
496
+
}
497
+
498
+
if existingMeta, exists := existing[diffHash]; exists {
499
+
if existingMeta.CommitHash != commit.Hash.String() {
500
+
err = UpdateMetadata(tmpDir, diffHash, commit.Hash.String())
501
+
if err != nil {
502
+
t.Fatalf("UpdateMetadata() error = %v", err)
503
+
}
504
+
505
+
updated, err := LoadExistingMetadata(tmpDir)
506
+
if err != nil {
507
+
t.Fatalf("LoadExistingMetadata() after update error = %v", err)
508
+
}
509
+
510
+
updatedMeta := updated[diffHash]
511
+
testutils.Expect.Equal(t, updatedMeta.CommitHash, commit.Hash.String(), "CommitHash should be updated for rebased commit")
512
+
}
513
+
}
514
+
}
515
+
516
+
func TestWritePartial(t *testing.T) {
517
+
tmpDir := t.TempDir()
518
+
519
+
tests := []struct {
520
+
name string
521
+
filename string
522
+
entry Entry
523
+
wantErr bool
524
+
wantType string
525
+
wantSummary string
526
+
}{
527
+
{
528
+
name: "basic partial entry",
529
+
filename: "abc1234.added.md",
530
+
entry: Entry{
531
+
Type: "added",
532
+
Scope: "cli",
533
+
Summary: "Add feature",
534
+
CommitHash: "abc123def456",
535
+
},
536
+
wantErr: false,
537
+
wantType: "added",
538
+
wantSummary: "Add feature",
539
+
},
540
+
{
541
+
name: "partial with different type",
542
+
filename: "def5678.fixed.md",
543
+
entry: Entry{
544
+
Type: "fixed",
545
+
Summary: "Fix bug",
546
+
CommitHash: "def5678abc",
547
+
},
548
+
wantErr: false,
549
+
wantType: "fixed",
550
+
wantSummary: "Fix bug",
551
+
},
552
+
}
553
+
554
+
for _, tt := range tests {
555
+
t.Run(tt.name, func(t *testing.T) {
556
+
filePath, err := WritePartial(tmpDir, tt.filename, tt.entry)
557
+
if (err != nil) != tt.wantErr {
558
+
t.Fatalf("WritePartial() error = %v, wantErr %v", err, tt.wantErr)
559
+
}
560
+
561
+
if tt.wantErr {
562
+
return
563
+
}
564
+
565
+
expectedPath := filepath.Join(tmpDir, tt.filename)
566
+
testutils.Expect.Equal(t, filePath, expectedPath, "File path should match expected")
567
+
568
+
if _, err := os.Stat(filePath); os.IsNotExist(err) {
569
+
t.Errorf("File was not created: %s", filePath)
570
+
}
571
+
572
+
content, err := os.ReadFile(filePath)
573
+
if err != nil {
574
+
t.Fatalf("Failed to read file: %v", err)
575
+
}
576
+
577
+
parts := strings.SplitN(string(content), "---\n", 3)
578
+
if len(parts) < 3 {
579
+
t.Fatal("Invalid YAML frontmatter format")
580
+
}
581
+
582
+
var parsed Entry
583
+
if err := yaml.Unmarshal([]byte(parts[1]), &parsed); err != nil {
584
+
t.Fatalf("Failed to parse YAML: %v", err)
585
+
}
586
+
587
+
testutils.Expect.Equal(t, parsed.Type, tt.wantType)
588
+
testutils.Expect.Equal(t, parsed.Summary, tt.wantSummary)
589
+
testutils.Expect.Equal(t, parsed.CommitHash, tt.entry.CommitHash)
590
+
})
591
+
}
592
+
}
593
+
594
+
func TestWritePartial_DuplicateFilename(t *testing.T) {
595
+
tmpDir := t.TempDir()
596
+
597
+
filename := "abc1234.added.md"
598
+
entry := Entry{
599
+
Type: "added",
600
+
Summary: "Test feature",
601
+
CommitHash: "abc1234",
602
+
}
603
+
604
+
_, err := WritePartial(tmpDir, filename, entry)
605
+
if err != nil {
606
+
t.Fatalf("First WritePartial() error = %v", err)
607
+
}
608
+
609
+
_, err = WritePartial(tmpDir, filename, entry)
610
+
if err == nil {
611
+
t.Error("Expected error when writing duplicate filename, got nil")
612
+
}
613
+
614
+
if !strings.Contains(err.Error(), "already exists") {
615
+
t.Errorf("Expected 'already exists' error, got: %v", err)
616
+
}
617
+
}
618
+
619
+
func TestDelete(t *testing.T) {
620
+
tmpDir := t.TempDir()
621
+
622
+
entry := Entry{
623
+
Type: "added",
624
+
Scope: "test",
625
+
Summary: "Test deletion",
626
+
}
627
+
628
+
filePath, err := Write(tmpDir, entry)
629
+
if err != nil {
630
+
t.Fatalf("Write() error = %v", err)
631
+
}
632
+
633
+
filename := filepath.Base(filePath)
634
+
635
+
if _, err := os.Stat(filePath); os.IsNotExist(err) {
636
+
t.Fatalf("File should exist before deletion: %s", filePath)
637
+
}
638
+
639
+
err = Delete(tmpDir, filename)
640
+
if err != nil {
641
+
t.Fatalf("Delete() error = %v", err)
642
+
}
643
+
644
+
if _, err := os.Stat(filePath); !os.IsNotExist(err) {
645
+
t.Errorf("File should not exist after deletion: %s", filePath)
646
+
}
647
+
}
648
+
649
+
func TestDelete_NonExistentFile(t *testing.T) {
650
+
tmpDir := t.TempDir()
651
+
652
+
err := Delete(tmpDir, "nonexistent.md")
653
+
if err == nil {
654
+
t.Error("Expected error when deleting non-existent file, got nil")
655
+
}
656
+
657
+
if !strings.Contains(err.Error(), "does not exist") {
658
+
t.Errorf("Expected 'does not exist' error, got: %v", err)
659
+
}
660
+
}
661
+
662
+
func TestUpdate(t *testing.T) {
663
+
tmpDir := t.TempDir()
664
+
665
+
originalEntry := Entry{
666
+
Type: "added",
667
+
Scope: "cli",
668
+
Summary: "Original summary",
669
+
}
670
+
671
+
filePath, err := Write(tmpDir, originalEntry)
672
+
if err != nil {
673
+
t.Fatalf("Write() error = %v", err)
674
+
}
675
+
676
+
filename := filepath.Base(filePath)
677
+
678
+
updatedEntry := Entry{
679
+
Type: "changed",
680
+
Scope: "api",
681
+
Summary: "Updated summary",
682
+
}
683
+
684
+
err = Update(tmpDir, filename, updatedEntry)
685
+
if err != nil {
686
+
t.Fatalf("Update() error = %v", err)
687
+
}
688
+
689
+
content, err := os.ReadFile(filePath)
690
+
if err != nil {
691
+
t.Fatalf("Failed to read updated file: %v", err)
692
+
}
693
+
694
+
parts := strings.SplitN(string(content), "---\n", 3)
695
+
if len(parts) < 3 {
696
+
t.Fatal("Invalid YAML frontmatter format")
697
+
}
698
+
699
+
var parsed Entry
700
+
if err := yaml.Unmarshal([]byte(parts[1]), &parsed); err != nil {
701
+
t.Fatalf("Failed to parse YAML: %v", err)
702
+
}
703
+
704
+
testutils.Expect.Equal(t, parsed.Type, updatedEntry.Type, "Type should be updated")
705
+
testutils.Expect.Equal(t, parsed.Scope, updatedEntry.Scope, "Scope should be updated")
706
+
testutils.Expect.Equal(t, parsed.Summary, updatedEntry.Summary, "Summary should be updated")
707
+
}
708
+
709
+
func TestUpdate_NonExistentFile(t *testing.T) {
710
+
tmpDir := t.TempDir()
711
+
712
+
entry := Entry{
713
+
Type: "added",
714
+
Summary: "Test",
715
+
}
716
+
717
+
err := Update(tmpDir, "nonexistent.md", entry)
718
+
if err == nil {
719
+
t.Error("Expected error when updating non-existent file, got nil")
720
+
}
721
+
722
+
if !strings.Contains(err.Error(), "does not exist") {
723
+
t.Errorf("Expected 'does not exist' error, got: %v", err)
724
+
}
725
+
}
726
+
727
+
func TestUpdate_PreserveMetadata(t *testing.T) {
728
+
tmpDir := t.TempDir()
729
+
730
+
originalEntry := Entry{
731
+
Type: "added",
732
+
Scope: "cli",
733
+
Summary: "Original",
734
+
Breaking: false,
735
+
CommitHash: "abc123",
736
+
DiffHash: "def456",
737
+
}
738
+
739
+
filePath, err := Write(tmpDir, originalEntry)
740
+
if err != nil {
741
+
t.Fatalf("Write() error = %v", err)
742
+
}
743
+
744
+
filename := filepath.Base(filePath)
745
+
746
+
updatedEntry := Entry{
747
+
Type: "changed",
748
+
Scope: "api",
749
+
Summary: "Updated",
750
+
Breaking: true,
751
+
CommitHash: "abc123",
752
+
DiffHash: "def456",
753
+
}
754
+
755
+
err = Update(tmpDir, filename, updatedEntry)
756
+
if err != nil {
757
+
t.Fatalf("Update() error = %v", err)
758
+
}
759
+
760
+
content, err := os.ReadFile(filePath)
761
+
if err != nil {
762
+
t.Fatalf("Failed to read updated file: %v", err)
763
+
}
764
+
765
+
parts := strings.SplitN(string(content), "---\n", 3)
766
+
var parsed Entry
767
+
if err := yaml.Unmarshal([]byte(parts[1]), &parsed); err != nil {
768
+
t.Fatalf("Failed to parse YAML: %v", err)
769
+
}
770
+
771
+
testutils.Expect.Equal(t, parsed.CommitHash, updatedEntry.CommitHash, "CommitHash should be preserved")
772
+
testutils.Expect.Equal(t, parsed.DiffHash, updatedEntry.DiffHash, "DiffHash should be preserved")
773
+
testutils.Expect.Equal(t, parsed.Breaking, updatedEntry.Breaking, "Breaking should be updated")
774
+
}
+15
-17
internal/diff/diff.go
+15
-17
internal/diff/diff.go
···
34
34
NewContent string // new content (only used for Replace operations)
35
35
}
36
36
37
+
type outputEdit struct {
38
+
edit Edit
39
+
origPosition int
40
+
}
41
+
42
+
type mergeInfo struct {
43
+
partnIndex int // index of the partner edit
44
+
isDelete bool
45
+
}
46
+
37
47
// Diff represents a generic diffing algorithm.
38
48
type Diff interface {
39
49
// Compute computes the edit operations needed to transform a into b.
···
367
377
return edits
368
378
}
369
379
370
-
type mergeInfo struct {
371
-
partnIndex int // index of the partner edit
372
-
isDelete bool
373
-
}
374
-
375
380
merged := make(map[int]mergeInfo)
376
381
const lookAheadWindow = 50
377
382
···
409
414
}
410
415
}
411
416
412
-
for i := 0; i < len(edits); i++ {
417
+
for i := range edits {
413
418
if _, exists := merged[i]; exists || edits[i].Kind != Insert {
414
419
continue
415
420
}
···
427
432
}
428
433
}
429
434
430
-
type outputEdit struct {
431
-
edit Edit
432
-
origPosition int
433
-
}
434
-
435
435
outputs := make([]outputEdit, 0, len(edits))
436
436
437
437
for i := range edits {
···
480
480
for _, out := range outputs {
481
481
result = append(result, out.edit)
482
482
}
483
-
484
483
return result
485
484
}
486
485
487
486
// areSimilarLines determines if two lines are similar enough to be considered a replacement.
488
487
//
489
488
// Uses a two-phase similarity check:
490
-
// 1. Common prefix must be at least 70% of the shorter line
491
-
// 2. Remaining suffixes must be at least 60% similar (Levenshtein-like check)
489
+
//
490
+
// 1. Common prefix must be at least 70% of the shorter line
491
+
// 2. Remaining suffixes must be at least 60% similar (Levenshtein-like check)
492
492
func areSimilarLines(a, b string) bool {
493
493
if a == b {
494
494
return true
···
501
501
}
502
502
503
503
commonPrefix := 0
504
-
for i := 0; i < minLen; i++ {
504
+
for i := range minLen {
505
505
if a[i] == b[i] {
506
506
commonPrefix++
507
507
} else {
···
530
530
}
531
531
532
532
maxSuffixLen := max(suffixLenB, suffixLenA)
533
-
534
533
if maxSuffixLen > 0 && float64(lenDiff)/float64(maxSuffixLen) > 0.3 {
535
534
return false
536
535
}
537
-
538
536
return true
539
537
}
+246
internal/diff/diff_test.go
+246
internal/diff/diff_test.go
···
432
432
})
433
433
}
434
434
}
435
+
436
+
func TestDiff_Compute_Unicode(t *testing.T) {
437
+
a := []string{"Emoji ๐", "Regular text"}
438
+
b := []string{"Emoji ๐", "Regular text"}
439
+
440
+
for _, alg := range diffAlgorithms {
441
+
t.Run(alg.name, func(t *testing.T) {
442
+
m := alg.new()
443
+
edits, err := m.Compute(a, b)
444
+
if err != nil {
445
+
t.Fatalf("unexpected error with unicode: %v", err)
446
+
}
447
+
448
+
reconstructed := ApplyEdits(a, edits)
449
+
if len(reconstructed) != len(b) {
450
+
t.Fatalf("reconstructed length %d != expected %d", len(reconstructed), len(b))
451
+
}
452
+
for i := range reconstructed {
453
+
if reconstructed[i] != b[i] {
454
+
t.Errorf("line %d: %q != %q", i, reconstructed[i], b[i])
455
+
}
456
+
}
457
+
})
458
+
}
459
+
}
460
+
461
+
func TestDiff_Compute_VeryLongLines(t *testing.T) {
462
+
longLine1 := strings.Repeat("a", 5000)
463
+
longLine2 := strings.Repeat("b", 5000)
464
+
longLine3 := strings.Repeat("c", 5000)
465
+
466
+
a := []string{longLine1, longLine2}
467
+
b := []string{longLine1, longLine3}
468
+
469
+
for _, alg := range diffAlgorithms {
470
+
t.Run(alg.name, func(t *testing.T) {
471
+
m := alg.new()
472
+
edits, err := m.Compute(a, b)
473
+
if err != nil {
474
+
t.Fatalf("unexpected error with long lines: %v", err)
475
+
}
476
+
477
+
reconstructed := ApplyEdits(a, edits)
478
+
if len(reconstructed) != len(b) {
479
+
t.Fatalf("reconstructed length %d != expected %d", len(reconstructed), len(b))
480
+
}
481
+
for i := range reconstructed {
482
+
if reconstructed[i] != b[i] {
483
+
t.Errorf("line %d: lengths %d != %d", i, len(reconstructed[i]), len(b[i]))
484
+
}
485
+
}
486
+
})
487
+
}
488
+
}
489
+
490
+
func TestDiff_Compute_WhitespaceOnly(t *testing.T) {
491
+
a := []string{"line1", " ", "line3"}
492
+
b := []string{"line1", " ", "line3"}
493
+
494
+
for _, alg := range diffAlgorithms {
495
+
t.Run(alg.name, func(t *testing.T) {
496
+
m := alg.new()
497
+
edits, err := m.Compute(a, b)
498
+
if err != nil {
499
+
t.Fatalf("unexpected error: %v", err)
500
+
}
501
+
502
+
reconstructed := ApplyEdits(a, edits)
503
+
if len(reconstructed) != len(b) {
504
+
t.Fatalf("reconstructed length %d != expected %d", len(reconstructed), len(b))
505
+
}
506
+
for i := range reconstructed {
507
+
if reconstructed[i] != b[i] {
508
+
t.Errorf("line %d: %q != %q", i, reconstructed[i], b[i])
509
+
}
510
+
}
511
+
})
512
+
}
513
+
}
514
+
515
+
func TestDiff_Compute_AlternatingLines(t *testing.T) {
516
+
a := []string{"a1", "a2", "a3", "a4", "a5"}
517
+
b := []string{"b1", "b2", "b3", "b4", "b5"}
518
+
519
+
for _, alg := range diffAlgorithms {
520
+
t.Run(alg.name, func(t *testing.T) {
521
+
m := alg.new()
522
+
edits, err := m.Compute(a, b)
523
+
if err != nil {
524
+
t.Fatalf("unexpected error: %v", err)
525
+
}
526
+
527
+
reconstructed := ApplyEdits(a, edits)
528
+
if len(reconstructed) != len(b) {
529
+
t.Fatalf("reconstructed length %d != expected %d", len(reconstructed), len(b))
530
+
}
531
+
for i := range reconstructed {
532
+
if reconstructed[i] != b[i] {
533
+
t.Errorf("line %d: %q != %q", i, reconstructed[i], b[i])
534
+
}
535
+
}
536
+
})
537
+
}
538
+
}
539
+
540
+
func TestDiff_CrossValidation(t *testing.T) {
541
+
testCases := []struct {
542
+
name string
543
+
a []string
544
+
b []string
545
+
}{
546
+
{"simple", []string{"a", "b", "c"}, []string{"a", "x", "c"}},
547
+
{"complex", []string{"1", "2", "3", "4"}, []string{"1", "x", "y", "4"}},
548
+
{"empty to content", []string{}, []string{"a", "b", "c"}},
549
+
{"content to empty", []string{"a", "b", "c"}, []string{}},
550
+
}
551
+
552
+
for _, tc := range testCases {
553
+
t.Run(tc.name, func(t *testing.T) {
554
+
lcs := &LCS{}
555
+
myers := &Myers{}
556
+
557
+
lcsEdits, err := lcs.Compute(tc.a, tc.b)
558
+
if err != nil {
559
+
t.Fatalf("LCS error: %v", err)
560
+
}
561
+
562
+
myersEdits, err := myers.Compute(tc.a, tc.b)
563
+
if err != nil {
564
+
t.Fatalf("Myers error: %v", err)
565
+
}
566
+
567
+
lcsResult := ApplyEdits(tc.a, lcsEdits)
568
+
myersResult := ApplyEdits(tc.a, myersEdits)
569
+
570
+
if len(lcsResult) != len(tc.b) {
571
+
t.Errorf("LCS reconstruction length mismatch: %d != %d", len(lcsResult), len(tc.b))
572
+
}
573
+
if len(myersResult) != len(tc.b) {
574
+
t.Errorf("Myers reconstruction length mismatch: %d != %d", len(myersResult), len(tc.b))
575
+
}
576
+
577
+
for i := range tc.b {
578
+
if i < len(lcsResult) && lcsResult[i] != tc.b[i] {
579
+
t.Errorf("LCS line %d: %q != %q", i, lcsResult[i], tc.b[i])
580
+
}
581
+
if i < len(myersResult) && myersResult[i] != tc.b[i] {
582
+
t.Errorf("Myers line %d: %q != %q", i, myersResult[i], tc.b[i])
583
+
}
584
+
}
585
+
})
586
+
}
587
+
}
588
+
589
+
func TestDiff_EditIndicesValid(t *testing.T) {
590
+
a := []string{"line1", "line2", "line3"}
591
+
b := []string{"line1", "modified", "line3", "line4"}
592
+
593
+
for _, alg := range diffAlgorithms {
594
+
t.Run(alg.name, func(t *testing.T) {
595
+
m := alg.new()
596
+
edits, err := m.Compute(a, b)
597
+
if err != nil {
598
+
t.Fatalf("unexpected error: %v", err)
599
+
}
600
+
601
+
for i, edit := range edits {
602
+
switch edit.Kind {
603
+
case Equal:
604
+
if edit.AIndex < 0 || edit.AIndex >= len(a) {
605
+
t.Errorf("edit %d: invalid AIndex %d (len(a)=%d)", i, edit.AIndex, len(a))
606
+
}
607
+
if edit.BIndex < 0 || edit.BIndex >= len(b) {
608
+
t.Errorf("edit %d: invalid BIndex %d (len(b)=%d)", i, edit.BIndex, len(b))
609
+
}
610
+
case Delete:
611
+
if edit.AIndex < 0 || edit.AIndex >= len(a) {
612
+
t.Errorf("edit %d: invalid AIndex %d for Delete", i, edit.AIndex)
613
+
}
614
+
case Insert:
615
+
if edit.BIndex < 0 || edit.BIndex >= len(b) {
616
+
t.Errorf("edit %d: invalid BIndex %d for Insert", i, edit.BIndex)
617
+
}
618
+
}
619
+
}
620
+
})
621
+
}
622
+
}
623
+
624
+
func BenchmarkLCS_SmallInput(b *testing.B) {
625
+
a := []string{"line1", "line2", "line3", "line4", "line5"}
626
+
c := []string{"line1", "modified", "line3", "line4", "added"}
627
+
lcs := &LCS{}
628
+
629
+
for b.Loop() {
630
+
_, _ = lcs.Compute(a, c)
631
+
}
632
+
}
633
+
634
+
func BenchmarkMyers_SmallInput(b *testing.B) {
635
+
a := []string{"line1", "line2", "line3", "line4", "line5"}
636
+
c := []string{"line1", "modified", "line3", "line4", "added"}
637
+
myers := &Myers{}
638
+
639
+
for b.Loop() {
640
+
_, _ = myers.Compute(a, c)
641
+
}
642
+
}
643
+
644
+
func BenchmarkLCS_MediumInput(b *testing.B) {
645
+
a := make([]string, 50)
646
+
c := make([]string, 50)
647
+
for i := range 50 {
648
+
a[i] = "line" + strings.Repeat("x", i)
649
+
if i%5 == 0 {
650
+
c[i] = "modified" + strings.Repeat("y", i)
651
+
} else {
652
+
c[i] = a[i]
653
+
}
654
+
}
655
+
656
+
lcs := &LCS{}
657
+
658
+
for b.Loop() {
659
+
_, _ = lcs.Compute(a, c)
660
+
}
661
+
}
662
+
663
+
func BenchmarkMyers_MediumInput(b *testing.B) {
664
+
a := make([]string, 50)
665
+
c := make([]string, 50)
666
+
for i := range 50 {
667
+
a[i] = "line" + strings.Repeat("x", i)
668
+
if i%5 == 0 {
669
+
c[i] = "modified" + strings.Repeat("y", i)
670
+
} else {
671
+
c[i] = a[i]
672
+
}
673
+
}
674
+
675
+
myers := &Myers{}
676
+
677
+
for b.Loop() {
678
+
_, _ = myers.Compute(a, c)
679
+
}
680
+
}
+4
internal/diff/format.go
+4
internal/diff/format.go
···
32
32
compressedIndicator = "โฎ"
33
33
)
34
34
35
+
type Formatter interface {
36
+
Format(edits []Edit) string
37
+
}
38
+
35
39
// SideBySideFormatter renders diff edits in a split-pane layout with syntax highlighting.
36
40
type SideBySideFormatter struct {
37
41
// TerminalWidth is the total available width for rendering
+587
internal/docs/README.md
+587
internal/docs/README.md
···
1
+
---
2
+
title: Testing Workflow
3
+
updated: 2025-11-08
4
+
version: 3
5
+
---
6
+
7
+
"Ride the lightning."
8
+
9
+
This document provides a comprehensive testing workflow for the `storm` changelog manager.
10
+
All tests should be run within this repository to validate functionality against real Git
11
+
history.
12
+
13
+
## Setup
14
+
15
+
```bash
16
+
# Build the CLI
17
+
task build
18
+
```
19
+
20
+
## Non-TTY Environment Handling
21
+
22
+
Storm automatically detects whether it's running in an interactive terminal (TTY) or a
23
+
non-interactive environment (CI pipelines, scripts, pipes). Commands gracefully degrade
24
+
or provide helpful error messages.
25
+
26
+
### TTY Detection
27
+
28
+
The CLI checks for:
29
+
30
+
- Terminal availability on stdin/stdout
31
+
- Common CI environment variables (GITHUB_ACTIONS, GITLAB_CI, CIRCLECI, etc.)
32
+
33
+
### Command Behavior
34
+
35
+
#### `generate --interactive`
36
+
37
+
**Interactive (TTY):** Launches TUI for commit selection
38
+
**Non-Interactive:** Returns error with suggestion to use non-interactive mode
39
+
40
+
```bash
41
+
# CI/Non-TTY
42
+
storm generate HEAD~5 HEAD --interactive
43
+
# Error: flag '--interactive' requires an interactive terminal (detected GitHub Actions environment)
44
+
```
45
+
46
+
**Workaround:**
47
+
48
+
```bash
49
+
# Use without --interactive flag for automatic processing
50
+
storm generate HEAD~5 HEAD
51
+
```
52
+
53
+
#### `unreleased review`
54
+
55
+
**Interactive (TTY):** Launches TUI for reviewing entries
56
+
**Non-Interactive:** Returns error with alternatives
57
+
58
+
```bash
59
+
# CI/Non-TTY
60
+
storm unreleased review
61
+
# Error: command 'storm unreleased review' requires an interactive terminal (detected CI environment)
62
+
#
63
+
# Alternatives:
64
+
# - Use 'storm unreleased list' to view entries in plain text
65
+
# - Use 'storm unreleased list --json' for JSON output
66
+
```
67
+
68
+
#### `diff`
69
+
70
+
**Interactive (TTY):** Launches TUI for navigating diffs
71
+
**Non-Interactive:** Outputs plain text diff to stdout
72
+
73
+
```bash
74
+
# CI/Non-TTY - automatically outputs plain text
75
+
storm diff HEAD~1 HEAD
76
+
# === File 1/3 ===
77
+
# --- HEAD~1:file.go
78
+
# +++ HEAD:file.go
79
+
# [plain text diff output]
80
+
```
81
+
82
+
### Testing Non-TTY Behavior
83
+
84
+
#### Simulate CI environment
85
+
86
+
```bash
87
+
CI=true storm unreleased review
88
+
# Should error with CI-friendly message
89
+
```
90
+
91
+
#### Pipe output
92
+
93
+
```bash
94
+
storm diff HEAD~1 HEAD | less
95
+
# Should output plain text diff (not TUI)
96
+
```
97
+
98
+
#### Redirect to file
99
+
100
+
```bash
101
+
storm diff HEAD~1 HEAD > changes.diff
102
+
# Should write plain text to file
103
+
```
104
+
105
+
**Expected Behaviors:**
106
+
107
+
- Clear error messages indicating TTY requirement
108
+
- Suggestions for alternative commands
109
+
- CI system name detection (e.g., "detected GitHub Actions environment")
110
+
- Automatic fallback to plain text for `diff` command
111
+
- No ANSI escape codes in piped/redirected output
112
+
113
+
## Core Workflow
114
+
115
+
### Manual Entry Creation (`unreleased add`)
116
+
117
+
Create entries manually without linking to commits.
118
+
119
+
#### Basic entry creation
120
+
121
+
```bash
122
+
storm unreleased add --type added --summary "Test manual entry"
123
+
```
124
+
125
+
**Expected:**
126
+
127
+
- Creates `.changes/<timestamp>-test-manual-entry.md`
128
+
- File contains YAML frontmatter with type and summary
129
+
- Styled success message displays created file path
130
+
131
+
#### Entry with scope
132
+
133
+
```bash
134
+
storm unreleased add --type fixed --scope api --summary "Fix authentication bug"
135
+
```
136
+
137
+
**Expected:**
138
+
139
+
- Includes `scope: api` in frontmatter
140
+
- Filename slugifies to `...-fix-authentication-bug.md`
141
+
142
+
#### Collision handling
143
+
144
+
```bash
145
+
# Run same command twice rapidly
146
+
storm unreleased add --type added --summary "Duplicate test"
147
+
storm unreleased add --type added --summary "Duplicate test"
148
+
```
149
+
150
+
**Expected:**
151
+
152
+
- Two different files created (second has `-1` suffix)
153
+
- Both files exist and are readable
154
+
155
+
**Edge Cases:**
156
+
157
+
- Invalid type (should error with helpful message)
158
+
- Missing required flags (should error)
159
+
- Very long summary (should truncate to 50 chars)
160
+
- Special characters in summary (should slugify correctly)
161
+
- Empty summary (should error)
162
+
163
+
### Commit-Linked Entry Creation (`unreleased partial`)
164
+
165
+
Create entries linked to specific commits with auto-detection.
166
+
167
+
#### Basic partial from commit
168
+
169
+
```bash
170
+
# Use a recent commit hash
171
+
storm unreleased partial HEAD
172
+
```
173
+
174
+
**Expected:**
175
+
176
+
- Auto-detects type from conventional commit format
177
+
- Creates `.changes/<sha7>.<type>.md`
178
+
- Includes `commit_hash` in frontmatter
179
+
- Shows styled success message
180
+
181
+
#### Override auto-detection
182
+
183
+
```bash
184
+
storm unreleased partial HEAD~1 --type fixed --summary "Custom summary"
185
+
```
186
+
187
+
**Expected:**
188
+
189
+
- Uses provided type instead of auto-detected
190
+
- Uses custom summary
191
+
- Preserves commit hash in frontmatter
192
+
193
+
#### Non-conventional commit
194
+
195
+
```bash
196
+
# Try a commit without conventional format
197
+
storm unreleased partial <old-commit>
198
+
```
199
+
200
+
**Expected:**
201
+
202
+
- Error message: "could not auto-detect change type"
203
+
- Suggests using `--type` flag
204
+
205
+
#### Duplicate prevention
206
+
207
+
```bash
208
+
storm unreleased partial HEAD
209
+
storm unreleased partial HEAD # Run again
210
+
```
211
+
212
+
**Expected:**
213
+
214
+
- Second command fails with "file already exists" error
215
+
216
+
**Edge Cases:**
217
+
218
+
- Invalid commit ref (should error)
219
+
- Merge commit (should handle gracefully)
220
+
- Initial commit with no parent (should work)
221
+
- Commit with multi-line message (should parse correctly)
222
+
- Commit with breaking change marker (should set `breaking: true`)
223
+
224
+
### Listing Entries (`unreleased list`)
225
+
226
+
Display all unreleased changes.
227
+
228
+
#### Text output
229
+
230
+
```bash
231
+
storm unreleased list
232
+
```
233
+
234
+
**Expected:**
235
+
236
+
- Color-coded type labels ([added], [fixed], etc.)
237
+
- Shows scope if present
238
+
- Displays filename
239
+
- Shows breaking change indicator if applicable
240
+
- Empty state message if no entries
241
+
242
+
#### JSON output
243
+
244
+
```bash
245
+
storm unreleased list --json
246
+
```
247
+
248
+
**Expected:**
249
+
250
+
- Valid JSON array
251
+
- Each entry has type, scope, summary, filename
252
+
- Can be piped to `jq` for processing
253
+
254
+
**Edge Cases:**
255
+
256
+
- Empty `.changes/` directory
257
+
- Malformed YAML in entry file
258
+
- Mixed entry types (manual + partial)
259
+
260
+
### Generating Entries from Git History (`generate`)
261
+
262
+
Scan commit ranges and create changelog entries.
263
+
264
+
#### Range generation
265
+
266
+
```bash
267
+
# Generate from last 5 commits
268
+
storm generate HEAD~5 HEAD
269
+
```
270
+
271
+
**Expected:**
272
+
273
+
- Lists N commits found
274
+
- Creates entries for conventional commits
275
+
- Skips non-conventional commits
276
+
- Shows created count and skipped count
277
+
- Uses diff-based deduplication
278
+
279
+
#### Interactive selection
280
+
281
+
```bash
282
+
storm generate HEAD~10 HEAD --interactive
283
+
```
284
+
285
+
**Expected:**
286
+
287
+
- Launches TUI with commit list
288
+
- Shows parsed metadata (type, scope, summary)
289
+
- Allows selection/deselection
290
+
- Creates only selected entries
291
+
- Handles cancellation (Ctrl+C)
292
+
- Errors gracefully in non-TTY with helpful message
293
+
294
+
#### Since tag
295
+
296
+
```bash
297
+
storm generate --since v0.1.0
298
+
```
299
+
300
+
**Expected:**
301
+
302
+
- Generates entries from v0.1.0 to HEAD
303
+
- Auto-detects tag as starting point
304
+
305
+
#### Deduplication
306
+
307
+
```bash
308
+
storm generate HEAD~3 HEAD
309
+
storm generate HEAD~3 HEAD # Run again
310
+
```
311
+
312
+
**Expected:**
313
+
314
+
- First run creates N entries
315
+
- Second run shows "Skipped N duplicates"
316
+
- No duplicate files created
317
+
318
+
#### Rebased commits
319
+
320
+
```bash
321
+
# Simulate rebase by checking metadata
322
+
storm generate <range-with-rebased-commits>
323
+
```
324
+
325
+
**Expected:**
326
+
327
+
- Detects same diff, different commit hash
328
+
- Updates metadata with new commit hash
329
+
- Shows "Updated N rebased commits"
330
+
331
+
**Edge Cases:**
332
+
333
+
- No commits in range (should show "No commits found")
334
+
- Range with only merge commits
335
+
- Range with revert commits (should skip)
336
+
- Commits with `[nochanges]` marker (should skip)
337
+
- Non-existent refs (should error)
338
+
339
+
### Reviewing Entries (`unreleased review`)
340
+
341
+
Interactive TUI for reviewing unreleased changes.
342
+
343
+
#### Basic review
344
+
345
+
```bash
346
+
storm unreleased review
347
+
```
348
+
349
+
**Expected:**
350
+
351
+
- Launches TUI with list of entries
352
+
- Shows entry details on selection
353
+
- Keyboard navigation works (j/k or arrows)
354
+
- Can mark entries with actions:
355
+
- Press `x` to mark for deletion
356
+
- Press `e` to mark for editing
357
+
- Press `space` to keep (undo marks)
358
+
- Action indicators shown: [โ] keep, [โ] delete, [โ] edit
359
+
- Footer shows action counts
360
+
- Exit with q or ESC to cancel, Enter to confirm
361
+
362
+
#### Deleting entries
363
+
364
+
```bash
365
+
storm unreleased review
366
+
# Press 'x' on unwanted entries, then Enter to confirm
367
+
```
368
+
369
+
**Expected:**
370
+
371
+
- Entries marked with [โ] are deleted from `.changes/`
372
+
- Shows "Deleted: `<filename>`" for each removed entry
373
+
- Final count: "Review completed: N deleted, M edited"
374
+
- Files are permanently removed
375
+
376
+
#### Editing entries
377
+
378
+
```bash
379
+
storm unreleased review
380
+
# Press 'e' on an entry, then Enter to confirm
381
+
```
382
+
383
+
**Expected:**
384
+
385
+
- Launches inline editor TUI for each marked entry
386
+
- Editor shows:
387
+
- Type (cycle with Ctrl+T through: added, changed, fixed, removed, security)
388
+
- Scope (text input field)
389
+
- Summary (text input field)
390
+
- Breaking change status
391
+
- Navigate fields with Tab/Shift+Tab
392
+
- Save with Enter or Ctrl+S
393
+
- Cancel with Esc (skips editing that entry)
394
+
- Shows "Updated: `<filename>`" for saved changes
395
+
- CommitHash and DiffHash preserved
396
+
397
+
#### Review workflow
398
+
399
+
```bash
400
+
# Full workflow: mark multiple actions
401
+
storm unreleased review
402
+
# 1. Navigate with j/k
403
+
# 2. Mark first entry with 'x' (delete)
404
+
# 3. Mark second entry with 'e' (edit)
405
+
# 4. Mark third entry with 'x' (delete)
406
+
# 5. Press Enter to confirm
407
+
```
408
+
409
+
**Expected:**
410
+
411
+
- All delete actions processed first
412
+
- Then edit TUI launched for each edit action
413
+
- Can cancel individual edits with Esc
414
+
- Final summary shows both delete and edit counts
415
+
- If no actions marked, shows "No changes requested"
416
+
417
+
**Edge Cases:**
418
+
419
+
- Empty changes directory (should show message, not crash)
420
+
- Corrupted entry file (should handle gracefully)
421
+
- Non-TTY environment (detects and errors with alternatives)
422
+
- CI environment (detects CI system name in error message)
423
+
- Cancel review (Esc/q) - no changes applied
424
+
- Delete file that no longer exists (should error gracefully)
425
+
- Edit with empty fields (fields preserve original if empty)
426
+
427
+
### CI Validation (`check`)
428
+
429
+
Validate that commits have changelog entries.
430
+
431
+
#### All commits documented
432
+
433
+
```bash
434
+
# After running generate for a range
435
+
storm check HEAD~5 HEAD
436
+
```
437
+
438
+
**Expected:**
439
+
440
+
- Shows "โ All commits have changelog entries"
441
+
- Exit code 0
442
+
443
+
#### Missing entries
444
+
445
+
```bash
446
+
# Create new commits without entries
447
+
git commit --allow-empty -m "feat: undocumented feature"
448
+
storm check HEAD~1 HEAD
449
+
```
450
+
451
+
**Expected:**
452
+
453
+
- Shows "โ N commits missing changelog entries"
454
+
- Lists missing commit SHAs and subjects
455
+
- Suggests commands to fix
456
+
- Exit code 1
457
+
458
+
#### Skip markers
459
+
460
+
```bash
461
+
git commit --allow-empty -m "chore: update deps [nochanges]"
462
+
storm check HEAD~1 HEAD
463
+
```
464
+
465
+
**Expected:**
466
+
467
+
- Skips commit with marker
468
+
- Shows "Skipped N commits with [nochanges] marker"
469
+
- Exit code 0
470
+
471
+
#### Since tag
472
+
473
+
```bash
474
+
storm check --since v0.1.0
475
+
```
476
+
477
+
**Expected:**
478
+
479
+
- Checks all commits since tag
480
+
- Reports missing entries
481
+
482
+
**Edge Cases:**
483
+
484
+
- Empty commit range (should succeed with 0 checks)
485
+
- Range with all skipped commits
486
+
- Invalid tag/ref (should error)
487
+
488
+
### Release Generation (`release`)
489
+
490
+
Promote unreleased changes to CHANGELOG.
491
+
492
+
#### Basic release
493
+
494
+
```bash
495
+
storm release --version 1.2.0
496
+
```
497
+
498
+
**Expected:**
499
+
500
+
- Creates/updates CHANGELOG.md
501
+
- Adds version header with date
502
+
- Groups entries by type (Added, Changed, Fixed, etc.)
503
+
- Maintains Keep a Changelog format
504
+
- Preserves existing changelog content
505
+
506
+
#### Dry run
507
+
508
+
```bash
509
+
storm release --version 1.2.0 --dry-run
510
+
```
511
+
512
+
**Expected:**
513
+
514
+
- Shows preview of changes
515
+
- No files modified
516
+
- Styled output shows what would be written
517
+
518
+
#### Clear changes
519
+
520
+
```bash
521
+
storm release --version 1.2.0 --clear-changes
522
+
```
523
+
524
+
**Expected:**
525
+
526
+
- Moves entries from `.changes/` to CHANGELOG
527
+
- Deletes `.changes/*.md` files after release
528
+
- Keeps `.changes/data/` metadata
529
+
530
+
#### Git tagging
531
+
532
+
```bash
533
+
storm release --version 1.2.0 --tag
534
+
```
535
+
536
+
**Expected:**
537
+
538
+
- Creates annotated Git tag `v1.2.0`
539
+
- Includes release notes in tag message
540
+
- Validates tag doesn't exist
541
+
542
+
**Edge Cases:**
543
+
544
+
- No unreleased entries (should warn)
545
+
- Existing version in CHANGELOG (should append)
546
+
- Malformed CHANGELOG.md (should handle)
547
+
- Tag already exists (should error)
548
+
- Custom date format with `--date`
549
+
550
+
### Diff Viewing (`diff`)
551
+
552
+
Display inline diffs between refs.
553
+
554
+
#### Basic diff
555
+
556
+
```bash
557
+
storm diff HEAD~1 HEAD
558
+
```
559
+
560
+
**Expected:**
561
+
562
+
- TTY: Launches interactive TUI with navigation
563
+
- Non-TTY: Outputs plain text diff to stdout
564
+
- Shows diff with syntax highlighting (TTY only)
565
+
- Iceberg theme colors (TTY only)
566
+
- Context lines displayed
567
+
- File headers shown
568
+
569
+
#### File filtering
570
+
571
+
```bash
572
+
storm diff HEAD~1 HEAD -- "*.go"
573
+
```
574
+
575
+
**Expected:**
576
+
577
+
- Shows only Go file changes
578
+
- Respects glob patterns
579
+
580
+
**Edge Cases:**
581
+
582
+
- No changes between refs
583
+
- Binary files (should indicate)
584
+
- Large diffs (should handle gracefully)
585
+
- Non-TTY environment (automatic plain text output)
586
+
- Piped output (plain text format)
587
+
- Redirected to file (plain text format)
+119
internal/docs/e2e/README.md
+119
internal/docs/e2e/README.md
···
1
+
---
2
+
title: Integration Testing Scenarios
3
+
updated: 2025-11-08
4
+
version: 2
5
+
---
6
+
7
+
## Feature Branch
8
+
9
+
```bash
10
+
# 1. Create feature branch
11
+
git checkout -b feature/new-auth
12
+
13
+
# 2. Make commits
14
+
git commit -m "feat(auth): add OAuth support"
15
+
git commit -m "test(auth): add OAuth tests"
16
+
17
+
# 3. Generate entries interactively
18
+
storm generate main HEAD --interactive
19
+
20
+
# 4. Validate all documented
21
+
storm check main HEAD
22
+
23
+
# 5. Review entries
24
+
storm unreleased list
25
+
26
+
# Expected: 2 entries created, check passes
27
+
```
28
+
29
+
## Release Preparation
30
+
31
+
```bash
32
+
# 1. Generate from last release
33
+
storm generate --since v1.0.0
34
+
35
+
# 2. Review and clean up entries
36
+
storm unreleased review
37
+
# Navigate with j/k
38
+
# Press 'x' to mark duplicates/mistakes for deletion
39
+
# Press 'e' to fix typos or categorization
40
+
# Press Enter to apply changes
41
+
42
+
# 3. Add manual entry for non-code change
43
+
storm unreleased add --type changed --summary "Updated documentation"
44
+
45
+
# 4. Dry-run release
46
+
storm release --version 1.1.0 --dry-run
47
+
48
+
# 5. Execute release with tag
49
+
storm release --version 1.1.0 --tag --clear-changes
50
+
51
+
# 6. Verify
52
+
git tag -n9 v1.1.0
53
+
cat CHANGELOG.md
54
+
55
+
# Expected: Clean CHANGELOG, annotated tag, empty .changes/
56
+
```
57
+
58
+
## Entry Cleanup Workflow
59
+
60
+
```bash
61
+
# 1. Create some test entries with issues
62
+
storm unreleased add --type added --summary "Test entry 1"
63
+
storm unreleased add --type fixed --summary "Wrong category entry"
64
+
storm unreleased add --type added --summary "Duplicate test entry"
65
+
storm unreleased add --type added --summary "Duplicate test entry"
66
+
67
+
# 2. Review and fix
68
+
storm unreleased review
69
+
# - Mark duplicate for deletion with 'x'
70
+
# - Mark wrong category entry for edit with 'e'
71
+
# - Press Enter to confirm
72
+
73
+
# 3. In editor TUI for marked entry:
74
+
# - Press Ctrl+T to cycle type from 'fixed' to 'changed'
75
+
# - Tab to scope field, enter "docs"
76
+
# - Tab to summary field, update text
77
+
# - Press Enter to save
78
+
79
+
# 4. Verify changes
80
+
storm unreleased list
81
+
82
+
# Expected: Only 2 entries remain, edited entry has correct type and scope
83
+
```
84
+
85
+
## CI Pipeline Validation
86
+
87
+
```bash
88
+
# 1. Simulate PR with new commits
89
+
git checkout -b pr/fix-bug
90
+
git commit -m "fix(api): resolve rate limit bug"
91
+
92
+
# 2. CI check (should fail)
93
+
storm check main HEAD
94
+
# Exit code: 1
95
+
96
+
# 3. Create entry
97
+
storm unreleased partial HEAD
98
+
99
+
# 4. CI check (should pass)
100
+
storm check main HEAD
101
+
# Exit code: 0
102
+
103
+
# Expected: PR can be merged with confidence
104
+
```
105
+
106
+
## Rebase Handling
107
+
108
+
```bash
109
+
# 1. Create entries for commits
110
+
storm generate HEAD~3 HEAD
111
+
112
+
# 2. Rebase interactively (squash/reword)
113
+
git rebase -i HEAD~3
114
+
115
+
# 3. Regenerate (should detect rebased commits)
116
+
storm generate HEAD~2 HEAD
117
+
118
+
# Expected: Metadata updated, no duplicates
119
+
```
+12
internal/style/style.go
+12
internal/style/style.go
···
58
58
fmt.Println(v)
59
59
}
60
60
61
+
func Successf(format string, args ...any) {
62
+
s := fmt.Sprintf(format, args...)
63
+
v := StyleAdded.Render(s)
64
+
fmt.Println(v)
65
+
}
66
+
67
+
func Warningf(format string, args ...any) {
68
+
s := fmt.Sprintf(format, args...)
69
+
v := StyleSecurity.Render(s)
70
+
fmt.Println(v)
71
+
}
72
+
61
73
func Newline() { fmt.Println() }
62
74
63
75
func Fixed(s string) {
+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
+
}
+110
internal/tty/tty.go
+110
internal/tty/tty.go
···
1
+
// package tty provides utilities for detecting terminal (TTY) availability and
2
+
// generating appropriate fallback behavior for non-interactive environments.
3
+
package tty
4
+
5
+
import (
6
+
"errors"
7
+
"fmt"
8
+
"os"
9
+
10
+
"golang.org/x/term"
11
+
)
12
+
13
+
// IsTTY checks if the given file descriptor is a terminal.
14
+
func IsTTY(fd uintptr) bool {
15
+
return term.IsTerminal(int(fd))
16
+
}
17
+
18
+
// IsInteractive checks if both stdin and stdout are connected to a terminal.
19
+
// This is the primary check for determining if TUI applications can run.
20
+
func IsInteractive() bool {
21
+
return IsTTY(os.Stdin.Fd()) && IsTTY(os.Stdout.Fd())
22
+
}
23
+
24
+
// IsCI detects if the current environment is a CI system by checking for common
25
+
// CI environment variables.
26
+
func IsCI() bool {
27
+
ciEnvVars := []string{
28
+
"CI", // Generic CI indicator
29
+
"CONTINUOUS_INTEGRATION",
30
+
"GITHUB_ACTIONS",
31
+
"GITLAB_CI",
32
+
"CIRCLECI",
33
+
"TRAVIS",
34
+
"JENKINS_URL",
35
+
"BUILDKITE",
36
+
"DRONE",
37
+
"TEAMCITY_VERSION",
38
+
}
39
+
40
+
for _, envVar := range ciEnvVars {
41
+
if os.Getenv(envVar) != "" {
42
+
return true
43
+
}
44
+
}
45
+
46
+
return false
47
+
}
48
+
49
+
// GetCIName attempts to identify the specific CI system being used.
50
+
func GetCIName() string {
51
+
ciMap := map[string]string{
52
+
"GITHUB_ACTIONS": "GitHub Actions",
53
+
"GITLAB_CI": "GitLab CI",
54
+
"CIRCLECI": "CircleCI",
55
+
"TRAVIS": "Travis CI",
56
+
"JENKINS_URL": "Jenkins",
57
+
"BUILDKITE": "Buildkite",
58
+
"DRONE": "Drone CI",
59
+
"TEAMCITY_VERSION": "TeamCity",
60
+
}
61
+
62
+
for envVar, name := range ciMap {
63
+
if os.Getenv(envVar) != "" {
64
+
return name
65
+
}
66
+
}
67
+
68
+
if IsCI() {
69
+
return "CI"
70
+
}
71
+
72
+
return ""
73
+
}
74
+
75
+
// ErrorInteractiveRequired returns a formatted error message indicating that the
76
+
// command requires an interactive terminal, with suggestions for alternatives.
77
+
func ErrorInteractiveRequired(commandName string, alternatives []string) error {
78
+
msg := fmt.Sprintf("command '%s' requires an interactive terminal", commandName)
79
+
80
+
if IsCI() {
81
+
ciName := GetCIName()
82
+
msg += fmt.Sprintf(" (detected %s environment)", ciName)
83
+
} else {
84
+
msg += " (stdin is not a TTY)"
85
+
}
86
+
87
+
if len(alternatives) > 0 {
88
+
msg += "\n\nAlternatives:"
89
+
for _, alt := range alternatives {
90
+
msg += fmt.Sprintf("\n - %s", alt)
91
+
}
92
+
}
93
+
94
+
return errors.New(msg)
95
+
}
96
+
97
+
// ErrorInteractiveFlag returns a formatted error message indicating that an
98
+
// interactive flag cannot be used in a non-TTY environment.
99
+
func ErrorInteractiveFlag(flagName string) error {
100
+
msg := fmt.Sprintf("flag '%s' requires an interactive terminal", flagName)
101
+
102
+
if IsCI() {
103
+
ciName := GetCIName()
104
+
msg += fmt.Sprintf(" (detected %s environment)", ciName)
105
+
} else {
106
+
msg += " (stdin is not a TTY)"
107
+
}
108
+
109
+
return errors.New(msg)
110
+
}
+288
internal/tty/tty_test.go
+288
internal/tty/tty_test.go
···
1
+
package tty
2
+
3
+
import (
4
+
"os"
5
+
"strings"
6
+
"testing"
7
+
)
8
+
9
+
func TestIsCI(t *testing.T) {
10
+
tests := []struct {
11
+
name string
12
+
envVars map[string]string
13
+
expected bool
14
+
}{
15
+
{
16
+
name: "no CI vars",
17
+
envVars: map[string]string{},
18
+
expected: false,
19
+
},
20
+
{
21
+
name: "generic CI var",
22
+
envVars: map[string]string{"CI": "true"},
23
+
expected: true,
24
+
},
25
+
{
26
+
name: "GitHub Actions",
27
+
envVars: map[string]string{"GITHUB_ACTIONS": "true"},
28
+
expected: true,
29
+
},
30
+
{
31
+
name: "GitLab CI",
32
+
envVars: map[string]string{"GITLAB_CI": "true"},
33
+
expected: true,
34
+
},
35
+
{
36
+
name: "CircleCI",
37
+
envVars: map[string]string{"CIRCLECI": "true"},
38
+
expected: true,
39
+
},
40
+
{
41
+
name: "multiple CI vars",
42
+
envVars: map[string]string{"CI": "true", "TRAVIS": "true"},
43
+
expected: true,
44
+
},
45
+
}
46
+
47
+
for _, tt := range tests {
48
+
t.Run(tt.name, func(t *testing.T) {
49
+
ciEnvVars := []string{
50
+
"CI", "CONTINUOUS_INTEGRATION", "GITHUB_ACTIONS",
51
+
"GITLAB_CI", "CIRCLECI", "TRAVIS", "JENKINS_URL",
52
+
"BUILDKITE", "DRONE", "TEAMCITY_VERSION",
53
+
}
54
+
for _, v := range ciEnvVars {
55
+
os.Unsetenv(v)
56
+
}
57
+
58
+
for k, v := range tt.envVars {
59
+
os.Setenv(k, v)
60
+
}
61
+
62
+
defer func() {
63
+
for k := range tt.envVars {
64
+
os.Unsetenv(k)
65
+
}
66
+
}()
67
+
68
+
result := IsCI()
69
+
if result != tt.expected {
70
+
t.Errorf("IsCI() = %v, expected %v", result, tt.expected)
71
+
}
72
+
})
73
+
}
74
+
}
75
+
76
+
func TestGetCIName(t *testing.T) {
77
+
tests := []struct {
78
+
name string
79
+
envVar string
80
+
expected string
81
+
}{
82
+
{
83
+
name: "GitHub Actions",
84
+
envVar: "GITHUB_ACTIONS",
85
+
expected: "GitHub Actions",
86
+
},
87
+
{
88
+
name: "GitLab CI",
89
+
envVar: "GITLAB_CI",
90
+
expected: "GitLab CI",
91
+
},
92
+
{
93
+
name: "CircleCI",
94
+
envVar: "CIRCLECI",
95
+
expected: "CircleCI",
96
+
},
97
+
{
98
+
name: "Travis CI",
99
+
envVar: "TRAVIS",
100
+
expected: "Travis CI",
101
+
},
102
+
{
103
+
name: "Jenkins",
104
+
envVar: "JENKINS_URL",
105
+
expected: "Jenkins",
106
+
},
107
+
{
108
+
name: "Buildkite",
109
+
envVar: "BUILDKITE",
110
+
expected: "Buildkite",
111
+
},
112
+
{
113
+
name: "Drone CI",
114
+
envVar: "DRONE",
115
+
expected: "Drone CI",
116
+
},
117
+
{
118
+
name: "TeamCity",
119
+
envVar: "TEAMCITY_VERSION",
120
+
expected: "TeamCity",
121
+
},
122
+
{
123
+
name: "Generic CI",
124
+
envVar: "CI",
125
+
expected: "CI",
126
+
},
127
+
{
128
+
name: "No CI",
129
+
envVar: "",
130
+
expected: "",
131
+
},
132
+
}
133
+
134
+
for _, tt := range tests {
135
+
t.Run(tt.name, func(t *testing.T) {
136
+
ciEnvVars := []string{
137
+
"CI", "CONTINUOUS_INTEGRATION", "GITHUB_ACTIONS",
138
+
"GITLAB_CI", "CIRCLECI", "TRAVIS", "JENKINS_URL",
139
+
"BUILDKITE", "DRONE", "TEAMCITY_VERSION",
140
+
}
141
+
for _, v := range ciEnvVars {
142
+
os.Unsetenv(v)
143
+
}
144
+
145
+
if tt.envVar != "" {
146
+
os.Setenv(tt.envVar, "true")
147
+
}
148
+
149
+
defer func() {
150
+
if tt.envVar != "" {
151
+
os.Unsetenv(tt.envVar)
152
+
}
153
+
}()
154
+
155
+
result := GetCIName()
156
+
if result != tt.expected {
157
+
t.Errorf("GetCIName() = %q, expected %q", result, tt.expected)
158
+
}
159
+
})
160
+
}
161
+
}
162
+
163
+
func TestErrorInteractiveRequired(t *testing.T) {
164
+
tests := []struct {
165
+
name string
166
+
commandName string
167
+
alternatives []string
168
+
ciEnv string
169
+
wantContains []string
170
+
}{
171
+
{
172
+
name: "basic error",
173
+
commandName: "review",
174
+
wantContains: []string{
175
+
"command 'review' requires an interactive terminal",
176
+
},
177
+
},
178
+
{
179
+
name: "with alternatives",
180
+
commandName: "review",
181
+
alternatives: []string{
182
+
"Use 'storm unreleased list' to view entries",
183
+
"Use 'storm unreleased list --json' for JSON output",
184
+
},
185
+
wantContains: []string{
186
+
"command 'review' requires an interactive terminal",
187
+
"Alternatives:",
188
+
"Use 'storm unreleased list' to view entries",
189
+
"Use 'storm unreleased list --json' for JSON output",
190
+
},
191
+
},
192
+
{
193
+
name: "CI environment",
194
+
commandName: "diff",
195
+
ciEnv: "GITHUB_ACTIONS",
196
+
wantContains: []string{
197
+
"command 'diff' requires an interactive terminal",
198
+
"detected GitHub Actions environment",
199
+
},
200
+
},
201
+
}
202
+
203
+
for _, tt := range tests {
204
+
t.Run(tt.name, func(t *testing.T) {
205
+
ciEnvVars := []string{
206
+
"CI", "CONTINUOUS_INTEGRATION", "GITHUB_ACTIONS",
207
+
"GITLAB_CI", "CIRCLECI", "TRAVIS", "JENKINS_URL",
208
+
"BUILDKITE", "DRONE", "TEAMCITY_VERSION",
209
+
}
210
+
for _, v := range ciEnvVars {
211
+
os.Unsetenv(v)
212
+
}
213
+
214
+
if tt.ciEnv != "" {
215
+
os.Setenv(tt.ciEnv, "true")
216
+
defer os.Unsetenv(tt.ciEnv)
217
+
}
218
+
219
+
err := ErrorInteractiveRequired(tt.commandName, tt.alternatives)
220
+
if err == nil {
221
+
t.Fatal("ErrorInteractiveRequired() returned nil, expected error")
222
+
}
223
+
224
+
errMsg := err.Error()
225
+
for _, want := range tt.wantContains {
226
+
if !strings.Contains(errMsg, want) {
227
+
t.Errorf("ErrorInteractiveRequired() error message missing %q\nGot: %s", want, errMsg)
228
+
}
229
+
}
230
+
})
231
+
}
232
+
}
233
+
234
+
func TestErrorInteractiveFlag(t *testing.T) {
235
+
tests := []struct {
236
+
name string
237
+
flagName string
238
+
ciEnv string
239
+
wantContains []string
240
+
}{
241
+
{
242
+
name: "basic error",
243
+
flagName: "--interactive",
244
+
wantContains: []string{
245
+
"flag '--interactive' requires an interactive terminal",
246
+
},
247
+
},
248
+
{
249
+
name: "CI environment",
250
+
flagName: "--interactive",
251
+
ciEnv: "GITLAB_CI",
252
+
wantContains: []string{
253
+
"flag '--interactive' requires an interactive terminal",
254
+
"detected GitLab CI environment",
255
+
},
256
+
},
257
+
}
258
+
259
+
for _, tt := range tests {
260
+
t.Run(tt.name, func(t *testing.T) {
261
+
ciEnvVars := []string{
262
+
"CI", "CONTINUOUS_INTEGRATION", "GITHUB_ACTIONS",
263
+
"GITLAB_CI", "CIRCLECI", "TRAVIS", "JENKINS_URL",
264
+
"BUILDKITE", "DRONE", "TEAMCITY_VERSION",
265
+
}
266
+
for _, v := range ciEnvVars {
267
+
os.Unsetenv(v)
268
+
}
269
+
270
+
if tt.ciEnv != "" {
271
+
os.Setenv(tt.ciEnv, "true")
272
+
defer os.Unsetenv(tt.ciEnv)
273
+
}
274
+
275
+
err := ErrorInteractiveFlag(tt.flagName)
276
+
if err == nil {
277
+
t.Fatal("ErrorInteractiveFlag() returned nil, expected error")
278
+
}
279
+
280
+
errMsg := err.Error()
281
+
for _, want := range tt.wantContains {
282
+
if !strings.Contains(errMsg, want) {
283
+
t.Errorf("ErrorInteractiveFlag() error message missing %q\nGot: %s", want, errMsg)
284
+
}
285
+
}
286
+
})
287
+
}
288
+
}
+213
internal/ui/entry_editor.go
+213
internal/ui/entry_editor.go
···
1
+
package ui
2
+
3
+
import (
4
+
"fmt"
5
+
"strings"
6
+
7
+
"github.com/charmbracelet/bubbles/key"
8
+
"github.com/charmbracelet/bubbles/textinput"
9
+
tea "github.com/charmbracelet/bubbletea"
10
+
"github.com/charmbracelet/lipgloss"
11
+
"github.com/stormlightlabs/git-storm/internal/changeset"
12
+
"github.com/stormlightlabs/git-storm/internal/style"
13
+
)
14
+
15
+
// EntryEditorModel holds the state for the inline entry editor TUI.
16
+
type EntryEditorModel struct {
17
+
entry changeset.Entry
18
+
filename string
19
+
inputs []textinput.Model
20
+
focusIdx int
21
+
typeIdx int // index in validTypes array
22
+
confirmed bool
23
+
cancelled bool
24
+
width int
25
+
height int
26
+
}
27
+
28
+
// validTypes defines the allowed changelog entry types.
29
+
var validTypes = []string{"added", "changed", "fixed", "removed", "security"}
30
+
31
+
// editorKeyMap defines keyboard shortcuts for the entry editor.
32
+
type editorKeyMap struct {
33
+
Next key.Binding
34
+
Prev key.Binding
35
+
Confirm key.Binding
36
+
Quit key.Binding
37
+
CycleType key.Binding
38
+
}
39
+
40
+
var editorKeys = editorKeyMap{
41
+
Next: key.NewBinding(
42
+
key.WithKeys("tab"),
43
+
key.WithHelp("tab", "next field"),
44
+
),
45
+
Prev: key.NewBinding(
46
+
key.WithKeys("shift+tab"),
47
+
key.WithHelp("shift+tab", "prev field"),
48
+
),
49
+
Confirm: key.NewBinding(
50
+
key.WithKeys("ctrl+s"),
51
+
key.WithHelp("ctrl+s", "save"),
52
+
),
53
+
Quit: key.NewBinding(
54
+
key.WithKeys("esc"),
55
+
key.WithHelp("esc", "cancel"),
56
+
),
57
+
CycleType: key.NewBinding(
58
+
key.WithKeys("ctrl+t"),
59
+
key.WithHelp("ctrl+t", "cycle type"),
60
+
),
61
+
}
62
+
63
+
// NewEntryEditorModel creates a new editor initialized with the given entry.
64
+
func NewEntryEditorModel(entry changeset.EntryWithFile) EntryEditorModel {
65
+
m := EntryEditorModel{
66
+
entry: entry.Entry,
67
+
filename: entry.Filename,
68
+
inputs: make([]textinput.Model, 2),
69
+
}
70
+
71
+
for i, t := range validTypes {
72
+
if t == entry.Entry.Type {
73
+
m.typeIdx = i
74
+
break
75
+
}
76
+
}
77
+
78
+
m.inputs[0] = textinput.New()
79
+
m.inputs[0].Placeholder = "optional scope (e.g., cli, api)"
80
+
m.inputs[0].SetValue(entry.Entry.Scope)
81
+
m.inputs[0].CharLimit = 50
82
+
m.inputs[0].Width = 50
83
+
84
+
m.inputs[1] = textinput.New()
85
+
m.inputs[1].Placeholder = "brief description of the change"
86
+
m.inputs[1].SetValue(entry.Entry.Summary)
87
+
m.inputs[1].CharLimit = 200
88
+
m.inputs[1].Width = 80
89
+
90
+
m.inputs[0].Focus()
91
+
return m
92
+
}
93
+
94
+
// Init implements tea.Model.
95
+
func (m EntryEditorModel) Init() tea.Cmd {
96
+
return textinput.Blink
97
+
}
98
+
99
+
// Update implements tea.Model.
100
+
func (m EntryEditorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
101
+
switch msg := msg.(type) {
102
+
case tea.KeyMsg:
103
+
switch {
104
+
case key.Matches(msg, editorKeys.Quit):
105
+
m.cancelled = true
106
+
return m, tea.Quit
107
+
case key.Matches(msg, editorKeys.Confirm):
108
+
m.confirmed = true
109
+
return m, tea.Quit
110
+
case key.Matches(msg, editorKeys.CycleType):
111
+
m.typeIdx = (m.typeIdx + 1) % len(validTypes)
112
+
return m, nil
113
+
case key.Matches(msg, editorKeys.Next):
114
+
m.nextField()
115
+
return m, nil
116
+
case key.Matches(msg, editorKeys.Prev):
117
+
m.prevField()
118
+
return m, nil
119
+
case msg.String() == "enter":
120
+
m.confirmed = true
121
+
return m, tea.Quit
122
+
}
123
+
case tea.WindowSizeMsg:
124
+
m.width = msg.Width
125
+
m.height = msg.Height
126
+
}
127
+
cmd := m.updateInputs(msg)
128
+
return m, cmd
129
+
}
130
+
131
+
// View implements tea.Model.
132
+
func (m EntryEditorModel) View() string {
133
+
if m.width == 0 {
134
+
return "Loading..."
135
+
}
136
+
137
+
var b strings.Builder
138
+
139
+
title := lipgloss.NewStyle().
140
+
Bold(true).
141
+
Foreground(style.AccentBlue).
142
+
Render(fmt.Sprintf("Editing: %s", m.filename))
143
+
b.WriteString(title)
144
+
b.WriteString("\n\n")
145
+
146
+
typeLabel := lipgloss.NewStyle().Foreground(lipgloss.Color("#6C7A89")).Render("Type:")
147
+
typeValue := getCategoryStyle(validTypes[m.typeIdx]).Render(validTypes[m.typeIdx])
148
+
b.WriteString(fmt.Sprintf("%s %s (ctrl+t to cycle)\n", typeLabel, typeValue))
149
+
150
+
scopeLabel := lipgloss.NewStyle().Foreground(lipgloss.Color("#6C7A89")).Render("Scope:")
151
+
b.WriteString(fmt.Sprintf("\n%s\n%s\n", scopeLabel, m.inputs[0].View()))
152
+
153
+
summaryLabel := lipgloss.NewStyle().Foreground(lipgloss.Color("#6C7A89")).Render("Summary:")
154
+
b.WriteString(fmt.Sprintf("\n%s\n%s\n", summaryLabel, m.inputs[1].View()))
155
+
156
+
breakingLabel := lipgloss.NewStyle().Foreground(lipgloss.Color("#6C7A89")).Render("Breaking:")
157
+
breakingValue := "no"
158
+
if m.entry.Breaking {
159
+
breakingValue = style.StyleRemoved.Render("yes")
160
+
}
161
+
b.WriteString(fmt.Sprintf("\n%s %s\n", breakingLabel, breakingValue))
162
+
163
+
b.WriteString("\n")
164
+
helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#6C7A89"))
165
+
b.WriteString(helpStyle.Render("tab: next โข shift+tab: prev โข ctrl+t: cycle type โข enter/ctrl+s: save โข esc: cancel"))
166
+
return b.String()
167
+
}
168
+
169
+
// GetEditedEntry returns the entry with updated values.
170
+
func (m EntryEditorModel) GetEditedEntry() changeset.Entry {
171
+
return changeset.Entry{
172
+
Type: validTypes[m.typeIdx],
173
+
Scope: strings.TrimSpace(m.inputs[0].Value()),
174
+
Summary: strings.TrimSpace(m.inputs[1].Value()),
175
+
Breaking: m.entry.Breaking,
176
+
CommitHash: m.entry.CommitHash,
177
+
DiffHash: m.entry.DiffHash,
178
+
}
179
+
}
180
+
181
+
// IsConfirmed returns true if the user confirmed the edit.
182
+
func (m EntryEditorModel) IsConfirmed() bool {
183
+
return m.confirmed
184
+
}
185
+
186
+
// IsCancelled returns true if the user cancelled the edit.
187
+
func (m EntryEditorModel) IsCancelled() bool {
188
+
return m.cancelled
189
+
}
190
+
191
+
// nextField moves focus to the next input field.
192
+
func (m *EntryEditorModel) nextField() {
193
+
m.inputs[m.focusIdx].Blur()
194
+
m.focusIdx = (m.focusIdx + 1) % len(m.inputs)
195
+
m.inputs[m.focusIdx].Focus()
196
+
}
197
+
198
+
// prevField moves focus to the previous input field.
199
+
func (m *EntryEditorModel) prevField() {
200
+
m.inputs[m.focusIdx].Blur()
201
+
m.focusIdx--
202
+
if m.focusIdx < 0 {
203
+
m.focusIdx = len(m.inputs) - 1
204
+
}
205
+
m.inputs[m.focusIdx].Focus()
206
+
}
207
+
208
+
// updateInputs handles updates for text input fields.
209
+
func (m *EntryEditorModel) updateInputs(msg tea.Msg) tea.Cmd {
210
+
var cmd tea.Cmd
211
+
m.inputs[m.focusIdx], cmd = m.inputs[m.focusIdx].Update(msg)
212
+
return cmd
213
+
}
+312
internal/ui/entry_editor_test.go
+312
internal/ui/entry_editor_test.go
···
1
+
package ui
2
+
3
+
import (
4
+
"testing"
5
+
6
+
tea "github.com/charmbracelet/bubbletea"
7
+
"github.com/stormlightlabs/git-storm/internal/changeset"
8
+
)
9
+
10
+
func TestEntryEditorModel_Init(t *testing.T) {
11
+
entry := changeset.EntryWithFile{
12
+
Entry: changeset.Entry{
13
+
Type: "added",
14
+
Scope: "cli",
15
+
Summary: "Test entry",
16
+
},
17
+
Filename: "test.md",
18
+
}
19
+
20
+
model := NewEntryEditorModel(entry)
21
+
22
+
cmd := model.Init()
23
+
if cmd == nil {
24
+
t.Error("Init() should return textinput.Blink command")
25
+
}
26
+
}
27
+
28
+
func TestEntryEditorModel_DefaultState(t *testing.T) {
29
+
entry := changeset.EntryWithFile{
30
+
Entry: changeset.Entry{
31
+
Type: "added",
32
+
Scope: "cli",
33
+
Summary: "Test entry",
34
+
},
35
+
Filename: "test.md",
36
+
}
37
+
38
+
model := NewEntryEditorModel(entry)
39
+
40
+
if model.confirmed {
41
+
t.Error("Model should not be confirmed initially")
42
+
}
43
+
44
+
if model.cancelled {
45
+
t.Error("Model should not be cancelled initially")
46
+
}
47
+
48
+
if model.focusIdx != 0 {
49
+
t.Errorf("Focus should be on first input, got %d", model.focusIdx)
50
+
}
51
+
52
+
if model.typeIdx != 0 {
53
+
t.Errorf("Type index should be 0 for 'added', got %d", model.typeIdx)
54
+
}
55
+
}
56
+
57
+
func TestEntryEditorModel_TypeCycling(t *testing.T) {
58
+
entry := changeset.EntryWithFile{
59
+
Entry: changeset.Entry{
60
+
Type: "added",
61
+
Scope: "cli",
62
+
Summary: "Test entry",
63
+
},
64
+
Filename: "test.md",
65
+
}
66
+
67
+
model := NewEntryEditorModel(entry)
68
+
69
+
initialType := model.typeIdx
70
+
71
+
updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyCtrlT})
72
+
model = updated.(EntryEditorModel)
73
+
74
+
if model.typeIdx == initialType {
75
+
t.Error("Type should have cycled to next value")
76
+
}
77
+
78
+
expectedNext := (initialType + 1) % len(validTypes)
79
+
if model.typeIdx != expectedNext {
80
+
t.Errorf("Type index should be %d, got %d", expectedNext, model.typeIdx)
81
+
}
82
+
}
83
+
84
+
func TestEntryEditorModel_Confirm(t *testing.T) {
85
+
entry := changeset.EntryWithFile{
86
+
Entry: changeset.Entry{
87
+
Type: "added",
88
+
Scope: "cli",
89
+
Summary: "Test entry",
90
+
},
91
+
Filename: "test.md",
92
+
}
93
+
94
+
model := NewEntryEditorModel(entry)
95
+
96
+
updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEnter})
97
+
model = updated.(EntryEditorModel)
98
+
99
+
if !model.confirmed {
100
+
t.Error("Model should be confirmed after pressing Enter")
101
+
}
102
+
103
+
if cmd == nil {
104
+
t.Error("Confirm should return tea.Quit command")
105
+
}
106
+
}
107
+
108
+
func TestEntryEditorModel_ConfirmWithCtrlS(t *testing.T) {
109
+
entry := changeset.EntryWithFile{
110
+
Entry: changeset.Entry{
111
+
Type: "added",
112
+
Scope: "cli",
113
+
Summary: "Test entry",
114
+
},
115
+
Filename: "test.md",
116
+
}
117
+
118
+
model := NewEntryEditorModel(entry)
119
+
120
+
updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyCtrlS})
121
+
model = updated.(EntryEditorModel)
122
+
123
+
if !model.confirmed {
124
+
t.Error("Model should be confirmed after pressing Ctrl+S")
125
+
}
126
+
127
+
if cmd == nil {
128
+
t.Error("Confirm should return tea.Quit command")
129
+
}
130
+
}
131
+
132
+
func TestEntryEditorModel_Cancel(t *testing.T) {
133
+
entry := changeset.EntryWithFile{
134
+
Entry: changeset.Entry{
135
+
Type: "added",
136
+
Scope: "cli",
137
+
Summary: "Test entry",
138
+
},
139
+
Filename: "test.md",
140
+
}
141
+
142
+
model := NewEntryEditorModel(entry)
143
+
144
+
updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEsc})
145
+
model = updated.(EntryEditorModel)
146
+
147
+
if !model.cancelled {
148
+
t.Error("Model should be cancelled after pressing Esc")
149
+
}
150
+
151
+
if cmd == nil {
152
+
t.Error("Cancel should return tea.Quit command")
153
+
}
154
+
}
155
+
156
+
func TestEntryEditorModel_FieldNavigation(t *testing.T) {
157
+
entry := changeset.EntryWithFile{
158
+
Entry: changeset.Entry{
159
+
Type: "added",
160
+
Scope: "cli",
161
+
Summary: "Test entry",
162
+
},
163
+
Filename: "test.md",
164
+
}
165
+
166
+
model := NewEntryEditorModel(entry)
167
+
168
+
if model.focusIdx != 0 {
169
+
t.Fatalf("Initial focus should be on field 0, got %d", model.focusIdx)
170
+
}
171
+
172
+
updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyTab})
173
+
model = updated.(EntryEditorModel)
174
+
175
+
if model.focusIdx != 1 {
176
+
t.Errorf("Focus should move to field 1 after Tab, got %d", model.focusIdx)
177
+
}
178
+
179
+
updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyShiftTab})
180
+
model = updated.(EntryEditorModel)
181
+
182
+
if model.focusIdx != 0 {
183
+
t.Errorf("Focus should move back to field 0 after Shift+Tab, got %d", model.focusIdx)
184
+
}
185
+
}
186
+
187
+
func TestEntryEditorModel_GetEditedEntry(t *testing.T) {
188
+
entry := changeset.EntryWithFile{
189
+
Entry: changeset.Entry{
190
+
Type: "added",
191
+
Scope: "cli",
192
+
Summary: "Test entry",
193
+
Breaking: false,
194
+
CommitHash: "abc123",
195
+
DiffHash: "def456",
196
+
},
197
+
Filename: "test.md",
198
+
}
199
+
200
+
model := NewEntryEditorModel(entry)
201
+
202
+
model.typeIdx = 1
203
+
204
+
editedEntry := model.GetEditedEntry()
205
+
206
+
if editedEntry.Type != validTypes[1] {
207
+
t.Errorf("Expected type %s, got %s", validTypes[1], editedEntry.Type)
208
+
}
209
+
210
+
if editedEntry.CommitHash != entry.Entry.CommitHash {
211
+
t.Error("CommitHash should be preserved")
212
+
}
213
+
214
+
if editedEntry.DiffHash != entry.Entry.DiffHash {
215
+
t.Error("DiffHash should be preserved")
216
+
}
217
+
}
218
+
219
+
func TestEntryEditorModel_IsConfirmed(t *testing.T) {
220
+
entry := changeset.EntryWithFile{
221
+
Entry: changeset.Entry{Type: "added", Summary: "Test"},
222
+
Filename: "test.md",
223
+
}
224
+
225
+
model := NewEntryEditorModel(entry)
226
+
227
+
if model.IsConfirmed() {
228
+
t.Error("Model should not be confirmed initially")
229
+
}
230
+
231
+
updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyEnter})
232
+
model = updated.(EntryEditorModel)
233
+
234
+
if !model.IsConfirmed() {
235
+
t.Error("Model should be confirmed after Enter key")
236
+
}
237
+
}
238
+
239
+
func TestEntryEditorModel_IsCancelled(t *testing.T) {
240
+
entry := changeset.EntryWithFile{
241
+
Entry: changeset.Entry{Type: "added", Summary: "Test"},
242
+
Filename: "test.md",
243
+
}
244
+
245
+
model := NewEntryEditorModel(entry)
246
+
247
+
if model.IsCancelled() {
248
+
t.Error("Model should not be cancelled initially")
249
+
}
250
+
251
+
updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyEsc})
252
+
model = updated.(EntryEditorModel)
253
+
254
+
if !model.IsCancelled() {
255
+
t.Error("Model should be cancelled after Esc key")
256
+
}
257
+
}
258
+
259
+
func TestEntryEditorModel_WindowSize(t *testing.T) {
260
+
entry := changeset.EntryWithFile{
261
+
Entry: changeset.Entry{Type: "added", Summary: "Test"},
262
+
Filename: "test.md",
263
+
}
264
+
265
+
model := NewEntryEditorModel(entry)
266
+
267
+
if model.width != 0 || model.height != 0 {
268
+
t.Error("Initial window size should be 0")
269
+
}
270
+
271
+
updated, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
272
+
model = updated.(EntryEditorModel)
273
+
274
+
if model.width != 100 {
275
+
t.Errorf("Width should be 100, got %d", model.width)
276
+
}
277
+
278
+
if model.height != 30 {
279
+
t.Errorf("Height should be 30, got %d", model.height)
280
+
}
281
+
}
282
+
283
+
func TestEntryEditorModel_TypeIndexForDifferentTypes(t *testing.T) {
284
+
tests := []struct {
285
+
entryType string
286
+
expectedIndex int
287
+
}{
288
+
{"added", 0},
289
+
{"changed", 1},
290
+
{"fixed", 2},
291
+
{"removed", 3},
292
+
{"security", 4},
293
+
}
294
+
295
+
for _, tt := range tests {
296
+
t.Run(tt.entryType, func(t *testing.T) {
297
+
entry := changeset.EntryWithFile{
298
+
Entry: changeset.Entry{
299
+
Type: tt.entryType,
300
+
Summary: "Test entry",
301
+
},
302
+
Filename: "test.md",
303
+
}
304
+
305
+
model := NewEntryEditorModel(entry)
306
+
307
+
if model.typeIdx != tt.expectedIndex {
308
+
t.Errorf("Type index for %s should be %d, got %d", tt.entryType, tt.expectedIndex, model.typeIdx)
309
+
}
310
+
})
311
+
}
312
+
}
+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
+
}
+29
package.json
+29
package.json
···
1
+
{
2
+
"name": "git-storm",
3
+
"version": "1.0.0",
4
+
"description": "",
5
+
"main": "index.js",
6
+
"scripts": {
7
+
"dev": "vitepress dev docs",
8
+
"build": "vitepress build docs",
9
+
"preview": "vitepress preview docs"
10
+
},
11
+
"keywords": [
12
+
"git",
13
+
"changelog",
14
+
"changeset"
15
+
],
16
+
"author": {
17
+
"name": "Owais J.",
18
+
"url": "https://stormlightlabs.org"
19
+
},
20
+
"license": "MIT",
21
+
"packageManager": "pnpm@10.20.0",
22
+
"devDependencies": {
23
+
"vitepress": "2.0.0-alpha.12",
24
+
"vue": "^3.5.24"
25
+
},
26
+
"dependencies": {
27
+
"@catppuccin/vitepress": "^0.1.2"
28
+
}
29
+
}
+1430
pnpm-lock.yaml
+1430
pnpm-lock.yaml
···
1
+
lockfileVersion: '9.0'
2
+
3
+
settings:
4
+
autoInstallPeers: true
5
+
excludeLinksFromLockfile: false
6
+
7
+
importers:
8
+
9
+
.:
10
+
dependencies:
11
+
'@catppuccin/vitepress':
12
+
specifier: ^0.1.2
13
+
version: 0.1.2(typescript@5.9.3)
14
+
devDependencies:
15
+
vitepress:
16
+
specifier: 2.0.0-alpha.12
17
+
version: 2.0.0-alpha.12(postcss@8.5.6)(typescript@5.9.3)
18
+
vue:
19
+
specifier: ^3.5.24
20
+
version: 3.5.24(typescript@5.9.3)
21
+
22
+
packages:
23
+
24
+
'@babel/helper-string-parser@7.27.1':
25
+
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
26
+
engines: {node: '>=6.9.0'}
27
+
28
+
'@babel/helper-validator-identifier@7.28.5':
29
+
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
30
+
engines: {node: '>=6.9.0'}
31
+
32
+
'@babel/parser@7.28.5':
33
+
resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==}
34
+
engines: {node: '>=6.0.0'}
35
+
hasBin: true
36
+
37
+
'@babel/types@7.28.5':
38
+
resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==}
39
+
engines: {node: '>=6.9.0'}
40
+
41
+
'@catppuccin/vitepress@0.1.2':
42
+
resolution: {integrity: sha512-dqhgo6U6GWbgh3McAgwemUC8Y2Aj48rRcQx/9iuPzBPAgo7NA3yi7ZcR0wolAENMmoOMAHBV+rz/5DfiGxtZLA==}
43
+
peerDependencies:
44
+
typescript: ^5.0.0
45
+
46
+
'@docsearch/css@4.3.1':
47
+
resolution: {integrity: sha512-Jnct7LKOi/+Oxbmq215YPYASkMdZqtyyDCkma8Cj4sCcbBuybL6fvyBaX7uJoM6kVF7aIpBA38RhHAyN5ByCHg==}
48
+
49
+
'@docsearch/js@4.3.1':
50
+
resolution: {integrity: sha512-Xi2OztaQqTnNj0HGTcS/RtoXe4ASOgKRuH8hAKKqISqv13oUxpVBIBUHpvPIU4qgmJRZN2gA2gdjdn+VuvrvRQ==}
51
+
52
+
'@esbuild/aix-ppc64@0.25.12':
53
+
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
54
+
engines: {node: '>=18'}
55
+
cpu: [ppc64]
56
+
os: [aix]
57
+
58
+
'@esbuild/android-arm64@0.25.12':
59
+
resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==}
60
+
engines: {node: '>=18'}
61
+
cpu: [arm64]
62
+
os: [android]
63
+
64
+
'@esbuild/android-arm@0.25.12':
65
+
resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==}
66
+
engines: {node: '>=18'}
67
+
cpu: [arm]
68
+
os: [android]
69
+
70
+
'@esbuild/android-x64@0.25.12':
71
+
resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==}
72
+
engines: {node: '>=18'}
73
+
cpu: [x64]
74
+
os: [android]
75
+
76
+
'@esbuild/darwin-arm64@0.25.12':
77
+
resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==}
78
+
engines: {node: '>=18'}
79
+
cpu: [arm64]
80
+
os: [darwin]
81
+
82
+
'@esbuild/darwin-x64@0.25.12':
83
+
resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==}
84
+
engines: {node: '>=18'}
85
+
cpu: [x64]
86
+
os: [darwin]
87
+
88
+
'@esbuild/freebsd-arm64@0.25.12':
89
+
resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==}
90
+
engines: {node: '>=18'}
91
+
cpu: [arm64]
92
+
os: [freebsd]
93
+
94
+
'@esbuild/freebsd-x64@0.25.12':
95
+
resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==}
96
+
engines: {node: '>=18'}
97
+
cpu: [x64]
98
+
os: [freebsd]
99
+
100
+
'@esbuild/linux-arm64@0.25.12':
101
+
resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==}
102
+
engines: {node: '>=18'}
103
+
cpu: [arm64]
104
+
os: [linux]
105
+
106
+
'@esbuild/linux-arm@0.25.12':
107
+
resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==}
108
+
engines: {node: '>=18'}
109
+
cpu: [arm]
110
+
os: [linux]
111
+
112
+
'@esbuild/linux-ia32@0.25.12':
113
+
resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==}
114
+
engines: {node: '>=18'}
115
+
cpu: [ia32]
116
+
os: [linux]
117
+
118
+
'@esbuild/linux-loong64@0.25.12':
119
+
resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==}
120
+
engines: {node: '>=18'}
121
+
cpu: [loong64]
122
+
os: [linux]
123
+
124
+
'@esbuild/linux-mips64el@0.25.12':
125
+
resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==}
126
+
engines: {node: '>=18'}
127
+
cpu: [mips64el]
128
+
os: [linux]
129
+
130
+
'@esbuild/linux-ppc64@0.25.12':
131
+
resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==}
132
+
engines: {node: '>=18'}
133
+
cpu: [ppc64]
134
+
os: [linux]
135
+
136
+
'@esbuild/linux-riscv64@0.25.12':
137
+
resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==}
138
+
engines: {node: '>=18'}
139
+
cpu: [riscv64]
140
+
os: [linux]
141
+
142
+
'@esbuild/linux-s390x@0.25.12':
143
+
resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==}
144
+
engines: {node: '>=18'}
145
+
cpu: [s390x]
146
+
os: [linux]
147
+
148
+
'@esbuild/linux-x64@0.25.12':
149
+
resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==}
150
+
engines: {node: '>=18'}
151
+
cpu: [x64]
152
+
os: [linux]
153
+
154
+
'@esbuild/netbsd-arm64@0.25.12':
155
+
resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==}
156
+
engines: {node: '>=18'}
157
+
cpu: [arm64]
158
+
os: [netbsd]
159
+
160
+
'@esbuild/netbsd-x64@0.25.12':
161
+
resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==}
162
+
engines: {node: '>=18'}
163
+
cpu: [x64]
164
+
os: [netbsd]
165
+
166
+
'@esbuild/openbsd-arm64@0.25.12':
167
+
resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==}
168
+
engines: {node: '>=18'}
169
+
cpu: [arm64]
170
+
os: [openbsd]
171
+
172
+
'@esbuild/openbsd-x64@0.25.12':
173
+
resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==}
174
+
engines: {node: '>=18'}
175
+
cpu: [x64]
176
+
os: [openbsd]
177
+
178
+
'@esbuild/openharmony-arm64@0.25.12':
179
+
resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==}
180
+
engines: {node: '>=18'}
181
+
cpu: [arm64]
182
+
os: [openharmony]
183
+
184
+
'@esbuild/sunos-x64@0.25.12':
185
+
resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==}
186
+
engines: {node: '>=18'}
187
+
cpu: [x64]
188
+
os: [sunos]
189
+
190
+
'@esbuild/win32-arm64@0.25.12':
191
+
resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==}
192
+
engines: {node: '>=18'}
193
+
cpu: [arm64]
194
+
os: [win32]
195
+
196
+
'@esbuild/win32-ia32@0.25.12':
197
+
resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==}
198
+
engines: {node: '>=18'}
199
+
cpu: [ia32]
200
+
os: [win32]
201
+
202
+
'@esbuild/win32-x64@0.25.12':
203
+
resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==}
204
+
engines: {node: '>=18'}
205
+
cpu: [x64]
206
+
os: [win32]
207
+
208
+
'@iconify-json/simple-icons@1.2.58':
209
+
resolution: {integrity: sha512-XtXEoRALqztdNc9ujYBj2tTCPKdIPKJBdLNDebFF46VV1aOAwTbAYMgNsK5GMCpTJupLCmpBWDn+gX5SpECorQ==}
210
+
211
+
'@iconify/types@2.0.0':
212
+
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
213
+
214
+
'@jridgewell/sourcemap-codec@1.5.5':
215
+
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
216
+
217
+
'@rolldown/pluginutils@1.0.0-beta.29':
218
+
resolution: {integrity: sha512-NIJgOsMjbxAXvoGq/X0gD7VPMQ8j9g0BiDaNjVNVjvl+iKXxL3Jre0v31RmBYeLEmkbj2s02v8vFTbUXi5XS2Q==}
219
+
220
+
'@rollup/rollup-android-arm-eabi@4.53.1':
221
+
resolution: {integrity: sha512-bxZtughE4VNVJlL1RdoSE545kc4JxL7op57KKoi59/gwuU5rV6jLWFXXc8jwgFoT6vtj+ZjO+Z2C5nrY0Cl6wA==}
222
+
cpu: [arm]
223
+
os: [android]
224
+
225
+
'@rollup/rollup-android-arm64@4.53.1':
226
+
resolution: {integrity: sha512-44a1hreb02cAAfAKmZfXVercPFaDjqXCK+iKeVOlJ9ltvnO6QqsBHgKVPTu+MJHSLLeMEUbeG2qiDYgbFPU48g==}
227
+
cpu: [arm64]
228
+
os: [android]
229
+
230
+
'@rollup/rollup-darwin-arm64@4.53.1':
231
+
resolution: {integrity: sha512-usmzIgD0rf1syoOZ2WZvy8YpXK5G1V3btm3QZddoGSa6mOgfXWkkv+642bfUUldomgrbiLQGrPryb7DXLovPWQ==}
232
+
cpu: [arm64]
233
+
os: [darwin]
234
+
235
+
'@rollup/rollup-darwin-x64@4.53.1':
236
+
resolution: {integrity: sha512-is3r/k4vig2Gt8mKtTlzzyaSQ+hd87kDxiN3uDSDwggJLUV56Umli6OoL+/YZa/KvtdrdyNfMKHzL/P4siOOmg==}
237
+
cpu: [x64]
238
+
os: [darwin]
239
+
240
+
'@rollup/rollup-freebsd-arm64@4.53.1':
241
+
resolution: {integrity: sha512-QJ1ksgp/bDJkZB4daldVmHaEQkG4r8PUXitCOC2WRmRaSaHx5RwPoI3DHVfXKwDkB+Sk6auFI/+JHacTekPRSw==}
242
+
cpu: [arm64]
243
+
os: [freebsd]
244
+
245
+
'@rollup/rollup-freebsd-x64@4.53.1':
246
+
resolution: {integrity: sha512-J6ma5xgAzvqsnU6a0+jgGX/gvoGokqpkx6zY4cWizRrm0ffhHDpJKQgC8dtDb3+MqfZDIqs64REbfHDMzxLMqQ==}
247
+
cpu: [x64]
248
+
os: [freebsd]
249
+
250
+
'@rollup/rollup-linux-arm-gnueabihf@4.53.1':
251
+
resolution: {integrity: sha512-JzWRR41o2U3/KMNKRuZNsDUAcAVUYhsPuMlx5RUldw0E4lvSIXFUwejtYz1HJXohUmqs/M6BBJAUBzKXZVddbg==}
252
+
cpu: [arm]
253
+
os: [linux]
254
+
255
+
'@rollup/rollup-linux-arm-musleabihf@4.53.1':
256
+
resolution: {integrity: sha512-L8kRIrnfMrEoHLHtHn+4uYA52fiLDEDyezgxZtGUTiII/yb04Krq+vk3P2Try+Vya9LeCE9ZHU8CXD6J9EhzHQ==}
257
+
cpu: [arm]
258
+
os: [linux]
259
+
260
+
'@rollup/rollup-linux-arm64-gnu@4.53.1':
261
+
resolution: {integrity: sha512-ysAc0MFRV+WtQ8li8hi3EoFi7us6d1UzaS/+Dp7FYZfg3NdDljGMoVyiIp6Ucz7uhlYDBZ/zt6XI0YEZbUO11Q==}
262
+
cpu: [arm64]
263
+
os: [linux]
264
+
265
+
'@rollup/rollup-linux-arm64-musl@4.53.1':
266
+
resolution: {integrity: sha512-UV6l9MJpDbDZZ/fJvqNcvO1PcivGEf1AvKuTcHoLjVZVFeAMygnamCTDikCVMRnA+qJe+B3pSbgX2+lBMqgBhA==}
267
+
cpu: [arm64]
268
+
os: [linux]
269
+
270
+
'@rollup/rollup-linux-loong64-gnu@4.53.1':
271
+
resolution: {integrity: sha512-UDUtelEprkA85g95Q+nj3Xf0M4hHa4DiJ+3P3h4BuGliY4NReYYqwlc0Y8ICLjN4+uIgCEvaygYlpf0hUj90Yg==}
272
+
cpu: [loong64]
273
+
os: [linux]
274
+
275
+
'@rollup/rollup-linux-ppc64-gnu@4.53.1':
276
+
resolution: {integrity: sha512-vrRn+BYhEtNOte/zbc2wAUQReJXxEx2URfTol6OEfY2zFEUK92pkFBSXRylDM7aHi+YqEPJt9/ABYzmcrS4SgQ==}
277
+
cpu: [ppc64]
278
+
os: [linux]
279
+
280
+
'@rollup/rollup-linux-riscv64-gnu@4.53.1':
281
+
resolution: {integrity: sha512-gto/1CxHyi4A7YqZZNznQYrVlPSaodOBPKM+6xcDSCMVZN/Fzb4K+AIkNz/1yAYz9h3Ng+e2fY9H6bgawVq17w==}
282
+
cpu: [riscv64]
283
+
os: [linux]
284
+
285
+
'@rollup/rollup-linux-riscv64-musl@4.53.1':
286
+
resolution: {integrity: sha512-KZ6Vx7jAw3aLNjFR8eYVcQVdFa/cvBzDNRFM3z7XhNNunWjA03eUrEwJYPk0G8V7Gs08IThFKcAPS4WY/ybIrQ==}
287
+
cpu: [riscv64]
288
+
os: [linux]
289
+
290
+
'@rollup/rollup-linux-s390x-gnu@4.53.1':
291
+
resolution: {integrity: sha512-HvEixy2s/rWNgpwyKpXJcHmE7om1M89hxBTBi9Fs6zVuLU4gOrEMQNbNsN/tBVIMbLyysz/iwNiGtMOpLAOlvA==}
292
+
cpu: [s390x]
293
+
os: [linux]
294
+
295
+
'@rollup/rollup-linux-x64-gnu@4.53.1':
296
+
resolution: {integrity: sha512-E/n8x2MSjAQgjj9IixO4UeEUeqXLtiA7pyoXCFYLuXpBA/t2hnbIdxHfA7kK9BFsYAoNU4st1rHYdldl8dTqGA==}
297
+
cpu: [x64]
298
+
os: [linux]
299
+
300
+
'@rollup/rollup-linux-x64-musl@4.53.1':
301
+
resolution: {integrity: sha512-IhJ087PbLOQXCN6Ui/3FUkI9pWNZe/Z7rEIVOzMsOs1/HSAECCvSZ7PkIbkNqL/AZn6WbZvnoVZw/qwqYMo4/w==}
302
+
cpu: [x64]
303
+
os: [linux]
304
+
305
+
'@rollup/rollup-openharmony-arm64@4.53.1':
306
+
resolution: {integrity: sha512-0++oPNgLJHBblreu0SFM7b3mAsBJBTY0Ksrmu9N6ZVrPiTkRgda52mWR7TKhHAsUb9noCjFvAw9l6ZO1yzaVbA==}
307
+
cpu: [arm64]
308
+
os: [openharmony]
309
+
310
+
'@rollup/rollup-win32-arm64-msvc@4.53.1':
311
+
resolution: {integrity: sha512-VJXivz61c5uVdbmitLkDlbcTk9Or43YC2QVLRkqp86QoeFSqI81bNgjhttqhKNMKnQMWnecOCm7lZz4s+WLGpQ==}
312
+
cpu: [arm64]
313
+
os: [win32]
314
+
315
+
'@rollup/rollup-win32-ia32-msvc@4.53.1':
316
+
resolution: {integrity: sha512-NmZPVTUOitCXUH6erJDzTQ/jotYw4CnkMDjCYRxNHVD9bNyfrGoIse684F9okwzKCV4AIHRbUkeTBc9F2OOH5Q==}
317
+
cpu: [ia32]
318
+
os: [win32]
319
+
320
+
'@rollup/rollup-win32-x64-gnu@4.53.1':
321
+
resolution: {integrity: sha512-2SNj7COIdAf6yliSpLdLG8BEsp5lgzRehgfkP0Av8zKfQFKku6JcvbobvHASPJu4f3BFxej5g+HuQPvqPhHvpQ==}
322
+
cpu: [x64]
323
+
os: [win32]
324
+
325
+
'@rollup/rollup-win32-x64-msvc@4.53.1':
326
+
resolution: {integrity: sha512-rLarc1Ofcs3DHtgSzFO31pZsCh8g05R2azN1q3fF+H423Co87My0R+tazOEvYVKXSLh8C4LerMK41/K7wlklcg==}
327
+
cpu: [x64]
328
+
os: [win32]
329
+
330
+
'@shikijs/core@3.15.0':
331
+
resolution: {integrity: sha512-8TOG6yG557q+fMsSVa8nkEDOZNTSxjbbR8l6lF2gyr6Np+jrPlslqDxQkN6rMXCECQ3isNPZAGszAfYoJOPGlg==}
332
+
333
+
'@shikijs/engine-javascript@3.15.0':
334
+
resolution: {integrity: sha512-ZedbOFpopibdLmvTz2sJPJgns8Xvyabe2QbmqMTz07kt1pTzfEvKZc5IqPVO/XFiEbbNyaOpjPBkkr1vlwS+qg==}
335
+
336
+
'@shikijs/engine-oniguruma@3.15.0':
337
+
resolution: {integrity: sha512-HnqFsV11skAHvOArMZdLBZZApRSYS4LSztk2K3016Y9VCyZISnlYUYsL2hzlS7tPqKHvNqmI5JSUJZprXloMvA==}
338
+
339
+
'@shikijs/langs@3.15.0':
340
+
resolution: {integrity: sha512-WpRvEFvkVvO65uKYW4Rzxs+IG0gToyM8SARQMtGGsH4GDMNZrr60qdggXrFOsdfOVssG/QQGEl3FnJ3EZ+8w8A==}
341
+
342
+
'@shikijs/themes@3.15.0':
343
+
resolution: {integrity: sha512-8ow2zWb1IDvCKjYb0KiLNrK4offFdkfNVPXb1OZykpLCzRU6j+efkY+Y7VQjNlNFXonSw+4AOdGYtmqykDbRiQ==}
344
+
345
+
'@shikijs/transformers@3.15.0':
346
+
resolution: {integrity: sha512-Hmwip5ovvSkg+Kc41JTvSHHVfCYF+C8Cp1omb5AJj4Xvd+y9IXz2rKJwmFRGsuN0vpHxywcXJ1+Y4B9S7EG1/A==}
347
+
348
+
'@shikijs/types@3.15.0':
349
+
resolution: {integrity: sha512-BnP+y/EQnhihgHy4oIAN+6FFtmfTekwOLsQbRw9hOKwqgNy8Bdsjq8B05oAt/ZgvIWWFrshV71ytOrlPfYjIJw==}
350
+
351
+
'@shikijs/vscode-textmate@10.0.2':
352
+
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
353
+
354
+
'@types/estree@1.0.8':
355
+
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
356
+
357
+
'@types/hast@3.0.4':
358
+
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
359
+
360
+
'@types/linkify-it@5.0.0':
361
+
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
362
+
363
+
'@types/markdown-it@14.1.2':
364
+
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
365
+
366
+
'@types/mdast@4.0.4':
367
+
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
368
+
369
+
'@types/mdurl@2.0.0':
370
+
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
371
+
372
+
'@types/unist@3.0.3':
373
+
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
374
+
375
+
'@types/web-bluetooth@0.0.21':
376
+
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
377
+
378
+
'@ungap/structured-clone@1.3.0':
379
+
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
380
+
381
+
'@vitejs/plugin-vue@6.0.1':
382
+
resolution: {integrity: sha512-+MaE752hU0wfPFJEUAIxqw18+20euHHdxVtMvbFcOEpjEyfqXH/5DCoTHiVJ0J29EhTJdoTkjEv5YBKU9dnoTw==}
383
+
engines: {node: ^20.19.0 || >=22.12.0}
384
+
peerDependencies:
385
+
vite: ^5.0.0 || ^6.0.0 || ^7.0.0
386
+
vue: ^3.2.25
387
+
388
+
'@vue/compiler-core@3.5.24':
389
+
resolution: {integrity: sha512-eDl5H57AOpNakGNAkFDH+y7kTqrQpJkZFXhWZQGyx/5Wh7B1uQYvcWkvZi11BDhscPgj8N7XV3oRwiPnx1Vrig==}
390
+
391
+
'@vue/compiler-dom@3.5.24':
392
+
resolution: {integrity: sha512-1QHGAvs53gXkWdd3ZMGYuvQFXHW4ksKWPG8HP8/2BscrbZ0brw183q2oNWjMrSWImYLHxHrx1ItBQr50I/q2zw==}
393
+
394
+
'@vue/compiler-sfc@3.5.24':
395
+
resolution: {integrity: sha512-8EG5YPRgmTB+YxYBM3VXy8zHD9SWHUJLIGPhDovo3Z8VOgvP+O7UP5vl0J4BBPWYD9vxtBabzW1EuEZ+Cqs14g==}
396
+
397
+
'@vue/compiler-ssr@3.5.24':
398
+
resolution: {integrity: sha512-trOvMWNBMQ/odMRHW7Ae1CdfYx+7MuiQu62Jtu36gMLXcaoqKvAyh+P73sYG9ll+6jLB6QPovqoKGGZROzkFFg==}
399
+
400
+
'@vue/devtools-api@8.0.3':
401
+
resolution: {integrity: sha512-YxZE7xNvvfq5XmjJh1ml+CzVNrRjuZYCuT5Xjj0u9RlXU7za/MRuZDUXcKfp0j7IvYkDut49vlKqbiQ1xhXP2w==}
402
+
403
+
'@vue/devtools-kit@8.0.3':
404
+
resolution: {integrity: sha512-UF4YUOVGdfzXLCv5pMg2DxocB8dvXz278fpgEE+nJ/DRALQGAva7sj9ton0VWZ9hmXw+SV8yKMrxP2MpMhq9Wg==}
405
+
406
+
'@vue/devtools-shared@8.0.3':
407
+
resolution: {integrity: sha512-s/QNll7TlpbADFZrPVsaUNPCOF8NvQgtgmmB7Tip6pLf/HcOvBTly0lfLQ0Eylu9FQ4OqBhFpLyBgwykiSf8zw==}
408
+
409
+
'@vue/reactivity@3.5.24':
410
+
resolution: {integrity: sha512-BM8kBhtlkkbnyl4q+HiF5R5BL0ycDPfihowulm02q3WYp2vxgPcJuZO866qa/0u3idbMntKEtVNuAUp5bw4teg==}
411
+
412
+
'@vue/runtime-core@3.5.24':
413
+
resolution: {integrity: sha512-RYP/byyKDgNIqfX/gNb2PB55dJmM97jc9wyF3jK7QUInYKypK2exmZMNwnjueWwGceEkP6NChd3D2ZVEp9undQ==}
414
+
415
+
'@vue/runtime-dom@3.5.24':
416
+
resolution: {integrity: sha512-Z8ANhr/i0XIluonHVjbUkjvn+CyrxbXRIxR7wn7+X7xlcb7dJsfITZbkVOeJZdP8VZwfrWRsWdShH6pngMxRjw==}
417
+
418
+
'@vue/server-renderer@3.5.24':
419
+
resolution: {integrity: sha512-Yh2j2Y4G/0/4z/xJ1Bad4mxaAk++C2v4kaa8oSYTMJBJ00/ndPuxCnWeot0/7/qafQFLh5pr6xeV6SdMcE/G1w==}
420
+
peerDependencies:
421
+
vue: 3.5.24
422
+
423
+
'@vue/shared@3.5.24':
424
+
resolution: {integrity: sha512-9cwHL2EsJBdi8NY22pngYYWzkTDhld6fAD6jlaeloNGciNSJL6bLpbxVgXl96X00Jtc6YWQv96YA/0sxex/k1A==}
425
+
426
+
'@vueuse/core@13.9.0':
427
+
resolution: {integrity: sha512-ts3regBQyURfCE2BcytLqzm8+MmLlo5Ln/KLoxDVcsZ2gzIwVNnQpQOL/UKV8alUqjSZOlpFZcRNsLRqj+OzyA==}
428
+
peerDependencies:
429
+
vue: ^3.5.0
430
+
431
+
'@vueuse/integrations@13.9.0':
432
+
resolution: {integrity: sha512-SDobKBbPIOe0cVL7QxMzGkuUGHvWTdihi9zOrrWaWUgFKe15cwEcwfWmgrcNzjT6kHnNmWuTajPHoIzUjYNYYQ==}
433
+
peerDependencies:
434
+
async-validator: ^4
435
+
axios: ^1
436
+
change-case: ^5
437
+
drauu: ^0.4
438
+
focus-trap: ^7
439
+
fuse.js: ^7
440
+
idb-keyval: ^6
441
+
jwt-decode: ^4
442
+
nprogress: ^0.2
443
+
qrcode: ^1.5
444
+
sortablejs: ^1
445
+
universal-cookie: ^7 || ^8
446
+
vue: ^3.5.0
447
+
peerDependenciesMeta:
448
+
async-validator:
449
+
optional: true
450
+
axios:
451
+
optional: true
452
+
change-case:
453
+
optional: true
454
+
drauu:
455
+
optional: true
456
+
focus-trap:
457
+
optional: true
458
+
fuse.js:
459
+
optional: true
460
+
idb-keyval:
461
+
optional: true
462
+
jwt-decode:
463
+
optional: true
464
+
nprogress:
465
+
optional: true
466
+
qrcode:
467
+
optional: true
468
+
sortablejs:
469
+
optional: true
470
+
universal-cookie:
471
+
optional: true
472
+
473
+
'@vueuse/metadata@13.9.0':
474
+
resolution: {integrity: sha512-1AFRvuiGphfF7yWixZa0KwjYH8ulyjDCC0aFgrGRz8+P4kvDFSdXLVfTk5xAN9wEuD1J6z4/myMoYbnHoX07zg==}
475
+
476
+
'@vueuse/shared@13.9.0':
477
+
resolution: {integrity: sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g==}
478
+
peerDependencies:
479
+
vue: ^3.5.0
480
+
481
+
birpc@2.8.0:
482
+
resolution: {integrity: sha512-Bz2a4qD/5GRhiHSwj30c/8kC8QGj12nNDwz3D4ErQ4Xhy35dsSDvF+RA/tWpjyU0pdGtSDiEk6B5fBGE1qNVhw==}
483
+
484
+
ccount@2.0.1:
485
+
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
486
+
487
+
character-entities-html4@2.1.0:
488
+
resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==}
489
+
490
+
character-entities-legacy@3.0.0:
491
+
resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==}
492
+
493
+
comma-separated-tokens@2.0.3:
494
+
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
495
+
496
+
copy-anything@4.0.5:
497
+
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
498
+
engines: {node: '>=18'}
499
+
500
+
csstype@3.1.3:
501
+
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
502
+
503
+
dequal@2.0.3:
504
+
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
505
+
engines: {node: '>=6'}
506
+
507
+
devlop@1.1.0:
508
+
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
509
+
510
+
entities@4.5.0:
511
+
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
512
+
engines: {node: '>=0.12'}
513
+
514
+
esbuild@0.25.12:
515
+
resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
516
+
engines: {node: '>=18'}
517
+
hasBin: true
518
+
519
+
estree-walker@2.0.2:
520
+
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
521
+
522
+
fdir@6.5.0:
523
+
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
524
+
engines: {node: '>=12.0.0'}
525
+
peerDependencies:
526
+
picomatch: ^3 || ^4
527
+
peerDependenciesMeta:
528
+
picomatch:
529
+
optional: true
530
+
531
+
focus-trap@7.6.6:
532
+
resolution: {integrity: sha512-v/Z8bvMCajtx4mEXmOo7QEsIzlIOqRXTIwgUfsFOF9gEsespdbD0AkPIka1bSXZ8Y8oZ+2IVDQZePkTfEHZl7Q==}
533
+
534
+
fsevents@2.3.3:
535
+
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
536
+
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
537
+
os: [darwin]
538
+
539
+
hast-util-to-html@9.0.5:
540
+
resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
541
+
542
+
hast-util-whitespace@3.0.0:
543
+
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
544
+
545
+
hookable@5.5.3:
546
+
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
547
+
548
+
htm@3.1.1:
549
+
resolution: {integrity: sha512-983Vyg8NwUE7JkZ6NmOqpCZ+sh1bKv2iYTlUkzlWmA5JD2acKoxd4KVxbMmxX/85mtfdnDmTFoNKcg5DGAvxNQ==}
550
+
551
+
html-void-elements@3.0.0:
552
+
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
553
+
554
+
is-what@5.5.0:
555
+
resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==}
556
+
engines: {node: '>=18'}
557
+
558
+
magic-string@0.30.21:
559
+
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
560
+
561
+
mark.js@8.11.1:
562
+
resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==}
563
+
564
+
mdast-util-to-hast@13.2.0:
565
+
resolution: {integrity: sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==}
566
+
567
+
micromark-util-character@2.1.1:
568
+
resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==}
569
+
570
+
micromark-util-encode@2.0.1:
571
+
resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==}
572
+
573
+
micromark-util-sanitize-uri@2.0.1:
574
+
resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==}
575
+
576
+
micromark-util-symbol@2.0.1:
577
+
resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==}
578
+
579
+
micromark-util-types@2.0.2:
580
+
resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==}
581
+
582
+
minisearch@7.2.0:
583
+
resolution: {integrity: sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==}
584
+
585
+
mitt@3.0.1:
586
+
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
587
+
588
+
nanoid@3.3.11:
589
+
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
590
+
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
591
+
hasBin: true
592
+
593
+
oniguruma-parser@0.12.1:
594
+
resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==}
595
+
596
+
oniguruma-to-es@4.3.3:
597
+
resolution: {integrity: sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg==}
598
+
599
+
perfect-debounce@2.0.0:
600
+
resolution: {integrity: sha512-fkEH/OBiKrqqI/yIgjR92lMfs2K8105zt/VT6+7eTjNwisrsh47CeIED9z58zI7DfKdH3uHAn25ziRZn3kgAow==}
601
+
602
+
picocolors@1.1.1:
603
+
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
604
+
605
+
picomatch@4.0.3:
606
+
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
607
+
engines: {node: '>=12'}
608
+
609
+
postcss@8.5.6:
610
+
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
611
+
engines: {node: ^10 || ^12 || >=14}
612
+
613
+
property-information@7.1.0:
614
+
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
615
+
616
+
regex-recursion@6.0.2:
617
+
resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
618
+
619
+
regex-utilities@2.3.0:
620
+
resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==}
621
+
622
+
regex@6.0.1:
623
+
resolution: {integrity: sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA==}
624
+
625
+
rfdc@1.4.1:
626
+
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
627
+
628
+
rollup@4.53.1:
629
+
resolution: {integrity: sha512-n2I0V0lN3E9cxxMqBCT3opWOiQBzRN7UG60z/WDKqdX2zHUS/39lezBcsckZFsV6fUTSnfqI7kHf60jDAPGKug==}
630
+
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
631
+
hasBin: true
632
+
633
+
shiki@3.15.0:
634
+
resolution: {integrity: sha512-kLdkY6iV3dYbtPwS9KXU7mjfmDm25f5m0IPNFnaXO7TBPcvbUOY72PYXSuSqDzwp+vlH/d7MXpHlKO/x+QoLXw==}
635
+
636
+
source-map-js@1.2.1:
637
+
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
638
+
engines: {node: '>=0.10.0'}
639
+
640
+
space-separated-tokens@2.0.2:
641
+
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
642
+
643
+
speakingurl@14.0.1:
644
+
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
645
+
engines: {node: '>=0.10.0'}
646
+
647
+
stringify-entities@4.0.4:
648
+
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
649
+
650
+
superjson@2.2.5:
651
+
resolution: {integrity: sha512-zWPTX96LVsA/eVYnqOM2+ofcdPqdS1dAF1LN4TS2/MWuUpfitd9ctTa87wt4xrYnZnkLtS69xpBdSxVBP5Rm6w==}
652
+
engines: {node: '>=16'}
653
+
654
+
tabbable@6.3.0:
655
+
resolution: {integrity: sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==}
656
+
657
+
tinyglobby@0.2.15:
658
+
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
659
+
engines: {node: '>=12.0.0'}
660
+
661
+
trim-lines@3.0.1:
662
+
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
663
+
664
+
typescript@5.9.3:
665
+
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
666
+
engines: {node: '>=14.17'}
667
+
hasBin: true
668
+
669
+
unist-util-is@6.0.1:
670
+
resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
671
+
672
+
unist-util-position@5.0.0:
673
+
resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==}
674
+
675
+
unist-util-stringify-position@4.0.0:
676
+
resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==}
677
+
678
+
unist-util-visit-parents@6.0.2:
679
+
resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==}
680
+
681
+
unist-util-visit@5.0.0:
682
+
resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==}
683
+
684
+
vfile-message@4.0.3:
685
+
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
686
+
687
+
vfile@6.0.3:
688
+
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
689
+
690
+
vite@7.2.2:
691
+
resolution: {integrity: sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==}
692
+
engines: {node: ^20.19.0 || >=22.12.0}
693
+
hasBin: true
694
+
peerDependencies:
695
+
'@types/node': ^20.19.0 || >=22.12.0
696
+
jiti: '>=1.21.0'
697
+
less: ^4.0.0
698
+
lightningcss: ^1.21.0
699
+
sass: ^1.70.0
700
+
sass-embedded: ^1.70.0
701
+
stylus: '>=0.54.8'
702
+
sugarss: ^5.0.0
703
+
terser: ^5.16.0
704
+
tsx: ^4.8.1
705
+
yaml: ^2.4.2
706
+
peerDependenciesMeta:
707
+
'@types/node':
708
+
optional: true
709
+
jiti:
710
+
optional: true
711
+
less:
712
+
optional: true
713
+
lightningcss:
714
+
optional: true
715
+
sass:
716
+
optional: true
717
+
sass-embedded:
718
+
optional: true
719
+
stylus:
720
+
optional: true
721
+
sugarss:
722
+
optional: true
723
+
terser:
724
+
optional: true
725
+
tsx:
726
+
optional: true
727
+
yaml:
728
+
optional: true
729
+
730
+
vitepress@2.0.0-alpha.12:
731
+
resolution: {integrity: sha512-yZwCwRRepcpN5QeAhwSnEJxS3I6zJcVixqL1dnm6km4cnriLpQyy2sXQDsE5Ti3pxGPbhU51nTMwI+XC1KNnJg==}
732
+
hasBin: true
733
+
peerDependencies:
734
+
markdown-it-mathjax3: ^4
735
+
oxc-minify: ^0.82.1
736
+
postcss: ^8
737
+
peerDependenciesMeta:
738
+
markdown-it-mathjax3:
739
+
optional: true
740
+
oxc-minify:
741
+
optional: true
742
+
postcss:
743
+
optional: true
744
+
745
+
vue@3.5.24:
746
+
resolution: {integrity: sha512-uTHDOpVQTMjcGgrqFPSb8iO2m1DUvo+WbGqoXQz8Y1CeBYQ0FXf2z1gLRaBtHjlRz7zZUBHxjVB5VTLzYkvftg==}
747
+
peerDependencies:
748
+
typescript: '*'
749
+
peerDependenciesMeta:
750
+
typescript:
751
+
optional: true
752
+
753
+
zwitch@2.0.4:
754
+
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
755
+
756
+
snapshots:
757
+
758
+
'@babel/helper-string-parser@7.27.1': {}
759
+
760
+
'@babel/helper-validator-identifier@7.28.5': {}
761
+
762
+
'@babel/parser@7.28.5':
763
+
dependencies:
764
+
'@babel/types': 7.28.5
765
+
766
+
'@babel/types@7.28.5':
767
+
dependencies:
768
+
'@babel/helper-string-parser': 7.27.1
769
+
'@babel/helper-validator-identifier': 7.28.5
770
+
771
+
'@catppuccin/vitepress@0.1.2(typescript@5.9.3)':
772
+
dependencies:
773
+
typescript: 5.9.3
774
+
775
+
'@docsearch/css@4.3.1': {}
776
+
777
+
'@docsearch/js@4.3.1':
778
+
dependencies:
779
+
htm: 3.1.1
780
+
781
+
'@esbuild/aix-ppc64@0.25.12':
782
+
optional: true
783
+
784
+
'@esbuild/android-arm64@0.25.12':
785
+
optional: true
786
+
787
+
'@esbuild/android-arm@0.25.12':
788
+
optional: true
789
+
790
+
'@esbuild/android-x64@0.25.12':
791
+
optional: true
792
+
793
+
'@esbuild/darwin-arm64@0.25.12':
794
+
optional: true
795
+
796
+
'@esbuild/darwin-x64@0.25.12':
797
+
optional: true
798
+
799
+
'@esbuild/freebsd-arm64@0.25.12':
800
+
optional: true
801
+
802
+
'@esbuild/freebsd-x64@0.25.12':
803
+
optional: true
804
+
805
+
'@esbuild/linux-arm64@0.25.12':
806
+
optional: true
807
+
808
+
'@esbuild/linux-arm@0.25.12':
809
+
optional: true
810
+
811
+
'@esbuild/linux-ia32@0.25.12':
812
+
optional: true
813
+
814
+
'@esbuild/linux-loong64@0.25.12':
815
+
optional: true
816
+
817
+
'@esbuild/linux-mips64el@0.25.12':
818
+
optional: true
819
+
820
+
'@esbuild/linux-ppc64@0.25.12':
821
+
optional: true
822
+
823
+
'@esbuild/linux-riscv64@0.25.12':
824
+
optional: true
825
+
826
+
'@esbuild/linux-s390x@0.25.12':
827
+
optional: true
828
+
829
+
'@esbuild/linux-x64@0.25.12':
830
+
optional: true
831
+
832
+
'@esbuild/netbsd-arm64@0.25.12':
833
+
optional: true
834
+
835
+
'@esbuild/netbsd-x64@0.25.12':
836
+
optional: true
837
+
838
+
'@esbuild/openbsd-arm64@0.25.12':
839
+
optional: true
840
+
841
+
'@esbuild/openbsd-x64@0.25.12':
842
+
optional: true
843
+
844
+
'@esbuild/openharmony-arm64@0.25.12':
845
+
optional: true
846
+
847
+
'@esbuild/sunos-x64@0.25.12':
848
+
optional: true
849
+
850
+
'@esbuild/win32-arm64@0.25.12':
851
+
optional: true
852
+
853
+
'@esbuild/win32-ia32@0.25.12':
854
+
optional: true
855
+
856
+
'@esbuild/win32-x64@0.25.12':
857
+
optional: true
858
+
859
+
'@iconify-json/simple-icons@1.2.58':
860
+
dependencies:
861
+
'@iconify/types': 2.0.0
862
+
863
+
'@iconify/types@2.0.0': {}
864
+
865
+
'@jridgewell/sourcemap-codec@1.5.5': {}
866
+
867
+
'@rolldown/pluginutils@1.0.0-beta.29': {}
868
+
869
+
'@rollup/rollup-android-arm-eabi@4.53.1':
870
+
optional: true
871
+
872
+
'@rollup/rollup-android-arm64@4.53.1':
873
+
optional: true
874
+
875
+
'@rollup/rollup-darwin-arm64@4.53.1':
876
+
optional: true
877
+
878
+
'@rollup/rollup-darwin-x64@4.53.1':
879
+
optional: true
880
+
881
+
'@rollup/rollup-freebsd-arm64@4.53.1':
882
+
optional: true
883
+
884
+
'@rollup/rollup-freebsd-x64@4.53.1':
885
+
optional: true
886
+
887
+
'@rollup/rollup-linux-arm-gnueabihf@4.53.1':
888
+
optional: true
889
+
890
+
'@rollup/rollup-linux-arm-musleabihf@4.53.1':
891
+
optional: true
892
+
893
+
'@rollup/rollup-linux-arm64-gnu@4.53.1':
894
+
optional: true
895
+
896
+
'@rollup/rollup-linux-arm64-musl@4.53.1':
897
+
optional: true
898
+
899
+
'@rollup/rollup-linux-loong64-gnu@4.53.1':
900
+
optional: true
901
+
902
+
'@rollup/rollup-linux-ppc64-gnu@4.53.1':
903
+
optional: true
904
+
905
+
'@rollup/rollup-linux-riscv64-gnu@4.53.1':
906
+
optional: true
907
+
908
+
'@rollup/rollup-linux-riscv64-musl@4.53.1':
909
+
optional: true
910
+
911
+
'@rollup/rollup-linux-s390x-gnu@4.53.1':
912
+
optional: true
913
+
914
+
'@rollup/rollup-linux-x64-gnu@4.53.1':
915
+
optional: true
916
+
917
+
'@rollup/rollup-linux-x64-musl@4.53.1':
918
+
optional: true
919
+
920
+
'@rollup/rollup-openharmony-arm64@4.53.1':
921
+
optional: true
922
+
923
+
'@rollup/rollup-win32-arm64-msvc@4.53.1':
924
+
optional: true
925
+
926
+
'@rollup/rollup-win32-ia32-msvc@4.53.1':
927
+
optional: true
928
+
929
+
'@rollup/rollup-win32-x64-gnu@4.53.1':
930
+
optional: true
931
+
932
+
'@rollup/rollup-win32-x64-msvc@4.53.1':
933
+
optional: true
934
+
935
+
'@shikijs/core@3.15.0':
936
+
dependencies:
937
+
'@shikijs/types': 3.15.0
938
+
'@shikijs/vscode-textmate': 10.0.2
939
+
'@types/hast': 3.0.4
940
+
hast-util-to-html: 9.0.5
941
+
942
+
'@shikijs/engine-javascript@3.15.0':
943
+
dependencies:
944
+
'@shikijs/types': 3.15.0
945
+
'@shikijs/vscode-textmate': 10.0.2
946
+
oniguruma-to-es: 4.3.3
947
+
948
+
'@shikijs/engine-oniguruma@3.15.0':
949
+
dependencies:
950
+
'@shikijs/types': 3.15.0
951
+
'@shikijs/vscode-textmate': 10.0.2
952
+
953
+
'@shikijs/langs@3.15.0':
954
+
dependencies:
955
+
'@shikijs/types': 3.15.0
956
+
957
+
'@shikijs/themes@3.15.0':
958
+
dependencies:
959
+
'@shikijs/types': 3.15.0
960
+
961
+
'@shikijs/transformers@3.15.0':
962
+
dependencies:
963
+
'@shikijs/core': 3.15.0
964
+
'@shikijs/types': 3.15.0
965
+
966
+
'@shikijs/types@3.15.0':
967
+
dependencies:
968
+
'@shikijs/vscode-textmate': 10.0.2
969
+
'@types/hast': 3.0.4
970
+
971
+
'@shikijs/vscode-textmate@10.0.2': {}
972
+
973
+
'@types/estree@1.0.8': {}
974
+
975
+
'@types/hast@3.0.4':
976
+
dependencies:
977
+
'@types/unist': 3.0.3
978
+
979
+
'@types/linkify-it@5.0.0': {}
980
+
981
+
'@types/markdown-it@14.1.2':
982
+
dependencies:
983
+
'@types/linkify-it': 5.0.0
984
+
'@types/mdurl': 2.0.0
985
+
986
+
'@types/mdast@4.0.4':
987
+
dependencies:
988
+
'@types/unist': 3.0.3
989
+
990
+
'@types/mdurl@2.0.0': {}
991
+
992
+
'@types/unist@3.0.3': {}
993
+
994
+
'@types/web-bluetooth@0.0.21': {}
995
+
996
+
'@ungap/structured-clone@1.3.0': {}
997
+
998
+
'@vitejs/plugin-vue@6.0.1(vite@7.2.2)(vue@3.5.24(typescript@5.9.3))':
999
+
dependencies:
1000
+
'@rolldown/pluginutils': 1.0.0-beta.29
1001
+
vite: 7.2.2
1002
+
vue: 3.5.24(typescript@5.9.3)
1003
+
1004
+
'@vue/compiler-core@3.5.24':
1005
+
dependencies:
1006
+
'@babel/parser': 7.28.5
1007
+
'@vue/shared': 3.5.24
1008
+
entities: 4.5.0
1009
+
estree-walker: 2.0.2
1010
+
source-map-js: 1.2.1
1011
+
1012
+
'@vue/compiler-dom@3.5.24':
1013
+
dependencies:
1014
+
'@vue/compiler-core': 3.5.24
1015
+
'@vue/shared': 3.5.24
1016
+
1017
+
'@vue/compiler-sfc@3.5.24':
1018
+
dependencies:
1019
+
'@babel/parser': 7.28.5
1020
+
'@vue/compiler-core': 3.5.24
1021
+
'@vue/compiler-dom': 3.5.24
1022
+
'@vue/compiler-ssr': 3.5.24
1023
+
'@vue/shared': 3.5.24
1024
+
estree-walker: 2.0.2
1025
+
magic-string: 0.30.21
1026
+
postcss: 8.5.6
1027
+
source-map-js: 1.2.1
1028
+
1029
+
'@vue/compiler-ssr@3.5.24':
1030
+
dependencies:
1031
+
'@vue/compiler-dom': 3.5.24
1032
+
'@vue/shared': 3.5.24
1033
+
1034
+
'@vue/devtools-api@8.0.3':
1035
+
dependencies:
1036
+
'@vue/devtools-kit': 8.0.3
1037
+
1038
+
'@vue/devtools-kit@8.0.3':
1039
+
dependencies:
1040
+
'@vue/devtools-shared': 8.0.3
1041
+
birpc: 2.8.0
1042
+
hookable: 5.5.3
1043
+
mitt: 3.0.1
1044
+
perfect-debounce: 2.0.0
1045
+
speakingurl: 14.0.1
1046
+
superjson: 2.2.5
1047
+
1048
+
'@vue/devtools-shared@8.0.3':
1049
+
dependencies:
1050
+
rfdc: 1.4.1
1051
+
1052
+
'@vue/reactivity@3.5.24':
1053
+
dependencies:
1054
+
'@vue/shared': 3.5.24
1055
+
1056
+
'@vue/runtime-core@3.5.24':
1057
+
dependencies:
1058
+
'@vue/reactivity': 3.5.24
1059
+
'@vue/shared': 3.5.24
1060
+
1061
+
'@vue/runtime-dom@3.5.24':
1062
+
dependencies:
1063
+
'@vue/reactivity': 3.5.24
1064
+
'@vue/runtime-core': 3.5.24
1065
+
'@vue/shared': 3.5.24
1066
+
csstype: 3.1.3
1067
+
1068
+
'@vue/server-renderer@3.5.24(vue@3.5.24(typescript@5.9.3))':
1069
+
dependencies:
1070
+
'@vue/compiler-ssr': 3.5.24
1071
+
'@vue/shared': 3.5.24
1072
+
vue: 3.5.24(typescript@5.9.3)
1073
+
1074
+
'@vue/shared@3.5.24': {}
1075
+
1076
+
'@vueuse/core@13.9.0(vue@3.5.24(typescript@5.9.3))':
1077
+
dependencies:
1078
+
'@types/web-bluetooth': 0.0.21
1079
+
'@vueuse/metadata': 13.9.0
1080
+
'@vueuse/shared': 13.9.0(vue@3.5.24(typescript@5.9.3))
1081
+
vue: 3.5.24(typescript@5.9.3)
1082
+
1083
+
'@vueuse/integrations@13.9.0(focus-trap@7.6.6)(vue@3.5.24(typescript@5.9.3))':
1084
+
dependencies:
1085
+
'@vueuse/core': 13.9.0(vue@3.5.24(typescript@5.9.3))
1086
+
'@vueuse/shared': 13.9.0(vue@3.5.24(typescript@5.9.3))
1087
+
vue: 3.5.24(typescript@5.9.3)
1088
+
optionalDependencies:
1089
+
focus-trap: 7.6.6
1090
+
1091
+
'@vueuse/metadata@13.9.0': {}
1092
+
1093
+
'@vueuse/shared@13.9.0(vue@3.5.24(typescript@5.9.3))':
1094
+
dependencies:
1095
+
vue: 3.5.24(typescript@5.9.3)
1096
+
1097
+
birpc@2.8.0: {}
1098
+
1099
+
ccount@2.0.1: {}
1100
+
1101
+
character-entities-html4@2.1.0: {}
1102
+
1103
+
character-entities-legacy@3.0.0: {}
1104
+
1105
+
comma-separated-tokens@2.0.3: {}
1106
+
1107
+
copy-anything@4.0.5:
1108
+
dependencies:
1109
+
is-what: 5.5.0
1110
+
1111
+
csstype@3.1.3: {}
1112
+
1113
+
dequal@2.0.3: {}
1114
+
1115
+
devlop@1.1.0:
1116
+
dependencies:
1117
+
dequal: 2.0.3
1118
+
1119
+
entities@4.5.0: {}
1120
+
1121
+
esbuild@0.25.12:
1122
+
optionalDependencies:
1123
+
'@esbuild/aix-ppc64': 0.25.12
1124
+
'@esbuild/android-arm': 0.25.12
1125
+
'@esbuild/android-arm64': 0.25.12
1126
+
'@esbuild/android-x64': 0.25.12
1127
+
'@esbuild/darwin-arm64': 0.25.12
1128
+
'@esbuild/darwin-x64': 0.25.12
1129
+
'@esbuild/freebsd-arm64': 0.25.12
1130
+
'@esbuild/freebsd-x64': 0.25.12
1131
+
'@esbuild/linux-arm': 0.25.12
1132
+
'@esbuild/linux-arm64': 0.25.12
1133
+
'@esbuild/linux-ia32': 0.25.12
1134
+
'@esbuild/linux-loong64': 0.25.12
1135
+
'@esbuild/linux-mips64el': 0.25.12
1136
+
'@esbuild/linux-ppc64': 0.25.12
1137
+
'@esbuild/linux-riscv64': 0.25.12
1138
+
'@esbuild/linux-s390x': 0.25.12
1139
+
'@esbuild/linux-x64': 0.25.12
1140
+
'@esbuild/netbsd-arm64': 0.25.12
1141
+
'@esbuild/netbsd-x64': 0.25.12
1142
+
'@esbuild/openbsd-arm64': 0.25.12
1143
+
'@esbuild/openbsd-x64': 0.25.12
1144
+
'@esbuild/openharmony-arm64': 0.25.12
1145
+
'@esbuild/sunos-x64': 0.25.12
1146
+
'@esbuild/win32-arm64': 0.25.12
1147
+
'@esbuild/win32-ia32': 0.25.12
1148
+
'@esbuild/win32-x64': 0.25.12
1149
+
1150
+
estree-walker@2.0.2: {}
1151
+
1152
+
fdir@6.5.0(picomatch@4.0.3):
1153
+
optionalDependencies:
1154
+
picomatch: 4.0.3
1155
+
1156
+
focus-trap@7.6.6:
1157
+
dependencies:
1158
+
tabbable: 6.3.0
1159
+
1160
+
fsevents@2.3.3:
1161
+
optional: true
1162
+
1163
+
hast-util-to-html@9.0.5:
1164
+
dependencies:
1165
+
'@types/hast': 3.0.4
1166
+
'@types/unist': 3.0.3
1167
+
ccount: 2.0.1
1168
+
comma-separated-tokens: 2.0.3
1169
+
hast-util-whitespace: 3.0.0
1170
+
html-void-elements: 3.0.0
1171
+
mdast-util-to-hast: 13.2.0
1172
+
property-information: 7.1.0
1173
+
space-separated-tokens: 2.0.2
1174
+
stringify-entities: 4.0.4
1175
+
zwitch: 2.0.4
1176
+
1177
+
hast-util-whitespace@3.0.0:
1178
+
dependencies:
1179
+
'@types/hast': 3.0.4
1180
+
1181
+
hookable@5.5.3: {}
1182
+
1183
+
htm@3.1.1: {}
1184
+
1185
+
html-void-elements@3.0.0: {}
1186
+
1187
+
is-what@5.5.0: {}
1188
+
1189
+
magic-string@0.30.21:
1190
+
dependencies:
1191
+
'@jridgewell/sourcemap-codec': 1.5.5
1192
+
1193
+
mark.js@8.11.1: {}
1194
+
1195
+
mdast-util-to-hast@13.2.0:
1196
+
dependencies:
1197
+
'@types/hast': 3.0.4
1198
+
'@types/mdast': 4.0.4
1199
+
'@ungap/structured-clone': 1.3.0
1200
+
devlop: 1.1.0
1201
+
micromark-util-sanitize-uri: 2.0.1
1202
+
trim-lines: 3.0.1
1203
+
unist-util-position: 5.0.0
1204
+
unist-util-visit: 5.0.0
1205
+
vfile: 6.0.3
1206
+
1207
+
micromark-util-character@2.1.1:
1208
+
dependencies:
1209
+
micromark-util-symbol: 2.0.1
1210
+
micromark-util-types: 2.0.2
1211
+
1212
+
micromark-util-encode@2.0.1: {}
1213
+
1214
+
micromark-util-sanitize-uri@2.0.1:
1215
+
dependencies:
1216
+
micromark-util-character: 2.1.1
1217
+
micromark-util-encode: 2.0.1
1218
+
micromark-util-symbol: 2.0.1
1219
+
1220
+
micromark-util-symbol@2.0.1: {}
1221
+
1222
+
micromark-util-types@2.0.2: {}
1223
+
1224
+
minisearch@7.2.0: {}
1225
+
1226
+
mitt@3.0.1: {}
1227
+
1228
+
nanoid@3.3.11: {}
1229
+
1230
+
oniguruma-parser@0.12.1: {}
1231
+
1232
+
oniguruma-to-es@4.3.3:
1233
+
dependencies:
1234
+
oniguruma-parser: 0.12.1
1235
+
regex: 6.0.1
1236
+
regex-recursion: 6.0.2
1237
+
1238
+
perfect-debounce@2.0.0: {}
1239
+
1240
+
picocolors@1.1.1: {}
1241
+
1242
+
picomatch@4.0.3: {}
1243
+
1244
+
postcss@8.5.6:
1245
+
dependencies:
1246
+
nanoid: 3.3.11
1247
+
picocolors: 1.1.1
1248
+
source-map-js: 1.2.1
1249
+
1250
+
property-information@7.1.0: {}
1251
+
1252
+
regex-recursion@6.0.2:
1253
+
dependencies:
1254
+
regex-utilities: 2.3.0
1255
+
1256
+
regex-utilities@2.3.0: {}
1257
+
1258
+
regex@6.0.1:
1259
+
dependencies:
1260
+
regex-utilities: 2.3.0
1261
+
1262
+
rfdc@1.4.1: {}
1263
+
1264
+
rollup@4.53.1:
1265
+
dependencies:
1266
+
'@types/estree': 1.0.8
1267
+
optionalDependencies:
1268
+
'@rollup/rollup-android-arm-eabi': 4.53.1
1269
+
'@rollup/rollup-android-arm64': 4.53.1
1270
+
'@rollup/rollup-darwin-arm64': 4.53.1
1271
+
'@rollup/rollup-darwin-x64': 4.53.1
1272
+
'@rollup/rollup-freebsd-arm64': 4.53.1
1273
+
'@rollup/rollup-freebsd-x64': 4.53.1
1274
+
'@rollup/rollup-linux-arm-gnueabihf': 4.53.1
1275
+
'@rollup/rollup-linux-arm-musleabihf': 4.53.1
1276
+
'@rollup/rollup-linux-arm64-gnu': 4.53.1
1277
+
'@rollup/rollup-linux-arm64-musl': 4.53.1
1278
+
'@rollup/rollup-linux-loong64-gnu': 4.53.1
1279
+
'@rollup/rollup-linux-ppc64-gnu': 4.53.1
1280
+
'@rollup/rollup-linux-riscv64-gnu': 4.53.1
1281
+
'@rollup/rollup-linux-riscv64-musl': 4.53.1
1282
+
'@rollup/rollup-linux-s390x-gnu': 4.53.1
1283
+
'@rollup/rollup-linux-x64-gnu': 4.53.1
1284
+
'@rollup/rollup-linux-x64-musl': 4.53.1
1285
+
'@rollup/rollup-openharmony-arm64': 4.53.1
1286
+
'@rollup/rollup-win32-arm64-msvc': 4.53.1
1287
+
'@rollup/rollup-win32-ia32-msvc': 4.53.1
1288
+
'@rollup/rollup-win32-x64-gnu': 4.53.1
1289
+
'@rollup/rollup-win32-x64-msvc': 4.53.1
1290
+
fsevents: 2.3.3
1291
+
1292
+
shiki@3.15.0:
1293
+
dependencies:
1294
+
'@shikijs/core': 3.15.0
1295
+
'@shikijs/engine-javascript': 3.15.0
1296
+
'@shikijs/engine-oniguruma': 3.15.0
1297
+
'@shikijs/langs': 3.15.0
1298
+
'@shikijs/themes': 3.15.0
1299
+
'@shikijs/types': 3.15.0
1300
+
'@shikijs/vscode-textmate': 10.0.2
1301
+
'@types/hast': 3.0.4
1302
+
1303
+
source-map-js@1.2.1: {}
1304
+
1305
+
space-separated-tokens@2.0.2: {}
1306
+
1307
+
speakingurl@14.0.1: {}
1308
+
1309
+
stringify-entities@4.0.4:
1310
+
dependencies:
1311
+
character-entities-html4: 2.1.0
1312
+
character-entities-legacy: 3.0.0
1313
+
1314
+
superjson@2.2.5:
1315
+
dependencies:
1316
+
copy-anything: 4.0.5
1317
+
1318
+
tabbable@6.3.0: {}
1319
+
1320
+
tinyglobby@0.2.15:
1321
+
dependencies:
1322
+
fdir: 6.5.0(picomatch@4.0.3)
1323
+
picomatch: 4.0.3
1324
+
1325
+
trim-lines@3.0.1: {}
1326
+
1327
+
typescript@5.9.3: {}
1328
+
1329
+
unist-util-is@6.0.1:
1330
+
dependencies:
1331
+
'@types/unist': 3.0.3
1332
+
1333
+
unist-util-position@5.0.0:
1334
+
dependencies:
1335
+
'@types/unist': 3.0.3
1336
+
1337
+
unist-util-stringify-position@4.0.0:
1338
+
dependencies:
1339
+
'@types/unist': 3.0.3
1340
+
1341
+
unist-util-visit-parents@6.0.2:
1342
+
dependencies:
1343
+
'@types/unist': 3.0.3
1344
+
unist-util-is: 6.0.1
1345
+
1346
+
unist-util-visit@5.0.0:
1347
+
dependencies:
1348
+
'@types/unist': 3.0.3
1349
+
unist-util-is: 6.0.1
1350
+
unist-util-visit-parents: 6.0.2
1351
+
1352
+
vfile-message@4.0.3:
1353
+
dependencies:
1354
+
'@types/unist': 3.0.3
1355
+
unist-util-stringify-position: 4.0.0
1356
+
1357
+
vfile@6.0.3:
1358
+
dependencies:
1359
+
'@types/unist': 3.0.3
1360
+
vfile-message: 4.0.3
1361
+
1362
+
vite@7.2.2:
1363
+
dependencies:
1364
+
esbuild: 0.25.12
1365
+
fdir: 6.5.0(picomatch@4.0.3)
1366
+
picomatch: 4.0.3
1367
+
postcss: 8.5.6
1368
+
rollup: 4.53.1
1369
+
tinyglobby: 0.2.15
1370
+
optionalDependencies:
1371
+
fsevents: 2.3.3
1372
+
1373
+
vitepress@2.0.0-alpha.12(postcss@8.5.6)(typescript@5.9.3):
1374
+
dependencies:
1375
+
'@docsearch/css': 4.3.1
1376
+
'@docsearch/js': 4.3.1
1377
+
'@iconify-json/simple-icons': 1.2.58
1378
+
'@shikijs/core': 3.15.0
1379
+
'@shikijs/transformers': 3.15.0
1380
+
'@shikijs/types': 3.15.0
1381
+
'@types/markdown-it': 14.1.2
1382
+
'@vitejs/plugin-vue': 6.0.1(vite@7.2.2)(vue@3.5.24(typescript@5.9.3))
1383
+
'@vue/devtools-api': 8.0.3
1384
+
'@vue/shared': 3.5.24
1385
+
'@vueuse/core': 13.9.0(vue@3.5.24(typescript@5.9.3))
1386
+
'@vueuse/integrations': 13.9.0(focus-trap@7.6.6)(vue@3.5.24(typescript@5.9.3))
1387
+
focus-trap: 7.6.6
1388
+
mark.js: 8.11.1
1389
+
minisearch: 7.2.0
1390
+
shiki: 3.15.0
1391
+
vite: 7.2.2
1392
+
vue: 3.5.24(typescript@5.9.3)
1393
+
optionalDependencies:
1394
+
postcss: 8.5.6
1395
+
transitivePeerDependencies:
1396
+
- '@types/node'
1397
+
- async-validator
1398
+
- axios
1399
+
- change-case
1400
+
- drauu
1401
+
- fuse.js
1402
+
- idb-keyval
1403
+
- jiti
1404
+
- jwt-decode
1405
+
- less
1406
+
- lightningcss
1407
+
- nprogress
1408
+
- qrcode
1409
+
- sass
1410
+
- sass-embedded
1411
+
- sortablejs
1412
+
- stylus
1413
+
- sugarss
1414
+
- terser
1415
+
- tsx
1416
+
- typescript
1417
+
- universal-cookie
1418
+
- yaml
1419
+
1420
+
vue@3.5.24(typescript@5.9.3):
1421
+
dependencies:
1422
+
'@vue/compiler-dom': 3.5.24
1423
+
'@vue/compiler-sfc': 3.5.24
1424
+
'@vue/runtime-dom': 3.5.24
1425
+
'@vue/server-renderer': 3.5.24(vue@3.5.24(typescript@5.9.3))
1426
+
'@vue/shared': 3.5.24
1427
+
optionalDependencies:
1428
+
typescript: 5.9.3
1429
+
1430
+
zwitch@2.0.4: {}