changelog generator & diff tool stormlightlabs.github.io/git-storm/
changelog changeset markdown golang git
at main 11 kB view raw
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}