v0.4.0 3027a40

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 len(s) > maxTreePathLen || !utf8.ValidString(s) {
159		return TreePath{}, fmt.Errorf("%w: %q: too long or not valid UTF-8", ErrInvalidPath, truncate(s))
160	}
161	for _, c := range s {
162		if c < 0x20 || c == 0x7f {
163			return TreePath{}, fmt.Errorf("%w: %q: control character", ErrInvalidPath, truncate(s))
164		}
165	}
166	for part := range strings.SplitSeq(s, "/") {
167		if part == "" || part == "." || part == ".." {
168			return TreePath{}, fmt.Errorf("%w: %q: empty, . or .. component", ErrInvalidPath, truncate(s))
169		}
170	}
171	return TreePath{s: s}, nil
172}
173
174func truncate(s string) string {
175	if len(s) > 64 {
176		return s[:64] + "…"
177	}
178	return s
179}
180
181// String returns the path, or "" for the root.
182func (p TreePath) String() string { return p.s }
183
184// IsRoot reports whether p is the root of the repository.
185func (p TreePath) IsRoot() bool { return p.s == "" }
186
187// Join returns the path of the entry name inside the directory p. name must
188// be a single component.
189func (p TreePath) Join(name string) string {
190	if p.s == "" {
191		return name
192	}
193	return p.s + "/" + name
194}
195
196// RefKind is the kind of a Git ref.
197type RefKind string
198
199// Ref kinds, in the order refs are listed.
200const (
201	// RefKindHead is the symbolic ref HEAD.
202	RefKindHead RefKind = "head"
203	// RefKindBranch is a branch (refs/heads/*).
204	RefKindBranch RefKind = "branch"
205	// RefKindTag is a tag (refs/tags/*), lightweight or annotated.
206	RefKindTag RefKind = "tag"
207)
208
209// Ref is a branch, a tag or HEAD.
210type Ref struct {
211	// Name is the full name: "refs/heads/main", "refs/tags/v1" or "HEAD".
212	Name string
213	// ShortName is Name without "refs/heads/" or "refs/tags/". For HEAD it is
214	// the short name of the default branch, empty if that branch is unborn.
215	ShortName string
216	Kind      RefKind
217	// ID is the object the ref points to (the tag object of an annotated
218	// tag). Zero for HEAD when the default branch is unborn.
219	ID ObjectID
220	// CommitID is the commit the ref designates once tags are followed. Zero
221	// for a tag that does not lead to a commit and for an unborn HEAD.
222	CommitID ObjectID
223}
224
225// ResolvedRevision is the result of resolving a Revision.
226type ResolvedRevision struct {
227	// CommitID is the commit the revision designates, tags followed.
228	CommitID ObjectID
229	// Ref is the full name of the ref the revision matched; empty when the
230	// revision was an object id or HEAD.
231	Ref string
232}
233
234// RefPath is a URL path split into a revision and the path inside it. It is
235// the answer to "feature/x/dir/file": the ref feature/x and the path dir/file.
236type RefPath struct {
237	// Name is the matched ref's short name, or the object id text.
238	Name string
239	// Ref is the matched ref's full name; empty when Name is an object id.
240	Ref string
241	// CommitID is the commit Name designates.
242	CommitID ObjectID
243	// Rest is what follows Name in the path, without leading slash.
244	Rest TreePath
245}
246
247// EntryKind is the kind of an entry of a Git tree.
248type EntryKind string
249
250// Entry kinds.
251const (
252	// EntryFile is a regular file (mode 100644).
253	EntryFile EntryKind = "file"
254	// EntryExecutable is an executable file (mode 100755).
255	EntryExecutable EntryKind = "executable"
256	// EntryDirectory is a directory (mode 040000).
257	EntryDirectory EntryKind = "directory"
258	// EntrySymlink is a symbolic link (mode 120000).
259	EntrySymlink EntryKind = "symlink"
260	// EntrySubmodule is a submodule commit (mode 160000).
261	EntrySubmodule EntryKind = "submodule"
262)
263
264// File mode bits as Git stores them.
265const (
266	modeTypeMask  = 0o170000
267	modeDirectory = 0o040000
268	modeSymlink   = 0o120000
269	modeRegular   = 0o100000
270	modeSubmodule = 0o160000
271)
272
273// EntryKindOf returns the kind of an entry from its Git mode, or "" if the
274// mode is not a Git mode.
275func EntryKindOf(mode uint32) EntryKind {
276	switch mode & modeTypeMask {
277	case modeDirectory:
278		return EntryDirectory
279	case modeSymlink:
280		return EntrySymlink
281	case modeSubmodule:
282		return EntrySubmodule
283	case modeRegular:
284		if mode&0o111 != 0 {
285			return EntryExecutable
286		}
287		return EntryFile
288	}
289	return ""
290}
291
292// TreeEntry is one entry of a directory.
293type TreeEntry struct {
294	// Name is the name in its directory; Path is the path from the root.
295	Name, Path string
296	Kind       EntryKind
297	Mode       uint32
298	// ID is the blob, tree or submodule commit.
299	ID ObjectID
300	// Size is the size of a file or symlink; 0 for directories and submodules.
301	Size int64
302	// LastCommit is the latest commit that changed the entry; nil unless
303	// asked for, and for the entries the reader left out (see
304	// Tree.LastCommitsOmitted).
305	LastCommit *Commit
306}
307
308// Tree is a listed directory.
309type Tree struct {
310	// CommitID is the commit the revision resolved to.
311	CommitID ObjectID
312	// ID is the tree object that was listed.
313	ID ObjectID
314	// Entries are the directories first, then the other entries, each group
315	// ordered by name.
316	Entries []TreeEntry
317	// LastCommitsOmitted is true when last commits were asked for and the
318	// reader gave only the first entries (MaxLastCommitEntries) one.
319	LastCommitsOmitted bool
320	// Truncated is true when the directory has more than MaxTreeEntries
321	// entries: Entries holds the first ones in the documented order.
322	Truncated bool
323}
324
325// MaxTreeEntries bounds the entries of a listed directory.
326const MaxTreeEntries = 5000
327
328// MaxLastCommitEntries bounds the entries of one directory that get a last
329// commit: each costs a history lookup.
330const MaxLastCommitEntries = 200
331
332// BlobInfo describes a file at a revision.
333type BlobInfo struct {
334	ID   ObjectID
335	Path string
336	Size int64
337	// Binary follows Git's rule: a NUL byte in the first 8,000 bytes.
338	Binary bool
339	// MediaType comes from the file name and the first bytes, without
340	// parameters; "application/octet-stream" when unknown.
341	MediaType string
342	Mode      uint32
343}
344
345// BinarySniffLen is how many leading bytes decide whether a blob is binary.
346const BinarySniffLen = 8000
347
348// Signature is who did something in Git, and when.
349type Signature struct {
350	Name, Email string
351	Time        time.Time
352}
353
354// Commit is the metadata of a commit.
355type Commit struct {
356	ID        ObjectID
357	Parents   []ObjectID
358	Author    Signature
359	Committer Signature
360	// Summary is the first line of Message.
361	Summary string
362	// Message is the whole message without trailing line breaks, converted to
363	// valid UTF-8.
364	Message string
365}
366
367// FileStatus tells what a commit did to a file.
368type FileStatus string
369
370// File statuses.
371const (
372	FileAdded       FileStatus = "added"
373	FileDeleted     FileStatus = "deleted"
374	FileModified    FileStatus = "modified"
375	FileRenamed     FileStatus = "renamed"
376	FileCopied      FileStatus = "copied"
377	FileTypeChanged FileStatus = "type_changed"
378)
379
380// LineKind tells how a line of a hunk relates to the two sides of a diff.
381type LineKind string
382
383// Line kinds.
384const (
385	LineContext  LineKind = "context"
386	LineAddition LineKind = "addition"
387	LineDeletion LineKind = "deletion"
388)
389
390// Line is one line of a hunk.
391type Line struct {
392	Kind LineKind
393	// OldNumber is 0 for an addition; NewNumber is 0 for a deletion.
394	OldNumber, NewNumber int
395	// Text has no line terminator and is valid UTF-8.
396	Text string
397	// NoNewlineAtEOF is set on the last line of a side that has no final line
398	// break.
399	NoNewlineAtEOF bool
400}
401
402// Hunk is a contiguous changed region with its context.
403type Hunk struct {
404	OldStart, OldLines int
405	NewStart, NewLines int
406	// Header is the text after the second "@@", often the enclosing function.
407	Header string
408	Lines  []Line
409}
410
411// FileDiff is the change of one file.
412type FileDiff struct {
413	// OldPath is empty for an added file, NewPath for a deleted one.
414	OldPath, NewPath string
415	Status           FileStatus
416	// OldMode and NewMode are 0 for an added and a deleted file.
417	OldMode, NewMode uint32
418	// OldID and NewID are the blob ids, zero for an added and a deleted file.
419	OldID, NewID ObjectID
420	// Binary is Git's numstat "-": no hunks, no additions or deletions.
421	Binary               bool
422	Additions, Deletions int
423	// Similarity is a percentage for a renamed or copied file, 0 otherwise.
424	Similarity int
425	// Truncated means the file exceeded a diff limit: Hunks is empty although
426	// the file changed. Additions and Deletions stay accurate.
427	Truncated bool
428	Hunks     []Hunk
429}
430
431// Diff is the structured difference a commit introduces against its first
432// parent, or against the empty tree for a root commit.
433type Diff struct {
434	// Files are ordered as Git reports them (by path). Files past
435	// DiffLimits.MaxFiles are left out.
436	Files []FileDiff
437	// Truncated is true when files were left out or a listed file is truncated.
438	Truncated bool
439}
440
441// DiffStats are the totals over every changed file, including the ones the
442// diff leaves out or truncates. Binary files count 0 lines.
443type DiffStats struct {
444	FilesChanged, Additions, Deletions int
445}
446
447// CommitDetail is a commit with its statistics and diff.
448type CommitDetail struct {
449	Commit Commit
450	Stats  DiffStats
451	Diff   Diff
452}
453
454// DiffLimits bound the size of a Diff. Lines are hunk lines, context included.
455type DiffLimits struct {
456	// MaxFiles is the number of files kept.
457	MaxFiles int
458	// MaxFileLines is the most lines one file may have; a file over it is
459	// truncated.
460	MaxFileLines int
461	// MaxTotalLines is the most lines of all the files. A file is truncated
462	// when it does not fit in what remains; a smaller one after it may still
463	// fit.
464	MaxTotalLines int
465	// MaxTotalBytes is the most text, in bytes, of all the lines. It bounds
466	// the memory of a diff whose lines are very long; files are truncated
467	// like for MaxTotalLines.
468	MaxTotalBytes int
469}
470
471// DefaultDiffLimits are the limits of the API contract: 300 files, 2,000
472// lines per file, 20,000 lines in total, plus 8 MiB of text.
473func DefaultDiffLimits() DiffLimits {
474	return DiffLimits{MaxFiles: 300, MaxFileLines: 2000, MaxTotalLines: 20000, MaxTotalBytes: 8 << 20}
475}
476
477// ParseBranchName validates s as the short name of a branch ("feat/x"), with
478// the syntax rules of ParseRevision, and rejects a full ref name, which would
479// not be short.
480func ParseBranchName(s string) (string, error) {
481	switch {
482	case s == "":
483		return "", fmt.Errorf("%w: empty branch name", ErrInvalidRevision)
484	case strings.HasPrefix(s, "refs/"):
485		return "", fmt.Errorf("%w: %q: give the short name of the branch", ErrInvalidRevision, s)
486	}
487	if reason := refNameProblem(s); reason != "" {
488		return "", fmt.Errorf("%w: %q: %s", ErrInvalidRevision, s, reason)
489	}
490	return s, nil
491}
492
493// MaxRefPathTries bounds the number of leading segments of a path tried as a
494// ref name by SplitRefPath.
495const MaxRefPathTries = 32
496
497// SplitRefPath splits a path such as "feature/x/dir/file" into the longest
498// leading part that resolve accepts as a revision and the rest. resolve
499// reports domain.ErrNotFound for a name that is not a revision; any other
500// error stops the search. At most MaxRefPathTries leading segments are tried.
501// It returns ErrNotFound if nothing matches.
502func SplitRefPath(path string, resolve func(Revision) (ResolvedRevision, error)) (RefPath, error) {
503	path = strings.Trim(path, "/")
504	segs := strings.Split(path, "/")
505	if path == "" || slices.Contains(segs, "") {
506		return RefPath{}, fmt.Errorf("path %q: %w", path, ErrNotFound)
507	}
508	for n := min(len(segs), MaxRefPathTries); n >= 1; n-- {
509		name := strings.Join(segs[:n], "/")
510		rev, err := ParseRevision(name)
511		if err != nil {
512			continue
513		}
514		res, err := resolve(rev)
515		if errors.Is(err, ErrNotFound) {
516			continue
517		}
518		if err != nil {
519			return RefPath{}, err
520		}
521		rest, err := ParseTreePath(strings.Join(segs[n:], "/"))
522		if err != nil {
523			return RefPath{}, err
524		}
525		return RefPath{Name: name, Ref: res.Ref, CommitID: res.CommitID, Rest: rest}, nil
526	}
527	return RefPath{}, fmt.Errorf("path %q: %w", path, ErrNotFound)
528}
529
530// SortTreeEntries orders the entries of a directory as the port promises:
531// directories first, then the other entries, each group by name.
532func SortTreeEntries(entries []TreeEntry) {
533	slices.SortStableFunc(entries, func(a, b TreeEntry) int {
534		ad, bd := a.Kind == EntryDirectory, b.Kind == EntryDirectory
535		switch {
536		case ad && !bd:
537			return -1
538		case !ad && bd:
539			return 1
540		}
541		return strings.Compare(a.Name, b.Name)
542	})
543}
544
545// BoundTree keeps the first MaxTreeEntries entries of t and sets Truncated
546// when there were more.
547func (t *Tree) BoundTree() {
548	if len(t.Entries) > MaxTreeEntries {
549		t.Entries, t.Truncated = t.Entries[:MaxTreeEntries], true
550	}
551}

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 44

↑↓ moveenter openesc close