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).
31//
32//hexcheck:ignore suspicious-business-logic-in-adapter runs git archive and compresses its output: a process concern, access and naming are the use case's
33func (g *Git) Archive(ctx context.Context, repo domain.RepoName, commit domain.ObjectID, prefix string, w io.Writer) error {
34 dir, err := g.repoDir(repo)
35 if err != nil {
36 return err
37 }
38 if _, ok := ctx.Deadline(); !ok {
39 var cancel context.CancelFunc
40 ctx, cancel = context.WithTimeout(ctx, archiveTimeout)
41 defer cancel()
42 }
43 release, err := g.acquireExec(ctx)
44 if err != nil {
45 return fmt.Errorf("git archive: %w", err)
46 }
47 defer release()
48
49 cmd := exec.CommandContext(ctx, g.bin, readArgs(dir, "archive", "--format=tar", "--prefix="+prefix+"/", commit.String(), "--")...)
50 cmd.Env = g.env
51 cmd.Dir = g.dataDir
52 cmd.WaitDelay = 5 * time.Second
53 stderr := &tailBuffer{max: 4096}
54 cmd.Stderr = stderr
55 stdout, err := cmd.StdoutPipe()
56 if err != nil {
57 return fmt.Errorf("git archive: %w", err)
58 }
59 if err := cmd.Start(); err != nil {
60 return fmt.Errorf("git archive: %w", err)
61 }
62 // The first byte decides whether git found the commit: wait for it before
63 // writing to w, so that a missing commit can still be answered.
64 first := make([]byte, 1)
65 if n, _ := io.ReadFull(stdout, first); n == 0 {
66 werr := cmd.Wait()
67 switch {
68 case ctx.Err() != nil:
69 return ctx.Err()
70 case werr != nil:
71 return fmt.Errorf("git archive %s %s: %w: %s", repo, commit, domain.ErrNotFound, strings.TrimSpace(stderr.String()))
72 }
73 return errors.New("git archive: empty output")
74 }
75 zw := gzip.NewWriter(w)
76 _, cerr := io.Copy(zw, io.MultiReader(bytes.NewReader(first), stdout))
77 if cerr == nil {
78 cerr = zw.Close()
79 }
80 if cerr != nil {
81 _ = cmd.Process.Kill()
82 }
83 werr := cmd.Wait()
84 switch {
85 case ctx.Err() != nil:
86 return ctx.Err()
87 case cerr != nil:
88 return fmt.Errorf("git archive %s: %w", repo, cerr)
89 case werr != nil:
90 return fmt.Errorf("git archive %s: %w: %s", repo, werr, strings.TrimSpace(stderr.String()))
91 }
92 return nil
93}