From 067abad861abc91baaf8d97a2415bee14dcd5781 Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Wed, 1 Jul 2026 13:51:26 +0900 Subject: [PATCH] claude difftastic Signed-off-by: Seongmin Lee --- appview/pulls/diff_hunks_test.go | 227 ++++++++++++++++--------- appview/pulls/pull2.go | 274 +++++++++++++++++++++---------- 2 files changed, 341 insertions(+), 160 deletions(-) diff --git a/appview/pulls/diff_hunks_test.go b/appview/pulls/diff_hunks_test.go index 22207c84..e6475291 100644 --- a/appview/pulls/diff_hunks_test.go +++ b/appview/pulls/diff_hunks_test.go @@ -1,6 +1,7 @@ package pulls import ( + "strings" "testing" gitmirrorv1 "tangled.org/core/gitmirror/proto/gen" @@ -25,111 +26,185 @@ func hunk(pairs ...*gitmirrorv1.LinePair) *gitmirrorv1.Hunk { return &gitmirrorv1.Hunk{Lines: pairs} } -func opCounts(h displayHunk) (ctx, del, add int) { - for _, r := range h.rows { - switch r.op { - case opContext: - ctx++ - case opDel: - del++ - case opAdd: - add++ +func TestAlignFile_Modification(t *testing.T) { + base := lines(10, map[int]string{5: "old"}) + head := lines(10, map[int]string{5: "new"}) + hunks := []*gitmirrorv1.Hunk{hunk(&gitmirrorv1.LinePair{Lhs: u32(5), Rhs: u32(5)})} + + pairs, change := alignFile(base, head, hunks) + // Whole file is represented (10 lines), exactly one change at the line-5 pair. + if len(pairs) != 10 || len(change) != 10 { + t.Fatalf("want 10 pairs, got %d", len(pairs)) + } + nChange := 0 + for i, c := range change { + if c { + nChange++ + if pairs[i].lhs != 5 || pairs[i].rhs != 5 { + t.Fatalf("change pair = %+v, want {5,5}", pairs[i]) + } } } - return + if nChange != 1 { + t.Fatalf("want 1 change, got %d", nChange) + } } -func TestBuildHunks_SingleModification(t *testing.T) { +func TestGroupHunks_SingleWindowClamped(t *testing.T) { base := lines(10, map[int]string{5: "old"}) head := lines(10, map[int]string{5: "new"}) - hunks := []*gitmirrorv1.Hunk{hunk(&gitmirrorv1.LinePair{Lhs: u32(5), Rhs: u32(5)})} + _, change := alignFile(base, head, []*gitmirrorv1.Hunk{hunk(&gitmirrorv1.LinePair{Lhs: u32(5), Rhs: u32(5)})}) - got := buildHunks(base, head, hunks) + got := groupHunks(change) if len(got) != 1 { - t.Fatalf("want 1 hunk, got %d", len(got)) - } - // 3 leading + del + add + 3 trailing = 8 rows. - if len(got[0].rows) != 8 { - t.Fatalf("want 8 rows, got %d: %+v", len(got[0].rows), got[0].rows) + t.Fatalf("want 1 window, got %d: %v", len(got), got) } - ctx, del, add := opCounts(got[0]) - if ctx != 6 || del != 1 || add != 1 { - t.Fatalf("want ctx=6 del=1 add=1, got ctx=%d del=%d add=%d", ctx, del, add) + // Change at index 5, ctx = n+1 = 4 -> [1, 10) clamped to file end. + if got[0] != [2]int{1, 10} { + t.Fatalf("window = %v, want [1 10)", got[0]) } - // Leading context starts at line index 2 (5-3), paired on both sides. - r0 := got[0].rows[0] - if r0.op != opContext || r0.lhs != 2 || r0.rhs != 2 { - t.Fatalf("bad first context row: %+v", r0) +} + +func TestGroupHunks_MergeClose(t *testing.T) { + // Changes 5 lines apart: windows overlap -> single merged window. + base := lines(20, map[int]string{4: "a", 9: "b"}) + head := lines(20, map[int]string{4: "A", 9: "B"}) + _, change := alignFile(base, head, []*gitmirrorv1.Hunk{ + hunk(&gitmirrorv1.LinePair{Lhs: u32(4), Rhs: u32(4)}), + hunk(&gitmirrorv1.LinePair{Lhs: u32(9), Rhs: u32(9)}), + }) + got := groupHunks(change) + if len(got) != 1 { + t.Fatalf("want 1 merged window, got %d: %v", len(got), got) } - if got[0].rows[3].op != opDel || got[0].rows[3].lhs != 5 { - t.Fatalf("bad del row: %+v", got[0].rows[3]) +} + +func TestGroupHunks_SplitFar(t *testing.T) { + // Changes 20 lines apart: windows disjoint -> two windows. + base := lines(40, map[int]string{4: "a", 25: "b"}) + head := lines(40, map[int]string{4: "A", 25: "B"}) + _, change := alignFile(base, head, []*gitmirrorv1.Hunk{ + hunk(&gitmirrorv1.LinePair{Lhs: u32(4), Rhs: u32(4)}), + hunk(&gitmirrorv1.LinePair{Lhs: u32(25), Rhs: u32(25)}), + }) + got := groupHunks(change) + if len(got) != 2 { + t.Fatalf("want 2 windows, got %d: %v", len(got), got) } } -func TestBuildHunks_MergeClose(t *testing.T) { - // Changes at index 3 and 8: gap of 4 unchanged lines <= 6, so merged into one hunk. - base := lines(12, map[int]string{3: "a", 8: "b"}) - head := lines(12, map[int]string{3: "A", 8: "B"}) - hunks := []*gitmirrorv1.Hunk{ - hunk(&gitmirrorv1.LinePair{Lhs: u32(3), Rhs: u32(3)}), - hunk(&gitmirrorv1.LinePair{Lhs: u32(8), Rhs: u32(8)}), +func TestAlignFile_PureInsertionAtTop(t *testing.T) { + base := lines(5, nil) + head := append([]string{"x", "y"}, base...) // 2 inserted lines, then base + hunks := []*gitmirrorv1.Hunk{hunk( + &gitmirrorv1.LinePair{Rhs: u32(0)}, + &gitmirrorv1.LinePair{Rhs: u32(1)}, + )} + pairs, change := alignFile(base, head, hunks) + + // First two pairs are the insertions (lhs absent), then 5 context pairs offset by 2. + if pairs[0] != (linePair{lhs: -1, rhs: 0}) || pairs[1] != (linePair{lhs: -1, rhs: 1}) { + t.Fatalf("insertion pairs = %+v %+v", pairs[0], pairs[1]) } - got := buildHunks(base, head, hunks) - if len(got) != 1 { - t.Fatalf("want 1 merged hunk, got %d", len(got)) + if !change[0] || !change[1] { + t.Fatalf("insertion pairs should be marked changed") } - ctx, del, add := opCounts(got[0]) - // 3 leading + 4 between + 3 trailing = 10 context; 2 del; 2 add. - if ctx != 10 || del != 2 || add != 2 { - t.Fatalf("want ctx=10 del=2 add=2, got ctx=%d del=%d add=%d", ctx, del, add) + if pairs[2] != (linePair{lhs: 0, rhs: 2}) { + t.Fatalf("first context pair = %+v, want {0,2}", pairs[2]) + } + if len(pairs) != 7 { + t.Fatalf("want 7 pairs, got %d", len(pairs)) } } -func TestBuildHunks_SplitFar(t *testing.T) { - // Changes at index 3 and 20: gap 16 > 6, so two separate hunks. - base := lines(25, map[int]string{3: "a", 20: "b"}) - head := lines(25, map[int]string{3: "A", 20: "B"}) - hunks := []*gitmirrorv1.Hunk{ - hunk(&gitmirrorv1.LinePair{Lhs: u32(3), Rhs: u32(3)}), - hunk(&gitmirrorv1.LinePair{Lhs: u32(20), Rhs: u32(20)}), +func TestAlignFile_PureDeletionKeepsColumnsPaired(t *testing.T) { + // Delete base line index 4 of a 10-line file -> head has 9 lines. The unchanged tail + // (base 5..9) must stay paired with head 4..8, not drift. + base := lines(10, nil) + head := append(append([]string{}, lines(4, nil)...), lines(10, nil)[5:]...) // line 4 removed + hunks := []*gitmirrorv1.Hunk{hunk(&gitmirrorv1.LinePair{Lhs: u32(4)})} + + pairs, change := alignFile(base, head, hunks) + + // The deletion pair. + del := -1 + for i, c := range change { + if c { + del = i + break + } } - got := buildHunks(base, head, hunks) - if len(got) != 2 { - t.Fatalf("want 2 hunks, got %d", len(got)) + if del < 0 || pairs[del] != (linePair{lhs: 4, rhs: -1}) { + t.Fatalf("deletion pair = %+v (idx %d)", pairs[del], del) + } + // Trailing context after the deletion: base 5..9 <-> head 4..8. + want := []linePair{{5, 4}, {6, 5}, {7, 6}, {8, 7}, {9, 8}} + got := pairs[del+1:] + if len(got) != len(want) { + t.Fatalf("trailing context len = %d, want %d: %+v", len(got), len(want), got) } - for i, h := range got { - ctx, del, add := opCounts(h) - if ctx != 6 || del != 1 || add != 1 { - t.Fatalf("hunk %d: want ctx=6 del=1 add=1, got ctx=%d del=%d add=%d", i, ctx, del, add) + for i := range want { + if got[i] != want[i] { + t.Fatalf("trailing pair %d = %+v, want %+v", i, got[i], want[i]) } } } -func TestBuildHunks_PureInsertionAtTop(t *testing.T) { - base := lines(5, nil) - // Two lines inserted at the very top; head = 2 new + 5 base. - head := append([]string{"x", "y"}, base...) +func TestRenderInline_Ordering(t *testing.T) { + base := lines(10, map[int]string{5: "old"}) + head := lines(10, map[int]string{5: "new"}) + h := buildHunks(base, head, []*gitmirrorv1.Hunk{hunk(&gitmirrorv1.LinePair{Lhs: u32(5), Rhs: u32(5)})})[0] + + var b strings.Builder + renderInline(&b, h, base, head) + out := b.String() + + // "old" (removed, LHS) must appear before "new" (added, RHS). + io, in := strings.Index(out, "old"), strings.Index(out, "new") + if io < 0 || in < 0 || io > in { + t.Fatalf("inline ordering wrong (old=%d new=%d):\n%s", io, in, out) + } + // Removed line carries '-', added line carries '+', context ' '. + for line := range strings.SplitSeq(strings.TrimRight(out, "\n"), "\n") { + switch { + case strings.Contains(line, "old") && !strings.Contains(line, "- old"): + t.Fatalf("removed line missing '-' marker: %q", line) + case strings.Contains(line, "new") && !strings.Contains(line, "+ new"): + t.Fatalf("added line missing '+' marker: %q", line) + } + } +} + +func TestRenderInline_MergedKeepsInteriorContextOnce(t *testing.T) { + // Two changes 5 lines apart merge into one hunk. The interior context line (index 7, + // "l7") must appear exactly once, with no +/- marker (not duplicated into both blocks). + base := lines(20, map[int]string{5: "old5", 10: "old10"}) + head := lines(20, map[int]string{5: "new5", 10: "new10"}) hunks := []*gitmirrorv1.Hunk{ - hunk( - &gitmirrorv1.LinePair{Rhs: u32(0)}, - &gitmirrorv1.LinePair{Rhs: u32(1)}, - ), + hunk(&gitmirrorv1.LinePair{Lhs: u32(5), Rhs: u32(5)}), + hunk(&gitmirrorv1.LinePair{Lhs: u32(10), Rhs: u32(10)}), } - got := buildHunks(base, head, hunks) - if len(got) != 1 { - t.Fatalf("want 1 hunk, got %d", len(got)) + built := buildHunks(base, head, hunks) + if len(built) != 1 { + t.Fatalf("want 1 merged hunk, got %d", len(built)) } - // No leading context (clamped to start); 2 adds; up to 3 trailing context. - if got[0].rows[0].op != opAdd || got[0].rows[1].op != opAdd { - t.Fatalf("want first two rows to be adds, got %+v", got[0].rows[:2]) + + var b strings.Builder + renderInline(&b, built[0], base, head) + out := b.String() + + if n := strings.Count(out, "l7"); n != 1 { + t.Fatalf("interior context 'l7' should appear once, got %d:\n%s", n, out) } - ctx, del, add := opCounts(got[0]) - if del != 0 || add != 2 || ctx != 3 { - t.Fatalf("want ctx=3 del=0 add=2, got ctx=%d del=%d add=%d", ctx, del, add) + for line := range strings.SplitSeq(strings.TrimRight(out, "\n"), "\n") { + if strings.Contains(line, "l7") && (strings.Contains(line, "- l7") || strings.Contains(line, "+ l7")) { + t.Fatalf("interior context should have no +/- marker: %q", line) + } } - // Trailing context pairs base line 0 with head line 2 (the insertion offset). - first := got[0].rows[2] - if first.op != opContext || first.lhs != 0 || first.rhs != 2 { - t.Fatalf("bad trailing context row: %+v", first) + // The actual changes still render. + for _, want := range []string{"- old5", "+ new5", "- old10", "+ new10"} { + if !strings.Contains(out, want) { + t.Fatalf("missing %q in:\n%s", want, out) + } } } diff --git a/appview/pulls/pull2.go b/appview/pulls/pull2.go index 78aec2fc..d8441e4a 100644 --- a/appview/pulls/pull2.go +++ b/appview/pulls/pull2.go @@ -282,136 +282,242 @@ func (s *Pulls) PullDiffFragment(w http.ResponseWriter, r *http.Request) { panic(err) } - // TODO: implement split view - _ = unified - + // c. render each file's diff, following difftastic's display layout + // (github.com/Wilfred/difftastic src/display): side-by-side by default, inline + // when ?view=unified. Word/token highlighting and line wrapping are omitted. + var b strings.Builder for _, f := range files { - params.Diff += f.diff.RhsSrc.Path + "\n" + b.WriteString(f.diff.GetRhsSrc().GetPath()) + b.WriteByte('\n') + + if isBinaryOrSubmodule(f.diff.GetLhsSrc()) || isBinaryOrSubmodule(f.diff.GetRhsSrc()) { + b.WriteString(" (binary or submodule)\n\n") + continue + } + for _, h := range buildHunks(f.baseLines, f.headLines, f.diff.Hunks) { - params.Diff += "@@@\n" - for _, row := range h.rows { - switch row.op { - case opContext: - params.Diff += fmt.Sprintf("%d\t%d\t %s\n", row.lhs+1, row.rhs+1, row.content) - case opDelete: - params.Diff += fmt.Sprintf("%d\t\t - %s\n", row.lhs+1, row.content) - case opAdd: - params.Diff += fmt.Sprintf("\t%d\t + %s\n", row.rhs+1, row.content) - } + b.WriteString("@@\n") + if unified { + renderInline(&b, h, f.baseLines, f.headLines) + } else { + renderSideBySide(&b, h, f.baseLines, f.headLines) } } + b.WriteByte('\n') } + params.Diff = b.String() } -type diffOp int +// numContextLines is difftastic's default (`num_context_lines`); pad_before/pad_after add +// one extra (n+1), which is what we display and use as the hunk-merge window. +const numContextLines = 3 -const ( - opContext diffOp = iota - opDelete - opAdd -) +// linePair is one aligned display line: 0-based line numbers into the base (lhs) and head +// (rhs) blobs; -1 means that side has no line here (a blank/padding cell). +type linePair struct { + lhs int + rhs int +} +// diffRow is one aligned display line plus whether it is novel (removed/added/modified) or +// unchanged context. -1 on a side means that side has no line here (a blank cell). type diffRow struct { - op diffOp lhs int rhs int - content string + changed bool } +// displayHunk is one rendered hunk: context + changed rows, in display order. type displayHunk struct { rows []diffRow } -const contextLines = 3 - -// buildHunks turns gitmirror's changed-lines-only hunks into display hunks that include -// up to contextLines of unchanged context around each change, merging hunks whose -// context windows overlap (gap <= 2*contextLines unchanged lines) into one display hunk. -// -// gitmirror hunks carry only changed lines (deletions + additions); context lines are -// shared content, so at any unchanged line baseLines[lhs] == headLines[rhs] and the two -// cursors advance together. We track li/ri (next unconsumed line per side) and derive a -// hunk's start on a side that has no lines from the other side via that invariant. +// buildHunks aligns the whole file (alignFile), groups changes into context windows +// (groupHunks), and slices each window into a displayHunk of rows. func buildHunks(baseLines, headLines []string, hunks []*gitmirrorv1.Hunk) []displayHunk { + pairs, change := alignFile(baseLines, headLines, hunks) var out []displayHunk - var cur *displayHunk + for _, rng := range groupHunks(change) { + var h displayHunk + for i := rng[0]; i < rng[1]; i++ { + h.rows = append(h.rows, diffRow{lhs: pairs[i].lhs, rhs: pairs[i].rhs, changed: change[i]}) + } + out = append(out, h) + } + return out +} + +// alignFile walks both blobs in lockstep and produces one linePair per displayed line of +// the whole file, plus a parallel `change` flag marking pairs that came from a gitmirror +// hunk's changed Lines (everything else is unchanged context). +// +// gitmirror hunks carry only changed lines; unchanged lines are a 1:1 bijection between +// the blobs, so the two cursors (li/ri) advance together across the gaps. A hunk side that +// has no lines (pure insertion/deletion) gets its start derived from the other side. +func alignFile(baseLines, headLines []string, hunks []*gitmirrorv1.Hunk) ([]linePair, []bool) { + var pairs []linePair + var change []bool li, ri := 0, 0 - // emitContext appends n unchanged rows starting at base line lx (paired head line rx). + // emit appends a run of unchanged context pairs [lx..lx+n) <-> [rx..rx+n) and advances + // both cursors past them (lx == li and rx == ri at every call site). emitContext := func(lx, rx, n int) { for j := range n { - cur.rows = append(cur.rows, diffRow{ - op: opContext, - lhs: lx + j, - rhs: rx + j, - content: baseLines[lx+j], - }) + pairs = append(pairs, linePair{lhs: lx + j, rhs: rx + j}) + change = append(change, false) } + li = lx + n + ri = rx + n } for _, h := range hunks { - var lhsNums, rhsNums []int + lhsStart, _, ok := hunkStart(h, li, ri) + if !ok { + continue + } + emitContext(li, ri, lhsStart-li) // gap before this change (== rhsStart-ri) + for _, lp := range h.Lines { + p := linePair{lhs: -1, rhs: -1} if lp.Lhs != nil { - lhsNums = append(lhsNums, int(*lp.Lhs)) + p.lhs = int(*lp.Lhs) + li = p.lhs + 1 } if lp.Rhs != nil { - rhsNums = append(rhsNums, int(*lp.Rhs)) + p.rhs = int(*lp.Rhs) + ri = p.rhs + 1 } + pairs = append(pairs, p) + change = append(change, true) } - if len(lhsNums) == 0 && len(rhsNums) == 0 { - continue + } + // Trailing unchanged tail. + emitContext(li, ri, len(baseLines)-li) + return pairs, change +} + +// hunkStart returns the first changed line number on each side, deriving the empty side +// from the cursors (unchanged lines advance both sides equally). ok is false for an +// empty hunk. +func hunkStart(h *gitmirrorv1.Hunk, li, ri int) (lhsStart, rhsStart int, ok bool) { + lhsStart, rhsStart = -1, -1 + for _, lp := range h.Lines { + if lp.Lhs != nil && lhsStart < 0 { + lhsStart = int(*lp.Lhs) + } + if lp.Rhs != nil && rhsStart < 0 { + rhsStart = int(*lp.Rhs) } + } + switch { + case lhsStart < 0 && rhsStart < 0: + return 0, 0, false + case lhsStart < 0: // pure insertion + lhsStart = li + (rhsStart - ri) + case rhsStart < 0: // pure deletion + rhsStart = ri + (lhsStart - li) + } + return lhsStart, rhsStart, true +} - // Start of the changed block on each side; derive the empty side from the other. - var lhsStart, rhsStart int - switch { - case len(lhsNums) > 0 && len(rhsNums) > 0: - lhsStart, rhsStart = lhsNums[0], rhsNums[0] - case len(lhsNums) == 0: // pure insertion - rhsStart = rhsNums[0] - lhsStart = li + (rhsStart - ri) - default: // pure deletion - lhsStart = lhsNums[0] - rhsStart = ri + (lhsStart - li) +// groupHunks expands every changed line into a context window of numContextLines+1 on each +// side and unions overlapping/adjacent windows, yielding [start,end) ranges into the +// aligned pairs. This is difftastic's merge_adjacent expressed over the pre-aligned list. +func groupHunks(change []bool) [][2]int { + const ctx = numContextLines + 1 + var out [][2]int + for i, c := range change { + if !c { + continue } + start := max(0, i-ctx) + end := min(len(change), i+ctx+1) + if n := len(out); n > 0 && start <= out[n-1][1] { + out[n-1][1] = max(out[n-1][1], end) // merge into previous window + } else { + out = append(out, [2]int{start, end}) + } + } + return out +} - gap := lhsStart - li // unchanged lines before this change (== rhsStart - ri) - - switch { - case cur == nil: - // First change (or just after a split): leading context only. - cur = &displayHunk{} - lead := min(gap, contextLines) - emitContext(lhsStart-lead, rhsStart-lead, lead) - case gap <= 2*contextLines: - // Close enough to merge: keep all the intervening lines as context. - emitContext(li, ri, gap) - default: - // Too far apart: close this hunk with trailing context, open a new one. - emitContext(li, ri, contextLines) - out = append(out, *cur) - cur = &displayHunk{} - emitContext(lhsStart-contextLines, rhsStart-contextLines, contextLines) +// renderSideBySide lays out a hunk's rows in two columns: +// +// [lhsNum][- ]lhsContent [rhsNum][+ ]rhsContent +// +// A changed side gets a `-` (left) or `+` (right) marker; context gets a space. An absent +// side (-1) renders as a blank gutter, blank marker, and empty content. +func renderSideBySide(b *strings.Builder, h displayHunk, baseLines, headLines []string) { + const spacer = " " + contentWidth := 0 + for _, r := range h.rows { + if r.lhs >= 0 && len(baseLines[r.lhs]) > contentWidth { + contentWidth = len(baseLines[r.lhs]) + } + } + for _, r := range h.rows { + lhs, rhs := "", "" + if r.lhs >= 0 { + lhs = baseLines[r.lhs] + } + if r.rhs >= 0 { + rhs = headLines[r.rhs] } + lmark := mark(r.changed && r.lhs >= 0, '-') + rmark := mark(r.changed && r.rhs >= 0, '+') + fmt.Fprintf(b, "%s%s%s%-*s%s|%s%s\n", + gutter(r.lhs), gutter(r.rhs), lmark, contentWidth, lhs, spacer, rmark, rhs) + } +} - // Changed lines: deletions then additions (matches the unified render order). - for _, ln := range lhsNums { - cur.rows = append(cur.rows, diffRow{op: opDelete, lhs: ln, content: baseLines[ln]}) +// renderInline lays out a hunk as a unified diff: context lines shown once (no marker), +// and each contiguous run of changed rows printed as all removed (LHS, `-`) then all added +// (RHS, `+`). Interior context between merged changes stays a single unmarked line. +func renderInline(b *strings.Builder, h displayHunk, baseLines, headLines []string) { + for i := 0; i < len(h.rows); { + r := h.rows[i] + if !r.changed { + if r.lhs >= 0 { + fmt.Fprintf(b, "%s%s%s\n", gutter(r.lhs), mark(false, ' '), baseLines[r.lhs]) + } + i++ + continue + } + // Contiguous run of changed rows: all removed, then all added. + j := i + for j < len(h.rows) && h.rows[j].changed { + j++ + } + for _, cr := range h.rows[i:j] { + if cr.lhs >= 0 { + fmt.Fprintf(b, "%s%s%s\n", gutter(cr.lhs), mark(true, '-'), baseLines[cr.lhs]) + } } - for _, ln := range rhsNums { - cur.rows = append(cur.rows, diffRow{op: opAdd, rhs: ln, content: headLines[ln]}) + for _, cr := range h.rows[i:j] { + if cr.rhs >= 0 { + fmt.Fprintf(b, "%s%s%s\n", gutter(cr.rhs), mark(true, '+'), headLines[cr.rhs]) + } } + i = j + } +} - li = lhsStart + len(lhsNums) - ri = rhsStart + len(rhsNums) +// mark returns the 2-char marker cell placed between the line-number gutter and content: +// " " when set, " " otherwise. +func mark(set bool, sign byte) string { + if set { + return string(sign) + " " } + return " " +} - if cur != nil { - emitContext(li, ri, min(len(baseLines)-li, contextLines)) - out = append(out, *cur) +// gutter renders a 1-based line number right-aligned to width w plus a trailing space, or +// blanks when the line is absent (-1). +func gutter(n int) string { + if n < 0 { + return strings.Repeat(" ", 5) } - return out + return fmt.Sprintf("%*d ", 4, n+1) } // getBlob streams a blob's bytes from gitmirror by OID and concatenates them. -- 2.51.2