+27
-16
ROADMAP.md
+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
+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
+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
+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
+1
-1
go.mod
+208
-4
internal/changeset/changeset.go
+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
+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
+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
+
}