detached 83e495f

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

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 4

Tags 45

↑↓ moveenter openesc close