v0.1.0 3f267b5

git.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package domain
4
5import (
6	"errors"
7	"fmt"
8	"strings"
9	"time"
10	"unicode/utf8"
11)
12
13// Errors of the read side. Callers match them with errors.Is; the rpc adapter
14// maps all of them but the not found ones to invalid_argument.
15var (
16	// ErrInvalidObjectID means a string is not a full SHA-1 object id.
17	ErrInvalidObjectID = errors.New("invalid object id")
18	// ErrInvalidRevision means a revision breaks the rules of ParseRevision.
19	ErrInvalidRevision = errors.New("invalid revision")
20	// ErrInvalidPath means a path inside a repository breaks the rules of
21	// ParseTreePath.
22	ErrInvalidPath = errors.New("invalid path")
23	// ErrInvalidPageToken means a page token was not produced by the server
24	// for this kind of listing.
25	ErrInvalidPageToken = errors.New("invalid page token")
26	// ErrInvalidPageSize means a page size is negative.
27	ErrInvalidPageSize = errors.New("invalid page size")
28	// ErrInvalidRefKind means a ref kind is not one of the known kinds.
29	ErrInvalidRefKind = errors.New("invalid ref kind")
30	// ErrNotDirectory means a path that must name a directory names something else.
31	ErrNotDirectory = errors.New("not a directory")
32	// ErrNotFile means a path that must name a file names something else.
33	ErrNotFile = errors.New("not a file")
34)
35
36// ObjectIDLen is the length of an object id in hexadecimal characters.
37// ermite creates SHA-1 repositories only.
38const ObjectIDLen = 40
39
40// ObjectID is a full, lowercase, hexadecimal SHA-1 object id. Only
41// ParseObjectID builds a non-zero value; the zero value means "no object".
42type ObjectID struct{ s string }
43
44// ParseObjectID validates s as a full object id.
45func ParseObjectID(s string) (ObjectID, error) {
46	if len(s) != ObjectIDLen || !isLowerHex(s) {
47		return ObjectID{}, fmt.Errorf("%w: %q", ErrInvalidObjectID, s)
48	}
49	return ObjectID{s: s}, nil
50}
51
52// String returns the id, or "" for the zero value.
53func (id ObjectID) String() string { return id.s }
54
55// IsZero reports whether id is the zero value.
56func (id ObjectID) IsZero() bool { return id.s == "" }
57
58func isLowerHex(s string) bool {
59	for i := range len(s) {
60		c := s[i]
61		if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
62			return false
63		}
64	}
65	return s != ""
66}
67
68// MinAbbrevLen is the shortest abbreviation of an object id accepted as a
69// revision.
70const MinAbbrevLen = 7
71
72// maxRevisionLen bounds a revision, which is a ref name or an object id.
73const maxRevisionLen = 512
74
75// Revision names a commit: a branch, a tag, a full ref name ("refs/heads/x"),
76// an object id or an abbreviation of at least MinAbbrevLen characters. The
77// zero value is HEAD. Parsing checks the syntax only, with the rules of
78// `git check-ref-format`; whether the revision exists is the reader's concern.
79type Revision struct{ s string }
80
81// ParseRevision validates s. The empty string is HEAD.
82func ParseRevision(s string) (Revision, error) {
83	if s == "" {
84		return Revision{}, nil
85	}
86	if reason := refNameProblem(s); reason != "" {
87		return Revision{}, fmt.Errorf("%w: %q: %s", ErrInvalidRevision, s, reason)
88	}
89	return Revision{s: s}, nil
90}
91
92// RevisionOf returns the revision that designates exactly the commit id.
93func RevisionOf(id ObjectID) Revision { return Revision(id) }
94
95// String returns the revision text, or "" for HEAD.
96func (r Revision) String() string { return r.s }
97
98// IsHead reports whether r is the zero value, which designates HEAD.
99func (r Revision) IsHead() bool { return r.s == "" }
100
101// IsObjectID reports whether r is a full object id.
102func (r Revision) IsObjectID() bool { return len(r.s) == ObjectIDLen && isLowerHex(r.s) }
103
104// IsAbbrevID reports whether r is a hexadecimal abbreviation of an object id
105// (MinAbbrevLen to ObjectIDLen-1 characters).
106func (r Revision) IsAbbrevID() bool {
107	return len(r.s) >= MinAbbrevLen && len(r.s) < ObjectIDLen && isLowerHex(r.s)
108}
109
110// refNameProblem returns why s cannot be a ref name or revision, or "".
111func refNameProblem(s string) string {
112	switch {
113	case len(s) > maxRevisionLen:
114		return "too long"
115	case !utf8.ValidString(s):
116		return "not valid UTF-8"
117	case strings.HasPrefix(s, "-"):
118		return "starts with a dash"
119	case strings.HasPrefix(s, "/") || strings.HasSuffix(s, "/"):
120		return "starts or ends with a slash"
121	case strings.HasSuffix(s, "."):
122		return "ends with a dot"
123	case s == "@":
124		return "is @"
125	case strings.Contains(s, ".."), strings.Contains(s, "@{"), strings.Contains(s, "//"):
126		return "contains a forbidden sequence"
127	}
128	for _, c := range s {
129		if c < 0x20 || c == 0x7f || strings.ContainsRune(" ~^:?*[\\", c) {
130			return "contains a forbidden character"
131		}
132	}
133	for part := range strings.SplitSeq(s, "/") {
134		if strings.HasPrefix(part, ".") || strings.HasSuffix(part, ".lock") {
135			return "has a component that starts with a dot or ends with .lock"
136		}
137	}
138	return ""
139}
140
141// maxTreePathLen bounds a path inside a repository, as Linux does.
142const maxTreePathLen = 4096
143
144// TreePath is a slash-separated path from the root of a repository, without
145// leading or trailing slash. The zero value is the root. Only ParseTreePath
146// builds a non-root value.
147type TreePath struct{ s string }
148
149// ParseTreePath validates s. It accepts one trailing slash, which it drops,
150// and the empty string (the root). Empty, "." and ".." components, NUL and
151// control characters, and text that is not valid UTF-8 are rejected.
152func ParseTreePath(s string) (TreePath, error) {
153	s = strings.TrimSuffix(s, "/")
154	if s == "" {
155		return TreePath{}, nil
156	}
157	if len(s) > maxTreePathLen || !utf8.ValidString(s) {
158		return TreePath{}, fmt.Errorf("%w: %q: too long or not valid UTF-8", ErrInvalidPath, truncate(s))
159	}
160	for _, c := range s {
161		if c < 0x20 || c == 0x7f {
162			return TreePath{}, fmt.Errorf("%w: %q: control character", ErrInvalidPath, truncate(s))
163		}
164	}
165	for part := range strings.SplitSeq(s, "/") {
166		if part == "" || part == "." || part == ".." {
167			return TreePath{}, fmt.Errorf("%w: %q: empty, . or .. component", ErrInvalidPath, truncate(s))
168		}
169	}
170	return TreePath{s: s}, nil
171}
172
173func truncate(s string) string {
174	if len(s) > 64 {
175		return s[:64] + "…"
176	}
177	return s
178}
179
180// String returns the path, or "" for the root.
181func (p TreePath) String() string { return p.s }
182
183// IsRoot reports whether p is the root of the repository.
184func (p TreePath) IsRoot() bool { return p.s == "" }
185
186// Join returns the path of the entry name inside the directory p. name must
187// be a single component.
188func (p TreePath) Join(name string) string {
189	if p.s == "" {
190		return name
191	}
192	return p.s + "/" + name
193}
194
195// RefKind is the kind of a Git ref.
196type RefKind string
197
198// Ref kinds, in the order refs are listed.
199const (
200	// RefKindHead is the symbolic ref HEAD.
201	RefKindHead RefKind = "head"
202	// RefKindBranch is a branch (refs/heads/*).
203	RefKindBranch RefKind = "branch"
204	// RefKindTag is a tag (refs/tags/*), lightweight or annotated.
205	RefKindTag RefKind = "tag"
206)
207
208// Ref is a branch, a tag or HEAD.
209type Ref struct {
210	// Name is the full name: "refs/heads/main", "refs/tags/v1" or "HEAD".
211	Name string
212	// ShortName is Name without "refs/heads/" or "refs/tags/". For HEAD it is
213	// the short name of the default branch, empty if that branch is unborn.
214	ShortName string
215	Kind      RefKind
216	// ID is the object the ref points to (the tag object of an annotated
217	// tag). Zero for HEAD when the default branch is unborn.
218	ID ObjectID
219	// CommitID is the commit the ref designates once tags are followed. Zero
220	// for a tag that does not lead to a commit and for an unborn HEAD.
221	CommitID ObjectID
222}
223
224// ResolvedRevision is the result of resolving a Revision.
225type ResolvedRevision struct {
226	// CommitID is the commit the revision designates, tags followed.
227	CommitID ObjectID
228	// Ref is the full name of the ref the revision matched; empty when the
229	// revision was an object id or HEAD.
230	Ref string
231}
232
233// RefPath is a URL path split into a revision and the path inside it. It is
234// the answer to "feature/x/dir/file": the ref feature/x and the path dir/file.
235type RefPath struct {
236	// Name is the matched ref's short name, or the object id text.
237	Name string
238	// Ref is the matched ref's full name; empty when Name is an object id.
239	Ref string
240	// CommitID is the commit Name designates.
241	CommitID ObjectID
242	// Rest is what follows Name in the path, without leading slash.
243	Rest TreePath
244}
245
246// EntryKind is the kind of an entry of a Git tree.
247type EntryKind string
248
249// Entry kinds.
250const (
251	// EntryFile is a regular file (mode 100644).
252	EntryFile EntryKind = "file"
253	// EntryExecutable is an executable file (mode 100755).
254	EntryExecutable EntryKind = "executable"
255	// EntryDirectory is a directory (mode 040000).
256	EntryDirectory EntryKind = "directory"
257	// EntrySymlink is a symbolic link (mode 120000).
258	EntrySymlink EntryKind = "symlink"
259	// EntrySubmodule is a submodule commit (mode 160000).
260	EntrySubmodule EntryKind = "submodule"
261)
262
263// File mode bits as Git stores them.
264const (
265	modeTypeMask  = 0o170000
266	modeDirectory = 0o040000
267	modeSymlink   = 0o120000
268	modeRegular   = 0o100000
269	modeSubmodule = 0o160000
270)
271
272// EntryKindOf returns the kind of an entry from its Git mode, or "" if the
273// mode is not a Git mode.
274func EntryKindOf(mode uint32) EntryKind {
275	switch mode & modeTypeMask {
276	case modeDirectory:
277		return EntryDirectory
278	case modeSymlink:
279		return EntrySymlink
280	case modeSubmodule:
281		return EntrySubmodule
282	case modeRegular:
283		if mode&0o111 != 0 {
284			return EntryExecutable
285		}
286		return EntryFile
287	}
288	return ""
289}
290
291// TreeEntry is one entry of a directory.
292type TreeEntry struct {
293	// Name is the name in its directory; Path is the path from the root.
294	Name, Path string
295	Kind       EntryKind
296	Mode       uint32
297	// ID is the blob, tree or submodule commit.
298	ID ObjectID
299	// Size is the size of a file or symlink; 0 for directories and submodules.
300	Size int64
301	// LastCommit is the latest commit that changed the entry; nil unless
302	// asked for, and for the entries the reader left out (see
303	// Tree.LastCommitsOmitted).
304	LastCommit *Commit
305}
306
307// Tree is a listed directory.
308type Tree struct {
309	// CommitID is the commit the revision resolved to.
310	CommitID ObjectID
311	// ID is the tree object that was listed.
312	ID ObjectID
313	// Entries are the directories first, then the other entries, each group
314	// ordered by name.
315	Entries []TreeEntry
316	// LastCommitsOmitted is true when last commits were asked for and the
317	// reader gave only the first entries (MaxLastCommitEntries) one.
318	LastCommitsOmitted bool
319	// Truncated is true when the directory has more than MaxTreeEntries
320	// entries: Entries holds the first ones in the documented order.
321	Truncated bool
322}
323
324// MaxTreeEntries bounds the entries of a listed directory.
325const MaxTreeEntries = 5000
326
327// MaxLastCommitEntries bounds the entries of one directory that get a last
328// commit: each costs a history lookup.
329const MaxLastCommitEntries = 200
330
331// BlobInfo describes a file at a revision.
332type BlobInfo struct {
333	ID   ObjectID
334	Path string
335	Size int64
336	// Binary follows Git's rule: a NUL byte in the first 8,000 bytes.
337	Binary bool
338	// MediaType comes from the file name and the first bytes, without
339	// parameters; "application/octet-stream" when unknown.
340	MediaType string
341	Mode      uint32
342}
343
344// BinarySniffLen is how many leading bytes decide whether a blob is binary.
345const BinarySniffLen = 8000
346
347// Signature is who did something in Git, and when.
348type Signature struct {
349	Name, Email string
350	Time        time.Time
351}
352
353// Commit is the metadata of a commit.
354type Commit struct {
355	ID        ObjectID
356	Parents   []ObjectID
357	Author    Signature
358	Committer Signature
359	// Summary is the first line of Message.
360	Summary string
361	// Message is the whole message without trailing line breaks, converted to
362	// valid UTF-8.
363	Message string
364}
365
366// FileStatus tells what a commit did to a file.
367type FileStatus string
368
369// File statuses.
370const (
371	FileAdded       FileStatus = "added"
372	FileDeleted     FileStatus = "deleted"
373	FileModified    FileStatus = "modified"
374	FileRenamed     FileStatus = "renamed"
375	FileCopied      FileStatus = "copied"
376	FileTypeChanged FileStatus = "type_changed"
377)
378
379// LineKind tells how a line of a hunk relates to the two sides of a diff.
380type LineKind string
381
382// Line kinds.
383const (
384	LineContext  LineKind = "context"
385	LineAddition LineKind = "addition"
386	LineDeletion LineKind = "deletion"
387)
388
389// Line is one line of a hunk.
390type Line struct {
391	Kind LineKind
392	// OldNumber is 0 for an addition; NewNumber is 0 for a deletion.
393	OldNumber, NewNumber int
394	// Text has no line terminator and is valid UTF-8.
395	Text string
396	// NoNewlineAtEOF is set on the last line of a side that has no final line
397	// break.
398	NoNewlineAtEOF bool
399}
400
401// Hunk is a contiguous changed region with its context.
402type Hunk struct {
403	OldStart, OldLines int
404	NewStart, NewLines int
405	// Header is the text after the second "@@", often the enclosing function.
406	Header string
407	Lines  []Line
408}
409
410// FileDiff is the change of one file.
411type FileDiff struct {
412	// OldPath is empty for an added file, NewPath for a deleted one.
413	OldPath, NewPath string
414	Status           FileStatus
415	// OldMode and NewMode are 0 for an added and a deleted file.
416	OldMode, NewMode uint32
417	// OldID and NewID are the blob ids, zero for an added and a deleted file.
418	OldID, NewID ObjectID
419	// Binary is Git's numstat "-": no hunks, no additions or deletions.
420	Binary               bool
421	Additions, Deletions int
422	// Similarity is a percentage for a renamed or copied file, 0 otherwise.
423	Similarity int
424	// Truncated means the file exceeded a diff limit: Hunks is empty although
425	// the file changed. Additions and Deletions stay accurate.
426	Truncated bool
427	Hunks     []Hunk
428}
429
430// Diff is the structured difference a commit introduces against its first
431// parent, or against the empty tree for a root commit.
432type Diff struct {
433	// Files are ordered as Git reports them (by path). Files past
434	// DiffLimits.MaxFiles are left out.
435	Files []FileDiff
436	// Truncated is true when files were left out or a listed file is truncated.
437	Truncated bool
438}
439
440// DiffStats are the totals over every changed file, including the ones the
441// diff leaves out or truncates. Binary files count 0 lines.
442type DiffStats struct {
443	FilesChanged, Additions, Deletions int
444}
445
446// CommitDetail is a commit with its statistics and diff.
447type CommitDetail struct {
448	Commit Commit
449	Stats  DiffStats
450	Diff   Diff
451}
452
453// DiffLimits bound the size of a Diff. Lines are hunk lines, context included.
454type DiffLimits struct {
455	// MaxFiles is the number of files kept.
456	MaxFiles int
457	// MaxFileLines is the most lines one file may have; a file over it is
458	// truncated.
459	MaxFileLines int
460	// MaxTotalLines is the most lines of all the files. A file is truncated
461	// when it does not fit in what remains; a smaller one after it may still
462	// fit.
463	MaxTotalLines int
464	// MaxTotalBytes is the most text, in bytes, of all the lines. It bounds
465	// the memory of a diff whose lines are very long; files are truncated
466	// like for MaxTotalLines.
467	MaxTotalBytes int
468}
469
470// DefaultDiffLimits are the limits of the API contract: 300 files, 2,000
471// lines per file, 20,000 lines in total, plus 8 MiB of text.
472func DefaultDiffLimits() DiffLimits {
473	return DiffLimits{MaxFiles: 300, MaxFileLines: 2000, MaxTotalLines: 20000, MaxTotalBytes: 8 << 20}
474}

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 3

Tags 43

↑↓ moveenter openesc close