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.Equal(t, domain.OmitNone, bin.Omitted)
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.Equal(t, domain.OmitNone, mode.Omitted)
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.Equal(t, domain.OmitNone, exact.Omitted)
319 require.Len(t, exact.Hunks, 1)
320 assert.Len(t, exact.Hunks[0].Lines, 2000)
321 assert.NotEqual(t, domain.OmitNone, over.Omitted)
322 assert.Empty(t, over.Hunks)
323 assert.Equal(t, 2001, over.Additions, "counts stay accurate")
324 assert.Equal(t, domain.OmitNone, small.Omitted)
325 assert.Len(t, small.Hunks, 1)
326 assert.False(t, d.Diff.Truncated)
327 assert.Equal(t, domain.DiffStats{FilesChanged: 3, Additions: 4004}, d.Stats)
328 })
329
330 t.Run("context does not count 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: 429 additions plus 429 deletions, 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.Equal(t, domain.OmitNone, big.Omitted)
350 require.Len(t, big.Hunks, 1)
351 assert.Len(t, big.Hunks[0].Lines, 3000+429)
352 assert.False(t, d.Diff.Truncated)
353 assert.Equal(t, 429, big.Additions)
354 assert.Equal(t, 429, big.Deletions)
355 assert.Equal(t, domain.OmitNone, tiny.Omitted, "the following file still fits the changed-line budget")
356 assert.Equal(t, "x\n", strings.TrimPrefix(unified(tiny.Hunks), "@@ -0,0 +1 @@\n+"))
357 assert.Equal(t, id, d.Commit.ID.String())
358 })
359
360 t.Run("the total limit truncates the file that does not fit", func(t *testing.T) {
361 f := readertest.NewFixture(t, data, "limit-total")
362 files := map[string]int{}
363 for i := range 11 {
364 files[fmt.Sprintf("f%02d", i)] = 1900 // 11 x 1900 = 20,900 > 20,000
365 }
366 files["g-small"] = 50 // sorts after f10: still fits in the 1,000 lines left
367 files["h-big"] = 1500 // does not fit
368 files["i-small"] = 10 // fits
369 commitFiles(f, "total", files)
370 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)
371 require.NoError(t, err)
372 require.Len(t, d.Diff.Files, 14)
373 total := 0
374 for _, file := range d.Diff.Files {
375 for _, h := range file.Hunks {
376 total += len(h.Lines)
377 }
378 }
379 assert.LessOrEqual(t, total, 20000)
380 for i := range 10 {
381 assert.Equal(t, domain.OmitNone, find(d.Diff, fmt.Sprintf("f%02d", i)).Omitted)
382 }
383 assert.NotEqual(t, domain.OmitNone, find(d.Diff, "f10").Omitted, "1,900 lines do not fit in the 1,000 left")
384 assert.Equal(t, domain.OmitNone, find(d.Diff, "g-small").Omitted)
385 assert.NotEqual(t, domain.OmitNone, find(d.Diff, "h-big").Omitted)
386 assert.Equal(t, domain.OmitNone, find(d.Diff, "i-small").Omitted)
387 assert.False(t, d.Diff.Truncated)
388 assert.Equal(t, 11*1900+50+1500+10, d.Stats.Additions)
389 assert.Equal(t, 14, d.Stats.FilesChanged)
390 })
391
392 t.Run("files past the file limit are omitted but counted", func(t *testing.T) {
393 f := readertest.NewFixture(t, data, "limit-files")
394 for i := range 305 {
395 f.Write(fmt.Sprintf("many/f%03d", i), "x\n")
396 }
397 f.Commit("many")
398 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)
399 require.NoError(t, err)
400 assert.Len(t, d.Diff.Files, 300)
401 assert.True(t, d.Diff.Truncated)
402 assert.Equal(t, domain.DiffStats{FilesChanged: 305, Additions: 305}, d.Stats)
403 assert.Equal(t, "many/f000", d.Diff.Files[0].NewPath)
404 assert.Equal(t, "many/f299", d.Diff.Files[299].NewPath)
405 for _, file := range d.Diff.Files {
406 assert.Equal(t, domain.OmitNone, file.Omitted)
407 assert.Len(t, file.Hunks, 1)
408 }
409 })
410
411 t.Run("exactly 300 files is not truncated", func(t *testing.T) {
412 f := readertest.NewFixture(t, data, "limit-300")
413 for i := range 300 {
414 f.Write(fmt.Sprintf("f%03d", i), "x\n")
415 }
416 f.Commit("300")
417 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)
418 require.NoError(t, err)
419 assert.Len(t, d.Diff.Files, 300)
420 assert.False(t, d.Diff.Truncated)
421 })
422
423 t.Run("custom limits and the byte limit", func(t *testing.T) {
424 f := readertest.NewFixture(t, data, "limit-custom")
425 commitFiles(f, "c", map[string]int{"a": 10, "b": 10, "c": 10})
426 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxFiles: 2, MaxFileLines: 100, MaxTotalLines: 15, MaxTotalBytes: 1 << 20})
427 require.NoError(t, err)
428 require.Len(t, d.Diff.Files, 2)
429 assert.Equal(t, domain.OmitNone, d.Diff.Files[0].Omitted)
430 assert.NotEqual(t, domain.OmitNone, d.Diff.Files[1].Omitted, "10 lines do not fit in the 5 left")
431 assert.Equal(t, 3, d.Stats.FilesChanged)
432
433 d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxFiles: 10, MaxFileLines: 100, MaxTotalLines: 100, MaxTotalBytes: 50})
434 require.NoError(t, err)
435 require.Len(t, d.Diff.Files, 3)
436 assert.Equal(t, domain.OmitNone, d.Diff.Files[0].Omitted, "30 bytes of text fit in 50")
437 for _, file := range d.Diff.Files[1:] {
438 assert.NotEqual(t, domain.OmitNone, file.Omitted, "the byte budget has 20 bytes left for 30")
439 assert.Empty(t, file.Hunks)
440 }
441 })
442
443 t.Run("a very long line truncates its file instead of being cut", func(t *testing.T) {
444 f := readertest.NewFixture(t, data, "limit-line")
445 f.Write("long", strings.Repeat("x", 200<<10)+"\n").Write("short", "ok\n").Commit("long")
446 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)
447 require.NoError(t, err)
448 long, short := find(d.Diff, "long"), find(d.Diff, "short")
449 assert.NotEqual(t, domain.OmitNone, long.Omitted)
450 assert.Empty(t, long.Hunks)
451 assert.Equal(t, domain.OmitNone, short.Omitted)
452 assert.Len(t, short.Hunks, 1)
453 })
454
455 t.Run("binary files and mode changes in a truncated commit", func(t *testing.T) {
456 f := readertest.NewFixture(t, data, "limit-mixed")
457 f.Lines("a-big", "x", 2500).WriteBytes("b-bin", []byte{0, 1, 2}).Write("c-mode", "m\n").Write("d-text", "t\n")
458 f.Commit("v1")
459 f.Lines("a-big", "y", 2500).WriteBytes("b-bin", []byte{0, 3}).Chmod("c-mode", 0o755).Write("d-text", "t2\n")
460 id := f.Commit("v2")
461 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)
462 require.NoError(t, err)
463 assert.NotEqual(t, domain.OmitNone, find(d.Diff, "a-big").Omitted)
464 assert.True(t, find(d.Diff, "b-bin").Binary)
465 assert.Equal(t, domain.OmitNone, find(d.Diff, "b-bin").Omitted)
466 assert.Equal(t, domain.OmitNone, find(d.Diff, "c-mode").Omitted)
467 assert.Len(t, find(d.Diff, "d-text").Hunks, 1)
468 // What is kept matches git.
469 sections := showSections(f, id)
470 require.Len(t, sections, 4)
471 assert.Equal(t, sections[3], unified(find(d.Diff, "d-text").Hunks))
472 })
473}
474
475func TestGetCommitStopsGitEarly(t *testing.T) {
476 ctx := context.Background()
477 g, data := newReader(t, nil)
478 f := readertest.NewFixture(t, data, "early")
479 f.Lines("a", "x", 5).Lines("z-huge", "y", 100000).Commit("c")
480
481 // z-huge is truncated by the numstat alone, so nothing of it is read.
482 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DefaultDiffLimits())
483 require.NoError(t, err)
484 require.Len(t, d.Diff.Files, 2)
485 assert.Equal(t, domain.OmitNone, d.Diff.Files[0].Omitted)
486 assert.NotEqual(t, domain.OmitNone, d.Diff.Files[1].Omitted)
487 assert.Equal(t, 100005, d.Stats.Additions)
488}
489
490// windowFixture commits a diff that mixes what the patch reader must step over
491// in a window: text, binary, a rename, a type change (two sections), a mode
492// change (no hunks) and more text. It returns the id of the second commit.
493func windowFixture(t *testing.T, data, name string) (*readertest.Fixture, string) {
494 t.Helper()
495 f := readertest.NewFixture(t, data, name)
496 f.Lines("a-text", "a", 20).WriteBytes("b-bin", []byte{0, 1, 2}).Lines("c-old", "c", 30).
497 Lines("d-type", "d", 5).Lines("e-mode", "e", 3).Lines("f-text", "f", 20).Lines("g-text", "g", 20)
498 f.Commit("v1")
499 f.Lines("a-text", "A", 21).WriteBytes("b-bin", []byte{0, 3}).Mv("c-old", "c-new").
500 Remove("d-type").Symlink("a-text", "d-type").Chmod("e-mode", 0o755).Lines("f-text", "F", 20).Lines("g-text", "G", 2)
501 f.Write("c-new", readLines("c", 30)+"extra\n") // a rename with one line added
502 id := f.Commit("v2")
503 return f, id
504}
505
506func readLines(prefix string, n int) string {
507 var b strings.Builder
508 for i := range n {
509 fmt.Fprintf(&b, "%s %d\n", prefix, i)
510 }
511 return b.String()
512}
513
514func TestDiffLimitsWindow(t *testing.T) {
515 ctx := context.Background()
516 g, data := newReader(t, nil)
517
518 t.Run("a window reads the hunks of its files only, whatever the files around it", func(t *testing.T) {
519 f, id := windowFixture(t, data, "win-mixed")
520 full, err := g.GetCommit(ctx, f.Name, readertest.Rev(t, id), domain.DefaultDiffLimits())
521 require.NoError(t, err)
522 assertMatchesGitShow(t, f, id, full)
523 n := len(full.Diff.Files)
524 require.GreaterOrEqual(t, n, 7)
525 status := map[domain.FileStatus]bool{}
526 for _, file := range full.Diff.Files {
527 status[file.Status] = true
528 }
529 assert.True(t, status[domain.FileTypeChanged], "the fixture has a type change")
530 assert.True(t, status[domain.FileRenamed], "the fixture has a rename")
531 assert.False(t, full.Diff.Truncated)
532
533 tests := []struct {
534 name string
535 from, to int
536 }{
537 {"first file", 0, 1},
538 {"middle", 2, 5},
539 {"type change alone", 3, 4},
540 {"binary alone", 1, 2},
541 {"mode change alone", 4, 5},
542 {"rename alone", 2, 3},
543 {"through the end, to 0", 3, 0},
544 {"to past the end", 5, 100},
545 {"last file", n - 1, n},
546 {"from past the end", n, n + 5},
547 {"far past the end", 1000, 0},
548 {"empty window", 3, 3},
549 {"whole diff", 0, 0},
550 }
551 for _, tc := range tests {
552 t.Run(tc.name, func(t *testing.T) {
553 limits := domain.DefaultDiffLimits()
554 limits.From, limits.To = tc.from, tc.to
555 d, err := g.GetCommit(ctx, f.Name, readertest.Rev(t, id), limits)
556 require.NoError(t, err)
557 require.Len(t, d.Diff.Files, n)
558 assert.Equal(t, full.Stats, d.Stats)
559 assert.False(t, d.Diff.Truncated, "files outside the window do not truncate the diff")
560 to := tc.to
561 if to == 0 {
562 to = n
563 }
564 for i, got := range d.Diff.Files {
565 want := full.Diff.Files[i]
566 if i < tc.from || i >= to {
567 want.Hunks = nil
568 }
569 assert.Equal(t, want, got, "file %d (%s)", i, want.NewPath)
570 }
571 })
572 }
573 })
574
575 t.Run("files outside the window use none of the total budget", func(t *testing.T) {
576 f := readertest.NewFixture(t, data, "win-budget")
577 commitFiles(f, "five", map[string]int{"a": 10, "b": 10, "c": 10, "d": 10, "e": 10})
578 limits := domain.DiffLimits{MaxFileLines: 100, MaxTotalLines: 15, MaxTotalBytes: 1 << 20, From: 3, To: 5}
579 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, limits)
580 require.NoError(t, err)
581 require.Len(t, d.Diff.Files, 5)
582 for i, file := range d.Diff.Files {
583 switch i {
584 case 3:
585 assert.Equal(t, domain.OmitNone, file.Omitted)
586 assert.Len(t, file.Hunks, 1, "10 lines fit in 15")
587 case 4:
588 assert.NotEqual(t, domain.OmitNone, file.Omitted, "the second file of the window does not fit in the 5 lines left")
589 default:
590 assert.Equal(t, domain.OmitNone, file.Omitted)
591 assert.Empty(t, file.Hunks)
592 assert.Equal(t, 10, file.Additions, "counts stay accurate")
593 }
594 }
595 assert.False(t, d.Diff.Truncated, "an omitted file does not set Diff.Truncated")
596
597 limits.From, limits.To = 0, 0
598 limits.MaxTotalBytes = 25 // "a 0\n".."a 9\n" is 30 bytes of text per file
599 d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits)
600 require.NoError(t, err)
601 assert.NotEqual(t, domain.OmitNone, d.Diff.Files[0].Omitted)
602 })
603
604 t.Run("a file beyond the window is not truncated by its own size", func(t *testing.T) {
605 f := readertest.NewFixture(t, data, "win-huge")
606 f.Lines("a", "x", 5).Lines("z-huge", "y", 5000).Commit("c")
607 limits := domain.DefaultDiffLimits()
608 limits.From, limits.To = 0, 1
609 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, limits)
610 require.NoError(t, err)
611 require.Len(t, d.Diff.Files, 2)
612 assert.Len(t, d.Diff.Files[0].Hunks, 1)
613 assert.Equal(t, domain.OmitNone, d.Diff.Files[1].Omitted)
614 assert.Empty(t, d.Diff.Files[1].Hunks)
615 assert.False(t, d.Diff.Truncated)
616 limits.From, limits.To = 1, 2
617 d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits)
618 require.NoError(t, err)
619 assert.NotEqual(t, domain.OmitNone, d.Diff.Files[1].Omitted, "in the window, over the per-file limit")
620 assert.False(t, d.Diff.Truncated)
621 })
622
623 t.Run("MaxFiles 0 lists every file", func(t *testing.T) {
624 f := readertest.NewFixture(t, data, "win-many")
625 const total = 350
626 for i := range total {
627 f.Write(fmt.Sprintf("many/f%03d", i), fmt.Sprintf("x%d\n", i))
628 }
629 f.Commit("many")
630 limits := domain.DefaultDiffLimits()
631 limits.MaxFiles = 0
632 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, limits)
633 require.NoError(t, err)
634 require.Len(t, d.Diff.Files, total)
635 assert.False(t, d.Diff.Truncated)
636 assert.Equal(t, domain.DiffStats{FilesChanged: total, Additions: total}, d.Stats)
637 for i, file := range d.Diff.Files {
638 assert.Equal(t, fmt.Sprintf("many/f%03d", i), file.NewPath)
639 require.Len(t, file.Hunks, 1, file.NewPath)
640 }
641
642 // A window past the 300th file reads its own hunks.
643 limits.From, limits.To = 320, 330
644 d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits)
645 require.NoError(t, err)
646 require.Len(t, d.Diff.Files, total)
647 assert.False(t, d.Diff.Truncated)
648 for i, file := range d.Diff.Files {
649 if i >= 320 && i < 330 {
650 require.Len(t, file.Hunks, 1)
651 assert.Equal(t, fmt.Sprintf("x%d", i), file.Hunks[0].Lines[0].Text)
652 } else {
653 assert.Empty(t, file.Hunks)
654 }
655 assert.Equal(t, domain.OmitNone, file.Omitted)
656 }
657
658 // The default limits still cut at 300.
659 d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DefaultDiffLimits())
660 require.NoError(t, err)
661 assert.Len(t, d.Diff.Files, 300)
662 assert.True(t, d.Diff.Truncated)
663 })
664}