Monorepo for Tangled
0

Configure Feed

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

claude clean room

Signed-off-by: Seongmin Lee <git@boltless.me>

Seongmin Lee (Jul 2, 2026, 12:51 PM +0900) b2d76746 31a4e39a

+255 -10
+135
appview/pulls/diff_hunks_test.go
··· 1 + package pulls 2 + 3 + import ( 4 + "testing" 5 + 6 + gitmirrorv1 "tangled.org/core/gitmirror/proto/gen" 7 + ) 8 + 9 + func u32(v uint32) *uint32 { return &v } 10 + 11 + // lines returns n lines named l0..l{n-1}, optionally overriding some by index. 12 + func lines(n int, override map[int]string) []string { 13 + out := make([]string, n) 14 + for i := range out { 15 + if v, ok := override[i]; ok { 16 + out[i] = v 17 + } else { 18 + out[i] = "l" + string(rune('0'+i%10)) 19 + } 20 + } 21 + return out 22 + } 23 + 24 + func hunk(pairs ...*gitmirrorv1.LinePair) *gitmirrorv1.Hunk { 25 + return &gitmirrorv1.Hunk{Lines: pairs} 26 + } 27 + 28 + func opCounts(h displayHunk) (ctx, del, add int) { 29 + for _, r := range h.rows { 30 + switch r.op { 31 + case opContext: 32 + ctx++ 33 + case opDel: 34 + del++ 35 + case opAdd: 36 + add++ 37 + } 38 + } 39 + return 40 + } 41 + 42 + func TestBuildHunks_SingleModification(t *testing.T) { 43 + base := lines(10, map[int]string{5: "old"}) 44 + head := lines(10, map[int]string{5: "new"}) 45 + hunks := []*gitmirrorv1.Hunk{hunk(&gitmirrorv1.LinePair{Lhs: u32(5), Rhs: u32(5)})} 46 + 47 + got := buildHunks(base, head, hunks) 48 + if len(got) != 1 { 49 + t.Fatalf("want 1 hunk, got %d", len(got)) 50 + } 51 + // 3 leading + del + add + 3 trailing = 8 rows. 52 + if len(got[0].rows) != 8 { 53 + t.Fatalf("want 8 rows, got %d: %+v", len(got[0].rows), got[0].rows) 54 + } 55 + ctx, del, add := opCounts(got[0]) 56 + if ctx != 6 || del != 1 || add != 1 { 57 + t.Fatalf("want ctx=6 del=1 add=1, got ctx=%d del=%d add=%d", ctx, del, add) 58 + } 59 + // Leading context starts at line index 2 (5-3), paired on both sides. 60 + r0 := got[0].rows[0] 61 + if r0.op != opContext || r0.lhs != 2 || r0.rhs != 2 { 62 + t.Fatalf("bad first context row: %+v", r0) 63 + } 64 + if got[0].rows[3].op != opDel || got[0].rows[3].lhs != 5 { 65 + t.Fatalf("bad del row: %+v", got[0].rows[3]) 66 + } 67 + } 68 + 69 + func TestBuildHunks_MergeClose(t *testing.T) { 70 + // Changes at index 3 and 8: gap of 4 unchanged lines <= 6, so merged into one hunk. 71 + base := lines(12, map[int]string{3: "a", 8: "b"}) 72 + head := lines(12, map[int]string{3: "A", 8: "B"}) 73 + hunks := []*gitmirrorv1.Hunk{ 74 + hunk(&gitmirrorv1.LinePair{Lhs: u32(3), Rhs: u32(3)}), 75 + hunk(&gitmirrorv1.LinePair{Lhs: u32(8), Rhs: u32(8)}), 76 + } 77 + got := buildHunks(base, head, hunks) 78 + if len(got) != 1 { 79 + t.Fatalf("want 1 merged hunk, got %d", len(got)) 80 + } 81 + ctx, del, add := opCounts(got[0]) 82 + // 3 leading + 4 between + 3 trailing = 10 context; 2 del; 2 add. 83 + if ctx != 10 || del != 2 || add != 2 { 84 + t.Fatalf("want ctx=10 del=2 add=2, got ctx=%d del=%d add=%d", ctx, del, add) 85 + } 86 + } 87 + 88 + func TestBuildHunks_SplitFar(t *testing.T) { 89 + // Changes at index 3 and 20: gap 16 > 6, so two separate hunks. 90 + base := lines(25, map[int]string{3: "a", 20: "b"}) 91 + head := lines(25, map[int]string{3: "A", 20: "B"}) 92 + hunks := []*gitmirrorv1.Hunk{ 93 + hunk(&gitmirrorv1.LinePair{Lhs: u32(3), Rhs: u32(3)}), 94 + hunk(&gitmirrorv1.LinePair{Lhs: u32(20), Rhs: u32(20)}), 95 + } 96 + got := buildHunks(base, head, hunks) 97 + if len(got) != 2 { 98 + t.Fatalf("want 2 hunks, got %d", len(got)) 99 + } 100 + for i, h := range got { 101 + ctx, del, add := opCounts(h) 102 + if ctx != 6 || del != 1 || add != 1 { 103 + t.Fatalf("hunk %d: want ctx=6 del=1 add=1, got ctx=%d del=%d add=%d", i, ctx, del, add) 104 + } 105 + } 106 + } 107 + 108 + func TestBuildHunks_PureInsertionAtTop(t *testing.T) { 109 + base := lines(5, nil) 110 + // Two lines inserted at the very top; head = 2 new + 5 base. 111 + head := append([]string{"x", "y"}, base...) 112 + hunks := []*gitmirrorv1.Hunk{ 113 + hunk( 114 + &gitmirrorv1.LinePair{Rhs: u32(0)}, 115 + &gitmirrorv1.LinePair{Rhs: u32(1)}, 116 + ), 117 + } 118 + got := buildHunks(base, head, hunks) 119 + if len(got) != 1 { 120 + t.Fatalf("want 1 hunk, got %d", len(got)) 121 + } 122 + // No leading context (clamped to start); 2 adds; up to 3 trailing context. 123 + if got[0].rows[0].op != opAdd || got[0].rows[1].op != opAdd { 124 + t.Fatalf("want first two rows to be adds, got %+v", got[0].rows[:2]) 125 + } 126 + ctx, del, add := opCounts(got[0]) 127 + if del != 0 || add != 2 || ctx != 3 { 128 + t.Fatalf("want ctx=3 del=0 add=2, got ctx=%d del=%d add=%d", ctx, del, add) 129 + } 130 + // Trailing context pairs base line 0 with head line 2 (the insertion offset). 131 + first := got[0].rows[2] 132 + if first.op != opContext || first.lhs != 0 || first.rhs != 2 { 133 + t.Fatalf("bad trailing context row: %+v", first) 134 + } 135 + }
+120 -10
appview/pulls/pull2.go
··· 285 285 // TODO: implement split view 286 286 _ = unified 287 287 288 - // TODO: implement context lines 289 288 for _, f := range files { 290 - params.Diff += f.diff.RhsSrc.Path + "\n\n" 291 - for _, hunk := range f.diff.Hunks { 289 + params.Diff += f.diff.RhsSrc.Path + "\n" 290 + for _, h := range buildHunks(f.baseLines, f.headLines, f.diff.Hunks) { 292 291 params.Diff += "@@@\n" 293 - for _, line := range hunk.Lines { 294 - if line.Lhs != nil { 295 - params.Diff += fmt.Sprintf("%d\t\t - %s\n", *line.Lhs+1, f.baseLines[*line.Lhs]) 292 + for _, row := range h.rows { 293 + switch row.op { 294 + case opContext: 295 + params.Diff += fmt.Sprintf("%d\t%d\t %s\n", row.lhs+1, row.rhs+1, row.content) 296 + case opDelete: 297 + params.Diff += fmt.Sprintf("%d\t\t - %s\n", row.lhs+1, row.content) 298 + case opAdd: 299 + params.Diff += fmt.Sprintf("\t%d\t + %s\n", row.rhs+1, row.content) 296 300 } 297 301 } 298 - for _, line := range hunk.Lines { 299 - if line.Rhs != nil { 300 - params.Diff += fmt.Sprintf("\t%d\t + %s\n", *line.Rhs+1, f.headLines[*line.Rhs]) 301 - } 302 + } 303 + } 304 + } 305 + 306 + type diffOp int 307 + 308 + const ( 309 + opContext diffOp = iota 310 + opDelete 311 + opAdd 312 + ) 313 + 314 + type diffRow struct { 315 + op diffOp 316 + lhs int 317 + rhs int 318 + content string 319 + } 320 + 321 + type displayHunk struct { 322 + rows []diffRow 323 + } 324 + 325 + const contextLines = 3 326 + 327 + // buildHunks turns gitmirror's changed-lines-only hunks into display hunks that include 328 + // up to contextLines of unchanged context around each change, merging hunks whose 329 + // context windows overlap (gap <= 2*contextLines unchanged lines) into one display hunk. 330 + // 331 + // gitmirror hunks carry only changed lines (deletions + additions); context lines are 332 + // shared content, so at any unchanged line baseLines[lhs] == headLines[rhs] and the two 333 + // cursors advance together. We track li/ri (next unconsumed line per side) and derive a 334 + // hunk's start on a side that has no lines from the other side via that invariant. 335 + func buildHunks(baseLines, headLines []string, hunks []*gitmirrorv1.Hunk) []displayHunk { 336 + var out []displayHunk 337 + var cur *displayHunk 338 + li, ri := 0, 0 339 + 340 + // emitContext appends n unchanged rows starting at base line lx (paired head line rx). 341 + emitContext := func(lx, rx, n int) { 342 + for j := range n { 343 + cur.rows = append(cur.rows, diffRow{ 344 + op: opContext, 345 + lhs: lx + j, 346 + rhs: rx + j, 347 + content: baseLines[lx+j], 348 + }) 349 + } 350 + } 351 + 352 + for _, h := range hunks { 353 + var lhsNums, rhsNums []int 354 + for _, lp := range h.Lines { 355 + if lp.Lhs != nil { 356 + lhsNums = append(lhsNums, int(*lp.Lhs)) 357 + } 358 + if lp.Rhs != nil { 359 + rhsNums = append(rhsNums, int(*lp.Rhs)) 302 360 } 303 361 } 362 + if len(lhsNums) == 0 && len(rhsNums) == 0 { 363 + continue 364 + } 365 + 366 + // Start of the changed block on each side; derive the empty side from the other. 367 + var lhsStart, rhsStart int 368 + switch { 369 + case len(lhsNums) > 0 && len(rhsNums) > 0: 370 + lhsStart, rhsStart = lhsNums[0], rhsNums[0] 371 + case len(lhsNums) == 0: // pure insertion 372 + rhsStart = rhsNums[0] 373 + lhsStart = li + (rhsStart - ri) 374 + default: // pure deletion 375 + lhsStart = lhsNums[0] 376 + rhsStart = ri + (lhsStart - li) 377 + } 378 + 379 + gap := lhsStart - li // unchanged lines before this change (== rhsStart - ri) 380 + 381 + switch { 382 + case cur == nil: 383 + // First change (or just after a split): leading context only. 384 + cur = &displayHunk{} 385 + lead := min(gap, contextLines) 386 + emitContext(lhsStart-lead, rhsStart-lead, lead) 387 + case gap <= 2*contextLines: 388 + // Close enough to merge: keep all the intervening lines as context. 389 + emitContext(li, ri, gap) 390 + default: 391 + // Too far apart: close this hunk with trailing context, open a new one. 392 + emitContext(li, ri, contextLines) 393 + out = append(out, *cur) 394 + cur = &displayHunk{} 395 + emitContext(lhsStart-contextLines, rhsStart-contextLines, contextLines) 396 + } 397 + 398 + // Changed lines: deletions then additions (matches the unified render order). 399 + for _, ln := range lhsNums { 400 + cur.rows = append(cur.rows, diffRow{op: opDelete, lhs: ln, content: baseLines[ln]}) 401 + } 402 + for _, ln := range rhsNums { 403 + cur.rows = append(cur.rows, diffRow{op: opAdd, rhs: ln, content: headLines[ln]}) 404 + } 405 + 406 + li = lhsStart + len(lhsNums) 407 + ri = rhsStart + len(rhsNums) 304 408 } 409 + 410 + if cur != nil { 411 + emitContext(li, ri, min(len(baseLines)-li, contextLines)) 412 + out = append(out, *cur) 413 + } 414 + return out 305 415 } 306 416 307 417 // getBlob streams a blob's bytes from gitmirror by OID and concatenates them.