v0.5.0 bfab294

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

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