v0.28.0 fca9155

browsecache_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase_test
4
5import (
6	"context"
7	"strings"
8	"sync"
9	"testing"
10	"time"
11
12	"github.com/stretchr/testify/assert"
13	"github.com/stretchr/testify/mock"
14	"github.com/stretchr/testify/require"
15
16	"bnema.dev/ermite/internal/domain"
17	"bnema.dev/ermite/internal/ports"
18	"bnema.dev/ermite/internal/ports/mocks"
19	"bnema.dev/ermite/internal/usecase"
20)
21
22func treePath(t *testing.T, s string) domain.TreePath {
23	t.Helper()
24	p, err := domain.ParseTreePath(s)
25	require.NoError(t, err)
26	return p
27}
28
29func treeWithLast(c string) domain.Tree {
30	commit := domain.Commit{ID: oid(c), Parents: []domain.ObjectID{oid("0")}, Summary: "s"}
31	return domain.Tree{
32		CommitID: oid("a"),
33		Entries:  []domain.TreeEntry{{Name: "f", Path: "d/f", LastCommit: &commit}},
34	}
35}
36
37// TestGetTreeLastCommitCache covers which GetTree calls are served from the
38// cache: only the ones with last commits at a full commit id.
39func TestGetTreeLastCommitCache(t *testing.T) {
40	t.Parallel()
41	ctx := context.Background()
42	pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
43	full := domain.RevisionOf(oid("a"))
44	branch, err := domain.ParseRevision("main")
45	require.NoError(t, err)
46	dir := treePath(t, "d")
47
48	tests := []struct {
49		name         string
50		rev          domain.Revision
51		includeLast  bool
52		wantReaderOn int // reader calls for two identical requests
53	}{
54		{"full id with last commits is cached", full, true, 1},
55		{"full id without last commits is not", full, false, 2},
56		{"branch name is not", branch, true, 2},
57		{"HEAD is not", domain.Revision{}, true, 2},
58	}
59	for _, tc := range tests {
60		t.Run(tc.name, func(t *testing.T) {
61			t.Parallel()
62			f := newBrowseFixture(t)
63			f.meta.EXPECT().Get(mock.Anything, pub.Name).Return(pub, nil)
64			f.reader.EXPECT().GetTree(mock.Anything, pub.Name, tc.rev, dir, tc.includeLast).
65				Return(treeWithLast("b"), nil).Times(tc.wantReaderOn)
66			for range 2 {
67				tree, err := f.browse.GetTree(ctx, domain.Anonymous(), pub.Name, tc.rev, dir, tc.includeLast)
68				require.NoError(t, err)
69				assert.Equal(t, "d/f", tree.Entries[0].Path)
70			}
71		})
72	}
73}
74
75func TestGetTreeLastCommitCacheKey(t *testing.T) {
76	t.Parallel()
77	ctx := context.Background()
78	pubA := domain.Repo{Name: name(t, "a"), Visibility: domain.VisibilityPublic}
79	pubB := domain.Repo{Name: name(t, "b"), Visibility: domain.VisibilityPublic}
80	revA, revB := domain.RevisionOf(oid("a")), domain.RevisionOf(oid("b"))
81	d1, d2 := treePath(t, "d1"), treePath(t, "d2")
82
83	// Each distinct (repo, commit, path) is one reader call, then a hit.
84	keys := []struct {
85		repo domain.Repo
86		rev  domain.Revision
87		path domain.TreePath
88	}{
89		{pubA, revA, d1}, {pubB, revA, d1}, {pubA, revB, d1}, {pubA, revA, d2}, {pubA, revA, domain.TreePath{}},
90	}
91	f := newBrowseFixture(t)
92	f.meta.EXPECT().Get(mock.Anything, mock.Anything).RunAndReturn(
93		func(_ context.Context, n domain.RepoName) (domain.Repo, error) {
94			return domain.Repo{Name: n, Visibility: domain.VisibilityPublic}, nil
95		})
96	for _, k := range keys {
97		f.reader.EXPECT().GetTree(mock.Anything, k.repo.Name, k.rev, k.path, true).Return(treeWithLast("b"), nil).Once()
98	}
99	for range 2 {
100		for _, k := range keys {
101			_, err := f.browse.GetTree(ctx, domain.Anonymous(), k.repo.Name, k.rev, k.path, true)
102			require.NoError(t, err)
103		}
104	}
105}
106
107// TestHistoryCacheNotServedAcrossRecreate: a repository deleted and created
108// again under the same name does not get the answers of the old one for a
109// commit id they both could hold.
110func TestHistoryCacheNotServedAcrossRecreate(t *testing.T) {
111	t.Parallel()
112	ctx := context.Background()
113	created := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
114	rev, dir := domain.RevisionOf(oid("a")), treePath(t, "d")
115	n := name(t, "pub")
116	old := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, CreatedAt: created}
117	again := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, CreatedAt: created.Add(time.Hour)}
118
119	tests := []struct {
120		name string
121		call func(b *usecase.Browse) error
122		// expect registers the reader call of the old repository (answers)
123		// and of the new one (not found).
124		expect func(r *mocks.RepoReader)
125	}{
126		{"tree with last commits", func(b *usecase.Browse) error {
127			_, err := b.GetTree(ctx, domain.Anonymous(), n, rev, dir, true)
128			return err
129		}, func(r *mocks.RepoReader) {
130			r.EXPECT().GetTree(mock.Anything, n, rev, dir, true).Return(treeWithLast("b"), nil).Once()
131			r.EXPECT().GetTree(mock.Anything, n, rev, dir, true).Return(domain.Tree{}, domain.ErrNotFound).Once()
132		}},
133		{"last change of a path", func(b *usecase.Browse) error {
134			_, err := b.ListCommits(ctx, domain.Anonymous(), n, ports.ListCommitsParams{Revision: rev, Path: dir, PageSize: 1})
135			return err
136		}, func(r *mocks.RepoReader) {
137			q := ports.CommitQuery{Revision: rev, Path: dir, Limit: 1}
138			r.EXPECT().ListCommits(mock.Anything, n, q).Return(ports.CommitPage{
139				Head: oid("a"), Commits: []domain.Commit{{ID: oid("c")}},
140			}, nil).Once()
141			r.EXPECT().ListCommits(mock.Anything, n, q).Return(ports.CommitPage{}, domain.ErrNotFound).Once()
142		}},
143	}
144	for _, tc := range tests {
145		t.Run(tc.name, func(t *testing.T) {
146			t.Parallel()
147			f := newBrowseFixture(t)
148			f.meta.EXPECT().Get(mock.Anything, n).Return(old, nil).Twice() // warm, hit
149			f.meta.EXPECT().Get(mock.Anything, n).Return(again, nil).Once()
150			tc.expect(f.reader)
151			require.NoError(t, tc.call(f.browse))
152			require.NoError(t, tc.call(f.browse)) // served from the cache
153			assert.ErrorIs(t, tc.call(f.browse), domain.ErrNotFound)
154		})
155	}
156}
157
158// TestGetTreeCacheKeepsAccessRule: a hit never skips the access check, and an
159// error is not cached.
160func TestGetTreeCacheKeepsAccessRule(t *testing.T) {
161	t.Parallel()
162	ctx := context.Background()
163	priv := domain.Repo{Name: name(t, "priv"), Visibility: domain.VisibilityPrivate}
164	rev, dir := domain.RevisionOf(oid("a")), treePath(t, "d")
165
166	f := newBrowseFixture(t)
167	f.meta.EXPECT().Get(mock.Anything, priv.Name).Return(priv, nil)
168	f.reader.EXPECT().GetTree(mock.Anything, priv.Name, rev, dir, true).Return(domain.Tree{}, domain.ErrNotDirectory).Once()
169	f.reader.EXPECT().GetTree(mock.Anything, priv.Name, rev, dir, true).Return(treeWithLast("b"), nil).Once()
170
171	_, err := f.browse.GetTree(ctx, domain.Root(), priv.Name, rev, dir, true)
172	require.ErrorIs(t, err, domain.ErrNotDirectory) // not cached
173	_, err = f.browse.GetTree(ctx, domain.Root(), priv.Name, rev, dir, true)
174	require.NoError(t, err) // now cached
175	_, err = f.browse.GetTree(ctx, domain.Anonymous(), priv.Name, rev, dir, true)
176	assert.ErrorIs(t, err, domain.ErrNotFound)
177}
178
179// TestGetTreeCacheReturnsCopies: a caller that edits its tree does not alter
180// what the next one gets.
181func TestGetTreeCacheReturnsCopies(t *testing.T) {
182	t.Parallel()
183	ctx := context.Background()
184	pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
185	rev, dir := domain.RevisionOf(oid("a")), treePath(t, "d")
186	f := newBrowseFixture(t)
187	f.meta.EXPECT().Get(mock.Anything, pub.Name).Return(pub, nil)
188	f.reader.EXPECT().GetTree(mock.Anything, pub.Name, rev, dir, true).Return(treeWithLast("b"), nil).Once()
189
190	for range 3 {
191		tree, err := f.browse.GetTree(ctx, domain.Anonymous(), pub.Name, rev, dir, true)
192		require.NoError(t, err)
193		require.Len(t, tree.Entries, 1)
194		assert.Equal(t, "s", tree.Entries[0].LastCommit.Summary)
195		tree.Entries[0].LastCommit.Summary = "changed"
196		tree.Entries[0].LastCommit.Parents[0] = oid("f")
197		tree.Entries[0].Name = "changed"
198	}
199}
200
201// TestHistoryCacheBounds: the entry bound and the byte bound evict the least
202// recently used answer.
203func TestHistoryCacheBounds(t *testing.T) {
204	t.Parallel()
205	ctx := context.Background()
206	pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
207	paths := []string{"p0", "p1", "p2"}
208
209	bigTree := treeWithLast("b")
210	bigTree.Entries[0].Name = strings.Repeat("x", 4096)
211
212	tests := []struct {
213		name       string
214		entries    int
215		bytes      int64
216		tree       domain.Tree
217		wantCached []bool // after requesting p0, p1, p2 (and touching p0 before p2 when touch)
218		touch      bool
219	}{
220		{"entry bound", 2, 1 << 20, treeWithLast("b"), []bool{false, true, true}, false},
221		{"entry bound is LRU", 2, 1 << 20, treeWithLast("b"), []bool{true, false, true}, true},
222		{"byte bound", 100, 6000, bigTree, []bool{false, false, true}, false},
223		{"entry larger than the byte bound", 100, 1000, bigTree, []bool{false, false, false}, false},
224		{"disabled by entries", 0, 1 << 20, treeWithLast("b"), []bool{false, false, false}, false},
225		{"disabled by bytes", 10, 0, treeWithLast("b"), []bool{false, false, false}, false},
226	}
227	for _, tc := range tests {
228		t.Run(tc.name, func(t *testing.T) {
229			t.Parallel()
230			f := newBrowseFixture(t)
231			f.browse.WithHistoryCache(tc.entries, tc.bytes)
232			f.meta.EXPECT().Get(mock.Anything, pub.Name).Return(pub, nil)
233			rev := domain.RevisionOf(oid("a"))
234			calls := map[string]int{}
235			f.reader.EXPECT().GetTree(mock.Anything, pub.Name, rev, mock.Anything, true).
236				RunAndReturn(func(_ context.Context, _ domain.RepoName, _ domain.Revision, p domain.TreePath, _ bool) (domain.Tree, error) {
237					calls[p.String()]++
238					return tc.tree, nil
239				})
240			get := func(p string) {
241				_, err := f.browse.GetTree(ctx, domain.Anonymous(), pub.Name, rev, treePath(t, p), true)
242				require.NoError(t, err)
243			}
244			get("p0")
245			get("p1")
246			if tc.touch {
247				get("p0")
248			}
249			get("p2")
250			before := map[string]int{"p0": calls["p0"], "p1": calls["p1"], "p2": calls["p2"]}
251			// Probe from the newest to the oldest so probing one does not evict
252			// the next one asked: asking a miss stores it.
253			for i := len(paths) - 1; i >= 0; i-- {
254				if !tc.wantCached[i] {
255					continue
256				}
257				get(paths[i])
258				assert.Equal(t, before[paths[i]], calls[paths[i]], "%s should be a hit", paths[i])
259			}
260			for i, p := range paths {
261				if tc.wantCached[i] {
262					continue
263				}
264				get(p)
265				assert.Equal(t, before[p]+1, calls[p], "%s should be a miss", p)
266				break // a miss stores the entry and may evict the others
267			}
268		})
269	}
270}
271
272// TestListCommitsLastChangeCache: the latest commit of a path at a full
273// commit id is cached; every other page shape is not.
274func TestListCommitsLastChangeCache(t *testing.T) {
275	t.Parallel()
276	ctx := context.Background()
277	pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
278	full := domain.RevisionOf(oid("a"))
279	main, err := domain.ParseRevision("main")
280	require.NoError(t, err)
281	file := treePath(t, "d/file.go")
282
283	tests := []struct {
284		name  string
285		rev   domain.Revision
286		path  domain.TreePath
287		size  int
288		calls int // reader calls for two identical requests
289	}{
290		{"last change at a full id", full, file, 1, 1},
291		{"last change of the root at a full id", full, domain.TreePath{}, 1, 1},
292		{"name", main, file, 1, 2},
293		{"HEAD", domain.Revision{}, file, 1, 2},
294		{"larger page", full, file, 2, 2},
295	}
296	for _, tc := range tests {
297		t.Run(tc.name, func(t *testing.T) {
298			t.Parallel()
299			f := newBrowseFixture(t)
300			f.meta.EXPECT().Get(mock.Anything, pub.Name).Return(pub, nil)
301			q := ports.CommitQuery{Revision: tc.rev, Path: tc.path, Limit: tc.size}
302			f.reader.EXPECT().ListCommits(mock.Anything, pub.Name, q).Return(ports.CommitPage{
303				Head:    oid("a"),
304				Commits: []domain.Commit{{ID: oid("c"), Parents: []domain.ObjectID{oid("d")}}},
305			}, nil).Times(tc.calls)
306			for range 2 {
307				page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name,
308					ports.ListCommitsParams{Revision: tc.rev, Path: tc.path, PageSize: tc.size})
309				require.NoError(t, err)
310				require.Len(t, page.Commits, 1)
311				page.Commits[0].Parents[0] = oid("f") // must not leak into the cache
312			}
313		})
314	}
315
316	t.Run("a following page is not cached", func(t *testing.T) {
317		t.Parallel()
318		f := newBrowseFixture(t)
319		f.meta.EXPECT().Get(mock.Anything, pub.Name).Return(pub, nil)
320		f.reader.EXPECT().ListCommits(mock.Anything, pub.Name, ports.CommitQuery{Revision: full, Path: file, Limit: 1}).
321			Return(ports.CommitPage{Head: oid("a"), Commits: []domain.Commit{{ID: oid("c")}}, HasMore: true}, nil).Once()
322		first, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{Revision: full, Path: file, PageSize: 1})
323		require.NoError(t, err)
324		require.NotEmpty(t, first.NextPageToken)
325		f.reader.EXPECT().ListCommits(mock.Anything, pub.Name, ports.CommitQuery{Revision: domain.RevisionOf(oid("a")), Path: file, Skip: 1, Limit: 1}).
326			Return(ports.CommitPage{Head: oid("a")}, nil).Twice()
327		for range 2 {
328			_, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{Path: file, PageSize: 1, PageToken: first.NextPageToken})
329			require.NoError(t, err)
330		}
331	})
332}
333
334func TestHistoryCacheConcurrent(t *testing.T) {
335	t.Parallel()
336	ctx := context.Background()
337	pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
338	f := newBrowseFixture(t)
339	f.browse.WithHistoryCache(4, 1<<20)
340	f.meta.EXPECT().Get(mock.Anything, pub.Name).Return(pub, nil)
341	f.reader.EXPECT().GetTree(mock.Anything, pub.Name, mock.Anything, mock.Anything, true).Return(treeWithLast("b"), nil)
342	paths := []string{"a", "b", "c", "d", "e", "f", "g"}
343	var wg sync.WaitGroup
344	for w := range 8 {
345		wg.Go(func() {
346			for i := range 200 {
347				p, err := domain.ParseTreePath(paths[(i+w)%len(paths)])
348				if err != nil {
349					t.Error(err)
350					return
351				}
352				if _, err := f.browse.GetTree(ctx, domain.Anonymous(), pub.Name, domain.RevisionOf(oid("a")), p, true); err != nil {
353					t.Error(err)
354					return
355				}
356			}
357		})
358	}
359	wg.Wait()
360}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 44

↑↓ moveenter openesc close