1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package gitcli_test
4
5import (
6 "context"
7 "fmt"
8 "strconv"
9 "strings"
10 "testing"
11
12 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/require"
14
15 "bnema.dev/ermite/internal/domain"
16 "bnema.dev/ermite/internal/readertest"
17)
18
19// unified renders hunks the way git prints them after the "+++" line.
20func unified(hunks []domain.Hunk) string {
21 var b strings.Builder
22 rng := func(start, n int) string {
23 if n == 1 {
24 return strconv.Itoa(start)
25 }
26 return fmt.Sprintf("%d,%d", start, n)
27 }
28 for _, h := range hunks {
29 fmt.Fprintf(&b, "@@ -%s +%s @@", rng(h.OldStart, h.OldLines), rng(h.NewStart, h.NewLines))
30 if h.Header != "" {
31 b.WriteString(" " + h.Header)
32 }
33 b.WriteByte('\n')
34 for _, l := range h.Lines {
35 b.WriteString(map[domain.LineKind]string{domain.LineContext: " ", domain.LineAddition: "+", domain.LineDeletion: "-"}[l.Kind])
36 b.WriteString(l.Text + "\n")
37 if l.NoNewlineAtEOF {
38 b.WriteString("\\ No newline at end of file\n")
39 }
40 }
41 }
42 return b.String()
43}
44
45// gitSections splits the output of `git show -p` into the text each
46// "diff --git" section has from its first hunk on ("" for a section without
47// hunk: binary, pure rename, mode change).
48func gitSections(out string) []string {
49 var sections []string
50 for _, s := range strings.Split("\n"+out, "\ndiff --git ")[1:] {
51 _, hunks, ok := strings.Cut(s, "\n@@ ")
52 if !ok {
53 sections = append(sections, "")
54 continue
55 }
56 sections = append(sections, "@@ "+strings.TrimSuffix(hunks, "\n")+"\n")
57 }
58 return sections
59}
60
61// showSections is git's own patch text of a commit against its first parent.
62func showSections(f *readertest.Fixture, id string) []string {
63 f.T.Helper()
64 out := f.Git("show", "--format=", "--no-color", "-M", "-m", "--first-parent", "-U3", id)
65 return gitSections(strings.ToValidUTF8(out, "\uFFFD"))
66}
67
68// showNumstat is git's own numstat of a commit against its first parent.
69func showNumstat(f *readertest.Fixture, id string) []string {
70 f.T.Helper()
71 out := f.Git("show", "--format=", "-M", "-m", "--first-parent", "--numstat", id)
72 if out == "" {
73 return nil
74 }
75 return strings.Split(out, "\n")
76}
77
78// assertMatchesGitShow checks the structured diff of a commit against the
79// text of `git show`: the patch of every file, its counts, and the totals.
80func assertMatchesGitShow(t *testing.T, f *readertest.Fixture, id string, d domain.CommitDetail) {
81 t.Helper()
82 sections := showSections(f, id)
83 var adds, dels int
84 numstat := showNumstat(f, id)
85 require.Len(t, numstat, len(d.Diff.Files), "one numstat line per file")
86 si := 0
87 for i, file := range d.Diff.Files {
88 label := file.OldPath + ">" + file.NewPath
89 fields := strings.SplitN(numstat[i], "\t", 3)
90 if fields[0] == "-" {
91 assert.True(t, file.Binary, label)
92 assert.Zero(t, file.Additions+file.Deletions, label)
93 } else {
94 assert.False(t, file.Binary, label)
95 assert.Equal(t, fields[0], strconv.Itoa(file.Additions), label)
96 assert.Equal(t, fields[1], strconv.Itoa(file.Deletions), label)
97 a, _ := strconv.Atoi(fields[0])
98 del, _ := strconv.Atoi(fields[1])
99 adds, dels = adds+a, dels+del
100 }
101 // A type change is two sections: the deletion, then the addition.
102 n := 1
103 if file.Status == domain.FileTypeChanged {
104 n = 2
105 }
106 require.LessOrEqual(t, si+n, len(sections), label)
107 assert.Equal(t, strings.Join(sections[si:si+n], ""), unified(file.Hunks), "patch of %s", label)
108 si += n
109 }
110 assert.Equal(t, len(sections), si, "every git section is accounted for")
111 assert.Equal(t, domain.DiffStats{FilesChanged: len(numstat), Additions: adds, Deletions: dels}, d.Stats)
112}
113
114func TestGetCommitMatchesGitShow(t *testing.T) {
115 ctx := context.Background()
116 g, data := newReader(t, nil)
117 h := readertest.NewHistory(t, data)
118
119 for _, tt := range []struct{ name, id string }{
120 {"root commit: adds, binary, empty file", h.C1},
121 {"rename with change, binary, mode change, add, delete", h.C2},
122 {"type change", h.C3},
123 {"merge, against the first parent", h.Merge},
124 {"side branch", h.Side},
125 } {
126 t.Run(tt.name, func(t *testing.T) {
127 d, err := g.GetCommit(ctx, h.Name, domain.RevisionOf(readertest.MustID(t, tt.id)), domain.DefaultDiffLimits())
128 require.NoError(t, err)
129 assert.Equal(t, tt.id, d.Commit.ID.String())
130 assert.False(t, d.Diff.Truncated)
131 assertMatchesGitShow(t, h.Fixture, tt.id, d)
132 })
133 }
134}
135
136func TestGetCommitFileMetadata(t *testing.T) {
137 ctx := context.Background()
138 g, data := newReader(t, nil)
139 h := readertest.NewHistory(t, data)
140
141 byPath := func(d domain.Diff) map[string]domain.FileDiff {
142 m := map[string]domain.FileDiff{}
143 for _, f := range d.Files {
144 m[f.OldPath+">"+f.NewPath] = f
145 }
146 return m
147 }
148 id := func(spec string) domain.ObjectID { return readertest.MustID(t, h.Git("rev-parse", spec)) }
149
150 d, err := g.GetCommit(ctx, h.Name, readertest.Rev(t, "v2"), domain.DefaultDiffLimits())
151 require.NoError(t, err)
152 assert.Equal(t, h.C2, d.Commit.ID.String())
153 files := byPath(d.Diff)
154 require.Len(t, files, 5)
155
156 ren := files["a.txt>b.txt"]
157 assert.Equal(t, domain.FileRenamed, ren.Status)
158 assert.Equal(t, 73, ren.Similarity)
159 assert.Equal(t, 1, ren.Additions)
160 assert.Equal(t, 0, ren.Deletions)
161 assert.Equal(t, uint32(0o100644), ren.OldMode)
162 assert.Equal(t, uint32(0o100644), ren.NewMode)
163 assert.Equal(t, id(h.C1+":a.txt"), ren.OldID)
164 assert.Equal(t, id(h.C2+":b.txt"), ren.NewID)
165 require.Len(t, ren.Hunks, 1)
166 assert.Equal(t, domain.Hunk{
167 OldStart: 1, OldLines: 3, NewStart: 1, NewLines: 4,
168 Lines: []domain.Line{
169 {Kind: domain.LineContext, OldNumber: 1, NewNumber: 1, Text: "one"},
170 {Kind: domain.LineContext, OldNumber: 2, NewNumber: 2, Text: "two"},
171 {Kind: domain.LineContext, OldNumber: 3, NewNumber: 3, Text: "three"},
172 {Kind: domain.LineAddition, NewNumber: 4, Text: "four"},
173 },
174 }, ren.Hunks[0])
175
176 bin := files["bin>bin"]
177 assert.Equal(t, domain.FileModified, bin.Status)
178 assert.True(t, bin.Binary)
179 assert.Empty(t, bin.Hunks)
180 assert.False(t, bin.Truncated)
181
182 mode := files["d/f>d/f"]
183 assert.Equal(t, domain.FileModified, mode.Status)
184 assert.Equal(t, uint32(0o100644), mode.OldMode)
185 assert.Equal(t, uint32(0o100755), mode.NewMode)
186 assert.Empty(t, mode.Hunks)
187 assert.False(t, mode.Binary)
188 assert.False(t, mode.Truncated)
189
190 del := files["README.md>"]
191 assert.Equal(t, domain.FileDeleted, del.Status)
192 assert.Equal(t, uint32(0), del.NewMode)
193 assert.True(t, del.NewID.IsZero())
194 assert.Equal(t, 1, del.Deletions)
195 require.Len(t, del.Hunks, 1)
196 assert.Equal(t, domain.LineDeletion, del.Hunks[0].Lines[0].Kind)
197 assert.Equal(t, 0, del.Hunks[0].NewStart, "a deleted file has no new side")
198
199 add := files[">n.txt"]
200 assert.Equal(t, domain.FileAdded, add.Status)
201 assert.Equal(t, uint32(0), add.OldMode)
202 assert.True(t, add.OldID.IsZero())
203 require.Len(t, add.Hunks, 1)
204 last := add.Hunks[0].Lines[1]
205 assert.Equal(t, domain.Line{Kind: domain.LineAddition, NewNumber: 2, Text: "nonl", NoNewlineAtEOF: true}, last)
206
207 d, err = g.GetCommit(ctx, h.Name, readertest.Rev(t, h.C3), domain.DefaultDiffLimits())
208 require.NoError(t, err)
209 require.Len(t, d.Diff.Files, 1)
210 tc := d.Diff.Files[0]
211 assert.Equal(t, domain.FileTypeChanged, tc.Status)
212 assert.Equal(t, uint32(0o120000), tc.NewMode)
213 assert.Equal(t, 1, tc.Additions)
214 assert.Equal(t, 2, tc.Deletions)
215}
216
217func TestGetCommitMergeDiffIsAgainstFirstParent(t *testing.T) {
218 ctx := context.Background()
219 g, data := newReader(t, nil)
220 h := readertest.NewHistory(t, data)
221
222 d, err := g.GetCommit(ctx, h.Name, readertest.Rev(t, "main"), domain.DefaultDiffLimits())
223 require.NoError(t, err)
224 require.Len(t, d.Commit.Parents, 2)
225 require.Len(t, d.Diff.Files, 1)
226 assert.Equal(t, "side.txt", d.Diff.Files[0].NewPath)
227 assert.Equal(t, domain.FileAdded, d.Diff.Files[0].Status)
228}
229
230func TestGetCommitEmptyCommit(t *testing.T) {
231 ctx := context.Background()
232 g, data := newReader(t, nil)
233 f := readertest.NewFixture(t, data, "empty-commit")
234 id := f.Commit("nothing")
235
236 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DefaultDiffLimits())
237 require.NoError(t, err)
238 assert.Equal(t, id, d.Commit.ID.String())
239 assert.Empty(t, d.Diff.Files)
240 assert.False(t, d.Diff.Truncated)
241 assert.Equal(t, domain.DiffStats{}, d.Stats)
242}
243
244func TestGetCommitErrors(t *testing.T) {
245 ctx := context.Background()
246 g, data := newReader(t, nil)
247 h := readertest.NewHistory(t, data)
248
249 _, err := g.GetCommit(ctx, h.Name, readertest.Rev(t, "nope"), domain.DefaultDiffLimits())
250 assert.ErrorIs(t, err, domain.ErrNotFound)
251 _, err = g.GetCommit(ctx, name(t, "nope"), domain.Revision{}, domain.DefaultDiffLimits())
252 assert.ErrorIs(t, err, domain.ErrNotFound)
253}
254
255func TestGetCommitHunkHeaderAndNonUTF8(t *testing.T) {
256 ctx := context.Background()
257 g, data := newReader(t, nil)
258 f := readertest.NewFixture(t, data, "hunks")
259 var src strings.Builder
260 src.WriteString("package x\n\nfunc Alpha() {\n")
261 for i := range 40 {
262 fmt.Fprintf(&src, "\tline(%d)\n", i)
263 }
264 src.WriteString("}\n")
265 f.Write("x.go", src.String()).Write("latin", "caf\xe9\nok\n")
266 f.Commit("v1")
267 f.Write("x.go", strings.Replace(src.String(), "\tline(30)", "\tchanged(30)", 1)).Write("latin", "caf\xe9\nOK\n")
268 id := f.Commit("v2")
269
270 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DefaultDiffLimits())
271 require.NoError(t, err)
272 assertMatchesGitShow(t, f, id, d)
273 var gofile, latin domain.FileDiff
274 for _, file := range d.Diff.Files {
275 switch file.NewPath {
276 case "x.go":
277 gofile = file
278 case "latin":
279 latin = file
280 }
281 }
282 require.Len(t, gofile.Hunks, 1)
283 assert.Equal(t, "func Alpha() {", gofile.Hunks[0].Header, "git's default function name rule")
284 assert.Equal(t, 31, gofile.Hunks[0].OldStart)
285 require.Len(t, latin.Hunks, 1)
286 assert.Equal(t, "caf\uFFFD", latin.Hunks[0].Lines[0].Text, "invalid UTF-8 is replaced")
287}
288
289// manyLines commits files and returns the id.
290func commitFiles(f *readertest.Fixture, msg string, files map[string]int) string {
291 for p, n := range files {
292 f.Lines(p, strings.ReplaceAll(p, "/", "_"), n)
293 }
294 return f.Commit(msg)
295}
296
297func TestDiffLimits(t *testing.T) {
298 ctx := context.Background()
299 g, data := newReader(t, nil)
300 defaults := domain.DefaultDiffLimits()
301 find := func(d domain.Diff, p string) domain.FileDiff {
302 for _, f := range d.Files {
303 if f.NewPath == p {
304 return f
305 }
306 }
307 t.Fatalf("file %s not in the diff", p)
308 return domain.FileDiff{}
309 }
310
311 t.Run("a file at the line limit is kept, one line more is truncated", func(t *testing.T) {
312 f := readertest.NewFixture(t, data, "limit-file")
313 commitFiles(f, "files", map[string]int{"exact": 2000, "over": 2001, "small": 3})
314 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)
315 require.NoError(t, err)
316 exact, over, small := find(d.Diff, "exact"), find(d.Diff, "over"), find(d.Diff, "small")
317
318 assert.False(t, exact.Truncated)
319 require.Len(t, exact.Hunks, 1)
320 assert.Len(t, exact.Hunks[0].Lines, 2000)
321 assert.True(t, over.Truncated)
322 assert.Empty(t, over.Hunks)
323 assert.Equal(t, 2001, over.Additions, "counts stay accurate")
324 assert.False(t, small.Truncated)
325 assert.Len(t, small.Hunks, 1)
326 assert.True(t, d.Diff.Truncated)
327 assert.Equal(t, domain.DiffStats{FilesChanged: 3, Additions: 4004}, d.Stats)
328 })
329
330 t.Run("context counts towards the file limit", func(t *testing.T) {
331 f := readertest.NewFixture(t, data, "limit-context")
332 f.Lines("big", "l", 3000).Commit("v1")
333 // Every 7th line changes: ~428 changed lines, but the hunks merge
334 // into one that spans the file with its context.
335 var b strings.Builder
336 for i := range 3000 {
337 if i%7 == 0 {
338 fmt.Fprintf(&b, "changed %d\n", i)
339 } else {
340 fmt.Fprintf(&b, "l %d\n", i)
341 }
342 }
343 f.Write("big", b.String())
344 f.Write("tiny", "x\n")
345 id := f.Commit("v2")
346 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)
347 require.NoError(t, err)
348 big, tiny := find(d.Diff, "big"), find(d.Diff, "tiny")
349 assert.True(t, big.Truncated)
350 assert.Empty(t, big.Hunks)
351 assert.Equal(t, 429, big.Additions)
352 assert.Equal(t, 429, big.Deletions)
353 assert.False(t, tiny.Truncated, "the file after a truncated one is still read")
354 assert.Equal(t, "x\n", strings.TrimPrefix(unified(tiny.Hunks), "@@ -0,0 +1 @@\n+"))
355 assert.Equal(t, id, d.Commit.ID.String())
356 })
357
358 t.Run("the total limit truncates the file that does not fit", func(t *testing.T) {
359 f := readertest.NewFixture(t, data, "limit-total")
360 files := map[string]int{}
361 for i := range 11 {
362 files[fmt.Sprintf("f%02d", i)] = 1900 // 11 x 1900 = 20,900 > 20,000
363 }
364 files["g-small"] = 50 // sorts after f10: still fits in the 1,000 lines left
365 files["h-big"] = 1500 // does not fit
366 files["i-small"] = 10 // fits
367 commitFiles(f, "total", files)
368 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)
369 require.NoError(t, err)
370 require.Len(t, d.Diff.Files, 14)
371 total := 0
372 for _, file := range d.Diff.Files {
373 for _, h := range file.Hunks {
374 total += len(h.Lines)
375 }
376 }
377 assert.LessOrEqual(t, total, 20000)
378 for i := range 10 {
379 assert.False(t, find(d.Diff, fmt.Sprintf("f%02d", i)).Truncated)
380 }
381 assert.True(t, find(d.Diff, "f10").Truncated, "1,900 lines do not fit in the 1,000 left")
382 assert.False(t, find(d.Diff, "g-small").Truncated)
383 assert.True(t, find(d.Diff, "h-big").Truncated)
384 assert.False(t, find(d.Diff, "i-small").Truncated)
385 assert.True(t, d.Diff.Truncated)
386 assert.Equal(t, 11*1900+50+1500+10, d.Stats.Additions)
387 assert.Equal(t, 14, d.Stats.FilesChanged)
388 })
389
390 t.Run("files past the file limit are omitted but counted", func(t *testing.T) {
391 f := readertest.NewFixture(t, data, "limit-files")
392 for i := range 305 {
393 f.Write(fmt.Sprintf("many/f%03d", i), "x\n")
394 }
395 f.Commit("many")
396 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)
397 require.NoError(t, err)
398 assert.Len(t, d.Diff.Files, 300)
399 assert.True(t, d.Diff.Truncated)
400 assert.Equal(t, domain.DiffStats{FilesChanged: 305, Additions: 305}, d.Stats)
401 assert.Equal(t, "many/f000", d.Diff.Files[0].NewPath)
402 assert.Equal(t, "many/f299", d.Diff.Files[299].NewPath)
403 for _, file := range d.Diff.Files {
404 assert.False(t, file.Truncated)
405 assert.Len(t, file.Hunks, 1)
406 }
407 })
408
409 t.Run("exactly 300 files is not truncated", func(t *testing.T) {
410 f := readertest.NewFixture(t, data, "limit-300")
411 for i := range 300 {
412 f.Write(fmt.Sprintf("f%03d", i), "x\n")
413 }
414 f.Commit("300")
415 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)
416 require.NoError(t, err)
417 assert.Len(t, d.Diff.Files, 300)
418 assert.False(t, d.Diff.Truncated)
419 })
420
421 t.Run("custom limits and the byte limit", func(t *testing.T) {
422 f := readertest.NewFixture(t, data, "limit-custom")
423 commitFiles(f, "c", map[string]int{"a": 10, "b": 10, "c": 10})
424 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxFiles: 2, MaxFileLines: 100, MaxTotalLines: 15, MaxTotalBytes: 1 << 20})
425 require.NoError(t, err)
426 require.Len(t, d.Diff.Files, 2)
427 assert.False(t, d.Diff.Files[0].Truncated)
428 assert.True(t, d.Diff.Files[1].Truncated, "10 lines do not fit in the 5 left")
429 assert.Equal(t, 3, d.Stats.FilesChanged)
430
431 d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxFiles: 10, MaxFileLines: 100, MaxTotalLines: 100, MaxTotalBytes: 50})
432 require.NoError(t, err)
433 require.Len(t, d.Diff.Files, 3)
434 assert.False(t, d.Diff.Files[0].Truncated, "30 bytes of text fit in 50")
435 for _, file := range d.Diff.Files[1:] {
436 assert.True(t, file.Truncated, "the byte budget has 20 bytes left for 30")
437 assert.Empty(t, file.Hunks)
438 }
439 })
440
441 t.Run("a very long line truncates its file instead of being cut", func(t *testing.T) {
442 f := readertest.NewFixture(t, data, "limit-line")
443 f.Write("long", strings.Repeat("x", 200<<10)+"\n").Write("short", "ok\n").Commit("long")
444 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)
445 require.NoError(t, err)
446 long, short := find(d.Diff, "long"), find(d.Diff, "short")
447 assert.True(t, long.Truncated)
448 assert.Empty(t, long.Hunks)
449 assert.False(t, short.Truncated)
450 assert.Len(t, short.Hunks, 1)
451 })
452
453 t.Run("binary files and mode changes in a truncated commit", func(t *testing.T) {
454 f := readertest.NewFixture(t, data, "limit-mixed")
455 f.Lines("a-big", "x", 2500).WriteBytes("b-bin", []byte{0, 1, 2}).Write("c-mode", "m\n").Write("d-text", "t\n")
456 f.Commit("v1")
457 f.Lines("a-big", "y", 2500).WriteBytes("b-bin", []byte{0, 3}).Chmod("c-mode", 0o755).Write("d-text", "t2\n")
458 id := f.Commit("v2")
459 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)
460 require.NoError(t, err)
461 assert.True(t, find(d.Diff, "a-big").Truncated)
462 assert.True(t, find(d.Diff, "b-bin").Binary)
463 assert.False(t, find(d.Diff, "b-bin").Truncated)
464 assert.False(t, find(d.Diff, "c-mode").Truncated)
465 assert.Len(t, find(d.Diff, "d-text").Hunks, 1)
466 // What is kept matches git.
467 sections := showSections(f, id)
468 require.Len(t, sections, 4)
469 assert.Equal(t, sections[3], unified(find(d.Diff, "d-text").Hunks))
470 })
471}
472
473func TestGetCommitStopsGitEarly(t *testing.T) {
474 ctx := context.Background()
475 g, data := newReader(t, nil)
476 f := readertest.NewFixture(t, data, "early")
477 f.Lines("a", "x", 5).Lines("z-huge", "y", 100000).Commit("c")
478
479 // z-huge is truncated by the numstat alone, so nothing of it is read.
480 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DefaultDiffLimits())
481 require.NoError(t, err)
482 require.Len(t, d.Diff.Files, 2)
483 assert.False(t, d.Diff.Files[0].Truncated)
484 assert.True(t, d.Diff.Files[1].Truncated)
485 assert.Equal(t, 100005, d.Stats.Additions)
486}
487
488// windowFixture commits a diff that mixes what the patch reader must step over
489// in a window: text, binary, a rename, a type change (two sections), a mode
490// change (no hunks) and more text. It returns the id of the second commit.
491func windowFixture(t *testing.T, data, name string) (*readertest.Fixture, string) {
492 t.Helper()
493 f := readertest.NewFixture(t, data, name)
494 f.Lines("a-text", "a", 20).WriteBytes("b-bin", []byte{0, 1, 2}).Lines("c-old", "c", 30).
495 Lines("d-type", "d", 5).Lines("e-mode", "e", 3).Lines("f-text", "f", 20).Lines("g-text", "g", 20)
496 f.Commit("v1")
497 f.Lines("a-text", "A", 21).WriteBytes("b-bin", []byte{0, 3}).Mv("c-old", "c-new").
498 Remove("d-type").Symlink("a-text", "d-type").Chmod("e-mode", 0o755).Lines("f-text", "F", 20).Lines("g-text", "G", 2)
499 f.Write("c-new", readLines("c", 30)+"extra\n") // a rename with one line added
500 id := f.Commit("v2")
501 return f, id
502}
503
504func readLines(prefix string, n int) string {
505 var b strings.Builder
506 for i := range n {
507 fmt.Fprintf(&b, "%s %d\n", prefix, i)
508 }
509 return b.String()
510}
511
512func TestDiffLimitsWindow(t *testing.T) {
513 ctx := context.Background()
514 g, data := newReader(t, nil)
515
516 t.Run("a window reads the hunks of its files only, whatever the files around it", func(t *testing.T) {
517 f, id := windowFixture(t, data, "win-mixed")
518 full, err := g.GetCommit(ctx, f.Name, readertest.Rev(t, id), domain.DefaultDiffLimits())
519 require.NoError(t, err)
520 assertMatchesGitShow(t, f, id, full)
521 n := len(full.Diff.Files)
522 require.GreaterOrEqual(t, n, 7)
523 status := map[domain.FileStatus]bool{}
524 for _, file := range full.Diff.Files {
525 status[file.Status] = true
526 }
527 assert.True(t, status[domain.FileTypeChanged], "the fixture has a type change")
528 assert.True(t, status[domain.FileRenamed], "the fixture has a rename")
529 assert.False(t, full.Diff.Truncated)
530
531 tests := []struct {
532 name string
533 from, to int
534 }{
535 {"first file", 0, 1},
536 {"middle", 2, 5},
537 {"type change alone", 3, 4},
538 {"binary alone", 1, 2},
539 {"mode change alone", 4, 5},
540 {"rename alone", 2, 3},
541 {"through the end, to 0", 3, 0},
542 {"to past the end", 5, 100},
543 {"last file", n - 1, n},
544 {"from past the end", n, n + 5},
545 {"far past the end", 1000, 0},
546 {"empty window", 3, 3},
547 {"whole diff", 0, 0},
548 }
549 for _, tc := range tests {
550 t.Run(tc.name, func(t *testing.T) {
551 limits := domain.DefaultDiffLimits()
552 limits.From, limits.To = tc.from, tc.to
553 d, err := g.GetCommit(ctx, f.Name, readertest.Rev(t, id), limits)
554 require.NoError(t, err)
555 require.Len(t, d.Diff.Files, n)
556 assert.Equal(t, full.Stats, d.Stats)
557 assert.False(t, d.Diff.Truncated, "files outside the window do not truncate the diff")
558 to := tc.to
559 if to == 0 {
560 to = n
561 }
562 for i, got := range d.Diff.Files {
563 want := full.Diff.Files[i]
564 if i < tc.from || i >= to {
565 want.Hunks = nil
566 }
567 assert.Equal(t, want, got, "file %d (%s)", i, want.NewPath)
568 }
569 })
570 }
571 })
572
573 t.Run("files outside the window use none of the total budget", func(t *testing.T) {
574 f := readertest.NewFixture(t, data, "win-budget")
575 commitFiles(f, "five", map[string]int{"a": 10, "b": 10, "c": 10, "d": 10, "e": 10})
576 limits := domain.DiffLimits{MaxFileLines: 100, MaxTotalLines: 15, MaxTotalBytes: 1 << 20, From: 3, To: 5}
577 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, limits)
578 require.NoError(t, err)
579 require.Len(t, d.Diff.Files, 5)
580 for i, file := range d.Diff.Files {
581 switch i {
582 case 3:
583 assert.False(t, file.Truncated)
584 assert.Len(t, file.Hunks, 1, "10 lines fit in 15")
585 case 4:
586 assert.True(t, file.Truncated, "the second file of the window does not fit in the 5 lines left")
587 default:
588 assert.False(t, file.Truncated)
589 assert.Empty(t, file.Hunks)
590 assert.Equal(t, 10, file.Additions, "counts stay accurate")
591 }
592 }
593 assert.True(t, d.Diff.Truncated, "a file of the window is truncated")
594
595 limits.From, limits.To = 0, 0
596 limits.MaxTotalBytes = 25 // "a 0\n".."a 9\n" is 30 bytes of text per file
597 d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits)
598 require.NoError(t, err)
599 assert.True(t, d.Diff.Files[0].Truncated)
600 })
601
602 t.Run("a file beyond the window is not truncated by its own size", func(t *testing.T) {
603 f := readertest.NewFixture(t, data, "win-huge")
604 f.Lines("a", "x", 5).Lines("z-huge", "y", 5000).Commit("c")
605 limits := domain.DefaultDiffLimits()
606 limits.From, limits.To = 0, 1
607 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, limits)
608 require.NoError(t, err)
609 require.Len(t, d.Diff.Files, 2)
610 assert.Len(t, d.Diff.Files[0].Hunks, 1)
611 assert.False(t, d.Diff.Files[1].Truncated)
612 assert.Empty(t, d.Diff.Files[1].Hunks)
613 assert.False(t, d.Diff.Truncated)
614 limits.From, limits.To = 1, 2
615 d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits)
616 require.NoError(t, err)
617 assert.True(t, d.Diff.Files[1].Truncated, "in the window, over the per-file limit")
618 assert.True(t, d.Diff.Truncated)
619 })
620
621 t.Run("MaxFiles 0 lists every file", func(t *testing.T) {
622 f := readertest.NewFixture(t, data, "win-many")
623 const total = 350
624 for i := range total {
625 f.Write(fmt.Sprintf("many/f%03d", i), fmt.Sprintf("x%d\n", i))
626 }
627 f.Commit("many")
628 limits := domain.DefaultDiffLimits()
629 limits.MaxFiles = 0
630 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, limits)
631 require.NoError(t, err)
632 require.Len(t, d.Diff.Files, total)
633 assert.False(t, d.Diff.Truncated)
634 assert.Equal(t, domain.DiffStats{FilesChanged: total, Additions: total}, d.Stats)
635 for i, file := range d.Diff.Files {
636 assert.Equal(t, fmt.Sprintf("many/f%03d", i), file.NewPath)
637 require.Len(t, file.Hunks, 1, file.NewPath)
638 }
639
640 // A window past the 300th file reads its own hunks.
641 limits.From, limits.To = 320, 330
642 d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits)
643 require.NoError(t, err)
644 require.Len(t, d.Diff.Files, total)
645 assert.False(t, d.Diff.Truncated)
646 for i, file := range d.Diff.Files {
647 if i >= 320 && i < 330 {
648 require.Len(t, file.Hunks, 1)
649 assert.Equal(t, fmt.Sprintf("x%d", i), file.Hunks[0].Lines[0].Text)
650 } else {
651 assert.Empty(t, file.Hunks)
652 }
653 assert.False(t, file.Truncated)
654 }
655
656 // The default limits still cut at 300.
657 d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DefaultDiffLimits())
658 require.NoError(t, err)
659 assert.Len(t, d.Diff.Files, 300)
660 assert.True(t, d.Diff.Truncated)
661 })
662}