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 (
2424 "time"
2525
2626 "gitbay.org/gitbay/internal/buildinfo"
27 "gitbay.org/gitbay/internal/toolpath"
2728)
2829
2930type job struct {
@@ -203,7 +204,7 @@ func (r *runner) run(j job) bool {
203204 defer os.RemoveAll(dir)
204205
205206 // 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))...)
207208 pipe, err := logCmd.StdinPipe()
208209 if err != nil {
209210 log.Printf("build %d: log pipe: %v", j.ID, err)
@@ -294,7 +295,7 @@ func (r *runner) run(j job) bool {
294295 }
295296 steps = append(steps, []string{"-C", dir, "checkout", "-q", j.SHA})
296297 for _, args := range steps {
297 cmd := exec.Command("git", args...)
298 cmd := exec.Command(toolpath.Look("git"), args...)
298299 cmd.Env = append(os.Environ(), "GIT_SSH_COMMAND="+gitSSH, "GIT_TERMINAL_PROMPT=0")
299300 cmd.Stdout, cmd.Stderr = sink, sink
300301 if ok, why := runStep(cmd, deadline); !ok {
@@ -305,7 +306,7 @@ func (r *runner) run(j job) bool {
305306
306307 for _, step := range j.Steps {
307308 fmt.Fprintf(sink, "$ %s\n", step)
308 cmd := exec.Command("sh", "-c", step)
309 cmd := exec.Command(toolpath.Look("sh"), "-c", step)
309310 cmd.Dir = dir
310311 cmd.Env = append(os.Environ(),
311312 "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 {
323324
324325// ssh runs one control command against the server and returns stdout.
325326func (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...)...)
327328 if stdin != nil {
328329 cmd.Stdin = stdin
329330 }
cmd/gitbay/local.go +5 −4
@@ -11,6 +11,7 @@ import (
1111
1212 "gitbay.org/gitbay/internal/cliconfig"
1313 "gitbay.org/gitbay/internal/protocol"
14 "gitbay.org/gitbay/internal/toolpath"
1415)
1516
1617// hasBodyFlag reports whether args already carry body/message input.
@@ -50,7 +51,7 @@ func maybeEditor(args []string, kind string, prefill func() string) ([]string, *
5051 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)
5152 f.Close()
5253
53 ed := exec.Command("sh", "-c", editor+" "+shellQuote(f.Name()))
54 ed := exec.Command(toolpath.Look("sh"), "-c", editor+" "+shellQuote(f.Name()))
5455 ed.Stdin, ed.Stdout, ed.Stderr = os.Stdin, os.Stdout, os.Stderr
5556 if err := ed.Run(); err != nil {
5657 return nil, nil, false, fmt.Errorf("editor: %w", err)
@@ -74,7 +75,7 @@ func maybeEditor(args []string, kind string, prefill func() string) ([]string, *
7475}
7576
7677func runGitLocal(args ...string) int {
77 cmd := exec.Command("git", args...)
78 cmd := exec.Command(toolpath.Look("git"), args...)
7879 cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
7980 if err := cmd.Run(); err != nil {
8081 if ee, ok := err.(*exec.ExitError); ok {
@@ -190,7 +191,7 @@ func captureSSH(t target, serverArgv []string) (string, int) {
190191 args := sshArgs(t.inst)
191192 quoted := quoteAll(serverArgv)
192193 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...)
194195 cmd.Stderr = os.Stderr
195196 out, err := cmd.Output()
196197 if err != nil {
@@ -301,7 +302,7 @@ func cmdRemoteList() int {
301302// currentBranch is the checked-out branch of the working directory's
302303// clone, or "" outside a clone or on a detached HEAD.
303304func 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()
305306 if err != nil {
306307 return ""
307308 }
cmd/gitbay/migrate.go +4 −3
@@ -11,6 +11,7 @@ import (
1111 "github.com/spf13/cobra"
1212
1313 "gitbay.org/gitbay/internal/protocol"
14 "gitbay.org/gitbay/internal/toolpath"
1415)
1516
1617func migrateCmd() *cobra.Command {
@@ -43,7 +44,7 @@ func sourceSSH(host string, port int, extra ...string) *exec.Cmd {
4344 }
4445 args = append(args, "git@"+host, "--")
4546 args = append(args, extra...)
46 return exec.Command("ssh", args...)
47 return exec.Command(toolpath.Look("ssh"), args...)
4748}
4849
4950func runMigrate(from string, fromPort int) int {
@@ -99,14 +100,14 @@ func runMigrate(from string, fromPort int) int {
99100 if fromPort != 0 && fromPort != 22 {
100101 srcURL = fmt.Sprintf("ssh://git@%s:%d/%s.git", from, fromPort, repoPath)
101102 }
102 clone := exec.Command("git", "clone", "--quiet", "--mirror", srcURL, tmp+"/r")
103 clone := exec.Command(toolpath.Look("git"), "clone", "--quiet", "--mirror", srcURL, tmp+"/r")
103104 clone.Stderr = os.Stderr
104105 if err := clone.Run(); err != nil {
105106 fmt.Fprintf(os.Stderr, "gitbay: cloning %s failed; fix and re-run migrate (it resumes)\n", repoPath)
106107 os.RemoveAll(tmp)
107108 return protocol.ExitFailure
108109 }
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),
110111 "+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*")
111112 if len(t.inst.SSHOptions) > 0 {
112113 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 (
1313
1414 "gitbay.org/gitbay/internal/cliconfig"
1515 "gitbay.org/gitbay/internal/protocol"
16 "gitbay.org/gitbay/internal/toolpath"
1617)
1718
1819// context is the resolved target for a command: which instance to talk to
@@ -59,7 +60,7 @@ func resolveTarget() (target, error) {
5960}
6061
6162func 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()
6364 if err != nil {
6465 return ""
6566 }
@@ -115,7 +116,7 @@ func runSSH(t target, serverArgv []string, stdin io.Reader) int {
115116 }
116117 args = append(args, t.inst.SSHUser()+"@"+t.inst.Host, "--", strings.Join(quoted, " "))
117118
118 cmd := exec.Command("ssh", args...)
119 cmd := exec.Command(toolpath.Look("ssh"), args...)
119120 cmd.Stdin = stdin
120121 cmd.Stdout = os.Stdout
121122 cmd.Stderr = os.Stderr
@@ -145,7 +146,7 @@ func sshCapture(t target, serverArgv []string) (string, int) {
145146 quoted[i] = shellQuote(a)
146147 }
147148 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()
149150 if err != nil {
150151 code := protocol.ExitProtocol
151152 if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() != 255 {
cmd/gitbayd/hook.go +3 −2
@@ -15,6 +15,7 @@ import (
1515 "gitbay.org/gitbay/internal/gitutil"
1616 "gitbay.org/gitbay/internal/hookd"
1717 "gitbay.org/gitbay/internal/policy"
18 "gitbay.org/gitbay/internal/toolpath"
1819)
1920
2021// incomingSHAs lists the commits this push introduces, in order, without
@@ -28,7 +29,7 @@ func incomingSHAs(updates []policy.RefUpdate) ([]string, error) {
2829 continue
2930 }
3031 // 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()
3233 if err != nil {
3334 return nil, fmt.Errorf("rev-list %s: %w", u.New, err)
3435 }
@@ -62,7 +63,7 @@ func streamIncomingCommits(updates []policy.RefUpdate, emit func(hookd.RawCommit
6263 // part-way through a large push leaves git blocked writing into a
6364 // pipe nobody is reading and Wait blocked on git.
6465 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")
6667 stdin, err := cmd.StdinPipe()
6768 if err != nil {
6869 cancel()
cmd/gitbayd/main.go +7
@@ -32,6 +32,7 @@ import (
3232 "gitbay.org/gitbay/internal/notify"
3333 "gitbay.org/gitbay/internal/sshd"
3434 "gitbay.org/gitbay/internal/store"
35 "gitbay.org/gitbay/internal/toolpath"
3536 "gitbay.org/gitbay/internal/webhook"
3637)
3738
@@ -122,6 +123,12 @@ func serveCmd() *cobra.Command {
122123 // First line of every run: the journal then says which commit is
123124 // serving, without rebuilding the binary to find out.
124125 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 }
125132 cfg, err := config.Load(configPath)
126133 if err != nil {
127134 return err
cmd/gitbayd/maint.go +2 −1
@@ -13,6 +13,7 @@ import (
1313 "gitbay.org/gitbay/internal/gitutil"
1414 "gitbay.org/gitbay/internal/lfs"
1515 "gitbay.org/gitbay/internal/store"
16 "gitbay.org/gitbay/internal/toolpath"
1617)
1718
1819func gcCmd() *cobra.Command {
@@ -51,7 +52,7 @@ func gcCmd() *cobra.Command {
5152 if aggressive {
5253 gcArgs = append(gcArgs, "--aggressive")
5354 }
54 if out, err := exec.Command("git", gcArgs...).CombinedOutput(); err != nil {
55 if out, err := exec.Command(toolpath.Look("git"), gcArgs...).CombinedOutput(); err != nil {
5556 fmt.Fprintf(os.Stderr, "%s: gc failed: %v\n%s", r.Path(), err, out)
5657 continue
5758 }
internal/gitd/gitd.go +2 −1
@@ -16,6 +16,7 @@ import (
1616 "gitbay.org/gitbay/internal/config"
1717 "gitbay.org/gitbay/internal/control"
1818 "gitbay.org/gitbay/internal/store"
19 "gitbay.org/gitbay/internal/toolpath"
1920)
2021
2122type Server struct {
@@ -68,7 +69,7 @@ func (s *Server) handle(conn net.Conn) {
6869 }
6970
7071 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)
7273 cmd.Env = append(os.Environ(), protoEnv...)
7374 cmd.Stdin = conn
7475 cmd.Stdout = conn
internal/gitutil/blame.go +3 −1
@@ -7,6 +7,8 @@ import (
77 "os/exec"
88 "strconv"
99 "strings"
10
11 "gitbay.org/gitbay/internal/toolpath"
1012)
1113
1214// 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) {
2830 if err != nil {
2931 return nil, err
3032 }
31 cmd := exec.Command("git", "-C", dir, "blame", "--porcelain",
33 cmd := exec.Command(toolpath.Look("git"), "-C", dir, "blame", "--porcelain",
3234 fmt.Sprintf("-L%d,%d", start, end), sha, "--", path)
3335 out, err := cmd.Output()
3436 if err != nil {
internal/gitutil/facts.go +5 −3
@@ -5,12 +5,14 @@ import (
55 "sort"
66 "strconv"
77 "strings"
8
9 "gitbay.org/gitbay/internal/toolpath"
810)
911
1012// CountCommits returns the number of commits reachable from ref, or 0 when
1113// the ref does not resolve (an empty repository).
1214func 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()
1416 if err != nil {
1517 return 0
1618 }
@@ -30,7 +32,7 @@ type Contributor struct {
3032// tie back to an account, after the repository's own .mailmap has had its
3133// say — a bare repo resolves that from HEAD:.mailmap with no config.
3234func 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",
3436 "--use-mailmap", "--format=%aN%x01%aE", "--end-of-options", ref).Output()
3537 if err != nil {
3638 return nil
@@ -62,7 +64,7 @@ func Contributors(dir, ref string) []Contributor {
6264// largest first, keyed by the extension map the caller supplies. Only
6365// blobs count; git's own metadata does not.
6466func 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()
6668 if err != nil {
6769 return nil
6870 }
internal/gitutil/gitutil.go +14 −12
@@ -11,6 +11,8 @@ import (
1111 "os/exec"
1212 "path/filepath"
1313 "strings"
14
15 "gitbay.org/gitbay/internal/toolpath"
1416)
1517
1618// InitBare creates a bare repository with the shared hooks directory wired
@@ -19,14 +21,14 @@ func InitBare(path, defaultBranch, hooksPath string) error {
1921 if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
2022 return err
2123 }
22 cmd := exec.Command("git", "init", "--bare", "--initial-branch="+defaultBranch, path)
24 cmd := exec.Command(toolpath.Look("git"), "init", "--bare", "--initial-branch="+defaultBranch, path)
2325 if out, err := cmd.CombinedOutput(); err != nil {
2426 return fmt.Errorf("git init: %v\n%s", err, out)
2527 }
2628 // An empty hooksPath leaves the bare repo with no hooks — used for
2729 // companion repos (wikis) that carry no ref policy.
2830 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)
3032 if out, err := cmd.CombinedOutput(); err != nil {
3133 return fmt.Errorf("git config core.hooksPath: %v\n%s", err, out)
3234 }
@@ -49,7 +51,7 @@ func Transport(service, repoPath string, stdin io.Reader, stdout, errW io.Writer
4951 default:
5052 return fmt.Errorf("unknown service %q", service)
5153 }
52 cmd := exec.Command("git", args...)
54 cmd := exec.Command(toolpath.Look("git"), args...)
5355 cmd.Env = append(os.Environ(), extraEnv...)
5456 cmd.Stdin = stdin
5557 cmd.Stdout = stdout
@@ -61,7 +63,7 @@ func Transport(service, repoPath string, stdin io.Reader, stdout, errW io.Writer
6163// dir. It must run with the caller's environment intact so that quarantined
6264// objects during pre-receive remain visible.
6365func 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)
6567 err := cmd.Run()
6668 if err == nil {
6769 return true, nil
@@ -87,7 +89,7 @@ func ZeroSHA(s string) bool {
8789
8890// RevList returns up to limit commit SHAs reachable from ref, newest first.
8991func 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)
9193 out, err := cmd.Output()
9294 if err != nil {
9395 return nil, fmt.Errorf("rev-list %s: %w", ref, err)
@@ -105,7 +107,7 @@ func RevList(dir, ref string, limit int) ([]string, error) {
105107// touch filePath, newest first. The "--" keeps the path from ever being
106108// read as an option or ref.
107109func 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",
109111 fmt.Sprintf("--max-count=%d", limit), "--end-of-options", ref, "--", filePath)
110112 out, err := cmd.Output()
111113 if err != nil {
@@ -123,7 +125,7 @@ func RevListPath(dir, ref, filePath string, limit int) ([]string, error) {
123125// PeelToCommit resolves a ref or object to its commit — annotated tags
124126// peel to the commit they point at.
125127func 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()
127129 if err != nil {
128130 return "", fmt.Errorf("rev-parse %s^{commit}: %w", ref, err)
129131 }
@@ -132,7 +134,7 @@ func PeelToCommit(dir, ref string) (string, error) {
132134
133135// ReadCommit returns the raw commit object bytes.
134136func 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)
136138 out, err := cmd.Output()
137139 if err != nil {
138140 return nil, fmt.Errorf("cat-file commit %s: %w", sha, err)
@@ -145,7 +147,7 @@ func ReadCommit(dir, sha string) ([]byte, error) {
145147// an interactive caller can watch. extraEnv carries credentials via
146148// GIT_ASKPASS; the URL itself must never contain them.
147149func 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,
149151 "+refs/heads/*:refs/heads/*",
150152 "+refs/tags/*:refs/tags/*",
151153 "+refs/notes/*:refs/notes/*")
@@ -168,7 +170,7 @@ func FetchMirror(ctx context.Context, dir, url string, errW io.Writer, extraEnv
168170// handshakes. extraEnv carries credentials via GIT_ASKPASS; the URL must
169171// never contain them.
170172func 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",
172174 url, "+refs/pull/*/head:refs/gh-pull/*")
173175 cmd.Env = append(os.Environ(), extraEnv...)
174176 cmd.Stderr = errW
@@ -180,7 +182,7 @@ func FetchPullHeads(ctx context.Context, dir, url string, errW io.Writer, extraE
180182
181183// RemoteDefaultBranch asks the remote which branch HEAD points at.
182184func 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")
184186 cmd.Env = append(os.Environ(), extraEnv...)
185187 out, err := cmd.Output()
186188 if err != nil {
@@ -199,7 +201,7 @@ func RemoteDefaultBranch(ctx context.Context, url string, extraEnv []string) (st
199201
200202// SetHead points the bare repo's HEAD at a branch.
201203func 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)
203205 if out, err := cmd.CombinedOutput(); err != nil {
204206 return fmt.Errorf("symbolic-ref: %v\n%s", err, out)
205207 }
internal/gitutil/grep.go +3 −1
@@ -7,6 +7,8 @@ import (
77 "strconv"
88 "strings"
99 "time"
10
11 "gitbay.org/gitbay/internal/toolpath"
1012)
1113
1214type GrepMatch struct {
@@ -23,7 +25,7 @@ func Grep(dir, ref, query string, max int) ([]GrepMatch, error) {
2325 defer cancel()
2426 // -z: NUL after the path and the line number, so paths containing
2527 // ':' 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)
2729 out, err := cmd.Output()
2830 if err != nil {
2931 if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 {
internal/gitutil/lastcommit.go +5 −3
@@ -6,6 +6,8 @@ import (
66 "strconv"
77 "strings"
88 "time"
9
10 "gitbay.org/gitbay/internal/toolpath"
911)
1012
1113// 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 {
4951 if prefix != "" {
5052 args = append(args, "--", strings.TrimSuffix(prefix, "/"))
5153 }
52 cmd := exec.Command("git", args...)
54 cmd := exec.Command(toolpath.Look("git"), args...)
5355 stdout, err := cmd.StdoutPipe()
5456 if err != nil {
5557 return nil
@@ -107,7 +109,7 @@ func parseCommitHeader(s string) EntryCommit {
107109// TipCommit is the commit at ref, for the bar above a tree listing that
108110// answers "who touched this repository last".
109111func 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",
111113 "--format=%H%x1f%ct%x1f%an%x1f%ae%x1f%s", "--end-of-options", ref).Output()
112114 if err != nil {
113115 return EntryCommit{}
@@ -137,7 +139,7 @@ func entryName(changed, prefix string) (string, bool) {
137139// page can report the facts the file listing no longer carries: its size,
138140// and whether it is executable or a symlink.
139141func 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()
141143 if err != nil {
142144 return TreeEntry{}, false
143145 }
internal/gitutil/lfs.go +4 −2
@@ -7,6 +7,8 @@ import (
77 "regexp"
88 "strconv"
99 "strings"
10
11 "gitbay.org/gitbay/internal/toolpath"
1012)
1113
1214// 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})$`)
1820// anywhere in the repository: every object, not just the reachable ones,
1921// since an unreachable blob is still an object gc has not removed.
2022func 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",
2224 "--batch-check=%(objecttype) %(objectsize) %(objectname)")
2325 out, err := list.Output()
2426 if err != nil {
@@ -38,7 +40,7 @@ func LFSPointerOIDs(dir string) ([]string, error) {
3840 if len(small) == 0 {
3941 return nil, nil
4042 }
41 cat := exec.Command("git", "-C", dir, "cat-file", "--batch")
43 cat := exec.Command(toolpath.Look("git"), "-C", dir, "cat-file", "--batch")
4244 cat.Stdin = strings.NewReader(strings.Join(small, "\n") + "\n")
4345 out, err = cat.Output()
4446 if err != nil {
internal/gitutil/merge.go +22 −20
@@ -6,13 +6,15 @@ import (
66 "os"
77 "os/exec"
88 "strings"
9
10 "gitbay.org/gitbay/internal/toolpath"
911)
1012
1113// FetchInto copies srcRef from srcDir into dstDir as dstRef, forcing the
1214// update. Objects are copied, not shared — the destination owns everything
1315// afterward, which is what keeps MRs alive when their fork is deleted.
1416func 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",
1618 srcDir, "+"+srcRef+":"+dstRef)
1719 if out, err := cmd.CombinedOutput(); err != nil {
1820 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 {
2830 if oldSHA != "" {
2931 args = append(args, oldSHA)
3032 }
31 cmd := exec.Command("git", args...)
33 cmd := exec.Command(toolpath.Look("git"), args...)
3234 if out, err := cmd.CombinedOutput(); err != nil {
3335 return fmt.Errorf("update-ref %s: %v\n%s", ref, err, out)
3436 }
@@ -36,7 +38,7 @@ func UpdateRefCAS(dir, ref, newSHA, oldSHA string) error {
3638}
3739
3840func 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)
4042 if out, err := cmd.CombinedOutput(); err != nil {
4143 return fmt.Errorf("delete-ref %s: %v\n%s", ref, err, out)
4244 }
@@ -45,7 +47,7 @@ func DeleteRef(dir, ref string) error {
4547
4648// RevListRange returns commits in old..new, newest first.
4749func 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)
4951 out, err := cmd.Output()
5052 if err != nil {
5153 return nil, fmt.Errorf("rev-list %s..%s: %w", old, new, err)
@@ -62,7 +64,7 @@ func RevListRange(dir, old, new string) ([]string, error) {
6264// MergeTree performs a real merge of ours and theirs, returning the merged
6365// tree id. conflict=true means the merge cannot be done automatically.
6466func 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)
6668 out, runErr := cmd.Output()
6769 tree = strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0])
6870 if runErr != nil {
@@ -81,7 +83,7 @@ func CommitTree(dir, tree string, parents []string, name, email, message string)
8183 for _, p := range parents {
8284 args = append(args, "-p", p)
8385 }
84 cmd := exec.Command("git", args...)
86 cmd := exec.Command(toolpath.Look("git"), args...)
8587 cmd.Env = append(os.Environ(),
8688 "GIT_AUTHOR_NAME="+name, "GIT_AUTHOR_EMAIL="+email,
8789 "GIT_COMMITTER_NAME="+name, "GIT_COMMITTER_EMAIL="+email,
@@ -98,7 +100,7 @@ func CommitTree(dir, tree string, parents []string, name, email, message string)
98100// truncated says whether it was cut, so the caller can say so instead of
99101// rendering a hunk that ends mid-line (#117).
100102func 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)
102104 out, err := cmd.Output()
103105 if err != nil {
104106 return "", false, fmt.Errorf("diff: %w", err)
@@ -122,7 +124,7 @@ func cutAtLine(out []byte, limit int64) ([]byte, bool) {
122124
123125// MergeBase returns the best common ancestor, or an error if none exists.
124126func 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)
126128 out, err := cmd.Output()
127129 if err != nil {
128130 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
141143 }
142144
143145 // 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")
145147 hb.Stdin = strings.NewReader(string(content))
146148 out, err := hb.Output()
147149 if err != nil {
@@ -159,17 +161,17 @@ func CommitFileChange(dir, branch, path string, content []byte, name, email, mes
159161 defer os.Remove(idx.Name())
160162 env := append(os.Environ(), "GIT_INDEX_FILE="+idx.Name())
161163
162 rt := exec.Command("git", "-C", dir, "read-tree", parent+"^{tree}")
164 rt := exec.Command(toolpath.Look("git"), "-C", dir, "read-tree", parent+"^{tree}")
163165 rt.Env = env
164166 if out, err := rt.CombinedOutput(); err != nil {
165167 return "", fmt.Errorf("read-tree: %v\n%s", err, out)
166168 }
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)
168170 ui.Env = env
169171 if out, err := ui.CombinedOutput(); err != nil {
170172 return "", fmt.Errorf("update-index: %v\n%s", err, out)
171173 }
172 wt := exec.Command("git", "-C", dir, "write-tree")
174 wt := exec.Command(toolpath.Look("git"), "-C", dir, "write-tree")
173175 wt.Env = env
174176 out, err = wt.Output()
175177 if err != nil {
@@ -189,7 +191,7 @@ func CommitFileChange(dir, branch, path string, content []byte, name, email, mes
189191
190192// CommitParents returns the parent SHAs of a commit.
191193func 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()
193195 if err != nil {
194196 return nil, fmt.Errorf("rev-list --parents %s: %w", sha, err)
195197 }
@@ -202,7 +204,7 @@ func CommitParents(dir, sha string) ([]string, error) {
202204
203205// AuthorIdent returns a commit's author name, email, and ISO date.
204206func 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()
206208 if err != nil {
207209 return "", "", "", fmt.Errorf("log %s: %w", sha, err)
208210 }
@@ -215,7 +217,7 @@ func AuthorIdent(dir, sha string) (name, email, date string, err error) {
215217
216218// CommitMessage returns a commit's full message.
217219func 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()
219221 if err != nil {
220222 return "", fmt.Errorf("log %s: %w", sha, err)
221223 }
@@ -225,7 +227,7 @@ func CommitMessage(dir, sha string) (string, error) {
225227// MergeTreeOnto replays commit's changes (relative to base) onto onto,
226228// returning the resulting tree. conflict=true when it cannot apply cleanly.
227229func 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)
229231 out, runErr := cmd.Output()
230232 tree = strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0])
231233 if runErr != nil {
@@ -245,7 +247,7 @@ func CommitTreeIdent(dir, tree string, parents []string,
245247 for _, p := range parents {
246248 args = append(args, "-p", p)
247249 }
248 cmd := exec.Command("git", args...)
250 cmd := exec.Command(toolpath.Look("git"), args...)
249251 env := append(os.Environ(),
250252 "GIT_AUTHOR_NAME="+authorName, "GIT_AUTHOR_EMAIL="+authorEmail,
251253 "GIT_COMMITTER_NAME="+committerName, "GIT_COMMITTER_EMAIL="+committerEmail,
@@ -263,7 +265,7 @@ func CommitTreeIdent(dir, tree string, parents []string,
263265
264266// ResolveTree returns the tree id of a commit.
265267func 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()
267269 if err != nil {
268270 return "", fmt.Errorf("rev-parse %s^{tree}: %w", sha, err)
269271 }
@@ -272,7 +274,7 @@ func ResolveTree(dir, sha string) (string, error) {
272274
273275// DiffFiles lists the paths changed between old and new.
274276func 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()
276278 if err != nil {
277279 return nil, fmt.Errorf("diff --name-only: %w", err)
278280 }
@@ -304,7 +306,7 @@ func DiffFiles(dir, old, new string) ([]string, error) {
304306// tells a reviewer nothing. It pairs at 80, and two genuinely unrelated
305307// commits are still left unpaired there; both measured.
306308func 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",
308310 oldBase+".."+oldHead, newBase+".."+newHead)
309311 out, err := cmd.Output()
310312 if err != nil {
internal/gitutil/messages.go +7 −5
@@ -4,6 +4,8 @@ import (
44 "fmt"
55 "os/exec"
66 "strings"
7
8 "gitbay.org/gitbay/internal/toolpath"
79)
810
911const zeroSHA = "0000000000000000000000000000000000000000"
@@ -22,7 +24,7 @@ func RevListAuthors(dir, old, new string, max int) ([]CommitAuthor, error) {
2224 if old != "" && old != zeroSHA {
2325 args = append(args, "^"+old)
2426 }
25 out, err := exec.Command("git", args...).Output()
27 out, err := exec.Command(toolpath.Look("git"), args...).Output()
2628 if err != nil {
2729 return nil, fmt.Errorf("rev-list authors: %w", err)
2830 }
@@ -39,7 +41,7 @@ func RevListAuthors(dir, old, new string, max int) ([]CommitAuthor, error) {
3941
4042// Parents returns a commit's parent shas.
4143func 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()
4345 if err != nil {
4446 return nil
4547 }
@@ -49,7 +51,7 @@ func Parents(dir, sha string) []string {
4951// LastCommitDate returns the committer date (YYYY-MM-DD) of the ref tip,
5052// or "" for empty repos.
5153func 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()
5355 if err != nil {
5456 return ""
5557 }
@@ -58,7 +60,7 @@ func LastCommitDate(dir, ref string) string {
5860
5961// HasCommit reports whether sha names a commit object present in dir.
6062func 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
6264}
6365
6466type CommitMsg struct {
@@ -76,7 +78,7 @@ func RevListMessages(dir, old, new string, max int) ([]CommitMsg, error) {
7678 if old != "" && old != zeroSHA {
7779 args = append(args, "^"+old)
7880 }
79 out, err := exec.Command("git", args...).Output()
81 out, err := exec.Command(toolpath.Look("git"), args...).Output()
8082 if err != nil {
8183 return nil, fmt.Errorf("rev-list messages: %w", err)
8284 }
internal/gitutil/read.go +8 −6
@@ -10,6 +10,8 @@ import (
1010 "strconv"
1111 "strings"
1212 "time"
13
14 "gitbay.org/gitbay/internal/toolpath"
1315)
1416
1517type TreeEntry struct {
@@ -26,7 +28,7 @@ func ListTree(dir, ref, path string) ([]TreeEntry, error) {
2628 if path != "" {
2729 spec = ref + ":" + path
2830 }
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)
3032 out, err := cmd.Output()
3133 if err != nil {
3234 return nil, fmt.Errorf("ls-tree %s: %w", spec, err)
@@ -56,7 +58,7 @@ func ListTree(dir, ref, path string) ([]TreeEntry, error) {
5658
5759// ReadBlob returns the contents of ref:path, capped at limit bytes.
5860func 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)
6062 stdout, err := cmd.StdoutPipe()
6163 if err != nil {
6264 return nil, err
@@ -74,7 +76,7 @@ func ReadBlob(dir, ref, path string, limit int64) ([]byte, error) {
7476
7577// ResolveRef resolves a ref or sha to a full commit sha; errors if absent.
7678func 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}")
7880 out, err := cmd.Output()
7981 if err != nil {
8082 return "", fmt.Errorf("unknown ref %q", ref)
@@ -89,7 +91,7 @@ type Ref struct {
8991
9092// Refs lists branches or tags; kind is "heads" or "tags".
9193func 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",
9395 "--format=%(refname:short) %(objectname)", "refs/"+kind)
9496 out, err := cmd.Output()
9597 if err != nil {
@@ -118,7 +120,7 @@ var ErrArchiveTooLarge = errors.New("archive exceeds the size limit")
118120func Archive(dir, ref, prefix string, w io.Writer) error {
119121 ctx, cancel := context.WithTimeout(context.Background(), archiveTimeout)
120122 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)
122124 lw := &cappedWriter{w: w, left: MaxArchiveBytes, stop: cancel}
123125 cmd.Stdout = lw
124126 err := cmd.Run()
@@ -152,7 +154,7 @@ func (c *cappedWriter) Write(p []byte) (int, error) {
152154
153155// ShowPatch returns the stat+patch text for one commit.
154156func 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)
156158 stdout, err := cmd.StdoutPipe()
157159 if err != nil {
158160 return "", false, err
internal/mirror/mirror.go +2 −1
@@ -17,6 +17,7 @@ import (
1717 "gitbay.org/gitbay/internal/config"
1818 "gitbay.org/gitbay/internal/control"
1919 "gitbay.org/gitbay/internal/store"
20 "gitbay.org/gitbay/internal/toolpath"
2021)
2122
2223const askpassScript = `#!/bin/sh
@@ -106,7 +107,7 @@ func (w *Worker) sync(m store.Mirror) error {
106107 args = []string{"-C", dir, "fetch", "--prune", m.URL,
107108 "+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*"}
108109 }
109 cmd := exec.CommandContext(ctx, "git", args...)
110 cmd := exec.CommandContext(ctx, toolpath.Look("git"), args...)
110111 cmd.Env = env
111112 if out, err := cmd.CombinedOutput(); err != nil {
112113 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}