v0.23.0 333c710

browse_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase_test
4
5import (
6	"context"
7	"encoding/base64"
8	"errors"
9	"fmt"
10	"io"
11	"runtime"
12	"strconv"
13	"strings"
14	"sync"
15	"sync/atomic"
16	"testing"
17	"time"
18
19	"github.com/stretchr/testify/assert"
20	"github.com/stretchr/testify/mock"
21	"github.com/stretchr/testify/require"
22
23	"bnema.dev/ermite/internal/domain"
24	"bnema.dev/ermite/internal/ports"
25	"bnema.dev/ermite/internal/ports/mocks"
26	"bnema.dev/ermite/internal/usecase"
27)
28
29type browseFixture struct {
30	meta   *mocks.RepoMetaStore
31	reader *mocks.RepoReader
32	browse *usecase.Browse
33}
34
35func newBrowseFixture(t *testing.T) *browseFixture {
36	t.Helper()
37	f := &browseFixture{meta: mocks.NewRepoMetaStore(t), reader: mocks.NewRepoReader(t)}
38	f.browse = usecase.NewBrowse(f.meta, f.reader)
39	return f
40}
41
42func oid(c string) domain.ObjectID {
43	id, err := domain.ParseObjectID(strings.Repeat(c, 40))
44	if err != nil {
45		panic(err)
46	}
47	return id
48}
49
50// browseOps are the read use cases, each as a call that returns its error.
51// The reader expectation of each is what must (not) run.
52type browseOp struct {
53	name string
54	call func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error
55	// expect registers the reader call a permitted caller makes.
56	expect func(r *mocks.RepoReader)
57}
58
59func browseOps() []browseOp {
60	root := domain.TreePath{}
61	return []browseOp{
62		{"ListRefs", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
63			_, err := b.ListRefs(ctx, p, repo, ports.ListRefsParams{})
64			return err
65		}, func(r *mocks.RepoReader) { r.EXPECT().ListRefs(mock.Anything, mock.Anything).Return(nil, nil) }},
66		{"ResolveRevision", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
67			_, err := b.ResolveRevision(ctx, p, repo, domain.Revision{})
68			return err
69		}, func(r *mocks.RepoReader) {
70			r.EXPECT().ResolveRevision(mock.Anything, mock.Anything, mock.Anything).Return(domain.ResolvedRevision{}, nil)
71		}},
72		{"ResolveRefPath", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
73			_, err := b.ResolveRefPath(ctx, p, repo, "main/x")
74			return err
75		}, func(r *mocks.RepoReader) {
76			r.EXPECT().ResolveRefPath(mock.Anything, mock.Anything, "main/x").Return(domain.RefPath{}, nil)
77		}},
78		{"GetTree", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
79			_, err := b.GetTree(ctx, p, repo, domain.Revision{}, root, true)
80			return err
81		}, func(r *mocks.RepoReader) {
82			r.EXPECT().GetTree(mock.Anything, mock.Anything, mock.Anything, mock.Anything, true).Return(domain.Tree{}, nil)
83		}},
84		{"ListPaths", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
85			_, err := b.ListPaths(ctx, p, repo, domain.Revision{})
86			return err
87		}, func(r *mocks.RepoReader) {
88			r.EXPECT().ListPaths(mock.Anything, mock.Anything, mock.Anything, domain.MaxPathEntries, domain.MaxPathBytes).Return(domain.PathList{}, nil)
89		}},
90		{"OpenBlob", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
91			_, rc, err := b.OpenBlob(ctx, p, repo, domain.Revision{}, root)
92			if rc != nil {
93				_ = rc.Close()
94			}
95			return err
96		}, func(r *mocks.RepoReader) {
97			r.EXPECT().OpenBlob(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
98				Return(domain.BlobInfo{}, io.NopCloser(strings.NewReader("")), nil)
99		}},
100		{"ListCommits", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
101			_, err := b.ListCommits(ctx, p, repo, ports.ListCommitsParams{})
102			return err
103		}, func(r *mocks.RepoReader) {
104			r.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything).Return(ports.CommitPage{}, nil)
105		}},
106		{"GetCommit", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
107			_, err := b.GetCommit(ctx, p, repo, domain.Revision{})
108			return err
109		}, func(r *mocks.RepoReader) {
110			r.EXPECT().GetCommit(mock.Anything, mock.Anything, mock.Anything, domain.DefaultDiffLimits()).Return(domain.CommitDetail{}, nil)
111		}},
112		{"RepoStats", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
113			_, err := b.RepoStats(ctx, p, repo, oid("a"), time.Now())
114			return err
115		}, func(r *mocks.RepoReader) {
116			r.EXPECT().CountCommits(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(1, nil)
117			r.EXPECT().LatestTag(mock.Anything, mock.Anything).Return(domain.TagInfo{}, false, nil)
118		}},
119	}
120}
121
122// RepoStats reads Git once per state of the repository (head and last push)
123// and per 10 minutes; concurrent callers share the read, and a failure is
124// not kept.
125func TestRepoStatsCache(t *testing.T) {
126	t.Parallel()
127	ctx := context.Background()
128	f := newBrowseFixture(t)
129	repo := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic, CreatedAt: time.Unix(100, 0), PushedAt: time.Unix(200, 0)}
130	var mu sync.Mutex
131	f.meta.EXPECT().Get(mock.Anything, repo.Name).RunAndReturn(func(context.Context, domain.RepoName) (domain.Repo, error) {
132		mu.Lock()
133		defer mu.Unlock()
134		return repo, nil
135	})
136	now := time.Date(2026, 10, 18, 0, 0, 0, 0, time.UTC)
137	var reads atomic.Int32
138	gate := make(chan struct{})
139	f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, mock.Anything, time.Time{}).RunAndReturn(
140		func(context.Context, domain.RepoName, domain.ObjectID, time.Time) (int, error) {
141			reads.Add(1)
142			<-gate
143			return 900, nil
144		})
145	f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, mock.Anything, mock.MatchedBy(func(s time.Time) bool { return !s.IsZero() })).Return(12, nil)
146	tag := domain.TagInfo{Name: "v1", At: now, CommitID: oid("c")}
147	f.reader.EXPECT().LatestTag(mock.Anything, repo.Name).Return(tag, true, nil)
148
149	stats := func(head string, at time.Time) ports.RepoStats {
150		st, err := f.browse.RepoStats(ctx, domain.Anonymous(), repo.Name, oid(head), at)
151		require.NoError(t, err)
152		return st
153	}
154
155	// Eight concurrent requests: one read.
156	var wg sync.WaitGroup
157	for range 8 {
158		wg.Go(func() {
159			st := stats("a", now)
160			assert.Equal(t, 900, st.Commits)
161			assert.Equal(t, 12, st.Recent)
162			assert.Equal(t, tag, *st.LatestTag)
163		})
164	}
165	for reads.Load() == 0 {
166		runtime.Gosched()
167	}
168	close(gate)
169	wg.Wait()
170	assert.EqualValues(t, 1, reads.Load(), "concurrent callers share the read")
171
172	stats("a", now.Add(9*time.Minute))
173	assert.EqualValues(t, 1, reads.Load(), "served from the cache")
174	stats("a", now.Add(10*time.Minute))
175	assert.EqualValues(t, 2, reads.Load(), "expired")
176	stats("b", now.Add(10*time.Minute))
177	assert.EqualValues(t, 3, reads.Load(), "another head")
178	mu.Lock()
179	repo.PushedAt = time.Unix(300, 0) // a push, a tag-only one included
180	mu.Unlock()
181	stats("b", now.Add(10*time.Minute))
182	assert.EqualValues(t, 4, reads.Load(), "a push forgets the figures")
183}
184
185func TestRepoStatsDoesNotKeepAFailure(t *testing.T) {
186	t.Parallel()
187	ctx := context.Background()
188	f := newBrowseFixture(t)
189	repo := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
190	f.meta.EXPECT().Get(ctx, repo.Name).Return(repo, nil)
191	now := time.Now()
192	f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, oid("a"), time.Time{}).Return(0, errors.New("boom")).Once()
193	_, err := f.browse.RepoStats(ctx, domain.Anonymous(), repo.Name, oid("a"), now)
194	require.Error(t, err)
195	f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, oid("a"), mock.Anything).Return(3, nil).Twice()
196	f.reader.EXPECT().LatestTag(mock.Anything, repo.Name).Return(domain.TagInfo{}, false, nil).Once()
197	st, err := f.browse.RepoStats(ctx, domain.Anonymous(), repo.Name, oid("a"), now)
198	require.NoError(t, err)
199	assert.Equal(t, 3, st.Commits)
200	assert.Nil(t, st.LatestTag)
201}
202
203// TestBrowseAccess is the access rule over every read use case and every
204// principal: anonymous sees public repositories only, a private one answers
205// not found; stored tokens need repo:read on the repository; and the Git
206// reader is never reached when access is refused.
207func TestBrowseAccess(t *testing.T) {
208	t.Parallel()
209	ctx := context.Background()
210	pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
211	priv := domain.Repo{Name: name(t, "priv"), Visibility: domain.VisibilityPrivate}
212
213	principals := []struct {
214		name string
215		p    domain.Principal
216		// who may read which repository
217		pub, priv bool
218	}{
219		{"anonymous", domain.Anonymous(), true, false},
220		{"root", domain.Root(), true, true},
221		{"token repo:read", tokenPrincipal(domain.ScopeRepoRead), true, true},
222		{"token repo:read on priv", tokenPrincipal(domain.ScopeRepoRead, "priv"), true, true},
223		{"token repo:read on another repo", tokenPrincipal(domain.ScopeRepoRead, "other"), true, false},
224		{"token repo:write only", tokenPrincipal(domain.ScopeRepoWrite), true, false},
225		{"token repo:admin only", tokenPrincipal(domain.ScopeRepoAdmin, "priv"), true, false},
226		{"token token:admin only", tokenPrincipal(domain.ScopeTokenAdmin), true, false},
227	}
228	for _, op := range browseOps() {
229		for _, pr := range principals {
230			for _, repo := range []domain.Repo{pub, priv} {
231				t.Run(op.name+"/"+pr.name+"/"+repo.Name.String(), func(t *testing.T) {
232					t.Parallel()
233					f := newBrowseFixture(t)
234					f.meta.EXPECT().Get(ctx, repo.Name).Return(repo, nil)
235					allowed := pr.pub
236					if repo.Visibility == domain.VisibilityPrivate {
237						allowed = pr.priv
238					}
239					if allowed {
240						op.expect(f.reader)
241					}
242					err := op.call(f.browse, ctx, pr.p, repo.Name)
243					if allowed {
244						assert.NoError(t, err)
245					} else {
246						assert.ErrorIs(t, err, domain.ErrNotFound)
247						assert.NotErrorIs(t, err, domain.ErrForbidden)
248					}
249				})
250			}
251		}
252		t.Run(op.name+"/missing repository", func(t *testing.T) {
253			t.Parallel()
254			f := newBrowseFixture(t)
255			f.meta.EXPECT().Get(ctx, name(t, "nope")).Return(domain.Repo{}, domain.ErrNotFound)
256			err := op.call(f.browse, ctx, domain.Root(), name(t, "nope"))
257			assert.ErrorIs(t, err, domain.ErrNotFound)
258		})
259		t.Run(op.name+"/zero name", func(t *testing.T) {
260			t.Parallel()
261			f := newBrowseFixture(t)
262			err := op.call(f.browse, ctx, domain.Root(), domain.RepoName{})
263			assert.ErrorIs(t, err, domain.ErrInvalidName)
264		})
265	}
266}
267
268func TestBrowseHidesPrivateOnReaderErrors(t *testing.T) {
269	t.Parallel()
270	// A reader error is passed through for a caller who may read.
271	ctx := context.Background()
272	f := newBrowseFixture(t)
273	pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
274	f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil)
275	f.reader.EXPECT().GetTree(ctx, pub.Name, domain.Revision{}, domain.TreePath{}, false).Return(domain.Tree{}, domain.ErrNotDirectory)
276	_, err := f.browse.GetTree(ctx, domain.Anonymous(), pub.Name, domain.Revision{}, domain.TreePath{}, false)
277	assert.ErrorIs(t, err, domain.ErrNotDirectory)
278}
279
280func ref(kind domain.RefKind, name string) domain.Ref {
281	return domain.Ref{Name: name, Kind: kind, ShortName: name}
282}
283
284func TestListRefsPagingAndKinds(t *testing.T) {
285	t.Parallel()
286	ctx := context.Background()
287	pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
288	all := []domain.Ref{
289		ref(domain.RefKindHead, "HEAD"),
290		ref(domain.RefKindBranch, "refs/heads/a"),
291		ref(domain.RefKindBranch, "refs/heads/b"),
292		ref(domain.RefKindBranch, "refs/heads/c"),
293		ref(domain.RefKindTag, "refs/tags/v1"),
294		ref(domain.RefKindTag, "refs/tags/v2"),
295	}
296	names := func(refs []domain.Ref) []string {
297		var out []string
298		for _, r := range refs {
299			out = append(out, r.Name)
300		}
301		return out
302	}
303	setup := func(t *testing.T, refs []domain.Ref) *usecase.Browse {
304		f := newBrowseFixture(t)
305		f.meta.EXPECT().Get(mock.Anything, pub.Name).Return(pub, nil)
306		f.reader.EXPECT().ListRefs(mock.Anything, pub.Name).Return(refs, nil)
307		return f.browse
308	}
309
310	t.Run("pages follow each other without gap or repeat", func(t *testing.T) {
311		b := setup(t, all)
312		var got []string
313		tok := ""
314		for pages := 0; ; pages++ {
315			require.Less(t, pages, 10)
316			page, err := b.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{PageSize: 2, PageToken: tok})
317			require.NoError(t, err)
318			got = append(got, names(page.Refs)...)
319			if tok = page.NextPageToken; tok == "" {
320				break
321			}
322		}
323		assert.Equal(t, names(all), got)
324	})
325
326	t.Run("a ref that appears between pages does not shift the others", func(t *testing.T) {
327		b := setup(t, all[:4])
328		page, err := b.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{PageSize: 2})
329		require.NoError(t, err)
330		require.Equal(t, []string{"HEAD", "refs/heads/a"}, names(page.Refs))
331
332		b2 := setup(t, append([]domain.Ref{all[0], ref(domain.RefKindBranch, "refs/heads/0new")}, all[1:4]...))
333		next, err := b2.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{PageSize: 2, PageToken: page.NextPageToken})
334		require.NoError(t, err)
335		assert.Equal(t, []string{"refs/heads/b", "refs/heads/c"}, names(next.Refs))
336	})
337
338	t.Run("kinds filter", func(t *testing.T) {
339		b := setup(t, all)
340		page, err := b.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{Kinds: []domain.RefKind{domain.RefKindTag}})
341		require.NoError(t, err)
342		assert.Equal(t, []string{"refs/tags/v1", "refs/tags/v2"}, names(page.Refs))
343		assert.Empty(t, page.NextPageToken)
344	})
345
346	t.Run("default and maximum page size", func(t *testing.T) {
347		var many []domain.Ref
348		many = append(many, ref(domain.RefKindHead, "HEAD"))
349		for i := range usecase.MaxRefPageSize + 100 {
350			many = append(many, ref(domain.RefKindTag, fmt.Sprintf("refs/tags/t%04d", i)))
351		}
352		b := setup(t, many)
353		page, err := b.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{})
354		require.NoError(t, err)
355		assert.Len(t, page.Refs, usecase.DefaultPageSize)
356		assert.NotEmpty(t, page.NextPageToken)
357
358		b = setup(t, many)
359		page, err = b.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{PageSize: 100000})
360		require.NoError(t, err)
361		assert.Len(t, page.Refs, usecase.MaxRefPageSize, "a page holds up to MaxRefPageSize refs")
362		assert.NotEmpty(t, page.NextPageToken)
363	})
364
365	t.Run("bad input never reaches the reader", func(t *testing.T) {
366		t.Parallel()
367		f := newBrowseFixture(t)
368		for _, params := range []ports.ListRefsParams{
369			{PageSize: -1},
370			{Kinds: []domain.RefKind{"nope"}},
371			{PageToken: "!!!"},
372			{PageToken: "Y2E6eA"}, // a token of another kind of listing
373		} {
374			_, err := f.browse.ListRefs(ctx, domain.Anonymous(), pub.Name, params)
375			assert.Error(t, err, "%+v", params)
376		}
377		_, err := f.browse.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{PageSize: -1})
378		assert.ErrorIs(t, err, domain.ErrInvalidPageSize)
379		_, err = f.browse.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{Kinds: []domain.RefKind{"x"}})
380		assert.ErrorIs(t, err, domain.ErrInvalidRefKind)
381		_, err = f.browse.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{PageToken: "!!!"})
382		assert.ErrorIs(t, err, domain.ErrInvalidPageToken)
383	})
384}
385
386func TestListCommitsPaging(t *testing.T) {
387	t.Parallel()
388	ctx := context.Background()
389	pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
390	p := mustPath(t, "d/f")
391
392	t.Run("first page asks for one more than the page and returns a token", func(t *testing.T) {
393		t.Parallel()
394		f := newBrowseFixture(t)
395		f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil)
396		f.reader.EXPECT().ListCommits(ctx, pub.Name, ports.CommitQuery{Path: p, Limit: 10}).
397			Return(ports.CommitPage{Head: oid("a"), Commits: []domain.Commit{{ID: oid("b")}}, HasMore: true}, nil)
398		page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{Path: p, PageSize: 10})
399		require.NoError(t, err)
400		require.Len(t, page.Commits, 1)
401		require.NotEmpty(t, page.NextPageToken)
402
403		// The next page starts from the pinned head, after the commits read.
404		f.reader.EXPECT().ListCommits(ctx, pub.Name, ports.CommitQuery{
405			Revision: domain.RevisionOf(oid("a")), Path: p, Skip: 1, Limit: 10,
406		}).Return(ports.CommitPage{Head: oid("a"), Commits: []domain.Commit{{ID: oid("c")}}}, nil)
407		f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil)
408		// The revision of the request is ignored once there is a token.
409		rev, _ := domain.ParseRevision("moved")
410		next, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{
411			Revision: rev, Path: p, PageSize: 10, PageToken: page.NextPageToken,
412		})
413		require.NoError(t, err)
414		assert.Equal(t, oid("c"), next.Commits[0].ID)
415		assert.Empty(t, next.NextPageToken)
416	})
417
418	t.Run("a token is bound to its path and must be well formed", func(t *testing.T) {
419		t.Parallel()
420		f := newBrowseFixture(t)
421		f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil)
422		f.reader.EXPECT().ListCommits(ctx, pub.Name, mock.Anything).
423			Return(ports.CommitPage{Head: oid("a"), Commits: []domain.Commit{{ID: oid("b")}}, HasMore: true}, nil)
424		page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{Path: p})
425		require.NoError(t, err)
426
427		for _, params := range []ports.ListCommitsParams{
428			{PageToken: page.NextPageToken},                             // no path
429			{Path: mustPath(t, "other"), PageToken: page.NextPageToken}, // another path
430			{Path: p, PageToken: "garbage"},
431			{Path: p, PageToken: "cjE6eDp5OnQ"}, // c1:x:y:t is not a token
432			{Path: p, PageToken: strings.Repeat("A", 2000)},
433		} {
434			_, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, params)
435			assert.ErrorIs(t, err, domain.ErrInvalidPageToken, "%+v", params)
436		}
437	})
438
439	t.Run("a token cannot skip deeper than the cap", func(t *testing.T) {
440		t.Parallel()
441		f := newBrowseFixture(t)
442		f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil).Maybe()
443		f.reader.EXPECT().ListCommits(ctx, pub.Name, mock.Anything).
444			Return(ports.CommitPage{Head: oid("a"), Commits: []domain.Commit{{ID: oid("b")}}, HasMore: true}, nil).Once()
445		page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{Path: p})
446		require.NoError(t, err)
447		raw, err := base64.RawURLEncoding.DecodeString(page.NextPageToken)
448		require.NoError(t, err)
449		parts := strings.Split(string(raw), ":") // c1:<head>:<skip>:<path digest>
450		require.Len(t, parts, 4)
451		withSkip := func(n int) string {
452			parts[2] = strconv.Itoa(n)
453			return base64.RawURLEncoding.EncodeToString([]byte(strings.Join(parts, ":")))
454		}
455
456		f.reader.EXPECT().ListCommits(ctx, pub.Name, mock.MatchedBy(func(q ports.CommitQuery) bool { return q.Skip == usecase.MaxCommitSkip })).
457			Return(ports.CommitPage{Head: oid("a")}, nil).Once()
458		_, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{Path: p, PageToken: withSkip(usecase.MaxCommitSkip)})
459		require.NoError(t, err, "the cap itself is allowed")
460		_, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{Path: p, PageToken: withSkip(usecase.MaxCommitSkip + 1)})
461		assert.ErrorIs(t, err, domain.ErrInvalidPageToken)
462	})
463
464	t.Run("page size", func(t *testing.T) {
465		t.Parallel()
466		f := newBrowseFixture(t)
467		f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil).Times(2)
468		f.reader.EXPECT().ListCommits(ctx, pub.Name, ports.CommitQuery{Limit: usecase.DefaultPageSize}).Return(ports.CommitPage{}, nil)
469		f.reader.EXPECT().ListCommits(ctx, pub.Name, ports.CommitQuery{Limit: 100}).Return(ports.CommitPage{}, nil)
470		_, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{})
471		require.NoError(t, err)
472		_, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{PageSize: 5000})
473		require.NoError(t, err)
474		_, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{PageSize: -3})
475		assert.ErrorIs(t, err, domain.ErrInvalidPageSize)
476	})
477
478	t.Run("the token of a private repository does not open it", func(t *testing.T) {
479		priv := domain.Repo{Name: name(t, "priv"), Visibility: domain.VisibilityPrivate}
480		f := newBrowseFixture(t)
481		f.meta.EXPECT().Get(ctx, priv.Name).Return(priv, nil)
482		f.reader.EXPECT().ListCommits(ctx, priv.Name, mock.Anything).
483			Return(ports.CommitPage{Head: oid("a"), Commits: []domain.Commit{{ID: oid("b")}}, HasMore: true}, nil)
484		page, err := f.browse.ListCommits(ctx, domain.Root(), priv.Name, ports.ListCommitsParams{})
485		require.NoError(t, err)
486		f.meta.EXPECT().Get(ctx, priv.Name).Return(priv, nil)
487		_, err = f.browse.ListCommits(ctx, domain.Anonymous(), priv.Name, ports.ListCommitsParams{PageToken: page.NextPageToken})
488		assert.ErrorIs(t, err, domain.ErrNotFound)
489	})
490}
491
492func mustPath(t *testing.T, s string) domain.TreePath {
493	t.Helper()
494	p, err := domain.ParseTreePath(s)
495	require.NoError(t, err)
496	return p
497}

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