1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package gitcli
4
5import (
6 "bytes"
7 "compress/gzip"
8 "context"
9 "errors"
10 "fmt"
11 "io"
12 "os/exec"
13 "strings"
14 "time"
15
16 "bnema.dev/ermite/internal/domain"
17 "bnema.dev/ermite/internal/ports"
18)
19
20// archiveTimeout bounds one archive when ctx has no deadline: a large
21// repository takes minutes, a stalled client must not hold a process forever.
22const archiveTimeout = 15 * time.Minute
23
24var _ ports.GitArchive = (*Git)(nil)
25
26// Archive streams the tree of the commit as a gzip-compressed tar, every path
27// below prefix ("<prefix>/..."), with `git archive`. The tar is compressed here,
28// not by a gzip binary. A client that goes away ends ctx, which kills git.
29// Nothing is written to w when the commit does not exist
30// (domain.ErrNotFound).
31func (g *Git) Archive(ctx context.Context, repo domain.RepoName, commit domain.ObjectID, prefix string, w io.Writer) error {
32 dir, err := g.repoDir(repo)
33 if err != nil {
34 return err
35 }
36 if _, ok := ctx.Deadline(); !ok {
37 var cancel context.CancelFunc
38 ctx, cancel = context.WithTimeout(ctx, archiveTimeout)
39 defer cancel()
40 }
41 release, err := g.acquireExec(ctx)
42 if err != nil {
43 return fmt.Errorf("git archive: %w", err)
44 }
45 defer release()
46
47 cmd := exec.CommandContext(ctx, g.bin, readArgs(dir, "archive", "--format=tar", "--prefix="+prefix+"/", commit.String(), "--")...)
48 cmd.Env = g.env
49 cmd.Dir = g.dataDir
50 cmd.WaitDelay = 5 * time.Second
51 stderr := &tailBuffer{max: 4096}
52 cmd.Stderr = stderr
53 stdout, err := cmd.StdoutPipe()
54 if err != nil {
55 return fmt.Errorf("git archive: %w", err)
56 }
57 if err := cmd.Start(); err != nil {
58 return fmt.Errorf("git archive: %w", err)
59 }
60 // The first byte decides whether git found the commit: wait for it before
61 // writing to w, so that a missing commit can still be answered.
62 first := make([]byte, 1)
63 if n, _ := io.ReadFull(stdout, first); n == 0 {
64 werr := cmd.Wait()
65 switch {
66 case ctx.Err() != nil:
67 return ctx.Err()
68 case werr != nil:
69 return fmt.Errorf("git archive %s %s: %w: %s", repo, commit, domain.ErrNotFound, strings.TrimSpace(stderr.String()))
70 }
71 return errors.New("git archive: empty output")
72 }
73 zw := gzip.NewWriter(w)
74 _, cerr := io.Copy(zw, io.MultiReader(bytes.NewReader(first), stdout))
75 if cerr == nil {
76 cerr = zw.Close()
77 }
78 if cerr != nil {
79 _ = cmd.Process.Kill()
80 }
81 werr := cmd.Wait()
82 switch {
83 case ctx.Err() != nil:
84 return ctx.Err()
85 case cerr != nil:
86 return fmt.Errorf("git archive %s: %w", repo, cerr)
87 case werr != nil:
88 return fmt.Errorf("git archive %s: %w: %s", repo, werr, strings.TrimSpace(stderr.String()))
89 }
90 return nil
91}