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