v0.8.1 b8e893f

ermitegit.go RawHistoryFolder

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}

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 3

Tags 44

↑↓ moveenter openesc close