v0.7.0 45bfc5b

conformance.go RawHistoryFolder

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

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