v0.15.0 8a6cfec

cache_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package ermitegit_test
4
5import (
6	"bytes"
7	"context"
8	"fmt"
9	"io"
10	"os"
11	"path/filepath"
12	"sync"
13	"testing"
14	"testing/synctest"
15	"time"
16
17	"github.com/bnema/zerowrap"
18	"github.com/stretchr/testify/assert"
19	"github.com/stretchr/testify/require"
20
21	"bnema.dev/ermite/internal/adapters/ermitegit"
22	"bnema.dev/ermite/internal/domain"
23	"bnema.dev/ermite/internal/ports"
24	"bnema.dev/ermite/internal/readertest"
25)
26
27func commitOf(t *testing.T, data, repo string) *readertest.Fixture {
28	t.Helper()
29	f := readertest.NewFixture(t, data, repo)
30	f.Write("a.txt", repo+"\n")
31	f.Commit("commit of " + repo)
32	return f
33}
34
35func TestCacheIsBoundedAndEvictsLeastRecentlyUsed(t *testing.T) {
36	ctx := context.Background()
37	a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.MaxRepos = 3 })
38	var fx []*readertest.Fixture
39	for i := range 6 {
40		fx = append(fx, commitOf(t, data, fmt.Sprintf("r%d", i)))
41	}
42	for _, f := range fx {
43		res, err := a.ResolveRevision(ctx, f.Name, domain.Revision{})
44		require.NoError(t, err)
45		require.Equal(t, f.Git("rev-parse", "HEAD"), res.CommitID.String())
46		assert.LessOrEqual(t, a.OpenRepos(), 3)
47	}
48	assert.Equal(t, 3, a.OpenRepos())
49	// An evicted repository is opened again on the next read.
50	res, err := a.ResolveRevision(ctx, fx[0].Name, domain.Revision{})
51	require.NoError(t, err)
52	assert.Equal(t, fx[0].Git("rev-parse", "HEAD"), res.CommitID.String())
53	assert.Equal(t, 3, a.OpenRepos())
54}
55
56// An evicted repository stays usable by the reads that hold it: a blob being
57// streamed is not cut when the cache closes its repository.
58func TestEvictionDoesNotCutAStream(t *testing.T) {
59	ctx := context.Background()
60	a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.MaxRepos = 1 })
61	f := readertest.NewFixture(t, data, "big")
62	content := readertest.RandomBytes(3<<20, 11)
63	f.WriteBytes("big.bin", content)
64	f.Commit("big")
65	other := commitOf(t, data, "other")
66
67	_, rc, err := a.OpenBlob(ctx, f.Name, domain.Revision{}, readertest.TreePath(t, "big.bin"))
68	require.NoError(t, err)
69	head := make([]byte, 1000)
70	_, err = rc.Read(head)
71	require.NoError(t, err)
72
73	_, err = a.ResolveRevision(ctx, other.Name, domain.Revision{}) // evicts "big"
74	require.NoError(t, err)
75	assert.Equal(t, 1, a.OpenRepos())
76
77	rest := new(bytes.Buffer)
78	_, err = rest.ReadFrom(rc)
79	require.NoError(t, err)
80	require.NoError(t, rc.Close())
81	assert.True(t, bytes.Equal(content[1000:], rest.Bytes()))
82}
83
84func TestConcurrentReadsEvictionAndPushes(t *testing.T) {
85	ctx := context.Background()
86	a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.MaxRepos = 2; c.RefreshEvery = -1 })
87	var fx []*readertest.Fixture
88	for i := range 5 {
89		fx = append(fx, commitOf(t, data, fmt.Sprintf("c%d", i)))
90	}
91	var wg sync.WaitGroup
92	for w := range 8 {
93		wg.Go(func() {
94			for i := range 40 {
95				f := fx[(w+i)%len(fx)]
96				if _, err := a.ListRefs(ctx, f.Name); err != nil {
97					t.Error(err)
98					return
99				}
100				page, err := a.ListCommits(ctx, f.Name, ports.CommitQuery{Limit: 5})
101				if err != nil || len(page.Commits) != 1 {
102					t.Errorf("%v %v", err, page)
103					return
104				}
105				if _, err := a.GetTree(ctx, f.Name, domain.Revision{}, domain.TreePath{}, true); err != nil {
106					t.Error(err)
107					return
108				}
109			}
110		})
111	}
112	wg.Wait()
113	a.Close()
114	_, err := a.ListRefs(ctx, fx[0].Name)
115	assert.ErrorIs(t, err, ermitegit.ErrClosed)
116}
117
118// A repository ermite-git rejects is served by git for every method, and the
119// reason is logged once at info.
120func TestUnsupportedRepositoryFallsBackToGit(t *testing.T) {
121	var logs bytes.Buffer
122	log := zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &logs})
123	ctx := zerowrap.WithCtx(context.Background(), log)
124	a, g, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), nil)
125
126	h := readertest.NewHistory(t, data)
127	// An alternates file makes the repository unsupported for the library.
128	other := t.TempDir()
129	require.NoError(t, os.WriteFile(filepath.Join(h.Bare, "objects", "info", "alternates"), []byte(filepath.Join(other, "objects")+"\n"), 0o644))
130
131	for range 3 {
132		want, err := g.ListRefs(ctx, h.Name)
133		require.NoError(t, err)
134		got, err := a.ListRefs(ctx, h.Name)
135		require.NoError(t, err)
136		assert.Equal(t, want, got)
137		page, err := a.ListCommits(ctx, h.Name, ports.CommitQuery{Limit: 100})
138		require.NoError(t, err)
139		assert.Len(t, page.Commits, 6)
140		tree, err := a.GetTree(ctx, h.Name, domain.Revision{}, domain.TreePath{}, true)
141		require.NoError(t, err)
142		assert.NotEmpty(t, tree.Entries)
143	}
144	assert.Equal(t, 1, bytes.Count(logs.Bytes(), []byte("served by git")), "logged once: %s", logs.String())
145	assert.Contains(t, logs.String(), `"level":"info"`)
146}
147
148// A repository that becomes unsupported while it is open (a replace ref is
149// pushed) is handed to git from then on.
150func TestRepositoryBecomingUnsupportedFallsBack(t *testing.T) {
151	ctx := context.Background()
152	a, g, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.RefreshEvery = -1 })
153	h := readertest.NewHistory(t, data)
154	_, err := a.ListRefs(ctx, h.Name)
155	require.NoError(t, err)
156
157	h.Git("replace", h.C2, h.C3)
158	want, err := g.ListRefs(ctx, h.Name)
159	require.NoError(t, err)
160	got, err := a.ListRefs(ctx, h.Name)
161	require.NoError(t, err)
162	assert.Equal(t, want, got)
163}
164
165// Errors that are not ErrUnsupported are returned, not hidden by a fallback.
166func TestOtherOpenErrorsAreReturned(t *testing.T) {
167	ctx := context.Background()
168	a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), nil)
169	f := commitOf(t, data, "broken")
170	// A corrupt packed-refs is neither unsupported nor missing.
171	require.NoError(t, os.WriteFile(filepath.Join(f.Bare, "packed-refs"), []byte("not a packed-refs file\n"), 0o644))
172	_, err := a.ListRefs(ctx, f.Name)
173	require.Error(t, err)
174	assert.NotErrorIs(t, err, domain.ErrNotFound)
175
176	_, err = a.ListRefs(ctx, readertest.RepoName(t, "ghost"))
177	assert.ErrorIs(t, err, domain.ErrNotFound)
178}
179
180func TestCancelledContext(t *testing.T) {
181	a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), nil)
182	f := commitOf(t, data, "ctx")
183	ctx, cancel := context.WithCancel(context.Background())
184	cancel()
185	_, err := a.ListRefs(ctx, f.Name)
186	assert.ErrorIs(t, err, context.Canceled)
187	_, err = a.ListCommits(ctx, f.Name, ports.CommitQuery{Limit: 5})
188	assert.ErrorIs(t, err, context.Canceled)
189	_, _, err = a.OpenBlob(ctx, f.Name, domain.Revision{}, readertest.TreePath(t, "a.txt"))
190	assert.ErrorIs(t, err, context.Canceled)
191}
192
193// A commit in an encoding other than UTF-8 is converted by git, not by the
194// library: the adapter hands it to the CLI reader even when ermite-git is
195// routed, and the answer is the one of git.
196func TestEncodedCommitIsServedByGit(t *testing.T) {
197	ctx := context.Background()
198	a, g, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), nil)
199	f := readertest.NewFixture(t, data, "enc")
200	f.Write("a.txt", "a\n")
201	f.Git("add", "-A")
202	f.Git("-c", "i18n.commitEncoding=ISO-8859-1", "commit", "--quiet", "-m", "caf\xe9")
203	want, err := g.ListCommits(ctx, f.Name, ports.CommitQuery{Limit: 5})
204	require.NoError(t, err)
205	got, err := a.ListCommits(ctx, f.Name, ports.CommitQuery{Limit: 5})
206	require.NoError(t, err)
207	assert.Equal(t, want, got)
208	assert.Equal(t, "café", got.Commits[0].Message)
209}
210
211// The idle tests run in a synctest bubble: the clock is fake, so IdleTTL is the
212// production one and the boundaries are exact. The sweeper ticks every
213// IdleTTL/4 from the creation of the adapter and closes what has been idle for
214// more than IdleTTL, so a repository last used at t is closed at the first tick
215// after t+IdleTTL, which is t+IdleTTL+IdleTTL/4 here (no time passes while the
216// fixtures are built: the bubble's clock only moves when everything sleeps).
217const idleTTL = ermitegit.DefaultIdleTTL
218
219// A repository that nobody reads for IdleTTL is closed, without a goroutine
220// per repository.
221func TestIdleRepositoriesAreClosed(t *testing.T) {
222	synctest.Test(t, func(t *testing.T) {
223		ctx := context.Background()
224		a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.IdleTTL = idleTTL })
225		f := commitOf(t, data, "idle")
226		_, err := a.ListRefs(ctx, f.Name)
227		require.NoError(t, err)
228		require.Equal(t, 1, a.OpenRepos())
229
230		synctest.Sleep(idleTTL) // idle for exactly the TTL: not more
231		assert.Equal(t, 1, a.OpenRepos(), "not idle for more than the TTL yet")
232		synctest.Sleep(idleTTL/4 - time.Nanosecond)
233		assert.Equal(t, 1, a.OpenRepos(), "the sweeper has not ticked yet")
234		synctest.Sleep(time.Nanosecond)
235		assert.Equal(t, 0, a.OpenRepos(), "closed by the first sweep after the TTL")
236
237		// It opens again on the next read.
238		_, err = a.ListRefs(ctx, f.Name)
239		require.NoError(t, err)
240		assert.Equal(t, 1, a.OpenRepos())
241	})
242}
243
244// A read keeps the repository open: the TTL counts from the last use.
245func TestReadsKeepARepositoryOpen(t *testing.T) {
246	synctest.Test(t, func(t *testing.T) {
247		ctx := context.Background()
248		a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.IdleTTL = idleTTL })
249		f := commitOf(t, data, "busy")
250		for range 10 {
251			_, err := a.ListRefs(ctx, f.Name)
252			require.NoError(t, err)
253			synctest.Sleep(idleTTL / 2)
254			require.Equal(t, 1, a.OpenRepos(), "used within the TTL")
255		}
256		synctest.Sleep(idleTTL)
257		assert.Equal(t, 0, a.OpenRepos(), "closed once nobody reads it")
258	})
259}
260
261// A Refresh after a write is not a read: a repository pushed to but never read
262// still closes after IdleTTL.
263func TestRefreshIsNotAUse(t *testing.T) {
264	synctest.Test(t, func(t *testing.T) {
265		ctx := context.Background()
266		a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.IdleTTL = idleTTL })
267		f := commitOf(t, data, "pushed")
268		_, err := a.ListRefs(ctx, f.Name)
269		require.NoError(t, err)
270		// A push every 30 seconds, until the sweep that follows the TTL.
271		for elapsed := time.Duration(0); elapsed < idleTTL+idleTTL/4; elapsed += 30 * time.Second {
272			require.Equal(t, 1, a.OpenRepos(), "open after %v", elapsed)
273			a.Refresh(ctx, f.Name)
274			synctest.Sleep(30 * time.Second)
275		}
276		assert.Equal(t, 0, a.OpenRepos(), "refreshes kept an unread repository open")
277	})
278}
279
280// The idle close does not cut a blob stream the reader still holds: the library
281// keeps the stream valid after Close.
282func TestIdleCloseDoesNotCutAStream(t *testing.T) {
283	synctest.Test(t, func(t *testing.T) {
284		ctx := context.Background()
285		a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.IdleTTL = idleTTL })
286		f := readertest.NewFixture(t, data, "stream")
287		content := readertest.RandomBytes(3<<20, 12)
288		f.WriteBytes("big.bin", content)
289		f.Commit("big")
290
291		_, rc, err := a.OpenBlob(ctx, f.Name, domain.Revision{}, readertest.TreePath(t, "big.bin"))
292		require.NoError(t, err)
293		synctest.Sleep(idleTTL + idleTTL/4)
294		require.Equal(t, 0, a.OpenRepos())
295		got, err := io.ReadAll(rc)
296		require.NoError(t, err)
297		require.NoError(t, rc.Close())
298		assert.True(t, bytes.Equal(content, got))
299	})
300}
301
302// A repository ermite-git could not read is served by git for UnsupportedRetry,
303// then opened again: a repack, or the removal of a replace ref, can make it
304// readable. The commit-graph of the history fixture is mapped only while the
305// library has the repository open.
306func TestUnsupportedRepositoryIsTriedAgainAfterTheRetryDelay(t *testing.T) {
307	synctest.Test(t, func(t *testing.T) {
308		ctx := context.Background()
309		a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), nil)
310		h := readertest.NewHistory(t, data)
311		h.Maintain()
312		alternates := filepath.Join(h.Bare, "objects", "info", "alternates")
313		require.NoError(t, os.WriteFile(alternates, []byte(filepath.Join(t.TempDir(), "objects")+"\n"), 0o644))
314		read := func() {
315			t.Helper()
316			// ListRefs: git's answer is a one-shot command, where ListCommits
317			// would start a long-lived cat-file process, whose pipes keep the
318			// bubble from ever being idle.
319			_, err := a.ListRefs(ctx, h.Name)
320			require.NoError(t, err)
321		}
322
323		read()
324		require.False(t, mapped(t, h.Bare), "unsupported: served by git")
325		require.NoError(t, os.Remove(alternates)) // readable now
326
327		synctest.Sleep(ermitegit.UnsupportedRetry)
328		read()
329		assert.False(t, mapped(t, h.Bare), "still served by git: the delay is not over")
330		synctest.Sleep(time.Nanosecond)
331		read()
332		assert.True(t, mapped(t, h.Bare), "tried again once the delay is over")
333	})
334}
335
336// mapped reports whether the process maps a file under dir (Linux).
337func mapped(t *testing.T, dir string) bool {
338	t.Helper()
339	maps, err := os.ReadFile("/proc/self/maps")
340	if err != nil {
341		t.Skip("no /proc/self/maps")
342	}
343	return bytes.Contains(maps, []byte(dir))
344}
345
346// Close releases the mappings before it returns, not later from another
347// goroutine.
348func TestCloseReleasesMappingsSynchronously(t *testing.T) {
349	ctx := context.Background()
350	a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), nil)
351	h := readertest.NewHistory(t, data)
352	h.Maintain()
353	_, err := a.ListCommits(ctx, h.Name, ports.CommitQuery{Limit: 5})
354	require.NoError(t, err)
355	require.True(t, mapped(t, h.Bare), "the commit-graph is mapped while the repository is open")
356	a.Close()
357	assert.False(t, mapped(t, h.Bare), "Close returned with the mappings still there")
358}
359
360// Forget (a deleted repository) also closes before it returns.
361func TestForgetReleasesMappingsSynchronously(t *testing.T) {
362	ctx := context.Background()
363	a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), nil)
364	h := readertest.NewHistory(t, data)
365	h.Maintain()
366	_, err := a.ListCommits(ctx, h.Name, ports.CommitQuery{Limit: 5})
367	require.NoError(t, err)
368	require.True(t, mapped(t, h.Bare))
369	a.Forget(h.Bare)
370	assert.False(t, mapped(t, h.Bare))
371}
372
373// Refresh, called after a write of ermite, makes the library notice at once
374// what the throttled read path would not for an hour.
375func TestRefreshAfterAWrite(t *testing.T) {
376	var logs bytes.Buffer
377	log := zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &logs})
378	ctx := zerowrap.WithCtx(context.Background(), log)
379	a, g, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.RefreshEvery = time.Hour })
380	h := readertest.NewHistory(t, data)
381
382	a.Refresh(ctx, h.Name) // not open: nothing to do
383	assert.Equal(t, 0, a.OpenRepos())
384	_, err := a.ListRefs(ctx, h.Name)
385	require.NoError(t, err)
386
387	h.Git("replace", h.C2, h.C3) // the repository stops being readable by the library
388	a.Refresh(ctx, h.Name)
389	assert.Contains(t, logs.String(), "served by git", "Refresh noticed the replace ref")
390	want, err := g.ListRefs(ctx, h.Name)
391	require.NoError(t, err)
392	got, err := a.ListRefs(ctx, h.Name)
393	require.NoError(t, err)
394	assert.Equal(t, want, got)
395
396	a.Close()
397	a.Refresh(ctx, h.Name) // after Close: no-op
398}

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