Commit cabf60cf5b

cabf60cf5be6415ea1a1da2e8269db8a4efea155

parent: 5441d8b8bf

Verified · cmc ci/build: success ci/sonar: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-04 23:40 UTC

exec: resolve git and ssh once, at start-up

Every spawn passed a bare "git" or "ssh" and let the kernel search PATH
again. 74 of the 118 findings in the first SonarCloud scan were this one
rule, which is the practical reason to change it: a dashboard that is
mostly permanent noise is one nobody reads, which is the state a scan
exists to avoid.

There are three real gains behind that, in the order they matter.

A missing tool is now one legible failure at start-up — gitbayd calls
toolpath.Verify before it loads config — instead of an opaque one at the
first push, on whichever request happened to need git.

The command a long-lived daemon runs is fixed for its lifetime, decided
from the environment it started with rather than resolved afresh each
time. gitbayd and gitbay-runner both run under systemd with a root-owned
PATH and a read-only /usr, so the search was never attacker-influenced in
a shipped configuration; a spawn that cannot be redirected is still one
fewer thing to reason about.

And the lookup leaves the hot path. A repository page can spawn several
git processes and each was searching PATH from scratch.

An unresolvable tool falls back to the bare name, so anything running
before Verify fails exactly the way it used to.

Production code only. The findings are all there, and rewriting the tests
would have been churn for a scanner that does not read them.

Closes #153

Layout: unified · split

cmd/gitbay-runner/main.go +5 −4
@@ -24,6 +24,7 @@ import (
24 "time" 24 "time"
25 25
26 "gitbay.org/gitbay/internal/buildinfo" 26 "gitbay.org/gitbay/internal/buildinfo"
27 "gitbay.org/gitbay/internal/toolpath"
27) 28)
28 29
29type job struct { 30type job struct {
@@ -203,7 +204,7 @@ func (r *runner) run(j job) bool {
203 defer os.RemoveAll(dir) 204 defer os.RemoveAll(dir)
204 205
205 // One long-lived `runner log` session receives the whole stream. 206 // One long-lived `runner log` session receives the whole stream.
206 logCmd := exec.Command("ssh", append(r.sshOpts, r.remote, "runner", "log", fmt.Sprint(j.ID))...) 207 logCmd := exec.Command(toolpath.Look("ssh"), append(r.sshOpts, r.remote, "runner", "log", fmt.Sprint(j.ID))...)
207 pipe, err := logCmd.StdinPipe() 208 pipe, err := logCmd.StdinPipe()
208 if err != nil { 209 if err != nil {
209 log.Printf("build %d: log pipe: %v", j.ID, err) 210 log.Printf("build %d: log pipe: %v", j.ID, err)
@@ -294,7 +295,7 @@ func (r *runner) run(j job) bool {
294 } 295 }
295 steps = append(steps, []string{"-C", dir, "checkout", "-q", j.SHA}) 296 steps = append(steps, []string{"-C", dir, "checkout", "-q", j.SHA})
296 for _, args := range steps { 297 for _, args := range steps {
297 cmd := exec.Command("git", args...) 298 cmd := exec.Command(toolpath.Look("git"), args...)
298 cmd.Env = append(os.Environ(), "GIT_SSH_COMMAND="+gitSSH, "GIT_TERMINAL_PROMPT=0") 299 cmd.Env = append(os.Environ(), "GIT_SSH_COMMAND="+gitSSH, "GIT_TERMINAL_PROMPT=0")
299 cmd.Stdout, cmd.Stderr = sink, sink 300 cmd.Stdout, cmd.Stderr = sink, sink
300 if ok, why := runStep(cmd, deadline); !ok { 301 if ok, why := runStep(cmd, deadline); !ok {
@@ -305,7 +306,7 @@ func (r *runner) run(j job) bool {
305 306
306 for _, step := range j.Steps { 307 for _, step := range j.Steps {
307 fmt.Fprintf(sink, "$ %s\n", step) 308 fmt.Fprintf(sink, "$ %s\n", step)
308 cmd := exec.Command("sh", "-c", step) 309 cmd := exec.Command(toolpath.Look("sh"), "-c", step)
309 cmd.Dir = dir 310 cmd.Dir = dir
310 cmd.Env = append(os.Environ(), 311 cmd.Env = append(os.Environ(),
311 "GITBAY_REPO="+j.Repo, "GITBAY_SHA="+j.SHA, "GITBAY_REF="+j.Ref, "GITBAY_JOB="+j.Job, "CI=true") 312 "GITBAY_REPO="+j.Repo, "GITBAY_SHA="+j.SHA, "GITBAY_REF="+j.Ref, "GITBAY_JOB="+j.Job, "CI=true")
@@ -323,7 +324,7 @@ func (r *runner) run(j job) bool {
323 324
324// ssh runs one control command against the server and returns stdout. 325// ssh runs one control command against the server and returns stdout.
325func (r *runner) ssh(stdin io.Reader, args ...string) (string, error) { 326func (r *runner) ssh(stdin io.Reader, args ...string) (string, error) {
326 cmd := exec.Command("ssh", append(append(r.sshOpts, r.remote), args...)...) 327 cmd := exec.Command(toolpath.Look("ssh"), append(append(r.sshOpts, r.remote), args...)...)
327 if stdin != nil { 328 if stdin != nil {
328 cmd.Stdin = stdin 329 cmd.Stdin = stdin
329 } 330 }
cmd/gitbay/local.go +5 −4
@@ -11,6 +11,7 @@ import (
11 11
12 "gitbay.org/gitbay/internal/cliconfig" 12 "gitbay.org/gitbay/internal/cliconfig"
13 "gitbay.org/gitbay/internal/protocol" 13 "gitbay.org/gitbay/internal/protocol"
14 "gitbay.org/gitbay/internal/toolpath"
14) 15)
15 16
16// hasBodyFlag reports whether args already carry body/message input. 17// hasBodyFlag reports whether args already carry body/message input.
@@ -50,7 +51,7 @@ func maybeEditor(args []string, kind string, prefill func() string) ([]string, *
50 fmt.Fprintf(f, "\n# Write the %s body above. Lines starting with '#' are ignored.\n# Save an empty file to skip the body.\n", kind) 51 fmt.Fprintf(f, "\n# Write the %s body above. Lines starting with '#' are ignored.\n# Save an empty file to skip the body.\n", kind)
51 f.Close() 52 f.Close()
52 53
53 ed := exec.Command("sh", "-c", editor+" "+shellQuote(f.Name())) 54 ed := exec.Command(toolpath.Look("sh"), "-c", editor+" "+shellQuote(f.Name()))
54 ed.Stdin, ed.Stdout, ed.Stderr = os.Stdin, os.Stdout, os.Stderr 55 ed.Stdin, ed.Stdout, ed.Stderr = os.Stdin, os.Stdout, os.Stderr
55 if err := ed.Run(); err != nil { 56 if err := ed.Run(); err != nil {
56 return nil, nil, false, fmt.Errorf("editor: %w", err) 57 return nil, nil, false, fmt.Errorf("editor: %w", err)
@@ -74,7 +75,7 @@ func maybeEditor(args []string, kind string, prefill func() string) ([]string, *
74} 75}
75 76
76func runGitLocal(args ...string) int { 77func runGitLocal(args ...string) int {
77 cmd := exec.Command("git", args...) 78 cmd := exec.Command(toolpath.Look("git"), args...)
78 cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr 79 cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
79 if err := cmd.Run(); err != nil { 80 if err := cmd.Run(); err != nil {
80 if ee, ok := err.(*exec.ExitError); ok { 81 if ee, ok := err.(*exec.ExitError); ok {
@@ -190,7 +191,7 @@ func captureSSH(t target, serverArgv []string) (string, int) {
190 args := sshArgs(t.inst) 191 args := sshArgs(t.inst)
191 quoted := quoteAll(serverArgv) 192 quoted := quoteAll(serverArgv)
192 args = append(args, t.inst.SSHUser()+"@"+t.inst.Host, "--", strings.Join(quoted, " ")) 193 args = append(args, t.inst.SSHUser()+"@"+t.inst.Host, "--", strings.Join(quoted, " "))
193 cmd := exec.Command("ssh", args...) 194 cmd := exec.Command(toolpath.Look("ssh"), args...)
194 cmd.Stderr = os.Stderr 195 cmd.Stderr = os.Stderr
195 out, err := cmd.Output() 196 out, err := cmd.Output()
196 if err != nil { 197 if err != nil {
@@ -301,7 +302,7 @@ func cmdRemoteList() int {
301// currentBranch is the checked-out branch of the working directory's 302// currentBranch is the checked-out branch of the working directory's
302// clone, or "" outside a clone or on a detached HEAD. 303// clone, or "" outside a clone or on a detached HEAD.
303func currentBranch() string { 304func currentBranch() string {
304 out, err := exec.Command("git", "symbolic-ref", "--quiet", "--short", "HEAD").Output() 305 out, err := exec.Command(toolpath.Look("git"), "symbolic-ref", "--quiet", "--short", "HEAD").Output()
305 if err != nil { 306 if err != nil {
306 return "" 307 return ""
307 } 308 }
cmd/gitbay/migrate.go +4 −3
@@ -11,6 +11,7 @@ import (
11 "github.com/spf13/cobra" 11 "github.com/spf13/cobra"
12 12
13 "gitbay.org/gitbay/internal/protocol" 13 "gitbay.org/gitbay/internal/protocol"
14 "gitbay.org/gitbay/internal/toolpath"
14) 15)
15 16
16func migrateCmd() *cobra.Command { 17func migrateCmd() *cobra.Command {
@@ -43,7 +44,7 @@ func sourceSSH(host string, port int, extra ...string) *exec.Cmd {
43 } 44 }
44 args = append(args, "git@"+host, "--") 45 args = append(args, "git@"+host, "--")
45 args = append(args, extra...) 46 args = append(args, extra...)
46 return exec.Command("ssh", args...) 47 return exec.Command(toolpath.Look("ssh"), args...)
47} 48}
48 49
49func runMigrate(from string, fromPort int) int { 50func runMigrate(from string, fromPort int) int {
@@ -99,14 +100,14 @@ func runMigrate(from string, fromPort int) int {
99 if fromPort != 0 && fromPort != 22 { 100 if fromPort != 0 && fromPort != 22 {
100 srcURL = fmt.Sprintf("ssh://git@%s:%d/%s.git", from, fromPort, repoPath) 101 srcURL = fmt.Sprintf("ssh://git@%s:%d/%s.git", from, fromPort, repoPath)
101 } 102 }
102 clone := exec.Command("git", "clone", "--quiet", "--mirror", srcURL, tmp+"/r") 103 clone := exec.Command(toolpath.Look("git"), "clone", "--quiet", "--mirror", srcURL, tmp+"/r")
103 clone.Stderr = os.Stderr 104 clone.Stderr = os.Stderr
104 if err := clone.Run(); err != nil { 105 if err := clone.Run(); err != nil {
105 fmt.Fprintf(os.Stderr, "gitbay: cloning %s failed; fix and re-run migrate (it resumes)\n", repoPath) 106 fmt.Fprintf(os.Stderr, "gitbay: cloning %s failed; fix and re-run migrate (it resumes)\n", repoPath)
106 os.RemoveAll(tmp) 107 os.RemoveAll(tmp)
107 return protocol.ExitFailure 108 return protocol.ExitFailure
108 } 109 }
109 push := exec.Command("git", "-C", tmp+"/r", "push", "--quiet", t.inst.CloneURL(repoPath), 110 push := exec.Command(toolpath.Look("git"), "-C", tmp+"/r", "push", "--quiet", t.inst.CloneURL(repoPath),
110 "+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*") 111 "+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*")
111 if len(t.inst.SSHOptions) > 0 { 112 if len(t.inst.SSHOptions) > 0 {
112 push.Env = append(os.Environ(), "GIT_SSH_COMMAND=ssh "+strings.Join(quoteAll(t.inst.SSHOptions), " ")) 113 push.Env = append(os.Environ(), "GIT_SSH_COMMAND=ssh "+strings.Join(quoteAll(t.inst.SSHOptions), " "))
cmd/gitbay/ssh.go +4 −3
@@ -13,6 +13,7 @@ import (
13 13
14 "gitbay.org/gitbay/internal/cliconfig" 14 "gitbay.org/gitbay/internal/cliconfig"
15 "gitbay.org/gitbay/internal/protocol" 15 "gitbay.org/gitbay/internal/protocol"
16 "gitbay.org/gitbay/internal/toolpath"
16) 17)
17 18
18// context is the resolved target for a command: which instance to talk to 19// context is the resolved target for a command: which instance to talk to
@@ -59,7 +60,7 @@ func resolveTarget() (target, error) {
59} 60}
60 61
61func originURL() string { 62func originURL() string {
62 out, err := exec.Command("git", "remote", "get-url", "origin").Output() 63 out, err := exec.Command(toolpath.Look("git"), "remote", "get-url", "origin").Output()
63 if err != nil { 64 if err != nil {
64 return "" 65 return ""
65 } 66 }
@@ -115,7 +116,7 @@ func runSSH(t target, serverArgv []string, stdin io.Reader) int {
115 } 116 }
116 args = append(args, t.inst.SSHUser()+"@"+t.inst.Host, "--", strings.Join(quoted, " ")) 117 args = append(args, t.inst.SSHUser()+"@"+t.inst.Host, "--", strings.Join(quoted, " "))
117 118
118 cmd := exec.Command("ssh", args...) 119 cmd := exec.Command(toolpath.Look("ssh"), args...)
119 cmd.Stdin = stdin 120 cmd.Stdin = stdin
120 cmd.Stdout = os.Stdout 121 cmd.Stdout = os.Stdout
121 cmd.Stderr = os.Stderr 122 cmd.Stderr = os.Stderr
@@ -145,7 +146,7 @@ func sshCapture(t target, serverArgv []string) (string, int) {
145 quoted[i] = shellQuote(a) 146 quoted[i] = shellQuote(a)
146 } 147 }
147 args = append(args, t.inst.SSHUser()+"@"+t.inst.Host, "--", strings.Join(quoted, " ")) 148 args = append(args, t.inst.SSHUser()+"@"+t.inst.Host, "--", strings.Join(quoted, " "))
148 out, err := exec.Command("ssh", args...).Output() 149 out, err := exec.Command(toolpath.Look("ssh"), args...).Output()
149 if err != nil { 150 if err != nil {
150 code := protocol.ExitProtocol 151 code := protocol.ExitProtocol
151 if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() != 255 { 152 if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() != 255 {
cmd/gitbayd/hook.go +3 −2
@@ -15,6 +15,7 @@ import (
15 "gitbay.org/gitbay/internal/gitutil" 15 "gitbay.org/gitbay/internal/gitutil"
16 "gitbay.org/gitbay/internal/hookd" 16 "gitbay.org/gitbay/internal/hookd"
17 "gitbay.org/gitbay/internal/policy" 17 "gitbay.org/gitbay/internal/policy"
18 "gitbay.org/gitbay/internal/toolpath"
18) 19)
19 20
20// incomingSHAs lists the commits this push introduces, in order, without 21// incomingSHAs lists the commits this push introduces, in order, without
@@ -28,7 +29,7 @@ func incomingSHAs(updates []policy.RefUpdate) ([]string, error) {
28 continue 29 continue
29 } 30 }
30 // Everything reachable from the new tip that no existing ref has. 31 // Everything reachable from the new tip that no existing ref has.
31 raw, err := exec.Command("git", "rev-list", u.New, "--not", "--all").Output() 32 raw, err := exec.Command(toolpath.Look("git"), "rev-list", u.New, "--not", "--all").Output()
32 if err != nil { 33 if err != nil {
33 return nil, fmt.Errorf("rev-list %s: %w", u.New, err) 34 return nil, fmt.Errorf("rev-list %s: %w", u.New, err)
34 } 35 }
@@ -62,7 +63,7 @@ func streamIncomingCommits(updates []policy.RefUpdate, emit func(hookd.RawCommit
62 // part-way through a large push leaves git blocked writing into a 63 // part-way through a large push leaves git blocked writing into a
63 // pipe nobody is reading and Wait blocked on git. 64 // pipe nobody is reading and Wait blocked on git.
64 ctx, cancel := context.WithCancel(context.Background()) 65 ctx, cancel := context.WithCancel(context.Background())
65 cmd := exec.CommandContext(ctx, "git", "cat-file", "--batch") 66 cmd := exec.CommandContext(ctx, toolpath.Look("git"), "cat-file", "--batch")
66 stdin, err := cmd.StdinPipe() 67 stdin, err := cmd.StdinPipe()
67 if err != nil { 68 if err != nil {
68 cancel() 69 cancel()
cmd/gitbayd/main.go +7
@@ -32,6 +32,7 @@ import (
32 "gitbay.org/gitbay/internal/notify" 32 "gitbay.org/gitbay/internal/notify"
33 "gitbay.org/gitbay/internal/sshd" 33 "gitbay.org/gitbay/internal/sshd"
34 "gitbay.org/gitbay/internal/store" 34 "gitbay.org/gitbay/internal/store"
35 "gitbay.org/gitbay/internal/toolpath"
35 "gitbay.org/gitbay/internal/webhook" 36 "gitbay.org/gitbay/internal/webhook"
36) 37)
37 38
@@ -122,6 +123,12 @@ func serveCmd() *cobra.Command {
122 // First line of every run: the journal then says which commit is 123 // First line of every run: the journal then says which commit is
123 // serving, without rebuilding the binary to find out. 124 // serving, without rebuilding the binary to find out.
124 logBuild() 125 logBuild()
126 // The tools this daemon shells out to are resolved once, at
127 // package init. Say so now rather than failing on whichever
128 // request first needed git.
129 if err := toolpath.Verify(); err != nil {
130 return fmt.Errorf("required tools missing: %w", err)
131 }
125 cfg, err := config.Load(configPath) 132 cfg, err := config.Load(configPath)
126 if err != nil { 133 if err != nil {
127 return err 134 return err
cmd/gitbayd/maint.go +2 −1
@@ -13,6 +13,7 @@ import (
13 "gitbay.org/gitbay/internal/gitutil" 13 "gitbay.org/gitbay/internal/gitutil"
14 "gitbay.org/gitbay/internal/lfs" 14 "gitbay.org/gitbay/internal/lfs"
15 "gitbay.org/gitbay/internal/store" 15 "gitbay.org/gitbay/internal/store"
16 "gitbay.org/gitbay/internal/toolpath"
16) 17)
17 18
18func gcCmd() *cobra.Command { 19func gcCmd() *cobra.Command {
@@ -51,7 +52,7 @@ func gcCmd() *cobra.Command {
51 if aggressive { 52 if aggressive {
52 gcArgs = append(gcArgs, "--aggressive") 53 gcArgs = append(gcArgs, "--aggressive")
53 } 54 }
54 if out, err := exec.Command("git", gcArgs...).CombinedOutput(); err != nil { 55 if out, err := exec.Command(toolpath.Look("git"), gcArgs...).CombinedOutput(); err != nil {
55 fmt.Fprintf(os.Stderr, "%s: gc failed: %v\n%s", r.Path(), err, out) 56 fmt.Fprintf(os.Stderr, "%s: gc failed: %v\n%s", r.Path(), err, out)
56 continue 57 continue
57 } 58 }
internal/gitd/gitd.go +2 −1
@@ -16,6 +16,7 @@ import (
16 "gitbay.org/gitbay/internal/config" 16 "gitbay.org/gitbay/internal/config"
17 "gitbay.org/gitbay/internal/control" 17 "gitbay.org/gitbay/internal/control"
18 "gitbay.org/gitbay/internal/store" 18 "gitbay.org/gitbay/internal/store"
19 "gitbay.org/gitbay/internal/toolpath"
19) 20)
20 21
21type Server struct { 22type Server struct {
@@ -68,7 +69,7 @@ func (s *Server) handle(conn net.Conn) {
68 } 69 }
69 70
70 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name) 71 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
71 cmd := exec.Command("git", "upload-pack", dir) 72 cmd := exec.Command(toolpath.Look("git"), "upload-pack", dir)
72 cmd.Env = append(os.Environ(), protoEnv...) 73 cmd.Env = append(os.Environ(), protoEnv...)
73 cmd.Stdin = conn 74 cmd.Stdin = conn
74 cmd.Stdout = conn 75 cmd.Stdout = conn
internal/gitutil/blame.go +3 −1
@@ -7,6 +7,8 @@ import (
7 "os/exec" 7 "os/exec"
8 "strconv" 8 "strconv"
9 "strings" 9 "strings"
10
11 "gitbay.org/gitbay/internal/toolpath"
10) 12)
11 13
12// BlameHunk is a run of consecutive lines attributed to one commit. 14// BlameHunk is a run of consecutive lines attributed to one commit.
@@ -28,7 +30,7 @@ func Blame(dir, ref, path string, start, end int) ([]BlameHunk, error) {
28 if err != nil { 30 if err != nil {
29 return nil, err 31 return nil, err
30 } 32 }
31 cmd := exec.Command("git", "-C", dir, "blame", "--porcelain", 33 cmd := exec.Command(toolpath.Look("git"), "-C", dir, "blame", "--porcelain",
32 fmt.Sprintf("-L%d,%d", start, end), sha, "--", path) 34 fmt.Sprintf("-L%d,%d", start, end), sha, "--", path)
33 out, err := cmd.Output() 35 out, err := cmd.Output()
34 if err != nil { 36 if err != nil {
internal/gitutil/facts.go +5 −3
@@ -5,12 +5,14 @@ import (
5 "sort" 5 "sort"
6 "strconv" 6 "strconv"
7 "strings" 7 "strings"
8
9 "gitbay.org/gitbay/internal/toolpath"
8) 10)
9 11
10// CountCommits returns the number of commits reachable from ref, or 0 when 12// CountCommits returns the number of commits reachable from ref, or 0 when
11// the ref does not resolve (an empty repository). 13// the ref does not resolve (an empty repository).
12func CountCommits(dir, ref string) int { 14func CountCommits(dir, ref string) int {
13 out, err := exec.Command("git", "-C", dir, "rev-list", "--count", "--end-of-options", ref).Output() 15 out, err := exec.Command(toolpath.Look("git"), "-C", dir, "rev-list", "--count", "--end-of-options", ref).Output()
14 if err != nil { 16 if err != nil {
15 return 0 17 return 0
16 } 18 }
@@ -30,7 +32,7 @@ type Contributor struct {
30// tie back to an account, after the repository's own .mailmap has had its 32// tie back to an account, after the repository's own .mailmap has had its
31// say — a bare repo resolves that from HEAD:.mailmap with no config. 33// say — a bare repo resolves that from HEAD:.mailmap with no config.
32func Contributors(dir, ref string) []Contributor { 34func Contributors(dir, ref string) []Contributor {
33 out, err := exec.Command("git", "-C", dir, "log", 35 out, err := exec.Command(toolpath.Look("git"), "-C", dir, "log",
34 "--use-mailmap", "--format=%aN%x01%aE", "--end-of-options", ref).Output() 36 "--use-mailmap", "--format=%aN%x01%aE", "--end-of-options", ref).Output()
35 if err != nil { 37 if err != nil {
36 return nil 38 return nil
@@ -62,7 +64,7 @@ func Contributors(dir, ref string) []Contributor {
62// largest first, keyed by the extension map the caller supplies. Only 64// largest first, keyed by the extension map the caller supplies. Only
63// blobs count; git's own metadata does not. 65// blobs count; git's own metadata does not.
64func Languages(dir, ref string, lang func(path string) string) []Language { 66func Languages(dir, ref string, lang func(path string) string) []Language {
65 out, err := exec.Command("git", "-C", dir, "ls-tree", "-r", "-l", "--full-name", "--end-of-options", ref).Output() 67 out, err := exec.Command(toolpath.Look("git"), "-C", dir, "ls-tree", "-r", "-l", "--full-name", "--end-of-options", ref).Output()
66 if err != nil { 68 if err != nil {
67 return nil 69 return nil
68 } 70 }
internal/gitutil/gitutil.go +14 −12
@@ -11,6 +11,8 @@ import (
11 "os/exec" 11 "os/exec"
12 "path/filepath" 12 "path/filepath"
13 "strings" 13 "strings"
14
15 "gitbay.org/gitbay/internal/toolpath"
14) 16)
15 17
16// InitBare creates a bare repository with the shared hooks directory wired 18// InitBare creates a bare repository with the shared hooks directory wired
@@ -19,14 +21,14 @@ func InitBare(path, defaultBranch, hooksPath string) error {
19 if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { 21 if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
20 return err 22 return err
21 } 23 }
22 cmd := exec.Command("git", "init", "--bare", "--initial-branch="+defaultBranch, path) 24 cmd := exec.Command(toolpath.Look("git"), "init", "--bare", "--initial-branch="+defaultBranch, path)
23 if out, err := cmd.CombinedOutput(); err != nil { 25 if out, err := cmd.CombinedOutput(); err != nil {
24 return fmt.Errorf("git init: %v\n%s", err, out) 26 return fmt.Errorf("git init: %v\n%s", err, out)
25 } 27 }
26 // An empty hooksPath leaves the bare repo with no hooks — used for 28 // An empty hooksPath leaves the bare repo with no hooks — used for
27 // companion repos (wikis) that carry no ref policy. 29 // companion repos (wikis) that carry no ref policy.
28 if hooksPath != "" { 30 if hooksPath != "" {
29 cmd = exec.Command("git", "-C", path, "config", "core.hooksPath", hooksPath) 31 cmd = exec.Command(toolpath.Look("git"), "-C", path, "config", "core.hooksPath", hooksPath)
30 if out, err := cmd.CombinedOutput(); err != nil { 32 if out, err := cmd.CombinedOutput(); err != nil {
31 return fmt.Errorf("git config core.hooksPath: %v\n%s", err, out) 33 return fmt.Errorf("git config core.hooksPath: %v\n%s", err, out)
32 } 34 }
@@ -49,7 +51,7 @@ func Transport(service, repoPath string, stdin io.Reader, stdout, errW io.Writer
49 default: 51 default:
50 return fmt.Errorf("unknown service %q", service) 52 return fmt.Errorf("unknown service %q", service)
51 } 53 }
52 cmd := exec.Command("git", args...) 54 cmd := exec.Command(toolpath.Look("git"), args...)
53 cmd.Env = append(os.Environ(), extraEnv...) 55 cmd.Env = append(os.Environ(), extraEnv...)
54 cmd.Stdin = stdin 56 cmd.Stdin = stdin
55 cmd.Stdout = stdout 57 cmd.Stdout = stdout
@@ -61,7 +63,7 @@ func Transport(service, repoPath string, stdin io.Reader, stdout, errW io.Writer
61// dir. It must run with the caller's environment intact so that quarantined 63// dir. It must run with the caller's environment intact so that quarantined
62// objects during pre-receive remain visible. 64// objects during pre-receive remain visible.
63func IsAncestor(dir, old, new string) (bool, error) { 65func IsAncestor(dir, old, new string) (bool, error) {
64 cmd := exec.Command("git", "-C", dir, "merge-base", "--is-ancestor", old, new) 66 cmd := exec.Command(toolpath.Look("git"), "-C", dir, "merge-base", "--is-ancestor", old, new)
65 err := cmd.Run() 67 err := cmd.Run()
66 if err == nil { 68 if err == nil {
67 return true, nil 69 return true, nil
@@ -87,7 +89,7 @@ func ZeroSHA(s string) bool {
87 89
88// RevList returns up to limit commit SHAs reachable from ref, newest first. 90// RevList returns up to limit commit SHAs reachable from ref, newest first.
89func RevList(dir, ref string, limit int) ([]string, error) { 91func RevList(dir, ref string, limit int) ([]string, error) {
90 cmd := exec.Command("git", "-C", dir, "rev-list", fmt.Sprintf("--max-count=%d", limit), "--end-of-options", ref) 92 cmd := exec.Command(toolpath.Look("git"), "-C", dir, "rev-list", fmt.Sprintf("--max-count=%d", limit), "--end-of-options", ref)
91 out, err := cmd.Output() 93 out, err := cmd.Output()
92 if err != nil { 94 if err != nil {
93 return nil, fmt.Errorf("rev-list %s: %w", ref, err) 95 return nil, fmt.Errorf("rev-list %s: %w", ref, err)
@@ -105,7 +107,7 @@ func RevList(dir, ref string, limit int) ([]string, error) {
105// touch filePath, newest first. The "--" keeps the path from ever being 107// touch filePath, newest first. The "--" keeps the path from ever being
106// read as an option or ref. 108// read as an option or ref.
107func RevListPath(dir, ref, filePath string, limit int) ([]string, error) { 109func RevListPath(dir, ref, filePath string, limit int) ([]string, error) {
108 cmd := exec.Command("git", "-C", dir, "rev-list", 110 cmd := exec.Command(toolpath.Look("git"), "-C", dir, "rev-list",
109 fmt.Sprintf("--max-count=%d", limit), "--end-of-options", ref, "--", filePath) 111 fmt.Sprintf("--max-count=%d", limit), "--end-of-options", ref, "--", filePath)
110 out, err := cmd.Output() 112 out, err := cmd.Output()
111 if err != nil { 113 if err != nil {
@@ -123,7 +125,7 @@ func RevListPath(dir, ref, filePath string, limit int) ([]string, error) {
123// PeelToCommit resolves a ref or object to its commit — annotated tags 125// PeelToCommit resolves a ref or object to its commit — annotated tags
124// peel to the commit they point at. 126// peel to the commit they point at.
125func PeelToCommit(dir, ref string) (string, error) { 127func PeelToCommit(dir, ref string) (string, error) {
126 out, err := exec.Command("git", "-C", dir, "rev-parse", "--verify", "--end-of-options", ref+"^{commit}").Output() 128 out, err := exec.Command(toolpath.Look("git"), "-C", dir, "rev-parse", "--verify", "--end-of-options", ref+"^{commit}").Output()
127 if err != nil { 129 if err != nil {
128 return "", fmt.Errorf("rev-parse %s^{commit}: %w", ref, err) 130 return "", fmt.Errorf("rev-parse %s^{commit}: %w", ref, err)
129 } 131 }
@@ -132,7 +134,7 @@ func PeelToCommit(dir, ref string) (string, error) {
132 134
133// ReadCommit returns the raw commit object bytes. 135// ReadCommit returns the raw commit object bytes.
134func ReadCommit(dir, sha string) ([]byte, error) { 136func ReadCommit(dir, sha string) ([]byte, error) {
135 cmd := exec.Command("git", "-C", dir, "cat-file", "commit", "--end-of-options", sha) 137 cmd := exec.Command(toolpath.Look("git"), "-C", dir, "cat-file", "commit", "--end-of-options", sha)
136 out, err := cmd.Output() 138 out, err := cmd.Output()
137 if err != nil { 139 if err != nil {
138 return nil, fmt.Errorf("cat-file commit %s: %w", sha, err) 140 return nil, fmt.Errorf("cat-file commit %s: %w", sha, err)
@@ -145,7 +147,7 @@ func ReadCommit(dir, sha string) ([]byte, error) {
145// an interactive caller can watch. extraEnv carries credentials via 147// an interactive caller can watch. extraEnv carries credentials via
146// GIT_ASKPASS; the URL itself must never contain them. 148// GIT_ASKPASS; the URL itself must never contain them.
147func FetchMirror(ctx context.Context, dir, url string, errW io.Writer, extraEnv []string) error { 149func FetchMirror(ctx context.Context, dir, url string, errW io.Writer, extraEnv []string) error {
148 cmd := exec.CommandContext(ctx, "git", "-C", dir, "fetch", "--progress", "--no-write-fetch-head", url, 150 cmd := exec.CommandContext(ctx, toolpath.Look("git"), "-C", dir, "fetch", "--progress", "--no-write-fetch-head", url,
149 "+refs/heads/*:refs/heads/*", 151 "+refs/heads/*:refs/heads/*",
150 "+refs/tags/*:refs/tags/*", 152 "+refs/tags/*:refs/tags/*",
151 "+refs/notes/*:refs/notes/*") 153 "+refs/notes/*:refs/notes/*")
@@ -168,7 +170,7 @@ func FetchMirror(ctx context.Context, dir, url string, errW io.Writer, extraEnv
168// handshakes. extraEnv carries credentials via GIT_ASKPASS; the URL must 170// handshakes. extraEnv carries credentials via GIT_ASKPASS; the URL must
169// never contain them. 171// never contain them.
170func FetchPullHeads(ctx context.Context, dir, url string, errW io.Writer, extraEnv []string) error { 172func FetchPullHeads(ctx context.Context, dir, url string, errW io.Writer, extraEnv []string) error {
171 cmd := exec.CommandContext(ctx, "git", "-C", dir, "fetch", "--no-write-fetch-head", "--no-tags", 173 cmd := exec.CommandContext(ctx, toolpath.Look("git"), "-C", dir, "fetch", "--no-write-fetch-head", "--no-tags",
172 url, "+refs/pull/*/head:refs/gh-pull/*") 174 url, "+refs/pull/*/head:refs/gh-pull/*")
173 cmd.Env = append(os.Environ(), extraEnv...) 175 cmd.Env = append(os.Environ(), extraEnv...)
174 cmd.Stderr = errW 176 cmd.Stderr = errW
@@ -180,7 +182,7 @@ func FetchPullHeads(ctx context.Context, dir, url string, errW io.Writer, extraE
180 182
181// RemoteDefaultBranch asks the remote which branch HEAD points at. 183// RemoteDefaultBranch asks the remote which branch HEAD points at.
182func RemoteDefaultBranch(ctx context.Context, url string, extraEnv []string) (string, error) { 184func RemoteDefaultBranch(ctx context.Context, url string, extraEnv []string) (string, error) {
183 cmd := exec.CommandContext(ctx, "git", "ls-remote", "--symref", url, "HEAD") 185 cmd := exec.CommandContext(ctx, toolpath.Look("git"), "ls-remote", "--symref", url, "HEAD")
184 cmd.Env = append(os.Environ(), extraEnv...) 186 cmd.Env = append(os.Environ(), extraEnv...)
185 out, err := cmd.Output() 187 out, err := cmd.Output()
186 if err != nil { 188 if err != nil {
@@ -199,7 +201,7 @@ func RemoteDefaultBranch(ctx context.Context, url string, extraEnv []string) (st
199 201
200// SetHead points the bare repo's HEAD at a branch. 202// SetHead points the bare repo's HEAD at a branch.
201func SetHead(dir, branch string) error { 203func SetHead(dir, branch string) error {
202 cmd := exec.Command("git", "-C", dir, "symbolic-ref", "HEAD", "refs/heads/"+branch) 204 cmd := exec.Command(toolpath.Look("git"), "-C", dir, "symbolic-ref", "HEAD", "refs/heads/"+branch)
203 if out, err := cmd.CombinedOutput(); err != nil { 205 if out, err := cmd.CombinedOutput(); err != nil {
204 return fmt.Errorf("symbolic-ref: %v\n%s", err, out) 206 return fmt.Errorf("symbolic-ref: %v\n%s", err, out)
205 } 207 }
internal/gitutil/grep.go +3 −1
@@ -7,6 +7,8 @@ import (
7 "strconv" 7 "strconv"
8 "strings" 8 "strings"
9 "time" 9 "time"
10
11 "gitbay.org/gitbay/internal/toolpath"
10) 12)
11 13
12type GrepMatch struct { 14type GrepMatch struct {
@@ -23,7 +25,7 @@ func Grep(dir, ref, query string, max int) ([]GrepMatch, error) {
23 defer cancel() 25 defer cancel()
24 // -z: NUL after the path and the line number, so paths containing 26 // -z: NUL after the path and the line number, so paths containing
25 // ':' parse unambiguously (format: "ref:path\0line\0text\n"). 27 // ':' parse unambiguously (format: "ref:path\0line\0text\n").
26 cmd := exec.CommandContext(ctx, "git", "-C", dir, "grep", "-nIiF", "-z", "-e", query, "--end-of-options", ref) 28 cmd := exec.CommandContext(ctx, toolpath.Look("git"), "-C", dir, "grep", "-nIiF", "-z", "-e", query, "--end-of-options", ref)
27 out, err := cmd.Output() 29 out, err := cmd.Output()
28 if err != nil { 30 if err != nil {
29 if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 { 31 if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 {
internal/gitutil/lastcommit.go +5 −3
@@ -6,6 +6,8 @@ import (
6 "strconv" 6 "strconv"
7 "strings" 7 "strings"
8 "time" 8 "time"
9
10 "gitbay.org/gitbay/internal/toolpath"
9) 11)
10 12
11// EntryCommit is the newest commit touching one entry of a tree listing. 13// EntryCommit is the newest commit touching one entry of a tree listing.
@@ -49,7 +51,7 @@ func LastCommits(dir, ref, path string, names []string) map[string]EntryCommit {
49 if prefix != "" { 51 if prefix != "" {
50 args = append(args, "--", strings.TrimSuffix(prefix, "/")) 52 args = append(args, "--", strings.TrimSuffix(prefix, "/"))
51 } 53 }
52 cmd := exec.Command("git", args...) 54 cmd := exec.Command(toolpath.Look("git"), args...)
53 stdout, err := cmd.StdoutPipe() 55 stdout, err := cmd.StdoutPipe()
54 if err != nil { 56 if err != nil {
55 return nil 57 return nil
@@ -107,7 +109,7 @@ func parseCommitHeader(s string) EntryCommit {
107// TipCommit is the commit at ref, for the bar above a tree listing that 109// TipCommit is the commit at ref, for the bar above a tree listing that
108// answers "who touched this repository last". 110// answers "who touched this repository last".
109func TipCommit(dir, ref string) EntryCommit { 111func TipCommit(dir, ref string) EntryCommit {
110 out, err := exec.Command("git", "-C", dir, "log", "-1", 112 out, err := exec.Command(toolpath.Look("git"), "-C", dir, "log", "-1",
111 "--format=%H%x1f%ct%x1f%an%x1f%ae%x1f%s", "--end-of-options", ref).Output() 113 "--format=%H%x1f%ct%x1f%an%x1f%ae%x1f%s", "--end-of-options", ref).Output()
112 if err != nil { 114 if err != nil {
113 return EntryCommit{} 115 return EntryCommit{}
@@ -137,7 +139,7 @@ func entryName(changed, prefix string) (string, bool) {
137// page can report the facts the file listing no longer carries: its size, 139// page can report the facts the file listing no longer carries: its size,
138// and whether it is executable or a symlink. 140// and whether it is executable or a symlink.
139func StatPath(dir, ref, path string) (TreeEntry, bool) { 141func StatPath(dir, ref, path string) (TreeEntry, bool) {
140 out, err := exec.Command("git", "-C", dir, "ls-tree", "-l", "--end-of-options", ref, "--", path).Output() 142 out, err := exec.Command(toolpath.Look("git"), "-C", dir, "ls-tree", "-l", "--end-of-options", ref, "--", path).Output()
141 if err != nil { 143 if err != nil {
142 return TreeEntry{}, false 144 return TreeEntry{}, false
143 } 145 }
internal/gitutil/lfs.go +4 −2
@@ -7,6 +7,8 @@ import (
7 "regexp" 7 "regexp"
8 "strconv" 8 "strconv"
9 "strings" 9 "strings"
10
11 "gitbay.org/gitbay/internal/toolpath"
10) 12)
11 13
12// lfsPointerMax bounds a pointer file; real ones are around 130 bytes. 14// lfsPointerMax bounds a pointer file; real ones are around 130 bytes.
@@ -18,7 +20,7 @@ var lfsOIDLine = regexp.MustCompile(`(?m)^oid sha256:([0-9a-f]{64})$`)
18// anywhere in the repository: every object, not just the reachable ones, 20// anywhere in the repository: every object, not just the reachable ones,
19// since an unreachable blob is still an object gc has not removed. 21// since an unreachable blob is still an object gc has not removed.
20func LFSPointerOIDs(dir string) ([]string, error) { 22func LFSPointerOIDs(dir string) ([]string, error) {
21 list := exec.Command("git", "-C", dir, "cat-file", "--batch-all-objects", 23 list := exec.Command(toolpath.Look("git"), "-C", dir, "cat-file", "--batch-all-objects",
22 "--batch-check=%(objecttype) %(objectsize) %(objectname)") 24 "--batch-check=%(objecttype) %(objectsize) %(objectname)")
23 out, err := list.Output() 25 out, err := list.Output()
24 if err != nil { 26 if err != nil {
@@ -38,7 +40,7 @@ func LFSPointerOIDs(dir string) ([]string, error) {
38 if len(small) == 0 { 40 if len(small) == 0 {
39 return nil, nil 41 return nil, nil
40 } 42 }
41 cat := exec.Command("git", "-C", dir, "cat-file", "--batch") 43 cat := exec.Command(toolpath.Look("git"), "-C", dir, "cat-file", "--batch")
42 cat.Stdin = strings.NewReader(strings.Join(small, "\n") + "\n") 44 cat.Stdin = strings.NewReader(strings.Join(small, "\n") + "\n")
43 out, err = cat.Output() 45 out, err = cat.Output()
44 if err != nil { 46 if err != nil {
internal/gitutil/merge.go +22 −20
@@ -6,13 +6,15 @@ import (
6 "os" 6 "os"
7 "os/exec" 7 "os/exec"
8 "strings" 8 "strings"
9
10 "gitbay.org/gitbay/internal/toolpath"
9) 11)
10 12
11// FetchInto copies srcRef from srcDir into dstDir as dstRef, forcing the 13// FetchInto copies srcRef from srcDir into dstDir as dstRef, forcing the
12// update. Objects are copied, not shared — the destination owns everything 14// update. Objects are copied, not shared — the destination owns everything
13// afterward, which is what keeps MRs alive when their fork is deleted. 15// afterward, which is what keeps MRs alive when their fork is deleted.
14func FetchInto(dstDir, srcDir, srcRef, dstRef string) error { 16func FetchInto(dstDir, srcDir, srcRef, dstRef string) error {
15 cmd := exec.Command("git", "-C", dstDir, "fetch", "--quiet", "--no-write-fetch-head", 17 cmd := exec.Command(toolpath.Look("git"), "-C", dstDir, "fetch", "--quiet", "--no-write-fetch-head",
16 srcDir, "+"+srcRef+":"+dstRef) 18 srcDir, "+"+srcRef+":"+dstRef)
17 if out, err := cmd.CombinedOutput(); err != nil { 19 if out, err := cmd.CombinedOutput(); err != nil {
18 return fmt.Errorf("fetch %s from %s: %v\n%s", srcRef, srcDir, err, out) 20 return fmt.Errorf("fetch %s from %s: %v\n%s", srcRef, srcDir, err, out)
@@ -28,7 +30,7 @@ func UpdateRefCAS(dir, ref, newSHA, oldSHA string) error {
28 if oldSHA != "" { 30 if oldSHA != "" {
29 args = append(args, oldSHA) 31 args = append(args, oldSHA)
30 } 32 }
31 cmd := exec.Command("git", args...) 33 cmd := exec.Command(toolpath.Look("git"), args...)
32 if out, err := cmd.CombinedOutput(); err != nil { 34 if out, err := cmd.CombinedOutput(); err != nil {
33 return fmt.Errorf("update-ref %s: %v\n%s", ref, err, out) 35 return fmt.Errorf("update-ref %s: %v\n%s", ref, err, out)
34 } 36 }
@@ -36,7 +38,7 @@ func UpdateRefCAS(dir, ref, newSHA, oldSHA string) error {
36} 38}
37 39
38func DeleteRef(dir, ref string) error { 40func DeleteRef(dir, ref string) error {
39 cmd := exec.Command("git", "-C", dir, "update-ref", "-d", ref) 41 cmd := exec.Command(toolpath.Look("git"), "-C", dir, "update-ref", "-d", ref)
40 if out, err := cmd.CombinedOutput(); err != nil { 42 if out, err := cmd.CombinedOutput(); err != nil {
41 return fmt.Errorf("delete-ref %s: %v\n%s", ref, err, out) 43 return fmt.Errorf("delete-ref %s: %v\n%s", ref, err, out)
42 } 44 }
@@ -45,7 +47,7 @@ func DeleteRef(dir, ref string) error {
45 47
46// RevListRange returns commits in old..new, newest first. 48// RevListRange returns commits in old..new, newest first.
47func RevListRange(dir, old, new string) ([]string, error) { 49func RevListRange(dir, old, new string) ([]string, error) {
48 cmd := exec.Command("git", "-C", dir, "rev-list", "--end-of-options", new, "^"+old) 50 cmd := exec.Command(toolpath.Look("git"), "-C", dir, "rev-list", "--end-of-options", new, "^"+old)
49 out, err := cmd.Output() 51 out, err := cmd.Output()
50 if err != nil { 52 if err != nil {
51 return nil, fmt.Errorf("rev-list %s..%s: %w", old, new, err) 53 return nil, fmt.Errorf("rev-list %s..%s: %w", old, new, err)
@@ -62,7 +64,7 @@ func RevListRange(dir, old, new string) ([]string, error) {
62// MergeTree performs a real merge of ours and theirs, returning the merged 64// MergeTree performs a real merge of ours and theirs, returning the merged
63// tree id. conflict=true means the merge cannot be done automatically. 65// tree id. conflict=true means the merge cannot be done automatically.
64func MergeTree(dir, ours, theirs string) (tree string, conflict bool, err error) { 66func MergeTree(dir, ours, theirs string) (tree string, conflict bool, err error) {
65 cmd := exec.Command("git", "-C", dir, "merge-tree", "--write-tree", "--end-of-options", ours, theirs) 67 cmd := exec.Command(toolpath.Look("git"), "-C", dir, "merge-tree", "--write-tree", "--end-of-options", ours, theirs)
66 out, runErr := cmd.Output() 68 out, runErr := cmd.Output()
67 tree = strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0]) 69 tree = strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0])
68 if runErr != nil { 70 if runErr != nil {
@@ -81,7 +83,7 @@ func CommitTree(dir, tree string, parents []string, name, email, message string)
81 for _, p := range parents { 83 for _, p := range parents {
82 args = append(args, "-p", p) 84 args = append(args, "-p", p)
83 } 85 }
84 cmd := exec.Command("git", args...) 86 cmd := exec.Command(toolpath.Look("git"), args...)
85 cmd.Env = append(os.Environ(), 87 cmd.Env = append(os.Environ(),
86 "GIT_AUTHOR_NAME="+name, "GIT_AUTHOR_EMAIL="+email, 88 "GIT_AUTHOR_NAME="+name, "GIT_AUTHOR_EMAIL="+email,
87 "GIT_COMMITTER_NAME="+name, "GIT_COMMITTER_EMAIL="+email, 89 "GIT_COMMITTER_NAME="+name, "GIT_COMMITTER_EMAIL="+email,
@@ -98,7 +100,7 @@ func CommitTree(dir, tree string, parents []string, name, email, message string)
98// truncated says whether it was cut, so the caller can say so instead of 100// truncated says whether it was cut, so the caller can say so instead of
99// rendering a hunk that ends mid-line (#117). 101// rendering a hunk that ends mid-line (#117).
100func Diff(dir, old, new string, limit int64) (patch string, truncated bool, err error) { 102func Diff(dir, old, new string, limit int64) (patch string, truncated bool, err error) {
101 cmd := exec.Command("git", "-C", dir, "diff", "--stat", "--patch", "--end-of-options", old, new) 103 cmd := exec.Command(toolpath.Look("git"), "-C", dir, "diff", "--stat", "--patch", "--end-of-options", old, new)
102 out, err := cmd.Output() 104 out, err := cmd.Output()
103 if err != nil { 105 if err != nil {
104 return "", false, fmt.Errorf("diff: %w", err) 106 return "", false, fmt.Errorf("diff: %w", err)
@@ -122,7 +124,7 @@ func cutAtLine(out []byte, limit int64) ([]byte, bool) {
122 124
123// MergeBase returns the best common ancestor, or an error if none exists. 125// MergeBase returns the best common ancestor, or an error if none exists.
124func MergeBase(dir, a, b string) (string, error) { 126func MergeBase(dir, a, b string) (string, error) {
125 cmd := exec.Command("git", "-C", dir, "merge-base", "--end-of-options", a, b) 127 cmd := exec.Command(toolpath.Look("git"), "-C", dir, "merge-base", "--end-of-options", a, b)
126 out, err := cmd.Output() 128 out, err := cmd.Output()
127 if err != nil { 129 if err != nil {
128 return "", fmt.Errorf("no common history between %s and %s", a, b) 130 return "", fmt.Errorf("no common history between %s and %s", a, b)
@@ -141,7 +143,7 @@ func CommitFileChange(dir, branch, path string, content []byte, name, email, mes
141 } 143 }
142 144
143 // Hash the new blob. 145 // Hash the new blob.
144 hb := exec.Command("git", "-C", dir, "hash-object", "-w", "--stdin") 146 hb := exec.Command(toolpath.Look("git"), "-C", dir, "hash-object", "-w", "--stdin")
145 hb.Stdin = strings.NewReader(string(content)) 147 hb.Stdin = strings.NewReader(string(content))
146 out, err := hb.Output() 148 out, err := hb.Output()
147 if err != nil { 149 if err != nil {
@@ -159,17 +161,17 @@ func CommitFileChange(dir, branch, path string, content []byte, name, email, mes
159 defer os.Remove(idx.Name()) 161 defer os.Remove(idx.Name())
160 env := append(os.Environ(), "GIT_INDEX_FILE="+idx.Name()) 162 env := append(os.Environ(), "GIT_INDEX_FILE="+idx.Name())
161 163
162 rt := exec.Command("git", "-C", dir, "read-tree", parent+"^{tree}") 164 rt := exec.Command(toolpath.Look("git"), "-C", dir, "read-tree", parent+"^{tree}")
163 rt.Env = env 165 rt.Env = env
164 if out, err := rt.CombinedOutput(); err != nil { 166 if out, err := rt.CombinedOutput(); err != nil {
165 return "", fmt.Errorf("read-tree: %v\n%s", err, out) 167 return "", fmt.Errorf("read-tree: %v\n%s", err, out)
166 } 168 }
167 ui := exec.Command("git", "-C", dir, "update-index", "--add", "--cacheinfo", "100644,"+blob+","+path) 169 ui := exec.Command(toolpath.Look("git"), "-C", dir, "update-index", "--add", "--cacheinfo", "100644,"+blob+","+path)
168 ui.Env = env 170 ui.Env = env
169 if out, err := ui.CombinedOutput(); err != nil { 171 if out, err := ui.CombinedOutput(); err != nil {
170 return "", fmt.Errorf("update-index: %v\n%s", err, out) 172 return "", fmt.Errorf("update-index: %v\n%s", err, out)
171 } 173 }
172 wt := exec.Command("git", "-C", dir, "write-tree") 174 wt := exec.Command(toolpath.Look("git"), "-C", dir, "write-tree")
173 wt.Env = env 175 wt.Env = env
174 out, err = wt.Output() 176 out, err = wt.Output()
175 if err != nil { 177 if err != nil {
@@ -189,7 +191,7 @@ func CommitFileChange(dir, branch, path string, content []byte, name, email, mes
189 191
190// CommitParents returns the parent SHAs of a commit. 192// CommitParents returns the parent SHAs of a commit.
191func CommitParents(dir, sha string) ([]string, error) { 193func CommitParents(dir, sha string) ([]string, error) {
192 out, err := exec.Command("git", "-C", dir, "rev-list", "--parents", "-n1", "--end-of-options", sha).Output() 194 out, err := exec.Command(toolpath.Look("git"), "-C", dir, "rev-list", "--parents", "-n1", "--end-of-options", sha).Output()
193 if err != nil { 195 if err != nil {
194 return nil, fmt.Errorf("rev-list --parents %s: %w", sha, err) 196 return nil, fmt.Errorf("rev-list --parents %s: %w", sha, err)
195 } 197 }
@@ -202,7 +204,7 @@ func CommitParents(dir, sha string) ([]string, error) {
202 204
203// AuthorIdent returns a commit's author name, email, and ISO date. 205// AuthorIdent returns a commit's author name, email, and ISO date.
204func AuthorIdent(dir, sha string) (name, email, date string, err error) { 206func AuthorIdent(dir, sha string) (name, email, date string, err error) {
205 out, err := exec.Command("git", "-C", dir, "log", "-1", "--format=%an%x1f%ae%x1f%aI", "--end-of-options", sha).Output() 207 out, err := exec.Command(toolpath.Look("git"), "-C", dir, "log", "-1", "--format=%an%x1f%ae%x1f%aI", "--end-of-options", sha).Output()
206 if err != nil { 208 if err != nil {
207 return "", "", "", fmt.Errorf("log %s: %w", sha, err) 209 return "", "", "", fmt.Errorf("log %s: %w", sha, err)
208 } 210 }
@@ -215,7 +217,7 @@ func AuthorIdent(dir, sha string) (name, email, date string, err error) {
215 217
216// CommitMessage returns a commit's full message. 218// CommitMessage returns a commit's full message.
217func CommitMessage(dir, sha string) (string, error) { 219func CommitMessage(dir, sha string) (string, error) {
218 out, err := exec.Command("git", "-C", dir, "log", "-1", "--format=%B", "--end-of-options", sha).Output() 220 out, err := exec.Command(toolpath.Look("git"), "-C", dir, "log", "-1", "--format=%B", "--end-of-options", sha).Output()
219 if err != nil { 221 if err != nil {
220 return "", fmt.Errorf("log %s: %w", sha, err) 222 return "", fmt.Errorf("log %s: %w", sha, err)
221 } 223 }
@@ -225,7 +227,7 @@ func CommitMessage(dir, sha string) (string, error) {
225// MergeTreeOnto replays commit's changes (relative to base) onto onto, 227// MergeTreeOnto replays commit's changes (relative to base) onto onto,
226// returning the resulting tree. conflict=true when it cannot apply cleanly. 228// returning the resulting tree. conflict=true when it cannot apply cleanly.
227func MergeTreeOnto(dir, base, onto, commit string) (tree string, conflict bool, err error) { 229func MergeTreeOnto(dir, base, onto, commit string) (tree string, conflict bool, err error) {
228 cmd := exec.Command("git", "-C", dir, "merge-tree", "--write-tree", "--merge-base="+base, "--end-of-options", onto, commit) 230 cmd := exec.Command(toolpath.Look("git"), "-C", dir, "merge-tree", "--write-tree", "--merge-base="+base, "--end-of-options", onto, commit)
229 out, runErr := cmd.Output() 231 out, runErr := cmd.Output()
230 tree = strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0]) 232 tree = strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0])
231 if runErr != nil { 233 if runErr != nil {
@@ -245,7 +247,7 @@ func CommitTreeIdent(dir, tree string, parents []string,
245 for _, p := range parents { 247 for _, p := range parents {
246 args = append(args, "-p", p) 248 args = append(args, "-p", p)
247 } 249 }
248 cmd := exec.Command("git", args...) 250 cmd := exec.Command(toolpath.Look("git"), args...)
249 env := append(os.Environ(), 251 env := append(os.Environ(),
250 "GIT_AUTHOR_NAME="+authorName, "GIT_AUTHOR_EMAIL="+authorEmail, 252 "GIT_AUTHOR_NAME="+authorName, "GIT_AUTHOR_EMAIL="+authorEmail,
251 "GIT_COMMITTER_NAME="+committerName, "GIT_COMMITTER_EMAIL="+committerEmail, 253 "GIT_COMMITTER_NAME="+committerName, "GIT_COMMITTER_EMAIL="+committerEmail,
@@ -263,7 +265,7 @@ func CommitTreeIdent(dir, tree string, parents []string,
263 265
264// ResolveTree returns the tree id of a commit. 266// ResolveTree returns the tree id of a commit.
265func ResolveTree(dir, sha string) (string, error) { 267func ResolveTree(dir, sha string) (string, error) {
266 out, err := exec.Command("git", "-C", dir, "rev-parse", "--verify", "--end-of-options", sha+"^{tree}").Output() 268 out, err := exec.Command(toolpath.Look("git"), "-C", dir, "rev-parse", "--verify", "--end-of-options", sha+"^{tree}").Output()
267 if err != nil { 269 if err != nil {
268 return "", fmt.Errorf("rev-parse %s^{tree}: %w", sha, err) 270 return "", fmt.Errorf("rev-parse %s^{tree}: %w", sha, err)
269 } 271 }
@@ -272,7 +274,7 @@ func ResolveTree(dir, sha string) (string, error) {
272 274
273// DiffFiles lists the paths changed between old and new. 275// DiffFiles lists the paths changed between old and new.
274func DiffFiles(dir, old, new string) ([]string, error) { 276func DiffFiles(dir, old, new string) ([]string, error) {
275 out, err := exec.Command("git", "-C", dir, "diff", "--name-only", "--end-of-options", old, new).Output() 277 out, err := exec.Command(toolpath.Look("git"), "-C", dir, "diff", "--name-only", "--end-of-options", old, new).Output()
276 if err != nil { 278 if err != nil {
277 return nil, fmt.Errorf("diff --name-only: %w", err) 279 return nil, fmt.Errorf("diff --name-only: %w", err)
278 } 280 }
@@ -304,7 +306,7 @@ func DiffFiles(dir, old, new string) ([]string, error) {
304// tells a reviewer nothing. It pairs at 80, and two genuinely unrelated 306// tells a reviewer nothing. It pairs at 80, and two genuinely unrelated
305// commits are still left unpaired there; both measured. 307// commits are still left unpaired there; both measured.
306func RangeDiff(dir, oldBase, oldHead, newBase, newHead string, limit int64) (patch string, truncated bool, err error) { 308func RangeDiff(dir, oldBase, oldHead, newBase, newHead string, limit int64) (patch string, truncated bool, err error) {
307 cmd := exec.Command("git", "-C", dir, "range-diff", "--creation-factor=80", "--end-of-options", 309 cmd := exec.Command(toolpath.Look("git"), "-C", dir, "range-diff", "--creation-factor=80", "--end-of-options",
308 oldBase+".."+oldHead, newBase+".."+newHead) 310 oldBase+".."+oldHead, newBase+".."+newHead)
309 out, err := cmd.Output() 311 out, err := cmd.Output()
310 if err != nil { 312 if err != nil {
internal/gitutil/messages.go +7 −5
@@ -4,6 +4,8 @@ import (
4 "fmt" 4 "fmt"
5 "os/exec" 5 "os/exec"
6 "strings" 6 "strings"
7
8 "gitbay.org/gitbay/internal/toolpath"
7) 9)
8 10
9const zeroSHA = "0000000000000000000000000000000000000000" 11const zeroSHA = "0000000000000000000000000000000000000000"
@@ -22,7 +24,7 @@ func RevListAuthors(dir, old, new string, max int) ([]CommitAuthor, error) {
22 if old != "" && old != zeroSHA { 24 if old != "" && old != zeroSHA {
23 args = append(args, "^"+old) 25 args = append(args, "^"+old)
24 } 26 }
25 out, err := exec.Command("git", args...).Output() 27 out, err := exec.Command(toolpath.Look("git"), args...).Output()
26 if err != nil { 28 if err != nil {
27 return nil, fmt.Errorf("rev-list authors: %w", err) 29 return nil, fmt.Errorf("rev-list authors: %w", err)
28 } 30 }
@@ -39,7 +41,7 @@ func RevListAuthors(dir, old, new string, max int) ([]CommitAuthor, error) {
39 41
40// Parents returns a commit's parent shas. 42// Parents returns a commit's parent shas.
41func Parents(dir, sha string) []string { 43func Parents(dir, sha string) []string {
42 out, err := exec.Command("git", "-C", dir, "log", "-1", "--format=%P", sha).Output() 44 out, err := exec.Command(toolpath.Look("git"), "-C", dir, "log", "-1", "--format=%P", sha).Output()
43 if err != nil { 45 if err != nil {
44 return nil 46 return nil
45 } 47 }
@@ -49,7 +51,7 @@ func Parents(dir, sha string) []string {
49// LastCommitDate returns the committer date (YYYY-MM-DD) of the ref tip, 51// LastCommitDate returns the committer date (YYYY-MM-DD) of the ref tip,
50// or "" for empty repos. 52// or "" for empty repos.
51func LastCommitDate(dir, ref string) string { 53func LastCommitDate(dir, ref string) string {
52 out, err := exec.Command("git", "-C", dir, "log", "-1", "--format=%cs", ref).Output() 54 out, err := exec.Command(toolpath.Look("git"), "-C", dir, "log", "-1", "--format=%cs", ref).Output()
53 if err != nil { 55 if err != nil {
54 return "" 56 return ""
55 } 57 }
@@ -58,7 +60,7 @@ func LastCommitDate(dir, ref string) string {
58 60
59// HasCommit reports whether sha names a commit object present in dir. 61// HasCommit reports whether sha names a commit object present in dir.
60func HasCommit(dir, sha string) bool { 62func HasCommit(dir, sha string) bool {
61 return exec.Command("git", "-C", dir, "cat-file", "-e", sha+"^{commit}").Run() == nil 63 return exec.Command(toolpath.Look("git"), "-C", dir, "cat-file", "-e", sha+"^{commit}").Run() == nil
62} 64}
63 65
64type CommitMsg struct { 66type CommitMsg struct {
@@ -76,7 +78,7 @@ func RevListMessages(dir, old, new string, max int) ([]CommitMsg, error) {
76 if old != "" && old != zeroSHA { 78 if old != "" && old != zeroSHA {
77 args = append(args, "^"+old) 79 args = append(args, "^"+old)
78 } 80 }
79 out, err := exec.Command("git", args...).Output() 81 out, err := exec.Command(toolpath.Look("git"), args...).Output()
80 if err != nil { 82 if err != nil {
81 return nil, fmt.Errorf("rev-list messages: %w", err) 83 return nil, fmt.Errorf("rev-list messages: %w", err)
82 } 84 }
internal/gitutil/read.go +8 −6
@@ -10,6 +10,8 @@ import (
10 "strconv" 10 "strconv"
11 "strings" 11 "strings"
12 "time" 12 "time"
13
14 "gitbay.org/gitbay/internal/toolpath"
13) 15)
14 16
15type TreeEntry struct { 17type TreeEntry struct {
@@ -26,7 +28,7 @@ func ListTree(dir, ref, path string) ([]TreeEntry, error) {
26 if path != "" { 28 if path != "" {
27 spec = ref + ":" + path 29 spec = ref + ":" + path
28 } 30 }
29 cmd := exec.Command("git", "-C", dir, "ls-tree", "-l", "--end-of-options", spec) 31 cmd := exec.Command(toolpath.Look("git"), "-C", dir, "ls-tree", "-l", "--end-of-options", spec)
30 out, err := cmd.Output() 32 out, err := cmd.Output()
31 if err != nil { 33 if err != nil {
32 return nil, fmt.Errorf("ls-tree %s: %w", spec, err) 34 return nil, fmt.Errorf("ls-tree %s: %w", spec, err)
@@ -56,7 +58,7 @@ func ListTree(dir, ref, path string) ([]TreeEntry, error) {
56 58
57// ReadBlob returns the contents of ref:path, capped at limit bytes. 59// ReadBlob returns the contents of ref:path, capped at limit bytes.
58func ReadBlob(dir, ref, path string, limit int64) ([]byte, error) { 60func ReadBlob(dir, ref, path string, limit int64) ([]byte, error) {
59 cmd := exec.Command("git", "-C", dir, "cat-file", "blob", "--end-of-options", ref+":"+path) 61 cmd := exec.Command(toolpath.Look("git"), "-C", dir, "cat-file", "blob", "--end-of-options", ref+":"+path)
60 stdout, err := cmd.StdoutPipe() 62 stdout, err := cmd.StdoutPipe()
61 if err != nil { 63 if err != nil {
62 return nil, err 64 return nil, err
@@ -74,7 +76,7 @@ func ReadBlob(dir, ref, path string, limit int64) ([]byte, error) {
74 76
75// ResolveRef resolves a ref or sha to a full commit sha; errors if absent. 77// ResolveRef resolves a ref or sha to a full commit sha; errors if absent.
76func ResolveRef(dir, ref string) (string, error) { 78func ResolveRef(dir, ref string) (string, error) {
77 cmd := exec.Command("git", "-C", dir, "rev-parse", "--verify", "--quiet", "--end-of-options", ref+"^{commit}") 79 cmd := exec.Command(toolpath.Look("git"), "-C", dir, "rev-parse", "--verify", "--quiet", "--end-of-options", ref+"^{commit}")
78 out, err := cmd.Output() 80 out, err := cmd.Output()
79 if err != nil { 81 if err != nil {
80 return "", fmt.Errorf("unknown ref %q", ref) 82 return "", fmt.Errorf("unknown ref %q", ref)
@@ -89,7 +91,7 @@ type Ref struct {
89 91
90// Refs lists branches or tags; kind is "heads" or "tags". 92// Refs lists branches or tags; kind is "heads" or "tags".
91func Refs(dir, kind string) ([]Ref, error) { 93func Refs(dir, kind string) ([]Ref, error) {
92 cmd := exec.Command("git", "-C", dir, "for-each-ref", 94 cmd := exec.Command(toolpath.Look("git"), "-C", dir, "for-each-ref",
93 "--format=%(refname:short) %(objectname)", "refs/"+kind) 95 "--format=%(refname:short) %(objectname)", "refs/"+kind)
94 out, err := cmd.Output() 96 out, err := cmd.Output()
95 if err != nil { 97 if err != nil {
@@ -118,7 +120,7 @@ var ErrArchiveTooLarge = errors.New("archive exceeds the size limit")
118func Archive(dir, ref, prefix string, w io.Writer) error { 120func Archive(dir, ref, prefix string, w io.Writer) error {
119 ctx, cancel := context.WithTimeout(context.Background(), archiveTimeout) 121 ctx, cancel := context.WithTimeout(context.Background(), archiveTimeout)
120 defer cancel() 122 defer cancel()
121 cmd := exec.CommandContext(ctx, "git", "-C", dir, "archive", "--format=tar.gz", "--prefix="+prefix+"/", "--end-of-options", ref) 123 cmd := exec.CommandContext(ctx, toolpath.Look("git"), "-C", dir, "archive", "--format=tar.gz", "--prefix="+prefix+"/", "--end-of-options", ref)
122 lw := &cappedWriter{w: w, left: MaxArchiveBytes, stop: cancel} 124 lw := &cappedWriter{w: w, left: MaxArchiveBytes, stop: cancel}
123 cmd.Stdout = lw 125 cmd.Stdout = lw
124 err := cmd.Run() 126 err := cmd.Run()
@@ -152,7 +154,7 @@ func (c *cappedWriter) Write(p []byte) (int, error) {
152 154
153// ShowPatch returns the stat+patch text for one commit. 155// ShowPatch returns the stat+patch text for one commit.
154func ShowPatch(dir, sha string, limit int64) (patch string, truncated bool, err error) { 156func ShowPatch(dir, sha string, limit int64) (patch string, truncated bool, err error) {
155 cmd := exec.Command("git", "-C", dir, "show", "--stat", "--patch", "--format=", "--end-of-options", sha) 157 cmd := exec.Command(toolpath.Look("git"), "-C", dir, "show", "--stat", "--patch", "--format=", "--end-of-options", sha)
156 stdout, err := cmd.StdoutPipe() 158 stdout, err := cmd.StdoutPipe()
157 if err != nil { 159 if err != nil {
158 return "", false, err 160 return "", false, err
internal/mirror/mirror.go +2 −1
@@ -17,6 +17,7 @@ import (
17 "gitbay.org/gitbay/internal/config" 17 "gitbay.org/gitbay/internal/config"
18 "gitbay.org/gitbay/internal/control" 18 "gitbay.org/gitbay/internal/control"
19 "gitbay.org/gitbay/internal/store" 19 "gitbay.org/gitbay/internal/store"
20 "gitbay.org/gitbay/internal/toolpath"
20) 21)
21 22
22const askpassScript = `#!/bin/sh 23const askpassScript = `#!/bin/sh
@@ -106,7 +107,7 @@ func (w *Worker) sync(m store.Mirror) error {
106 args = []string{"-C", dir, "fetch", "--prune", m.URL, 107 args = []string{"-C", dir, "fetch", "--prune", m.URL,
107 "+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*"} 108 "+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*"}
108 } 109 }
109 cmd := exec.CommandContext(ctx, "git", args...) 110 cmd := exec.CommandContext(ctx, toolpath.Look("git"), args...)
110 cmd.Env = env 111 cmd.Env = env
111 if out, err := cmd.CombinedOutput(); err != nil { 112 if out, err := cmd.CombinedOutput(); err != nil {
112 return fmt.Errorf("git %s: %v: %.300s", m.Direction, err, out) 113 return fmt.Errorf("git %s: %v: %.300s", m.Direction, err, out)
internal/toolpath/toolpath.go added +64
@@ -0,0 +1,64 @@
1// Package toolpath resolves the external programs gitbay runs — git, ssh,
2// sh — to absolute paths once, when the process starts, instead of
3// letting the kernel search PATH on every spawn.
4//
5// Three things it buys, in the order they matter.
6//
7// A missing tool becomes one legible failure at start-up rather than an
8// opaque one at the first push, on whichever request happened to need it.
9//
10// The command a long-lived daemon runs is then fixed for its lifetime,
11// decided from the environment it was started with rather than resolved
12// afresh each time. gitbayd and gitbay-runner both run under systemd with
13// a root-owned PATH and a read-only /usr, so a search was never
14// attacker-influenced in a shipped configuration — but a spawn that
15// cannot be redirected is one fewer thing to reason about, and it is what
16// the scanner asks for (go:S4036).
17//
18// And the lookup leaves the hot path: a repository page can spawn several
19// git processes, and each was searching PATH from scratch.
20package toolpath
21
22import (
23 "fmt"
24 "os/exec"
25 "strings"
26 "sync"
27)
28
29var (
30 mu sync.Mutex
31 cache = map[string]string{}
32 missing []string
33)
34
35// Look returns the absolute path to name, or name itself when it is not
36// on PATH. Returning the bare name keeps the failure where it already
37// was — exec reporting it — for anything that runs before Verify, and for
38// a tool a particular binary never actually uses.
39func Look(name string) string {
40 mu.Lock()
41 defer mu.Unlock()
42 if p, ok := cache[name]; ok {
43 return p
44 }
45 p, err := exec.LookPath(name)
46 if err != nil {
47 p = name
48 missing = append(missing, name)
49 }
50 cache[name] = p
51 return p
52}
53
54// Verify reports the tools that were asked for and not found, so a daemon
55// can refuse to start rather than fail on its first request. Call it after
56// the packages that need tools are initialised.
57func Verify() error {
58 mu.Lock()
59 defer mu.Unlock()
60 if len(missing) == 0 {
61 return nil
62 }
63 return fmt.Errorf("not found on PATH: %s", strings.Join(missing, ", "))
64}
internal/toolpath/toolpath_test.go added +47
@@ -0,0 +1,47 @@
1package toolpath
2
3import (
4 "os/exec"
5 "path/filepath"
6 "strings"
7 "testing"
8)
9
10func TestLookResolvesToAnAbsolutePath(t *testing.T) {
11 got := Look("go")
12 if !filepath.IsAbs(got) {
13 t.Fatalf("Look(go) = %q, want an absolute path", got)
14 }
15 want, err := exec.LookPath("go")
16 if err != nil {
17 t.Fatal(err)
18 }
19 if got != want {
20 t.Errorf("Look(go) = %q, want %q", got, want)
21 }
22}
23
24// A second call must not search again: resolving once is the point, and a
25// daemon that re-resolved would follow a PATH that changed under it.
26func TestLookIsResolvedOnce(t *testing.T) {
27 first := Look("go")
28 if second := Look("go"); second != first {
29 t.Errorf("Look(go) returned %q then %q", first, second)
30 }
31}
32
33// An absent tool falls back to the bare name so exec reports it the way
34// it always did, and Verify names it.
35func TestMissingToolFallsBackAndIsReported(t *testing.T) {
36 const name = "gitbay-no-such-tool-exists"
37 if got := Look(name); got != name {
38 t.Errorf("Look(%q) = %q, want the bare name back", name, got)
39 }
40 err := Verify()
41 if err == nil {
42 t.Fatal("Verify found nothing missing after an unresolvable lookup")
43 }
44 if !strings.Contains(err.Error(), name) {
45 t.Errorf("Verify error %q does not name the missing tool", err)
46 }
47}