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}