v0.1.0 3f267b5

read_test.go RawHistoryFolder

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

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 45

↑↓ moveenter openesc close