v0.28.0 fca9155

browse.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6	"context"
7	"crypto/sha256"
8	"encoding/base64"
9	"encoding/hex"
10	"fmt"
11	"io"
12	"slices"
13	"strconv"
14	"strings"
15
16	"bnema.dev/ermite/internal/domain"
17	"bnema.dev/ermite/internal/ports"
18)
19
20// Browse are the read use cases over the Git data of a repository: refs,
21// trees, blobs, history and commits. Every method applies the access rule
22// first: a repository the principal may not read, or that does not exist,
23// answers domain.ErrNotFound before the Git reader is touched.
24type Browse struct {
25	meta   ports.RepoMetaStore
26	reader ports.RepoReader
27	// history keeps the answers about a commit designated by its full id (a
28	// tree with the last commit of its entries, the latest commit of a path):
29	// they never change, and computing them costs a history walk. Nil
30	// disables it.
31	history *historyCache
32	// stats keeps the figures of the home page.
33	stats statsCache
34	// verifier fills the signature state of the commits; nil leaves it
35	// unchecked.
36	verifier ports.CommitVerifier
37}
38
39var _ ports.BrowseService = (*Browse)(nil)
40
41// NewBrowse builds the read use cases over their ports, with a history cache
42// of the default bounds.
43func NewBrowse(meta ports.RepoMetaStore, reader ports.RepoReader) *Browse {
44	return &Browse{
45		meta: meta, reader: reader,
46		history: newHistoryCache(DefaultHistoryCacheEntries, DefaultHistoryCacheBytes),
47	}
48}
49
50// WithHistoryCache replaces the bounds of the history cache: at most
51// maxEntries answers and about maxBytes of memory, least recently used first
52// out. A bound that is not positive disables the cache. Call it before the
53// first request.
54func (b *Browse) WithHistoryCache(maxEntries int, maxBytes int64) *Browse {
55	b.history = newHistoryCache(maxEntries, maxBytes)
56	return b
57}
58
59// WithVerifier sets the checker of commit signatures (ADR 0030): commits
60// returned by ListCommits and GetCommit then carry their Verification. Call
61// it before the first request.
62func (b *Browse) WithVerifier(v ports.CommitVerifier) *Browse {
63	b.verifier = v
64	return b
65}
66
67// verify fills the Verification of the commits, in place, with one call. The
68// slice must not be shared with a cache.
69func (b *Browse) verify(ctx context.Context, repo domain.RepoName, commits []domain.Commit) {
70	verifyCommits(ctx, b.verifier, repo, commits)
71}
72
73// verifyCommits fills the Verification of the commits in place, with one call
74// to v; a nil v leaves them unchecked.
75func verifyCommits(ctx context.Context, v ports.CommitVerifier, repo domain.RepoName, commits []domain.Commit) {
76	if v == nil || len(commits) == 0 {
77		return
78	}
79	res := v.VerifyCommits(ctx, repo, commits)
80	for i := range commits {
81		if v, ok := res[commits[i].ID]; ok {
82			commits[i].Verification = v
83		}
84	}
85}
86
87// authorize returns the repository if p may read it. The caller uses it to
88// tell a repository from a later one created under the same name.
89func (b *Browse) authorize(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) {
90	repo, err := lookupRepo(ctx, b.meta, name)
91	if err != nil {
92		return domain.Repo{}, err
93	}
94	if !canRead(p, repo) {
95		return domain.Repo{}, domain.ErrNotFound
96	}
97	return repo, nil
98}
99
100// repoEpoch identifies one life of a repository name: its creation time in
101// nanoseconds, 0 if unknown.
102func repoEpoch(r domain.Repo) int64 {
103	if r.CreatedAt.IsZero() {
104		return 0
105	}
106	return r.CreatedAt.UnixNano()
107}
108
109func kindRank(k domain.RefKind) int {
110	switch k {
111	case domain.RefKindHead:
112		return 0
113	case domain.RefKindBranch:
114		return 1
115	}
116	return 2
117}
118
119// ListRefs lists HEAD, the branches and the tags, in that order, then by
120// name. The page token names the last ref of the previous page, so refs that
121// appear or disappear between two pages never shift the others.
122func (b *Browse) ListRefs(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.ListRefsParams) (ports.RefPage, error) {
123	size, err := pageSizeUpTo(params.PageSize, MaxRefPageSize)
124	if err != nil {
125		return ports.RefPage{}, err
126	}
127	for _, k := range params.Kinds {
128		if k != domain.RefKindHead && k != domain.RefKindBranch && k != domain.RefKindTag {
129			return ports.RefPage{}, fmt.Errorf("%w: %q", domain.ErrInvalidRefKind, k)
130		}
131	}
132	after, err := decodeRefToken(params.PageToken)
133	if err != nil {
134		return ports.RefPage{}, err
135	}
136	if _, err := b.authorize(ctx, p, repo); err != nil {
137		return ports.RefPage{}, err
138	}
139	all, err := b.reader.ListRefs(ctx, repo)
140	if err != nil {
141		return ports.RefPage{}, err
142	}
143
144	var wanted []domain.Ref
145	for _, r := range all {
146		if len(params.Kinds) == 0 || slices.Contains(params.Kinds, r.Kind) {
147			wanted = append(wanted, r)
148		}
149	}
150	var page ports.RefPage
151	page.Refs, page.NextPageToken = pageAfter(wanted, size,
152		func(r domain.Ref) bool { return after == nil || refAfter(r, *after) },
153		encodeRefToken)
154	return page, nil
155}
156
157// refAfter reports whether r sorts after the ref the token names.
158func refAfter(r domain.Ref, last refCursor) bool {
159	if kr, lr := kindRank(r.Kind), kindRank(last.kind); kr != lr {
160		return kr > lr
161	}
162	return r.Name > last.name
163}
164
165// refCursor is the decoded page token of ListRefs.
166type refCursor struct {
167	kind domain.RefKind
168	name string
169}
170
171const (
172	refTokenPrefix    = "r1:"
173	commitTokenPrefix = "c1:"
174)
175
176func encodeRefToken(last domain.Ref) string {
177	return base64.RawURLEncoding.EncodeToString([]byte(refTokenPrefix + string(last.Kind) + ":" + last.Name))
178}
179
180// decodeRefToken returns nil for the empty token.
181func decodeRefToken(tok string) (*refCursor, error) {
182	if tok == "" {
183		return nil, nil
184	}
185	raw, ok := decodeToken(tok, refTokenPrefix)
186	if !ok {
187		return nil, fmt.Errorf("%w: refs", domain.ErrInvalidPageToken)
188	}
189	kind, name, ok := strings.Cut(raw, ":")
190	k := domain.RefKind(kind)
191	if !ok || name == "" || (k != domain.RefKindHead && k != domain.RefKindBranch && k != domain.RefKindTag) {
192		return nil, fmt.Errorf("%w: refs", domain.ErrInvalidPageToken)
193	}
194	return &refCursor{kind: k, name: name}, nil
195}
196
197// ResolveRevision turns a revision into the commit it designates.
198func (b *Browse) ResolveRevision(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error) {
199	if _, err := b.authorize(ctx, p, repo); err != nil {
200		return domain.ResolvedRevision{}, err
201	}
202	return b.reader.ResolveRevision(ctx, repo, rev)
203}
204
205// ResolveRefPath splits a path such as "feature/x/dir/file" into the longest
206// leading ref and the path inside it.
207func (b *Browse) ResolveRefPath(ctx context.Context, p domain.Principal, repo domain.RepoName, path string) (domain.RefPath, error) {
208	if _, err := b.authorize(ctx, p, repo); err != nil {
209		return domain.RefPath{}, err
210	}
211	return b.reader.ResolveRefPath(ctx, repo, path)
212}
213
214// GetTree lists a directory at a revision. With includeLastCommit, entries get
215// their last commit, for at most domain.MaxLastCommitEntries of them.
216func (b *Browse) GetTree(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool) (domain.Tree, error) {
217	r, err := b.authorize(ctx, p, repo)
218	if err != nil {
219		return domain.Tree{}, err
220	}
221	// The last commits are the costly part (a history walk per entry) and
222	// depend only on the commit and the path: cache them once the revision is
223	// a full commit id. A name or HEAD can move, so it is never a key.
224	if !includeLastCommit || !rev.IsObjectID() {
225		return b.reader.GetTree(ctx, repo, rev, path, includeLastCommit)
226	}
227	key := historyKey{repo: repo, created: repoEpoch(r), rev: rev.String(), path: path.String(), kind: kindTreeLast}
228	if e, ok := b.history.get(key); ok {
229		return cloneTree(e.tree), nil
230	}
231	tree, err := b.reader.GetTree(ctx, repo, rev, path, true)
232	if err != nil {
233		return domain.Tree{}, err
234	}
235	b.history.put(treeEntry(key, tree))
236	return tree, nil
237}
238
239// ListPaths lists the paths of every file and symbolic link of a revision, at
240// most domain.MaxPathEntries paths and domain.MaxPathBytes bytes of paths.
241func (b *Browse) ListPaths(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.PathList, error) {
242	if _, err := b.authorize(ctx, p, repo); err != nil {
243		return domain.PathList{}, err
244	}
245	return b.reader.ListPaths(ctx, repo, rev, domain.MaxPathEntries, domain.MaxPathBytes)
246}
247
248// OpenBlob returns the description of a file and a reader of its content. The
249// caller closes the reader.
250func (b *Browse) OpenBlob(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) {
251	if _, err := b.authorize(ctx, p, repo); err != nil {
252		return domain.BlobInfo{}, nil, err
253	}
254	return b.reader.OpenBlob(ctx, repo, rev, path)
255}
256
257// ListCommits returns a page of history. The page token holds the commit the
258// listing started from, how many commits were already returned and a digest
259// of the path; a token used with another path is invalid.
260func (b *Browse) ListCommits(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.ListCommitsParams) (ports.HistoryPage, error) {
261	size, err := pageSize(params.PageSize)
262	if err != nil {
263		return ports.HistoryPage{}, err
264	}
265	q := ports.CommitQuery{Revision: params.Revision, Path: params.Path, Limit: size}
266	if params.PageToken != "" {
267		head, skip, err := decodeCommitToken(params.PageToken, params.Path)
268		if err != nil {
269			return ports.HistoryPage{}, err
270		}
271		q.Revision, q.Skip = domain.RevisionOf(head), skip
272	}
273	r, err := b.authorize(ctx, p, repo)
274	if err != nil {
275		return ports.HistoryPage{}, err
276	}
277	res, err := b.listCommits(ctx, r, q)
278	if err != nil {
279		return ports.HistoryPage{}, err
280	}
281	// res.Commits is a copy (see listCommits): the cached page is untouched.
282	b.verify(ctx, repo, res.Commits)
283	out := ports.HistoryPage{Commits: res.Commits}
284	if res.HasMore {
285		out.NextPageToken = encodeCommitToken(res.Head, q.Skip+len(res.Commits), params.Path)
286	}
287	return out, nil
288}
289
290// listCommits asks the reader, through the history cache for the first
291// commit of a path at a full commit id (the "last change" of a file): that
292// answer is immutable.
293func (b *Browse) listCommits(ctx context.Context, r domain.Repo, q ports.CommitQuery) (ports.CommitPage, error) {
294	repo := r.Name
295	if q.Limit != 1 || q.Skip != 0 || !q.Revision.IsObjectID() {
296		return b.reader.ListCommits(ctx, repo, q)
297	}
298	key := historyKey{repo: r.Name, created: repoEpoch(r), rev: q.Revision.String(), path: q.Path.String(), kind: kindPage, limit: q.Limit}
299	if e, ok := b.history.get(key); ok {
300		return clonePage(e.page), nil
301	}
302	res, err := b.reader.ListCommits(ctx, repo, q)
303	if err != nil {
304		return ports.CommitPage{}, err
305	}
306	b.history.put(pageEntry(key, res))
307	return res, nil
308}
309
310func pathDigest(path domain.TreePath) string {
311	sum := sha256.Sum256([]byte(path.String()))
312	return hex.EncodeToString(sum[:6])
313}
314
315func encodeCommitToken(head domain.ObjectID, skip int, path domain.TreePath) string {
316	raw := commitTokenPrefix + head.String() + ":" + strconv.Itoa(skip) + ":" + pathDigest(path)
317	return base64.RawURLEncoding.EncodeToString([]byte(raw))
318}
319
320func decodeCommitToken(tok string, path domain.TreePath) (domain.ObjectID, int, error) {
321	bad := fmt.Errorf("%w: commits", domain.ErrInvalidPageToken)
322	raw, ok := decodeToken(tok, commitTokenPrefix)
323	if !ok {
324		return domain.ObjectID{}, 0, bad
325	}
326	parts := strings.Split(raw, ":")
327	if len(parts) != 3 {
328		return domain.ObjectID{}, 0, bad
329	}
330	head, err := domain.ParseObjectID(parts[0])
331	skip, err2 := strconv.Atoi(parts[1])
332	if err != nil || err2 != nil || skip < 0 || skip > MaxCommitSkip || parts[2] != pathDigest(path) {
333		return domain.ObjectID{}, 0, bad
334	}
335	return head, skip, nil
336}
337
338// GetCommit returns a commit with its statistics and its diff against the
339// first parent, within the default diff limits.
340func (b *Browse) GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error) {
341	if _, err := b.authorize(ctx, p, repo); err != nil {
342		return domain.CommitDetail{}, err
343	}
344	d, err := b.reader.GetCommit(ctx, repo, rev, domain.DefaultDiffLimits())
345	if err != nil {
346		return domain.CommitDetail{}, err
347	}
348	if b.verifier != nil {
349		one := []domain.Commit{d.Commit}
350		b.verify(ctx, repo, one)
351		d.Commit = one[0]
352	}
353	return d, nil
354}

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 2

Tags 44

↑↓ moveenter openesc close