detached 1b7c75a

feat(domain): add read-side git types

ObjectID, Revision and TreePath validation, Ref, TreeEntry, BlobInfo,
Commit, Signature, structured Diff (files, hunks, lines, stats) and the
diff limits of the API contract.
commit
1b7c75a15eeeb099bf8717a307dfe849f50f3409
author
brice · · 2026-10-10 08:25 UTC
committer
brice · · 2026-10-10 08:25 UTC
parent
423aa4f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +569 -0
A internal/domain/git.go +468 -0
@@ -0,0 +1,468 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
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.
15+var (
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.
38+const 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".
42+type ObjectID struct{ s string }
43+
44+// ParseObjectID validates s as a full object id.
45+func 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.
53+func (id ObjectID) String() string { return id.s }
54+
55+// IsZero reports whether id is the zero value.
56+func (id ObjectID) IsZero() bool { return id.s == "" }
57+
58+func 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.
70+const MinAbbrevLen = 7
71+
72+// maxRevisionLen bounds a revision, which is a ref name or an object id.
73+const 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.
79+type Revision struct{ s string }
80+
81+// ParseRevision validates s. The empty string is HEAD.
82+func 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.
93+func RevisionOf(id ObjectID) Revision { return Revision(id) }
94+
95+// String returns the revision text, or "" for HEAD.
96+func (r Revision) String() string { return r.s }
97+
98+// IsHead reports whether r is the zero value, which designates HEAD.
99+func (r Revision) IsHead() bool { return r.s == "" }
100+
101+// IsObjectID reports whether r is a full object id.
102+func (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).
106+func (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 "".
111+func 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.
142+const 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.
147+type 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.
152+func 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+
173+func 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.
181+func (p TreePath) String() string { return p.s }
182+
183+// IsRoot reports whether p is the root of the repository.
184+func (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.
188+func (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.
196+type RefKind string
197+
198+// Ref kinds, in the order refs are listed.
199+const (
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.
209+type 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.
225+type 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.
235+type 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.
247+type EntryKind string
248+
249+// Entry kinds.
250+const (
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.
264+const (
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.
274+func 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.
292+type 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.
308+type 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+}
320+
321+// MaxLastCommitEntries bounds the entries of one directory that get a last
322+// commit: each costs a history lookup.
323+const MaxLastCommitEntries = 200
324+
325+// BlobInfo describes a file at a revision.
326+type BlobInfo struct {
327+ ID ObjectID
328+ Path string
329+ Size int64
330+ // Binary follows Git's rule: a NUL byte in the first 8,000 bytes.
331+ Binary bool
332+ // MediaType comes from the file name and the first bytes, without
333+ // parameters; "application/octet-stream" when unknown.
334+ MediaType string
335+ Mode uint32
336+}
337+
338+// BinarySniffLen is how many leading bytes decide whether a blob is binary.
339+const BinarySniffLen = 8000
340+
341+// Signature is who did something in Git, and when.
342+type Signature struct {
343+ Name, Email string
344+ Time time.Time
345+}
346+
347+// Commit is the metadata of a commit.
348+type Commit struct {
349+ ID ObjectID
350+ Parents []ObjectID
351+ Author Signature
352+ Committer Signature
353+ // Summary is the first line of Message.
354+ Summary string
355+ // Message is the whole message without trailing line breaks, converted to
356+ // valid UTF-8.
357+ Message string
358+}
359+
360+// FileStatus tells what a commit did to a file.
361+type FileStatus string
362+
363+// File statuses.
364+const (
365+ FileAdded FileStatus = "added"
366+ FileDeleted FileStatus = "deleted"
367+ FileModified FileStatus = "modified"
368+ FileRenamed FileStatus = "renamed"
369+ FileCopied FileStatus = "copied"
370+ FileTypeChanged FileStatus = "type_changed"
371+)
372+
373+// LineKind tells how a line of a hunk relates to the two sides of a diff.
374+type LineKind string
375+
376+// Line kinds.
377+const (
378+ LineContext LineKind = "context"
379+ LineAddition LineKind = "addition"
380+ LineDeletion LineKind = "deletion"
381+)
382+
383+// Line is one line of a hunk.
384+type Line struct {
385+ Kind LineKind
386+ // OldNumber is 0 for an addition; NewNumber is 0 for a deletion.
387+ OldNumber, NewNumber int
388+ // Text has no line terminator and is valid UTF-8.
389+ Text string
390+ // NoNewlineAtEOF is set on the last line of a side that has no final line
391+ // break.
392+ NoNewlineAtEOF bool
393+}
394+
395+// Hunk is a contiguous changed region with its context.
396+type Hunk struct {
397+ OldStart, OldLines int
398+ NewStart, NewLines int
399+ // Header is the text after the second "@@", often the enclosing function.
400+ Header string
401+ Lines []Line
402+}
403+
404+// FileDiff is the change of one file.
405+type FileDiff struct {
406+ // OldPath is empty for an added file, NewPath for a deleted one.
407+ OldPath, NewPath string
408+ Status FileStatus
409+ // OldMode and NewMode are 0 for an added and a deleted file.
410+ OldMode, NewMode uint32
411+ // OldID and NewID are the blob ids, zero for an added and a deleted file.
412+ OldID, NewID ObjectID
413+ // Binary is Git's numstat "-": no hunks, no additions or deletions.
414+ Binary bool
415+ Additions, Deletions int
416+ // Similarity is a percentage for a renamed or copied file, 0 otherwise.
417+ Similarity int
418+ // Truncated means the file exceeded a diff limit: Hunks is empty although
419+ // the file changed. Additions and Deletions stay accurate.
420+ Truncated bool
421+ Hunks []Hunk
422+}
423+
424+// Diff is the structured difference a commit introduces against its first
425+// parent, or against the empty tree for a root commit.
426+type Diff struct {
427+ // Files are ordered as Git reports them (by path). Files past
428+ // DiffLimits.MaxFiles are left out.
429+ Files []FileDiff
430+ // Truncated is true when files were left out or a listed file is truncated.
431+ Truncated bool
432+}
433+
434+// DiffStats are the totals over every changed file, including the ones the
435+// diff leaves out or truncates. Binary files count 0 lines.
436+type DiffStats struct {
437+ FilesChanged, Additions, Deletions int
438+}
439+
440+// CommitDetail is a commit with its statistics and diff.
441+type CommitDetail struct {
442+ Commit Commit
443+ Stats DiffStats
444+ Diff Diff
445+}
446+
447+// DiffLimits bound the size of a Diff. Lines are hunk lines, context included.
448+type DiffLimits struct {
449+ // MaxFiles is the number of files kept.
450+ MaxFiles int
451+ // MaxFileLines is the most lines one file may have; a file over it is
452+ // truncated.
453+ MaxFileLines int
454+ // MaxTotalLines is the most lines of all the files. A file is truncated
455+ // when it does not fit in what remains; a smaller one after it may still
456+ // fit.
457+ MaxTotalLines int
458+ // MaxTotalBytes is the most text, in bytes, of all the lines. It bounds
459+ // the memory of a diff whose lines are very long; files are truncated
460+ // like for MaxTotalLines.
461+ MaxTotalBytes int
462+}
463+
464+// DefaultDiffLimits are the limits of the API contract: 300 files, 2,000
465+// lines per file, 20,000 lines in total, plus 8 MiB of text.
466+func DefaultDiffLimits() DiffLimits {
467+ return DiffLimits{MaxFiles: 300, MaxFileLines: 2000, MaxTotalLines: 20000, MaxTotalBytes: 8 << 20}
468+}
@@ -0,0 +1,468 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
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.
15+var (
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.
38+const 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".
42+type ObjectID struct{ s string }
43+
44+// ParseObjectID validates s as a full object id.
45+func 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.
53+func (id ObjectID) String() string { return id.s }
54+
55+// IsZero reports whether id is the zero value.
56+func (id ObjectID) IsZero() bool { return id.s == "" }
57+
58+func 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.
70+const MinAbbrevLen = 7
71+
72+// maxRevisionLen bounds a revision, which is a ref name or an object id.
73+const 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.
79+type Revision struct{ s string }
80+
81+// ParseRevision validates s. The empty string is HEAD.
82+func 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.
93+func RevisionOf(id ObjectID) Revision { return Revision(id) }
94+
95+// String returns the revision text, or "" for HEAD.
96+func (r Revision) String() string { return r.s }
97+
98+// IsHead reports whether r is the zero value, which designates HEAD.
99+func (r Revision) IsHead() bool { return r.s == "" }
100+
101+// IsObjectID reports whether r is a full object id.
102+func (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).
106+func (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 "".
111+func 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.
142+const 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.
147+type 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.
152+func 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+
173+func 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.
181+func (p TreePath) String() string { return p.s }
182+
183+// IsRoot reports whether p is the root of the repository.
184+func (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.
188+func (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.
196+type RefKind string
197+
198+// Ref kinds, in the order refs are listed.
199+const (
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.
209+type 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.
225+type 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.
235+type 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.
247+type EntryKind string
248+
249+// Entry kinds.
250+const (
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.
264+const (
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.
274+func 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.
292+type 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.
308+type 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+}
320+
321+// MaxLastCommitEntries bounds the entries of one directory that get a last
322+// commit: each costs a history lookup.
323+const MaxLastCommitEntries = 200
324+
325+// BlobInfo describes a file at a revision.
326+type BlobInfo struct {
327+ ID ObjectID
328+ Path string
329+ Size int64
330+ // Binary follows Git's rule: a NUL byte in the first 8,000 bytes.
331+ Binary bool
332+ // MediaType comes from the file name and the first bytes, without
333+ // parameters; "application/octet-stream" when unknown.
334+ MediaType string
335+ Mode uint32
336+}
337+
338+// BinarySniffLen is how many leading bytes decide whether a blob is binary.
339+const BinarySniffLen = 8000
340+
341+// Signature is who did something in Git, and when.
342+type Signature struct {
343+ Name, Email string
344+ Time time.Time
345+}
346+
347+// Commit is the metadata of a commit.
348+type Commit struct {
349+ ID ObjectID
350+ Parents []ObjectID
351+ Author Signature
352+ Committer Signature
353+ // Summary is the first line of Message.
354+ Summary string
355+ // Message is the whole message without trailing line breaks, converted to
356+ // valid UTF-8.
357+ Message string
358+}
359+
360+// FileStatus tells what a commit did to a file.
361+type FileStatus string
362+
363+// File statuses.
364+const (
365+ FileAdded FileStatus = "added"
366+ FileDeleted FileStatus = "deleted"
367+ FileModified FileStatus = "modified"
368+ FileRenamed FileStatus = "renamed"
369+ FileCopied FileStatus = "copied"
370+ FileTypeChanged FileStatus = "type_changed"
371+)
372+
373+// LineKind tells how a line of a hunk relates to the two sides of a diff.
374+type LineKind string
375+
376+// Line kinds.
377+const (
378+ LineContext LineKind = "context"
379+ LineAddition LineKind = "addition"
380+ LineDeletion LineKind = "deletion"
381+)
382+
383+// Line is one line of a hunk.
384+type Line struct {
385+ Kind LineKind
386+ // OldNumber is 0 for an addition; NewNumber is 0 for a deletion.
387+ OldNumber, NewNumber int
388+ // Text has no line terminator and is valid UTF-8.
389+ Text string
390+ // NoNewlineAtEOF is set on the last line of a side that has no final line
391+ // break.
392+ NoNewlineAtEOF bool
393+}
394+
395+// Hunk is a contiguous changed region with its context.
396+type Hunk struct {
397+ OldStart, OldLines int
398+ NewStart, NewLines int
399+ // Header is the text after the second "@@", often the enclosing function.
400+ Header string
401+ Lines []Line
402+}
403+
404+// FileDiff is the change of one file.
405+type FileDiff struct {
406+ // OldPath is empty for an added file, NewPath for a deleted one.
407+ OldPath, NewPath string
408+ Status FileStatus
409+ // OldMode and NewMode are 0 for an added and a deleted file.
410+ OldMode, NewMode uint32
411+ // OldID and NewID are the blob ids, zero for an added and a deleted file.
412+ OldID, NewID ObjectID
413+ // Binary is Git's numstat "-": no hunks, no additions or deletions.
414+ Binary bool
415+ Additions, Deletions int
416+ // Similarity is a percentage for a renamed or copied file, 0 otherwise.
417+ Similarity int
418+ // Truncated means the file exceeded a diff limit: Hunks is empty although
419+ // the file changed. Additions and Deletions stay accurate.
420+ Truncated bool
421+ Hunks []Hunk
422+}
423+
424+// Diff is the structured difference a commit introduces against its first
425+// parent, or against the empty tree for a root commit.
426+type Diff struct {
427+ // Files are ordered as Git reports them (by path). Files past
428+ // DiffLimits.MaxFiles are left out.
429+ Files []FileDiff
430+ // Truncated is true when files were left out or a listed file is truncated.
431+ Truncated bool
432+}
433+
434+// DiffStats are the totals over every changed file, including the ones the
435+// diff leaves out or truncates. Binary files count 0 lines.
436+type DiffStats struct {
437+ FilesChanged, Additions, Deletions int
438+}
439+
440+// CommitDetail is a commit with its statistics and diff.
441+type CommitDetail struct {
442+ Commit Commit
443+ Stats DiffStats
444+ Diff Diff
445+}
446+
447+// DiffLimits bound the size of a Diff. Lines are hunk lines, context included.
448+type DiffLimits struct {
449+ // MaxFiles is the number of files kept.
450+ MaxFiles int
451+ // MaxFileLines is the most lines one file may have; a file over it is
452+ // truncated.
453+ MaxFileLines int
454+ // MaxTotalLines is the most lines of all the files. A file is truncated
455+ // when it does not fit in what remains; a smaller one after it may still
456+ // fit.
457+ MaxTotalLines int
458+ // MaxTotalBytes is the most text, in bytes, of all the lines. It bounds
459+ // the memory of a diff whose lines are very long; files are truncated
460+ // like for MaxTotalLines.
461+ MaxTotalBytes int
462+}
463+
464+// DefaultDiffLimits are the limits of the API contract: 300 files, 2,000
465+// lines per file, 20,000 lines in total, plus 8 MiB of text.
466+func DefaultDiffLimits() DiffLimits {
467+ return DiffLimits{MaxFiles: 300, MaxFileLines: 2000, MaxTotalLines: 20000, MaxTotalBytes: 8 << 20}
468+}
A internal/domain/git_test.go +101 -0
@@ -0,0 +1,101 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain_test
4+
5+import (
6+ "strings"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+func TestParseObjectID(t *testing.T) {
16+ good := strings.Repeat("ab12", 10)
17+ id, err := domain.ParseObjectID(good)
18+ require.NoError(t, err)
19+ assert.Equal(t, good, id.String())
20+ assert.False(t, id.IsZero())
21+ assert.True(t, domain.ObjectID{}.IsZero())
22+
23+ for _, bad := range []string{"", "abc", strings.ToUpper(good), good + "0", good[:39], strings.Repeat("g", 40), "main"} {
24+ _, err := domain.ParseObjectID(bad)
25+ assert.ErrorIs(t, err, domain.ErrInvalidObjectID, bad)
26+ }
27+}
28+
29+func TestParseRevision(t *testing.T) {
30+ valid := []string{
31+ "", "main", "feature/x", "refs/heads/feature/x", "v1.0.0", "HEAD",
32+ "abc1234", strings.Repeat("a", 40), "release-2026.10", "café",
33+ }
34+ for _, s := range valid {
35+ r, err := domain.ParseRevision(s)
36+ require.NoError(t, err, s)
37+ assert.Equal(t, s, r.String())
38+ }
39+ invalid := []string{
40+ "-x", "--upload-pack=x", "/x", "x/", "x.", "a..b", "a@{1}", "@", "a b", "a~1", "a^", "a:b", "a?", "a*", "a[",
41+ "a\\b", "a\nb", "a\x00b", "a//b", ".hidden", "a/.b", "x.lock", "a/x.lock/b", "\xff", strings.Repeat("a", 513),
42+ }
43+ for _, s := range invalid {
44+ _, err := domain.ParseRevision(s)
45+ assert.ErrorIs(t, err, domain.ErrInvalidRevision, "%q", s)
46+ }
47+}
48+
49+func TestRevisionKinds(t *testing.T) {
50+ head, _ := domain.ParseRevision("")
51+ assert.True(t, head.IsHead())
52+ full, _ := domain.ParseRevision(strings.Repeat("a", 40))
53+ assert.True(t, full.IsObjectID())
54+ assert.False(t, full.IsAbbrevID())
55+ short, _ := domain.ParseRevision("abcdef1")
56+ assert.True(t, short.IsAbbrevID())
57+ tooShort, _ := domain.ParseRevision("abcdef")
58+ assert.False(t, tooShort.IsAbbrevID())
59+ branch, _ := domain.ParseRevision("deadbeefx")
60+ assert.False(t, branch.IsAbbrevID())
61+
62+ id, _ := domain.ParseObjectID(strings.Repeat("c", 40))
63+ assert.True(t, domain.RevisionOf(id).IsObjectID())
64+}
65+
66+func TestParseTreePath(t *testing.T) {
67+ root, err := domain.ParseTreePath("")
68+ require.NoError(t, err)
69+ assert.True(t, root.IsRoot())
70+ assert.Equal(t, "x", root.Join("x"))
71+
72+ p, err := domain.ParseTreePath("a/b/")
73+ require.NoError(t, err)
74+ assert.Equal(t, "a/b", p.String())
75+ assert.Equal(t, "a/b/c", p.Join("c"))
76+
77+ for _, bad := range []string{"/a", "a//b", "./a", "a/..", "..", ".", "a\x00b", "a\nb", "\xff", strings.Repeat("a", 4097), "//"} {
78+ _, err := domain.ParseTreePath(bad)
79+ assert.ErrorIs(t, err, domain.ErrInvalidPath, "%q", bad)
80+ }
81+}
82+
83+func TestEntryKindOf(t *testing.T) {
84+ tests := map[uint32]domain.EntryKind{
85+ 0o100644: domain.EntryFile,
86+ 0o100664: domain.EntryFile,
87+ 0o100755: domain.EntryExecutable,
88+ 0o040000: domain.EntryDirectory,
89+ 0o120000: domain.EntrySymlink,
90+ 0o160000: domain.EntrySubmodule,
91+ 0o000000: "",
92+ 0o060000: "",
93+ }
94+ for mode, want := range tests {
95+ assert.Equal(t, want, domain.EntryKindOf(mode), "%o", mode)
96+ }
97+}
98+
99+func TestDefaultDiffLimits(t *testing.T) {
100+ assert.Equal(t, domain.DiffLimits{MaxFiles: 300, MaxFileLines: 2000, MaxTotalLines: 20000, MaxTotalBytes: 8 << 20}, domain.DefaultDiffLimits())
101+}
@@ -0,0 +1,101 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain_test
4+
5+import (
6+ "strings"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+func TestParseObjectID(t *testing.T) {
16+ good := strings.Repeat("ab12", 10)
17+ id, err := domain.ParseObjectID(good)
18+ require.NoError(t, err)
19+ assert.Equal(t, good, id.String())
20+ assert.False(t, id.IsZero())
21+ assert.True(t, domain.ObjectID{}.IsZero())
22+
23+ for _, bad := range []string{"", "abc", strings.ToUpper(good), good + "0", good[:39], strings.Repeat("g", 40), "main"} {
24+ _, err := domain.ParseObjectID(bad)
25+ assert.ErrorIs(t, err, domain.ErrInvalidObjectID, bad)
26+ }
27+}
28+
29+func TestParseRevision(t *testing.T) {
30+ valid := []string{
31+ "", "main", "feature/x", "refs/heads/feature/x", "v1.0.0", "HEAD",
32+ "abc1234", strings.Repeat("a", 40), "release-2026.10", "café",
33+ }
34+ for _, s := range valid {
35+ r, err := domain.ParseRevision(s)
36+ require.NoError(t, err, s)
37+ assert.Equal(t, s, r.String())
38+ }
39+ invalid := []string{
40+ "-x", "--upload-pack=x", "/x", "x/", "x.", "a..b", "a@{1}", "@", "a b", "a~1", "a^", "a:b", "a?", "a*", "a[",
41+ "a\\b", "a\nb", "a\x00b", "a//b", ".hidden", "a/.b", "x.lock", "a/x.lock/b", "\xff", strings.Repeat("a", 513),
42+ }
43+ for _, s := range invalid {
44+ _, err := domain.ParseRevision(s)
45+ assert.ErrorIs(t, err, domain.ErrInvalidRevision, "%q", s)
46+ }
47+}
48+
49+func TestRevisionKinds(t *testing.T) {
50+ head, _ := domain.ParseRevision("")
51+ assert.True(t, head.IsHead())
52+ full, _ := domain.ParseRevision(strings.Repeat("a", 40))
53+ assert.True(t, full.IsObjectID())
54+ assert.False(t, full.IsAbbrevID())
55+ short, _ := domain.ParseRevision("abcdef1")
56+ assert.True(t, short.IsAbbrevID())
57+ tooShort, _ := domain.ParseRevision("abcdef")
58+ assert.False(t, tooShort.IsAbbrevID())
59+ branch, _ := domain.ParseRevision("deadbeefx")
60+ assert.False(t, branch.IsAbbrevID())
61+
62+ id, _ := domain.ParseObjectID(strings.Repeat("c", 40))
63+ assert.True(t, domain.RevisionOf(id).IsObjectID())
64+}
65+
66+func TestParseTreePath(t *testing.T) {
67+ root, err := domain.ParseTreePath("")
68+ require.NoError(t, err)
69+ assert.True(t, root.IsRoot())
70+ assert.Equal(t, "x", root.Join("x"))
71+
72+ p, err := domain.ParseTreePath("a/b/")
73+ require.NoError(t, err)
74+ assert.Equal(t, "a/b", p.String())
75+ assert.Equal(t, "a/b/c", p.Join("c"))
76+
77+ for _, bad := range []string{"/a", "a//b", "./a", "a/..", "..", ".", "a\x00b", "a\nb", "\xff", strings.Repeat("a", 4097), "//"} {
78+ _, err := domain.ParseTreePath(bad)
79+ assert.ErrorIs(t, err, domain.ErrInvalidPath, "%q", bad)
80+ }
81+}
82+
83+func TestEntryKindOf(t *testing.T) {
84+ tests := map[uint32]domain.EntryKind{
85+ 0o100644: domain.EntryFile,
86+ 0o100664: domain.EntryFile,
87+ 0o100755: domain.EntryExecutable,
88+ 0o040000: domain.EntryDirectory,
89+ 0o120000: domain.EntrySymlink,
90+ 0o160000: domain.EntrySubmodule,
91+ 0o000000: "",
92+ 0o060000: "",
93+ }
94+ for mode, want := range tests {
95+ assert.Equal(t, want, domain.EntryKindOf(mode), "%o", mode)
96+ }
97+}
98+
99+func TestDefaultDiffLimits(t *testing.T) {
100+ assert.Equal(t, domain.DiffLimits{MaxFiles: 300, MaxFileLines: 2000, MaxTotalLines: 20000, MaxTotalBytes: 8 << 20}, domain.DefaultDiffLimits())
101+}

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