v0.2.0 41cf2ec

read.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package gitcli
4
5import (
6	"bufio"
7	"context"
8	"errors"
9	"fmt"
10	"io"
11	"io/fs"
12	"os"
13	"path/filepath"
14	"slices"
15	"strconv"
16	"strings"
17	"sync"
18	"time"
19
20	"bnema.dev/ermite/internal/domain"
21	"bnema.dev/ermite/internal/ports"
22)
23
24var _ ports.RepoReader = (*Git)(nil)
25
26const (
27	headsRefPrefix = "refs/heads/"
28	tagsRefPrefix  = "refs/tags/"
29	// maxRefLine bounds a line of for-each-ref.
30	maxRefLine = 16 * 1024
31)
32
33// notFound reports whether err means a missing object.
34func notFound(err error) bool { return errors.Is(err, errObjectMissing) }
35
36// ListRefs lists HEAD, the branches and the tags with `git for-each-ref`.
37// An annotated tag is followed to its commit; a tag of a tag is followed with
38// one `cat-file` request each.
39//
40//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain refs
41func (g *Git) ListRefs(ctx context.Context, repo domain.RepoName) ([]domain.Ref, error) {
42	dir, err := g.repoDir(repo)
43	if err != nil {
44		return nil, err
45	}
46	type raw struct {
47		name, id, typ, derefID, derefType string
48	}
49	var raws []raw
50	args := readArgs(dir, "for-each-ref",
51		"--format=%(refname)%00%(objectname)%00%(objecttype)%00%(*objectname)%00%(*objecttype)",
52		headsRefPrefix, tagsRefPrefix)
53	err = g.pipe(ctx, args, func(r *bufio.Reader) error {
54		lr := newLineReader(r, maxRefLine)
55		for {
56			line, _, err := lr.next()
57			if errors.Is(err, io.EOF) {
58				return nil
59			}
60			if err != nil {
61				return err
62			}
63			f := strings.Split(string(line), "\x00")
64			if len(f) != 5 {
65				return fmt.Errorf("git for-each-ref: unexpected line %q", truncateText(string(line)))
66			}
67			raws = append(raws, raw{f[0], f[1], f[2], f[3], f[4]})
68		}
69	})
70	if err != nil {
71		return nil, fmt.Errorf("list refs of %q: %w", repo, err)
72	}
73
74	refs := make([]domain.Ref, 0, len(raws)+1)
75	byName := make(map[string]domain.Ref, len(raws))
76	var nested []int // indices in refs of tags that point to a tag
77	for _, rw := range raws {
78		ref := domain.Ref{Name: rw.name}
79		switch {
80		case strings.HasPrefix(rw.name, headsRefPrefix):
81			ref.Kind, ref.ShortName = domain.RefKindBranch, strings.TrimPrefix(rw.name, headsRefPrefix)
82		case strings.HasPrefix(rw.name, tagsRefPrefix):
83			ref.Kind, ref.ShortName = domain.RefKindTag, strings.TrimPrefix(rw.name, tagsRefPrefix)
84		default:
85			continue
86		}
87		if ref.ID, err = domain.ParseObjectID(rw.id); err != nil {
88			return nil, fmt.Errorf("list refs of %q: %w", repo, err)
89		}
90		switch {
91		case rw.typ == "commit":
92			ref.CommitID = ref.ID
93		case rw.typ == "tag" && rw.derefType == "commit":
94			if ref.CommitID, err = domain.ParseObjectID(rw.derefID); err != nil {
95				return nil, fmt.Errorf("list refs of %q: %w", repo, err)
96			}
97		case rw.typ == "tag" && rw.derefType == "tag":
98			nested = append(nested, len(refs))
99		}
100		refs = append(refs, ref)
101		byName[ref.Name] = ref
102	}
103	if len(nested) > 0 {
104		err := g.cat.do(ctx, dir, func(c *catConn) error {
105			for _, i := range nested {
106				h, err := c.info(refs[i].Name + "^{commit}")
107				if notFound(err) {
108					continue
109				}
110				if err != nil {
111					return err
112				}
113				if refs[i].CommitID, err = domain.ParseObjectID(h.ID); err != nil {
114					return err
115				}
116			}
117			return nil
118		})
119		if err != nil {
120			return nil, fmt.Errorf("follow tags of %q: %w", repo, err)
121		}
122		for _, i := range nested {
123			byName[refs[i].Name] = refs[i]
124		}
125	}
126
127	head := domain.Ref{Name: "HEAD", Kind: domain.RefKindHead}
128	target, err := readHead(dir)
129	if err != nil {
130		return nil, fmt.Errorf("read HEAD of %q: %w", repo, err)
131	}
132	if b, ok := byName[target]; ok && target != "" {
133		head.ShortName, head.ID, head.CommitID = b.ShortName, b.ID, b.CommitID
134	} else if short, ok := strings.CutPrefix(target, headsRefPrefix); ok {
135		head.ShortName = short // unborn branch
136	}
137	return append([]domain.Ref{head}, refs...), nil
138}
139
140// readHead returns the ref HEAD of the bare repository in dir points to, or
141// "" if HEAD is detached. It reads the HEAD file (the repositories use the
142// files ref backend), which is what `git symbolic-ref HEAD` would answer
143// without starting a process. A missing file is domain.ErrNotFound.
144func readHead(dir string) (string, error) {
145	raw, err := os.ReadFile(filepath.Join(dir, "HEAD"))
146	if err != nil {
147		if errors.Is(err, fs.ErrNotExist) {
148			return "", domain.ErrNotFound
149		}
150		return "", fmt.Errorf("read HEAD: %w", err)
151	}
152	ref, ok := strings.CutPrefix(strings.TrimSpace(string(raw)), "ref: ")
153	if !ok {
154		return "", nil // a commit id: detached
155	}
156	return ref, nil
157}
158
159// revCandidate is one way to read a revision as a commit.
160type revCandidate struct {
161	spec string // object expression for cat-file
162	ref  string // full ref name it stands for, "" for ids and HEAD
163}
164
165// candidates lists, in order, how a revision is tried. A full object id is an
166// object id first: a branch or a tag that happens to be named like a commit
167// must not redirect a link to that commit; refs are tried only when no such
168// object exists. Otherwise it is the branch, then the tag, then (for "HEAD",
169// full ref names and abbreviations) as written. Other forms Git understands,
170// such as "main~2" or "FETCH_HEAD", are not revisions of ermite.
171func candidates(rev domain.Revision) []revCandidate {
172	s := rev.String()
173	switch {
174	case rev.IsHead() || s == "HEAD":
175		return []revCandidate{{spec: "HEAD^{commit}"}}
176	}
177	refs := []revCandidate{
178		{spec: headsRefPrefix + s + "^{commit}", ref: headsRefPrefix + s},
179		{spec: tagsRefPrefix + s + "^{commit}", ref: tagsRefPrefix + s},
180	}
181	switch {
182	case rev.IsObjectID():
183		return append([]revCandidate{{spec: s + "^{commit}"}}, refs...)
184	case strings.HasPrefix(s, "refs/"):
185		return append(refs, revCandidate{spec: s + "^{commit}", ref: s})
186	case rev.IsAbbrevID():
187		return append(refs, revCandidate{spec: s + "^{commit}"})
188	}
189	return refs
190}
191
192// resolve resolves rev on an open cat-file connection.
193//
194//hexcheck:ignore suspicious-business-logic-in-adapter maps a revision to git object names, a git detail of the reader
195func resolve(c *catConn, rev domain.Revision) (domain.ResolvedRevision, error) {
196	for _, cand := range candidates(rev) {
197		h, err := c.info(cand.spec)
198		if notFound(err) {
199			continue
200		}
201		if err != nil {
202			return domain.ResolvedRevision{}, err
203		}
204		if h.Type != "commit" {
205			continue
206		}
207		id, err := domain.ParseObjectID(h.ID)
208		if err != nil {
209			return domain.ResolvedRevision{}, c.fail(err)
210		}
211		return domain.ResolvedRevision{CommitID: id, Ref: cand.ref}, nil
212	}
213	return domain.ResolvedRevision{}, fmt.Errorf("revision %q: %w", rev, domain.ErrNotFound)
214}
215
216// ResolveRevision resolves a revision with `cat-file`.
217//
218//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port
219func (g *Git) ResolveRevision(ctx context.Context, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error) {
220	dir, err := g.repoDir(repo)
221	if err != nil {
222		return domain.ResolvedRevision{}, err
223	}
224	var res domain.ResolvedRevision
225	err = g.cat.do(ctx, dir, func(c *catConn) (err error) {
226		res, err = resolve(c, rev)
227		return err
228	})
229	if err != nil {
230		return domain.ResolvedRevision{}, fmt.Errorf("resolve %q in %q: %w", rev, repo, err)
231	}
232	return res, nil
233}
234
235// maxRefPathTries bounds the number of leading segments tried as a ref name.
236const maxRefPathTries = 32
237
238// ResolveRefPath tries the leading segments of path as a revision (branch,
239// tag, full ref name, HEAD; a single segment may also be a commit id), longest
240// first, with up to one `cat-file` request per candidate on the repository's
241// process. At most 32 segments are tried.
242//
243//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port
244func (g *Git) ResolveRefPath(ctx context.Context, repo domain.RepoName, path string) (domain.RefPath, error) {
245	dir, err := g.repoDir(repo)
246	if err != nil {
247		return domain.RefPath{}, err
248	}
249	path = strings.Trim(path, "/")
250	segs := strings.Split(path, "/")
251	if path == "" || slices.Contains(segs, "") {
252		return domain.RefPath{}, fmt.Errorf("path %q: %w", path, domain.ErrNotFound)
253	}
254	var out domain.RefPath
255	err = g.cat.do(ctx, dir, func(c *catConn) error {
256		out = domain.RefPath{}
257		for n := min(len(segs), maxRefPathTries); n >= 1; n-- {
258			name := strings.Join(segs[:n], "/")
259			rev, err := domain.ParseRevision(name)
260			if err != nil {
261				continue
262			}
263			res, err := resolve(c, rev)
264			if errors.Is(err, domain.ErrNotFound) {
265				continue
266			}
267			if err != nil {
268				return err
269			}
270			rest, err := domain.ParseTreePath(strings.Join(segs[n:], "/"))
271			if err != nil {
272				return err
273			}
274			out = domain.RefPath{Name: name, Ref: res.Ref, CommitID: res.CommitID, Rest: rest}
275			return nil
276		}
277		return fmt.Errorf("path %q: %w", path, domain.ErrNotFound)
278	})
279	if err != nil {
280		return domain.RefPath{}, fmt.Errorf("resolve path %q in %q: %w", path, repo, err)
281	}
282	return out, nil
283}
284
285// object is what lookup finds at a path of a revision.
286type object struct {
287	commit domain.ObjectID
288	info   catInfo
289}
290
291// lookup resolves rev and reads the header of the object at path, in one
292// request series on the repository's process. A missing path is
293// domain.ErrNotFound.
294func (g *Git) lookup(ctx context.Context, dir string, rev domain.Revision, path domain.TreePath) (object, error) {
295	var obj object
296	err := g.cat.do(ctx, dir, func(c *catConn) error {
297		res, err := resolve(c, rev)
298		if err != nil {
299			return err
300		}
301		spec := res.CommitID.String() + "^{tree}"
302		if !path.IsRoot() {
303			spec = res.CommitID.String() + ":" + path.String()
304		}
305		h, err := c.info(spec)
306		if notFound(err) {
307			return fmt.Errorf("path %q: %w", path, domain.ErrNotFound)
308		}
309		if err != nil {
310			return err
311		}
312		obj = object{commit: res.CommitID, info: h}
313		return nil
314	})
315	return obj, err
316}
317
318// GetTree lists a directory with `git ls-tree -z -l`.
319//
320//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port
321func (g *Git) GetTree(ctx context.Context, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLast bool) (domain.Tree, error) {
322	dir, err := g.repoDir(repo)
323	if err != nil {
324		return domain.Tree{}, err
325	}
326	obj, err := g.lookup(ctx, dir, rev, path)
327	if err != nil {
328		return domain.Tree{}, fmt.Errorf("tree %q of %q: %w", path, repo, err)
329	}
330	if obj.info.Type != "tree" {
331		return domain.Tree{}, fmt.Errorf("tree %q of %q: %w", path, repo, domain.ErrNotDirectory)
332	}
333	treeID, err := domain.ParseObjectID(obj.info.ID)
334	if err != nil {
335		return domain.Tree{}, err
336	}
337	tree := domain.Tree{CommitID: obj.commit, ID: treeID}
338
339	err = g.pipe(ctx, readArgs(dir, "ls-tree", "-z", "-l", treeID.String()), func(r *bufio.Reader) error {
340		for {
341			tok, err := readToken(r)
342			if errors.Is(err, io.EOF) {
343				return nil
344			}
345			if err != nil {
346				return err
347			}
348			e, err := parseTreeEntry(tok, path)
349			if err != nil {
350				return err
351			}
352			tree.Entries = append(tree.Entries, e)
353		}
354	})
355	if err != nil {
356		return domain.Tree{}, fmt.Errorf("list tree %q of %q: %w", path, repo, err)
357	}
358	slices.SortStableFunc(tree.Entries, func(a, b domain.TreeEntry) int {
359		ad, bd := a.Kind == domain.EntryDirectory, b.Kind == domain.EntryDirectory
360		switch {
361		case ad && !bd:
362			return -1
363		case !ad && bd:
364			return 1
365		}
366		return strings.Compare(a.Name, b.Name)
367	})
368
369	if len(tree.Entries) > domain.MaxTreeEntries {
370		tree.Entries, tree.Truncated = tree.Entries[:domain.MaxTreeEntries], true
371	}
372	if includeLast {
373		if err := g.fillLastCommits(ctx, dir, &tree); err != nil {
374			return domain.Tree{}, fmt.Errorf("last commits of %q in %q: %w", path, repo, err)
375		}
376	}
377	return tree, nil
378}
379
380// parseTreeEntry parses "<mode> <type> <id> <size>\t<name>" of ls-tree -l.
381//
382//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port
383func parseTreeEntry(rec string, parent domain.TreePath) (domain.TreeEntry, error) {
384	meta, name, ok := strings.Cut(rec, "\t")
385	f := strings.Fields(meta)
386	if !ok || len(f) != 4 || name == "" {
387		return domain.TreeEntry{}, fmt.Errorf("git ls-tree: unexpected entry %q", truncateText(rec))
388	}
389	mode, err := strconv.ParseUint(f[0], 8, 32)
390	if err != nil {
391		return domain.TreeEntry{}, fmt.Errorf("git ls-tree: mode of %q: %w", truncateText(rec), err)
392	}
393	id, err := domain.ParseObjectID(f[2])
394	if err != nil {
395		return domain.TreeEntry{}, fmt.Errorf("git ls-tree: %w", err)
396	}
397	var size int64
398	if f[3] != "-" {
399		if size, err = strconv.ParseInt(f[3], 10, 64); err != nil {
400			return domain.TreeEntry{}, fmt.Errorf("git ls-tree: size of %q: %w", truncateText(rec), err)
401		}
402	}
403	kind := domain.EntryKindOf(uint32(mode))
404	if kind == "" {
405		return domain.TreeEntry{}, fmt.Errorf("git ls-tree: unknown mode %s of %q", f[0], name)
406	}
407	return domain.TreeEntry{
408		Name: name, Path: parent.Join(name), Kind: kind, Mode: uint32(mode), ID: id, Size: size,
409	}, nil
410}
411
412// fillLastCommits sets the last commit of the first MaxLastCommitEntries
413// entries with `git log -1 <commit> -- <path>`, run by a small pool of
414// workers: min(4, max(1, MaxExec/2)) at most. Git answers each from the
415// commit-graph and its changed-path filters when they exist. Every command
416// takes a slot of the exec semaphore on its own (the workers hold none), so a
417// large directory cannot use more than its share of the processes the server
418// may run. Each worker writes its own entry, so the result does not depend on
419// the scheduling; the first error stops the others and is returned. Entries
420// beyond the bound are left without one and the tree says so.
421//
422//hexcheck:ignore suspicious-business-logic-in-adapter fills the tree with git output; the bound comes from the port
423func (g *Git) fillLastCommits(ctx context.Context, dir string, tree *domain.Tree) error {
424	n := len(tree.Entries)
425	if n > domain.MaxLastCommitEntries {
426		n = domain.MaxLastCommitEntries
427		tree.LastCommitsOmitted = true
428	}
429	workers := min(4, max(1, cap(g.exec)/2), n)
430	if workers == 0 {
431		return nil
432	}
433	ctx, cancel := context.WithCancel(ctx)
434	defer cancel()
435	var (
436		wg       sync.WaitGroup
437		mu       sync.Mutex
438		firstErr error
439	)
440	next := make(chan int)
441	for range workers {
442		wg.Go(func() {
443			for i := range next {
444				c, err := g.lastCommit(ctx, dir, tree.CommitID, tree.Entries[i].Path)
445				if err != nil {
446					mu.Lock()
447					if firstErr == nil {
448						firstErr = err
449						cancel()
450					}
451					mu.Unlock()
452					continue
453				}
454				tree.Entries[i].LastCommit = &c
455			}
456		})
457	}
458feed:
459	for i := range n {
460		select {
461		case next <- i:
462		case <-ctx.Done():
463			break feed
464		}
465	}
466	close(next)
467	wg.Wait()
468	if firstErr != nil {
469		return firstErr
470	}
471	return ctx.Err()
472}
473
474// lastCommit returns the newest commit reachable from head that changed path;
475// an empty path means head itself.
476//
477//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port
478func (g *Git) lastCommit(ctx context.Context, dir string, head domain.ObjectID, path string) (domain.Commit, error) {
479	var c domain.Commit
480	found := false
481	args := readArgs(dir, "log", "-z", "-n", "1", "--format="+commitFormat, "--end-of-options", head.String())
482	if path != "" {
483		args = append(args, "--", path)
484	}
485	err := g.pipe(ctx, args, func(r *bufio.Reader) (err error) {
486		c, err = readCommit(r)
487		if errors.Is(err, io.EOF) {
488			return nil
489		}
490		found = err == nil
491		return err
492	})
493	if err != nil {
494		return domain.Commit{}, err
495	}
496	if !found {
497		return domain.Commit{}, fmt.Errorf("no commit touches %q: %w", path, domain.ErrNotFound)
498	}
499	return c, nil
500}
501
502// commitFormat is the `git log` format of a commit: NUL separated fields,
503// the message last. With -z the commits are NUL terminated, so a commit is
504// nine tokens.
505const commitFormat = "%H%x00%P%x00%an%x00%ae%x00%at%x00%cn%x00%ce%x00%ct%x00%B"
506
507// readCommit reads one commit in commitFormat. It returns io.EOF at the end
508// of the output.
509//
510//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port
511func readCommit(r *bufio.Reader) (domain.Commit, error) {
512	var f [9]string
513	for i := range f {
514		tok, err := readToken(r)
515		if err != nil {
516			if i > 0 && errors.Is(err, io.EOF) {
517				err = io.ErrUnexpectedEOF
518			}
519			return domain.Commit{}, err
520		}
521		f[i] = tok
522	}
523	id, err := domain.ParseObjectID(f[0])
524	if err != nil {
525		return domain.Commit{}, fmt.Errorf("git log: %w", err)
526	}
527	c := domain.Commit{ID: id}
528	for p := range strings.FieldsSeq(f[1]) {
529		pid, err := domain.ParseObjectID(p)
530		if err != nil {
531			return domain.Commit{}, fmt.Errorf("git log: parent of %s: %w", id, err)
532		}
533		c.Parents = append(c.Parents, pid)
534	}
535	if c.Author, err = signature(f[2], f[3], f[4]); err != nil {
536		return domain.Commit{}, fmt.Errorf("git log: author of %s: %w", id, err)
537	}
538	if c.Committer, err = signature(f[5], f[6], f[7]); err != nil {
539		return domain.Commit{}, fmt.Errorf("git log: committer of %s: %w", id, err)
540	}
541	c.Message = strings.TrimRight(strings.ToValidUTF8(f[8], "\uFFFD"), "\n")
542	c.Summary, _, _ = strings.Cut(c.Message, "\n")
543	return c, nil
544}
545
546func signature(name, email, unix string) (domain.Signature, error) {
547	sec, err := strconv.ParseInt(unix, 10, 64)
548	if err != nil {
549		return domain.Signature{}, err
550	}
551	return domain.Signature{
552		Name:  strings.ToValidUTF8(name, "\uFFFD"),
553		Email: strings.ToValidUTF8(email, "\uFFFD"),
554		Time:  time.Unix(sec, 0).UTC(),
555	}, nil
556}
557
558// ListCommits pages through `git log`, which stops after Skip+Limit+1 commits.
559//
560//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port
561func (g *Git) ListCommits(ctx context.Context, repo domain.RepoName, q ports.CommitQuery) (ports.CommitPage, error) {
562	if q.Limit < 1 || q.Limit > ports.MaxCommitLimit || q.Skip < 0 {
563		return ports.CommitPage{}, fmt.Errorf("list commits: %w: limit %d, skip %d", domain.ErrInvalidPageSize, q.Limit, q.Skip)
564	}
565	dir, err := g.repoDir(repo)
566	if err != nil {
567		return ports.CommitPage{}, err
568	}
569	var head domain.ResolvedRevision
570	err = g.cat.do(ctx, dir, func(c *catConn) (err error) {
571		head, err = resolve(c, q.Revision)
572		return err
573	})
574	if err != nil {
575		return ports.CommitPage{}, fmt.Errorf("history of %q: %w", repo, err)
576	}
577	page := ports.CommitPage{Head: head.CommitID}
578	args := readArgs(dir, "log", "-z", "--skip="+strconv.Itoa(q.Skip), "-n", strconv.Itoa(q.Limit+1),
579		"--format="+commitFormat, "--end-of-options", head.CommitID.String())
580	if !q.Path.IsRoot() {
581		args = append(args, "--", q.Path.String())
582	}
583	err = g.pipe(ctx, args, func(r *bufio.Reader) error {
584		for {
585			c, err := readCommit(r)
586			if errors.Is(err, io.EOF) {
587				return nil
588			}
589			if err != nil {
590				return err
591			}
592			if len(page.Commits) == q.Limit {
593				page.HasMore = true
594				return errStop
595			}
596			page.Commits = append(page.Commits, c)
597		}
598	})
599	if err != nil {
600		return ports.CommitPage{}, fmt.Errorf("history of %q: %w", repo, err)
601	}
602	return page, nil
603}

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 4

Tags 45

↑↓ moveenter openesc close