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