Skip to content

Commit 32d8a16

Browse files
committed
feat(git/exec): a shared environment composer for git commands
Add Env(EnvOptions) as the single place that builds a git command's environment: the always-applied scrub set, plus transport variables inherited from the parent when set (SSH agent, PATH, TLS, proxy), plus caller-supplied literals appended last so they override. Re-express Command in terms of Env so there is one composer, not two. This gives the change provider's repository and the Runway merger one source of truth to build on instead of each keeping its own copy of the scrub set and transport list. HOME is intentionally excluded from the shared transport list, since callers that isolate HOME and callers that inherit it disagree; each supplies it through Literal or Passthrough.
1 parent 3ae3f5b commit 32d8a16

3 files changed

Lines changed: 188 additions & 25 deletions

File tree

platform/git/exec/BUILD.bazel

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,18 @@
1-
load("@rules_go//go:def.bzl", "go_library")
1+
load("@rules_go//go:def.bzl", "go_library", "go_test")
22

33
go_library(
44
name = "go_default_library",
55
srcs = ["gitexec.go"],
66
importpath = "github.com/uber/submitqueue/platform/git/exec",
77
visibility = ["//visibility:public"],
88
)
9+
10+
go_test(
11+
name = "go_default_test",
12+
srcs = ["gitexec_test.go"],
13+
embed = [":go_default_library"],
14+
deps = [
15+
"@com_github_stretchr_testify//assert:go_default_library",
16+
"@com_github_stretchr_testify//require:go_default_library",
17+
],
18+
)

platform/git/exec/gitexec.go

Lines changed: 80 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,23 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15-
// Package gitexec locates a git binary and runs it with the ambient
16-
// environment stripped out.
15+
// Package gitexec locates a git binary and composes the environment git runs
16+
// in. It is the single source of truth for that environment across every
17+
// SubmitQueue caller — demo tooling, the change provider's repository, and the
18+
// Runway merger.
1719
//
18-
// Demo and development tooling drives git on a developer's own machine, where
19-
// hooks, a signing key, or a commit template configured globally would each
20-
// break a run in a way that has nothing to do with SubmitQueue. Every command
21-
// built here therefore carries the same scrubbed environment the git merger
22-
// uses (see runway/extension/merger/git), so tooling behaves the same on every
23-
// machine.
20+
// The environment has two halves. The scrub set denies git all ambient
21+
// configuration that could change what a command produces — a global hooks
22+
// path, a signing requirement, a commit template — which is what makes a
23+
// scripted run behave the same on every machine. The transport set carries
24+
// what a command needs to reach a remote — the SSH agent socket, git's ssh and
25+
// credential helpers on PATH, TLS roots, proxy settings — none of which can
26+
// change an answer. Every caller shares both halves; they differ only in the
27+
// literal entries they add (a pinned exec path, an isolated HOME), which is why
28+
// Env takes those as options rather than baking one caller's policy in.
2429
//
25-
// This resolves only the executable, because tooling runs porcelain
26-
// (init, clone, commit, push) rather than constructing a merger's GitRuntime,
27-
// which additionally pins the exec path and template directory.
30+
// HOME is deliberately not in the transport set: a caller that isolates HOME
31+
// and a caller that inherits it disagree, so each supplies it itself.
2832
package gitexec
2933

3034
import (
@@ -67,23 +71,75 @@ func Resolve(path string) (string, error) {
6771
return absolute, nil
6872
}
6973

74+
// scrubEnv denies git every ambient configuration input that could change what
75+
// a command produces. Always applied, first, so a later entry can override it.
76+
var scrubEnv = []string{
77+
"GIT_CONFIG_NOSYSTEM=1",
78+
"GIT_CONFIG_GLOBAL=" + os.DevNull,
79+
"GIT_ATTR_NOSYSTEM=1",
80+
"GIT_TERMINAL_PROMPT=0",
81+
"GIT_PAGER=cat",
82+
"GIT_EDITOR=:",
83+
}
84+
85+
// transportEnvNames are inherited from the parent process when set. None can
86+
// change what a command produces; each decides whether a remote is reachable.
87+
// HOME is intentionally absent — see the package doc.
88+
var transportEnvNames = []string{
89+
"PATH",
90+
"SSH_AUTH_SOCK", "SSH_AGENT_PID",
91+
"GIT_SSH", "GIT_SSH_COMMAND", "GIT_SSH_VARIANT",
92+
"GIT_SSL_CAINFO", "GIT_SSL_CAPATH",
93+
"SSL_CERT_DIR", "SSL_CERT_FILE",
94+
"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY",
95+
"http_proxy", "https_proxy", "no_proxy",
96+
}
97+
98+
// EnvOptions selects what, on top of the always-applied scrub set, a git
99+
// command's environment carries.
100+
type EnvOptions struct {
101+
// Transport inherits the transport variables from the parent when set.
102+
Transport bool
103+
// Passthrough names further variables to inherit from the parent when set.
104+
Passthrough []string
105+
// Literal entries are appended last as "NAME=value", so they override any
106+
// inherited value of the same name.
107+
Literal []string
108+
}
109+
110+
// Env composes a git command environment: the scrub set, then the requested
111+
// variables inherited from the parent (only those actually set, so an unset
112+
// SSH_AUTH_SOCK stays absent rather than becoming empty), then the literals.
113+
func Env(opts EnvOptions) []string {
114+
env := make([]string, 0, len(scrubEnv)+len(transportEnvNames)+len(opts.Passthrough)+len(opts.Literal))
115+
env = append(env, scrubEnv...)
116+
117+
names := make([]string, 0, len(transportEnvNames)+len(opts.Passthrough))
118+
if opts.Transport {
119+
names = append(names, transportEnvNames...)
120+
}
121+
names = append(names, opts.Passthrough...)
122+
123+
seen := make(map[string]bool, len(names))
124+
for _, name := range names {
125+
if name == "" || seen[name] {
126+
continue
127+
}
128+
seen[name] = true
129+
if v, ok := os.LookupEnv(name); ok {
130+
env = append(env, name+"="+v)
131+
}
132+
}
133+
return append(env, opts.Literal...)
134+
}
135+
70136
// Command builds a git invocation in dir with the ambient environment removed.
71-
// An empty dir runs in the current working directory.
137+
// An empty dir runs in the current working directory. PATH is passed so git can
138+
// find its helpers; nothing else the host sets reaches the command.
72139
func Command(ctx context.Context, git, dir string, args ...string) *exec.Cmd {
73140
cmd := exec.CommandContext(ctx, git, args...)
74141
cmd.Dir = dir
75-
// A developer's global config is the usual reason a scripted git run fails
76-
// on one machine and not another: a hooks path, a signing requirement, or a
77-
// commit template. None of it is relevant to seeding a sandbox.
78-
cmd.Env = []string{
79-
"GIT_CONFIG_NOSYSTEM=1",
80-
"GIT_CONFIG_GLOBAL=" + os.DevNull,
81-
"GIT_ATTR_NOSYSTEM=1",
82-
"GIT_TERMINAL_PROMPT=0",
83-
"GIT_PAGER=cat",
84-
"GIT_EDITOR=:",
85-
"PATH=" + os.Getenv("PATH"),
86-
}
142+
cmd.Env = Env(EnvOptions{Literal: []string{"PATH=" + os.Getenv("PATH")}})
87143
return cmd
88144
}
89145

platform/git/exec/gitexec_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// Copyright (c) 2025 Uber Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package gitexec
16+
17+
import (
18+
"os"
19+
"strings"
20+
"testing"
21+
22+
"github.com/stretchr/testify/assert"
23+
"github.com/stretchr/testify/require"
24+
)
25+
26+
// value returns the value of NAME=value entry for name, and whether it is
27+
// present at all.
28+
func value(env []string, name string) (string, bool) {
29+
prefix := name + "="
30+
found := ""
31+
ok := false
32+
for _, e := range env {
33+
if strings.HasPrefix(e, prefix) {
34+
found = strings.TrimPrefix(e, prefix)
35+
ok = true
36+
}
37+
}
38+
return found, ok
39+
}
40+
41+
func TestEnv_AlwaysScrubs(t *testing.T) {
42+
env := Env(EnvOptions{})
43+
for _, want := range scrubEnv {
44+
assert.Contains(t, env, want)
45+
}
46+
}
47+
48+
func TestEnv_TransportInheritedOnlyWhenSet(t *testing.T) {
49+
t.Setenv("SSH_AUTH_SOCK", "/tmp/agent.sock")
50+
51+
withTransport := Env(EnvOptions{Transport: true})
52+
got, ok := value(withTransport, "SSH_AUTH_SOCK")
53+
assert.True(t, ok)
54+
assert.Equal(t, "/tmp/agent.sock", got)
55+
56+
withoutTransport := Env(EnvOptions{})
57+
_, ok = value(withoutTransport, "SSH_AUTH_SOCK")
58+
assert.False(t, ok)
59+
}
60+
61+
func TestEnv_UnsetTransportVarStaysAbsent(t *testing.T) {
62+
// An unset SSH_AUTH_SOCK means "there is no agent", so it must be absent
63+
// rather than exported empty. t.Setenv records the original for restoration;
64+
// Unsetenv then removes it for the duration of the test.
65+
t.Setenv("SSH_AUTH_SOCK", "placeholder")
66+
require.NoError(t, os.Unsetenv("SSH_AUTH_SOCK"))
67+
68+
env := Env(EnvOptions{Transport: true})
69+
_, ok := value(env, "SSH_AUTH_SOCK")
70+
assert.False(t, ok)
71+
}
72+
73+
func TestEnv_LiteralOverridesInherited(t *testing.T) {
74+
t.Setenv("PATH", "/host/bin")
75+
76+
env := Env(EnvOptions{Transport: true, Literal: []string{"PATH=/pinned/bin"}})
77+
got, ok := value(env, "PATH")
78+
assert.True(t, ok)
79+
assert.Equal(t, "/pinned/bin", got)
80+
}
81+
82+
func TestEnv_PassthroughDeduplicatesWithTransport(t *testing.T) {
83+
t.Setenv("PATH", "/host/bin")
84+
85+
env := Env(EnvOptions{Transport: true, Passthrough: []string{"PATH"}})
86+
count := 0
87+
for _, e := range env {
88+
if strings.HasPrefix(e, "PATH=") {
89+
count++
90+
}
91+
}
92+
assert.Equal(t, 1, count)
93+
}
94+
95+
func TestEnv_HomeNotInSharedTransportList(t *testing.T) {
96+
assert.NotContains(t, transportEnvNames, "HOME")
97+
}

0 commit comments

Comments
 (0)