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

Configure Feed

Select the types of activity you want to include in your feed.

feat(changelog): build changelog handling and behind release command

* implement version validation, changelog merging, and optional file deletion.

* writer in changelog package for parsing, building, and writing CHANGELOG.md files.

+1283 -65
+135
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 + - [x] `storm unreleased` - Manage unreleased changes 18 + - [x] `unreleased add` - Create new entry 19 + - [x] `unreleased list` - Display entries (text and JSON) 20 + - [x] `unreleased review` - Interactive TUI review 21 + - [ ] Implement delete action from review 22 + - [ ] Implement edit action from review 23 + - [x] `storm release` - Promote unreleased changes to CHANGELOG 24 + - [x] Read all `.changes/*.md` files 25 + - [x] Merge into `CHANGELOG.md` 26 + - [x] Create version header with date 27 + - [x] Clear `.changes/` directory with `--clear-changes` flag 28 + - [x] Optional date override with `--date` flag 29 + - [x] Generate GitHub comparison links automatically 30 + - [x] Dry-run mode 31 + - [ ] Optional Git tag creation (Phase 7) 32 + - [x] `storm diff`: display inline diffs between refs with support for file filtering, 33 + context expansion, and multiple view modes. 34 + 35 + ## Git Integration and Commit Parsing 36 + 37 + - [x] Core gitlog utilities for parsing refs, retrieving commits and file contents, and 38 + categorizing conventional commits by type and change significance. 39 + 40 + ## Diff Engine and Styling 41 + 42 + - [x] Diff package implements the Myers diff algorithm with split and unified rendering, 43 + compressed unchanged sections, and an iceberg-themed color palette for styled visual 44 + output. 45 + 46 + ## `.changes` Storage and Parsing 47 + 48 + Local storage for unreleased changelog entries. 49 + 50 + ### Tasks 51 + 52 + - [x] Define `Entry` struct with YAML frontmatter 53 + - [x] Implement `changeset.Write(dir, entry)` 54 + - [x] Generate unique filenames (timestamp-based) 55 + - [x] Write YAML frontmatter 56 + - [x] Create `.changes/` directory if missing 57 + - [x] Implement `changeset.List(dir)` 58 + - [x] Parse YAML frontmatter 59 + - [x] Return `EntryWithFile` structs 60 + - [ ] Implement diff-based deduplication 61 + - [ ] Compute diff hash for commits 62 + - [ ] Load existing entries by hash 63 + - [ ] Detect rebased commits (same diff, different hash) 64 + - [ ] Add `--update-rebased`, `--skip-rebased`, `--warn-rebased` flags 65 + 66 + ## TUI 67 + 68 + - [x] Delivered Bubble Tea UIs for selecting commits, reviewing unreleased changes, and 69 + interactively viewing multi-file diffs with full keyboard-driven navigation and view 70 + toggles. 71 + 72 + ## Keep a Changelog Writer 73 + 74 + - [x] Adds a full changelog pipeline that parses the existing file, builds and writes 75 + new releases, and validates dates/sections to strictly match the Keep a Changelog 76 + [spec](https://keepachangelog.com/en/1.1.0/), including autogenerated comparison links. 77 + 78 + ## Phase 7: Git Tagging and CI Integration 79 + 80 + Repository tagging and automation-friendly features. 81 + 82 + ### Tasks 83 + 84 + - [ ] Implement Git tagging in `release` command 85 + - [ ] Create annotated tag with version 86 + - [ ] Include release notes in tag message 87 + - [ ] Validate tag doesn't already exist 88 + - [ ] Support `--tag` flag 89 + - [ ] Add JSON output modes for all commands 90 + - [x] `unreleased list --json` (implemented) 91 + - [ ] `generate --output-json` 92 + - [ ] `release --output-json` 93 + - [x] Add `--dry-run` support 94 + - [x] `release --dry-run` - implemented 95 + - [x] Show what would be written without writing 96 + - [x] Display preview of CHANGELOG changes with styled output 97 + - [ ] Non-TTY environment handling 98 + - [ ] Detect TTY availability 99 + - [ ] Fallback to non-interactive mode 100 + - [ ] CI-friendly error messages 101 + - [ ] Add pre-commit hook examples 102 + - [ ] Validate commit message format 103 + - [ ] Ensure `.changes/` entries exist for features 104 + - [ ] Create GitHub Actions workflow examples 105 + - [ ] Auto-release on version tag 106 + - [ ] Validate CHANGELOG on PR 107 + 108 + ## Testing Strategy 109 + 110 + ### Current Status 111 + 112 + - [x] Test utilities package - internal/testutils/ 113 + - [x] Unit tests for changelog package - internal/changelog/changelog_test.go 114 + - [ ] Unit tests for diff engine 115 + - [ ] Unit tests for Git integration (in-memory repos) 116 + - [ ] Golden files for diff output 117 + - [ ] Golden files for changelog output 118 + - [ ] Bubble Tea program testing 119 + 120 + ### Planned Test Coverage 121 + 122 + - [ ] `internal/diff` - Myers algorithm correctness 123 + - [ ] `internal/gitlog` - Commit parsing and range queries 124 + - [x] `internal/changeset` - File I/O and YAML parsing 125 + - [x] `internal/changelog` - Keep a Changelog formatting (13 test cases, all passing) 126 + - [ ] `cmd/generate` - End-to-end commit to entry flow 127 + - [ ] `cmd/unreleased` - Entry management 128 + - [ ] `cmd/release` - Changelog generation and tagging 129 + 130 + ## Notes 131 + 132 + - No shell calls to `git` - all operations via `go-git` 133 + - Conventional commits are parsed but not enforced 134 + - TUI sessions degrade gracefully in non-TTY environments (to be implemented) 135 + - All output follows Keep a Changelog v1.1.0 specification
+1 -34
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 { ··· 39 27 return nil 40 28 }, 41 29 } 42 - } 43 - 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 30 } 64 31 65 32 func main() {
+175
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 + --date <YYYY-MM-DD> Release date (default: today) 10 + --clear-changes Delete .changes/*.md files after successful release 11 + --dry-run Preview changes without writing files 12 + --tag Create a Git tag after release (not implemented) 13 + --repo <path> Path to the Git repository (default: .) 14 + --output <path> Output changelog file path (default: CHANGELOG.md) 15 + */ 16 + package main 17 + 18 + import ( 19 + "fmt" 20 + "os" 21 + "path/filepath" 22 + "time" 23 + 24 + "github.com/spf13/cobra" 25 + "github.com/stormlightlabs/git-storm/internal/changelog" 26 + "github.com/stormlightlabs/git-storm/internal/changeset" 27 + "github.com/stormlightlabs/git-storm/internal/style" 28 + ) 29 + 30 + func releaseCmd() *cobra.Command { 31 + var ( 32 + version string 33 + date string 34 + clearChanges bool 35 + dryRun bool 36 + tag bool 37 + ) 38 + 39 + c := &cobra.Command{ 40 + Use: "release", 41 + Short: "Promote unreleased changes into a new changelog version", 42 + Long: `Merges all .changes entries into CHANGELOG.md under a new version header. 43 + Optionally creates a Git tag and clears the .changes directory.`, 44 + RunE: func(cmd *cobra.Command, args []string) error { 45 + if err := changelog.ValidateVersion(version); err != nil { 46 + return err 47 + } 48 + 49 + releaseDate := date 50 + if releaseDate == "" { 51 + releaseDate = time.Now().Format("2006-01-02") 52 + } else { 53 + if err := changelog.ValidateDate(releaseDate); err != nil { 54 + return err 55 + } 56 + } 57 + 58 + style.Headlinef("Preparing release %s (%s)", version, releaseDate) 59 + style.Newline() 60 + 61 + changesDir := ".changes" 62 + entries, err := changeset.List(changesDir) 63 + if err != nil { 64 + return fmt.Errorf("failed to read .changes directory: %w", err) 65 + } 66 + 67 + if len(entries) == 0 { 68 + return fmt.Errorf("no unreleased changes found in %s", changesDir) 69 + } 70 + 71 + style.Println("Found %d unreleased entries", len(entries)) 72 + style.Newline() 73 + 74 + var entryList []changeset.Entry 75 + for _, e := range entries { 76 + entryList = append(entryList, e.Entry) 77 + } 78 + 79 + newVersion, err := changelog.Build(entryList, version, releaseDate) 80 + if err != nil { 81 + return fmt.Errorf("failed to build version: %w", err) 82 + } 83 + 84 + changelogPath := filepath.Join(repoPath, output) 85 + existingChangelog, err := changelog.Parse(changelogPath) 86 + if err != nil { 87 + return fmt.Errorf("failed to parse existing changelog: %w", err) 88 + } 89 + 90 + changelog.Merge(existingChangelog, newVersion) 91 + 92 + if dryRun { 93 + style.Headline("Dry-run mode: Preview of CHANGELOG.md") 94 + style.Newline() 95 + displayVersionPreview(newVersion) 96 + style.Newline() 97 + style.Println("No files were modified (--dry-run)") 98 + return nil 99 + } 100 + 101 + if err := changelog.Write(changelogPath, existingChangelog, repoPath); err != nil { 102 + return fmt.Errorf("failed to write CHANGELOG.md: %w", err) 103 + } 104 + 105 + style.Addedf("✓ Updated %s", changelogPath) 106 + 107 + if clearChanges { 108 + deletedCount := 0 109 + for _, entry := range entries { 110 + filePath := filepath.Join(changesDir, entry.Filename) 111 + if err := os.Remove(filePath); err != nil { 112 + style.Println("Warning: failed to delete %s: %v", filePath, err) 113 + continue 114 + } 115 + deletedCount++ 116 + } 117 + style.Println("✓ Deleted %d entry files from %s", deletedCount, changesDir) 118 + } 119 + 120 + style.Newline() 121 + style.Headlinef("Release %s completed successfully", version) 122 + 123 + if tag { 124 + style.Newline() 125 + style.Println("Note: --tag flag is not yet implemented (Phase 7)") 126 + } 127 + 128 + return nil 129 + }, 130 + } 131 + 132 + c.Flags().StringVar(&version, "version", "", "Semantic version for the new release (e.g., 1.3.0)") 133 + c.Flags().StringVar(&date, "date", "", "Release date in YYYY-MM-DD format (default: today)") 134 + c.Flags().BoolVar(&clearChanges, "clear-changes", false, "Delete .changes/*.md files after successful release") 135 + c.Flags().BoolVar(&dryRun, "dry-run", false, "Preview changes without writing files") 136 + c.Flags().BoolVar(&tag, "tag", false, "Create a Git tag after release (not implemented)") 137 + c.MarkFlagRequired("version") 138 + 139 + return c 140 + } 141 + 142 + // displayVersionPreview shows a formatted preview of the version being released. 143 + func displayVersionPreview(version *changelog.Version) { 144 + fmt.Printf("## [%s] - %s\n\n", version.Number, version.Date) 145 + 146 + for i, section := range version.Sections { 147 + if i > 0 { 148 + fmt.Println() 149 + } 150 + 151 + var sectionTitle string 152 + switch section.Type { 153 + case "added": 154 + sectionTitle = style.StyleAdded.Render("### Added") 155 + case "changed": 156 + sectionTitle = style.StyleChanged.Render("### Changed") 157 + case "deprecated": 158 + sectionTitle = "### Deprecated" 159 + case "removed": 160 + sectionTitle = style.StyleRemoved.Render("### Removed") 161 + case "fixed": 162 + sectionTitle = style.StyleFixed.Render("### Fixed") 163 + case "security": 164 + sectionTitle = style.StyleSecurity.Render("### Security") 165 + default: 166 + sectionTitle = fmt.Sprintf("### %s", section.Type) 167 + } 168 + fmt.Println(sectionTitle) 169 + fmt.Println() 170 + 171 + for _, entry := range section.Entries { 172 + fmt.Printf("- %s\n", entry) 173 + } 174 + } 175 + }
+15 -14
cmd/unreleased.go
··· 54 54 ) 55 55 56 56 func unreleasedCmd() *cobra.Command { 57 + var ( 58 + changeType string 59 + scope string 60 + summary string 61 + outputJSON bool 62 + ) 63 + 64 + changesDir := ".changes" 65 + validTypes := []string{"added", "changed", "fixed", "removed", "security"} 66 + 57 67 add := &cobra.Command{ 58 68 Use: "add", 59 69 Short: "Add a new unreleased change entry", 60 70 Long: `Creates a new .changes/<date>-<summary>.md file with the specified type, 61 71 scope, and summary.`, 62 72 RunE: func(cmd *cobra.Command, args []string) error { 63 - validTypes := []string{"added", "changed", "fixed", "removed", "security"} 64 73 if !slices.Contains(validTypes, changeType) { 65 74 return fmt.Errorf("invalid type %q: must be one of %s", changeType, strings.Join(validTypes, ", ")) 66 75 } 67 76 68 - entry := changeset.Entry{ 77 + if filePath, err := changeset.Write(changesDir, changeset.Entry{ 69 78 Type: changeType, 70 79 Scope: scope, 71 80 Summary: summary, 72 - } 73 - 74 - changesDir := ".changes" 75 - filePath, err := changeset.Write(changesDir, entry) 76 - if err != nil { 81 + }); err != nil { 77 82 return fmt.Errorf("failed to create changelog entry: %w", err) 83 + } else { 84 + style.Addedf("Created %s", filePath) 85 + return nil 78 86 } 79 - 80 - style.Addedf("Created %s", filePath) 81 - return nil 82 87 }, 83 88 } 84 89 add.Flags().StringVar(&changeType, "type", "", "Type of change (added, changed, fixed, removed, security)") ··· 92 97 Short: "List all unreleased changes", 93 98 Long: "Prints all pending .changes entries to stdout. Supports JSON output.", 94 99 RunE: func(cmd *cobra.Command, args []string) error { 95 - changesDir := ".changes" 96 100 entries, err := changeset.List(changesDir) 97 101 if err != nil { 98 102 return fmt.Errorf("failed to list changelog entries: %w", err) ··· 130 134 Long: `Launches an interactive Bubble Tea TUI to review, edit, or categorize 131 135 unreleased entries before final release.`, 132 136 RunE: func(cmd *cobra.Command, args []string) error { 133 - changesDir := ".changes" 134 137 entries, err := changeset.List(changesDir) 135 138 if err != nil { 136 139 return fmt.Errorf("failed to list changelog entries: %w", err) ··· 179 182 180 183 style.Headlinef("Review completed: %d to delete, %d to edit", deleteCount, editCount) 181 184 style.Println("Note: Delete and edit actions are not yet implemented") 182 - 183 185 return nil 184 186 }, 185 187 } ··· 191 193 and reviewing pending entries before release.`, 192 194 } 193 195 root.AddCommand(add, list, review) 194 - 195 196 return root 196 197 } 197 198
+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 + }
+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 }