changelog generator & diff tool
stormlightlabs.github.io/git-storm/
changelog
changeset
markdown
golang
git
1package diff
2
3// EditKind defines the type of diff operation.
4type EditKind int
5
6const (
7 Equal EditKind = iota // context: lines unchanged
8 Insert // added lines ('+')
9 Delete // removed lines ('-')
10 Replace // changed lines (shown as Delete + Insert in unified view)
11)
12
13func (e EditKind) String() string {
14 switch e {
15 case Equal:
16 return "Equal"
17 case Insert:
18 return "Insert"
19 case Delete:
20 return "Delete"
21 case Replace:
22 return "Replace"
23 default:
24 return "Unknown"
25 }
26}
27
28// Edit represents a single edit operation in a diff.
29type Edit struct {
30 Kind EditKind // Equal, Insert, Delete, or Replace
31 AIndex int // index in original sequence (-1 for Insert-only)
32 BIndex int // index in new sequence (-1 for Delete-only)
33 Content string // the line or token (old content for Replace)
34 NewContent string // new content (only used for Replace operations)
35}
36
37type outputEdit struct {
38 edit Edit
39 origPosition int
40}
41
42type mergeInfo struct {
43 partnIndex int // index of the partner edit
44 isDelete bool
45}
46
47// Diff represents a generic diffing algorithm.
48type Diff interface {
49 // Compute computes the edit operations needed to transform a into b.
50 Compute(a, b []string) ([]Edit, error)
51
52 // Name returns the human-readable algorithm name (e.g., "LCS", "Hunt–McIlroy").
53 Name() string
54}
55
56// LCS implements a shortest-edit-script diff algorithm.
57//
58// For maintainability we use the classic dynamic-programming formulation based on the longest common subsequence.
59// While the original Myers paper achieves O(ND) time, this O(NM) variant is simpler and still practical for the small inputs handled by this package.
60type LCS struct{}
61
62func (l *LCS) Name() string { return "LCS" }
63
64// Compute computes the shortest edit script using the LCS diff algorithm.
65//
66// It builds an LCS matrix and walks it to emit the sequence of Equal, Insert, and Delete operations required to transform a into b.
67func (l *LCS) Compute(a, b []string) ([]Edit, error) {
68 n := len(a)
69 lenB := len(b)
70
71 if n == 0 && lenB == 0 {
72 return []Edit{}, nil
73 }
74
75 if n == 0 {
76 edits := make([]Edit, lenB)
77 for i := range lenB {
78 edits[i] = Edit{Kind: Insert, AIndex: -1, BIndex: i, Content: b[i]}
79 }
80 return edits, nil
81 }
82
83 if lenB == 0 {
84 edits := make([]Edit, n)
85 for i := range n {
86 edits[i] = Edit{Kind: Delete, AIndex: i, BIndex: -1, Content: a[i]}
87 }
88 return edits, nil
89 }
90
91 lcs := make([][]int, n+1)
92 for i := range lcs {
93 lcs[i] = make([]int, lenB+1)
94 }
95
96 for i := n - 1; i >= 0; i-- {
97 for j := lenB - 1; j >= 0; j-- {
98 if a[i] == b[j] {
99 lcs[i][j] = lcs[i+1][j+1] + 1
100 } else if lcs[i+1][j] >= lcs[i][j+1] {
101 lcs[i][j] = lcs[i+1][j]
102 } else {
103 lcs[i][j] = lcs[i][j+1]
104 }
105 }
106 }
107
108 edits := make([]Edit, 0, n+lenB)
109
110 i, j := 0, 0
111 for i < n && j < lenB {
112 switch {
113 case a[i] == b[j]:
114 edits = append(edits, Edit{
115 Kind: Equal,
116 AIndex: i,
117 BIndex: j,
118 Content: a[i],
119 })
120 i++
121 j++
122 case lcs[i+1][j] >= lcs[i][j+1]:
123 edits = append(edits, Edit{
124 Kind: Delete,
125 AIndex: i,
126 BIndex: -1,
127 Content: a[i],
128 })
129 i++
130 default:
131 edits = append(edits, Edit{
132 Kind: Insert,
133 AIndex: -1,
134 BIndex: j,
135 Content: b[j],
136 })
137 j++
138 }
139 }
140
141 for i < n {
142 edits = append(edits, Edit{
143 Kind: Delete,
144 AIndex: i,
145 BIndex: -1,
146 Content: a[i],
147 })
148 i++
149 }
150
151 for j < lenB {
152 edits = append(edits, Edit{
153 Kind: Insert,
154 AIndex: -1,
155 BIndex: j,
156 Content: b[j],
157 })
158 j++
159 }
160
161 return edits, nil
162}
163
164// Myers implements the Myers algorithm.
165type Myers struct{}
166
167// Name returns algorithm name.
168func (m *Myers) Name() string {
169 return "Myers"
170}
171
172// Compute computes the diff edits needed to transform a into b.
173func (m *Myers) Compute(a, b []string) ([]Edit, error) {
174 n := len(a)
175 mLen := len(b)
176 max := n + mLen
177
178 if n == 0 && mLen == 0 {
179 return []Edit{}, nil
180 }
181
182 if n == 0 {
183 edits := make([]Edit, mLen)
184 for i := range mLen {
185 edits[i] = Edit{Kind: Insert, AIndex: -1, BIndex: i, Content: b[i]}
186 }
187 return edits, nil
188 }
189
190 if mLen == 0 {
191 edits := make([]Edit, n)
192 for i := range n {
193 edits[i] = Edit{Kind: Delete, AIndex: i, BIndex: -1, Content: a[i]}
194 }
195 return edits, nil
196 }
197
198 offset := max
199 size := 2*max + 1
200 V := make([]int, size)
201 trace := make([][]int, max+1)
202
203 if offset+1 < size {
204 V[offset+1] = 0
205 }
206 for D := 0; D <= max; D++ {
207 currentV := make([]int, size)
208 copy(currentV, V)
209 trace[D] = currentV
210
211 for k := -D; k <= D; k += 2 {
212 idx := offset + k
213
214 var x int
215 if k == -D || (k != D && V[idx-1] < V[idx+1]) {
216 x = V[idx+1]
217 } else {
218 x = V[idx-1] + 1
219 }
220 y := x - k
221
222 for x < n && y < mLen && a[x] == b[y] {
223 x++
224 y++
225 }
226
227 V[idx] = x
228
229 if x >= n && y >= mLen {
230 return m.buildEdits(a, b, trace, D, offset), nil
231 }
232 }
233 }
234
235 return nil, nil
236}
237
238// buildEdits reconstructs the edit script from the trace of V arrays.
239func (m *Myers) buildEdits(a, b []string, trace [][]int, D, offset int) []Edit {
240 var edits []Edit
241 x := len(a)
242 y := len(b)
243
244 for d := D; d > 0; d-- {
245 V := trace[d]
246 k := x - y
247 idx := offset + k
248
249 var prevK int
250 if k == -d || (k != d && V[idx-1] < V[idx+1]) {
251 prevK = k + 1
252 } else {
253 prevK = k - 1
254 }
255
256 prevX := V[offset+prevK]
257 prevY := prevX - prevK
258
259 var xStart, yStart int
260 if prevK == k-1 {
261 xStart = prevX + 1
262 yStart = prevY
263 } else {
264 xStart = prevX
265 yStart = prevY + 1
266 }
267
268 for x > xStart && y > yStart {
269 x--
270 y--
271 edits = append(edits, Edit{
272 Kind: Equal,
273 AIndex: x,
274 BIndex: y,
275 Content: a[x],
276 })
277 }
278
279 if xStart == prevX+1 {
280 x--
281 edits = append(edits, Edit{
282 Kind: Delete,
283 AIndex: x,
284 BIndex: -1,
285 Content: a[x],
286 })
287 } else {
288 y--
289 edits = append(edits, Edit{
290 Kind: Insert,
291 AIndex: -1,
292 BIndex: y,
293 Content: b[y],
294 })
295 }
296
297 x = prevX
298 y = prevY
299 }
300
301 for x > 0 && y > 0 {
302 if a[x-1] == b[y-1] {
303 x--
304 y--
305 edits = append(edits, Edit{
306 Kind: Equal,
307 AIndex: x,
308 BIndex: y,
309 Content: a[x],
310 })
311 } else {
312 break
313 }
314 }
315
316 for x > 0 {
317 x--
318 edits = append(edits, Edit{
319 Kind: Delete,
320 AIndex: x,
321 BIndex: -1,
322 Content: a[x],
323 })
324 }
325 for y > 0 {
326 y--
327 edits = append(edits, Edit{
328 Kind: Insert,
329 AIndex: -1,
330 BIndex: y,
331 Content: b[y],
332 })
333 }
334
335 for i, j := 0, len(edits)-1; i < j; i, j = i+1, j-1 {
336 edits[i], edits[j] = edits[j], edits[i]
337 }
338 return edits
339}
340
341// ApplyEdits applies a sequence of edits to reconstruct the target sequence to verify that the diff is correct.
342func ApplyEdits(_ []string, edits []Edit) []string {
343 result := make([]string, 0)
344 for _, edit := range edits {
345 switch edit.Kind {
346 case Equal, Insert:
347 result = append(result, edit.Content)
348 case Delete:
349 // Skip deleted lines
350 }
351 }
352 return result
353}
354
355// CountEditKinds returns a map counting occurrences of each [EditKind].
356func CountEditKinds(edits []Edit) map[EditKind]int {
357 counts := make(map[EditKind]int)
358 for _, edit := range edits {
359 counts[edit.Kind]++
360 }
361 return counts
362}
363
364// MergeReplacements merges Delete+Insert pairs into Replace operations for better side-by-side rendering.
365//
366// This function identifies blocks of Delete and Insert operations and pairs them up based on similarity.
367// When a Delete and Insert represent the same logical line being modified (e.g., version bump),
368// they are merged into a Replace operation that can be rendered on a single line.
369//
370// The function uses a similarity heuristic to determine if a Delete and Insert pair should be merged:
371// - They must share a common prefix of at least 70% of the shorter line's length
372// - This prevents merging unrelated changes (e.g., different package names)
373//
374// The algorithm processes edits in windows, looking ahead up to 10 positions to find matching pairs.
375func MergeReplacements(edits []Edit) []Edit {
376 if len(edits) <= 1 {
377 return edits
378 }
379
380 merged := make(map[int]mergeInfo)
381 const lookAheadWindow = 50
382
383 for i := range edits {
384 if _, exists := merged[i]; exists || edits[i].Kind != Delete {
385 continue
386 }
387
388 found := false
389 for j := i + 1; j < len(edits) && j < i+lookAheadWindow; j++ {
390 if _, exists := merged[j]; exists || edits[j].Kind != Insert {
391 continue
392 }
393
394 if areSimilarLines(edits[i].Content, edits[j].Content) {
395 merged[i] = mergeInfo{partnIndex: j, isDelete: true}
396 merged[j] = mergeInfo{partnIndex: i, isDelete: false}
397 found = true
398 break
399 }
400 }
401
402 if !found {
403 for j := i - 1; j >= 0 && j >= i-lookAheadWindow; j-- {
404 if _, exists := merged[j]; exists || edits[j].Kind != Insert {
405 continue
406 }
407
408 if areSimilarLines(edits[i].Content, edits[j].Content) {
409 merged[i] = mergeInfo{partnIndex: j, isDelete: true}
410 merged[j] = mergeInfo{partnIndex: i, isDelete: false}
411 break
412 }
413 }
414 }
415 }
416
417 for i := range edits {
418 if _, exists := merged[i]; exists || edits[i].Kind != Insert {
419 continue
420 }
421
422 for j := max(0, i-lookAheadWindow); j < i; j++ {
423 if _, exists := merged[j]; exists || edits[j].Kind != Delete {
424 continue
425 }
426
427 if areSimilarLines(edits[j].Content, edits[i].Content) {
428 merged[j] = mergeInfo{partnIndex: i, isDelete: true}
429 merged[i] = mergeInfo{partnIndex: j, isDelete: false}
430 break
431 }
432 }
433 }
434
435 outputs := make([]outputEdit, 0, len(edits))
436
437 for i := range edits {
438 info, isMerged := merged[i]
439 if !isMerged {
440 outputs = append(outputs, outputEdit{
441 edit: edits[i],
442 origPosition: i,
443 })
444 } else if info.isDelete {
445 outputs = append(outputs, outputEdit{
446 edit: Edit{
447 Kind: Replace,
448 AIndex: edits[i].AIndex,
449 BIndex: edits[info.partnIndex].BIndex,
450 Content: edits[i].Content,
451 NewContent: edits[info.partnIndex].Content,
452 },
453 origPosition: i,
454 })
455 }
456 }
457
458 for i := 0; i < len(outputs); i++ {
459 for j := i + 1; j < len(outputs); j++ {
460 ei := outputs[i].edit
461 ej := outputs[j].edit
462
463 keyI := ei.BIndex
464 if keyI == -1 {
465 keyI = ei.AIndex
466 }
467
468 keyJ := ej.BIndex
469 if keyJ == -1 {
470 keyJ = ej.AIndex
471 }
472
473 if keyI > keyJ {
474 outputs[i], outputs[j] = outputs[j], outputs[i]
475 }
476 }
477 }
478
479 result := make([]Edit, 0, len(outputs))
480 for _, out := range outputs {
481 result = append(result, out.edit)
482 }
483 return result
484}
485
486// areSimilarLines determines if two lines are similar enough to be considered a replacement.
487//
488// Uses a two-phase similarity 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)
492func areSimilarLines(a, b string) bool {
493 if a == b {
494 return true
495 }
496
497 minLen := min(len(b), len(a))
498
499 if minLen == 0 {
500 return false
501 }
502
503 commonPrefix := 0
504 for i := range minLen {
505 if a[i] == b[i] {
506 commonPrefix++
507 } else {
508 break
509 }
510 }
511
512 prefixThreshold := float64(minLen) * 0.7
513 if float64(commonPrefix) < prefixThreshold {
514 return false
515 }
516
517 suffixA := a[commonPrefix:]
518 suffixB := b[commonPrefix:]
519
520 suffixLenA := len(suffixA)
521 suffixLenB := len(suffixB)
522
523 if suffixLenA == 0 && suffixLenB == 0 {
524 return true
525 }
526
527 lenDiff := suffixLenA - suffixLenB
528 if lenDiff < 0 {
529 lenDiff = -lenDiff
530 }
531
532 maxSuffixLen := max(suffixLenB, suffixLenA)
533 if maxSuffixLen > 0 && float64(lenDiff)/float64(maxSuffixLen) > 0.3 {
534 return false
535 }
536 return true
537}