Go · 3884 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package protocol
4
5 import (
6 "bytes"
7 "context"
8 "errors"
9 "io"
10 "os"
11 "os/exec"
12 "sync"
13 "time"
14 )
15
16 // stderrCap bounds the in-memory stderr drainer's buffer so a chatty
17 // subprocess can't OOM us. Truncated stderr is fine — we only surface
18 // it on error and a few KB is enough to identify the failure.
19 const stderrCap = 16 * 1024
20
21 // Service identifies which git plumbing program we're driving.
22 type Service string
23
24 const (
25 UploadPack Service = "git-upload-pack"
26 ReceivePack Service = "git-receive-pack"
27 )
28
29 // Cmd builds an *exec.Cmd that drives the requested service against the
30 // bare repo at gitDir. AdvertiseRefs flips on the --advertise-refs flag
31 // used by the info/refs response. extraEnv is appended to os.Environ()
32 // — the caller uses it to thread SHITHUB_USER_ID/etc. through git's
33 // environment so post-receive hooks can read them.
34 //
35 // Subprocess lifecycle:
36 // - Killed on ctx cancel (Go ≥ 1.20: cmd.Cancel default kills with
37 // SIGKILL; we tighten to a 250ms WaitDelay so a stuck subprocess
38 // doesn't pin a worker forever).
39 // - Stderr is drained by the helper Drain() so the OS pipe buffer
40 // doesn't fill and deadlock the caller's stdout copy. Captured
41 // stderr is exposed via Stderr() on the returned Process.
42 func Cmd(ctx context.Context, svc Service, gitDir string, advertiseRefs bool, extraEnv []string) *exec.Cmd {
43 args := []string{"--stateless-rpc"}
44 if advertiseRefs {
45 args = append(args, "--advertise-refs")
46 }
47 args = append(args, gitDir)
48 //nolint:gosec // G204: svc is an enum from a fixed set; gitDir is constructed via storage.RepoFS path validation.
49 cmd := exec.CommandContext(ctx, string(svc), args...)
50 // Append, don't replace. The pre-receive / post-receive hooks
51 // re-invoke shithubd, which reads SHITHUB_DATABASE_URL etc. from
52 // the environment via config.Load. Without inheriting os.Environ
53 // the hook fails with "DB URL not set" on every push. Later
54 // entries win in Go's exec.Cmd, so the SHITHUB_USER_ID / etc.
55 // values in extraEnv override any conflicting parent-env keys.
56 cmd.Env = append(os.Environ(), extraEnv...)
57 cmd.WaitDelay = 250 * time.Millisecond
58 return cmd
59 }
60
61 // DrainStderr starts a goroutine that copies stderr into a bounded ring
62 // buffer; returns a function the caller invokes after Wait() to get the
63 // captured bytes. The cap is `stderrCap`; further writes are silently
64 // discarded.
65 //
66 // Always call this BEFORE Start() and call the returned closure AFTER
67 // Wait() to avoid the deadlock where a verbose stderr fills the OS
68 // pipe.
69 func DrainStderr(cmd *exec.Cmd) func() []byte {
70 pipe, err := cmd.StderrPipe()
71 if err != nil {
72 // Stderr already wired (or another wiring error). Fall back to
73 // a no-op drain so callers don't have to special-case.
74 return func() []byte { return nil }
75 }
76 buf := &cappedBuffer{cap: stderrCap}
77 done := make(chan struct{})
78 go func() {
79 defer close(done)
80 _, _ = io.Copy(buf, pipe)
81 }()
82 return func() []byte {
83 <-done
84 return buf.Bytes()
85 }
86 }
87
88 // cappedBuffer is a bytes.Buffer that drops writes after the cap.
89 type cappedBuffer struct {
90 mu sync.Mutex
91 buf bytes.Buffer
92 cap int
93 }
94
95 func (c *cappedBuffer) Write(p []byte) (int, error) {
96 c.mu.Lock()
97 defer c.mu.Unlock()
98 free := c.cap - c.buf.Len()
99 if free <= 0 {
100 return len(p), nil // accept-and-drop
101 }
102 if len(p) > free {
103 _, _ = c.buf.Write(p[:free])
104 return len(p), nil
105 }
106 return c.buf.Write(p)
107 }
108
109 func (c *cappedBuffer) Bytes() []byte {
110 c.mu.Lock()
111 defer c.mu.Unlock()
112 out := make([]byte, c.buf.Len())
113 copy(out, c.buf.Bytes())
114 return out
115 }
116
117 // ErrSubprocessFailed is returned by Run when the git subprocess exits
118 // non-zero. The captured stderr is wrapped via %s so the operator sees
119 // it in logs without it being escaped through the chain.
120 var ErrSubprocessFailed = errors.New("git subprocess exited non-zero")
121