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

Compare changes

Choose any two refs to compare.

+4
.gitignore
··· 33 33 .gocache/ 34 34 .task/ 35 35 tmp/ 36 + node_modules/ 37 + **/.vitepress/cache/ 38 + # Added by goreleaser init: 39 + dist/
+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
··· 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
··· 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
··· 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
··· 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
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + "path/filepath" 6 + 7 + "github.com/spf13/cobra" 8 + "github.com/stormlightlabs/git-storm/internal/changelog" 9 + "github.com/stormlightlabs/git-storm/internal/style" 10 + "github.com/stormlightlabs/git-storm/internal/versioning" 11 + ) 12 + 13 + func bumpCmd() *cobra.Command { 14 + var bumpKind string 15 + var toolchainSelectors []string 16 + 17 + cmd := &cobra.Command{ 18 + Use: "bump", 19 + Short: "Calculate the next semantic version and optionally update toolchain manifests", 20 + RunE: func(cmd *cobra.Command, args []string) error { 21 + kind, err := versioning.ParseBumpType(bumpKind) 22 + if err != nil { 23 + return err 24 + } 25 + 26 + changelogPath := filepath.Join(repoPath, output) 27 + parsed, err := changelog.Parse(changelogPath) 28 + if err != nil { 29 + return fmt.Errorf("failed to parse changelog: %w", err) 30 + } 31 + 32 + current, _ := versioning.LatestVersion(parsed) 33 + nextVersion, err := versioning.Next(current, kind) 34 + if err != nil { 35 + return err 36 + } 37 + 38 + style.Headlinef("Next version: %s", nextVersion) 39 + 40 + updated, err := updateToolchainTargets(repoPath, nextVersion, toolchainSelectors) 41 + if err != nil { 42 + return err 43 + } 44 + for _, manifest := range updated { 45 + style.Addedf("โœ“ Updated %s", manifest.RelPath) 46 + } 47 + 48 + fmt.Fprintln(cmd.OutOrStdout(), nextVersion) 49 + return nil 50 + }, 51 + } 52 + 53 + cmd.Flags().StringVar(&bumpKind, "bump", "", "Which semver component to bump (major, minor, or patch)") 54 + cmd.Flags().StringSliceVar(&toolchainSelectors, "toolchain", nil, "Toolchain manifests to update (paths, types, or 'interactive')") 55 + cmd.MarkFlagRequired("bump") 56 + 57 + return cmd 58 + }
+86
cmd/bump_test.go
··· 1 + package main 2 + 3 + import ( 4 + "bytes" 5 + "os" 6 + "path/filepath" 7 + "strings" 8 + "testing" 9 + ) 10 + 11 + const sampleChangelog = `# Changelog 12 + 13 + ## [Unreleased] 14 + 15 + ## [1.2.3] - 2024-01-01 16 + ### Added 17 + - Initial release 18 + ` 19 + 20 + func TestBumpCommandPrintsVersion(t *testing.T) { 21 + dir := t.TempDir() 22 + writeFile(t, filepath.Join(dir, "CHANGELOG.md"), sampleChangelog) 23 + 24 + oldRepo := repoPath 25 + oldOutput := output 26 + repoPath = dir 27 + output = "CHANGELOG.md" 28 + t.Cleanup(func() { 29 + repoPath = oldRepo 30 + output = oldOutput 31 + }) 32 + 33 + cmd := bumpCmd() 34 + cmd.SetArgs([]string{"--bump", "minor"}) 35 + var out bytes.Buffer 36 + cmd.SetOut(&out) 37 + cmd.SetErr(&out) 38 + 39 + if err := cmd.Execute(); err != nil { 40 + t.Fatalf("bump command failed: %v", err) 41 + } 42 + 43 + if !strings.Contains(out.String(), "1.3.0") { 44 + t.Fatalf("expected output to contain 1.3.0, got %q", out.String()) 45 + } 46 + } 47 + 48 + func TestBumpCommandUpdatesPackageJSON(t *testing.T) { 49 + dir := t.TempDir() 50 + writeFile(t, filepath.Join(dir, "CHANGELOG.md"), sampleChangelog) 51 + writeFile(t, filepath.Join(dir, "package.json"), `{"name":"demo","version":"1.2.3"}`) 52 + 53 + oldRepo := repoPath 54 + oldOutput := output 55 + repoPath = dir 56 + output = "CHANGELOG.md" 57 + t.Cleanup(func() { 58 + repoPath = oldRepo 59 + output = oldOutput 60 + }) 61 + 62 + cmd := bumpCmd() 63 + cmd.SetArgs([]string{"--bump", "patch", "--toolchain", "package.json"}) 64 + var out bytes.Buffer 65 + cmd.SetOut(&out) 66 + cmd.SetErr(&out) 67 + 68 + if err := cmd.Execute(); err != nil { 69 + t.Fatalf("bump command failed: %v", err) 70 + } 71 + 72 + contents, err := os.ReadFile(filepath.Join(dir, "package.json")) 73 + if err != nil { 74 + t.Fatalf("failed to read package.json: %v", err) 75 + } 76 + 77 + if !strings.Contains(string(contents), "1.2.4") { 78 + t.Fatalf("expected package.json to contain bumped version, got %s", contents) 79 + } 80 + } 81 + 82 + func writeFile(t *testing.T, path, contents string) { 83 + if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { 84 + t.Fatalf("failed to write %s: %v", path, err) 85 + } 86 + }
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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/shared/shared.go
··· 1 + package shared 2 + 3 + import ( 4 + "golang.org/x/text/cases" 5 + "golang.org/x/text/language" 6 + ) 7 + 8 + var caser = cases.Title(language.English) 9 + 10 + func TitleCase(s string) string { 11 + return caser.String(s) 12 + }
+36
internal/shared/shared_test.go
··· 1 + package shared 2 + 3 + import "testing" 4 + 5 + func TestTitleCase(t *testing.T) { 6 + t.Run("Basic", func(t *testing.T) { 7 + got := TitleCase("hello world") 8 + want := "Hello World" 9 + if got != want { 10 + t.Fatalf("TitleCase() = %q, want %q", got, want) 11 + } 12 + }) 13 + 14 + t.Run("MixedCase", func(t *testing.T) { 15 + got := TitleCase("go is GREAT") 16 + want := "Go Is Great" 17 + if got != want { 18 + t.Fatalf("TitleCase() = %q, want %q", got, want) 19 + } 20 + }) 21 + 22 + t.Run("WithPunctuation", func(t *testing.T) { 23 + got := TitleCase("don't stop believing") 24 + want := "Don't Stop Believing" 25 + if got != want { 26 + t.Fatalf("TitleCase() = %q, want %q", got, want) 27 + } 28 + }) 29 + 30 + t.Run("ExtraSpaces", func(t *testing.T) { 31 + got := TitleCase(" leading and internal spaces ") 32 + if got != " Leading And Internal Spaces " { 33 + t.Fatalf("TitleCase() = %q, spacing/words not as expected", got) 34 + } 35 + }) 36 + }
+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
··· 1 + package toolchain 2 + 3 + import ( 4 + "bytes" 5 + "encoding/json" 6 + "fmt" 7 + "io/fs" 8 + "os" 9 + "path/filepath" 10 + "regexp" 11 + "sort" 12 + "strings" 13 + ) 14 + 15 + // ManifestType enumerates supported ecosystem manifests whose versions we can bump. 16 + type ManifestType string 17 + 18 + const ( 19 + ManifestCargo ManifestType = "cargo" 20 + ManifestPython ManifestType = "python" 21 + ManifestNode ManifestType = "node" 22 + ManifestDeno ManifestType = "deno" 23 + ) 24 + 25 + var manifestFilenames = map[string]ManifestType{ 26 + "cargo.toml": ManifestCargo, 27 + "pyproject.toml": ManifestPython, 28 + "package.json": ManifestNode, 29 + "deno.json": ManifestDeno, 30 + } 31 + 32 + var toolchainAliases = map[string]ManifestType{ 33 + "cargo": ManifestCargo, 34 + "rust": ManifestCargo, 35 + "cargo.toml": ManifestCargo, 36 + "pyproject": ManifestPython, 37 + "pyproject.toml": ManifestPython, 38 + "python": ManifestPython, 39 + "package": ManifestNode, 40 + "package.json": ManifestNode, 41 + "npm": ManifestNode, 42 + "node": ManifestNode, 43 + "deno": ManifestDeno, 44 + "deno.json": ManifestDeno, 45 + } 46 + 47 + var skipWalkDirs = map[string]struct{}{ 48 + ".git": {}, 49 + "node_modules": {}, 50 + "vendor": {}, 51 + "dist": {}, 52 + "target": {}, 53 + "tmp": {}, 54 + } 55 + 56 + // Manifest describes a discovered manifest file with its current version. 57 + type Manifest struct { 58 + Type ManifestType 59 + Path string 60 + RelPath string 61 + Version string 62 + Name string 63 + } 64 + 65 + // DisplayLabel returns a concise label for TUI listings. 66 + func (m Manifest) DisplayLabel() string { 67 + label := m.RelPath 68 + if m.Name != "" { 69 + label = fmt.Sprintf("%s ยท %s", label, m.Name) 70 + } 71 + if m.Version != "" { 72 + label = fmt.Sprintf("%s @ %s", label, m.Version) 73 + } 74 + return label 75 + } 76 + 77 + // Discover scans the repository tree for supported manifest files. 78 + func Discover(root string) ([]Manifest, error) { 79 + absRoot, err := filepath.Abs(root) 80 + if err != nil { 81 + return nil, err 82 + } 83 + 84 + var manifests []Manifest 85 + err = filepath.WalkDir(absRoot, func(path string, d fs.DirEntry, walkErr error) error { 86 + if walkErr != nil { 87 + return walkErr 88 + } 89 + if d.IsDir() { 90 + if path != absRoot { 91 + if _, skip := skipWalkDirs[strings.ToLower(d.Name())]; skip { 92 + return filepath.SkipDir 93 + } 94 + } 95 + return nil 96 + } 97 + 98 + kind, ok := manifestFilenames[strings.ToLower(d.Name())] 99 + if !ok { 100 + return nil 101 + } 102 + 103 + manifest, err := buildManifest(absRoot, path, kind) 104 + if err != nil { 105 + return err 106 + } 107 + manifests = append(manifests, manifest) 108 + return nil 109 + }) 110 + if err != nil { 111 + return nil, err 112 + } 113 + 114 + sort.Slice(manifests, func(i, j int) bool { 115 + return manifests[i].RelPath < manifests[j].RelPath 116 + }) 117 + return manifests, nil 118 + } 119 + 120 + // ResolveTargets resolves CLI selectors into manifest targets, optionally requesting a TUI selection. 121 + func ResolveTargets(root string, selectors []string) ([]Manifest, bool, []Manifest, error) { 122 + if len(selectors) == 0 { 123 + return nil, false, nil, nil 124 + } 125 + 126 + absRoot, err := filepath.Abs(root) 127 + if err != nil { 128 + return nil, false, nil, err 129 + } 130 + 131 + available, err := Discover(absRoot) 132 + if err != nil { 133 + return nil, false, nil, err 134 + } 135 + 136 + manifestByPath := make(map[string]Manifest) 137 + for _, manifest := range available { 138 + manifestByPath[filepath.Clean(manifest.Path)] = manifest 139 + } 140 + 141 + var selected []Manifest 142 + seen := make(map[string]struct{}) 143 + interactive := false 144 + 145 + for _, raw := range selectors { 146 + value := strings.TrimSpace(raw) 147 + if value == "" { 148 + continue 149 + } 150 + 151 + lower := strings.ToLower(value) 152 + switch lower { 153 + case "interactive", "tui", "select": 154 + interactive = true 155 + continue 156 + } 157 + 158 + if kind, ok := toolchainAliases[lower]; ok { 159 + matched := false 160 + for _, manifest := range available { 161 + if manifest.Type == kind { 162 + key := filepath.Clean(manifest.Path) 163 + if _, exists := seen[key]; !exists { 164 + selected = append(selected, manifest) 165 + seen[key] = struct{}{} 166 + } 167 + matched = true 168 + } 169 + } 170 + if !matched { 171 + return nil, false, nil, fmt.Errorf("no %s manifest found", value) 172 + } 173 + continue 174 + } 175 + 176 + target := value 177 + if !filepath.IsAbs(value) { 178 + target = filepath.Join(absRoot, value) 179 + } 180 + target = filepath.Clean(target) 181 + 182 + manifest, err := loadManifest(absRoot, target) 183 + if err != nil { 184 + return nil, false, nil, err 185 + } 186 + 187 + if _, exists := seen[target]; !exists { 188 + selected = append(selected, manifest) 189 + seen[target] = struct{}{} 190 + } 191 + } 192 + 193 + return selected, interactive, available, nil 194 + } 195 + 196 + // UpdateManifest rewrites the manifest on disk with the provided version. 197 + func UpdateManifest(manifest Manifest, newVersion string) error { 198 + switch manifest.Type { 199 + case ManifestCargo: 200 + return updateTomlVersion(manifest.Path, []string{"package"}, newVersion) 201 + case ManifestPython: 202 + return updateTomlVersion(manifest.Path, []string{"project", "tool.poetry"}, newVersion) 203 + case ManifestNode: 204 + return updateJSONVersion(manifest.Path, newVersion) 205 + case ManifestDeno: 206 + return updateJSONVersion(manifest.Path, newVersion) 207 + default: 208 + return fmt.Errorf("unsupported manifest type: %s", manifest.Type) 209 + } 210 + } 211 + 212 + func buildManifest(root, path string, kind ManifestType) (Manifest, error) { 213 + version, name, err := extractMetadata(path, kind) 214 + if err != nil { 215 + return Manifest{}, err 216 + } 217 + 218 + rel, err := filepath.Rel(root, path) 219 + if err != nil { 220 + rel = path 221 + } 222 + 223 + return Manifest{ 224 + Type: kind, 225 + Path: filepath.Clean(path), 226 + RelPath: filepath.Clean(rel), 227 + Version: version, 228 + Name: name, 229 + }, nil 230 + } 231 + 232 + func loadManifest(root, path string) (Manifest, error) { 233 + info, err := os.Stat(path) 234 + if err != nil { 235 + return Manifest{}, fmt.Errorf("unable to read %s: %w", path, err) 236 + } 237 + if info.IsDir() { 238 + return Manifest{}, fmt.Errorf("%s is a directory", path) 239 + } 240 + 241 + kind, ok := manifestFilenames[strings.ToLower(filepath.Base(path))] 242 + if !ok { 243 + return Manifest{}, fmt.Errorf("unsupported toolchain file: %s", filepath.Base(path)) 244 + } 245 + 246 + return buildManifest(root, path, kind) 247 + } 248 + 249 + func extractMetadata(path string, kind ManifestType) (string, string, error) { 250 + switch kind { 251 + case ManifestCargo: 252 + return parseTomlManifest(path, []string{"package"}) 253 + case ManifestPython: 254 + return parseTomlManifest(path, []string{"project", "tool.poetry"}) 255 + case ManifestNode: 256 + return parseJSONManifest(path) 257 + case ManifestDeno: 258 + return parseJSONManifest(path) 259 + default: 260 + return "", "", fmt.Errorf("unsupported manifest type: %s", kind) 261 + } 262 + } 263 + 264 + func parseJSONManifest(path string) (string, string, error) { 265 + data, err := os.ReadFile(path) 266 + if err != nil { 267 + return "", "", err 268 + } 269 + 270 + var payload map[string]any 271 + if err := json.Unmarshal(data, &payload); err != nil { 272 + return "", "", fmt.Errorf("failed to parse %s: %w", filepath.Base(path), err) 273 + } 274 + 275 + version, _ := payload["version"].(string) 276 + if version == "" { 277 + return "", "", fmt.Errorf("version not found in %s", filepath.Base(path)) 278 + } 279 + 280 + name, _ := payload["name"].(string) 281 + return version, name, nil 282 + } 283 + 284 + func parseTomlManifest(path string, sections []string) (string, string, error) { 285 + data, err := os.ReadFile(path) 286 + if err != nil { 287 + return "", "", err 288 + } 289 + 290 + sectionSet := make(map[string]struct{}) 291 + for _, section := range sections { 292 + sectionSet[section] = struct{}{} 293 + } 294 + 295 + var current string 296 + var version string 297 + var name string 298 + lines := strings.Split(string(data), "\n") 299 + 300 + for _, line := range lines { 301 + trimmed := strings.TrimSpace(line) 302 + if trimmed == "" || strings.HasPrefix(trimmed, "#") { 303 + continue 304 + } 305 + if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { 306 + current = strings.TrimSpace(strings.Trim(trimmed, "[]")) 307 + continue 308 + } 309 + if _, ok := sectionSet[current]; !ok { 310 + continue 311 + } 312 + 313 + if value, ok := parseTomlAssignment(line, "version"); ok && version == "" { 314 + version = value 315 + continue 316 + } 317 + if value, ok := parseTomlAssignment(line, "name"); ok && name == "" { 318 + name = value 319 + } 320 + } 321 + 322 + if version == "" { 323 + return "", "", fmt.Errorf("version not found in %s", filepath.Base(path)) 324 + } 325 + 326 + return version, name, nil 327 + } 328 + 329 + func parseTomlAssignment(line, key string) (string, bool) { 330 + withoutComment := strings.Split(line, "#")[0] 331 + parts := strings.SplitN(withoutComment, "=", 2) 332 + if len(parts) != 2 { 333 + return "", false 334 + } 335 + if strings.TrimSpace(parts[0]) != key { 336 + return "", false 337 + } 338 + 339 + value := strings.TrimSpace(parts[1]) 340 + if len(value) >= 2 { 341 + if (value[0] == '"' && value[len(value)-1] == '"') || (value[0] == '\'' && value[len(value)-1] == '\'') { 342 + value = value[1 : len(value)-1] 343 + } 344 + } 345 + return value, true 346 + } 347 + 348 + var tomlVersionPattern = regexp.MustCompile(`^(\s*version\s*=\s*)(['"])([^'"]*)(['"])(.*)$`) 349 + 350 + func updateTomlVersion(path string, sections []string, newVersion string) error { 351 + data, err := os.ReadFile(path) 352 + if err != nil { 353 + return err 354 + } 355 + 356 + sectionSet := make(map[string]struct{}) 357 + for _, section := range sections { 358 + sectionSet[section] = struct{}{} 359 + } 360 + 361 + lines := strings.Split(string(data), "\n") 362 + current := "" 363 + replaced := false 364 + 365 + for idx, line := range lines { 366 + trimmed := strings.TrimSpace(line) 367 + if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { 368 + current = strings.TrimSpace(strings.Trim(trimmed, "[]")) 369 + continue 370 + } 371 + if replaced { 372 + continue 373 + } 374 + if _, ok := sectionSet[current]; !ok { 375 + continue 376 + } 377 + if matches := tomlVersionPattern.FindStringSubmatch(line); matches != nil { 378 + lines[idx] = fmt.Sprintf("%s%s%s%s%s", matches[1], matches[2], newVersion, matches[4], matches[5]) 379 + replaced = true 380 + } 381 + } 382 + 383 + if !replaced { 384 + return fmt.Errorf("version not found in %s", filepath.Base(path)) 385 + } 386 + 387 + return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0644) 388 + } 389 + 390 + func updateJSONVersion(path string, newVersion string) error { 391 + data, err := os.ReadFile(path) 392 + if err != nil { 393 + return err 394 + } 395 + 396 + start, end, err := findRootJSONVersion(data) 397 + if err != nil { 398 + return fmt.Errorf("version not found in %s", filepath.Base(path)) 399 + } 400 + 401 + var buf bytes.Buffer 402 + buf.Grow(len(data) - (end - start) + len(newVersion)) 403 + buf.Write(data[:start]) 404 + buf.WriteString(newVersion) 405 + buf.Write(data[end:]) 406 + 407 + return os.WriteFile(path, buf.Bytes(), 0644) 408 + } 409 + 410 + func findRootJSONVersion(data []byte) (int, int, error) { 411 + depth := 0 412 + inString := false 413 + escape := false 414 + keyStart := -1 415 + 416 + for i := 0; i < len(data); i++ { 417 + b := data[i] 418 + if inString { 419 + if escape { 420 + escape = false 421 + continue 422 + } 423 + if b == '\\' { 424 + escape = true 425 + continue 426 + } 427 + if b == '"' { 428 + inString = false 429 + keyEnd := i 430 + if keyStart >= 0 { 431 + j := i + 1 432 + for j < len(data) && (data[j] == ' ' || data[j] == '\t' || data[j] == '\n' || data[j] == '\r') { 433 + j++ 434 + } 435 + if j < len(data) && data[j] == ':' { 436 + key := string(data[keyStart:keyEnd]) 437 + if key == "version" && depth == 1 { 438 + valueStart, valueEnd, err := locateJSONString(data, j+1) 439 + if err != nil { 440 + return -1, -1, err 441 + } 442 + return valueStart, valueEnd, nil 443 + } 444 + } 445 + } 446 + keyStart = -1 447 + } 448 + continue 449 + } 450 + 451 + switch b { 452 + case '"': 453 + inString = true 454 + keyStart = i + 1 455 + case '{', '[': 456 + depth++ 457 + case '}', ']': 458 + if depth > 0 { 459 + depth-- 460 + } 461 + } 462 + } 463 + 464 + return -1, -1, fmt.Errorf("version key not found") 465 + } 466 + 467 + func locateJSONString(data []byte, start int) (int, int, error) { 468 + i := start 469 + for i < len(data) && (data[i] == ' ' || data[i] == '\t' || data[i] == '\n' || data[i] == '\r') { 470 + i++ 471 + } 472 + if i >= len(data) || data[i] != '"' { 473 + return -1, -1, fmt.Errorf("version value must be a string") 474 + } 475 + valueStart := i + 1 476 + for j := valueStart; j < len(data); j++ { 477 + if data[j] == '\\' { 478 + j++ 479 + continue 480 + } 481 + if data[j] == '"' { 482 + return valueStart, j, nil 483 + } 484 + } 485 + return -1, -1, fmt.Errorf("unterminated version string") 486 + }
+145
internal/toolchain/manifest_test.go
··· 1 + package toolchain 2 + 3 + import ( 4 + "os" 5 + "path/filepath" 6 + "strings" 7 + "testing" 8 + ) 9 + 10 + func TestDiscover(t *testing.T) { 11 + dir := t.TempDir() 12 + 13 + writeFile(t, dir, "Cargo.toml", `[package] 14 + name = "demo" 15 + version = "0.1.0" 16 + 17 + [dependencies] 18 + serde = "1" 19 + `) 20 + 21 + subDir := filepath.Join(dir, "app") 22 + if err := os.MkdirAll(subDir, 0o755); err != nil { 23 + t.Fatalf("failed to create subdir: %v", err) 24 + } 25 + writeFile(t, subDir, "pyproject.toml", `[project] 26 + name = "demo-app" 27 + version = "0.2.0" 28 + `) 29 + 30 + writeFile(t, dir, "package.json", `{ 31 + "name": "demo-web", 32 + "version": "1.5.0" 33 + }`) 34 + 35 + writeFile(t, dir, "deno.json", `{ 36 + "version": "3.1.4" 37 + }`) 38 + 39 + manifests, err := Discover(dir) 40 + if err != nil { 41 + t.Fatalf("Discover returned error: %v", err) 42 + } 43 + if len(manifests) != 4 { 44 + t.Fatalf("expected 4 manifests, got %d", len(manifests)) 45 + } 46 + 47 + manifestByType := make(map[ManifestType]Manifest) 48 + for _, manifest := range manifests { 49 + manifestByType[manifest.Type] = manifest 50 + } 51 + 52 + if manifestByType[ManifestCargo].Version != "0.1.0" { 53 + t.Fatalf("cargo version mismatch: %#v", manifestByType[ManifestCargo]) 54 + } 55 + if manifestByType[ManifestPython].Version != "0.2.0" { 56 + t.Fatalf("python version mismatch: %#v", manifestByType[ManifestPython]) 57 + } 58 + if manifestByType[ManifestNode].Version != "1.5.0" { 59 + t.Fatalf("npm version mismatch: %#v", manifestByType[ManifestNode]) 60 + } 61 + if manifestByType[ManifestDeno].Version != "3.1.4" { 62 + t.Fatalf("deno version mismatch: %#v", manifestByType[ManifestDeno]) 63 + } 64 + } 65 + 66 + func TestResolveTargets(t *testing.T) { 67 + dir := t.TempDir() 68 + writeFile(t, dir, "Cargo.toml", `[package] 69 + name = "demo" 70 + version = "0.1.0" 71 + `) 72 + writeFile(t, dir, "package.json", `{"name":"demo","version":"1.0.0"}`) 73 + 74 + selected, interactive, available, err := ResolveTargets(dir, []string{"cargo"}) 75 + if err != nil { 76 + t.Fatalf("ResolveTargets returned error: %v", err) 77 + } 78 + if interactive { 79 + t.Fatal("expected interactive to be false") 80 + } 81 + if len(selected) != 1 || selected[0].Type != ManifestCargo { 82 + t.Fatalf("expected cargo manifest to be selected, got %#v", selected) 83 + } 84 + if len(available) != 2 { 85 + t.Fatalf("expected 2 discovered manifests, got %d", len(available)) 86 + } 87 + 88 + selected, interactive, _, err = ResolveTargets(dir, []string{"interactive"}) 89 + if err != nil { 90 + t.Fatalf("ResolveTargets interactive: %v", err) 91 + } 92 + if !interactive { 93 + t.Fatal("expected interactive mode to be true") 94 + } 95 + if len(selected) != 0 { 96 + t.Fatalf("interactive request should not preselect manifests: got %d", len(selected)) 97 + } 98 + 99 + selected, _, _, err = ResolveTargets(dir, []string{"package.json"}) 100 + if err != nil { 101 + t.Fatalf("ResolveTargets path selection: %v", err) 102 + } 103 + if len(selected) != 1 || selected[0].Type != ManifestNode { 104 + t.Fatalf("expected package.json selection, got %#v", selected) 105 + } 106 + } 107 + 108 + func TestUpdateManifest(t *testing.T) { 109 + dir := t.TempDir() 110 + writeFile(t, dir, "Cargo.toml", `[package] 111 + name = "demo" 112 + version = "0.1.0" 113 + `) 114 + writeFile(t, dir, "pyproject.toml", `[project] 115 + name = "demo" 116 + version = "0.2.0" 117 + `) 118 + writeFile(t, dir, "package.json", `{"name":"demo","version":"1.0.0"}`) 119 + writeFile(t, dir, "deno.json", `{"version":"1.1.0"}`) 120 + 121 + files := []string{"Cargo.toml", "pyproject.toml", "package.json", "deno.json"} 122 + for _, name := range files { 123 + manifest, err := loadManifest(dir, filepath.Join(dir, name)) 124 + if err != nil { 125 + t.Fatalf("loadManifest(%s) error: %v", name, err) 126 + } 127 + if err := UpdateManifest(manifest, "9.9.9"); err != nil { 128 + t.Fatalf("UpdateManifest(%s) error: %v", name, err) 129 + } 130 + data, err := os.ReadFile(filepath.Join(dir, name)) 131 + if err != nil { 132 + t.Fatalf("read updated %s: %v", name, err) 133 + } 134 + if !strings.Contains(string(data), "9.9.9") { 135 + t.Fatalf("%s was not updated: %s", name, string(data)) 136 + } 137 + } 138 + } 139 + 140 + func writeFile(t *testing.T, dir, name, content string) { 141 + t.Helper() 142 + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { 143 + t.Fatalf("failed to write %s: %v", name, err) 144 + } 145 + }
+147
internal/toolchain/selector.go
··· 1 + package toolchain 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "strings" 7 + 8 + tea "github.com/charmbracelet/bubbletea" 9 + "github.com/charmbracelet/lipgloss" 10 + "github.com/stormlightlabs/git-storm/internal/style" 11 + "golang.org/x/term" 12 + ) 13 + 14 + var ( 15 + cursorStyle = lipgloss.NewStyle().Foreground(style.AccentBlue).Bold(true) 16 + selectedStyle = lipgloss.NewStyle().Foreground(style.AddedColor) 17 + mutedStyle = lipgloss.NewStyle().Foreground(style.AccentSteel) 18 + ) 19 + 20 + // SelectManifests launches an interactive selector so users can pick which manifests to update. 21 + func SelectManifests(manifests []Manifest) ([]Manifest, error) { 22 + if len(manifests) == 0 { 23 + return nil, fmt.Errorf("no toolchain manifests detected") 24 + } 25 + if !isTTY() { 26 + return nil, fmt.Errorf("interactive selection requires a TTY; pass specific --toolchain paths instead") 27 + } 28 + 29 + program := tea.NewProgram(newSelectorModel(manifests)) 30 + finalModel, err := program.Run() 31 + if err != nil { 32 + return nil, err 33 + } 34 + 35 + model, ok := finalModel.(selectorModel) 36 + if !ok { 37 + return nil, fmt.Errorf("unexpected selector model type %T", finalModel) 38 + } 39 + if model.cancelled { 40 + return nil, fmt.Errorf("selection cancelled") 41 + } 42 + return model.selectedManifests(), nil 43 + } 44 + 45 + func isTTY() bool { 46 + return term.IsTerminal(int(os.Stdout.Fd())) && term.IsTerminal(int(os.Stdin.Fd())) 47 + } 48 + 49 + type selectorModel struct { 50 + manifests []Manifest 51 + cursor int 52 + selected map[int]struct{} 53 + done bool 54 + cancelled bool 55 + } 56 + 57 + func newSelectorModel(manifests []Manifest) selectorModel { 58 + return selectorModel{ 59 + manifests: manifests, 60 + selected: make(map[int]struct{}), 61 + } 62 + } 63 + 64 + func (m selectorModel) Init() tea.Cmd { return nil } 65 + 66 + func (m selectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 67 + switch msg := msg.(type) { 68 + case tea.KeyMsg: 69 + switch msg.String() { 70 + case "q", "esc", "ctrl+c": 71 + m.cancelled = true 72 + return m, tea.Quit 73 + case "enter": 74 + if len(m.selected) == 0 && len(m.manifests) > 0 { 75 + m.selected[m.cursor] = struct{}{} 76 + } 77 + m.done = true 78 + return m, tea.Quit 79 + case " ": 80 + if _, ok := m.selected[m.cursor]; ok { 81 + delete(m.selected, m.cursor) 82 + } else { 83 + m.selected[m.cursor] = struct{}{} 84 + } 85 + case "j", "down": 86 + if m.cursor < len(m.manifests)-1 { 87 + m.cursor++ 88 + } 89 + case "k", "up": 90 + if m.cursor > 0 { 91 + m.cursor-- 92 + } 93 + case "home", "g": 94 + m.cursor = 0 95 + case "end", "G": 96 + if len(m.manifests) > 0 { 97 + m.cursor = len(m.manifests) - 1 98 + } 99 + } 100 + } 101 + 102 + return m, nil 103 + } 104 + 105 + func (m selectorModel) View() string { 106 + if len(m.manifests) == 0 { 107 + return "No manifests available" 108 + } 109 + 110 + var view strings.Builder 111 + view.WriteString(style.StyleHeadline.Render("Select toolchain manifests to bump")) 112 + view.WriteString("\n\n") 113 + 114 + for i, manifest := range m.manifests { 115 + cursor := " " 116 + if i == m.cursor { 117 + cursor = cursorStyle.Render("โ€บ") 118 + } 119 + checkbox := "[ ]" 120 + if _, ok := m.selected[i]; ok { 121 + checkbox = selectedStyle.Render("[x]") 122 + } 123 + line := fmt.Sprintf("%s %s %s", cursor, checkbox, manifest.DisplayLabel()) 124 + view.WriteString(line) 125 + view.WriteString("\n") 126 + } 127 + 128 + view.WriteString("\n") 129 + view.WriteString(mutedStyle.Render("space: toggle โ€ข enter: confirm โ€ข q: cancel")) 130 + return view.String() 131 + } 132 + 133 + func (m selectorModel) selectedManifests() []Manifest { 134 + if len(m.manifests) == 0 { 135 + return nil 136 + } 137 + var chosen []Manifest 138 + for idx, manifest := range m.manifests { 139 + if _, ok := m.selected[idx]; ok { 140 + chosen = append(chosen, manifest) 141 + } 142 + } 143 + if len(chosen) == 0 && !m.cancelled && len(m.manifests) > 0 { 144 + return []Manifest{m.manifests[m.cursor]} 145 + } 146 + return chosen 147 + }
+35
internal/toolchain/selector_test.go
··· 1 + package toolchain 2 + 3 + import ( 4 + "testing" 5 + 6 + tea "github.com/charmbracelet/bubbletea" 7 + "github.com/stormlightlabs/git-storm/internal/testutils" 8 + ) 9 + 10 + func TestSelectorModel_DefaultSelection(t *testing.T) { 11 + manifests := []Manifest{{RelPath: "Cargo.toml"}, {RelPath: "package.json"}} 12 + model := newSelectorModel(manifests) 13 + final := testutils.RunModelWithInteraction(t, model, []tea.Msg{tea.KeyMsg{Type: tea.KeyEnter}}) 14 + selector := final.(selectorModel) 15 + selected := selector.selectedManifests() 16 + if len(selected) != 1 || selected[0].RelPath != "Cargo.toml" { 17 + t.Fatalf("expected first manifest to be selected, got %#v", selected) 18 + } 19 + } 20 + 21 + func TestSelectorModel_ToggleSelection(t *testing.T) { 22 + manifests := []Manifest{{RelPath: "Cargo.toml"}, {RelPath: "package.json"}} 23 + model := newSelectorModel(manifests) 24 + msgs := []tea.Msg{ 25 + tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}, 26 + tea.KeyMsg{Type: tea.KeySpace}, 27 + tea.KeyMsg{Type: tea.KeyEnter}, 28 + } 29 + final := testutils.RunModelWithInteraction(t, model, msgs) 30 + selector := final.(selectorModel) 31 + selected := selector.selectedManifests() 32 + if len(selected) != 1 || selected[0].RelPath != "package.json" { 33 + t.Fatalf("expected second manifest to be selected, got %#v", selected) 34 + } 35 + }
+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
··· 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
··· 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
··· 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
··· 171 171 172 172 // renderHeader creates the header bar showing file paths. 173 173 func (m DiffModel) renderHeader() string { 174 - headerStyle := lipgloss.NewStyle(). 175 - Foreground(style.AccentBlue). 176 - Bold(true). 177 - Padding(0, 1) 174 + headerStyle := lipgloss.NewStyle().Foreground(style.AccentBlue).Bold(true).Padding(0, 1) 178 175 179 176 oldLabel := lipgloss.NewStyle().Foreground(style.RemovedColor).Render("โˆ’") 180 177 newLabel := lipgloss.NewStyle().Foreground(style.AddedColor).Render("+") ··· 199 196 totalWidth := m.viewport.Width 200 197 helpWidth := lipgloss.Width(helpText) 201 198 scrollWidth := lipgloss.Width(scrollInfo) 202 - padding := totalWidth - helpWidth - scrollWidth - 2 203 - 204 - if padding < 0 { 205 - padding = 0 206 - } 199 + padding := max(totalWidth-helpWidth-scrollWidth-2, 0) 207 200 208 201 return footerStyle.Render( 209 202 helpText + strings.Repeat(" ", padding) + scrollInfo, ··· 422 415 totalWidth := m.width 423 416 helpWidth := lipgloss.Width(helpText) 424 417 scrollWidth := lipgloss.Width(scrollInfo) 425 - padding := totalWidth - helpWidth - scrollWidth - 2 426 - 427 - if padding < 0 { 428 - padding = 0 429 - } 418 + padding := max(totalWidth-helpWidth-scrollWidth-2, 0) 430 419 431 420 return footerStyle.Render( 432 421 helpText + strings.Repeat(" ", padding) + scrollInfo,
+110
internal/versioning/versioning.go
··· 1 + package versioning 2 + 3 + import ( 4 + "fmt" 5 + "strconv" 6 + "strings" 7 + 8 + "github.com/stormlightlabs/git-storm/internal/changelog" 9 + ) 10 + 11 + // BumpType represents the semantic version component to increment. 12 + type BumpType string 13 + 14 + const ( 15 + BumpMajor BumpType = "major" 16 + BumpMinor BumpType = "minor" 17 + BumpPatch BumpType = "patch" 18 + ) 19 + 20 + // Version represents a semantic version split into numeric components. 21 + type Version struct { 22 + Major int 23 + Minor int 24 + Patch int 25 + } 26 + 27 + // Parse converts a semantic version string (X.Y.Z) into a Version structure. 28 + // An empty string returns 0.0.0 to simplify bump workflows. 29 + func Parse(version string) (Version, error) { 30 + if version == "" { 31 + return Version{}, nil 32 + } 33 + 34 + parts := strings.Split(version, ".") 35 + if len(parts) != 3 { 36 + return Version{}, fmt.Errorf("invalid semantic version: %s", version) 37 + } 38 + 39 + vals := make([]int, 3) 40 + for i, part := range parts { 41 + value, err := strconv.Atoi(part) 42 + if err != nil || value < 0 { 43 + return Version{}, fmt.Errorf("invalid semantic version: %s", version) 44 + } 45 + vals[i] = value 46 + } 47 + 48 + return Version{Major: vals[0], Minor: vals[1], Patch: vals[2]}, nil 49 + } 50 + 51 + // String formats the Version back into X.Y.Z form. 52 + func (v Version) String() string { 53 + return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) 54 + } 55 + 56 + // Bump increments the requested component following semver rules. 57 + func (v Version) Bump(kind BumpType) Version { 58 + switch kind { 59 + case BumpMajor: 60 + return Version{Major: v.Major + 1, Minor: 0, Patch: 0} 61 + case BumpMinor: 62 + return Version{Major: v.Major, Minor: v.Minor + 1, Patch: 0} 63 + case BumpPatch: 64 + return Version{Major: v.Major, Minor: v.Minor, Patch: v.Patch + 1} 65 + default: 66 + return v 67 + } 68 + } 69 + 70 + // Next returns the bumped version string for the provided semantic version. 71 + func Next(current string, kind BumpType) (string, error) { 72 + parsed, err := Parse(current) 73 + if err != nil { 74 + return "", err 75 + } 76 + return parsed.Bump(kind).String(), nil 77 + } 78 + 79 + // ParseBumpType validates user input into a BumpType. 80 + func ParseBumpType(value string) (BumpType, error) { 81 + switch strings.ToLower(value) { 82 + case string(BumpMajor): 83 + return BumpMajor, nil 84 + case string(BumpMinor): 85 + return BumpMinor, nil 86 + case string(BumpPatch): 87 + return BumpPatch, nil 88 + default: 89 + return "", fmt.Errorf("invalid bump type %q (expected major, minor, or patch)", value) 90 + } 91 + } 92 + 93 + // LatestVersion scans a parsed changelog for the most recent released version. 94 + // It skips the "Unreleased" section if present and validates with Keep a Changelog semantics. 95 + func LatestVersion(ch *changelog.Changelog) (string, bool) { 96 + if ch == nil { 97 + return "", false 98 + } 99 + 100 + for _, v := range ch.Versions { 101 + if strings.EqualFold(v.Number, "unreleased") { 102 + continue 103 + } 104 + if err := changelog.ValidateVersion(v.Number); err == nil { 105 + return v.Number, true 106 + } 107 + } 108 + 109 + return "", false 110 + }
+87
internal/versioning/versioning_test.go
··· 1 + package versioning 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/stormlightlabs/git-storm/internal/changelog" 7 + ) 8 + 9 + func TestParseAndBump(t *testing.T) { 10 + version, err := Parse("1.2.3") 11 + if err != nil { 12 + t.Fatalf("Parse returned error: %v", err) 13 + } 14 + 15 + if version.Major != 1 || version.Minor != 2 || version.Patch != 3 { 16 + t.Fatalf("unexpected parse result: %#v", version) 17 + } 18 + 19 + major := version.Bump(BumpMajor) 20 + if got := major.String(); got != "2.0.0" { 21 + t.Fatalf("major bump = %s, want 2.0.0", got) 22 + } 23 + 24 + minor := version.Bump(BumpMinor) 25 + if got := minor.String(); got != "1.3.0" { 26 + t.Fatalf("minor bump = %s, want 1.3.0", got) 27 + } 28 + 29 + patch := version.Bump(BumpPatch) 30 + if got := patch.String(); got != "1.2.4" { 31 + t.Fatalf("patch bump = %s, want 1.2.4", got) 32 + } 33 + } 34 + 35 + func TestParseBumpType(t *testing.T) { 36 + cases := map[string]BumpType{ 37 + "MAJOR": BumpMajor, 38 + "minor": BumpMinor, 39 + "Patch": BumpPatch, 40 + } 41 + 42 + for input, expected := range cases { 43 + kind, err := ParseBumpType(input) 44 + if err != nil { 45 + t.Fatalf("ParseBumpType(%s) returned error: %v", input, err) 46 + } 47 + if kind != expected { 48 + t.Fatalf("ParseBumpType(%s) = %s, want %s", input, kind, expected) 49 + } 50 + } 51 + 52 + if _, err := ParseBumpType("invalid"); err == nil { 53 + t.Fatal("expected error for invalid bump type") 54 + } 55 + } 56 + 57 + func TestNextHandlesEmptyVersion(t *testing.T) { 58 + got, err := Next("", BumpMinor) 59 + if err != nil { 60 + t.Fatalf("Next returned error: %v", err) 61 + } 62 + if got != "0.1.0" { 63 + t.Fatalf("Next = %s, want 0.1.0", got) 64 + } 65 + } 66 + 67 + func TestLatestVersion(t *testing.T) { 68 + ch := &changelog.Changelog{ 69 + Versions: []changelog.Version{ 70 + {Number: "Unreleased"}, 71 + {Number: "1.5.0"}, 72 + {Number: "0.9.1"}, 73 + }, 74 + } 75 + 76 + version, ok := LatestVersion(ch) 77 + if !ok { 78 + t.Fatal("LatestVersion returned false") 79 + } 80 + if version != "1.5.0" { 81 + t.Fatalf("LatestVersion = %s, want 1.5.0", version) 82 + } 83 + 84 + if _, ok := LatestVersion(&changelog.Changelog{}); ok { 85 + t.Fatal("expected LatestVersion to return false when no releases exist") 86 + } 87 + }
+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
··· 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: {}
+2
pnpm-workspace.yaml
··· 1 + onlyBuiltDependencies: 2 + - esbuild