v0.25.1 7fabed9

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

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 45

↑↓ moveenter openesc close