v0.23.0 333c710

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

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 44

↑↓ moveenter openesc close