1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3// Package ermitegit implements ports.RepoReader over the ermite-git library.
4//
5// The adapter wraps the gitcli reader. Each method has a route, a constant
6// below: the methods that match gitcli in the shared conformance table and
7// beat it in the benchmarks are served by ermite-git, the others (and
8// GetCommit) by the wrapped reader. A repository that ermite-git cannot read
9// (ErrUnsupported: SHA-256, reftable, alternates, replace refs...) is served
10// by the wrapped reader for every method.
11//
12// Any other error of the library is returned, never hidden by a fallback.
13// The exceptions are decisions about the data, not failures: a commit whose
14// message is not in UTF-8 (git converts it, the library does not) and an
15// object larger than the library's in-memory bound are read by the wrapped
16// reader.
17//
18// Freshness: a read that begins after a push has returned sees the push,
19// whichever path made it, because the library reads refs from disk on each
20// call (loose refs always, packed-refs after a stat) and rescans the packs
21// when an object is missing (TestFreshness proves it with no Refresh at all).
22// The adapter still calls Refresh to release replaced packs, pick up a new
23// commit-graph and notice a repository that stopped being supported: at once
24// when ermite itself wrote (the push and the maintenance tell it through
25// Refresh; a merge request merge waits for its maintenance), and as a safety
26// net at most once per Config.RefreshEvery for each repository while it is
27// read, by one reader at a time. A Refresh stats the files of the repository,
28// too much to pay on every read of a method that itself costs 2 µs.
29//
30// Memory: the cache of open repositories is bounded (LRU) and drops the ones
31// idle longer than Config.IdleTTL; each repository holds at most
32// Config.DeltaCacheSize of delta bases and reads objects up to
33// Config.MaxObjectSize into memory. gitcli's Trash tells the adapter to drop
34// a deleted repository (see Forget).
35package ermitegit
36
37import (
38 "context"
39 "errors"
40 "fmt"
41 "io"
42 "sync"
43 "time"
44
45 git "bnema.dev/ermite-git"
46 "github.com/bnema/zerowrap"
47
48 "bnema.dev/ermite/internal/domain"
49 "bnema.dev/ermite/internal/ports"
50)
51
52var (
53 _ ports.RepoReader = (*Adapter)(nil)
54 _ ports.RepoFreshener = (*Adapter)(nil)
55)
56
57// ErrClosed is returned by reads after Close.
58var ErrClosed = errors.New("ermitegit: reader is closed")
59
60// Backend says which reader serves a method.
61type Backend uint8
62
63// Backends.
64const (
65 // CLI is the git binary, through the gitcli reader.
66 CLI Backend = iota
67 // ErmiteGit is the ermite-git library.
68 ErmiteGit
69)
70
71// String returns "cli" or "ermite-git".
72func (b Backend) String() string {
73 if b == ErmiteGit {
74 return "ermite-git"
75 }
76 return "cli"
77}
78
79// Routes says which reader serves each method. GetCommit is not here: it is
80// always served by the wrapped reader.
81type Routes struct {
82 ListRefs Backend
83 // ResolveRevision is a revision that is a name or a full id.
84 ResolveRevision Backend
85 // ResolveRevisionAbbrev is a revision that is an abbreviated id.
86 ResolveRevisionAbbrev Backend
87 ResolveRefPath Backend
88 // GetTree is a directory listing without last commits.
89 GetTree Backend
90 // GetTreeLastCommits is the listing of the root with the last commit of
91 // each entry.
92 GetTreeLastCommits Backend
93 // GetTreeSubdirLastCommits is the same for a subdirectory, which costs a
94 // much longer history walk (195 ms with git on lazygit).
95 GetTreeSubdirLastCommits Backend
96 OpenBlob Backend
97 // ListCommits is a page of history of a revision.
98 ListCommits Backend
99 // ListCommitsPath is a page of the history of a file or a directory.
100 ListCommitsPath Backend
101 // ListPaths is the recursive list of the file paths of a revision.
102 ListPaths Backend
103}
104
105// tree picks the route of a directory listing.
106func (r Routes) tree(path domain.TreePath, lastCommits bool) Backend {
107 switch {
108 case !lastCommits:
109 return r.GetTree
110 case path.IsRoot():
111 return r.GetTreeLastCommits
112 default:
113 return r.GetTreeSubdirLastCommits
114 }
115}
116
117// AllRoutes returns routes that send every method to b. The tests and the
118// benchmarks use it to exercise a backend whatever the production routes are.
119func AllRoutes(b Backend) Routes {
120 return Routes{b, b, b, b, b, b, b, b, b, b, b}
121}
122
123// Config configures an Adapter.
124type Config struct {
125 // Fallback serves the methods that are not routed to ermite-git and the
126 // repositories ermite-git cannot read. Required.
127 Fallback ports.RepoReader
128 // RepoDir returns the directory of a repository. It does not check that it
129 // exists. Required.
130 RepoDir func(domain.RepoName) string
131 // RefreshEvery is the longest a repository goes without a Refresh of the
132 // library while it is read. Zero means DefaultRefreshEvery; a negative
133 // value refreshes before every read. It does not affect what a read
134 // sees: see the package comment.
135 RefreshEvery time.Duration
136 // MaxRepos bounds the repositories kept open; zero means DefaultMaxRepos.
137 MaxRepos int
138 // IdleTTL is how long a repository may go unused before it is closed.
139 // Zero means DefaultIdleTTL; a negative value keeps repositories open
140 // until evicted.
141 IdleTTL time.Duration
142 // DeltaCacheSize is the budget in bytes of the cache of delta bases of
143 // each open repository. Zero means DefaultDeltaCacheSize; a negative value
144 // disables the cache.
145 DeltaCacheSize int64
146 // MaxObjectSize is the size in bytes above which an object that must be
147 // held in memory is read by the wrapped reader instead. Zero means
148 // DefaultMaxObjectSize; a negative value means the library default
149 // (128 MiB).
150 MaxObjectSize int64
151 // Routes overrides DefaultRoutes when not nil.
152 Routes *Routes
153}
154
155// Adapter is a ports.RepoReader that routes each method to ermite-git or to
156// the wrapped reader.
157type Adapter struct {
158 cli ports.RepoReader
159 dirOf func(domain.RepoName) string
160 routes Routes
161 cache *cache
162
163 logged sync.Map // dir -> struct{}: the unsupported repositories already logged
164}
165
166// New builds an adapter.
167func New(cfg Config) *Adapter {
168 routes := DefaultRoutes()
169 if cfg.Routes != nil {
170 routes = *cfg.Routes
171 }
172 refreshEvery := cfg.RefreshEvery
173 if refreshEvery == 0 {
174 refreshEvery = DefaultRefreshEvery
175 }
176 idleTTL := cfg.IdleTTL
177 if idleTTL == 0 {
178 idleTTL = DefaultIdleTTL
179 }
180 deltaCache := cfg.DeltaCacheSize
181 if deltaCache == 0 {
182 deltaCache = DefaultDeltaCacheSize
183 }
184 maxObject := cfg.MaxObjectSize
185 if maxObject == 0 {
186 maxObject = DefaultMaxObjectSize
187 }
188 a := &Adapter{cli: cfg.Fallback, dirOf: cfg.RepoDir, routes: routes}
189 a.cache = newCache(cacheConfig{
190 max: cfg.MaxRepos, refreshEvery: refreshEvery, idleTTL: idleTTL,
191 openOpts: []git.Option{git.WithDeltaCacheSize(deltaCache), git.WithMaxObjectSize(maxObject)},
192 onUnsupport: a.logUnsupported,
193 })
194 return a
195}
196
197// Close closes the open repositories. Reads after Close fail with ErrClosed;
198// blob streams already returned stay valid until they are closed. The wrapped
199// reader is not closed.
200func (a *Adapter) Close() { a.cache.close() }
201
202// Forget drops the open repository of dir, which was moved away or deleted.
203func (a *Adapter) Forget(dir string) {
204 a.cache.forget(dir)
205 a.logged.Delete(dir)
206}
207
208// Refresh tells the adapter that ermite wrote to the repository (a push, the
209// maintenance): the library refreshes it now, if it is open, instead of at the
210// next read after RefreshEvery. A repository that is not open has nothing to
211// refresh. A failure is logged, not returned: reads do not depend on it.
212func (a *Adapter) Refresh(ctx context.Context, repo domain.RepoName) {
213 if err := a.cache.refreshNow(ctx, repo, a.dirOf(repo)); err != nil {
214 log := zerowrap.FromCtx(ctx)
215 log.Warn().Err(err).Str("repo", repo.String()).Msg("refresh of the open repository failed")
216 }
217}
218
219// OpenRepos returns the number of repositories in the cache, for tests.
220func (a *Adapter) OpenRepos() int { return a.cache.size() }
221
222// logUnsupported logs, once per directory, that a repository is served by git.
223func (a *Adapter) logUnsupported(ctx context.Context, dir string, name domain.RepoName, cause error) {
224 if _, seen := a.logged.LoadOrStore(dir, struct{}{}); seen {
225 return
226 }
227 log := zerowrap.FromCtx(ctx)
228 log.Info().Str("repo", name.String()).Str("reason", cause.Error()).Msg("repository is not readable by ermite-git, served by git")
229}
230
231// errFallback makes serve hand the call to the wrapped reader.
232var errFallback = errors.New("ermitegit: served by git")
233
234// serve runs fn on the open repository, or cli when the repository is
235// unsupported, when fn asks for it, or when the library refuses on a bound.
236func serve[T any](ctx context.Context, a *Adapter, repo domain.RepoName, fn func(*git.Repo) (T, error), cli func() (T, error)) (T, error) {
237 var zero T
238 if err := ctx.Err(); err != nil {
239 return zero, err
240 }
241 h, err := a.cache.get(ctx, repo, a.dirOf(repo))
242 if err != nil {
243 return zero, err
244 }
245 defer h.release()
246 if h.repo == nil {
247 return cli()
248 }
249 v, err := fn(h.repo)
250 switch {
251 case err == nil:
252 return v, nil
253 case errors.Is(err, errFallback), errors.Is(err, git.ErrTooLarge):
254 return cli()
255 case errors.Is(err, git.ErrUnsupported):
256 h.fallBack(ctx, repo, err)
257 return cli()
258 }
259 return zero, mapErr(ctx, err)
260}
261
262// mapErr turns the errors of the library into the ones of the port. A
263// cancelled context stays itself.
264func mapErr(ctx context.Context, err error) error {
265 switch {
266 case ctx.Err() != nil && errors.Is(err, ctx.Err()):
267 return err
268 case errors.Is(err, domain.ErrNotFound), errors.Is(err, domain.ErrNotFile), errors.Is(err, domain.ErrNotDirectory):
269 return err // already one of the port's
270 case errors.Is(err, git.ErrNotFound), errors.Is(err, git.ErrAmbiguous):
271 // gitcli reads an ambiguous abbreviation as a missing object.
272 return fmt.Errorf("%w: %w", domain.ErrNotFound, err)
273 case errors.Is(err, git.ErrInvalidPath):
274 return fmt.Errorf("%w: %w", domain.ErrInvalidPath, err)
275 case errors.Is(err, git.ErrInvalidID):
276 return fmt.Errorf("%w: %w", domain.ErrInvalidObjectID, err)
277 case errors.Is(err, git.ErrClosed):
278 return fmt.Errorf("%w: %w", ErrClosed, err)
279 }
280 return err
281}
282
283// ListRefs lists HEAD, the branches and the tags.
284func (a *Adapter) ListRefs(ctx context.Context, repo domain.RepoName) ([]domain.Ref, error) {
285 cli := func() ([]domain.Ref, error) { return a.cli.ListRefs(ctx, repo) }
286 if a.routes.ListRefs == CLI {
287 return cli()
288 }
289 return serve(ctx, a, repo, func(r *git.Repo) ([]domain.Ref, error) { return listRefs(r) }, cli)
290}
291
292// ResolveRevision turns a revision into a commit.
293//
294//hexcheck:ignore suspicious-business-logic-in-adapter routes a call by a code constant; rules and limits come from domain and ports
295func (a *Adapter) ResolveRevision(ctx context.Context, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error) {
296 cli := func() (domain.ResolvedRevision, error) { return a.cli.ResolveRevision(ctx, repo, rev) }
297 route := a.routes.ResolveRevision
298 if rev.IsAbbrevID() {
299 route = a.routes.ResolveRevisionAbbrev
300 }
301 if route == CLI {
302 return cli()
303 }
304 return serve(ctx, a, repo, func(r *git.Repo) (domain.ResolvedRevision, error) {
305 res, err := resolveRevision(r, rev)
306 if err != nil {
307 return domain.ResolvedRevision{}, fmt.Errorf("resolve %q in %q: %w", rev, repo, err)
308 }
309 return res.domain()
310 }, cli)
311}
312
313// ResolveRefPath splits a path into a revision and the rest.
314//
315//hexcheck:ignore suspicious-business-logic-in-adapter routes a call by a code constant; rules and limits come from domain and ports
316func (a *Adapter) ResolveRefPath(ctx context.Context, repo domain.RepoName, path string) (domain.RefPath, error) {
317 cli := func() (domain.RefPath, error) { return a.cli.ResolveRefPath(ctx, repo, path) }
318 if a.routes.ResolveRefPath == CLI {
319 return cli()
320 }
321 return serve(ctx, a, repo, func(r *git.Repo) (domain.RefPath, error) {
322 out, err := domain.SplitRefPath(path, func(rev domain.Revision) (domain.ResolvedRevision, error) {
323 res, err := resolveRevision(r, rev)
324 if err != nil {
325 return domain.ResolvedRevision{}, mapErr(ctx, err)
326 }
327 return res.domain()
328 })
329 if err != nil {
330 return domain.RefPath{}, fmt.Errorf("resolve path %q in %q: %w", path, repo, err)
331 }
332 return out, nil
333 }, cli)
334}
335
336// ListPaths lists the file and symbolic link paths of a revision.
337func (a *Adapter) ListPaths(ctx context.Context, repo domain.RepoName, rev domain.Revision, maxPaths, maxBytes int) (domain.PathList, error) {
338 cli := func() (domain.PathList, error) { return a.cli.ListPaths(ctx, repo, rev, maxPaths, maxBytes) }
339 if a.routes.ListPaths == CLI {
340 return cli()
341 }
342 return serve(ctx, a, repo, func(r *git.Repo) (domain.PathList, error) {
343 list, err := listPaths(ctx, r, rev, maxPaths, maxBytes)
344 if err != nil {
345 return domain.PathList{}, fmt.Errorf("paths of %q: %w", repo, err)
346 }
347 return list, nil
348 }, cli)
349}
350
351// GetTree lists a directory.
352func (a *Adapter) GetTree(ctx context.Context, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool) (domain.Tree, error) {
353 cli := func() (domain.Tree, error) { return a.cli.GetTree(ctx, repo, rev, path, includeLastCommit) }
354 if a.routes.tree(path, includeLastCommit) == CLI {
355 return cli()
356 }
357 return serve(ctx, a, repo, func(r *git.Repo) (domain.Tree, error) {
358 tree, err := getTree(ctx, r, rev, path, includeLastCommit)
359 if err != nil {
360 return domain.Tree{}, fmt.Errorf("tree %q of %q: %w", path, repo, err)
361 }
362 return tree, nil
363 }, cli)
364}
365
366// OpenBlob describes a file and returns its content as a stream.
367func (a *Adapter) OpenBlob(ctx context.Context, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) {
368 type result struct {
369 info domain.BlobInfo
370 rc io.ReadCloser
371 }
372 cli := func() (result, error) {
373 info, rc, err := a.cli.OpenBlob(ctx, repo, rev, path)
374 return result{info, rc}, err
375 }
376 if a.routes.OpenBlob == CLI {
377 res, err := cli()
378 return res.info, res.rc, err
379 }
380 res, err := serve(ctx, a, repo, func(r *git.Repo) (result, error) {
381 info, rc, err := openBlob(ctx, r, rev, path)
382 if err != nil {
383 return result{}, fmt.Errorf("blob %q of %q: %w", path, repo, err)
384 }
385 return result{info, rc}, nil
386 }, cli)
387 return res.info, res.rc, err
388}
389
390// ListCommits returns a page of history.
391//
392//hexcheck:ignore suspicious-business-logic-in-adapter routes a call by a code constant; rules and limits come from domain and ports
393func (a *Adapter) ListCommits(ctx context.Context, repo domain.RepoName, q ports.CommitQuery) (ports.CommitPage, error) {
394 if err := q.Validate(); err != nil {
395 return ports.CommitPage{}, err
396 }
397 cli := func() (ports.CommitPage, error) { return a.cli.ListCommits(ctx, repo, q) }
398 route := a.routes.ListCommits
399 if !q.Path.IsRoot() {
400 route = a.routes.ListCommitsPath
401 }
402 if route == CLI {
403 return cli()
404 }
405 return serve(ctx, a, repo, func(r *git.Repo) (ports.CommitPage, error) {
406 page, err := listCommits(ctx, r, q)
407 if err != nil {
408 return ports.CommitPage{}, fmt.Errorf("history of %q: %w", repo, err)
409 }
410 return page, nil
411 }, cli)
412}
413
414// GetCommit is served by the wrapped reader: the diff stays on git.
415func (a *Adapter) GetCommit(ctx context.Context, repo domain.RepoName, rev domain.Revision, limits domain.DiffLimits) (domain.CommitDetail, error) {
416 return a.cli.GetCommit(ctx, repo, rev, limits)
417}
418
419// CountCommits is served by the wrapped reader: git counts from the commit
420// graph.
421func (a *Adapter) CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID) (int, error) {
422 return a.cli.CountCommits(ctx, repo, head)
423}
424
425// CommitTimes is served by the wrapped reader.
426func (a *Adapter) CommitTimes(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) ([]time.Time, bool, error) {
427 return a.cli.CommitTimes(ctx, repo, head, since)
428}
429
430// LatestTag is served by the wrapped reader.
431func (a *Adapter) LatestTag(ctx context.Context, repo domain.RepoName) (domain.TagInfo, bool, error) {
432 return a.cli.LatestTag(ctx, repo)
433}