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

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