v0.25.1 7fabed9

conformance.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package readertest
4
5import (
6	"context"
7	"encoding/hex"
8	"errors"
9	"fmt"
10	"io"
11	"slices"
12	"strings"
13	"testing"
14	"time"
15
16	"github.com/stretchr/testify/assert"
17	"github.com/stretchr/testify/require"
18
19	"bnema.dev/ermite/internal/domain"
20	"bnema.dev/ermite/internal/ports"
21)
22
23// Maker builds a RepoReader serving the repositories below data/repos, and
24// returns it with the data directory. The reader is closed when the test ends.
25type Maker func(t *testing.T) (ports.RepoReader, string)
26
27// Conformance runs the RepoReader contract against the readers mk builds:
28// every adapter that serves repositories must pass it, whatever its route.
29func Conformance(t *testing.T, mk Maker) {
30	for _, tc := range []struct {
31		name string
32		fn   func(*testing.T, Maker)
33	}{
34		{"ListRefs", testListRefs},
35		{"ListRefsEmptyAndMissing", testListRefsEmptyAndMissing},
36		{"DetachedHead", testDetachedHead},
37		{"ResolveRevision", testResolveRevision},
38		{"ResolveRefPath", testResolveRefPath},
39		{"GetTree", testGetTree},
40		{"GetTreeLastCommit", testGetTreeLastCommit},
41		{"GetTreeLastCommitBound", testGetTreeLastCommitBound},
42		{"ListCommits", testListCommits},
43		{"OpenBlob", testOpenBlob},
44		{"OpenBlobMediaTypes", testOpenBlobMediaTypes},
45		{"BinaryRuleLooksAtFirst8000Bytes", testBinaryRuleLooksAtFirst8000Bytes},
46		{"OpenBlobLargeThroughStream", testOpenBlobLargeThroughStream},
47		{"CancelledContext", testCancelledContext},
48		{"HostileRevisionAndPathStayLiteral", testHostileRevisionAndPathStayLiteral},
49		{"ResolveRevisionObjectIDBeatsRefs", testResolveRevisionObjectIDBeatsRefs},
50		{"GetTreeIsBounded", testGetTreeIsBounded},
51		{"GetTreeRefusesAGitlinkToATree", testGetTreeRefusesAGitlinkToATree},
52		{"ListPaths", testListPaths},
53		{"CountCommitsAndLatestTag", testCountCommitsAndLatestTag},
54	} {
55		t.Run(tc.name, func(t *testing.T) { tc.fn(t, mk) })
56	}
57}
58
59// Rev parses a revision.
60func Rev(t *testing.T, s string) domain.Revision {
61	t.Helper()
62	r, err := domain.ParseRevision(s)
63	require.NoError(t, err)
64	return r
65}
66
67// TreePath parses a tree path.
68func TreePath(t *testing.T, s string) domain.TreePath {
69	t.Helper()
70	p, err := domain.ParseTreePath(s)
71	require.NoError(t, err)
72	return p
73}
74
75func testListRefs(t *testing.T, mk Maker) {
76	ctx := context.Background()
77	g, data := mk(t)
78	h := NewHistory(t, data)
79
80	refs, err := g.ListRefs(ctx, h.Name)
81	require.NoError(t, err)
82
83	type row struct {
84		name  string
85		short string
86		kind  domain.RefKind
87		id    string
88		commi string
89	}
90	var got []row
91	for _, r := range refs {
92		got = append(got, row{r.Name, r.ShortName, r.Kind, r.ID.String(), r.CommitID.String()})
93	}
94	tagID := func(n string) string { return h.Git("rev-parse", "refs/tags/"+n) }
95	assert.Equal(t, []row{
96		{"HEAD", "main", domain.RefKindHead, h.Merge, h.Merge},
97		{"refs/heads/feature/x", "feature/x", domain.RefKindBranch, h.Side, h.Side},
98		{"refs/heads/main", "main", domain.RefKindBranch, h.Merge, h.Merge},
99		{"refs/heads/rel/1", "rel/1", domain.RefKindBranch, h.C1, h.C1},
100		{"refs/heads/release/1.0", "release/1.0", domain.RefKindBranch, h.C2, h.C2},
101		{"refs/tags/rel/1/x", "rel/1/x", domain.RefKindTag, h.C3, h.C3},
102		{"refs/tags/tree-tag", "tree-tag", domain.RefKindTag, tagID("tree-tag"), ""},
103		{"refs/tags/v1", "v1", domain.RefKindTag, h.C1, h.C1},
104		{"refs/tags/v2", "v2", domain.RefKindTag, tagID("v2"), h.C2},
105		{"refs/tags/v2-again", "v2-again", domain.RefKindTag, tagID("v2-again"), h.C2},
106	}, got)
107}
108
109func testCountCommitsAndLatestTag(t *testing.T, mk Maker) {
110	ctx := context.Background()
111	g, data := mk(t)
112	f := NewFixture(t, data, "stats")
113
114	_, ok, err := g.LatestTag(ctx, f.Name)
115	require.NoError(t, err)
116	assert.False(t, ok, "no tag yet")
117
118	var ids []string
119	for i := range 4 { // committed at 12:01, 12:02, 12:03, 12:04
120		ids = append(ids, f.Commit("c"+itoa(i)))
121	}
122	head := MustID(t, ids[3])
123	n, err := g.CountCommits(ctx, f.Name, head)
124	require.NoError(t, err)
125	assert.Equal(t, 4, n)
126	n, err = g.CountCommits(ctx, f.Name, MustID(t, ids[1]))
127	require.NoError(t, err)
128	assert.Equal(t, 2, n, "from another commit")
129	_, err = g.CountCommits(ctx, f.Name, MustID(t, strings.Repeat("ab", 20)))
130	assert.ErrorIs(t, err, domain.ErrNotFound)
131
132	unix := func(ts []time.Time) []int64 {
133		var out []int64
134		for _, x := range ts {
135			out = append(out, x.Unix())
136		}
137		slices.Sort(out)
138		return out
139	}
140	base := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
141	times, more, err := g.CommitTimes(ctx, f.Name, head, base.Add(150*time.Second))
142	require.NoError(t, err)
143	assert.False(t, more)
144	assert.Equal(t, []int64{base.Add(3 * time.Minute).Unix(), base.Add(4 * time.Minute).Unix()}, unix(times), "12:03 and 12:04")
145	_, _, err = g.CommitTimes(ctx, f.Name, MustID(t, strings.Repeat("ab", 20)), base)
146	assert.ErrorIs(t, err, domain.ErrNotFound)
147
148	// Dates out of order: a commit dated in the past on top of recent ones
149	// does not hide them.
150	recent := f.When
151	f.When = recent.Add(-48 * time.Hour)
152	old := f.Commit("backdated")
153	f.When = recent
154	times, _, err = g.CommitTimes(ctx, f.Name, MustID(t, old), base.Add(150*time.Second))
155	require.NoError(t, err)
156	assert.Len(t, times, 2, "the recent commits behind an old one count")
157
158	// The latest tag is the one made last: an annotated tag by its own date,
159	// a lightweight one by the date of its commit.
160	f.Git("tag", "light-old", ids[0])
161	f.When = f.When.Add(time.Hour)
162	f.Git("tag", "-a", "-m", "new", "v2", ids[0])
163	f.Git("tag", "light-new", ids[3])
164	tag, ok, err := g.LatestTag(ctx, f.Name)
165	require.NoError(t, err)
166	require.True(t, ok)
167	assert.Equal(t, "v2", tag.Name)
168	assert.True(t, tag.At.Equal(f.When), "%v", tag.At)
169	assert.Equal(t, ids[0], tag.CommitID.String(), "an annotated tag is peeled to its commit")
170
171	f.When = f.When.Add(time.Hour)
172	f.Git("tag", "-a", "-m", "tree", "tree-tag", ids[0]+"^{tree}")
173	tag, ok, err = g.LatestTag(ctx, f.Name)
174	require.NoError(t, err)
175	require.True(t, ok)
176	assert.Equal(t, "tree-tag", tag.Name)
177	assert.True(t, tag.CommitID.IsZero(), "a tag of a tree has no commit")
178
179	_, _, err = g.LatestTag(ctx, RepoName(t, "nope"))
180	assert.ErrorIs(t, err, domain.ErrNotFound)
181}
182
183func testListRefsEmptyAndMissing(t *testing.T, mk Maker) {
184	ctx := context.Background()
185	g, data := mk(t)
186	f := NewFixture(t, data, "empty")
187
188	refs, err := g.ListRefs(ctx, f.Name)
189	require.NoError(t, err)
190	require.Len(t, refs, 1)
191	assert.Equal(t, "HEAD", refs[0].Name)
192	assert.Equal(t, "main", refs[0].ShortName, "unborn default branch")
193	assert.True(t, refs[0].ID.IsZero())
194	assert.True(t, refs[0].CommitID.IsZero())
195
196	_, err = g.ListRefs(ctx, RepoName(t, "nope"))
197	assert.ErrorIs(t, err, domain.ErrNotFound)
198
199	// No commit to resolve in an empty repository.
200	_, err = g.ResolveRevision(ctx, f.Name, domain.Revision{})
201	assert.ErrorIs(t, err, domain.ErrNotFound)
202	_, err = g.GetTree(ctx, f.Name, domain.Revision{}, domain.TreePath{}, false)
203	assert.ErrorIs(t, err, domain.ErrNotFound)
204	_, err = g.ListCommits(ctx, f.Name, ports.CommitQuery{Limit: 10})
205	assert.ErrorIs(t, err, domain.ErrNotFound)
206}
207
208func testDetachedHead(t *testing.T, mk Maker) {
209	ctx := context.Background()
210	g, data := mk(t)
211	h := NewHistory(t, data)
212	h.Git("update-ref", "--no-deref", "HEAD", h.C1)
213
214	refs, err := g.ListRefs(ctx, h.Name)
215	require.NoError(t, err)
216	assert.Equal(t, "HEAD", refs[0].Name)
217	assert.Empty(t, refs[0].ShortName)
218}
219
220func testResolveRevision(t *testing.T, mk Maker) {
221	ctx := context.Background()
222	g, data := mk(t)
223	h := NewHistory(t, data)
224
225	tests := []struct {
226		rev    string
227		commit string
228		ref    string
229	}{
230		{"", h.Merge, ""},
231		{"HEAD", h.Merge, ""},
232		{"main", h.Merge, "refs/heads/main"},
233		{"feature/x", h.Side, "refs/heads/feature/x"},
234		{"refs/heads/feature/x", h.Side, "refs/heads/feature/x"},
235		{"v1", h.C1, "refs/tags/v1"},
236		{"v2", h.C2, "refs/tags/v2"},
237		{"v2-again", h.C2, "refs/tags/v2-again"},
238		{"refs/tags/v2", h.C2, "refs/tags/v2"},
239		{h.C2, h.C2, ""},
240		{h.C2[:7], h.C2, ""},
241		{h.C2[:12], h.C2, ""},
242	}
243	for _, tt := range tests {
244		t.Run(tt.rev, func(t *testing.T) {
245			got, err := g.ResolveRevision(ctx, h.Name, Rev(t, tt.rev))
246			require.NoError(t, err)
247			assert.Equal(t, tt.commit, got.CommitID.String())
248			assert.Equal(t, tt.ref, got.Ref)
249		})
250	}
251	for _, bad := range []string{"nope", "tree-tag", "deadbeefdeadbeef", "HEAD~1", h.C2[:6], "refs/heads", h.Git("rev-parse", h.Merge+"^{tree}")} {
252		t.Run("missing "+bad, func(t *testing.T) {
253			r, err := domain.ParseRevision(bad)
254			if err != nil {
255				return // not a revision at all, rejected before the reader
256			}
257			_, err = g.ResolveRevision(ctx, h.Name, r)
258			assert.ErrorIs(t, err, domain.ErrNotFound)
259		})
260	}
261
262	t.Run("a branch wins over a tag of the same name", func(t *testing.T) {
263		h.Git("branch", "both", h.C1)
264		h.Git("tag", "both", h.C2)
265		got, err := g.ResolveRevision(ctx, h.Name, Rev(t, "both"))
266		require.NoError(t, err)
267		assert.Equal(t, h.C1, got.CommitID.String())
268	})
269}
270
271func testResolveRefPath(t *testing.T, mk Maker) {
272	ctx := context.Background()
273	g, data := mk(t)
274	h := NewHistory(t, data)
275
276	tests := []struct {
277		path string
278		name string
279		ref  string
280		id   string
281		rest string
282	}{
283		{"main", "main", "refs/heads/main", h.Merge, ""},
284		{"main/d/f", "main", "refs/heads/main", h.Merge, "d/f"},
285		{"feature/x", "feature/x", "refs/heads/feature/x", h.Side, ""},
286		{"feature/x/d/f", "feature/x", "refs/heads/feature/x", h.Side, "d/f"},
287		{"feature/x/", "feature/x", "refs/heads/feature/x", h.Side, ""},
288		{"release/1.0/a.txt", "release/1.0", "refs/heads/release/1.0", h.C2, "a.txt"},
289		// The longest ref wins: the tag rel/1/x over the branch rel/1.
290		{"rel/1/x/d/f", "rel/1/x", "refs/tags/rel/1/x", h.C3, "d/f"},
291		{"rel/1/d/f", "rel/1", "refs/heads/rel/1", h.C1, "d/f"},
292		{"v2/we:ird*.txt", "v2", "refs/tags/v2", h.C2, "we:ird*.txt"},
293		{h.C1 + "/d", h.C1, "", h.C1, "d"},
294		{h.C1[:8] + "/d/f", h.C1[:8], "", h.C1, "d/f"},
295	}
296	for _, tt := range tests {
297		t.Run(tt.path, func(t *testing.T) {
298			got, err := g.ResolveRefPath(ctx, h.Name, tt.path)
299			require.NoError(t, err)
300			assert.Equal(t, tt.name, got.Name)
301			assert.Equal(t, tt.ref, got.Ref)
302			assert.Equal(t, tt.id, got.CommitID.String())
303			assert.Equal(t, tt.rest, got.Rest.String())
304		})
305	}
306	for _, bad := range []string{"", "/", "nope", "nope/d/f", "a//b", "../x", "d/f"} {
307		t.Run("missing "+bad, func(t *testing.T) {
308			_, err := g.ResolveRefPath(ctx, h.Name, bad)
309			assert.ErrorIs(t, err, domain.ErrNotFound)
310		})
311	}
312	_, err := g.ResolveRefPath(ctx, RepoName(t, "nope"), "main")
313	assert.ErrorIs(t, err, domain.ErrNotFound)
314}
315
316func testGetTree(t *testing.T, mk Maker) {
317	ctx := context.Background()
318	g, data := mk(t)
319	h := NewHistory(t, data)
320
321	t.Run("root at HEAD", func(t *testing.T) {
322		tree, err := g.GetTree(ctx, h.Name, domain.Revision{}, domain.TreePath{}, false)
323		require.NoError(t, err)
324		assert.Equal(t, h.Merge, tree.CommitID.String())
325		assert.Equal(t, h.Git("rev-parse", h.Merge+"^{tree}"), tree.ID.String())
326		type row struct {
327			name, path string
328			kind       domain.EntryKind
329			mode       uint32
330			size       int64
331		}
332		var got []row
333		for _, e := range tree.Entries {
334			got = append(got, row{e.Name, e.Path, e.Kind, e.Mode, e.Size})
335			assert.Nil(t, e.LastCommit)
336		}
337		assert.Equal(t, []row{
338			{"d", "d", domain.EntryDirectory, 0o040000, 0},
339			{"b.txt", "b.txt", domain.EntryFile, 0o100644, 19},
340			{"bin", "bin", domain.EntryFile, 0o100644, 2},
341			{"empty", "empty", domain.EntryFile, 0o100644, 0},
342			{"main.txt", "main.txt", domain.EntryFile, 0o100644, 2},
343			{"n.txt", "n.txt", domain.EntrySymlink, 0o120000, 5},
344			{"side.txt", "side.txt", domain.EntryFile, 0o100644, 2},
345			{"we:ird*.txt", "we:ird*.txt", domain.EntryFile, 0o100644, 1},
346		}, got)
347		assert.False(t, tree.LastCommitsOmitted)
348	})
349
350	t.Run("subdirectory with an executable", func(t *testing.T) {
351		tree, err := g.GetTree(ctx, h.Name, Rev(t, "v2"), TreePath(t, "d"), false)
352		require.NoError(t, err)
353		require.Len(t, tree.Entries, 2)
354		assert.Equal(t, "sub", tree.Entries[0].Name)
355		assert.Equal(t, "d/sub", tree.Entries[0].Path)
356		assert.Equal(t, domain.EntryExecutable, tree.Entries[1].Kind)
357		assert.Equal(t, uint32(0o100755), tree.Entries[1].Mode)
358		assert.Equal(t, "d/f", tree.Entries[1].Path)
359		assert.Equal(t, h.C2, tree.CommitID.String())
360	})
361
362	t.Run("errors", func(t *testing.T) {
363		_, err := g.GetTree(ctx, h.Name, domain.Revision{}, TreePath(t, "nope"), false)
364		assert.ErrorIs(t, err, domain.ErrNotFound)
365		_, err = g.GetTree(ctx, h.Name, domain.Revision{}, TreePath(t, "d/nope/x"), false)
366		assert.ErrorIs(t, err, domain.ErrNotFound)
367		_, err = g.GetTree(ctx, h.Name, domain.Revision{}, TreePath(t, "b.txt"), false)
368		assert.ErrorIs(t, err, domain.ErrNotDirectory)
369		_, err = g.GetTree(ctx, h.Name, Rev(t, "nope"), domain.TreePath{}, false)
370		assert.ErrorIs(t, err, domain.ErrNotFound)
371	})
372
373	t.Run("paths are literal", func(t *testing.T) {
374		tree, err := g.GetTree(ctx, h.Name, domain.Revision{}, TreePath(t, "d"), true)
375		require.NoError(t, err)
376		require.Len(t, tree.Entries, 2)
377		_, err = g.GetTree(ctx, h.Name, domain.Revision{}, TreePath(t, "*"), false)
378		assert.ErrorIs(t, err, domain.ErrNotFound)
379	})
380}
381
382func testGetTreeLastCommit(t *testing.T, mk Maker) {
383	ctx := context.Background()
384	g, data := mk(t)
385	h := NewHistory(t, data)
386
387	tree, err := g.GetTree(ctx, h.Name, Rev(t, "main"), domain.TreePath{}, true)
388	require.NoError(t, err)
389	got := map[string]string{}
390	for _, e := range tree.Entries {
391		require.NotNil(t, e.LastCommit, e.Path)
392		got[e.Path] = e.LastCommit.Summary
393		// The oracle: git log -1 for the same path.
394		assert.Equal(t, h.Git("log", "-1", "--format=%H", h.Merge, "--", e.Path), e.LastCommit.ID.String(), e.Path)
395	}
396	assert.Equal(t, "second", got["d"], "chmod of d/f is the last change of d")
397	assert.Equal(t, "second", got["b.txt"])
398	assert.Equal(t, "first", got["we:ird*.txt"])
399	assert.Equal(t, "third: type change", got["n.txt"])
400	assert.Equal(t, "side work", got["side.txt"])
401	assert.Equal(t, "Ada Lovelace", tree.Entries[0].LastCommit.Author.Name)
402}
403
404func testGetTreeLastCommitBound(t *testing.T, mk Maker) {
405	ctx := context.Background()
406	g, data := mk(t)
407	f := NewFixture(t, data, "wide")
408	n := domain.MaxLastCommitEntries + 5
409	for i := range n {
410		f.Write("f"+strings.Repeat("0", 3-len(itoa(i)))+itoa(i), "x\n")
411	}
412	f.Commit("wide")
413
414	tree, err := g.GetTree(ctx, f.Name, domain.Revision{}, domain.TreePath{}, true)
415	require.NoError(t, err)
416	require.Len(t, tree.Entries, n)
417	assert.True(t, tree.LastCommitsOmitted)
418	with := 0
419	for _, e := range tree.Entries {
420		if e.LastCommit != nil {
421			with++
422		}
423	}
424	assert.Equal(t, domain.MaxLastCommitEntries, with)
425	assert.NotNil(t, tree.Entries[0].LastCommit)
426	assert.Nil(t, tree.Entries[n-1].LastCommit)
427
428	tree, err = g.GetTree(ctx, f.Name, domain.Revision{}, domain.TreePath{}, false)
429	require.NoError(t, err)
430	assert.False(t, tree.LastCommitsOmitted)
431}
432
433func itoa(i int) string {
434	if i == 0 {
435		return "0"
436	}
437	var b []byte
438	for ; i > 0; i /= 10 {
439		b = append([]byte{byte('0' + i%10)}, b...)
440	}
441	return string(b)
442}
443
444func testListCommits(t *testing.T, mk Maker) {
445	ctx := context.Background()
446	g, data := mk(t)
447	h := NewHistory(t, data)
448
449	oracle := strings.Split(h.Git("log", "--format=%H", h.Merge), "\n")
450	require.Len(t, oracle, 6)
451
452	t.Run("all", func(t *testing.T) {
453		page, err := g.ListCommits(ctx, h.Name, ports.CommitQuery{Limit: 100})
454		require.NoError(t, err)
455		assert.Equal(t, h.Merge, page.Head.String())
456		assert.False(t, page.HasMore)
457		var ids []string
458		for _, c := range page.Commits {
459			ids = append(ids, c.ID.String())
460		}
461		assert.Equal(t, oracle, ids)
462	})
463
464	t.Run("pages", func(t *testing.T) {
465		var ids []string
466		skip := 0
467		for {
468			page, err := g.ListCommits(ctx, h.Name, ports.CommitQuery{Skip: skip, Limit: 2})
469			require.NoError(t, err)
470			for _, c := range page.Commits {
471				ids = append(ids, c.ID.String())
472			}
473			skip += len(page.Commits)
474			if !page.HasMore {
475				break
476			}
477			require.Len(t, page.Commits, 2)
478		}
479		assert.Equal(t, oracle, ids)
480	})
481
482	t.Run("path", func(t *testing.T) {
483		page, err := g.ListCommits(ctx, h.Name, ports.CommitQuery{Path: TreePath(t, "d/f"), Limit: 10})
484		require.NoError(t, err)
485		var ids []string
486		for _, c := range page.Commits {
487			ids = append(ids, c.ID.String())
488		}
489		assert.Equal(t, strings.Split(h.Git("log", "--format=%H", h.Merge, "--", "d/f"), "\n"), ids)
490		assert.Len(t, ids, 2)
491
492		page, err = g.ListCommits(ctx, h.Name, ports.CommitQuery{Path: TreePath(t, "we:ird*.txt"), Limit: 10})
493		require.NoError(t, err)
494		require.Len(t, page.Commits, 1, "paths are literal")
495		page, err = g.ListCommits(ctx, h.Name, ports.CommitQuery{Path: TreePath(t, "*"), Limit: 10})
496		require.NoError(t, err)
497		assert.Empty(t, page.Commits)
498	})
499
500	t.Run("revision", func(t *testing.T) {
501		page, err := g.ListCommits(ctx, h.Name, ports.CommitQuery{Revision: Rev(t, "v2"), Limit: 10})
502		require.NoError(t, err)
503		require.Len(t, page.Commits, 2)
504		assert.Equal(t, h.C2, page.Head.String())
505		assert.Equal(t, h.C2, page.Commits[0].ID.String())
506	})
507
508	t.Run("metadata", func(t *testing.T) {
509		page, err := g.ListCommits(ctx, h.Name, ports.CommitQuery{Revision: domain.RevisionOf(MustID(t, h.C1)), Limit: 10})
510		require.NoError(t, err)
511		require.Len(t, page.Commits, 1)
512		c := page.Commits[0]
513		assert.Empty(t, c.Parents)
514		assert.Equal(t, "first", c.Summary)
515		assert.Equal(t, "first\n\nbody of the first commit", c.Message)
516		assert.Equal(t, domain.Signature{Name: "Ada Lovelace", Email: "ada@example.org", Time: time.Date(2026, 1, 1, 12, 1, 0, 0, time.UTC)}, c.Author)
517		assert.Equal(t, "Grace Hopper", c.Committer.Name)
518		assert.Equal(t, "grace@example.org", c.Committer.Email)
519
520		page, err = g.ListCommits(ctx, h.Name, ports.CommitQuery{Limit: 1})
521		require.NoError(t, err)
522		require.Len(t, page.Commits, 1)
523		assert.Equal(t, []string{h.Git("rev-parse", h.Merge+"^1"), h.Side}, idStrings(page.Commits[0].Parents))
524	})
525
526	t.Run("bad queries", func(t *testing.T) {
527		for _, q := range []ports.CommitQuery{{Limit: 0}, {Limit: -1}, {Limit: ports.MaxCommitLimit + 1}, {Limit: 1, Skip: -1}} {
528			_, err := g.ListCommits(ctx, h.Name, q)
529			assert.ErrorIs(t, err, domain.ErrInvalidPageSize, "%+v", q)
530		}
531		_, err := g.ListCommits(ctx, h.Name, ports.CommitQuery{Revision: Rev(t, "nope"), Limit: 1})
532		assert.ErrorIs(t, err, domain.ErrNotFound)
533		_, err = g.ListCommits(ctx, RepoName(t, "nope"), ports.CommitQuery{Limit: 1})
534		assert.ErrorIs(t, err, domain.ErrNotFound)
535	})
536}
537
538// MustID parses an object id.
539func MustID(t *testing.T, s string) domain.ObjectID {
540	t.Helper()
541	id, err := domain.ParseObjectID(s)
542	require.NoError(t, err)
543	return id
544}
545
546func idStrings(ids []domain.ObjectID) []string {
547	out := make([]string, len(ids))
548	for i, id := range ids {
549		out[i] = id.String()
550	}
551	return out
552}
553
554func testOpenBlob(t *testing.T, mk Maker) {
555	ctx := context.Background()
556	g, data := mk(t)
557	h := NewHistory(t, data)
558
559	read := func(t *testing.T, r string, p string) (domain.BlobInfo, string) {
560		t.Helper()
561		info, rc, err := g.OpenBlob(ctx, h.Name, Rev(t, r), TreePath(t, p))
562		require.NoError(t, err)
563		body, err := io.ReadAll(rc)
564		require.NoError(t, err)
565		require.NoError(t, rc.Close())
566		return info, string(body)
567	}
568
569	info, body := read(t, "main", "b.txt")
570	assert.Equal(t, "one\ntwo\nthree\nfour\n", body)
571	assert.Equal(t, int64(19), info.Size)
572	assert.Equal(t, "b.txt", info.Path)
573	assert.Equal(t, uint32(0o100644), info.Mode)
574	assert.False(t, info.Binary)
575	assert.Equal(t, "text/plain", info.MediaType)
576	assert.Equal(t, h.Git("rev-parse", "main:b.txt"), info.ID.String())
577
578	info, body = read(t, "main", "bin")
579	assert.Equal(t, "\x00\x02", body)
580	assert.True(t, info.Binary)
581	assert.Equal(t, "application/octet-stream", info.MediaType)
582
583	info, body = read(t, "main", "empty")
584	assert.Empty(t, body)
585	assert.Equal(t, int64(0), info.Size)
586	assert.False(t, info.Binary)
587
588	info, body = read(t, "main", "n.txt")
589	assert.Equal(t, "b.txt", body, "a symlink's content is its target")
590	assert.Equal(t, uint32(0o120000), info.Mode)
591
592	info, _ = read(t, "main", "d/f")
593	assert.Equal(t, uint32(0o100755), info.Mode)
594
595	info, _ = read(t, "v1", "a.txt")
596	assert.Equal(t, "text/plain", info.MediaType)
597
598	for p, want := range map[string]error{"d": domain.ErrNotFile, "nope": domain.ErrNotFound, "d/nope": domain.ErrNotFound, "": domain.ErrNotFile} {
599		_, rc, err := g.OpenBlob(ctx, h.Name, domain.Revision{}, TreePath(t, p))
600		assert.ErrorIs(t, err, want, p)
601		assert.Nil(t, rc)
602	}
603	_, _, err := g.OpenBlob(ctx, h.Name, Rev(t, "nope"), TreePath(t, "b.txt"))
604	assert.ErrorIs(t, err, domain.ErrNotFound)
605}
606
607func testOpenBlobMediaTypes(t *testing.T, mk Maker) {
608	ctx := context.Background()
609	g, data := mk(t)
610	f := NewFixture(t, data, "media")
611	png := []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89")
612	f.WriteBytes("logo.png", png).
613		WriteBytes("noext", png).
614		Write("page.html", "<!doctype html><html><body>hi</body></html>\n").
615		Write("style.css", "a { color: red }\n").
616		Write("data.json", `{"a":1}`+"\n").
617		Write("notes", "plain words\n").
618		Write("main.go", "package main\n").
619		WriteBytes("blob.dat", []byte{0xff, 0xfe, 0, 1, 2}).
620		WriteBytes("fake.txt", []byte{'a', 0, 'b'}).
621		Write("latin", "caf\xe9\n")
622	f.Commit("media")
623
624	want := map[string]struct {
625		media  string
626		binary bool
627	}{
628		"logo.png":  {"image/png", true},
629		"noext":     {"image/png", true},
630		"page.html": {"text/html", false},
631		"style.css": {"text/css", false},
632		"data.json": {"application/json", false},
633		"notes":     {"text/plain", false},
634		"main.go":   {"text/plain", false},
635		"blob.dat":  {"application/octet-stream", true},
636		"fake.txt":  {"application/octet-stream", true},
637		"latin":     {"text/plain", false},
638	}
639	for p, w := range want {
640		info, rc, err := g.OpenBlob(ctx, f.Name, domain.Revision{}, TreePath(t, p))
641		require.NoError(t, err, p)
642		require.NoError(t, rc.Close())
643		assert.Equal(t, w.media, info.MediaType, p)
644		assert.Equal(t, w.binary, info.Binary, p)
645	}
646}
647
648func testBinaryRuleLooksAtFirst8000Bytes(t *testing.T, mk Maker) {
649	ctx := context.Background()
650	g, data := mk(t)
651	f := NewFixture(t, data, "sniff")
652	late := append([]byte(strings.Repeat("a", 8000)), 0) // NUL at offset 8000: not seen
653	early := append([]byte(strings.Repeat("a", 7999)), 0)
654	f.WriteBytes("late", late).WriteBytes("early", early)
655	// Over the in-memory threshold, so that the streaming path decides too.
656	f.WriteBytes("biglate", append([]byte(strings.Repeat("a", 300<<10)), 0)).
657		WriteBytes("bigearly", append([]byte(strings.Repeat("a", 7999)), append([]byte{0}, make([]byte, 300<<10)...)...))
658	f.Commit("sniff")
659
660	for p, binary := range map[string]bool{"late": false, "early": true, "biglate": false, "bigearly": true} {
661		info, rc, err := g.OpenBlob(ctx, f.Name, domain.Revision{}, TreePath(t, p))
662		require.NoError(t, err, p)
663		require.NoError(t, rc.Close())
664		assert.Equal(t, binary, info.Binary, p)
665	}
666}
667
668func testOpenBlobLargeThroughStream(t *testing.T, mk Maker) {
669	ctx := context.Background()
670	g, data := mk(t)
671	f := NewFixture(t, data, "large")
672	content := RandomBytes(1<<20+17, 7)
673	f.WriteBytes("big.bin", content).Commit("big")
674
675	info, rc, err := g.OpenBlob(ctx, f.Name, domain.Revision{}, TreePath(t, "big.bin"))
676	require.NoError(t, err)
677	assert.Equal(t, int64(len(content)), info.Size)
678	got, err := io.ReadAll(rc)
679	require.NoError(t, err)
680	assert.Equal(t, content, got)
681	require.NoError(t, rc.Close())
682	require.NoError(t, rc.Close(), "Close is idempotent")
683}
684
685func testCancelledContext(t *testing.T, mk Maker) {
686	g, data := mk(t)
687	h := NewHistory(t, data)
688
689	ctx, cancel := context.WithCancel(context.Background())
690	cancel()
691	_, err := g.ListRefs(ctx, h.Name)
692	assert.ErrorIs(t, err, context.Canceled)
693	_, err = g.ResolveRevision(ctx, h.Name, domain.Revision{})
694	assert.ErrorIs(t, err, context.Canceled)
695	_, err = g.GetCommit(ctx, h.Name, domain.Revision{}, domain.DefaultDiffLimits())
696	assert.ErrorIs(t, err, context.Canceled)
697	_, err = g.ListCommits(ctx, h.Name, ports.CommitQuery{Limit: 5})
698	assert.ErrorIs(t, err, context.Canceled)
699
700	// The adapter still works with a live context.
701	_, err = g.ResolveRevision(context.Background(), h.Name, domain.Revision{})
702	assert.NoError(t, err)
703}
704
705func testHostileRevisionAndPathStayLiteral(t *testing.T, mk Maker) {
706	ctx := context.Background()
707	g, data := mk(t)
708	h := NewHistory(t, data)
709
710	// Revisions that look like options or git revision syntax are rejected by
711	// the domain; ones that pass it are plain names for the reader.
712	for _, s := range []string{"--output=/tmp/x", "-h", "main^", "main~1", "main:b.txt", "@{u}", "HEAD@{1}"} {
713		_, err := domain.ParseRevision(s)
714		assert.ErrorIs(t, err, domain.ErrInvalidRevision, s)
715	}
716	_, err := g.ResolveRevision(ctx, h.Name, Rev(t, "HEAD.x"))
717	assert.ErrorIs(t, err, domain.ErrNotFound)
718	_, err = g.GetTree(ctx, h.Name, domain.Revision{}, TreePath(t, ":(top)d"), false)
719	assert.ErrorIs(t, err, domain.ErrNotFound)
720	var pe *domain.InvalidNameError
721	assert.False(t, errors.As(err, &pe))
722}
723
724// A full object id is an object id before it is a ref: a branch or a tag
725// named like a commit must not redirect a link to that commit.
726func testResolveRevisionObjectIDBeatsRefs(t *testing.T, mk Maker) {
727	ctx := context.Background()
728	g, data := mk(t)
729	h := NewHistory(t, data)
730
731	// Git itself warns about these refs and prefers the object id.
732	h.Git("-c", "advice.objectNameWarning=false", "branch", h.C1, h.C3)
733	h.Git("-c", "advice.objectNameWarning=false", "tag", h.C2, h.C3)
734
735	got, err := g.ResolveRevision(ctx, h.Name, Rev(t, h.C1))
736	require.NoError(t, err)
737	assert.Equal(t, h.C1, got.CommitID.String(), "the commit, not the branch of the same name")
738	assert.Empty(t, got.Ref)
739
740	got, err = g.ResolveRevision(ctx, h.Name, Rev(t, h.C2))
741	require.NoError(t, err)
742	assert.Equal(t, h.C2, got.CommitID.String(), "the commit, not the tag of the same name")
743
744	// An abbreviation is still a name first, as before.
745	h.Git("branch", "abc1234", h.C3)
746	if !strings.HasPrefix(h.C1, "abc1234") {
747		got, err = g.ResolveRevision(ctx, h.Name, Rev(t, "abc1234"))
748		require.NoError(t, err)
749		assert.Equal(t, h.C3, got.CommitID.String())
750		assert.Equal(t, "refs/heads/abc1234", got.Ref)
751	}
752
753	// The ref is used when no object has that id.
754	ghost := strings.Repeat("ab", 20)
755	h.Git("-c", "advice.objectNameWarning=false", "branch", ghost, h.C3)
756	got, err = g.ResolveRevision(ctx, h.Name, Rev(t, ghost))
757	require.NoError(t, err)
758	assert.Equal(t, h.C3, got.CommitID.String())
759	assert.Equal(t, "refs/heads/"+ghost, got.Ref)
760}
761
762func testGetTreeIsBounded(t *testing.T, mk Maker) {
763	ctx := context.Background()
764	g, data := mk(t)
765	f := NewFixture(t, data, "wide")
766	n := domain.MaxTreeEntries + 7
767	for i := range n {
768		f.Write(fmt.Sprintf("d/f%05d", i), "x")
769	}
770	f.Commit("wide")
771
772	tree, err := g.GetTree(ctx, f.Name, domain.Revision{}, TreePath(t, "d"), false)
773	require.NoError(t, err)
774	assert.True(t, tree.Truncated)
775	require.Len(t, tree.Entries, domain.MaxTreeEntries)
776	assert.Equal(t, "f00000", tree.Entries[0].Name, "the first entries in order")
777	assert.Equal(t, fmt.Sprintf("f%05d", domain.MaxTreeEntries-1), tree.Entries[domain.MaxTreeEntries-1].Name)
778
779	small, err := g.GetTree(ctx, f.Name, domain.Revision{}, domain.TreePath{}, false)
780	require.NoError(t, err)
781	assert.False(t, small.Truncated)
782}
783
784func testListPaths(t *testing.T, mk Maker) {
785	ctx := context.Background()
786	g, data := mk(t)
787	f := buildPaths(t, data)
788
789	// git ls-tree -r in order, minus the submodule and the path no address can
790	// carry.
791	var want []string
792	for _, rec := range strings.Split(strings.TrimSuffix(GitOut(t, f.Bare, "ls-tree", "-r", "-z", "HEAD"), "\x00"), "\x00") {
793		meta, p, _ := strings.Cut(rec, "\t")
794		if !strings.Contains(meta, " commit ") && !strings.Contains(p, "\t") {
795			want = append(want, p)
796		}
797	}
798	require.Equal(t, []string{
799		"a-b.txt", "a/b.txt", "a/b/c.txt", "d/e/f/deep.txt", "héllo wörld.txt", "link", "run.sh", "z.txt",
800	}, want, "the fixture")
801
802	got, err := g.ListPaths(ctx, f.Name, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes)
803	require.NoError(t, err)
804	assert.Equal(t, want, got.Paths)
805	assert.False(t, got.Truncated)
806	assert.Equal(t, GitOut(t, f.Bare, "rev-parse", "HEAD"), got.CommitID.String())
807	assert.Equal(t, GitOut(t, f.Bare, "rev-parse", "HEAD^{tree}"), got.TreeID.String())
808
809	t.Run("revisions", func(t *testing.T) {
810		for _, s := range []string{"main", "v1", "refs/tags/v1", f.Git("rev-parse", "HEAD"), f.Git("rev-parse", "HEAD")[:10]} {
811			list, err := g.ListPaths(ctx, f.Name, Rev(t, s), domain.MaxPathEntries, domain.MaxPathBytes)
812			require.NoError(t, err, s)
813			assert.Equal(t, want, list.Paths, s)
814		}
815		// The first commit has no submodule: the same files.
816		first, err := g.ListPaths(ctx, f.Name, Rev(t, f.Git("rev-parse", "HEAD~1")), domain.MaxPathEntries, domain.MaxPathBytes)
817		require.NoError(t, err)
818		assert.Equal(t, want, first.Paths)
819		assert.Equal(t, GitOut(t, f.Bare, "rev-parse", "HEAD~1^{tree}"), first.TreeID.String())
820		assert.NotEqual(t, got.TreeID, first.TreeID)
821	})
822	t.Run("not found", func(t *testing.T) {
823		for _, s := range []string{"nope", "refs/heads/nope", strings.Repeat("ab", 20), "deadbeef"} {
824			_, err := g.ListPaths(ctx, f.Name, Rev(t, s), domain.MaxPathEntries, domain.MaxPathBytes)
825			assert.ErrorIs(t, err, domain.ErrNotFound, s)
826		}
827		_, err := g.ListPaths(ctx, RepoName(t, "no-such-repo"), domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes)
828		assert.ErrorIs(t, err, domain.ErrNotFound)
829	})
830	t.Run("empty repository", func(t *testing.T) {
831		e := NewFixture(t, data, "empty-paths")
832		_, err := g.ListPaths(ctx, e.Name, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes)
833		assert.ErrorIs(t, err, domain.ErrNotFound)
834	})
835	t.Run("bounds", func(t *testing.T) {
836		list, err := g.ListPaths(ctx, f.Name, domain.Revision{}, 3, domain.MaxPathBytes)
837		require.NoError(t, err)
838		assert.Equal(t, want[:3], list.Paths)
839		assert.True(t, list.Truncated)
840		assert.Equal(t, got.CommitID, list.CommitID)
841		assert.Equal(t, got.TreeID, list.TreeID)
842
843		list, err = g.ListPaths(ctx, f.Name, domain.Revision{}, len(want), domain.MaxPathBytes)
844		require.NoError(t, err)
845		assert.Equal(t, want, list.Paths)
846		assert.False(t, list.Truncated, "exactly maxPaths paths is not truncated")
847
848		sum := len(want[0]) + len(want[1]) + len(want[2])
849		list, err = g.ListPaths(ctx, f.Name, domain.Revision{}, domain.MaxPathEntries, sum-1)
850		require.NoError(t, err)
851		assert.Equal(t, want[:2], list.Paths)
852		assert.True(t, list.Truncated)
853
854		list, err = g.ListPaths(ctx, f.Name, domain.Revision{}, domain.MaxPathEntries, sum)
855		require.NoError(t, err)
856		assert.Equal(t, want[:3], list.Paths, "bytes up to maxBytes fit")
857		assert.True(t, list.Truncated)
858
859		total := 0
860		for _, p := range want {
861			total += len(p)
862		}
863		list, err = g.ListPaths(ctx, f.Name, domain.Revision{}, domain.MaxPathEntries, total)
864		require.NoError(t, err)
865		assert.Equal(t, want, list.Paths)
866		assert.False(t, list.Truncated, "exactly maxBytes bytes is not truncated")
867	})
868	t.Run("cancelled context", func(t *testing.T) {
869		ctx, cancel := context.WithCancel(ctx)
870		cancel()
871		_, err := g.ListPaths(ctx, f.Name, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes)
872		assert.ErrorIs(t, err, context.Canceled)
873	})
874	t.Run("a large tree", func(t *testing.T) {
875		w := NewFixture(t, data, "paths-wide")
876		for i := range 300 {
877			w.Write(fmt.Sprintf("d%02d/e%d/f%03d.txt", i%17, i%5, i), "x")
878		}
879		w.Commit("wide")
880		order := lines(GitOut(t, w.Bare, "ls-tree", "-r", "--name-only", "HEAD"))
881		list, err := g.ListPaths(ctx, w.Name, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes)
882		require.NoError(t, err)
883		assert.Equal(t, order, list.Paths)
884		list, err = g.ListPaths(ctx, w.Name, domain.Revision{}, 100, domain.MaxPathBytes)
885		require.NoError(t, err)
886		assert.Equal(t, order[:100], list.Paths)
887		assert.True(t, list.Truncated)
888	})
889}
890
891// A tree entry of mode 160000 (a submodule) names a commit of another
892// repository. When its id is that of a tree this repository has, it still is no
893// directory of it: both readers answer not found, and the same id as a real
894// directory lists.
895func testGetTreeRefusesAGitlinkToATree(t *testing.T, mk Maker) {
896	ctx := context.Background()
897	g, data := mk(t)
898	f := NewFixture(t, data, "gitlink")
899	f.Write("README", "r\n")
900	f.Write("dir/f.txt", "f\n")
901	f.Commit("dir")
902	dirID := f.Git("rev-parse", "HEAD:dir")
903	raw := func(entries ...[3]string) string {
904		var b strings.Builder
905		for _, e := range entries {
906			id, err := hex.DecodeString(e[2])
907			require.NoError(t, err)
908			b.WriteString(e[0] + " " + e[1] + "\x00")
909			b.Write(id)
910		}
911		return f.WriteObject("tree", b.String())
912	}
913	both := raw([3]string{"40000", "asdir", dirID}, [3]string{"160000", "sub", dirID}) // the same id, two modes
914	commit := f.Git("commit-tree", both, "-m", "gitlink")
915	f.Git("update-ref", "refs/heads/main", commit)
916
917	tree, err := g.GetTree(ctx, f.Name, Rev(t, commit), TreePath(t, "asdir"), false)
918	require.NoError(t, err)
919	require.Len(t, tree.Entries, 1)
920	assert.Equal(t, "f.txt", tree.Entries[0].Name)
921	_, err = g.GetTree(ctx, f.Name, Rev(t, commit), TreePath(t, "sub"), false)
922	assert.ErrorIs(t, err, domain.ErrNotFound, "a gitlink is no directory, whatever it names")
923}

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 3

Tags 43

↑↓ moveenter openesc close