1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3// Package gitcli implements the Git ports over the git binary. Every
4// invocation runs in an isolated environment that is built here and never
5// inherits the caller's.
6package gitcli
7
8import (
9 "bytes"
10 "context"
11 "errors"
12 "fmt"
13 "io"
14 "os/exec"
15 "path/filepath"
16 "runtime"
17 "strings"
18 "sync"
19 "time"
20)
21
22// Config configures a Git adapter. The adapter never reads the process
23// environment: the caller passes what it needs.
24type Config struct {
25 // Bin is the git executable: a name looked up in the process PATH, or a path.
26 // It is required; the default belongs to the configuration.
27 Bin string
28 // DataDir is the data root (absolute). repos/, repos/.staging/ and
29 // trash/ below it must exist; run/git-home/ is the HOME of git.
30 DataDir string
31 // Path is the PATH given to git, which it uses to find its helpers.
32 Path string
33 // MaxReceiveBytes is the largest pack a push may send
34 // (receive.maxInputSize); zero leaves Git unlimited.
35 MaxReceiveBytes int64
36 // CatFileIdle is how long a repository's long-lived `git cat-file`
37 // process may stay unused before it is stopped. Zero means 60 seconds.
38 CatFileIdle time.Duration
39 // MaxCatFiles bounds the long-lived cat-file processes. Zero means 16.
40 MaxCatFiles int
41 // MaxExec bounds the one-shot git processes that run at once (every
42 // command but the long-lived cat-file processes and the blob streams);
43 // callers beyond it wait for a slot until their context ends. Zero means
44 // max(4, 2*GOMAXPROCS).
45 MaxExec int
46 // MaxBlobStreams bounds the open blob streams, each of which holds a git
47 // process until its reader is closed. Streams beyond it are refused with
48 // domain.ErrResourceExhausted rather than queued: a slow reader could hold
49 // a slot for long. Zero means 32.
50 MaxBlobStreams int
51}
52
53// Git is the git CLI adapter for ports.GitRepos.
54type Git struct {
55 bin string
56 dataDir string
57 env []string
58 maxRecv int64
59
60 mu sync.Mutex // guards backend
61 backend string // git-http-backend, resolved on first use
62
63 cat *catPool // long-lived cat-file processes of the read side
64
65 exec chan struct{} // slots of the one-shot processes, see Config.MaxExec
66 blobs chan struct{} // slots of the blob streams, see Config.MaxBlobStreams
67}
68
69// Defaults of the process bounds.
70const defaultMaxBlobStreams = 32
71
72func defaultMaxExec() int { return max(4, 2*runtime.GOMAXPROCS(0)) }
73
74// New builds an adapter. It does not run git: call CheckVersion.
75func New(cfg Config) *Git {
76 env := buildEnv(cfg.DataDir, cfg.Path)
77 maxExec, maxBlobs := cfg.MaxExec, cfg.MaxBlobStreams
78 if maxExec <= 0 {
79 maxExec = defaultMaxExec()
80 }
81 if maxBlobs <= 0 {
82 maxBlobs = defaultMaxBlobStreams
83 }
84 return &Git{
85 bin: cfg.Bin, dataDir: cfg.DataDir, env: env, maxRecv: cfg.MaxReceiveBytes,
86 cat: newCatPool(cfg.Bin, env, cfg.CatFileIdle, cfg.MaxCatFiles),
87 exec: make(chan struct{}, maxExec),
88 blobs: make(chan struct{}, maxBlobs),
89 }
90}
91
92// Close stops the long-lived git processes of the read side and waits for
93// them, until ctx ends. Reads after Close fail with ErrClosed. Close is
94// idempotent; the adapter's write side keeps working.
95func (g *Git) Close(ctx context.Context) error { return g.cat.close(ctx) }
96
97// buildEnv returns the complete environment of every git invocation. Nothing
98// else is passed: no GIT_* variable, no user HOME, no locale.
99func buildEnv(dataDir, path string) []string {
100 return []string{
101 "GIT_CONFIG_NOSYSTEM=1",
102 "HOME=" + filepath.Join(dataDir, "run", "git-home"),
103 "GIT_TERMINAL_PROMPT=0",
104 "LC_ALL=C",
105 "PATH=" + path,
106 }
107}
108
109// commandTimeout bounds one git invocation when ctx has no deadline.
110const commandTimeout = 2 * time.Minute
111
112// acquireExec takes a slot of the one-shot processes, waiting until ctx ends.
113// The wait counts against the deadline of ctx, so a saturated server answers a
114// request with its deadline rather than hanging it.
115func (g *Git) acquireExec(ctx context.Context) (release func(), err error) {
116 select {
117 case g.exec <- struct{}{}:
118 return func() { <-g.exec }, nil
119 case <-ctx.Done():
120 return nil, ctx.Err()
121 }
122}
123
124// run executes git with args in the isolated environment and returns stdout.
125func (g *Git) run(ctx context.Context, args ...string) (string, error) {
126 return g.runInput(ctx, nil, args...)
127}
128
129// runInput is run with the given standard input; nil means none.
130func (g *Git) runInput(ctx context.Context, stdin io.Reader, args ...string) (string, error) {
131 if _, ok := ctx.Deadline(); !ok {
132 var cancel context.CancelFunc
133 ctx, cancel = context.WithTimeout(ctx, commandTimeout)
134 defer cancel()
135 }
136 release, err := g.acquireExec(ctx)
137 if err != nil {
138 return "", fmt.Errorf("git %s: %w", subcommand(args), err)
139 }
140 defer release()
141 cmd := exec.CommandContext(ctx, g.bin, args...)
142 cmd.Env = g.env
143 cmd.Dir = g.dataDir
144 cmd.WaitDelay = 5 * time.Second
145 cmd.Stdin = stdin
146 var stdout, stderr bytes.Buffer
147 cmd.Stdout = &stdout
148 cmd.Stderr = &stderr
149 if err := cmd.Run(); err != nil {
150 msg := strings.TrimSpace(stderr.String())
151 if len(msg) > 512 {
152 msg = msg[:512] + "…"
153 }
154 var exit *exec.ExitError
155 if errors.As(err, &exit) && msg != "" {
156 return "", fmt.Errorf("git %s: %w: %s", subcommand(args), err, msg)
157 }
158 return "", fmt.Errorf("git %s: %w", subcommand(args), err)
159 }
160 return stdout.String(), nil
161}