v0.25.0 4492065

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).Return(1, nil)
117			r.EXPECT().CommitTimes(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, false, nil)
118			r.EXPECT().LatestTag(mock.Anything, mock.Anything).Return(domain.TagInfo{}, false, nil)
119		}},
120	}
121}
122
123// RepoStats reads Git once per state of the repository (head and last push)
124// and per 10 minutes; concurrent callers share the read, and a failure is
125// not kept.
126func TestRepoStatsCache(t *testing.T) {
127	t.Parallel()
128	ctx := context.Background()
129	f := newBrowseFixture(t)
130	repo := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic, CreatedAt: time.Unix(100, 0), PushedAt: time.Unix(200, 0)}
131	var mu sync.Mutex
132	f.meta.EXPECT().Get(mock.Anything, repo.Name).RunAndReturn(func(context.Context, domain.RepoName) (domain.Repo, error) {
133		mu.Lock()
134		defer mu.Unlock()
135		return repo, nil
136	})
137	now := time.Date(2026, 10, 18, 0, 0, 0, 0, time.UTC)
138	var reads atomic.Int32
139	gate := make(chan struct{})
140	f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, mock.Anything).RunAndReturn(
141		func(context.Context, domain.RepoName, domain.ObjectID) (int, error) {
142			reads.Add(1)
143			<-gate
144			return 900, nil
145		})
146	recent := make([]time.Time, 12)
147	for i := range recent {
148		recent[i] = now.Add(-time.Duration(i) * time.Hour)
149	}
150	f.reader.EXPECT().CommitTimes(mock.Anything, repo.Name, mock.Anything, mock.Anything).Return(recent, false, nil)
151	tag := domain.TagInfo{Name: "v1", At: now, CommitID: oid("c")}
152	f.reader.EXPECT().LatestTag(mock.Anything, repo.Name).Return(tag, true, nil)
153
154	stats := func(head string, at time.Time) ports.RepoStats {
155		st, err := f.browse.RepoStats(ctx, domain.Anonymous(), repo.Name, oid(head), at)
156		require.NoError(t, err)
157		return st
158	}
159
160	// Eight concurrent requests: one read.
161	var wg sync.WaitGroup
162	for range 8 {
163		wg.Go(func() {
164			st := stats("a", now)
165			assert.Equal(t, 900, st.Commits)
166			assert.Equal(t, 12, st.Recent)
167			assert.Equal(t, tag, *st.LatestTag)
168			require.Len(t, st.Activity, ports.ActivityDays)
169			assert.Equal(t, 12, st.Activity[ports.ActivityDays-1], "all in the last day")
170		})
171	}
172	for reads.Load() == 0 {
173		runtime.Gosched()
174	}
175	close(gate)
176	wg.Wait()
177	assert.EqualValues(t, 1, reads.Load(), "concurrent callers share the read")
178
179	stats("a", now.Add(9*time.Minute))
180	assert.EqualValues(t, 1, reads.Load(), "served from the cache")
181	stats("a", now.Add(10*time.Minute))
182	assert.EqualValues(t, 2, reads.Load(), "expired")
183	stats("b", now.Add(10*time.Minute))
184	assert.EqualValues(t, 3, reads.Load(), "another head")
185	mu.Lock()
186	repo.PushedAt = time.Unix(300, 0) // a push, a tag-only one included
187	mu.Unlock()
188	stats("b", now.Add(10*time.Minute))
189	assert.EqualValues(t, 4, reads.Load(), "a push forgets the figures")
190}
191
192// The activity spreads the commits over the last days, oldest first, and
193// the recent count covers 30 days.
194func TestRepoStatsActivity(t *testing.T) {
195	t.Parallel()
196	ctx := context.Background()
197	f := newBrowseFixture(t)
198	repo := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
199	f.meta.EXPECT().Get(ctx, repo.Name).Return(repo, nil)
200	now := time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC)
201	day := 24 * time.Hour
202	times := []time.Time{
203		now.Add(time.Hour),    // in the future: the last day
204		now.Add(-time.Minute), // the last day
205		now.Add(-day - 1),     // the day before
206		now.Add(-29 * day),    // recent, day 29 ago
207		now.Add(-31 * day),    // not recent
208		now.Add(-89*day - 1),  // the first day of the span
209	}
210	f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, oid("a")).Return(100, nil)
211	f.reader.EXPECT().CommitTimes(mock.Anything, repo.Name, oid("a"), now.Add(-ports.ActivityDays*day)).Return(times, false, nil)
212	f.reader.EXPECT().LatestTag(mock.Anything, repo.Name).Return(domain.TagInfo{}, false, nil)
213	st, err := f.browse.RepoStats(ctx, domain.Anonymous(), repo.Name, oid("a"), now)
214	require.NoError(t, err)
215	assert.Equal(t, 4, st.Recent)
216	last := ports.ActivityDays - 1
217	assert.Equal(t, 2, st.Activity[last])
218	assert.Equal(t, 1, st.Activity[last-1])
219	assert.Equal(t, 1, st.Activity[last-29])
220	assert.Equal(t, 1, st.Activity[last-31])
221	assert.Equal(t, 1, st.Activity[0])
222	sum := 0
223	for _, n := range st.Activity {
224		sum += n
225	}
226	assert.Equal(t, 6, sum)
227	assert.False(t, st.Partial)
228}
229
230// The days are 24 hours whatever the zone of now: across a change of
231// daylight saving time, the walk and the bins agree and no hour is lost.
232func TestRepoStatsActivityAcrossDST(t *testing.T) {
233	t.Parallel()
234	paris, err := time.LoadLocation("Europe/Paris")
235	require.NoError(t, err)
236	ctx := context.Background()
237	f := newBrowseFixture(t)
238	repo := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
239	f.meta.EXPECT().Get(ctx, repo.Name).Return(repo, nil)
240	// 90 days of 24 hours before this noon cross the spring change (March 29).
241	now := time.Date(2026, 4, 1, 12, 0, 0, 0, paris)
242	day := 24 * time.Hour
243	since := now.Add(-ports.ActivityDays * day)
244	require.NotEqual(t, now.AddDate(0, 0, -ports.ActivityDays), since, "the calendar would differ by an hour")
245	times := []time.Time{since.Add(time.Minute), now.Add(-30*day + time.Minute)}
246	f.reader.EXPECT().CommitTimes(mock.Anything, repo.Name, oid("a"), since).Return(times, true, nil)
247	f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, oid("a")).Return(200_000, nil)
248	f.reader.EXPECT().LatestTag(mock.Anything, repo.Name).Return(domain.TagInfo{}, false, nil)
249	st, err := f.browse.RepoStats(ctx, domain.Anonymous(), repo.Name, oid("a"), now)
250	require.NoError(t, err)
251	assert.Equal(t, 1, st.Activity[0], "the start of the span is in the first day")
252	assert.Equal(t, 1, st.Recent, "30 days of 24 hours")
253	assert.True(t, st.Partial, "the walk was truncated")
254}
255
256func TestRepoStatsDoesNotKeepAFailure(t *testing.T) {
257	t.Parallel()
258	ctx := context.Background()
259	f := newBrowseFixture(t)
260	repo := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
261	f.meta.EXPECT().Get(ctx, repo.Name).Return(repo, nil)
262	now := time.Now()
263	f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, oid("a")).Return(0, errors.New("boom")).Once()
264	_, err := f.browse.RepoStats(ctx, domain.Anonymous(), repo.Name, oid("a"), now)
265	require.Error(t, err)
266	f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, oid("a")).Return(3, nil).Once()
267	f.reader.EXPECT().CommitTimes(mock.Anything, repo.Name, oid("a"), mock.Anything).Return([]time.Time{now}, false, nil).Once()
268	f.reader.EXPECT().LatestTag(mock.Anything, repo.Name).Return(domain.TagInfo{}, false, nil).Once()
269	st, err := f.browse.RepoStats(ctx, domain.Anonymous(), repo.Name, oid("a"), now)
270	require.NoError(t, err)
271	assert.Equal(t, 3, st.Commits)
272	assert.Nil(t, st.LatestTag)
273}
274
275// TestBrowseAccess is the access rule over every read use case and every
276// principal: anonymous sees public repositories only, a private one answers
277// not found; stored tokens need repo:read on the repository; and the Git
278// reader is never reached when access is refused.
279func TestBrowseAccess(t *testing.T) {
280	t.Parallel()
281	ctx := context.Background()
282	pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
283	priv := domain.Repo{Name: name(t, "priv"), Visibility: domain.VisibilityPrivate}
284
285	principals := []struct {
286		name string
287		p    domain.Principal
288		// who may read which repository
289		pub, priv bool
290	}{
291		{"anonymous", domain.Anonymous(), true, false},
292		{"root", domain.Root(), true, true},
293		{"token repo:read", tokenPrincipal(domain.ScopeRepoRead), true, true},
294		{"token repo:read on priv", tokenPrincipal(domain.ScopeRepoRead, "priv"), true, true},
295		{"token repo:read on another repo", tokenPrincipal(domain.ScopeRepoRead, "other"), true, false},
296		{"token repo:write only", tokenPrincipal(domain.ScopeRepoWrite), true, false},
297		{"token repo:admin only", tokenPrincipal(domain.ScopeRepoAdmin, "priv"), true, false},
298		{"token token:admin only", tokenPrincipal(domain.ScopeTokenAdmin), true, false},
299	}
300	for _, op := range browseOps() {
301		for _, pr := range principals {
302			for _, repo := range []domain.Repo{pub, priv} {
303				t.Run(op.name+"/"+pr.name+"/"+repo.Name.String(), func(t *testing.T) {
304					t.Parallel()
305					f := newBrowseFixture(t)
306					f.meta.EXPECT().Get(ctx, repo.Name).Return(repo, nil)
307					allowed := pr.pub
308					if repo.Visibility == domain.VisibilityPrivate {
309						allowed = pr.priv
310					}
311					if allowed {
312						op.expect(f.reader)
313					}
314					err := op.call(f.browse, ctx, pr.p, repo.Name)
315					if allowed {
316						assert.NoError(t, err)
317					} else {
318						assert.ErrorIs(t, err, domain.ErrNotFound)
319						assert.NotErrorIs(t, err, domain.ErrForbidden)
320					}
321				})
322			}
323		}
324		t.Run(op.name+"/missing repository", func(t *testing.T) {
325			t.Parallel()
326			f := newBrowseFixture(t)
327			f.meta.EXPECT().Get(ctx, name(t, "nope")).Return(domain.Repo{}, domain.ErrNotFound)
328			err := op.call(f.browse, ctx, domain.Root(), name(t, "nope"))
329			assert.ErrorIs(t, err, domain.ErrNotFound)
330		})
331		t.Run(op.name+"/zero name", func(t *testing.T) {
332			t.Parallel()
333			f := newBrowseFixture(t)
334			err := op.call(f.browse, ctx, domain.Root(), domain.RepoName{})
335			assert.ErrorIs(t, err, domain.ErrInvalidName)
336		})
337	}
338}
339
340func TestBrowseHidesPrivateOnReaderErrors(t *testing.T) {
341	t.Parallel()
342	// A reader error is passed through for a caller who may read.
343	ctx := context.Background()
344	f := newBrowseFixture(t)
345	pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
346	f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil)
347	f.reader.EXPECT().GetTree(ctx, pub.Name, domain.Revision{}, domain.TreePath{}, false).Return(domain.Tree{}, domain.ErrNotDirectory)
348	_, err := f.browse.GetTree(ctx, domain.Anonymous(), pub.Name, domain.Revision{}, domain.TreePath{}, false)
349	assert.ErrorIs(t, err, domain.ErrNotDirectory)
350}
351
352func ref(kind domain.RefKind, name string) domain.Ref {
353	return domain.Ref{Name: name, Kind: kind, ShortName: name}
354}
355
356func TestListRefsPagingAndKinds(t *testing.T) {
357	t.Parallel()
358	ctx := context.Background()
359	pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
360	all := []domain.Ref{
361		ref(domain.RefKindHead, "HEAD"),
362		ref(domain.RefKindBranch, "refs/heads/a"),
363		ref(domain.RefKindBranch, "refs/heads/b"),
364		ref(domain.RefKindBranch, "refs/heads/c"),
365		ref(domain.RefKindTag, "refs/tags/v1"),
366		ref(domain.RefKindTag, "refs/tags/v2"),
367	}
368	names := func(refs []domain.Ref) []string {
369		var out []string
370		for _, r := range refs {
371			out = append(out, r.Name)
372		}
373		return out
374	}
375	setup := func(t *testing.T, refs []domain.Ref) *usecase.Browse {
376		f := newBrowseFixture(t)
377		f.meta.EXPECT().Get(mock.Anything, pub.Name).Return(pub, nil)
378		f.reader.EXPECT().ListRefs(mock.Anything, pub.Name).Return(refs, nil)
379		return f.browse
380	}
381
382	t.Run("pages follow each other without gap or repeat", func(t *testing.T) {
383		b := setup(t, all)
384		var got []string
385		tok := ""
386		for pages := 0; ; pages++ {
387			require.Less(t, pages, 10)
388			page, err := b.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{PageSize: 2, PageToken: tok})
389			require.NoError(t, err)
390			got = append(got, names(page.Refs)...)
391			if tok = page.NextPageToken; tok == "" {
392				break
393			}
394		}
395		assert.Equal(t, names(all), got)
396	})
397
398	t.Run("a ref that appears between pages does not shift the others", func(t *testing.T) {
399		b := setup(t, all[:4])
400		page, err := b.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{PageSize: 2})
401		require.NoError(t, err)
402		require.Equal(t, []string{"HEAD", "refs/heads/a"}, names(page.Refs))
403
404		b2 := setup(t, append([]domain.Ref{all[0], ref(domain.RefKindBranch, "refs/heads/0new")}, all[1:4]...))
405		next, err := b2.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{PageSize: 2, PageToken: page.NextPageToken})
406		require.NoError(t, err)
407		assert.Equal(t, []string{"refs/heads/b", "refs/heads/c"}, names(next.Refs))
408	})
409
410	t.Run("kinds filter", func(t *testing.T) {
411		b := setup(t, all)
412		page, err := b.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{Kinds: []domain.RefKind{domain.RefKindTag}})
413		require.NoError(t, err)
414		assert.Equal(t, []string{"refs/tags/v1", "refs/tags/v2"}, names(page.Refs))
415		assert.Empty(t, page.NextPageToken)
416	})
417
418	t.Run("default and maximum page size", func(t *testing.T) {
419		var many []domain.Ref
420		many = append(many, ref(domain.RefKindHead, "HEAD"))
421		for i := range usecase.MaxRefPageSize + 100 {
422			many = append(many, ref(domain.RefKindTag, fmt.Sprintf("refs/tags/t%04d", i)))
423		}
424		b := setup(t, many)
425		page, err := b.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{})
426		require.NoError(t, err)
427		assert.Len(t, page.Refs, usecase.DefaultPageSize)
428		assert.NotEmpty(t, page.NextPageToken)
429
430		b = setup(t, many)
431		page, err = b.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{PageSize: 100000})
432		require.NoError(t, err)
433		assert.Len(t, page.Refs, usecase.MaxRefPageSize, "a page holds up to MaxRefPageSize refs")
434		assert.NotEmpty(t, page.NextPageToken)
435	})
436
437	t.Run("bad input never reaches the reader", func(t *testing.T) {
438		t.Parallel()
439		f := newBrowseFixture(t)
440		for _, params := range []ports.ListRefsParams{
441			{PageSize: -1},
442			{Kinds: []domain.RefKind{"nope"}},
443			{PageToken: "!!!"},
444			{PageToken: "Y2E6eA"}, // a token of another kind of listing
445		} {
446			_, err := f.browse.ListRefs(ctx, domain.Anonymous(), pub.Name, params)
447			assert.Error(t, err, "%+v", params)
448		}
449		_, err := f.browse.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{PageSize: -1})
450		assert.ErrorIs(t, err, domain.ErrInvalidPageSize)
451		_, err = f.browse.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{Kinds: []domain.RefKind{"x"}})
452		assert.ErrorIs(t, err, domain.ErrInvalidRefKind)
453		_, err = f.browse.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{PageToken: "!!!"})
454		assert.ErrorIs(t, err, domain.ErrInvalidPageToken)
455	})
456}
457
458func TestListCommitsPaging(t *testing.T) {
459	t.Parallel()
460	ctx := context.Background()
461	pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
462	p := mustPath(t, "d/f")
463
464	t.Run("first page asks for one more than the page and returns a token", func(t *testing.T) {
465		t.Parallel()
466		f := newBrowseFixture(t)
467		f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil)
468		f.reader.EXPECT().ListCommits(ctx, pub.Name, ports.CommitQuery{Path: p, Limit: 10}).
469			Return(ports.CommitPage{Head: oid("a"), Commits: []domain.Commit{{ID: oid("b")}}, HasMore: true}, nil)
470		page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{Path: p, PageSize: 10})
471		require.NoError(t, err)
472		require.Len(t, page.Commits, 1)
473		require.NotEmpty(t, page.NextPageToken)
474
475		// The next page starts from the pinned head, after the commits read.
476		f.reader.EXPECT().ListCommits(ctx, pub.Name, ports.CommitQuery{
477			Revision: domain.RevisionOf(oid("a")), Path: p, Skip: 1, Limit: 10,
478		}).Return(ports.CommitPage{Head: oid("a"), Commits: []domain.Commit{{ID: oid("c")}}}, nil)
479		f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil)
480		// The revision of the request is ignored once there is a token.
481		rev, _ := domain.ParseRevision("moved")
482		next, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{
483			Revision: rev, Path: p, PageSize: 10, PageToken: page.NextPageToken,
484		})
485		require.NoError(t, err)
486		assert.Equal(t, oid("c"), next.Commits[0].ID)
487		assert.Empty(t, next.NextPageToken)
488	})
489
490	t.Run("a token is bound to its path and must be well formed", func(t *testing.T) {
491		t.Parallel()
492		f := newBrowseFixture(t)
493		f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil)
494		f.reader.EXPECT().ListCommits(ctx, pub.Name, mock.Anything).
495			Return(ports.CommitPage{Head: oid("a"), Commits: []domain.Commit{{ID: oid("b")}}, HasMore: true}, nil)
496		page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{Path: p})
497		require.NoError(t, err)
498
499		for _, params := range []ports.ListCommitsParams{
500			{PageToken: page.NextPageToken},                             // no path
501			{Path: mustPath(t, "other"), PageToken: page.NextPageToken}, // another path
502			{Path: p, PageToken: "garbage"},
503			{Path: p, PageToken: "cjE6eDp5OnQ"}, // c1:x:y:t is not a token
504			{Path: p, PageToken: strings.Repeat("A", 2000)},
505		} {
506			_, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, params)
507			assert.ErrorIs(t, err, domain.ErrInvalidPageToken, "%+v", params)
508		}
509	})
510
511	t.Run("a token cannot skip deeper than the cap", func(t *testing.T) {
512		t.Parallel()
513		f := newBrowseFixture(t)
514		f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil).Maybe()
515		f.reader.EXPECT().ListCommits(ctx, pub.Name, mock.Anything).
516			Return(ports.CommitPage{Head: oid("a"), Commits: []domain.Commit{{ID: oid("b")}}, HasMore: true}, nil).Once()
517		page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{Path: p})
518		require.NoError(t, err)
519		raw, err := base64.RawURLEncoding.DecodeString(page.NextPageToken)
520		require.NoError(t, err)
521		parts := strings.Split(string(raw), ":") // c1:<head>:<skip>:<path digest>
522		require.Len(t, parts, 4)
523		withSkip := func(n int) string {
524			parts[2] = strconv.Itoa(n)
525			return base64.RawURLEncoding.EncodeToString([]byte(strings.Join(parts, ":")))
526		}
527
528		f.reader.EXPECT().ListCommits(ctx, pub.Name, mock.MatchedBy(func(q ports.CommitQuery) bool { return q.Skip == usecase.MaxCommitSkip })).
529			Return(ports.CommitPage{Head: oid("a")}, nil).Once()
530		_, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{Path: p, PageToken: withSkip(usecase.MaxCommitSkip)})
531		require.NoError(t, err, "the cap itself is allowed")
532		_, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{Path: p, PageToken: withSkip(usecase.MaxCommitSkip + 1)})
533		assert.ErrorIs(t, err, domain.ErrInvalidPageToken)
534	})
535
536	t.Run("page size", func(t *testing.T) {
537		t.Parallel()
538		f := newBrowseFixture(t)
539		f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil).Times(2)
540		f.reader.EXPECT().ListCommits(ctx, pub.Name, ports.CommitQuery{Limit: usecase.DefaultPageSize}).Return(ports.CommitPage{}, nil)
541		f.reader.EXPECT().ListCommits(ctx, pub.Name, ports.CommitQuery{Limit: 100}).Return(ports.CommitPage{}, nil)
542		_, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{})
543		require.NoError(t, err)
544		_, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{PageSize: 5000})
545		require.NoError(t, err)
546		_, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{PageSize: -3})
547		assert.ErrorIs(t, err, domain.ErrInvalidPageSize)
548	})
549
550	t.Run("the token of a private repository does not open it", func(t *testing.T) {
551		priv := domain.Repo{Name: name(t, "priv"), Visibility: domain.VisibilityPrivate}
552		f := newBrowseFixture(t)
553		f.meta.EXPECT().Get(ctx, priv.Name).Return(priv, nil)
554		f.reader.EXPECT().ListCommits(ctx, priv.Name, mock.Anything).
555			Return(ports.CommitPage{Head: oid("a"), Commits: []domain.Commit{{ID: oid("b")}}, HasMore: true}, nil)
556		page, err := f.browse.ListCommits(ctx, domain.Root(), priv.Name, ports.ListCommitsParams{})
557		require.NoError(t, err)
558		f.meta.EXPECT().Get(ctx, priv.Name).Return(priv, nil)
559		_, err = f.browse.ListCommits(ctx, domain.Anonymous(), priv.Name, ports.ListCommitsParams{PageToken: page.NextPageToken})
560		assert.ErrorIs(t, err, domain.ErrNotFound)
561	})
562}
563
564func mustPath(t *testing.T, s string) domain.TreePath {
565	t.Helper()
566	p, err := domain.ParseTreePath(s)
567	require.NoError(t, err)
568	return p
569}

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