v0.17.0 ab8e178

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

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