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

feat(release): annotated Git tagging with release notes

* update changelog entry handling to include metadata

* add tests for changeset functionality

* add TitleCase utility function

+27 -16
ROADMAP.md
··· 14 14 - [x] Parse conventional commits 15 15 - [x] Write entries to `.changes/` 16 16 - [ ] Deduplication logic (see TODO in generate.go) 17 + - [ ] Add --output-json for machine use 17 18 - [x] `storm unreleased` - Manage unreleased changes 18 19 - [x] `unreleased add` - Create new entry 19 20 - [x] `unreleased list` - Display entries (text and JSON) 20 21 - [x] `unreleased review` - Interactive TUI review 21 22 - [ ] Implement delete action from review 22 23 - [ ] Implement edit action from review 24 + - [ ] Figure out how these fit in to `unreleased` 25 + - [ ] `storm partial create` - Create new partial file 26 + - [ ] Filename format: `<sha7>.<type>.md` 27 + - [ ] Supports configurable categories (feature, fix, doc, removal, etc.) 28 + - [ ] Optional `--type`, `--issue`, `--message` flags 29 + - [ ] `storm check` - Validate that changes include unreleased partials 30 + - [ ] Detect missing partials for changed code paths 31 + - [ ] Honor `[nochanges]` marker in commit messages 32 + - [ ] Exit non-zero for CI enforcement 23 33 - [x] `storm release` - Promote unreleased changes to CHANGELOG 24 34 - [x] Read all `.changes/*.md` files 25 35 - [x] Merge into `CHANGELOG.md` ··· 57 67 - [x] Implement `changeset.List(dir)` 58 68 - [x] Parse YAML frontmatter 59 69 - [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 70 + - [x] Implement diff-based deduplication 71 + - [x] Compute diff hash for commits 72 + - [x] Load existing entries by hash 73 + - [x] Detect rebased commits (same diff, different hash) 74 + - [x] Update rebased commit metadata automatically 65 75 66 76 ## TUI 67 77 ··· 74 84 - [x] Adds a full changelog pipeline that parses the existing file, builds and writes 75 85 new releases, and validates dates/sections to strictly match the Keep a Changelog 76 86 [spec](https://keepachangelog.com/en/1.1.0/), including autogenerated comparison links. 87 + - [ ] Ensure deterministic sorting by category and filename timestamp 77 88 78 89 ## Phase 7: Git Tagging and CI Integration 79 90 ··· 81 92 82 93 ### Tasks 83 94 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 95 + - [x] Implement Git tagging in `release` command 96 + - [x] Create annotated tag with version 97 + - [x] Include release notes in tag message 98 + - [x] Validate tag doesn't already exist 99 + - [x] Support `--tag` flag 89 100 - [ ] Add JSON output modes for all commands 90 - - [x] `unreleased list --json` (implemented) 101 + - [x] `unreleased list --json` 91 102 - [ ] `generate --output-json` 92 103 - [ ] `release --output-json` 93 104 - [x] Add `--dry-run` support 94 - - [x] `release --dry-run` - implemented 105 + - [x] `release --dry-run` 95 106 - [x] Show what would be written without writing 96 107 - [x] Display preview of CHANGELOG changes with styled output 97 108 - [ ] Non-TTY environment handling ··· 111 122 112 123 - [x] Test utilities package - internal/testutils/ 113 124 - [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) 125 + - [x] Unit tests for diff engine 126 + - [x] Unit tests for Git integration (in-memory repos) 116 127 - [ ] Golden files for diff output 117 128 - [ ] Golden files for changelog output 118 129 - [ ] Bubble Tea program testing 119 130 120 131 ### Planned Test Coverage 121 132 122 - - [ ] `internal/diff` - Myers algorithm correctness 133 + - [x] `internal/diff` - Myers algorithm correctness 123 134 - [ ] `internal/gitlog` - Commit parsing and range queries 124 135 - [x] `internal/changeset` - File I/O and YAML parsing 125 136 - [x] `internal/changelog` - Keep a Changelog formatting (13 test cases, all passing) ··· 131 142 132 143 - No shell calls to `git` - all operations via `go-git` 133 144 - Conventional commits are parsed but not enforced 134 - - TUI sessions degrade gracefully in non-TTY environments (to be implemented) 145 + - TUI sessions degrade gracefully in non-TTY environments 135 146 - All output follows Keep a Changelog v1.1.0 specification
+49 -14
cmd/generate.go
··· 168 168 } 169 169 } 170 170 171 - entries := []changeset.Entry{} 171 + changesDir := ".changes" 172 + existingMetadata, err := changeset.LoadExistingMetadata(changesDir) 173 + if err != nil { 174 + return fmt.Errorf("failed to load existing metadata: %w", err) 175 + } 176 + 177 + created := 0 172 178 skipped := 0 179 + duplicates := 0 180 + rebased := 0 173 181 174 182 for _, item := range selectedItems { 175 183 if item.Category == "" { ··· 177 185 continue 178 186 } 179 187 180 - entry := changeset.Entry{ 181 - Type: item.Category, 182 - Scope: item.Meta.Scope, 183 - Summary: item.Meta.Description, 184 - Breaking: item.Meta.Breaking, 188 + diffHash, err := changeset.ComputeDiffHash(item.Commit) 189 + if err != nil { 190 + style.Println("Warning: failed to compute diff hash for commit %s: %v", item.Commit.Hash.String()[:7], err) 191 + skipped++ 192 + continue 193 + } 194 + 195 + if existing, exists := existingMetadata[diffHash]; exists { 196 + if existing.CommitHash == item.Commit.Hash.String() { 197 + duplicates++ 198 + continue 199 + } else { 200 + if err := changeset.UpdateMetadata(changesDir, diffHash, item.Commit.Hash.String()); err != nil { 201 + style.Println("Warning: failed to update metadata for rebased commit: %v", err) 202 + continue 203 + } 204 + style.Println(" Updated rebased commit %s (was %s)", item.Commit.Hash.String()[:7], existing.CommitHash[:7]) 205 + rebased++ 206 + continue 207 + } 185 208 } 186 209 187 - entries = append(entries, entry) 188 - } 210 + meta := changeset.Metadata{ 211 + CommitHash: item.Commit.Hash.String(), 212 + DiffHash: diffHash, 213 + Type: item.Category, 214 + Scope: item.Meta.Scope, 215 + Summary: item.Meta.Description, 216 + Breaking: item.Meta.Breaking, 217 + Author: item.Commit.Author.Name, 218 + Date: item.Commit.Author.When, 219 + } 189 220 190 - changesDir := ".changes" 191 - created := 0 192 - for _, entry := range entries { 193 - filePath, err := changeset.Write(changesDir, entry) 221 + filePath, err := changeset.WriteWithMetadata(changesDir, meta) 194 222 if err != nil { 195 223 fmt.Printf("Error: failed to write entry: %v\n", err) 224 + skipped++ 196 225 continue 197 226 } 198 227 style.Addedf("✓ Created %s", filePath) ··· 200 229 } 201 230 202 231 style.Newline() 203 - style.Headlinef("Generated %d changelog entries", created) 232 + style.Headlinef("Generated %d new changelog entries", created) 233 + if duplicates > 0 { 234 + style.Println(" Skipped %d duplicates", duplicates) 235 + } 236 + if rebased > 0 { 237 + style.Println(" Updated %d rebased commits", rebased) 238 + } 204 239 if skipped > 0 { 205 - style.Println("Skipped %d commits (reverts or non-matching types)", skipped) 240 + style.Println(" Skipped %d commits (reverts or non-matching types)", skipped) 206 241 } 207 242 208 243 return nil
+68 -3
cmd/release.go
··· 9 9 --date <YYYY-MM-DD> Release date (default: today) 10 10 --clear-changes Delete .changes/*.md files after successful release 11 11 --dry-run Preview changes without writing files 12 - --tag Create a Git tag after release (not implemented) 12 + --tag Create an annotated Git tag with release notes 13 13 --repo <path> Path to the Git repository (default: .) 14 14 --output <path> Output changelog file path (default: CHANGELOG.md) 15 15 */ ··· 19 19 "fmt" 20 20 "os" 21 21 "path/filepath" 22 + "strings" 22 23 "time" 23 24 25 + "github.com/go-git/go-git/v6" 26 + "github.com/go-git/go-git/v6/plumbing/object" 24 27 "github.com/spf13/cobra" 25 28 "github.com/stormlightlabs/git-storm/internal/changelog" 26 29 "github.com/stormlightlabs/git-storm/internal/changeset" 30 + "github.com/stormlightlabs/git-storm/internal/shared" 27 31 "github.com/stormlightlabs/git-storm/internal/style" 28 32 ) 29 33 ··· 122 126 123 127 if tag { 124 128 style.Newline() 125 - style.Println("Note: --tag flag is not yet implemented (Phase 7)") 129 + if err := createReleaseTag(repoPath, version, newVersion); err != nil { 130 + return fmt.Errorf("failed to create Git tag: %w", err) 131 + } 132 + tagName := fmt.Sprintf("v%s", version) 133 + style.Addedf("✓ Created Git tag %s", tagName) 126 134 } 127 135 128 136 return nil ··· 133 141 c.Flags().StringVar(&date, "date", "", "Release date in YYYY-MM-DD format (default: today)") 134 142 c.Flags().BoolVar(&clearChanges, "clear-changes", false, "Delete .changes/*.md files after successful release") 135 143 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)") 144 + c.Flags().BoolVar(&tag, "tag", false, "Create an annotated Git tag with release notes") 137 145 c.MarkFlagRequired("version") 138 146 139 147 return c 148 + } 149 + 150 + // createReleaseTag creates an annotated Git tag for the release with changelog entries as the message. 151 + func createReleaseTag(repoPath, version string, versionData *changelog.Version) error { 152 + repo, err := git.PlainOpen(repoPath) 153 + if err != nil { 154 + return fmt.Errorf("failed to open repository: %w", err) 155 + } 156 + 157 + head, err := repo.Head() 158 + if err != nil { 159 + return fmt.Errorf("failed to get HEAD: %w", err) 160 + } 161 + 162 + tagName := fmt.Sprintf("v%s", version) 163 + 164 + _, err = repo.Tag(tagName) 165 + if err == nil { 166 + return fmt.Errorf("tag %s already exists", tagName) 167 + } 168 + 169 + tagMessage := buildTagMessage(version, versionData) 170 + 171 + _, err = repo.CreateTag(tagName, head.Hash(), &git.CreateTagOptions{ 172 + Message: tagMessage, 173 + Tagger: &object.Signature{ 174 + Name: "storm", 175 + Email: "noreply@storm", 176 + When: time.Now(), 177 + }, 178 + }) 179 + if err != nil { 180 + return fmt.Errorf("failed to create tag: %w", err) 181 + } 182 + return nil 183 + } 184 + 185 + // buildTagMessage formats the version's changelog entries into a tag message. 186 + func buildTagMessage(version string, versionData *changelog.Version) string { 187 + var builder strings.Builder 188 + 189 + builder.WriteString(fmt.Sprintf("Release %s\n\n", version)) 190 + 191 + for i, section := range versionData.Sections { 192 + if i > 0 { 193 + builder.WriteString("\n") 194 + } 195 + 196 + sectionTitle := shared.TitleCase(section.Type) 197 + builder.WriteString(fmt.Sprintf("%s:\n", sectionTitle)) 198 + 199 + for _, entry := range section.Entries { 200 + builder.WriteString(fmt.Sprintf("- %s\n", entry)) 201 + } 202 + } 203 + 204 + return builder.String() 140 205 } 141 206 142 207 // displayVersionPreview shows a formatted preview of the version being released.
+196
cmd/release_test.go
··· 1 + package main 2 + 3 + import ( 4 + "strings" 5 + "testing" 6 + 7 + "github.com/stormlightlabs/git-storm/internal/changelog" 8 + "github.com/stormlightlabs/git-storm/internal/testutils" 9 + ) 10 + 11 + func TestCreateReleaseTag(t *testing.T) { 12 + repo := testutils.SetupTestRepo(t) 13 + worktree, err := repo.Worktree() 14 + if err != nil { 15 + t.Fatalf("Failed to get worktree: %v", err) 16 + } 17 + 18 + repoPath := worktree.Filesystem.Root() 19 + version := &changelog.Version{ 20 + Number: "1.0.0", 21 + Date: "2024-01-15", 22 + Sections: []changelog.Section{ 23 + { 24 + Type: "added", 25 + Entries: []string{ 26 + "New authentication system", 27 + "User profile management", 28 + }, 29 + }, 30 + { 31 + Type: "fixed", 32 + Entries: []string{ 33 + "Memory leak in database connection pool", 34 + }, 35 + }, 36 + }, 37 + } 38 + 39 + err = createReleaseTag(repoPath, "1.0.0", version) 40 + if err != nil { 41 + t.Fatalf("createReleaseTag() error = %v", err) 42 + } 43 + 44 + tagRef, err := repo.Tag("v1.0.0") 45 + if err != nil { 46 + t.Fatalf("Tag v1.0.0 should exist, got error: %v", err) 47 + } 48 + 49 + tagObj, err := repo.TagObject(tagRef.Hash()) 50 + if err != nil { 51 + t.Fatalf("Tag should be annotated, got error: %v", err) 52 + } 53 + 54 + head, err := repo.Head() 55 + if err != nil { 56 + t.Fatalf("Failed to get HEAD: %v", err) 57 + } 58 + 59 + testutils.Expect.Equal(t, tagObj.Target, head.Hash(), "Tag should point to HEAD") 60 + 61 + message := tagObj.Message 62 + testutils.Expect.True(t, strings.Contains(message, "Release 1.0.0"), "Tag message should contain version") 63 + testutils.Expect.True(t, strings.Contains(message, "Added:"), "Tag message should contain Added section") 64 + testutils.Expect.True(t, strings.Contains(message, "Fixed:"), "Tag message should contain Fixed section") 65 + testutils.Expect.True(t, strings.Contains(message, "New authentication system"), "Tag message should contain entry") 66 + testutils.Expect.True(t, strings.Contains(message, "Memory leak"), "Tag message should contain entry") 67 + } 68 + 69 + func TestCreateReleaseTag_DuplicateTag(t *testing.T) { 70 + repo := testutils.SetupTestRepo(t) 71 + worktree, err := repo.Worktree() 72 + if err != nil { 73 + t.Fatalf("Failed to get worktree: %v", err) 74 + } 75 + 76 + repoPath := worktree.Filesystem.Root() 77 + version := &changelog.Version{ 78 + Number: "1.0.0", 79 + Date: "2024-01-15", 80 + Sections: []changelog.Section{ 81 + { 82 + Type: "added", 83 + Entries: []string{"Feature 1"}, 84 + }, 85 + }, 86 + } 87 + 88 + err = createReleaseTag(repoPath, "1.0.0", version) 89 + if err != nil { 90 + t.Fatalf("First createReleaseTag() error = %v", err) 91 + } 92 + 93 + err = createReleaseTag(repoPath, "1.0.0", version) 94 + if err == nil { 95 + t.Error("Expected error when creating duplicate tag, got nil") 96 + } 97 + 98 + testutils.Expect.True(t, strings.Contains(err.Error(), "already exists"), "Error should indicate tag already exists") 99 + } 100 + 101 + func TestCreateReleaseTag_TagNameFormat(t *testing.T) { 102 + tests := []struct { 103 + version string 104 + expectedTag string 105 + }{ 106 + {"1.0.0", "v1.0.0"}, 107 + {"2.5.3", "v2.5.3"}, 108 + {"0.1.0", "v0.1.0"}, 109 + } 110 + 111 + for _, tt := range tests { 112 + t.Run(tt.version, func(t *testing.T) { 113 + repo := testutils.SetupTestRepo(t) 114 + worktree, err := repo.Worktree() 115 + if err != nil { 116 + t.Fatalf("Failed to get worktree: %v", err) 117 + } 118 + 119 + repoPath := worktree.Filesystem.Root() 120 + version := &changelog.Version{ 121 + Number: tt.version, 122 + Date: "2024-01-15", 123 + Sections: []changelog.Section{ 124 + { 125 + Type: "added", 126 + Entries: []string{"Feature"}, 127 + }, 128 + }, 129 + } 130 + 131 + err = createReleaseTag(repoPath, tt.version, version) 132 + if err != nil { 133 + t.Fatalf("createReleaseTag() error = %v", err) 134 + } 135 + 136 + _, err = repo.Tag(tt.expectedTag) 137 + if err != nil { 138 + t.Errorf("Tag %s should exist, got error: %v", tt.expectedTag, err) 139 + } 140 + }) 141 + } 142 + } 143 + 144 + func TestBuildTagMessage(t *testing.T) { 145 + version := &changelog.Version{ 146 + Number: "1.2.3", 147 + Date: "2024-01-15", 148 + Sections: []changelog.Section{ 149 + { 150 + Type: "added", 151 + Entries: []string{ 152 + "Feature A", 153 + "Feature B", 154 + }, 155 + }, 156 + { 157 + Type: "changed", 158 + Entries: []string{"Updated API"}, 159 + }, 160 + { 161 + Type: "fixed", 162 + Entries: []string{ 163 + "Bug 1", 164 + "Bug 2", 165 + }, 166 + }, 167 + }, 168 + } 169 + message := buildTagMessage("1.2.3", version) 170 + 171 + testutils.Expect.True(t, strings.HasPrefix(message, "Release 1.2.3\n\n"), "Message should start with release header") 172 + 173 + testutils.Expect.True(t, strings.Contains(message, "Added:\n"), "Should contain Added section") 174 + testutils.Expect.True(t, strings.Contains(message, "Changed:\n"), "Should contain Changed section") 175 + testutils.Expect.True(t, strings.Contains(message, "Fixed:\n"), "Should contain Fixed section") 176 + 177 + testutils.Expect.True(t, strings.Contains(message, "- Feature A\n"), "Should contain entry") 178 + testutils.Expect.True(t, strings.Contains(message, "- Feature B\n"), "Should contain entry") 179 + testutils.Expect.True(t, strings.Contains(message, "- Updated API\n"), "Should contain entry") 180 + testutils.Expect.True(t, strings.Contains(message, "- Bug 1\n"), "Should contain entry") 181 + testutils.Expect.True(t, strings.Contains(message, "- Bug 2\n"), "Should contain entry") 182 + 183 + sections := strings.Split(message, "\n\n") 184 + testutils.Expect.True(t, len(sections) >= 3, "Sections should be separated by blank lines") 185 + } 186 + 187 + func TestBuildTagMessage_EmptyVersion(t *testing.T) { 188 + version := &changelog.Version{ 189 + Number: "1.0.0", 190 + Date: "2024-01-15", 191 + Sections: []changelog.Section{}, 192 + } 193 + message := buildTagMessage("1.0.0", version) 194 + 195 + testutils.Expect.True(t, strings.HasPrefix(message, "Release 1.0.0\n\n"), "Should still have release header even with no sections") 196 + }
+1 -1
go.mod
··· 69 69 golang.org/x/net v0.46.0 // indirect 70 70 golang.org/x/sync v0.17.0 // indirect 71 71 golang.org/x/sys v0.37.0 // indirect 72 - golang.org/x/text v0.30.0 // indirect 72 + golang.org/x/text v0.30.0 73 73 )
+208 -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 + Type string `json:"type"` 74 + Scope string `json:"scope"` 75 + Summary string `json:"summary"` 76 + Breaking bool `json:"breaking"` 77 + Author string `json:"author"` 78 + Date time.Time `json:"date"` 79 + Filename string `json:"filename"` // relative path to .md file 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 + // WriteWithMetadata creates a new .changes/<diffHash7>-<slug>.md file with YAML 119 + // frontmatter and saves corresponding metadata to .changes/data/<diffHash>.json. 120 + // 121 + // The filename uses the first 7 characters of the diff hash for human-readable 122 + // identification, while the JSON metadata file uses the full hash for 123 + // deduplication lookups. 124 + func WriteWithMetadata(dir string, meta Metadata) (string, error) { 125 + if err := os.MkdirAll(dir, 0755); err != nil { 126 + return "", fmt.Errorf("failed to create directory %s: %w", dir, err) 127 + } 128 + 129 + diffHashShort := meta.DiffHash[:7] 130 + slug := slugify(meta.Summary) 131 + filename := fmt.Sprintf("%s-%s.md", diffHashShort, slug) 132 + filePath := filepath.Join(dir, filename) 133 + 134 + entry := Entry{ 135 + Type: meta.Type, 136 + Scope: meta.Scope, 137 + Summary: meta.Summary, 138 + Breaking: meta.Breaking, 139 + CommitHash: meta.CommitHash, 140 + DiffHash: meta.DiffHash, 141 + } 142 + 143 + yamlBytes, err := yaml.Marshal(entry) 144 + if err != nil { 145 + return "", fmt.Errorf("failed to marshal entry to YAML: %w", err) 146 + } 147 + 148 + content := fmt.Sprintf("---\n%s---\n", string(yamlBytes)) 149 + if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { 150 + return "", fmt.Errorf("failed to write file %s: %w", filePath, err) 151 + } 152 + 153 + meta.Filename = filename 154 + if err := SaveMetadata(dir, meta); err != nil { 155 + return "", fmt.Errorf("failed to save metadata: %w", err) 156 + } 157 + return filePath, nil 158 + } 159 + 97 160 // slugify converts a string into a URL-friendly slug by converting to lowercase, 98 161 // replaces spaces and special chars with hyphens. 99 162 func slugify(input string) string { ··· 170 233 171 234 return entry, nil 172 235 } 236 + 237 + // ComputeDiffHash calculates a stable hash of the commit's diff content. This 238 + // hash is independent of the commit hash, so rebased commits with identical 239 + // diffs will produce the same hash. 240 + // 241 + // The hash is computed from: 242 + // - Sorted list of changed file paths 243 + // - For each file: the full diff content (additions and deletions) 244 + func ComputeDiffHash(commit *object.Commit) (string, error) { 245 + tree, err := commit.Tree() 246 + if err != nil { 247 + return "", fmt.Errorf("failed to get commit tree: %w", err) 248 + } 249 + 250 + var parentTree *object.Tree 251 + if commit.NumParents() > 0 { 252 + parent, err := commit.Parent(0) 253 + if err != nil { 254 + return "", fmt.Errorf("failed to get parent commit: %w", err) 255 + } 256 + parentTree, err = parent.Tree() 257 + if err != nil { 258 + return "", fmt.Errorf("failed to get parent tree: %w", err) 259 + } 260 + } 261 + 262 + var changes object.Changes 263 + if parentTree != nil { 264 + changes, err = parentTree.Diff(tree) 265 + if err != nil { 266 + return "", fmt.Errorf("failed to compute diff: %w", err) 267 + } 268 + } else { 269 + emptyTree := &object.Tree{} 270 + changes, err = object.DiffTreeWithOptions(context.TODO(), emptyTree, tree, &object.DiffTreeOptions{}) 271 + if err != nil { 272 + return "", fmt.Errorf("failed to compute diff for initial commit: %w", err) 273 + } 274 + } 275 + 276 + var diffParts []string 277 + for _, change := range changes { 278 + patch, err := change.Patch() 279 + if err != nil { 280 + return "", fmt.Errorf("failed to get patch for %s: %w", change.To.Name, err) 281 + } 282 + 283 + diffParts = append(diffParts, fmt.Sprintf("FILE:%s\n%s", change.To.Name, patch.String())) 284 + } 285 + 286 + sort.Strings(diffParts) 287 + 288 + hasher := sha256.New() 289 + for _, part := range diffParts { 290 + hasher.Write([]byte(part)) 291 + } 292 + 293 + return hex.EncodeToString(hasher.Sum(nil)), nil 294 + } 295 + 296 + // SaveMetadata writes metadata to .changes/data/<diffHash>.json 297 + func SaveMetadata(dir string, meta Metadata) error { 298 + dataDir := filepath.Join(dir, "data") 299 + if err := os.MkdirAll(dataDir, 0755); err != nil { 300 + return fmt.Errorf("failed to create data directory: %w", err) 301 + } 302 + 303 + filePath := filepath.Join(dataDir, meta.DiffHash+".json") 304 + data, err := json.MarshalIndent(meta, "", " ") 305 + if err != nil { 306 + return fmt.Errorf("failed to marshal metadata: %w", err) 307 + } 308 + 309 + if err := os.WriteFile(filePath, data, 0644); err != nil { 310 + return fmt.Errorf("failed to write metadata file: %w", err) 311 + } 312 + 313 + return nil 314 + } 315 + 316 + // LoadExistingMetadata reads all metadata files from .changes/data/*.json 317 + // and creates a map of diff hash -> metadata for O(1) lookups. 318 + func LoadExistingMetadata(dir string) (map[string]Metadata, error) { 319 + dataDir := filepath.Join(dir, "data") 320 + result := make(map[string]Metadata) 321 + entries, err := os.ReadDir(dataDir) 322 + if err != nil { 323 + if os.IsNotExist(err) { 324 + return result, nil 325 + } 326 + return nil, fmt.Errorf("failed to read data directory: %w", err) 327 + } 328 + 329 + for _, entry := range entries { 330 + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { 331 + continue 332 + } 333 + 334 + filePath := filepath.Join(dataDir, entry.Name()) 335 + data, err := os.ReadFile(filePath) 336 + if err != nil { 337 + return nil, fmt.Errorf("failed to read metadata file %s: %w", entry.Name(), err) 338 + } 339 + 340 + var meta Metadata 341 + if err := json.Unmarshal(data, &meta); err != nil { 342 + return nil, fmt.Errorf("failed to unmarshal metadata from %s: %w", entry.Name(), err) 343 + } 344 + 345 + result[meta.DiffHash] = meta 346 + } 347 + return result, nil 348 + } 349 + 350 + // UpdateMetadata updates an existing metadata file with a new commit hash when 351 + // a rebased commit is detected (same diff, different commit hash). 352 + func UpdateMetadata(dir string, diffHash string, newCommitHash string) error { 353 + dataDir := filepath.Join(dir, "data") 354 + filePath := filepath.Join(dataDir, diffHash+".json") 355 + data, err := os.ReadFile(filePath) 356 + if err != nil { 357 + return fmt.Errorf("failed to read existing metadata: %w", err) 358 + } 359 + 360 + var meta Metadata 361 + if err := json.Unmarshal(data, &meta); err != nil { 362 + return fmt.Errorf("failed to unmarshal metadata: %w", err) 363 + } 364 + 365 + meta.CommitHash = newCommitHash 366 + 367 + updatedData, err := json.MarshalIndent(meta, "", " ") 368 + if err != nil { 369 + return fmt.Errorf("failed to marshal updated metadata: %w", err) 370 + } 371 + 372 + if err := os.WriteFile(filePath, updatedData, 0644); err != nil { 373 + return fmt.Errorf("failed to write updated metadata: %w", err) 374 + } 375 + return nil 376 + }
+313
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) { ··· 200 203 t.Errorf("File was not created: %s", filePath) 201 204 } 202 205 } 206 + 207 + func TestComputeDiffHash_Stability(t *testing.T) { 208 + repo := testutils.SetupTestRepo(t) 209 + commits := testutils.GetCommitHistory(t, repo) 210 + 211 + if len(commits) == 0 { 212 + t.Fatal("Expected at least one commit in test repo") 213 + } 214 + 215 + commit := commits[0] 216 + hash1, err := ComputeDiffHash(commit) 217 + if err != nil { 218 + t.Fatalf("ComputeDiffHash() error = %v", err) 219 + } 220 + 221 + hash2, err := ComputeDiffHash(commit) 222 + if err != nil { 223 + t.Fatalf("ComputeDiffHash() second call error = %v", err) 224 + } 225 + 226 + testutils.Expect.Equal(t, hash1, hash2, "Diff hash should be stable across multiple calls") 227 + testutils.Expect.Equal(t, len(hash1), 64, "Diff hash should be 64 characters (SHA256 hex)") 228 + } 229 + 230 + func TestComputeDiffHash_DifferentCommits(t *testing.T) { 231 + repo := testutils.SetupTestRepo(t) 232 + 233 + testutils.AddCommit(t, repo, "file1.txt", "content1", "Add file1") 234 + testutils.AddCommit(t, repo, "file2.txt", "content2", "Add file2") 235 + 236 + commits := testutils.GetCommitHistory(t, repo) 237 + if len(commits) < 2 { 238 + t.Fatal("Expected at least 2 commits") 239 + } 240 + 241 + hash1, err := ComputeDiffHash(commits[0]) 242 + if err != nil { 243 + t.Fatalf("ComputeDiffHash() for commit 1 error = %v", err) 244 + } 245 + 246 + hash2, err := ComputeDiffHash(commits[1]) 247 + if err != nil { 248 + t.Fatalf("ComputeDiffHash() for commit 2 error = %v", err) 249 + } 250 + 251 + testutils.Expect.NotEqual(t, hash1, hash2, "Different commits should have different diff hashes") 252 + } 253 + 254 + func TestWriteWithMetadata(t *testing.T) { 255 + tmpDir := t.TempDir() 256 + 257 + meta := Metadata{ 258 + CommitHash: "abc123def456", 259 + DiffHash: "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", 260 + Type: "added", 261 + Scope: "cli", 262 + Summary: "Add new feature", 263 + Breaking: false, 264 + Author: "Test User", 265 + Date: time.Now(), 266 + Filename: "", 267 + } 268 + 269 + filePath, err := WriteWithMetadata(tmpDir, meta) 270 + if err != nil { 271 + t.Fatalf("WriteWithMetadata() error = %v", err) 272 + } 273 + 274 + testutils.Expect.True(t, strings.HasSuffix(filePath, ".md"), "File path should have .md extension") 275 + if _, err := os.Stat(filePath); os.IsNotExist(err) { 276 + t.Errorf("Markdown file was not created: %s", filePath) 277 + } 278 + 279 + filename := filepath.Base(filePath) 280 + testutils.Expect.True(t, strings.HasPrefix(filename, meta.DiffHash[:7]), "Filename should start with first 7 chars of diff hash") 281 + 282 + content, err := os.ReadFile(filePath) 283 + if err != nil { 284 + t.Fatalf("Failed to read markdown file: %v", err) 285 + } 286 + 287 + var parsedEntry Entry 288 + parts := strings.SplitN(string(content), "---\n", 3) 289 + if len(parts) < 3 { 290 + t.Fatal("Invalid YAML frontmatter format") 291 + } 292 + 293 + if err := yaml.Unmarshal([]byte(parts[1]), &parsedEntry); err != nil { 294 + t.Fatalf("Failed to parse YAML: %v", err) 295 + } 296 + 297 + testutils.Expect.Equal(t, parsedEntry.Type, meta.Type) 298 + testutils.Expect.Equal(t, parsedEntry.Summary, meta.Summary) 299 + testutils.Expect.Equal(t, parsedEntry.CommitHash, meta.CommitHash) 300 + testutils.Expect.Equal(t, parsedEntry.DiffHash, meta.DiffHash) 301 + 302 + jsonPath := filepath.Join(tmpDir, "data", meta.DiffHash+".json") 303 + if _, err := os.Stat(jsonPath); os.IsNotExist(err) { 304 + t.Errorf("JSON metadata file was not created: %s", jsonPath) 305 + } 306 + 307 + jsonContent, err := os.ReadFile(jsonPath) 308 + if err != nil { 309 + t.Fatalf("Failed to read JSON metadata: %v", err) 310 + } 311 + 312 + var parsedMeta Metadata 313 + if err := json.Unmarshal(jsonContent, &parsedMeta); err != nil { 314 + t.Fatalf("Failed to parse JSON metadata: %v", err) 315 + } 316 + 317 + testutils.Expect.Equal(t, parsedMeta.CommitHash, meta.CommitHash) 318 + testutils.Expect.Equal(t, parsedMeta.DiffHash, meta.DiffHash) 319 + testutils.Expect.Equal(t, parsedMeta.Type, meta.Type) 320 + testutils.Expect.Equal(t, parsedMeta.Summary, meta.Summary) 321 + } 322 + 323 + func TestLoadExistingMetadata(t *testing.T) { 324 + tmpDir := t.TempDir() 325 + 326 + meta1 := Metadata{ 327 + CommitHash: "abc123", 328 + DiffHash: "hash1111111111111111111111111111111111111111111111111111111111111", 329 + Type: "added", 330 + Summary: "Feature 1", 331 + Author: "User1", 332 + Date: time.Now(), 333 + } 334 + 335 + meta2 := Metadata{ 336 + CommitHash: "def456", 337 + DiffHash: "hash2222222222222222222222222222222222222222222222222222222222222", 338 + Type: "fixed", 339 + Summary: "Fix 1", 340 + Author: "User2", 341 + Date: time.Now(), 342 + } 343 + 344 + _, err := WriteWithMetadata(tmpDir, meta1) 345 + if err != nil { 346 + t.Fatalf("Failed to write meta1: %v", err) 347 + } 348 + 349 + _, err = WriteWithMetadata(tmpDir, meta2) 350 + if err != nil { 351 + t.Fatalf("Failed to write meta2: %v", err) 352 + } 353 + 354 + loaded, err := LoadExistingMetadata(tmpDir) 355 + if err != nil { 356 + t.Fatalf("LoadExistingMetadata() error = %v", err) 357 + } 358 + 359 + testutils.Expect.Equal(t, len(loaded), 2, "Should load 2 metadata entries") 360 + 361 + if m, exists := loaded[meta1.DiffHash]; exists { 362 + testutils.Expect.Equal(t, m.CommitHash, meta1.CommitHash) 363 + testutils.Expect.Equal(t, m.Type, meta1.Type) 364 + } else { 365 + t.Errorf("meta1 not found in loaded metadata") 366 + } 367 + 368 + if m, exists := loaded[meta2.DiffHash]; exists { 369 + testutils.Expect.Equal(t, m.CommitHash, meta2.CommitHash) 370 + testutils.Expect.Equal(t, m.Type, meta2.Type) 371 + } else { 372 + t.Errorf("meta2 not found in loaded metadata") 373 + } 374 + } 375 + 376 + func TestLoadExistingMetadata_EmptyDirectory(t *testing.T) { 377 + tmpDir := t.TempDir() 378 + 379 + loaded, err := LoadExistingMetadata(tmpDir) 380 + if err != nil { 381 + t.Fatalf("LoadExistingMetadata() error = %v", err) 382 + } 383 + 384 + testutils.Expect.Equal(t, len(loaded), 0, "Should return empty map for non-existent data directory") 385 + } 386 + 387 + func TestUpdateMetadata(t *testing.T) { 388 + tmpDir := t.TempDir() 389 + 390 + meta := Metadata{ 391 + CommitHash: "original123", 392 + DiffHash: "diffhash111111111111111111111111111111111111111111111111111111111", 393 + Type: "added", 394 + Summary: "Feature", 395 + Author: "User", 396 + Date: time.Now(), 397 + } 398 + 399 + _, err := WriteWithMetadata(tmpDir, meta) 400 + if err != nil { 401 + t.Fatalf("Failed to write metadata: %v", err) 402 + } 403 + 404 + newCommitHash := "rebased456" 405 + err = UpdateMetadata(tmpDir, meta.DiffHash, newCommitHash) 406 + if err != nil { 407 + t.Fatalf("UpdateMetadata() error = %v", err) 408 + } 409 + 410 + loaded, err := LoadExistingMetadata(tmpDir) 411 + if err != nil { 412 + t.Fatalf("LoadExistingMetadata() error = %v", err) 413 + } 414 + 415 + updated, exists := loaded[meta.DiffHash] 416 + if !exists { 417 + t.Fatal("Updated metadata not found") 418 + } 419 + 420 + testutils.Expect.Equal(t, updated.CommitHash, newCommitHash, "CommitHash should be updated") 421 + testutils.Expect.Equal(t, updated.Type, meta.Type, "Other fields should remain unchanged") 422 + testutils.Expect.Equal(t, updated.Summary, meta.Summary, "Other fields should remain unchanged") 423 + } 424 + 425 + func TestDeduplication_SameCommit(t *testing.T) { 426 + tmpDir := t.TempDir() 427 + repo := testutils.SetupTestRepo(t) 428 + commits := testutils.GetCommitHistory(t, repo) 429 + if len(commits) == 0 { 430 + t.Fatal("Expected at least one commit") 431 + } 432 + 433 + commit := commits[0] 434 + diffHash, err := ComputeDiffHash(commit) 435 + if err != nil { 436 + t.Fatalf("ComputeDiffHash() error = %v", err) 437 + } 438 + 439 + meta := Metadata{ 440 + CommitHash: commit.Hash.String(), 441 + DiffHash: diffHash, 442 + Type: "added", 443 + Summary: "Test feature", 444 + Author: commit.Author.Name, 445 + Date: commit.Author.When, 446 + } 447 + 448 + _, err = WriteWithMetadata(tmpDir, meta) 449 + if err != nil { 450 + t.Fatalf("First WriteWithMetadata() error = %v", err) 451 + } 452 + 453 + existing, err := LoadExistingMetadata(tmpDir) 454 + if err != nil { 455 + t.Fatalf("LoadExistingMetadata() error = %v", err) 456 + } 457 + 458 + if existingMeta, exists := existing[diffHash]; exists { 459 + testutils.Expect.Equal(t, existingMeta.CommitHash, commit.Hash.String(), "Should detect exact duplicate") 460 + } else { 461 + t.Error("Metadata should exist in loaded entries") 462 + } 463 + } 464 + 465 + func TestDeduplication_RebasedCommit(t *testing.T) { 466 + tmpDir := t.TempDir() 467 + repo := testutils.SetupTestRepo(t) 468 + 469 + commits := testutils.GetCommitHistory(t, repo) 470 + if len(commits) == 0 { 471 + t.Fatal("Expected at least one commit") 472 + } 473 + 474 + commit := commits[0] 475 + diffHash, err := ComputeDiffHash(commit) 476 + if err != nil { 477 + t.Fatalf("ComputeDiffHash() error = %v", err) 478 + } 479 + 480 + originalMeta := Metadata{ 481 + CommitHash: "original_commit_hash_123", 482 + DiffHash: diffHash, 483 + Type: "added", 484 + Summary: "Test feature", 485 + Author: commit.Author.Name, 486 + Date: commit.Author.When, 487 + } 488 + 489 + _, err = WriteWithMetadata(tmpDir, originalMeta) 490 + if err != nil { 491 + t.Fatalf("WriteWithMetadata() error = %v", err) 492 + } 493 + 494 + existing, err := LoadExistingMetadata(tmpDir) 495 + if err != nil { 496 + t.Fatalf("LoadExistingMetadata() error = %v", err) 497 + } 498 + 499 + if existingMeta, exists := existing[diffHash]; exists { 500 + if existingMeta.CommitHash != commit.Hash.String() { 501 + err = UpdateMetadata(tmpDir, diffHash, commit.Hash.String()) 502 + if err != nil { 503 + t.Fatalf("UpdateMetadata() error = %v", err) 504 + } 505 + 506 + updated, err := LoadExistingMetadata(tmpDir) 507 + if err != nil { 508 + t.Fatalf("LoadExistingMetadata() after update error = %v", err) 509 + } 510 + 511 + updatedMeta := updated[diffHash] 512 + testutils.Expect.Equal(t, updatedMeta.CommitHash, commit.Hash.String(), "CommitHash should be updated for rebased commit") 513 + } 514 + } 515 + }
+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 + }
+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 + }