v0.8.1 b8e893f

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	"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}

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