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}