v0.36.0 1fb2421

git.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package domain
4
5import (
6	"errors"
7	"fmt"
8	"slices"
9	"strings"
10	"time"
11	"unicode/utf8"
12)
13
14// Errors of the read side. Callers match them with errors.Is; the rpc adapter
15// maps all of them but the not found ones to invalid_argument.
16var (
17	// ErrInvalidObjectID means a string is not a full SHA-1 object id.
18	ErrInvalidObjectID = errors.New("invalid object id")
19	// ErrInvalidRevision means a revision breaks the rules of ParseRevision.
20	ErrInvalidRevision = errors.New("invalid revision")
21	// ErrInvalidPath means a path inside a repository breaks the rules of
22	// ParseTreePath.
23	ErrInvalidPath = errors.New("invalid path")
24	// ErrInvalidPageToken means a page token was not produced by the server
25	// for this kind of listing.
26	ErrInvalidPageToken = errors.New("invalid page token")
27	// ErrInvalidPageSize means a page size is negative.
28	ErrInvalidPageSize = errors.New("invalid page size")
29	// ErrInvalidRefKind means a ref kind is not one of the known kinds.
30	ErrInvalidRefKind = errors.New("invalid ref kind")
31	// ErrNotDirectory means a path that must name a directory names something else.
32	ErrNotDirectory = errors.New("not a directory")
33	// ErrNotFile means a path that must name a file names something else.
34	ErrNotFile = errors.New("not a file")
35)
36
37// ObjectIDLen is the length of an object id in hexadecimal characters.
38// ermite creates SHA-1 repositories only.
39const ObjectIDLen = 40
40
41// ObjectID is a full, lowercase, hexadecimal SHA-1 object id. Only
42// ParseObjectID builds a non-zero value; the zero value means "no object".
43type ObjectID struct{ s string }
44
45// ParseObjectID validates s as a full object id.
46func ParseObjectID(s string) (ObjectID, error) {
47	if len(s) != ObjectIDLen || !isLowerHex(s) {
48		return ObjectID{}, fmt.Errorf("%w: %q", ErrInvalidObjectID, s)
49	}
50	return ObjectID{s: s}, nil
51}
52
53// String returns the id, or "" for the zero value.
54func (id ObjectID) String() string { return id.s }
55
56// IsZero reports whether id is the zero value.
57func (id ObjectID) IsZero() bool { return id.s == "" }
58
59func isLowerHex(s string) bool {
60	for i := range len(s) {
61		c := s[i]
62		if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
63			return false
64		}
65	}
66	return s != ""
67}
68
69// MinAbbrevLen is the shortest abbreviation of an object id accepted as a
70// revision.
71const MinAbbrevLen = 7
72
73// maxRevisionLen bounds a revision, which is a ref name or an object id.
74const maxRevisionLen = 512
75
76// Revision names a commit: a branch, a tag, a full ref name ("refs/heads/x"),
77// an object id or an abbreviation of at least MinAbbrevLen characters. The
78// zero value is HEAD. Parsing checks the syntax only, with the rules of
79// `git check-ref-format`; whether the revision exists is the reader's concern.
80type Revision struct{ s string }
81
82// ParseRevision validates s. The empty string is HEAD.
83func ParseRevision(s string) (Revision, error) {
84	if s == "" {
85		return Revision{}, nil
86	}
87	if reason := refNameProblem(s); reason != "" {
88		return Revision{}, fmt.Errorf("%w: %q: %s", ErrInvalidRevision, s, reason)
89	}
90	return Revision{s: s}, nil
91}
92
93// RevisionOf returns the revision that designates exactly the commit id.
94func RevisionOf(id ObjectID) Revision { return Revision(id) }
95
96// String returns the revision text, or "" for HEAD.
97func (r Revision) String() string { return r.s }
98
99// IsHead reports whether r is the zero value, which designates HEAD.
100func (r Revision) IsHead() bool { return r.s == "" }
101
102// IsObjectID reports whether r is a full object id.
103func (r Revision) IsObjectID() bool { return len(r.s) == ObjectIDLen && isLowerHex(r.s) }
104
105// IsAbbrevID reports whether r is a hexadecimal abbreviation of an object id
106// (MinAbbrevLen to ObjectIDLen-1 characters).
107func (r Revision) IsAbbrevID() bool {
108	return len(r.s) >= MinAbbrevLen && len(r.s) < ObjectIDLen && isLowerHex(r.s)
109}
110
111// refNameProblem returns why s cannot be a ref name or revision, or "".
112func refNameProblem(s string) string {
113	switch {
114	case len(s) > maxRevisionLen:
115		return "too long"
116	case !utf8.ValidString(s):
117		return "not valid UTF-8"
118	case strings.HasPrefix(s, "-"):
119		return "starts with a dash"
120	case strings.HasPrefix(s, "/") || strings.HasSuffix(s, "/"):
121		return "starts or ends with a slash"
122	case strings.HasSuffix(s, "."):
123		return "ends with a dot"
124	case s == "@":
125		return "is @"
126	case strings.Contains(s, ".."), strings.Contains(s, "@{"), strings.Contains(s, "//"):
127		return "contains a forbidden sequence"
128	}
129	for _, c := range s {
130		if c < 0x20 || c == 0x7f || strings.ContainsRune(" ~^:?*[\\", c) {
131			return "contains a forbidden character"
132		}
133	}
134	for part := range strings.SplitSeq(s, "/") {
135		if strings.HasPrefix(part, ".") || strings.HasSuffix(part, ".lock") {
136			return "has a component that starts with a dot or ends with .lock"
137		}
138	}
139	return ""
140}
141
142// maxTreePathLen bounds a path inside a repository, as Linux does.
143const maxTreePathLen = 4096
144
145// TreePath is a slash-separated path from the root of a repository, without
146// leading or trailing slash. The zero value is the root. Only ParseTreePath
147// builds a non-root value.
148type TreePath struct{ s string }
149
150// ParseTreePath validates s. It accepts one trailing slash, which it drops,
151// and the empty string (the root). Empty, "." and ".." components, NUL and
152// control characters, and text that is not valid UTF-8 are rejected.
153func ParseTreePath(s string) (TreePath, error) {
154	s = strings.TrimSuffix(s, "/")
155	if s == "" {
156		return TreePath{}, nil
157	}
158	if why := treePathProblem(s); why != "" {
159		return TreePath{}, fmt.Errorf("%w: %q: %s", ErrInvalidPath, truncate(s), why)
160	}
161	return TreePath{s: s}, nil
162}
163
164// IsAddressablePath reports whether s is a non-root path that ParseTreePath
165// accepts as it is (no trailing slash). It allocates nothing.
166func IsAddressablePath(s string) bool {
167	return s != "" && treePathProblem(s) == ""
168}
169
170// IsAddressablePathBytes is IsAddressablePath for a path held in bytes; it
171// spares the copy into a string.
172func IsAddressablePathBytes(b []byte) bool {
173	return len(b) > 0 && len(b) <= maxTreePathLen && utf8.Valid(b) && pathShapeProblem(b) == ""
174}
175
176// treePathProblem returns why s cannot be a non-root tree path, or "".
177func treePathProblem(s string) string {
178	if len(s) > maxTreePathLen || !utf8.ValidString(s) {
179		return "too long or not valid UTF-8"
180	}
181	return pathShapeProblem(s)
182}
183
184// pathShapeProblem checks the control characters and the components of a path
185// that is valid UTF-8 and short enough. The bytes of a multi-byte character
186// are all 0x80 or more, so it looks at bytes.
187func pathShapeProblem[S ~string | ~[]byte](s S) string {
188	start := 0
189	for i := 0; i <= len(s); i++ {
190		if i < len(s) && s[i] != '/' {
191			if c := s[i]; c < 0x20 || c == 0x7f {
192				return "control character"
193			}
194			continue
195		}
196		n := i - start
197		if n == 0 || (n == 1 && s[start] == '.') || (n == 2 && s[start] == '.' && s[start+1] == '.') {
198			return "empty, . or .. component"
199		}
200		start = i + 1
201	}
202	return ""
203}
204
205func truncate(s string) string {
206	if len(s) > 64 {
207		return s[:64] + "…"
208	}
209	return s
210}
211
212// String returns the path, or "" for the root.
213func (p TreePath) String() string { return p.s }
214
215// IsRoot reports whether p is the root of the repository.
216func (p TreePath) IsRoot() bool { return p.s == "" }
217
218// Join returns the path of the entry name inside the directory p. name must
219// be a single component.
220func (p TreePath) Join(name string) string {
221	if p.s == "" {
222		return name
223	}
224	return p.s + "/" + name
225}
226
227// RefKind is the kind of a Git ref.
228type RefKind string
229
230// Ref kinds, in the order refs are listed.
231const (
232	// RefKindHead is the symbolic ref HEAD.
233	RefKindHead RefKind = "head"
234	// RefKindBranch is a branch (refs/heads/*).
235	RefKindBranch RefKind = "branch"
236	// RefKindTag is a tag (refs/tags/*), lightweight or annotated.
237	RefKindTag RefKind = "tag"
238)
239
240// Ref is a branch, a tag or HEAD.
241type Ref struct {
242	// Name is the full name: "refs/heads/main", "refs/tags/v1" or "HEAD".
243	Name string
244	// ShortName is Name without "refs/heads/" or "refs/tags/". For HEAD it is
245	// the short name of the default branch, empty if that branch is unborn.
246	ShortName string
247	Kind      RefKind
248	// ID is the object the ref points to (the tag object of an annotated
249	// tag). Zero for HEAD when the default branch is unborn.
250	ID ObjectID
251	// CommitID is the commit the ref designates once tags are followed. Zero
252	// for a tag that does not lead to a commit and for an unborn HEAD.
253	CommitID ObjectID
254}
255
256// TagInfo is a tag and when it was made: the tagger date of an annotated
257// tag, the committer date of the commit of a lightweight one.
258type TagInfo struct {
259	Name string // short name, without refs/tags/
260	At   time.Time
261	// CommitID is the commit the tag leads to; zero for a tag of a tree or
262	// a blob.
263	CommitID ObjectID
264}
265
266// ResolvedRevision is the result of resolving a Revision.
267type ResolvedRevision struct {
268	// CommitID is the commit the revision designates, tags followed.
269	CommitID ObjectID
270	// Ref is the full name of the ref the revision matched; empty when the
271	// revision was an object id or HEAD.
272	Ref string
273}
274
275// RefPath is a URL path split into a revision and the path inside it. It is
276// the answer to "feature/x/dir/file": the ref feature/x and the path dir/file.
277type RefPath struct {
278	// Name is the matched ref's short name, or the object id text.
279	Name string
280	// Ref is the matched ref's full name; empty when Name is an object id.
281	Ref string
282	// CommitID is the commit Name designates.
283	CommitID ObjectID
284	// Rest is what follows Name in the path, without leading slash.
285	Rest TreePath
286}
287
288// EntryKind is the kind of an entry of a Git tree.
289type EntryKind string
290
291// Entry kinds.
292const (
293	// EntryFile is a regular file (mode 100644).
294	EntryFile EntryKind = "file"
295	// EntryExecutable is an executable file (mode 100755).
296	EntryExecutable EntryKind = "executable"
297	// EntryDirectory is a directory (mode 040000).
298	EntryDirectory EntryKind = "directory"
299	// EntrySymlink is a symbolic link (mode 120000).
300	EntrySymlink EntryKind = "symlink"
301	// EntrySubmodule is a submodule commit (mode 160000).
302	EntrySubmodule EntryKind = "submodule"
303)
304
305// File mode bits as Git stores them.
306const (
307	modeTypeMask  = 0o170000
308	modeDirectory = 0o040000
309	modeSymlink   = 0o120000
310	modeRegular   = 0o100000
311	modeSubmodule = 0o160000
312)
313
314// EntryKindOf returns the kind of an entry from its Git mode, or "" if the
315// mode is not a Git mode.
316func EntryKindOf(mode uint32) EntryKind {
317	switch mode & modeTypeMask {
318	case modeDirectory:
319		return EntryDirectory
320	case modeSymlink:
321		return EntrySymlink
322	case modeSubmodule:
323		return EntrySubmodule
324	case modeRegular:
325		if mode&0o111 != 0 {
326			return EntryExecutable
327		}
328		return EntryFile
329	}
330	return ""
331}
332
333// TreeEntry is one entry of a directory.
334type TreeEntry struct {
335	// Name is the name in its directory; Path is the path from the root.
336	Name, Path string
337	Kind       EntryKind
338	Mode       uint32
339	// ID is the blob, tree or submodule commit.
340	ID ObjectID
341	// Size is the size of a file or symlink; 0 for directories and submodules.
342	Size int64
343	// LastCommit is the latest commit that changed the entry; nil unless
344	// asked for, and for the entries the reader left out (see
345	// Tree.LastCommitsOmitted).
346	LastCommit *Commit
347}
348
349// Tree is a listed directory.
350type Tree struct {
351	// CommitID is the commit the revision resolved to.
352	CommitID ObjectID
353	// ID is the tree object that was listed.
354	ID ObjectID
355	// Entries are the directories first, then the other entries, each group
356	// ordered by name.
357	Entries []TreeEntry
358	// LastCommitsOmitted is true when last commits were asked for and the
359	// reader gave only the first entries (MaxLastCommitEntries) one.
360	LastCommitsOmitted bool
361	// Truncated is true when the directory has more than MaxTreeEntries
362	// entries: Entries holds the first ones in the documented order.
363	Truncated bool
364}
365
366// MaxTreeEntries bounds the entries of a listed directory.
367const MaxTreeEntries = 5000
368
369// MaxPathEntries bounds the paths ListPaths returns.
370const MaxPathEntries = 100_000
371
372// MaxPathBytes bounds the total bytes of the paths ListPaths returns.
373const MaxPathBytes = 8 << 20
374
375// PathList is the recursive list of the file paths of a revision.
376type PathList struct {
377	// CommitID is the commit the revision resolved to.
378	CommitID ObjectID
379	// TreeID is the root tree of that commit.
380	TreeID ObjectID
381	// Paths are the paths of the files and symbolic links, in tree order (the
382	// order of `git ls-tree -r`).
383	Paths []string
384	// Truncated is true when more paths exist than the ones in Paths.
385	Truncated bool
386
387	size int // bytes of Paths, kept by Add
388}
389
390// Add lists p, the next path of a walk in tree order, within the bounds of
391// ListPaths: at most maxPaths paths and maxBytes bytes of paths. A path that
392// is not addressable (IsAddressablePath) is left out. When a bound is reached
393// p is not listed, Truncated is set, and Add returns false: the walk can stop.
394// Otherwise it returns true. The bytes of p are kept, not copied.
395func (l *PathList) Add(p string, maxPaths, maxBytes int) bool {
396	if !IsAddressablePath(p) {
397		return true
398	}
399	if len(l.Paths) >= maxPaths || l.size+len(p) > maxBytes {
400		l.Truncated = true
401		return false
402	}
403	l.size += len(p)
404	l.Paths = append(l.Paths, p)
405	return true
406}
407
408// MaxLastCommitEntries bounds the entries of one directory that get a last
409// commit: each costs a history lookup.
410const MaxLastCommitEntries = 200
411
412// BlobInfo describes a file at a revision.
413type BlobInfo struct {
414	ID   ObjectID
415	Path string
416	Size int64
417	// Binary follows Git's rule: a NUL byte in the first 8,000 bytes.
418	Binary bool
419	// MediaType comes from the file name and the first bytes, without
420	// parameters; "application/octet-stream" when unknown.
421	MediaType string
422	Mode      uint32
423}
424
425// BinarySniffLen is how many leading bytes decide whether a blob is binary.
426const BinarySniffLen = 8000
427
428// Signature is who did something in Git, and when.
429type Signature struct {
430	Name, Email string
431	Time        time.Time
432}
433
434// Commit is the metadata of a commit.
435type Commit struct {
436	ID        ObjectID
437	Parents   []ObjectID
438	Author    Signature
439	Committer Signature
440	// Summary is the first line of Message.
441	Summary string
442	// Message is the whole message without trailing line breaks, converted to
443	// valid UTF-8.
444	Message string
445	// Verification is the signature state (ADR 0030). The readers leave it
446	// zero (unchecked); the browse use case fills it.
447	Verification CommitVerification
448}
449
450// FileStatus tells what a commit did to a file.
451type FileStatus string
452
453// File statuses.
454const (
455	FileAdded       FileStatus = "added"
456	FileDeleted     FileStatus = "deleted"
457	FileModified    FileStatus = "modified"
458	FileRenamed     FileStatus = "renamed"
459	FileCopied      FileStatus = "copied"
460	FileTypeChanged FileStatus = "type_changed"
461)
462
463// LineKind tells how a line of a hunk relates to the two sides of a diff.
464type LineKind string
465
466// Line kinds.
467const (
468	LineContext  LineKind = "context"
469	LineAddition LineKind = "addition"
470	LineDeletion LineKind = "deletion"
471)
472
473// Line is one line of a hunk.
474type Line struct {
475	Kind LineKind
476	// OldNumber is 0 for an addition; NewNumber is 0 for a deletion.
477	OldNumber, NewNumber int
478	// Text has no line terminator and is valid UTF-8.
479	Text string
480	// NoNewlineAtEOF is set on the last line of a side that has no final line
481	// break.
482	NoNewlineAtEOF bool
483}
484
485// Hunk is a contiguous changed region with its context.
486type Hunk struct {
487	OldStart, OldLines int
488	NewStart, NewLines int
489	// Header is the text after the second "@@", often the enclosing function.
490	Header string
491	Lines  []Line
492}
493
494// FileDiff is the change of one file.
495type FileDiff struct {
496	// OldPath is empty for an added file, NewPath for a deleted one.
497	OldPath, NewPath string
498	Status           FileStatus
499	// OldMode and NewMode are 0 for an added and a deleted file.
500	OldMode, NewMode uint32
501	// OldID and NewID are the blob ids, zero for an added and a deleted file.
502	OldID, NewID ObjectID
503	// Binary is Git's numstat "-": no hunks, no additions or deletions.
504	Binary               bool
505	Additions, Deletions int
506	// Similarity is a percentage for a renamed or copied file, 0 otherwise.
507	Similarity int
508	// Omitted tells why the hunks were not kept: Hunks is empty although the
509	// file changed; Additions and Deletions stay accurate. A file outside the
510	// window is OmitNone with no Hunks.
511	Omitted FileOmission
512	Hunks   []Hunk
513}
514
515// CommentPath is the path a comment designates for f, as Diff.HasLine reads it:
516// the old path of a deleted file, the new path otherwise.
517func (f FileDiff) CommentPath() string {
518	if f.Status == FileDeleted {
519		return f.OldPath
520	}
521	return f.NewPath
522}
523
524// FileOmission tells why a changed file has no hunks.
525type FileOmission uint8
526
527// File omissions.
528const (
529	OmitNone        FileOmission = iota
530	OmitGenerated                // SkipGenerated and IsGeneratedPath: hunks not read
531	OmitLarge                    // changed lines > MaxFileLines
532	OmitBudget                   // did not fit in the remaining MaxTotalLines
533	OmitUnavailable              // retained text would exceed MaxTotalBytes
534)
535
536// Collapsed reports a file the web view folds and can load on its own.
537func (o FileOmission) Collapsed() bool {
538	return o == OmitGenerated || o == OmitLarge || o == OmitBudget
539}
540
541// Diff is the structured difference a commit introduces against its first
542// parent, or against the empty tree for a root commit.
543type Diff struct {
544	// Files are ordered as Git reports them (by path). Files past
545	// DiffLimits.MaxFiles (when it is not 0) are left out.
546	Files []FileDiff
547	// Truncated is true when files past DiffLimits.MaxFiles were left out. It
548	// says nothing of the listed files: see FileDiff.Omitted.
549	Truncated bool
550}
551
552// DiffStats are the totals over every changed file, including the ones the
553// diff leaves out or truncates. Binary files count 0 lines.
554type DiffStats struct {
555	FilesChanged, Additions, Deletions int
556}
557
558// CommitDetail is a commit with its statistics and diff.
559type CommitDetail struct {
560	Commit Commit
561	Stats  DiffStats
562	Diff   Diff
563}
564
565// DiffLimits bound the size of a Diff. Line limits count additions plus
566// deletions, not context. A file that does not fit is listed without hunks and
567// with an Omitted reason.
568type DiffLimits struct {
569	// MaxFiles is the number of files kept; 0 means no limit on the number of
570	// files (every changed file is listed).
571	MaxFiles int
572	// MaxFileLines is the most changed lines one file may have; a file over it is
573	// OmitLarge. 0 means no limit.
574	MaxFileLines int
575	// MaxTotalLines is the most changed lines of all the files. A file is
576	// OmitBudget when it does not fit in what remains; a smaller one after it
577	// may still fit. 0 means no limit.
578	MaxTotalLines int
579	// MaxTotalBytes is the most text, in bytes, of all the lines, including context. It bounds
580	// the memory of a diff whose lines are very long; a file whose
581	// text does not fit is OmitUnavailable.
582	MaxTotalBytes int
583	// From and To select the files whose hunks are read: those with an index in
584	// [From, To), counted from 0 in the order of Diff.Files. The other files
585	// are listed (paths, status, modes, ids, Binary, Additions, Deletions,
586	// Similarity) without hunks; they are OmitNone and use none of
587	// MaxTotalLines and MaxTotalBytes. To == 0 means no end, so the zero values
588	// select every file. MaxFileLines applies to each file of the window.
589	From, To int
590	// MaxAloneLines is the most changed lines a file may have to be read at all;
591	// a file over it is OmitUnavailable (it cannot be shown even alone), unlike
592	// OmitLarge, which a read of that file alone can load. 0 means no limit.
593	MaxAloneLines int
594	// SkipGenerated omits the files of the window that IsGeneratedPath
595	// recognizes (OmitGenerated), without reading their hunks.
596	SkipGenerated bool
597}
598
599// MaxOneFileLines is the most changed lines of a file read alone on the web
600// (OneFileDiffLimits). A file over it is OmitUnavailable, so a short-lines
601// file within the byte guard cannot produce millions of rows.
602const MaxOneFileLines = 50000
603
604// OneFileDiffLimits reads the hunks of file i only, with no MaxFileLines cap;
605// MaxAloneLines (MaxOneFileLines) and MaxTotalBytes are the guards. i must be at least 0: From -1 with To 0
606// would mean the whole diff, so a negative i reads no file at all.
607func OneFileDiffLimits(i int) DiffLimits {
608	if i < 0 {
609		return DiffLimits{From: 1, To: 1, MaxAloneLines: MaxOneFileLines, MaxTotalBytes: DefaultDiffLimits().MaxTotalBytes}
610	}
611	return DiffLimits{From: i, To: i + 1, MaxAloneLines: MaxOneFileLines, MaxTotalBytes: DefaultDiffLimits().MaxTotalBytes}
612}
613
614// DefaultDiffLimits are the limits of the API contract: 300 files, 2,000
615// changed lines per file, 20,000 changed lines in total, plus 8 MiB of text
616// including context. Generated files are not skipped.
617func DefaultDiffLimits() DiffLimits {
618	return DiffLimits{MaxFiles: 300, MaxFileLines: 2000, MaxTotalLines: 20000, MaxTotalBytes: 8 << 20}
619}
620
621// ParseBranchName validates s as the short name of a branch ("feat/x"), with
622// the syntax rules of ParseRevision, and rejects a full ref name, which would
623// not be short.
624func ParseBranchName(s string) (string, error) {
625	switch {
626	case s == "":
627		return "", fmt.Errorf("%w: empty branch name", ErrInvalidRevision)
628	case strings.HasPrefix(s, "refs/"):
629		return "", fmt.Errorf("%w: %q: give the short name of the branch", ErrInvalidRevision, s)
630	}
631	if reason := refNameProblem(s); reason != "" {
632		return "", fmt.Errorf("%w: %q: %s", ErrInvalidRevision, s, reason)
633	}
634	return s, nil
635}
636
637// MaxRefPathTries bounds the number of leading segments of a path tried as a
638// ref name by SplitRefPath.
639const MaxRefPathTries = 32
640
641// SplitRefPath splits a path such as "feature/x/dir/file" into the longest
642// leading part that resolve accepts as a revision and the rest. resolve
643// reports domain.ErrNotFound for a name that is not a revision; any other
644// error stops the search. At most MaxRefPathTries leading segments are tried.
645// It returns ErrNotFound if nothing matches.
646func SplitRefPath(path string, resolve func(Revision) (ResolvedRevision, error)) (RefPath, error) {
647	path = strings.Trim(path, "/")
648	segs := strings.Split(path, "/")
649	if path == "" || slices.Contains(segs, "") {
650		return RefPath{}, fmt.Errorf("path %q: %w", path, ErrNotFound)
651	}
652	for n := min(len(segs), MaxRefPathTries); n >= 1; n-- {
653		name := strings.Join(segs[:n], "/")
654		rev, err := ParseRevision(name)
655		if err != nil {
656			continue
657		}
658		res, err := resolve(rev)
659		if errors.Is(err, ErrNotFound) {
660			continue
661		}
662		if err != nil {
663			return RefPath{}, err
664		}
665		rest, err := ParseTreePath(strings.Join(segs[n:], "/"))
666		if err != nil {
667			return RefPath{}, err
668		}
669		return RefPath{Name: name, Ref: res.Ref, CommitID: res.CommitID, Rest: rest}, nil
670	}
671	return RefPath{}, fmt.Errorf("path %q: %w", path, ErrNotFound)
672}
673
674// SortTreeEntries orders the entries of a directory as the port promises:
675// directories first, then the other entries, each group by name.
676func SortTreeEntries(entries []TreeEntry) {
677	slices.SortStableFunc(entries, func(a, b TreeEntry) int {
678		ad, bd := a.Kind == EntryDirectory, b.Kind == EntryDirectory
679		switch {
680		case ad && !bd:
681			return -1
682		case !ad && bd:
683			return 1
684		}
685		return strings.Compare(a.Name, b.Name)
686	})
687}
688
689// BoundTree keeps the first MaxTreeEntries entries of t and sets Truncated
690// when there were more.
691func (t *Tree) BoundTree() {
692	if len(t.Entries) > MaxTreeEntries {
693		t.Entries, t.Truncated = t.Entries[:MaxTreeEntries], true
694	}
695}
696
697// CommitPageFiles is how many files of a commit get their hunks read with its
698// page; the files past it are collapsed (see Diff.CollapseFrom) and load on
699// their own.
700const CommitPageFiles = 300
701
702// CollapseFrom marks the files from index n on that have lines to show (not
703// binary, not already omitted, with changes) as OmitBudget: listed, not read.
704func (d *Diff) CollapseFrom(n int) {
705	for i := max(n, 0); i < len(d.Files); i++ {
706		if f := &d.Files[i]; !f.Binary && f.Omitted == OmitNone && f.Additions+f.Deletions > 0 {
707			f.Omitted = OmitBudget
708		}
709	}
710}

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