Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion platform/git/exec/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,18 @@ go_library(
srcs = [
"command_error.go",
"gitexec.go",
"runtime.go",
],
importpath = "github.com/uber/submitqueue/platform/git/exec",
visibility = ["//visibility:public"],
)

go_test(
name = "go_default_test",
srcs = ["gitexec_test.go"],
srcs = [
"gitexec_test.go",
"runtime_test.go",
],
embed = [":go_default_library"],
deps = [
"@com_github_stretchr_testify//assert:go_default_library",
Expand Down
88 changes: 88 additions & 0 deletions platform/git/exec/runtime.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// Copyright (c) 2026 Uber Technologies, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package gitexec

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i am wondering if it needs to exit on it's own vs being part of the local gitworkspace impl itself ? do you see any use of it outside?


import (
"context"
"fmt"
"os/exec"
"path/filepath"
)

// Runtime identifies the explicitly provided Git runtime that commands run on.
type Runtime struct {
// Executable is the absolute path to the Git executable.
Executable string
// ExecPath is the absolute directory containing Git's helper executables.
ExecPath string
// TemplateDir is the absolute directory containing Git's repository
// templates.
TemplateDir string
// PassthroughEnv names additional environment variables to inherit from
// the parent process, on top of the auth and transport ones always passed
// through. For a deployment whose remote needs something unusual; leave
// empty otherwise. Names that could alter merge semantics do not belong
// here — the scrubbed environment is what keeps a merge reproducible.
PassthroughEnv []string
}

// Validate reports whether every path of the runtime is set and absolute.
func (r Runtime) Validate() error {
for _, field := range []struct{ name, path string }{
{"executable", r.Executable},
{"exec path", r.ExecPath},
{"template dir", r.TemplateDir},
} {
if field.path == "" {
return fmt.Errorf("git runtime %s is required", field.name)
}
if !filepath.IsAbs(field.path) {
return fmt.Errorf("git runtime %s must be absolute: %q", field.name, field.path)
}
}
return nil
}

// Command constructs a Git command without inheriting the caller's
// environment. The executable and helper paths come from the pinned runtime;
// repository-local configuration remains an intentional input.
func (r Runtime) Command(ctx context.Context, dir string, args ...string) *exec.Cmd {
gitArgs := make([]string, 0, len(args)+3)
gitArgs = append(gitArgs,
"--exec-path="+r.ExecPath,
"-c", "init.templateDir="+r.TemplateDir,
)
gitArgs = append(gitArgs, args...)

cmd := exec.CommandContext(ctx, r.Executable, gitArgs...)
cmd.Dir = dir
// HOME and XDG_CONFIG_HOME are isolated to the checkout rather than inherited,
// so the runtime's literals — appended last — override any HOME a deployment
// passed through. The remaining literals pin git's runtime; the scrub set and
// transport variables come from Env.
cmd.Env = Env(EnvOptions{
Transport: true,
Passthrough: r.PassthroughEnv,
Literal: []string{
"HOME=" + filepath.Join(dir, ".submitqueue-git-home"),
"XDG_CONFIG_HOME=" + filepath.Join(dir, ".submitqueue-git-home", "xdg"),
"GIT_EXEC_PATH=" + r.ExecPath,
"GIT_TEMPLATE_DIR=" + r.TemplateDir,
"LC_ALL=C",
"LANG=C",
},
})
return cmd
}
124 changes: 124 additions & 0 deletions platform/git/exec/runtime_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// Copyright (c) 2026 Uber Technologies, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package gitexec

import (
"context"
"os"
"path/filepath"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func dummyRuntime(t *testing.T) Runtime {
t.Helper()
dir := t.TempDir()
return Runtime{
Executable: filepath.Join(dir, "git"),
ExecPath: filepath.Join(dir, "git-core"),
TemplateDir: filepath.Join(dir, "templates"),
}
}

func TestRuntimeValidate(t *testing.T) {
valid := dummyRuntime(t)
tests := []struct {
name string
mutate func(*Runtime)
wantErr bool
}{
{name: "valid", mutate: func(*Runtime) {}},
{name: "missing executable", mutate: func(r *Runtime) { r.Executable = "" }, wantErr: true},
{name: "relative exec path", mutate: func(r *Runtime) { r.ExecPath = "git-core" }, wantErr: true},
{name: "relative template dir", mutate: func(r *Runtime) { r.TemplateDir = "templates" }, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
runtime := valid
tt.mutate(&runtime)
err := runtime.Validate()
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}

func TestRuntimeCommandDoesNotInheritEnvironment(t *testing.T) {
t.Setenv("SUBMITQUEUE_GIT_AMBIENT", "ambient")
runtime := dummyRuntime(t)

cmd := runtime.Command(context.Background(), t.TempDir(), "--version")

assert.Equal(t, runtime.Executable, cmd.Path)
assert.NotContains(t, cmd.Env, "SUBMITQUEUE_GIT_AMBIENT=ambient")
assert.Contains(t, cmd.Env, "GIT_CONFIG_NOSYSTEM=1")
assert.Contains(t, cmd.Env, "GIT_EXEC_PATH="+runtime.ExecPath)
}

func TestRuntimeCommandPreservesAuthEnvironment(t *testing.T) {
// Scrubbing denies git ambient configuration; it must not also deny it the
// means to reach the remote. Without the agent socket an SSH remote cannot
// authenticate, and without PATH git cannot even exec ssh.
t.Setenv("SSH_AUTH_SOCK", "/tmp/agent.sock")
t.Setenv("PATH", "/usr/bin:/bin")
t.Setenv("HTTPS_PROXY", "http://proxy.example.com:3128")
t.Setenv("SUBMITQUEUE_GIT_AMBIENT", "ambient")

cmd := dummyRuntime(t).Command(context.Background(), t.TempDir(), "--version")

assert.Contains(t, cmd.Env, "SSH_AUTH_SOCK=/tmp/agent.sock")
assert.Contains(t, cmd.Env, "PATH=/usr/bin:/bin")
assert.Contains(t, cmd.Env, "HTTPS_PROXY=http://proxy.example.com:3128")
assert.NotContains(t, cmd.Env, "SUBMITQUEUE_GIT_AMBIENT=ambient")
}

func TestRuntimeCommandOmitsUnsetAuthEnvironment(t *testing.T) {
// An unset variable must be omitted rather than exported empty: an empty
// SSH_AUTH_SOCK tells ssh there is no agent instead of letting it look.
t.Setenv("SSH_AUTH_SOCK", "")
require.NoError(t, os.Unsetenv("SSH_AUTH_SOCK"))

cmd := dummyRuntime(t).Command(context.Background(), t.TempDir(), "--version")

for _, entry := range cmd.Env {
assert.False(t, strings.HasPrefix(entry, "SSH_AUTH_SOCK="), "unset variable leaked as %q", entry)
}
}

func TestRuntimeCommandPassesThroughExtraEnvironment(t *testing.T) {
t.Setenv("SUBMITQUEUE_CUSTOM_TRANSPORT", "value")
runtime := dummyRuntime(t)
runtime.PassthroughEnv = []string{"SUBMITQUEUE_CUSTOM_TRANSPORT"}

cmd := runtime.Command(context.Background(), t.TempDir(), "--version")

assert.Contains(t, cmd.Env, "SUBMITQUEUE_CUSTOM_TRANSPORT=value")
}

func TestRuntimeCommandIsolatesHome(t *testing.T) {
t.Setenv("HOME", "/ambient/home")
dir := t.TempDir()

cmd := dummyRuntime(t).Command(context.Background(), dir, "--version")

assert.Contains(t, cmd.Env, "HOME="+filepath.Join(dir, ".submitqueue-git-home"))
assert.NotContains(t, cmd.Env, "HOME=/ambient/home")
}
74 changes: 4 additions & 70 deletions runway/extension/merger/git/git_merger.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,6 @@ import (
"errors"
"fmt"
"os/exec"
"path/filepath"
"strings"
"sync"

Expand Down Expand Up @@ -97,23 +96,6 @@ const (
defaultCommitterEmail = "runway@submitqueue.invalid"
)

// GitRuntime identifies the explicitly provided Git runtime used by the Merger.
type GitRuntime struct {
// Executable is the absolute path to the Git executable.
Executable string
// ExecPath is the absolute directory containing Git's helper executables.
ExecPath string
// TemplateDir is the absolute directory containing Git's repository
// templates.
TemplateDir string
// PassthroughEnv names additional environment variables to inherit from
// the parent process, on top of the auth and transport ones always passed
// through. For a deployment whose remote needs something unusual; leave
// empty otherwise. Names that could alter merge semantics do not belong
// here — the scrubbed environment is what keeps a merge reproducible.
PassthroughEnv []string
}

// Params holds the dependencies for the git Merger.
type Params struct {
// CheckoutPath is the absolute path to an existing git checkout that the
Expand All @@ -128,7 +110,7 @@ type Params struct {
// concrete strategy (REBASE, SQUASH_REBASE, MERGE, or PROMOTE).
DefaultStrategy mergestrategypb.Strategy
// Runtime is the pinned Git runtime used for every invocation.
Runtime GitRuntime
Runtime gitexec.Runtime
// MaxPushAttempts caps how many times a committing merge retries the full
// reset/apply/push cycle when the remote tip moves under it. Defaults to
// defaultMaxPushAttempts when zero or negative.
Expand Down Expand Up @@ -166,7 +148,7 @@ type gitMerger struct {
remote string
target string
defaultStrategy mergestrategypb.Strategy
runtime GitRuntime
runtime gitexec.Runtime
maxPushAttempts int
fetchRefspecs []string
checkStaleness bool
Expand Down Expand Up @@ -201,7 +183,7 @@ type resolvedStep struct {
// The checkout must already exist and have the configured remote. Runtime paths
// must be absolute and DefaultStrategy must be a concrete strategy.
func NewMerger(params Params) (merger.Merger, error) {
if err := params.Runtime.validate(); err != nil {
if err := params.Runtime.Validate(); err != nil {
return nil, err
}
if !isConcreteStrategy(params.DefaultStrategy) {
Expand Down Expand Up @@ -237,22 +219,6 @@ func NewMerger(params Params) (merger.Merger, error) {
}, nil
}

func (r GitRuntime) validate() error {
for name, path := range map[string]string{
"executable": r.Executable,
"exec path": r.ExecPath,
"template dir": r.TemplateDir,
} {
if path == "" {
return fmt.Errorf("git runtime %s is required", name)
}
if !filepath.IsAbs(path) {
return fmt.Errorf("git runtime %s must be absolute: %q", name, path)
}
}
return nil
}

// CheckMergeability applies the request's steps as a dry run: it verifies each
// step applies cleanly without committing to the remote, and returns per-step
// results with empty Outputs.
Expand Down Expand Up @@ -1025,43 +991,11 @@ func (m *gitMerger) commandAs(ctx context.Context, author authorIdent, args ...s
"-c", "commit.gpgsign=false",
)
withIdentity = append(withIdentity, args...)
cmd := newGitCommand(ctx, m.runtime, m.checkoutPath, withIdentity...)
cmd := m.runtime.Command(ctx, m.checkoutPath, withIdentity...)
cmd.Env = append(cmd.Env, author.env()...)
return cmd
}

// newGitCommand constructs a Git command without inheriting the caller's
// environment. The executable and helper paths come from the pinned runtime;
// repository-local configuration remains an intentional input.
func newGitCommand(ctx context.Context, runtime GitRuntime, dir string, args ...string) *exec.Cmd {
gitArgs := make([]string, 0, len(args)+3)
gitArgs = append(gitArgs,
"--exec-path="+runtime.ExecPath,
"-c", "init.templateDir="+runtime.TemplateDir,
)
gitArgs = append(gitArgs, args...)

cmd := exec.CommandContext(ctx, runtime.Executable, gitArgs...)
cmd.Dir = dir
// HOME and XDG_CONFIG_HOME are isolated to the checkout rather than inherited,
// so the runtime's literals — appended last — override any HOME a deployment
// passed through. The remaining literals pin git's runtime; the scrub set and
// transport variables come from the shared composer.
cmd.Env = gitexec.Env(gitexec.EnvOptions{
Transport: true,
Passthrough: runtime.PassthroughEnv,
Literal: []string{
"HOME=" + filepath.Join(dir, ".submitqueue-git-home"),
"XDG_CONFIG_HOME=" + filepath.Join(dir, ".submitqueue-git-home", "xdg"),
"GIT_EXEC_PATH=" + runtime.ExecPath,
"GIT_TEMPLATE_DIR=" + runtime.TemplateDir,
"LC_ALL=C",
"LANG=C",
},
})
return cmd
}

// isConcreteStrategy reports whether s names a concrete integration strategy
// (i.e. not DEFAULT and not an unknown value).
func isConcreteStrategy(s mergestrategypb.Strategy) bool {
Expand Down
Loading
Loading