v0.5.0 bfab294

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