v0.34.0 7c1886f

convert.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package ermitegit
4
5import (
6	"bytes"
7	"context"
8	"errors"
9	"fmt"
10	"io"
11	"strings"
12	"time"
13
14	git "bnema.dev/ermite-git"
15
16	"bnema.dev/ermite/internal/domain"
17	"bnema.dev/ermite/internal/ports"
18)
19
20const (
21	headsPrefix = "refs/heads/"
22	tagsPrefix  = "refs/tags/"
23)
24
25func domainID(id git.ObjectID) (domain.ObjectID, error) { return domain.ParseObjectID(id.String()) }
26
27// listRefs lists HEAD, then the branches, then the tags, like gitcli: a tag
28// follows to its commit when it leads to one; HEAD takes the ids of its
29// branch or tag, and only the name of an unborn branch.
30func listRefs(r *git.Repo) ([]domain.Ref, error) {
31	var refs []domain.Ref
32	byName := make(map[string]domain.Ref)
33	for _, prefix := range [...]string{headsPrefix, tagsPrefix} {
34		kind := domain.RefKindBranch
35		if prefix == tagsPrefix {
36			kind = domain.RefKindTag
37		}
38		for ref, err := range r.Refs(prefix) {
39			if err != nil {
40				return nil, fmt.Errorf("list refs: %w", err)
41			}
42			out := domain.Ref{Name: ref.Name, ShortName: ref.Name[len(prefix):], Kind: kind}
43			var err2 error
44			if out.ID, err2 = domainID(ref.ID); err2 != nil {
45				return nil, err2
46			}
47			commit, err := r.PeelRef(ref)
48			switch {
49			case err == nil:
50				if out.CommitID, err2 = domainID(commit); err2 != nil {
51					return nil, err2
52				}
53			case errors.Is(err, git.ErrWrongType):
54				// A tag of a tree or a blob has no commit.
55			case errors.Is(err, git.ErrNotFound):
56				// The ref exists and its object does not: the repository is
57				// damaged, which is not a missing repository or revision, so
58				// the error must not match domain.ErrNotFound (gitcli fails
59				// the same way). %v hides git.ErrNotFound from mapErr.
60				return nil, fmt.Errorf("ref %s points to a missing object: %v", ref.Name, err)
61			default:
62				return nil, fmt.Errorf("follow %s: %w", ref.Name, err)
63			}
64			refs = append(refs, out)
65			byName[out.Name] = out
66		}
67	}
68	head := domain.Ref{Name: "HEAD", Kind: domain.RefKindHead}
69	target, _, err := r.Head()
70	if err != nil {
71		return nil, fmt.Errorf("read HEAD: %w", err)
72	}
73	if b, ok := byName[target]; ok && target != "" {
74		head.ShortName, head.ID, head.CommitID = b.ShortName, b.ID, b.CommitID
75	} else if short, ok := cutPrefix(target, headsPrefix); ok {
76		head.ShortName = short // unborn branch
77	}
78	return append([]domain.Ref{head}, refs...), nil
79}
80
81func cutPrefix(s, prefix string) (string, bool) {
82	if len(s) >= len(prefix) && s[:len(prefix)] == prefix {
83		return s[len(prefix):], true
84	}
85	return s, false
86}
87
88// resolved is a revision resolved to a commit.
89type resolved struct {
90	commit git.ObjectID
91	ref    string
92}
93
94func (r resolved) domain() (domain.ResolvedRevision, error) {
95	id, err := domainID(r.commit)
96	return domain.ResolvedRevision{CommitID: id, Ref: r.ref}, err
97}
98
99// peelRef reads a ref and peels it to a commit. ok is false when the ref does
100// not exist or does not lead to a commit.
101func peelRef(r *git.Repo, name string) (_ git.ObjectID, ok bool, _ error) {
102	// LookupRefID allocates nothing on a miss, which is the common case of the
103	// candidates of resolveRevision (heads/x, then tags/x, then refs/...).
104	id, found, err := r.LookupRefID(name)
105	if err != nil || !found {
106		return git.ObjectID{}, false, err
107	}
108	return peelID(r, id)
109}
110
111func peelID(r *git.Repo, id git.ObjectID) (_ git.ObjectID, ok bool, _ error) {
112	commit, err := r.PeelToCommit(id)
113	if errors.Is(err, git.ErrNotFound) || errors.Is(err, git.ErrWrongType) {
114		return git.ObjectID{}, false, nil
115	}
116	if err != nil {
117		return git.ObjectID{}, false, err
118	}
119	return commit, true, nil
120}
121
122// resolveRevision performs the lookups ordered by the domain, rather than
123// Repo.Resolve, whose Git-specific resolution order differs from the port.
124func resolveRevision(r *git.Repo, rev domain.Revision) (resolved, error) {
125	for _, candidate := range rev.Candidates() {
126		c, ok, err := lookupRevisionCandidate(r, candidate)
127		if err != nil {
128			return resolved{}, err
129		}
130		if ok {
131			return resolved{commit: c, ref: candidate.ResolvedRef()}, nil
132		}
133	}
134	return resolved{}, notFound(rev)
135}
136
137// lookupRevisionCandidate translates one domain candidate to the library API.
138func lookupRevisionCandidate(r *git.Repo, candidate domain.RevisionCandidate) (git.ObjectID, bool, error) {
139	switch candidate.Kind {
140	case domain.RevisionCandidateHead, domain.RevisionCandidateRef:
141		return peelRef(r, candidate.Name)
142	case domain.RevisionCandidateObject:
143		id, err := git.ParseID(candidate.Name)
144		if err != nil {
145			return git.ObjectID{}, false, err
146		}
147		return peelID(r, id)
148	case domain.RevisionCandidateAbbrevID:
149		id, err := r.ResolveCommitPrefix(candidate.Name)
150		if err == nil {
151			// The prefix may name an annotated tag: git peels it.
152			id, err = r.PeelToCommit(id)
153		}
154		switch {
155		case err == nil:
156			return id, true, nil
157		case errors.Is(err, git.ErrNotFound), errors.Is(err, git.ErrAmbiguous), errors.Is(err, git.ErrWrongType):
158			return git.ObjectID{}, false, nil
159		default:
160			return git.ObjectID{}, false, err
161		}
162	}
163	return git.ObjectID{}, false, fmt.Errorf("unknown revision candidate kind %d", candidate.Kind)
164}
165
166func notFound(rev domain.Revision) error {
167	return fmt.Errorf("revision %q: %w", rev, domain.ErrNotFound)
168}
169
170// canonMode is the mode git reports for a tree entry: legacy permission bits
171// are normalized (tree-walk.c canon_mode).
172func canonMode(m git.Mode) uint32 {
173	switch {
174	case m.IsRegular():
175		if m&0o100 != 0 {
176			return 0o100755
177		}
178		return 0o100644
179	case m.IsSymlink():
180		return 0o120000
181	case m.IsTree():
182		return 0o040000
183	}
184	return 0o160000
185}
186
187// commitTree resolves rev and reads the root tree of the commit.
188func commitTree(r *git.Repo, rev domain.Revision) (domain.ObjectID, git.ObjectID, error) {
189	res, err := resolveRevision(r, rev)
190	if err != nil {
191		return domain.ObjectID{}, git.ObjectID{}, err
192	}
193	var c git.Commit
194	if err := r.ReadCommit(res.commit, &c); err != nil {
195		return domain.ObjectID{}, git.ObjectID{}, err
196	}
197	id, err := domainID(res.commit)
198	return id, c.Tree, err
199}
200
201// lookup finds the entry at a path of a commit tree. A path that does not
202// exist, and a submodule (whose commit is not in the repository), are
203// domain.ErrNotFound like in gitcli.
204func lookup(r *git.Repo, tree git.ObjectID, path domain.TreePath) (git.Entry, error) {
205	e, err := r.LookupPath(tree, path.String())
206	if errors.Is(err, git.ErrNotFound) {
207		return git.Entry{}, fmt.Errorf("path %q: %w", path, domain.ErrNotFound)
208	}
209	if err != nil {
210		return git.Entry{}, err
211	}
212	if e.Mode.IsGitlink() {
213		return git.Entry{}, fmt.Errorf("path %q: %w", path, domain.ErrNotFound)
214	}
215	return e, nil
216}
217
218// getTree lists a directory.
219//
220//hexcheck:ignore suspicious-business-logic-in-adapter converts ermite-git values into domain values; the limits and the orders come from domain
221func getTree(ctx context.Context, r *git.Repo, rev domain.Revision, path domain.TreePath, includeLast bool) (domain.Tree, error) {
222	commit, root, err := commitTree(r, rev)
223	if err != nil {
224		return domain.Tree{}, err
225	}
226	dir, err := lookup(r, root, path)
227	if err != nil {
228		return domain.Tree{}, err
229	}
230	if !dir.Mode.IsTree() {
231		return domain.Tree{}, domain.ErrNotDirectory
232	}
233	treeID, err := domainID(dir.ID)
234	if err != nil {
235		return domain.Tree{}, err
236	}
237	tree := domain.Tree{CommitID: commit, ID: treeID}
238	// The ids, the names and the paths of all the entries share one backing
239	// string, cut into substrings.
240	type span struct {
241		name, path, id [2]int
242		mode           uint32
243		kind           domain.EntryKind
244	}
245	var (
246		spans []span
247		buf   []byte
248		bad   error
249	)
250	prefix := path.String()
251	err = r.ReadTree(dir.ID, func(e git.Entry) bool {
252		mode := canonMode(e.Mode)
253		kind := domain.EntryKindOf(mode)
254		if kind == "" {
255			bad = fmt.Errorf("unknown mode %o of %q", uint32(e.Mode), e.Name)
256			return false
257		}
258		var sp span
259		sp.mode, sp.kind = mode, kind
260		sp.id[0] = len(buf)
261		buf = e.ID.AppendHex(buf)
262		sp.id[1] = len(buf)
263		sp.name[0] = len(buf)
264		buf = append(buf, e.Name...)
265		sp.name[1] = len(buf)
266		if prefix == "" {
267			sp.path = sp.name
268		} else {
269			sp.path[0] = len(buf)
270			buf = append(buf, prefix...)
271			buf = append(buf, '/')
272			buf = append(buf, e.Name...)
273			sp.path[1] = len(buf)
274		}
275		spans = append(spans, sp)
276		return true
277	})
278	if err == nil {
279		err = bad
280	}
281	if err != nil {
282		return domain.Tree{}, fmt.Errorf("list tree %q: %w", path, err)
283	}
284	text := string(buf)
285	tree.Entries = make([]domain.TreeEntry, len(spans))
286	for i, sp := range spans {
287		id, err := domain.ParseObjectID(text[sp.id[0]:sp.id[1]])
288		if err != nil {
289			return domain.Tree{}, err
290		}
291		tree.Entries[i] = domain.TreeEntry{
292			Name: text[sp.name[0]:sp.name[1]], Path: text[sp.path[0]:sp.path[1]],
293			Kind: sp.kind, Mode: sp.mode, ID: id,
294		}
295	}
296	domain.SortTreeEntries(tree.Entries)
297	tree.BoundTree()
298	// The size of a file costs a header read: only for the entries that stay.
299	for i, e := range tree.Entries {
300		if e.Kind == domain.EntryDirectory || e.Kind == domain.EntrySubmodule {
301			continue
302		}
303		id, err := git.ParseID(e.ID.String())
304		if err != nil {
305			return domain.Tree{}, err
306		}
307		_, size, err := r.ObjectHeader(id)
308		if err != nil {
309			return domain.Tree{}, fmt.Errorf("size of %q: %w", e.Path, err)
310		}
311		tree.Entries[i].Size = size
312	}
313	if includeLast {
314		if err := fillLastCommits(ctx, r, commit, path, &tree); err != nil {
315			return domain.Tree{}, fmt.Errorf("last commits of %q: %w", path, err)
316		}
317	}
318	return tree, nil
319}
320
321// pathChunk is the size of the blocks that hold the bytes of the paths.
322const pathChunk = 32 << 10
323
324// pathWalker lists the files below a tree, depth first, in tree order.
325type pathWalker struct {
326	ctx      context.Context
327	r        *git.Repo
328	maxPaths int
329	maxBytes int
330
331	// list collects the paths. Their bytes live in chunks: a chunk is filled
332	// by a strings.Builder, so a path is read back as a string without a copy,
333	// and a full chunk is left to the paths that point into it instead of
334	// being grown and copied.
335	list   domain.PathList
336	chunk  strings.Builder
337	prefix []byte // "dir/sub/" of the directory being read
338	bad    error
339}
340
341// listPaths lists the files and symbolic links of a revision recursively.
342func listPaths(ctx context.Context, r *git.Repo, rev domain.Revision, maxPaths, maxBytes int) (domain.PathList, error) {
343	commit, root, err := commitTree(r, rev)
344	if err != nil {
345		return domain.PathList{}, err
346	}
347	treeID, err := domainID(root)
348	if err != nil {
349		return domain.PathList{}, err
350	}
351	w := &pathWalker{ctx: ctx, r: r, maxPaths: maxPaths, maxBytes: maxBytes}
352	w.list.CommitID, w.list.TreeID = commit, treeID
353	if err := w.walk(root); err != nil {
354		return domain.PathList{}, err
355	}
356	return w.list, nil
357}
358
359// walk lists one directory. The recursion is inside the callback of ReadTree,
360// where the entries of a subdirectory come at the place of the subdirectory,
361// like in `git ls-tree -r`; the callback can only say stop, so the reasons to
362// stop are kept in the walker. Every ReadTree takes its own pooled buffer, so
363// the nesting is safe.
364func (w *pathWalker) walk(tree git.ObjectID) error {
365	if err := w.ctx.Err(); err != nil {
366		return err
367	}
368	err := w.r.ReadTree(tree, func(e git.Entry) bool {
369		switch {
370		case e.Mode.IsGitlink():
371			return true
372		case e.Mode.IsTree():
373			mark := len(w.prefix)
374			w.prefix = append(w.prefix, e.Name...)
375			// The paths below a directory that is not addressable are not
376			// either: this also bounds the depth of the recursion.
377			if !domain.IsAddressablePathBytes(w.prefix) {
378				w.prefix = w.prefix[:mark]
379				return true
380			}
381			w.prefix = append(w.prefix, '/')
382			err := w.walk(e.ID)
383			w.prefix = w.prefix[:mark]
384			if err != nil {
385				w.bad = err
386				return false
387			}
388			return !w.list.Truncated
389		case e.Mode.IsRegular(), e.Mode.IsSymlink():
390			return w.add(e.Name)
391		}
392		w.bad = fmt.Errorf("unknown mode %o of %q", uint32(e.Mode), e.Name)
393		return false
394	})
395	if err == nil {
396		err = w.bad
397	}
398	return err
399}
400
401// add lists the file name of the current directory, and says whether the walk
402// goes on. PathList.Add filters the paths that are not addressable; their
403// bytes stay in the chunk, which is rare enough not to matter.
404func (w *pathWalker) add(name []byte) bool {
405	mark := len(w.prefix)
406	w.prefix = append(w.prefix, name...)
407	path := w.prefix
408	w.prefix = w.prefix[:mark]
409	if w.chunk.Cap()-w.chunk.Len() < len(path) {
410		w.chunk = strings.Builder{}
411		w.chunk.Grow(max(pathChunk, len(path)))
412	}
413	start := w.chunk.Len()
414	w.chunk.Write(path)
415	return w.list.Add(w.chunk.String()[start:], w.maxPaths, w.maxBytes)
416}
417
418// fillLastCommits sets the last commit of the first MaxLastCommitEntries
419// entries with one walk.
420//
421//hexcheck:ignore suspicious-business-logic-in-adapter converts ermite-git values into domain values; the limits and the orders come from domain
422func fillLastCommits(ctx context.Context, r *git.Repo, commit domain.ObjectID, dir domain.TreePath, tree *domain.Tree) error {
423	n := len(tree.Entries)
424	if n > domain.MaxLastCommitEntries {
425		n = domain.MaxLastCommitEntries
426		tree.LastCommitsOmitted = true
427	}
428	if n == 0 {
429		return nil
430	}
431	start, err := git.ParseID(commit.String())
432	if err != nil {
433		return err
434	}
435	names := make([]string, n)
436	for i := range n {
437		names[i] = tree.Entries[i].Name
438	}
439	ids, err := r.LastCommits(ctx, start, dir.String(), names)
440	if err != nil {
441		return err
442	}
443	var c git.Commit
444	seen := make(map[git.ObjectID]domain.Commit, 8)
445	for i, id := range ids {
446		if id.IsZero() {
447			return fmt.Errorf("no commit touches %q: %w", tree.Entries[i].Path, domain.ErrNotFound)
448		}
449		dc, ok := seen[id]
450		if !ok {
451			if err := r.ReadCommit(id, &c); err != nil {
452				return err
453			}
454			if dc, err = convertCommit(&c); err != nil {
455				return err
456			}
457			seen[id] = dc
458		}
459		cp := dc
460		tree.Entries[i].LastCommit = &cp
461	}
462	return nil
463}
464
465// convertCommit builds a domain commit. A commit in another encoding than
466// UTF-8 is converted by git and not by the library: errFallback.
467//
468// The ids, the names, the emails and the message share one backing string, cut
469// into substrings: one allocation for the text of a commit instead of one per
470// field.
471func convertCommit(c *git.Commit) (domain.Commit, error) {
472	if len(c.Encoding) > 0 && !isUTF8Label(c.Encoding) {
473		return domain.Commit{}, errFallback
474	}
475	buf := make([]byte, 0, 40*(1+len(c.Parents))+len(c.Author.Name)+len(c.Author.Email)+
476		len(c.Committer.Name)+len(c.Committer.Email)+len(c.Message))
477	buf = c.ID.AppendHex(buf)
478	for _, p := range c.Parents {
479		buf = p.AppendHex(buf)
480	}
481	end := [5]int{}
482	for i, f := range [...][]byte{c.Author.Name, c.Author.Email, c.Committer.Name, c.Committer.Email, c.Message} {
483		if i == 0 || i == 2 {
484			f = trimNameRight(f)
485		}
486		buf = append(buf, f...)
487		end[i] = len(buf)
488	}
489	text := string(buf)
490
491	id, err := domain.ParseObjectID(text[:40])
492	if err != nil {
493		return domain.Commit{}, err
494	}
495	out := domain.Commit{ID: id}
496	if len(c.Parents) > 0 {
497		out.Parents = make([]domain.ObjectID, len(c.Parents))
498		for i := range c.Parents {
499			if out.Parents[i], err = domain.ParseObjectID(text[40*(i+1) : 40*(i+2)]); err != nil {
500				return domain.Commit{}, err
501			}
502		}
503	}
504	at := 40 * (1 + len(c.Parents))
505	out.Author = domain.Signature{
506		Name: domain.ValidGitText(text[at:end[0]]), Email: domain.ValidGitText(text[end[0]:end[1]]),
507		Time: time.Unix(c.Author.Time, 0).UTC(),
508	}
509	out.Committer = domain.Signature{
510		Name: domain.ValidGitText(text[end[1]:end[2]]), Email: domain.ValidGitText(text[end[2]:end[3]]),
511		Time: time.Unix(c.Committer.Time, 0).UTC(),
512	}
513	out.Message, out.Summary = domain.SplitCommitMessage(text[end[3]:])
514	return out, nil
515}
516
517// trimNameRight drops the trailing whitespace of a name the way git does (the
518// library trims spaces only).
519func trimNameRight(b []byte) []byte { return bytes.TrimRight(b, " \t\r\n") }
520
521func isUTF8Label(b []byte) bool {
522	return bytes.EqualFold(b, []byte("utf-8")) || bytes.EqualFold(b, []byte("utf8"))
523}
524
525// listCommits returns a page of history, one commit more than the page to
526// know whether the history goes on.
527func listCommits(ctx context.Context, r *git.Repo, q ports.CommitQuery) (ports.CommitPage, error) {
528	res, err := resolveRevision(r, q.Revision)
529	if err != nil {
530		return ports.CommitPage{}, err
531	}
532	head, err := res.domain()
533	if err != nil {
534		return ports.CommitPage{}, err
535	}
536	page := ports.CommitPage{Head: head.CommitID, Commits: make([]domain.Commit, 0, min(q.Limit, 64))}
537	opts := git.WalkOptions{Skip: q.Skip, Limit: q.Limit + 1, Path: q.Path.String()}
538	for c, err := range r.Walk(ctx, res.commit, opts) {
539		if err != nil {
540			return ports.CommitPage{}, err
541		}
542		if len(page.Commits) == q.Limit {
543			page.HasMore = true
544			break
545		}
546		dc, err := convertCommit(c)
547		if err != nil {
548			return ports.CommitPage{}, err
549		}
550		page.Commits = append(page.Commits, dc)
551	}
552	if len(page.Commits) == 0 {
553		page.Commits = nil // like gitcli: an empty page has no slice
554	}
555	return page, nil
556}
557
558// openBlob describes the file at path and opens its content. The first
559// BinarySniffLen bytes are read to decide Binary and MediaType, then given
560// back to the reader.
561//
562//hexcheck:ignore suspicious-business-logic-in-adapter converts ermite-git values into domain values; the limits and the orders come from domain
563func openBlob(ctx context.Context, r *git.Repo, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) {
564	if path.IsRoot() {
565		return domain.BlobInfo{}, nil, domain.ErrNotFile
566	}
567	_, root, err := commitTree(r, rev)
568	if err != nil {
569		return domain.BlobInfo{}, nil, err
570	}
571	e, err := lookup(r, root, path)
572	if err != nil {
573		return domain.BlobInfo{}, nil, err
574	}
575	if e.Mode.IsTree() {
576		return domain.BlobInfo{}, nil, domain.ErrNotFile
577	}
578	id, err := domainID(e.ID)
579	if err != nil {
580		return domain.BlobInfo{}, nil, err
581	}
582	size, rc, err := r.OpenBlob(e.ID)
583	if err != nil {
584		return domain.BlobInfo{}, nil, err
585	}
586	info := domain.BlobInfo{ID: id, Path: path.String(), Size: size, Mode: canonMode(e.Mode)}
587	head := make([]byte, min(size, int64(domain.BinarySniffLen)))
588	if _, err := io.ReadFull(rc, head); err != nil {
589		_ = rc.Close()
590		return domain.BlobInfo{}, nil, fmt.Errorf("read blob: %w", err)
591	}
592	info.SniffContent(head)
593	return info, &blobReader{ctx: ctx, head: head, rc: rc}, nil
594}
595
596// blobReader replays the bytes read to sniff the content, then the rest of
597// the blob. It stops when the context ends, as a gitcli stream does.
598type blobReader struct {
599	ctx  context.Context
600	head []byte
601	rc   io.ReadCloser
602}
603
604func (b *blobReader) Read(p []byte) (int, error) {
605	if err := b.ctx.Err(); err != nil {
606		return 0, err
607	}
608	if len(b.head) > 0 {
609		n := copy(p, b.head)
610		b.head = b.head[n:]
611		return n, nil
612	}
613	return b.rc.Read(p)
614}
615
616func (b *blobReader) Close() error { return b.rc.Close() }

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