···11+package pulls
22+33+import (
44+ "testing"
55+66+ gitmirrorv1 "tangled.org/core/gitmirror/proto/gen"
77+)
88+99+func u32(v uint32) *uint32 { return &v }
1010+1111+// lines returns n lines named l0..l{n-1}, optionally overriding some by index.
1212+func lines(n int, override map[int]string) []string {
1313+ out := make([]string, n)
1414+ for i := range out {
1515+ if v, ok := override[i]; ok {
1616+ out[i] = v
1717+ } else {
1818+ out[i] = "l" + string(rune('0'+i%10))
1919+ }
2020+ }
2121+ return out
2222+}
2323+2424+func hunk(pairs ...*gitmirrorv1.LinePair) *gitmirrorv1.Hunk {
2525+ return &gitmirrorv1.Hunk{Lines: pairs}
2626+}
2727+2828+func opCounts(h displayHunk) (ctx, del, add int) {
2929+ for _, r := range h.rows {
3030+ switch r.op {
3131+ case opContext:
3232+ ctx++
3333+ case opDel:
3434+ del++
3535+ case opAdd:
3636+ add++
3737+ }
3838+ }
3939+ return
4040+}
4141+4242+func TestBuildHunks_SingleModification(t *testing.T) {
4343+ base := lines(10, map[int]string{5: "old"})
4444+ head := lines(10, map[int]string{5: "new"})
4545+ hunks := []*gitmirrorv1.Hunk{hunk(&gitmirrorv1.LinePair{Lhs: u32(5), Rhs: u32(5)})}
4646+4747+ got := buildHunks(base, head, hunks)
4848+ if len(got) != 1 {
4949+ t.Fatalf("want 1 hunk, got %d", len(got))
5050+ }
5151+ // 3 leading + del + add + 3 trailing = 8 rows.
5252+ if len(got[0].rows) != 8 {
5353+ t.Fatalf("want 8 rows, got %d: %+v", len(got[0].rows), got[0].rows)
5454+ }
5555+ ctx, del, add := opCounts(got[0])
5656+ if ctx != 6 || del != 1 || add != 1 {
5757+ t.Fatalf("want ctx=6 del=1 add=1, got ctx=%d del=%d add=%d", ctx, del, add)
5858+ }
5959+ // Leading context starts at line index 2 (5-3), paired on both sides.
6060+ r0 := got[0].rows[0]
6161+ if r0.op != opContext || r0.lhs != 2 || r0.rhs != 2 {
6262+ t.Fatalf("bad first context row: %+v", r0)
6363+ }
6464+ if got[0].rows[3].op != opDel || got[0].rows[3].lhs != 5 {
6565+ t.Fatalf("bad del row: %+v", got[0].rows[3])
6666+ }
6767+}
6868+6969+func TestBuildHunks_MergeClose(t *testing.T) {
7070+ // Changes at index 3 and 8: gap of 4 unchanged lines <= 6, so merged into one hunk.
7171+ base := lines(12, map[int]string{3: "a", 8: "b"})
7272+ head := lines(12, map[int]string{3: "A", 8: "B"})
7373+ hunks := []*gitmirrorv1.Hunk{
7474+ hunk(&gitmirrorv1.LinePair{Lhs: u32(3), Rhs: u32(3)}),
7575+ hunk(&gitmirrorv1.LinePair{Lhs: u32(8), Rhs: u32(8)}),
7676+ }
7777+ got := buildHunks(base, head, hunks)
7878+ if len(got) != 1 {
7979+ t.Fatalf("want 1 merged hunk, got %d", len(got))
8080+ }
8181+ ctx, del, add := opCounts(got[0])
8282+ // 3 leading + 4 between + 3 trailing = 10 context; 2 del; 2 add.
8383+ if ctx != 10 || del != 2 || add != 2 {
8484+ t.Fatalf("want ctx=10 del=2 add=2, got ctx=%d del=%d add=%d", ctx, del, add)
8585+ }
8686+}
8787+8888+func TestBuildHunks_SplitFar(t *testing.T) {
8989+ // Changes at index 3 and 20: gap 16 > 6, so two separate hunks.
9090+ base := lines(25, map[int]string{3: "a", 20: "b"})
9191+ head := lines(25, map[int]string{3: "A", 20: "B"})
9292+ hunks := []*gitmirrorv1.Hunk{
9393+ hunk(&gitmirrorv1.LinePair{Lhs: u32(3), Rhs: u32(3)}),
9494+ hunk(&gitmirrorv1.LinePair{Lhs: u32(20), Rhs: u32(20)}),
9595+ }
9696+ got := buildHunks(base, head, hunks)
9797+ if len(got) != 2 {
9898+ t.Fatalf("want 2 hunks, got %d", len(got))
9999+ }
100100+ for i, h := range got {
101101+ ctx, del, add := opCounts(h)
102102+ if ctx != 6 || del != 1 || add != 1 {
103103+ t.Fatalf("hunk %d: want ctx=6 del=1 add=1, got ctx=%d del=%d add=%d", i, ctx, del, add)
104104+ }
105105+ }
106106+}
107107+108108+func TestBuildHunks_PureInsertionAtTop(t *testing.T) {
109109+ base := lines(5, nil)
110110+ // Two lines inserted at the very top; head = 2 new + 5 base.
111111+ head := append([]string{"x", "y"}, base...)
112112+ hunks := []*gitmirrorv1.Hunk{
113113+ hunk(
114114+ &gitmirrorv1.LinePair{Rhs: u32(0)},
115115+ &gitmirrorv1.LinePair{Rhs: u32(1)},
116116+ ),
117117+ }
118118+ got := buildHunks(base, head, hunks)
119119+ if len(got) != 1 {
120120+ t.Fatalf("want 1 hunk, got %d", len(got))
121121+ }
122122+ // No leading context (clamped to start); 2 adds; up to 3 trailing context.
123123+ if got[0].rows[0].op != opAdd || got[0].rows[1].op != opAdd {
124124+ t.Fatalf("want first two rows to be adds, got %+v", got[0].rows[:2])
125125+ }
126126+ ctx, del, add := opCounts(got[0])
127127+ if del != 0 || add != 2 || ctx != 3 {
128128+ t.Fatalf("want ctx=3 del=0 add=2, got ctx=%d del=%d add=%d", ctx, del, add)
129129+ }
130130+ // Trailing context pairs base line 0 with head line 2 (the insertion offset).
131131+ first := got[0].rows[2]
132132+ if first.op != opContext || first.lhs != 0 || first.rhs != 2 {
133133+ t.Fatalf("bad trailing context row: %+v", first)
134134+ }
135135+}
+120-10
appview/pulls/pull2.go
···285285 // TODO: implement split view
286286 _ = unified
287287288288- // TODO: implement context lines
289288 for _, f := range files {
290290- params.Diff += f.diff.RhsSrc.Path + "\n\n"
291291- for _, hunk := range f.diff.Hunks {
289289+ params.Diff += f.diff.RhsSrc.Path + "\n"
290290+ for _, h := range buildHunks(f.baseLines, f.headLines, f.diff.Hunks) {
292291 params.Diff += "@@@\n"
293293- for _, line := range hunk.Lines {
294294- if line.Lhs != nil {
295295- params.Diff += fmt.Sprintf("%d\t\t - %s\n", *line.Lhs+1, f.baseLines[*line.Lhs])
292292+ for _, row := range h.rows {
293293+ switch row.op {
294294+ case opContext:
295295+ params.Diff += fmt.Sprintf("%d\t%d\t %s\n", row.lhs+1, row.rhs+1, row.content)
296296+ case opDelete:
297297+ params.Diff += fmt.Sprintf("%d\t\t - %s\n", row.lhs+1, row.content)
298298+ case opAdd:
299299+ params.Diff += fmt.Sprintf("\t%d\t + %s\n", row.rhs+1, row.content)
296300 }
297301 }
298298- for _, line := range hunk.Lines {
299299- if line.Rhs != nil {
300300- params.Diff += fmt.Sprintf("\t%d\t + %s\n", *line.Rhs+1, f.headLines[*line.Rhs])
301301- }
302302+ }
303303+ }
304304+}
305305+306306+type diffOp int
307307+308308+const (
309309+ opContext diffOp = iota
310310+ opDelete
311311+ opAdd
312312+)
313313+314314+type diffRow struct {
315315+ op diffOp
316316+ lhs int
317317+ rhs int
318318+ content string
319319+}
320320+321321+type displayHunk struct {
322322+ rows []diffRow
323323+}
324324+325325+const contextLines = 3
326326+327327+// buildHunks turns gitmirror's changed-lines-only hunks into display hunks that include
328328+// up to contextLines of unchanged context around each change, merging hunks whose
329329+// context windows overlap (gap <= 2*contextLines unchanged lines) into one display hunk.
330330+//
331331+// gitmirror hunks carry only changed lines (deletions + additions); context lines are
332332+// shared content, so at any unchanged line baseLines[lhs] == headLines[rhs] and the two
333333+// cursors advance together. We track li/ri (next unconsumed line per side) and derive a
334334+// hunk's start on a side that has no lines from the other side via that invariant.
335335+func buildHunks(baseLines, headLines []string, hunks []*gitmirrorv1.Hunk) []displayHunk {
336336+ var out []displayHunk
337337+ var cur *displayHunk
338338+ li, ri := 0, 0
339339+340340+ // emitContext appends n unchanged rows starting at base line lx (paired head line rx).
341341+ emitContext := func(lx, rx, n int) {
342342+ for j := range n {
343343+ cur.rows = append(cur.rows, diffRow{
344344+ op: opContext,
345345+ lhs: lx + j,
346346+ rhs: rx + j,
347347+ content: baseLines[lx+j],
348348+ })
349349+ }
350350+ }
351351+352352+ for _, h := range hunks {
353353+ var lhsNums, rhsNums []int
354354+ for _, lp := range h.Lines {
355355+ if lp.Lhs != nil {
356356+ lhsNums = append(lhsNums, int(*lp.Lhs))
357357+ }
358358+ if lp.Rhs != nil {
359359+ rhsNums = append(rhsNums, int(*lp.Rhs))
302360 }
303361 }
362362+ if len(lhsNums) == 0 && len(rhsNums) == 0 {
363363+ continue
364364+ }
365365+366366+ // Start of the changed block on each side; derive the empty side from the other.
367367+ var lhsStart, rhsStart int
368368+ switch {
369369+ case len(lhsNums) > 0 && len(rhsNums) > 0:
370370+ lhsStart, rhsStart = lhsNums[0], rhsNums[0]
371371+ case len(lhsNums) == 0: // pure insertion
372372+ rhsStart = rhsNums[0]
373373+ lhsStart = li + (rhsStart - ri)
374374+ default: // pure deletion
375375+ lhsStart = lhsNums[0]
376376+ rhsStart = ri + (lhsStart - li)
377377+ }
378378+379379+ gap := lhsStart - li // unchanged lines before this change (== rhsStart - ri)
380380+381381+ switch {
382382+ case cur == nil:
383383+ // First change (or just after a split): leading context only.
384384+ cur = &displayHunk{}
385385+ lead := min(gap, contextLines)
386386+ emitContext(lhsStart-lead, rhsStart-lead, lead)
387387+ case gap <= 2*contextLines:
388388+ // Close enough to merge: keep all the intervening lines as context.
389389+ emitContext(li, ri, gap)
390390+ default:
391391+ // Too far apart: close this hunk with trailing context, open a new one.
392392+ emitContext(li, ri, contextLines)
393393+ out = append(out, *cur)
394394+ cur = &displayHunk{}
395395+ emitContext(lhsStart-contextLines, rhsStart-contextLines, contextLines)
396396+ }
397397+398398+ // Changed lines: deletions then additions (matches the unified render order).
399399+ for _, ln := range lhsNums {
400400+ cur.rows = append(cur.rows, diffRow{op: opDelete, lhs: ln, content: baseLines[ln]})
401401+ }
402402+ for _, ln := range rhsNums {
403403+ cur.rows = append(cur.rows, diffRow{op: opAdd, rhs: ln, content: headLines[ln]})
404404+ }
405405+406406+ li = lhsStart + len(lhsNums)
407407+ ri = rhsStart + len(rhsNums)
304408 }
409409+410410+ if cur != nil {
411411+ emitContext(li, ri, min(len(baseLines)-li, contextLines))
412412+ out = append(out, *cur)
413413+ }
414414+ return out
305415}
306416307417// getBlob streams a blob's bytes from gitmirror by OID and concatenates them.