v0.4.0 3027a40

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

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 4

Tags 45

↑↓ moveenter openesc close