1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package gitcli
4
5import (
6 "bufio"
7 "context"
8 "errors"
9 "fmt"
10 "io"
11 "io/fs"
12 "os"
13 "path/filepath"
14 "strconv"
15 "strings"
16 "sync"
17 "time"
18
19 "bnema.dev/ermite/internal/domain"
20 "bnema.dev/ermite/internal/ports"
21)
22
23var _ ports.RepoReader = (*Git)(nil)
24
25const (
26 headsRefPrefix = "refs/heads/"
27 tagsRefPrefix = "refs/tags/"
28 // maxRefLine bounds a line of for-each-ref.
29 maxRefLine = 16 * 1024
30)
31
32// notFound reports whether err means a missing object.
33func notFound(err error) bool { return errors.Is(err, errObjectMissing) }
34
35// ListRefs lists HEAD, the branches and the tags with `git for-each-ref`.
36// An annotated tag is followed to its commit; a tag of a tag is followed with
37// one `cat-file` request each.
38//
39//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain refs
40func (g *Git) ListRefs(ctx context.Context, repo domain.RepoName) ([]domain.Ref, error) {
41 dir, err := g.repoDir(repo)
42 if err != nil {
43 return nil, err
44 }
45 type raw struct {
46 name, id, typ, derefID, derefType string
47 }
48 var raws []raw
49 args := readArgs(dir, "for-each-ref",
50 "--format=%(refname)%00%(objectname)%00%(objecttype)%00%(*objectname)%00%(*objecttype)",
51 headsRefPrefix, tagsRefPrefix)
52 err = g.pipe(ctx, args, func(r *bufio.Reader) error {
53 lr := newLineReader(r, maxRefLine)
54 for {
55 line, _, err := lr.next()
56 if errors.Is(err, io.EOF) {
57 return nil
58 }
59 if err != nil {
60 return err
61 }
62 f := strings.Split(string(line), "\x00")
63 if len(f) != 5 {
64 return fmt.Errorf("git for-each-ref: unexpected line %q", truncateText(string(line)))
65 }
66 raws = append(raws, raw{f[0], f[1], f[2], f[3], f[4]})
67 }
68 })
69 if err != nil {
70 return nil, fmt.Errorf("list refs of %q: %w", repo, err)
71 }
72
73 refs := make([]domain.Ref, 0, len(raws)+1)
74 byName := make(map[string]domain.Ref, len(raws))
75 var nested []int // indices in refs of tags that point to a tag
76 for _, rw := range raws {
77 ref := domain.Ref{Name: rw.name}
78 switch {
79 case strings.HasPrefix(rw.name, headsRefPrefix):
80 ref.Kind, ref.ShortName = domain.RefKindBranch, strings.TrimPrefix(rw.name, headsRefPrefix)
81 case strings.HasPrefix(rw.name, tagsRefPrefix):
82 ref.Kind, ref.ShortName = domain.RefKindTag, strings.TrimPrefix(rw.name, tagsRefPrefix)
83 default:
84 continue
85 }
86 if ref.ID, err = domain.ParseObjectID(rw.id); err != nil {
87 return nil, fmt.Errorf("list refs of %q: %w", repo, err)
88 }
89 switch {
90 case rw.typ == "commit":
91 ref.CommitID = ref.ID
92 case rw.typ == "tag" && rw.derefType == "commit":
93 if ref.CommitID, err = domain.ParseObjectID(rw.derefID); err != nil {
94 return nil, fmt.Errorf("list refs of %q: %w", repo, err)
95 }
96 case rw.typ == "tag" && rw.derefType == "tag":
97 nested = append(nested, len(refs))
98 }
99 refs = append(refs, ref)
100 byName[ref.Name] = ref
101 }
102 if len(nested) > 0 {
103 err := g.cat.do(ctx, dir, func(c *catConn) error {
104 for _, i := range nested {
105 h, err := c.info(refs[i].Name + "^{commit}")
106 if notFound(err) {
107 continue
108 }
109 if err != nil {
110 return err
111 }
112 if refs[i].CommitID, err = domain.ParseObjectID(h.ID); err != nil {
113 return err
114 }
115 }
116 return nil
117 })
118 if err != nil {
119 return nil, fmt.Errorf("follow tags of %q: %w", repo, err)
120 }
121 for _, i := range nested {
122 byName[refs[i].Name] = refs[i]
123 }
124 }
125
126 head := domain.Ref{Name: "HEAD", Kind: domain.RefKindHead}
127 target, err := readHead(dir)
128 if err != nil {
129 return nil, fmt.Errorf("read HEAD of %q: %w", repo, err)
130 }
131 if b, ok := byName[target]; ok && target != "" {
132 head.ShortName, head.ID, head.CommitID = b.ShortName, b.ID, b.CommitID
133 } else if short, ok := strings.CutPrefix(target, headsRefPrefix); ok {
134 head.ShortName = short // unborn branch
135 }
136 return append([]domain.Ref{head}, refs...), nil
137}
138
139// readHead returns the ref HEAD of the bare repository in dir points to, or
140// "" if HEAD is detached. It reads the HEAD file (the repositories use the
141// files ref backend), which is what `git symbolic-ref HEAD` would answer
142// without starting a process. A missing file is domain.ErrNotFound.
143func readHead(dir string) (string, error) {
144 raw, err := os.ReadFile(filepath.Join(dir, "HEAD"))
145 if err != nil {
146 if errors.Is(err, fs.ErrNotExist) {
147 return "", domain.ErrNotFound
148 }
149 return "", fmt.Errorf("read HEAD: %w", err)
150 }
151 ref, ok := strings.CutPrefix(strings.TrimSpace(string(raw)), "ref: ")
152 if !ok {
153 return "", nil // a commit id: detached
154 }
155 return ref, nil
156}
157
158// revCandidate is one way to read a revision as a commit.
159type revCandidate struct {
160 spec string // object expression for cat-file
161 ref string // full ref name it stands for, "" for ids and HEAD
162}
163
164// candidates lists, in order, how a revision is tried. A full object id is an
165// object id first: a branch or a tag that happens to be named like a commit
166// must not redirect a link to that commit; refs are tried only when no such
167// object exists. Otherwise it is the branch, then the tag, then (for "HEAD",
168// full ref names and abbreviations) as written. Other forms Git understands,
169// such as "main~2" or "FETCH_HEAD", are not revisions of ermite.
170func candidates(rev domain.Revision) []revCandidate {
171 s := rev.String()
172 switch {
173 case rev.IsHead() || s == "HEAD":
174 return []revCandidate{{spec: "HEAD^{commit}"}}
175 }
176 refs := []revCandidate{
177 {spec: headsRefPrefix + s + "^{commit}", ref: headsRefPrefix + s},
178 {spec: tagsRefPrefix + s + "^{commit}", ref: tagsRefPrefix + s},
179 }
180 switch {
181 case rev.IsObjectID():
182 return append([]revCandidate{{spec: s + "^{commit}"}}, refs...)
183 case strings.HasPrefix(s, "refs/"):
184 return append(refs, revCandidate{spec: s + "^{commit}", ref: s})
185 case rev.IsAbbrevID():
186 return append(refs, revCandidate{spec: s + "^{commit}"})
187 }
188 return refs
189}
190
191// resolve resolves rev on an open cat-file connection.
192//
193//hexcheck:ignore suspicious-business-logic-in-adapter maps a revision to git object names, a git detail of the reader
194func resolve(c *catConn, rev domain.Revision) (domain.ResolvedRevision, error) {
195 for _, cand := range candidates(rev) {
196 h, err := c.info(cand.spec)
197 if notFound(err) {
198 continue
199 }
200 if err != nil {
201 return domain.ResolvedRevision{}, err
202 }
203 if h.Type != "commit" {
204 continue
205 }
206 id, err := domain.ParseObjectID(h.ID)
207 if err != nil {
208 return domain.ResolvedRevision{}, c.fail(err)
209 }
210 return domain.ResolvedRevision{CommitID: id, Ref: cand.ref}, nil
211 }
212 return domain.ResolvedRevision{}, fmt.Errorf("revision %q: %w", rev, domain.ErrNotFound)
213}
214
215// ResolveRevision resolves a revision with `cat-file`.
216//
217//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port
218func (g *Git) ResolveRevision(ctx context.Context, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error) {
219 dir, err := g.repoDir(repo)
220 if err != nil {
221 return domain.ResolvedRevision{}, err
222 }
223 var res domain.ResolvedRevision
224 err = g.cat.do(ctx, dir, func(c *catConn) (err error) {
225 res, err = resolve(c, rev)
226 return err
227 })
228 if err != nil {
229 return domain.ResolvedRevision{}, fmt.Errorf("resolve %q in %q: %w", rev, repo, err)
230 }
231 return res, nil
232}
233
234// ResolveRefPath tries the leading segments of path as a revision (branch,
235// tag, full ref name, HEAD; a single segment may also be a commit id), longest
236// first, with up to one `cat-file` request per candidate on the repository's
237// process. At most domain.MaxRefPathTries segments are tried.
238//
239//hexcheck:ignore suspicious-business-logic-in-adapter the split is domain.SplitRefPath; this only runs it on a cat-file connection
240func (g *Git) ResolveRefPath(ctx context.Context, repo domain.RepoName, path string) (domain.RefPath, error) {
241 dir, err := g.repoDir(repo)
242 if err != nil {
243 return domain.RefPath{}, err
244 }
245 var out domain.RefPath
246 err = g.cat.do(ctx, dir, func(c *catConn) (err error) {
247 out, err = domain.SplitRefPath(path, func(rev domain.Revision) (domain.ResolvedRevision, error) {
248 return resolve(c, rev)
249 })
250 return err
251 })
252 if err != nil {
253 return domain.RefPath{}, fmt.Errorf("resolve path %q in %q: %w", path, repo, err)
254 }
255 return out, nil
256}
257
258// object is what lookup finds at a path of a revision.
259type object struct {
260 commit domain.ObjectID
261 info catInfo
262}
263
264// lookup resolves rev and reads the header of the object at path, in one
265// request series on the repository's process. A missing path is
266// domain.ErrNotFound.
267func (g *Git) lookup(ctx context.Context, dir string, rev domain.Revision, path domain.TreePath) (object, error) {
268 var obj object
269 err := g.cat.do(ctx, dir, func(c *catConn) error {
270 res, err := resolve(c, rev)
271 if err != nil {
272 return err
273 }
274 spec := res.CommitID.String() + "^{tree}"
275 if !path.IsRoot() {
276 spec = res.CommitID.String() + ":" + path.String()
277 }
278 h, err := c.info(spec)
279 if notFound(err) {
280 return fmt.Errorf("path %q: %w", path, domain.ErrNotFound)
281 }
282 if err != nil {
283 return err
284 }
285 obj = object{commit: res.CommitID, info: h}
286 return nil
287 })
288 return obj, err
289}
290
291// ListPaths lists the files and symbolic links of a revision with
292// `git ls-tree -r -z`, in its order, up to the bounds of the port.
293//
294//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port
295func (g *Git) ListPaths(ctx context.Context, repo domain.RepoName, rev domain.Revision, maxPaths, maxBytes int) (domain.PathList, error) {
296 dir, err := g.repoDir(repo)
297 if err != nil {
298 return domain.PathList{}, err
299 }
300 obj, err := g.lookup(ctx, dir, rev, domain.TreePath{})
301 if err != nil {
302 return domain.PathList{}, fmt.Errorf("paths of %q: %w", repo, err)
303 }
304 treeID, err := domain.ParseObjectID(obj.info.ID)
305 if err != nil {
306 return domain.PathList{}, fmt.Errorf("paths of %q: %w", repo, err)
307 }
308 list := domain.PathList{CommitID: obj.commit, TreeID: treeID}
309 err = g.pipe(ctx, readArgs(dir, "ls-tree", "-r", "-z", treeID.String()), func(r *bufio.Reader) error {
310 for {
311 tok, err := readToken(r)
312 if errors.Is(err, io.EOF) {
313 return nil
314 }
315 if err != nil {
316 return err
317 }
318 meta, path, ok := strings.Cut(tok, "\t")
319 if !ok {
320 return fmt.Errorf("malformed ls-tree entry %q", tok)
321 }
322 // meta is "<mode> <type> <id>": a submodule is a commit.
323 _, rest, _ := strings.Cut(meta, " ")
324 if typ, _, _ := strings.Cut(rest, " "); typ == "commit" {
325 continue
326 }
327 // A copy: path would keep the whole "<mode> <type> <id>\t" entry alive.
328 if !list.Add(strings.Clone(path), maxPaths, maxBytes) {
329 return errStop
330 }
331 }
332 })
333 if err != nil {
334 return domain.PathList{}, fmt.Errorf("paths of %q: %w", repo, err)
335 }
336 return list, nil
337}
338
339// GetTree lists a directory with `git ls-tree -z -l`.
340//
341//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port
342func (g *Git) GetTree(ctx context.Context, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLast bool) (domain.Tree, error) {
343 dir, err := g.repoDir(repo)
344 if err != nil {
345 return domain.Tree{}, err
346 }
347 obj, err := g.lookup(ctx, dir, rev, path)
348 if err != nil {
349 return domain.Tree{}, fmt.Errorf("tree %q of %q: %w", path, repo, err)
350 }
351 if obj.info.Type != "tree" {
352 return domain.Tree{}, fmt.Errorf("tree %q of %q: %w", path, repo, domain.ErrNotDirectory)
353 }
354 treeID, err := domain.ParseObjectID(obj.info.ID)
355 if err != nil {
356 return domain.Tree{}, err
357 }
358 tree := domain.Tree{CommitID: obj.commit, ID: treeID}
359
360 err = g.pipe(ctx, readArgs(dir, "ls-tree", "-z", "-l", treeID.String()), func(r *bufio.Reader) error {
361 for {
362 tok, err := readToken(r)
363 if errors.Is(err, io.EOF) {
364 return nil
365 }
366 if err != nil {
367 return err
368 }
369 e, err := parseTreeEntry(tok, path)
370 if err != nil {
371 return err
372 }
373 tree.Entries = append(tree.Entries, e)
374 }
375 })
376 if err != nil {
377 return domain.Tree{}, fmt.Errorf("list tree %q of %q: %w", path, repo, err)
378 }
379 domain.SortTreeEntries(tree.Entries)
380 tree.BoundTree()
381 if includeLast {
382 if err := g.fillLastCommits(ctx, dir, &tree); err != nil {
383 return domain.Tree{}, fmt.Errorf("last commits of %q in %q: %w", path, repo, err)
384 }
385 }
386 return tree, nil
387}
388
389// parseTreeEntry parses "<mode> <type> <id> <size>\t<name>" of ls-tree -l.
390//
391//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port
392func parseTreeEntry(rec string, parent domain.TreePath) (domain.TreeEntry, error) {
393 meta, name, ok := strings.Cut(rec, "\t")
394 f := strings.Fields(meta)
395 if !ok || len(f) != 4 || name == "" {
396 return domain.TreeEntry{}, fmt.Errorf("git ls-tree: unexpected entry %q", truncateText(rec))
397 }
398 mode, err := strconv.ParseUint(f[0], 8, 32)
399 if err != nil {
400 return domain.TreeEntry{}, fmt.Errorf("git ls-tree: mode of %q: %w", truncateText(rec), err)
401 }
402 id, err := domain.ParseObjectID(f[2])
403 if err != nil {
404 return domain.TreeEntry{}, fmt.Errorf("git ls-tree: %w", err)
405 }
406 var size int64
407 if f[3] != "-" {
408 if size, err = strconv.ParseInt(f[3], 10, 64); err != nil {
409 return domain.TreeEntry{}, fmt.Errorf("git ls-tree: size of %q: %w", truncateText(rec), err)
410 }
411 }
412 kind := domain.EntryKindOf(uint32(mode))
413 if kind == "" {
414 return domain.TreeEntry{}, fmt.Errorf("git ls-tree: unknown mode %s of %q", f[0], name)
415 }
416 return domain.TreeEntry{
417 Name: name, Path: parent.Join(name), Kind: kind, Mode: uint32(mode), ID: id, Size: size,
418 }, nil
419}
420
421// fillLastCommits sets the last commit of the first MaxLastCommitEntries
422// entries with `git log -1 <commit> -- <path>`, run by a small pool of
423// workers: min(4, max(1, MaxExec/2)) at most. Git answers each from the
424// commit-graph and its changed-path filters when they exist. Every command
425// takes a slot of the exec semaphore on its own (the workers hold none), so a
426// large directory cannot use more than its share of the processes the server
427// may run. Each worker writes its own entry, so the result does not depend on
428// the scheduling; the first error stops the others and is returned. Entries
429// beyond the bound are left without one and the tree says so.
430//
431//hexcheck:ignore suspicious-business-logic-in-adapter fills the tree with git output; the bound comes from the port
432func (g *Git) fillLastCommits(ctx context.Context, dir string, tree *domain.Tree) error {
433 n := len(tree.Entries)
434 if n > domain.MaxLastCommitEntries {
435 n = domain.MaxLastCommitEntries
436 tree.LastCommitsOmitted = true
437 }
438 workers := min(4, max(1, cap(g.exec)/2), n)
439 if workers == 0 {
440 return nil
441 }
442 ctx, cancel := context.WithCancel(ctx)
443 defer cancel()
444 var (
445 wg sync.WaitGroup
446 mu sync.Mutex
447 firstErr error
448 )
449 next := make(chan int)
450 for range workers {
451 wg.Go(func() {
452 for i := range next {
453 c, err := g.lastCommit(ctx, dir, tree.CommitID, tree.Entries[i].Path)
454 if err != nil {
455 mu.Lock()
456 if firstErr == nil {
457 firstErr = err
458 cancel()
459 }
460 mu.Unlock()
461 continue
462 }
463 tree.Entries[i].LastCommit = &c
464 }
465 })
466 }
467feed:
468 for i := range n {
469 select {
470 case next <- i:
471 case <-ctx.Done():
472 break feed
473 }
474 }
475 close(next)
476 wg.Wait()
477 if firstErr != nil {
478 return firstErr
479 }
480 return ctx.Err()
481}
482
483// lastCommit returns the newest commit reachable from head that changed path;
484// an empty path means head itself.
485//
486//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port
487func (g *Git) lastCommit(ctx context.Context, dir string, head domain.ObjectID, path string) (domain.Commit, error) {
488 var c domain.Commit
489 found := false
490 args := readArgs(dir, "log", "-z", "-n", "1", "--format="+commitFormat, "--end-of-options", head.String())
491 if path != "" {
492 args = append(args, "--", path)
493 }
494 err := g.pipe(ctx, args, func(r *bufio.Reader) (err error) {
495 c, err = readCommit(r)
496 if errors.Is(err, io.EOF) {
497 return nil
498 }
499 found = err == nil
500 return err
501 })
502 if err != nil {
503 return domain.Commit{}, err
504 }
505 if !found {
506 return domain.Commit{}, fmt.Errorf("no commit touches %q: %w", path, domain.ErrNotFound)
507 }
508 return c, nil
509}
510
511// commitFormat is the `git log` format of a commit: NUL separated fields,
512// the message last. With -z the commits are NUL terminated, so a commit is
513// nine tokens.
514const commitFormat = "%H%x00%P%x00%an%x00%ae%x00%at%x00%cn%x00%ce%x00%ct%x00%B"
515
516// readCommit reads one commit in commitFormat. It returns io.EOF at the end
517// of the output.
518//
519//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port
520func readCommit(r *bufio.Reader) (domain.Commit, error) {
521 var f [9]string
522 for i := range f {
523 tok, err := readToken(r)
524 if err != nil {
525 if i > 0 && errors.Is(err, io.EOF) {
526 err = io.ErrUnexpectedEOF
527 }
528 return domain.Commit{}, err
529 }
530 f[i] = tok
531 }
532 id, err := domain.ParseObjectID(f[0])
533 if err != nil {
534 return domain.Commit{}, fmt.Errorf("git log: %w", err)
535 }
536 c := domain.Commit{ID: id}
537 for p := range strings.FieldsSeq(f[1]) {
538 pid, err := domain.ParseObjectID(p)
539 if err != nil {
540 return domain.Commit{}, fmt.Errorf("git log: parent of %s: %w", id, err)
541 }
542 c.Parents = append(c.Parents, pid)
543 }
544 if c.Author, err = signature(f[2], f[3], f[4]); err != nil {
545 return domain.Commit{}, fmt.Errorf("git log: author of %s: %w", id, err)
546 }
547 if c.Committer, err = signature(f[5], f[6], f[7]); err != nil {
548 return domain.Commit{}, fmt.Errorf("git log: committer of %s: %w", id, err)
549 }
550 c.Message, c.Summary = domain.SplitCommitMessage(f[8])
551 return c, nil
552}
553
554func signature(name, email, unix string) (domain.Signature, error) {
555 sec, err := strconv.ParseInt(unix, 10, 64)
556 if err != nil {
557 return domain.Signature{}, err
558 }
559 return domain.Signature{
560 Name: domain.ValidGitText(name),
561 Email: domain.ValidGitText(email),
562 Time: time.Unix(sec, 0).UTC(),
563 }, nil
564}
565
566// ListCommits pages through `git log`, which stops after Skip+Limit+1 commits.
567//
568//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port
569func (g *Git) ListCommits(ctx context.Context, repo domain.RepoName, q ports.CommitQuery) (ports.CommitPage, error) {
570 if err := q.Validate(); err != nil {
571 return ports.CommitPage{}, err
572 }
573 dir, err := g.repoDir(repo)
574 if err != nil {
575 return ports.CommitPage{}, err
576 }
577 var head domain.ResolvedRevision
578 err = g.cat.do(ctx, dir, func(c *catConn) (err error) {
579 head, err = resolve(c, q.Revision)
580 return err
581 })
582 if err != nil {
583 return ports.CommitPage{}, fmt.Errorf("history of %q: %w", repo, err)
584 }
585 page := ports.CommitPage{Head: head.CommitID}
586 args := readArgs(dir, "log", "-z", "--skip="+strconv.Itoa(q.Skip), "-n", strconv.Itoa(q.Limit+1),
587 "--format="+commitFormat, "--end-of-options", head.CommitID.String())
588 if !q.Path.IsRoot() {
589 args = append(args, "--", q.Path.String())
590 }
591 err = g.pipe(ctx, args, func(r *bufio.Reader) error {
592 for {
593 c, err := readCommit(r)
594 if errors.Is(err, io.EOF) {
595 return nil
596 }
597 if err != nil {
598 return err
599 }
600 if len(page.Commits) == q.Limit {
601 page.HasMore = true
602 return errStop
603 }
604 page.Commits = append(page.Commits, c)
605 }
606 })
607 if err != nil {
608 return ports.CommitPage{}, fmt.Errorf("history of %q: %w", repo, err)
609 }
610 return page, nil
611}