v0.28.0 fca9155

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

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