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