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)
17
18// unified renders hunks the way git prints them after the "+++" line.
19func unified(hunks []domain.Hunk) string {
20 var b strings.Builder
21 rng := func(start, n int) string {
22 if n == 1 {
23 return strconv.Itoa(start)
24 }
25 return fmt.Sprintf("%d,%d", start, n)
26 }
27 for _, h := range hunks {
28 fmt.Fprintf(&b, "@@ -%s +%s @@", rng(h.OldStart, h.OldLines), rng(h.NewStart, h.NewLines))
29 if h.Header != "" {
30 b.WriteString(" " + h.Header)
31 }
32 b.WriteByte('\n')
33 for _, l := range h.Lines {
34 b.WriteString(map[domain.LineKind]string{domain.LineContext: " ", domain.LineAddition: "+", domain.LineDeletion: "-"}[l.Kind])
35 b.WriteString(l.Text + "\n")
36 if l.NoNewlineAtEOF {
37 b.WriteString("\\ No newline at end of file\n")
38 }
39 }
40 }
41 return b.String()
42}
43
44// gitSections splits the output of `git show -p` into the text each
45// "diff --git" section has from its first hunk on ("" for a section without
46// hunk: binary, pure rename, mode change).
47func gitSections(out string) []string {
48 var sections []string
49 for _, s := range strings.Split("\n"+out, "\ndiff --git ")[1:] {
50 _, hunks, ok := strings.Cut(s, "\n@@ ")
51 if !ok {
52 sections = append(sections, "")
53 continue
54 }
55 sections = append(sections, "@@ "+strings.TrimSuffix(hunks, "\n")+"\n")
56 }
57 return sections
58}
59
60// showSections is git's own patch text of a commit against its first parent.
61func (f *fixtureRepo) showSections(id string) []string {
62 f.t.Helper()
63 out := f.git("show", "--format=", "--no-color", "-M", "-m", "--first-parent", "-U3", id)
64 return gitSections(strings.ToValidUTF8(out, "\uFFFD"))
65}
66
67// showNumstat is git's own numstat of a commit against its first parent.
68func (f *fixtureRepo) showNumstat(id string) []string {
69 f.t.Helper()
70 out := f.git("show", "--format=", "-M", "-m", "--first-parent", "--numstat", id)
71 if out == "" {
72 return nil
73 }
74 return strings.Split(out, "\n")
75}
76
77// assertMatchesGitShow checks the structured diff of a commit against the
78// text of `git show`: the patch of every file, its counts, and the totals.
79func assertMatchesGitShow(t *testing.T, f *fixtureRepo, id string, d domain.CommitDetail) {
80 t.Helper()
81 sections := f.showSections(id)
82 var adds, dels int
83 numstat := f.showNumstat(id)
84 require.Len(t, numstat, len(d.Diff.Files), "one numstat line per file")
85 si := 0
86 for i, file := range d.Diff.Files {
87 label := file.OldPath + ">" + file.NewPath
88 fields := strings.SplitN(numstat[i], "\t", 3)
89 if fields[0] == "-" {
90 assert.True(t, file.Binary, label)
91 assert.Zero(t, file.Additions+file.Deletions, label)
92 } else {
93 assert.False(t, file.Binary, label)
94 assert.Equal(t, fields[0], strconv.Itoa(file.Additions), label)
95 assert.Equal(t, fields[1], strconv.Itoa(file.Deletions), label)
96 a, _ := strconv.Atoi(fields[0])
97 del, _ := strconv.Atoi(fields[1])
98 adds, dels = adds+a, dels+del
99 }
100 // A type change is two sections: the deletion, then the addition.
101 n := 1
102 if file.Status == domain.FileTypeChanged {
103 n = 2
104 }
105 require.LessOrEqual(t, si+n, len(sections), label)
106 assert.Equal(t, strings.Join(sections[si:si+n], ""), unified(file.Hunks), "patch of %s", label)
107 si += n
108 }
109 assert.Equal(t, len(sections), si, "every git section is accounted for")
110 assert.Equal(t, domain.DiffStats{FilesChanged: len(numstat), Additions: adds, Deletions: dels}, d.Stats)
111}
112
113func TestGetCommitMatchesGitShow(t *testing.T) {
114 ctx := context.Background()
115 g, data := newReader(t, nil)
116 h := newHistory(t, data)
117
118 for _, tt := range []struct{ name, id string }{
119 {"root commit: adds, binary, empty file", h.c1},
120 {"rename with change, binary, mode change, add, delete", h.c2},
121 {"type change", h.c3},
122 {"merge, against the first parent", h.merge},
123 {"side branch", h.side},
124 } {
125 t.Run(tt.name, func(t *testing.T) {
126 d, err := g.GetCommit(ctx, h.name, domain.RevisionOf(mustID(t, tt.id)), domain.DefaultDiffLimits())
127 require.NoError(t, err)
128 assert.Equal(t, tt.id, d.Commit.ID.String())
129 assert.False(t, d.Diff.Truncated)
130 assertMatchesGitShow(t, h.fixtureRepo, tt.id, d)
131 })
132 }
133}
134
135func TestGetCommitFileMetadata(t *testing.T) {
136 ctx := context.Background()
137 g, data := newReader(t, nil)
138 h := newHistory(t, data)
139
140 byPath := func(d domain.Diff) map[string]domain.FileDiff {
141 m := map[string]domain.FileDiff{}
142 for _, f := range d.Files {
143 m[f.OldPath+">"+f.NewPath] = f
144 }
145 return m
146 }
147 id := func(spec string) domain.ObjectID { return mustID(t, h.git("rev-parse", spec)) }
148
149 d, err := g.GetCommit(ctx, h.name, rev(t, "v2"), domain.DefaultDiffLimits())
150 require.NoError(t, err)
151 assert.Equal(t, h.c2, d.Commit.ID.String())
152 files := byPath(d.Diff)
153 require.Len(t, files, 5)
154
155 ren := files["a.txt>b.txt"]
156 assert.Equal(t, domain.FileRenamed, ren.Status)
157 assert.Equal(t, 73, ren.Similarity)
158 assert.Equal(t, 1, ren.Additions)
159 assert.Equal(t, 0, ren.Deletions)
160 assert.Equal(t, uint32(0o100644), ren.OldMode)
161 assert.Equal(t, uint32(0o100644), ren.NewMode)
162 assert.Equal(t, id(h.c1+":a.txt"), ren.OldID)
163 assert.Equal(t, id(h.c2+":b.txt"), ren.NewID)
164 require.Len(t, ren.Hunks, 1)
165 assert.Equal(t, domain.Hunk{
166 OldStart: 1, OldLines: 3, NewStart: 1, NewLines: 4,
167 Lines: []domain.Line{
168 {Kind: domain.LineContext, OldNumber: 1, NewNumber: 1, Text: "one"},
169 {Kind: domain.LineContext, OldNumber: 2, NewNumber: 2, Text: "two"},
170 {Kind: domain.LineContext, OldNumber: 3, NewNumber: 3, Text: "three"},
171 {Kind: domain.LineAddition, NewNumber: 4, Text: "four"},
172 },
173 }, ren.Hunks[0])
174
175 bin := files["bin>bin"]
176 assert.Equal(t, domain.FileModified, bin.Status)
177 assert.True(t, bin.Binary)
178 assert.Empty(t, bin.Hunks)
179 assert.False(t, bin.Truncated)
180
181 mode := files["d/f>d/f"]
182 assert.Equal(t, domain.FileModified, mode.Status)
183 assert.Equal(t, uint32(0o100644), mode.OldMode)
184 assert.Equal(t, uint32(0o100755), mode.NewMode)
185 assert.Empty(t, mode.Hunks)
186 assert.False(t, mode.Binary)
187 assert.False(t, mode.Truncated)
188
189 del := files["README.md>"]
190 assert.Equal(t, domain.FileDeleted, del.Status)
191 assert.Equal(t, uint32(0), del.NewMode)
192 assert.True(t, del.NewID.IsZero())
193 assert.Equal(t, 1, del.Deletions)
194 require.Len(t, del.Hunks, 1)
195 assert.Equal(t, domain.LineDeletion, del.Hunks[0].Lines[0].Kind)
196 assert.Equal(t, 0, del.Hunks[0].NewStart, "a deleted file has no new side")
197
198 add := files[">n.txt"]
199 assert.Equal(t, domain.FileAdded, add.Status)
200 assert.Equal(t, uint32(0), add.OldMode)
201 assert.True(t, add.OldID.IsZero())
202 require.Len(t, add.Hunks, 1)
203 last := add.Hunks[0].Lines[1]
204 assert.Equal(t, domain.Line{Kind: domain.LineAddition, NewNumber: 2, Text: "nonl", NoNewlineAtEOF: true}, last)
205
206 d, err = g.GetCommit(ctx, h.name, rev(t, h.c3), domain.DefaultDiffLimits())
207 require.NoError(t, err)
208 require.Len(t, d.Diff.Files, 1)
209 tc := d.Diff.Files[0]
210 assert.Equal(t, domain.FileTypeChanged, tc.Status)
211 assert.Equal(t, uint32(0o120000), tc.NewMode)
212 assert.Equal(t, 1, tc.Additions)
213 assert.Equal(t, 2, tc.Deletions)
214}
215
216func TestGetCommitMergeDiffIsAgainstFirstParent(t *testing.T) {
217 ctx := context.Background()
218 g, data := newReader(t, nil)
219 h := newHistory(t, data)
220
221 d, err := g.GetCommit(ctx, h.name, rev(t, "main"), domain.DefaultDiffLimits())
222 require.NoError(t, err)
223 require.Len(t, d.Commit.Parents, 2)
224 require.Len(t, d.Diff.Files, 1)
225 assert.Equal(t, "side.txt", d.Diff.Files[0].NewPath)
226 assert.Equal(t, domain.FileAdded, d.Diff.Files[0].Status)
227}
228
229func TestGetCommitEmptyCommit(t *testing.T) {
230 ctx := context.Background()
231 g, data := newReader(t, nil)
232 f := newFixtureRepo(t, data, "empty-commit")
233 id := f.commit("nothing")
234
235 d, err := g.GetCommit(ctx, f.name, domain.Revision{}, domain.DefaultDiffLimits())
236 require.NoError(t, err)
237 assert.Equal(t, id, d.Commit.ID.String())
238 assert.Empty(t, d.Diff.Files)
239 assert.False(t, d.Diff.Truncated)
240 assert.Equal(t, domain.DiffStats{}, d.Stats)
241}
242
243func TestGetCommitErrors(t *testing.T) {
244 ctx := context.Background()
245 g, data := newReader(t, nil)
246 h := newHistory(t, data)
247
248 _, err := g.GetCommit(ctx, h.name, rev(t, "nope"), domain.DefaultDiffLimits())
249 assert.ErrorIs(t, err, domain.ErrNotFound)
250 _, err = g.GetCommit(ctx, name(t, "nope"), domain.Revision{}, domain.DefaultDiffLimits())
251 assert.ErrorIs(t, err, domain.ErrNotFound)
252}
253
254func TestGetCommitHunkHeaderAndNonUTF8(t *testing.T) {
255 ctx := context.Background()
256 g, data := newReader(t, nil)
257 f := newFixtureRepo(t, data, "hunks")
258 var src strings.Builder
259 src.WriteString("package x\n\nfunc Alpha() {\n")
260 for i := range 40 {
261 fmt.Fprintf(&src, "\tline(%d)\n", i)
262 }
263 src.WriteString("}\n")
264 f.write("x.go", src.String()).write("latin", "caf\xe9\nok\n")
265 f.commit("v1")
266 f.write("x.go", strings.Replace(src.String(), "\tline(30)", "\tchanged(30)", 1)).write("latin", "caf\xe9\nOK\n")
267 id := f.commit("v2")
268
269 d, err := g.GetCommit(ctx, f.name, domain.Revision{}, domain.DefaultDiffLimits())
270 require.NoError(t, err)
271 assertMatchesGitShow(t, f, id, d)
272 var gofile, latin domain.FileDiff
273 for _, file := range d.Diff.Files {
274 switch file.NewPath {
275 case "x.go":
276 gofile = file
277 case "latin":
278 latin = file
279 }
280 }
281 require.Len(t, gofile.Hunks, 1)
282 assert.Equal(t, "func Alpha() {", gofile.Hunks[0].Header, "git's default function name rule")
283 assert.Equal(t, 31, gofile.Hunks[0].OldStart)
284 require.Len(t, latin.Hunks, 1)
285 assert.Equal(t, "caf\uFFFD", latin.Hunks[0].Lines[0].Text, "invalid UTF-8 is replaced")
286}
287
288// manyLines commits files and returns the id.
289func commitFiles(f *fixtureRepo, msg string, files map[string]int) string {
290 for p, n := range files {
291 f.lines(p, strings.ReplaceAll(p, "/", "_"), n)
292 }
293 return f.commit(msg)
294}
295
296func TestDiffLimits(t *testing.T) {
297 ctx := context.Background()
298 g, data := newReader(t, nil)
299 defaults := domain.DefaultDiffLimits()
300 find := func(d domain.Diff, p string) domain.FileDiff {
301 for _, f := range d.Files {
302 if f.NewPath == p {
303 return f
304 }
305 }
306 t.Fatalf("file %s not in the diff", p)
307 return domain.FileDiff{}
308 }
309
310 t.Run("a file at the line limit is kept, one line more is truncated", func(t *testing.T) {
311 f := newFixtureRepo(t, data, "limit-file")
312 commitFiles(f, "files", map[string]int{"exact": 2000, "over": 2001, "small": 3})
313 d, err := g.GetCommit(ctx, f.name, domain.Revision{}, defaults)
314 require.NoError(t, err)
315 exact, over, small := find(d.Diff, "exact"), find(d.Diff, "over"), find(d.Diff, "small")
316
317 assert.False(t, exact.Truncated)
318 require.Len(t, exact.Hunks, 1)
319 assert.Len(t, exact.Hunks[0].Lines, 2000)
320 assert.True(t, over.Truncated)
321 assert.Empty(t, over.Hunks)
322 assert.Equal(t, 2001, over.Additions, "counts stay accurate")
323 assert.False(t, small.Truncated)
324 assert.Len(t, small.Hunks, 1)
325 assert.True(t, d.Diff.Truncated)
326 assert.Equal(t, domain.DiffStats{FilesChanged: 3, Additions: 4004}, d.Stats)
327 })
328
329 t.Run("context counts towards the file limit", func(t *testing.T) {
330 f := newFixtureRepo(t, data, "limit-context")
331 f.lines("big", "l", 3000).commit("v1")
332 // Every 7th line changes: ~428 changed lines, but the hunks merge
333 // into one that spans the file with its context.
334 var b strings.Builder
335 for i := range 3000 {
336 if i%7 == 0 {
337 fmt.Fprintf(&b, "changed %d\n", i)
338 } else {
339 fmt.Fprintf(&b, "l %d\n", i)
340 }
341 }
342 f.write("big", b.String())
343 f.write("tiny", "x\n")
344 id := f.commit("v2")
345 d, err := g.GetCommit(ctx, f.name, domain.Revision{}, defaults)
346 require.NoError(t, err)
347 big, tiny := find(d.Diff, "big"), find(d.Diff, "tiny")
348 assert.True(t, big.Truncated)
349 assert.Empty(t, big.Hunks)
350 assert.Equal(t, 429, big.Additions)
351 assert.Equal(t, 429, big.Deletions)
352 assert.False(t, tiny.Truncated, "the file after a truncated one is still read")
353 assert.Equal(t, "x\n", strings.TrimPrefix(unified(tiny.Hunks), "@@ -0,0 +1 @@\n+"))
354 assert.Equal(t, id, d.Commit.ID.String())
355 })
356
357 t.Run("the total limit truncates the file that does not fit", func(t *testing.T) {
358 f := newFixtureRepo(t, data, "limit-total")
359 files := map[string]int{}
360 for i := range 11 {
361 files[fmt.Sprintf("f%02d", i)] = 1900 // 11 x 1900 = 20,900 > 20,000
362 }
363 files["g-small"] = 50 // sorts after f10: still fits in the 1,000 lines left
364 files["h-big"] = 1500 // does not fit
365 files["i-small"] = 10 // fits
366 commitFiles(f, "total", files)
367 d, err := g.GetCommit(ctx, f.name, domain.Revision{}, defaults)
368 require.NoError(t, err)
369 require.Len(t, d.Diff.Files, 14)
370 total := 0
371 for _, file := range d.Diff.Files {
372 for _, h := range file.Hunks {
373 total += len(h.Lines)
374 }
375 }
376 assert.LessOrEqual(t, total, 20000)
377 for i := range 10 {
378 assert.False(t, find(d.Diff, fmt.Sprintf("f%02d", i)).Truncated)
379 }
380 assert.True(t, find(d.Diff, "f10").Truncated, "1,900 lines do not fit in the 1,000 left")
381 assert.False(t, find(d.Diff, "g-small").Truncated)
382 assert.True(t, find(d.Diff, "h-big").Truncated)
383 assert.False(t, find(d.Diff, "i-small").Truncated)
384 assert.True(t, d.Diff.Truncated)
385 assert.Equal(t, 11*1900+50+1500+10, d.Stats.Additions)
386 assert.Equal(t, 14, d.Stats.FilesChanged)
387 })
388
389 t.Run("files past the file limit are omitted but counted", func(t *testing.T) {
390 f := newFixtureRepo(t, data, "limit-files")
391 for i := range 305 {
392 f.write(fmt.Sprintf("many/f%03d", i), "x\n")
393 }
394 f.commit("many")
395 d, err := g.GetCommit(ctx, f.name, domain.Revision{}, defaults)
396 require.NoError(t, err)
397 assert.Len(t, d.Diff.Files, 300)
398 assert.True(t, d.Diff.Truncated)
399 assert.Equal(t, domain.DiffStats{FilesChanged: 305, Additions: 305}, d.Stats)
400 assert.Equal(t, "many/f000", d.Diff.Files[0].NewPath)
401 assert.Equal(t, "many/f299", d.Diff.Files[299].NewPath)
402 for _, file := range d.Diff.Files {
403 assert.False(t, file.Truncated)
404 assert.Len(t, file.Hunks, 1)
405 }
406 })
407
408 t.Run("exactly 300 files is not truncated", func(t *testing.T) {
409 f := newFixtureRepo(t, data, "limit-300")
410 for i := range 300 {
411 f.write(fmt.Sprintf("f%03d", i), "x\n")
412 }
413 f.commit("300")
414 d, err := g.GetCommit(ctx, f.name, domain.Revision{}, defaults)
415 require.NoError(t, err)
416 assert.Len(t, d.Diff.Files, 300)
417 assert.False(t, d.Diff.Truncated)
418 })
419
420 t.Run("custom limits and the byte limit", func(t *testing.T) {
421 f := newFixtureRepo(t, data, "limit-custom")
422 commitFiles(f, "c", map[string]int{"a": 10, "b": 10, "c": 10})
423 d, err := g.GetCommit(ctx, f.name, domain.Revision{}, domain.DiffLimits{MaxFiles: 2, MaxFileLines: 100, MaxTotalLines: 15, MaxTotalBytes: 1 << 20})
424 require.NoError(t, err)
425 require.Len(t, d.Diff.Files, 2)
426 assert.False(t, d.Diff.Files[0].Truncated)
427 assert.True(t, d.Diff.Files[1].Truncated, "10 lines do not fit in the 5 left")
428 assert.Equal(t, 3, d.Stats.FilesChanged)
429
430 d, err = g.GetCommit(ctx, f.name, domain.Revision{}, domain.DiffLimits{MaxFiles: 10, MaxFileLines: 100, MaxTotalLines: 100, MaxTotalBytes: 50})
431 require.NoError(t, err)
432 require.Len(t, d.Diff.Files, 3)
433 assert.False(t, d.Diff.Files[0].Truncated, "30 bytes of text fit in 50")
434 for _, file := range d.Diff.Files[1:] {
435 assert.True(t, file.Truncated, "the byte budget has 20 bytes left for 30")
436 assert.Empty(t, file.Hunks)
437 }
438 })
439
440 t.Run("a very long line truncates its file instead of being cut", func(t *testing.T) {
441 f := newFixtureRepo(t, data, "limit-line")
442 f.write("long", strings.Repeat("x", 200<<10)+"\n").write("short", "ok\n").commit("long")
443 d, err := g.GetCommit(ctx, f.name, domain.Revision{}, defaults)
444 require.NoError(t, err)
445 long, short := find(d.Diff, "long"), find(d.Diff, "short")
446 assert.True(t, long.Truncated)
447 assert.Empty(t, long.Hunks)
448 assert.False(t, short.Truncated)
449 assert.Len(t, short.Hunks, 1)
450 })
451
452 t.Run("binary files and mode changes in a truncated commit", func(t *testing.T) {
453 f := newFixtureRepo(t, data, "limit-mixed")
454 f.lines("a-big", "x", 2500).writeBytes("b-bin", []byte{0, 1, 2}).write("c-mode", "m\n").write("d-text", "t\n")
455 f.commit("v1")
456 f.lines("a-big", "y", 2500).writeBytes("b-bin", []byte{0, 3}).chmod("c-mode", 0o755).write("d-text", "t2\n")
457 id := f.commit("v2")
458 d, err := g.GetCommit(ctx, f.name, domain.Revision{}, defaults)
459 require.NoError(t, err)
460 assert.True(t, find(d.Diff, "a-big").Truncated)
461 assert.True(t, find(d.Diff, "b-bin").Binary)
462 assert.False(t, find(d.Diff, "b-bin").Truncated)
463 assert.False(t, find(d.Diff, "c-mode").Truncated)
464 assert.Len(t, find(d.Diff, "d-text").Hunks, 1)
465 // What is kept matches git.
466 sections := f.showSections(id)
467 require.Len(t, sections, 4)
468 assert.Equal(t, sections[3], unified(find(d.Diff, "d-text").Hunks))
469 })
470}
471
472func TestGetCommitStopsGitEarly(t *testing.T) {
473 ctx := context.Background()
474 g, data := newReader(t, nil)
475 f := newFixtureRepo(t, data, "early")
476 f.lines("a", "x", 5).lines("z-huge", "y", 100000).commit("c")
477
478 // z-huge is truncated by the numstat alone, so nothing of it is read.
479 d, err := g.GetCommit(ctx, f.name, domain.Revision{}, domain.DefaultDiffLimits())
480 require.NoError(t, err)
481 require.Len(t, d.Diff.Files, 2)
482 assert.False(t, d.Diff.Files[0].Truncated)
483 assert.True(t, d.Diff.Files[1].Truncated)
484 assert.Equal(t, 100005, d.Stats.Additions)
485}