1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package ermitegit
4
5import (
6 "bytes"
7 "context"
8 "errors"
9 "fmt"
10 "io"
11 "strings"
12 "time"
13
14 git "bnema.dev/ermite-git"
15
16 "bnema.dev/ermite/internal/domain"
17 "bnema.dev/ermite/internal/ports"
18)
19
20const (
21 headsPrefix = "refs/heads/"
22 tagsPrefix = "refs/tags/"
23)
24
25func domainID(id git.ObjectID) (domain.ObjectID, error) { return domain.ParseObjectID(id.String()) }
26
27// listRefs lists HEAD, then the branches, then the tags, like gitcli: a tag
28// follows to its commit when it leads to one; HEAD takes the ids of its
29// branch or tag, and only the name of an unborn branch.
30//
31//hexcheck:ignore suspicious-business-logic-in-adapter converts ermite-git values into domain values; the limits and the orders come from domain
32func listRefs(r *git.Repo) ([]domain.Ref, error) {
33 var refs []domain.Ref
34 byName := make(map[string]domain.Ref)
35 for _, prefix := range [...]string{headsPrefix, tagsPrefix} {
36 kind := domain.RefKindBranch
37 if prefix == tagsPrefix {
38 kind = domain.RefKindTag
39 }
40 for ref, err := range r.Refs(prefix) {
41 if err != nil {
42 return nil, fmt.Errorf("list refs: %w", err)
43 }
44 out := domain.Ref{Name: ref.Name, ShortName: ref.Name[len(prefix):], Kind: kind}
45 var err2 error
46 if out.ID, err2 = domainID(ref.ID); err2 != nil {
47 return nil, err2
48 }
49 commit, err := r.PeelRef(ref)
50 switch {
51 case err == nil:
52 if out.CommitID, err2 = domainID(commit); err2 != nil {
53 return nil, err2
54 }
55 case errors.Is(err, git.ErrWrongType):
56 // A tag of a tree or a blob has no commit.
57 case errors.Is(err, git.ErrNotFound):
58 // The ref exists and its object does not: the repository is
59 // damaged, which is not a missing repository or revision, so
60 // the error must not match domain.ErrNotFound (gitcli fails
61 // the same way). %v hides git.ErrNotFound from mapErr.
62 return nil, fmt.Errorf("ref %s points to a missing object: %v", ref.Name, err)
63 default:
64 return nil, fmt.Errorf("follow %s: %w", ref.Name, err)
65 }
66 refs = append(refs, out)
67 byName[out.Name] = out
68 }
69 }
70 head := domain.Ref{Name: "HEAD", Kind: domain.RefKindHead}
71 target, _, err := r.Head()
72 if err != nil {
73 return nil, fmt.Errorf("read HEAD: %w", err)
74 }
75 if b, ok := byName[target]; ok && target != "" {
76 head.ShortName, head.ID, head.CommitID = b.ShortName, b.ID, b.CommitID
77 } else if short, ok := cutPrefix(target, headsPrefix); ok {
78 head.ShortName = short // unborn branch
79 }
80 return append([]domain.Ref{head}, refs...), nil
81}
82
83func cutPrefix(s, prefix string) (string, bool) {
84 if len(s) >= len(prefix) && s[:len(prefix)] == prefix {
85 return s[len(prefix):], true
86 }
87 return s, false
88}
89
90// resolved is a revision resolved to a commit.
91type resolved struct {
92 commit git.ObjectID
93 ref string
94}
95
96func (r resolved) domain() (domain.ResolvedRevision, error) {
97 id, err := domainID(r.commit)
98 return domain.ResolvedRevision{CommitID: id, Ref: r.ref}, err
99}
100
101// peelRef reads a ref and peels it to a commit. ok is false when the ref does
102// not exist or does not lead to a commit.
103func peelRef(r *git.Repo, name string) (_ git.ObjectID, ok bool, _ error) {
104 // LookupRefID allocates nothing on a miss, which is the common case of the
105 // candidates of resolveRevision (heads/x, then tags/x, then refs/...).
106 id, found, err := r.LookupRefID(name)
107 if err != nil || !found {
108 return git.ObjectID{}, false, err
109 }
110 return peelID(r, id)
111}
112
113func peelID(r *git.Repo, id git.ObjectID) (_ git.ObjectID, ok bool, _ error) {
114 commit, err := r.PeelToCommit(id)
115 if errors.Is(err, git.ErrNotFound) || errors.Is(err, git.ErrWrongType) {
116 return git.ObjectID{}, false, nil
117 }
118 if err != nil {
119 return git.ObjectID{}, false, err
120 }
121 return commit, true, nil
122}
123
124// resolveRevision tries the candidates of gitcli in the same order, which
125// the port documents: HEAD is HEAD; a full object id is an object before it
126// is a ref; then the branch, then the tag; then a full ref name and an
127// abbreviated id as written. It does not use Repo.Resolve, whose order is
128// git's own (tags before branches, no ref for a missing full id).
129//
130//hexcheck:ignore suspicious-business-logic-in-adapter tries the candidates of the port in its documented order, like gitcli; the rule is the port contract
131func resolveRevision(r *git.Repo, rev domain.Revision) (resolved, error) {
132 s := rev.String()
133 if rev.IsHead() || s == "HEAD" {
134 if c, ok, err := peelRef(r, "HEAD"); err != nil || ok {
135 return resolved{commit: c}, err
136 }
137 return resolved{}, notFound(rev)
138 }
139 if rev.IsObjectID() {
140 id, err := git.ParseID(s)
141 if err != nil {
142 return resolved{}, err
143 }
144 if c, ok, err := peelID(r, id); err != nil || ok {
145 return resolved{commit: c}, err
146 }
147 }
148 for _, name := range [...]string{headsPrefix + s, tagsPrefix + s} {
149 if c, ok, err := peelRef(r, name); err != nil || ok {
150 return resolved{commit: c, ref: name}, err
151 }
152 }
153 switch {
154 case len(s) > 5 && s[:5] == "refs/":
155 if c, ok, err := peelRef(r, s); err != nil || ok {
156 return resolved{commit: c, ref: s}, err
157 }
158 case rev.IsAbbrevID():
159 id, err := r.ResolveCommitPrefix(s)
160 if err == nil {
161 // The prefix may name an annotated tag: git peels it.
162 id, err = r.PeelToCommit(id)
163 }
164 switch {
165 case err == nil:
166 return resolved{commit: id}, nil
167 case errors.Is(err, git.ErrNotFound), errors.Is(err, git.ErrAmbiguous), errors.Is(err, git.ErrWrongType):
168 default:
169 return resolved{}, err
170 }
171 }
172 return resolved{}, notFound(rev)
173}
174
175func notFound(rev domain.Revision) error {
176 return fmt.Errorf("revision %q: %w", rev, domain.ErrNotFound)
177}
178
179// canonMode is the mode git reports for a tree entry: legacy permission bits
180// are normalized (tree-walk.c canon_mode).
181func canonMode(m git.Mode) uint32 {
182 switch {
183 case m.IsRegular():
184 if m&0o100 != 0 {
185 return 0o100755
186 }
187 return 0o100644
188 case m.IsSymlink():
189 return 0o120000
190 case m.IsTree():
191 return 0o040000
192 }
193 return 0o160000
194}
195
196// commitTree resolves rev and reads the root tree of the commit.
197func commitTree(r *git.Repo, rev domain.Revision) (domain.ObjectID, git.ObjectID, error) {
198 res, err := resolveRevision(r, rev)
199 if err != nil {
200 return domain.ObjectID{}, git.ObjectID{}, err
201 }
202 var c git.Commit
203 if err := r.ReadCommit(res.commit, &c); err != nil {
204 return domain.ObjectID{}, git.ObjectID{}, err
205 }
206 id, err := domainID(res.commit)
207 return id, c.Tree, err
208}
209
210// lookup finds the entry at a path of a commit tree. A path that does not
211// exist, and a submodule (whose commit is not in the repository), are
212// domain.ErrNotFound like in gitcli.
213func lookup(r *git.Repo, tree git.ObjectID, path domain.TreePath) (git.Entry, error) {
214 e, err := r.LookupPath(tree, path.String())
215 if errors.Is(err, git.ErrNotFound) {
216 return git.Entry{}, fmt.Errorf("path %q: %w", path, domain.ErrNotFound)
217 }
218 if err != nil {
219 return git.Entry{}, err
220 }
221 if e.Mode.IsGitlink() {
222 return git.Entry{}, fmt.Errorf("path %q: %w", path, domain.ErrNotFound)
223 }
224 return e, nil
225}
226
227// getTree lists a directory.
228//
229//hexcheck:ignore suspicious-business-logic-in-adapter converts ermite-git values into domain values; the limits and the orders come from domain
230func getTree(ctx context.Context, r *git.Repo, rev domain.Revision, path domain.TreePath, includeLast bool) (domain.Tree, error) {
231 commit, root, err := commitTree(r, rev)
232 if err != nil {
233 return domain.Tree{}, err
234 }
235 dir, err := lookup(r, root, path)
236 if err != nil {
237 return domain.Tree{}, err
238 }
239 if !dir.Mode.IsTree() {
240 return domain.Tree{}, domain.ErrNotDirectory
241 }
242 treeID, err := domainID(dir.ID)
243 if err != nil {
244 return domain.Tree{}, err
245 }
246 tree := domain.Tree{CommitID: commit, ID: treeID}
247 // The ids, the names and the paths of all the entries share one backing
248 // string, cut into substrings.
249 type span struct {
250 name, path, id [2]int
251 mode uint32
252 kind domain.EntryKind
253 }
254 var (
255 spans []span
256 buf []byte
257 bad error
258 )
259 prefix := path.String()
260 err = r.ReadTree(dir.ID, func(e git.Entry) bool {
261 mode := canonMode(e.Mode)
262 kind := domain.EntryKindOf(mode)
263 if kind == "" {
264 bad = fmt.Errorf("unknown mode %o of %q", uint32(e.Mode), e.Name)
265 return false
266 }
267 var sp span
268 sp.mode, sp.kind = mode, kind
269 sp.id[0] = len(buf)
270 buf = e.ID.AppendHex(buf)
271 sp.id[1] = len(buf)
272 sp.name[0] = len(buf)
273 buf = append(buf, e.Name...)
274 sp.name[1] = len(buf)
275 if prefix == "" {
276 sp.path = sp.name
277 } else {
278 sp.path[0] = len(buf)
279 buf = append(buf, prefix...)
280 buf = append(buf, '/')
281 buf = append(buf, e.Name...)
282 sp.path[1] = len(buf)
283 }
284 spans = append(spans, sp)
285 return true
286 })
287 if err == nil {
288 err = bad
289 }
290 if err != nil {
291 return domain.Tree{}, fmt.Errorf("list tree %q: %w", path, err)
292 }
293 text := string(buf)
294 tree.Entries = make([]domain.TreeEntry, len(spans))
295 for i, sp := range spans {
296 id, err := domain.ParseObjectID(text[sp.id[0]:sp.id[1]])
297 if err != nil {
298 return domain.Tree{}, err
299 }
300 tree.Entries[i] = domain.TreeEntry{
301 Name: text[sp.name[0]:sp.name[1]], Path: text[sp.path[0]:sp.path[1]],
302 Kind: sp.kind, Mode: sp.mode, ID: id,
303 }
304 }
305 domain.SortTreeEntries(tree.Entries)
306 tree.BoundTree()
307 // The size of a file costs a header read: only for the entries that stay.
308 for i, e := range tree.Entries {
309 if e.Kind == domain.EntryDirectory || e.Kind == domain.EntrySubmodule {
310 continue
311 }
312 id, err := git.ParseID(e.ID.String())
313 if err != nil {
314 return domain.Tree{}, err
315 }
316 _, size, err := r.ObjectHeader(id)
317 if err != nil {
318 return domain.Tree{}, fmt.Errorf("size of %q: %w", e.Path, err)
319 }
320 tree.Entries[i].Size = size
321 }
322 if includeLast {
323 if err := fillLastCommits(ctx, r, commit, path, &tree); err != nil {
324 return domain.Tree{}, fmt.Errorf("last commits of %q: %w", path, err)
325 }
326 }
327 return tree, nil
328}
329
330// pathChunk is the size of the blocks that hold the bytes of the paths.
331const pathChunk = 32 << 10
332
333// pathWalker lists the files below a tree, depth first, in tree order.
334type pathWalker struct {
335 ctx context.Context
336 r *git.Repo
337 maxPaths int
338 maxBytes int
339
340 // list collects the paths. Their bytes live in chunks: a chunk is filled
341 // by a strings.Builder, so a path is read back as a string without a copy,
342 // and a full chunk is left to the paths that point into it instead of
343 // being grown and copied.
344 list domain.PathList
345 chunk strings.Builder
346 prefix []byte // "dir/sub/" of the directory being read
347 bad error
348}
349
350// listPaths lists the files and symbolic links of a revision recursively.
351func listPaths(ctx context.Context, r *git.Repo, rev domain.Revision, maxPaths, maxBytes int) (domain.PathList, error) {
352 commit, root, err := commitTree(r, rev)
353 if err != nil {
354 return domain.PathList{}, err
355 }
356 treeID, err := domainID(root)
357 if err != nil {
358 return domain.PathList{}, err
359 }
360 w := &pathWalker{ctx: ctx, r: r, maxPaths: maxPaths, maxBytes: maxBytes}
361 w.list.CommitID, w.list.TreeID = commit, treeID
362 if err := w.walk(root); err != nil {
363 return domain.PathList{}, err
364 }
365 return w.list, nil
366}
367
368// walk lists one directory. The recursion is inside the callback of ReadTree,
369// where the entries of a subdirectory come at the place of the subdirectory,
370// like in `git ls-tree -r`; the callback can only say stop, so the reasons to
371// stop are kept in the walker. Every ReadTree takes its own pooled buffer, so
372// the nesting is safe.
373func (w *pathWalker) walk(tree git.ObjectID) error {
374 if err := w.ctx.Err(); err != nil {
375 return err
376 }
377 err := w.r.ReadTree(tree, func(e git.Entry) bool {
378 switch {
379 case e.Mode.IsGitlink():
380 return true
381 case e.Mode.IsTree():
382 mark := len(w.prefix)
383 w.prefix = append(w.prefix, e.Name...)
384 // The paths below a directory that is not addressable are not
385 // either: this also bounds the depth of the recursion.
386 if !domain.IsAddressablePathBytes(w.prefix) {
387 w.prefix = w.prefix[:mark]
388 return true
389 }
390 w.prefix = append(w.prefix, '/')
391 err := w.walk(e.ID)
392 w.prefix = w.prefix[:mark]
393 if err != nil {
394 w.bad = err
395 return false
396 }
397 return !w.list.Truncated
398 case e.Mode.IsRegular(), e.Mode.IsSymlink():
399 return w.add(e.Name)
400 }
401 w.bad = fmt.Errorf("unknown mode %o of %q", uint32(e.Mode), e.Name)
402 return false
403 })
404 if err == nil {
405 err = w.bad
406 }
407 return err
408}
409
410// add lists the file name of the current directory, and says whether the walk
411// goes on. PathList.Add filters the paths that are not addressable; their
412// bytes stay in the chunk, which is rare enough not to matter.
413func (w *pathWalker) add(name []byte) bool {
414 mark := len(w.prefix)
415 w.prefix = append(w.prefix, name...)
416 path := w.prefix
417 w.prefix = w.prefix[:mark]
418 if w.chunk.Cap()-w.chunk.Len() < len(path) {
419 w.chunk = strings.Builder{}
420 w.chunk.Grow(max(pathChunk, len(path)))
421 }
422 start := w.chunk.Len()
423 w.chunk.Write(path)
424 return w.list.Add(w.chunk.String()[start:], w.maxPaths, w.maxBytes)
425}
426
427// fillLastCommits sets the last commit of the first MaxLastCommitEntries
428// entries with one walk.
429//
430//hexcheck:ignore suspicious-business-logic-in-adapter converts ermite-git values into domain values; the limits and the orders come from domain
431func fillLastCommits(ctx context.Context, r *git.Repo, commit domain.ObjectID, dir domain.TreePath, tree *domain.Tree) error {
432 n := len(tree.Entries)
433 if n > domain.MaxLastCommitEntries {
434 n = domain.MaxLastCommitEntries
435 tree.LastCommitsOmitted = true
436 }
437 if n == 0 {
438 return nil
439 }
440 start, err := git.ParseID(commit.String())
441 if err != nil {
442 return err
443 }
444 names := make([]string, n)
445 for i := range n {
446 names[i] = tree.Entries[i].Name
447 }
448 ids, err := r.LastCommits(ctx, start, dir.String(), names)
449 if err != nil {
450 return err
451 }
452 var c git.Commit
453 seen := make(map[git.ObjectID]domain.Commit, 8)
454 for i, id := range ids {
455 if id.IsZero() {
456 return fmt.Errorf("no commit touches %q: %w", tree.Entries[i].Path, domain.ErrNotFound)
457 }
458 dc, ok := seen[id]
459 if !ok {
460 if err := r.ReadCommit(id, &c); err != nil {
461 return err
462 }
463 if dc, err = convertCommit(&c); err != nil {
464 return err
465 }
466 seen[id] = dc
467 }
468 cp := dc
469 tree.Entries[i].LastCommit = &cp
470 }
471 return nil
472}
473
474// convertCommit builds a domain commit. A commit in another encoding than
475// UTF-8 is converted by git and not by the library: errFallback.
476//
477// The ids, the names, the emails and the message share one backing string, cut
478// into substrings: one allocation for the text of a commit instead of one per
479// field.
480//
481//hexcheck:ignore suspicious-business-logic-in-adapter converts ermite-git values into domain values; the limits and the orders come from domain
482func convertCommit(c *git.Commit) (domain.Commit, error) {
483 if len(c.Encoding) > 0 && !isUTF8Label(c.Encoding) {
484 return domain.Commit{}, errFallback
485 }
486 buf := make([]byte, 0, 40*(1+len(c.Parents))+len(c.Author.Name)+len(c.Author.Email)+
487 len(c.Committer.Name)+len(c.Committer.Email)+len(c.Message))
488 buf = c.ID.AppendHex(buf)
489 for _, p := range c.Parents {
490 buf = p.AppendHex(buf)
491 }
492 end := [5]int{}
493 for i, f := range [...][]byte{c.Author.Name, c.Author.Email, c.Committer.Name, c.Committer.Email, c.Message} {
494 if i == 0 || i == 2 {
495 f = trimNameRight(f)
496 }
497 buf = append(buf, f...)
498 end[i] = len(buf)
499 }
500 text := string(buf)
501
502 id, err := domain.ParseObjectID(text[:40])
503 if err != nil {
504 return domain.Commit{}, err
505 }
506 out := domain.Commit{ID: id}
507 if len(c.Parents) > 0 {
508 out.Parents = make([]domain.ObjectID, len(c.Parents))
509 for i := range c.Parents {
510 if out.Parents[i], err = domain.ParseObjectID(text[40*(i+1) : 40*(i+2)]); err != nil {
511 return domain.Commit{}, err
512 }
513 }
514 }
515 at := 40 * (1 + len(c.Parents))
516 out.Author = domain.Signature{
517 Name: domain.ValidGitText(text[at:end[0]]), Email: domain.ValidGitText(text[end[0]:end[1]]),
518 Time: time.Unix(c.Author.Time, 0).UTC(),
519 }
520 out.Committer = domain.Signature{
521 Name: domain.ValidGitText(text[end[1]:end[2]]), Email: domain.ValidGitText(text[end[2]:end[3]]),
522 Time: time.Unix(c.Committer.Time, 0).UTC(),
523 }
524 out.Message, out.Summary = domain.SplitCommitMessage(text[end[3]:])
525 return out, nil
526}
527
528// trimNameRight drops the trailing whitespace of a name the way git does (the
529// library trims spaces only).
530func trimNameRight(b []byte) []byte { return bytes.TrimRight(b, " \t\r\n") }
531
532func isUTF8Label(b []byte) bool {
533 return bytes.EqualFold(b, []byte("utf-8")) || bytes.EqualFold(b, []byte("utf8"))
534}
535
536// listCommits returns a page of history, one commit more than the page to
537// know whether the history goes on.
538func listCommits(ctx context.Context, r *git.Repo, q ports.CommitQuery) (ports.CommitPage, error) {
539 res, err := resolveRevision(r, q.Revision)
540 if err != nil {
541 return ports.CommitPage{}, err
542 }
543 head, err := res.domain()
544 if err != nil {
545 return ports.CommitPage{}, err
546 }
547 page := ports.CommitPage{Head: head.CommitID, Commits: make([]domain.Commit, 0, min(q.Limit, 64))}
548 opts := git.WalkOptions{Skip: q.Skip, Limit: q.Limit + 1, Path: q.Path.String()}
549 for c, err := range r.Walk(ctx, res.commit, opts) {
550 if err != nil {
551 return ports.CommitPage{}, err
552 }
553 if len(page.Commits) == q.Limit {
554 page.HasMore = true
555 break
556 }
557 dc, err := convertCommit(c)
558 if err != nil {
559 return ports.CommitPage{}, err
560 }
561 page.Commits = append(page.Commits, dc)
562 }
563 if len(page.Commits) == 0 {
564 page.Commits = nil // like gitcli: an empty page has no slice
565 }
566 return page, nil
567}
568
569// openBlob describes the file at path and opens its content. The first
570// BinarySniffLen bytes are read to decide Binary and MediaType, then given
571// back to the reader.
572//
573//hexcheck:ignore suspicious-business-logic-in-adapter converts ermite-git values into domain values; the limits and the orders come from domain
574func openBlob(ctx context.Context, r *git.Repo, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) {
575 if path.IsRoot() {
576 return domain.BlobInfo{}, nil, domain.ErrNotFile
577 }
578 _, root, err := commitTree(r, rev)
579 if err != nil {
580 return domain.BlobInfo{}, nil, err
581 }
582 e, err := lookup(r, root, path)
583 if err != nil {
584 return domain.BlobInfo{}, nil, err
585 }
586 if e.Mode.IsTree() {
587 return domain.BlobInfo{}, nil, domain.ErrNotFile
588 }
589 id, err := domainID(e.ID)
590 if err != nil {
591 return domain.BlobInfo{}, nil, err
592 }
593 size, rc, err := r.OpenBlob(e.ID)
594 if err != nil {
595 return domain.BlobInfo{}, nil, err
596 }
597 info := domain.BlobInfo{ID: id, Path: path.String(), Size: size, Mode: canonMode(e.Mode)}
598 head := make([]byte, min(size, int64(domain.BinarySniffLen)))
599 if _, err := io.ReadFull(rc, head); err != nil {
600 _ = rc.Close()
601 return domain.BlobInfo{}, nil, fmt.Errorf("read blob: %w", err)
602 }
603 info.SniffContent(head)
604 return info, &blobReader{ctx: ctx, head: head, rc: rc}, nil
605}
606
607// blobReader replays the bytes read to sniff the content, then the rest of
608// the blob. It stops when the context ends, as a gitcli stream does.
609type blobReader struct {
610 ctx context.Context
611 head []byte
612 rc io.ReadCloser
613}
614
615func (b *blobReader) Read(p []byte) (int, error) {
616 if err := b.ctx.Err(); err != nil {
617 return 0, err
618 }
619 if len(b.head) > 0 {
620 n := copy(p, b.head)
621 b.head = b.head[n:]
622 return n, nil
623 }
624 return b.rc.Read(p)
625}
626
627func (b *blobReader) Close() error { return b.rc.Close() }