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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | + | } | |||