v0.2.0 41cf2ec

diff_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close