From d099ade1c798b23816ab4833794ce37e8e247ab6 Mon Sep 17 00:00:00 2001 From: jasonkeung Date: Thu, 28 May 2026 16:16:47 -0400 Subject: [PATCH 1/3] Redirect direct backend git global config to an isolated per-task file The direct backend runs the oz CLI directly on the host, so git config --global writes from the agent (notably the url..insteadOf rewrites set up by the warp driver) persisted into the real ~/.gitconfig. Set GIT_CONFIG_GLOBAL to a per-task .gitconfig (inside the per-task workspace, or a temp dir in shared --target-dir mode) for the agent invocation and the setup/teardown commands, so those global writes stay isolated and are removed with the task. HOME is left unchanged, so the plaintext credential files the driver writes (~/.git-credentials, ~/.config/gh/hosts.yml) are intentionally untouched. Docker and Kubernetes backends are unaffected. Add a regression test that runs git config --global insteadOf and asserts it lands in the per-task config rather than the real ~/.gitconfig. Co-Authored-By: Oz --- internal/worker/direct.go | 44 +++++++++++++++++--- internal/worker/direct_test.go | 76 ++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 5 deletions(-) diff --git a/internal/worker/direct.go b/internal/worker/direct.go index 70d7bf1..98145c4 100644 --- a/internal/worker/direct.go +++ b/internal/worker/direct.go @@ -47,6 +47,31 @@ func hostBaseEnv() []string { return base } +// prepareTaskGitConfig returns the path to use as the task's global git config +// (GIT_CONFIG_GLOBAL) along with a cleanup function. Redirecting only git's +// global config keeps writes like `git config --global url..insteadOf` out of +// the developer's real ~/.gitconfig (and $XDG_CONFIG_HOME/git/config) without +// repointing HOME for every tool the agent runs. +func prepareTaskGitConfig(workspaceDir, taskID string, usingTargetDir bool) (string, func(), error) { + if !usingTargetDir { + // The per-task workspace is removed after the task, so the config goes with it. + return filepath.Join(workspaceDir, ".gitconfig"), func() {}, nil + } + + // In shared target-dir mode the workspace is the user's real checkout, so keep + // the throwaway global config in a temporary directory outside of it. + sanitizedTaskID := strings.NewReplacer("/", "_", "\\", "_").Replace(taskID) + dir, err := os.MkdirTemp("", fmt.Sprintf("oz-gitconfig-%s-", sanitizedTaskID)) + if err != nil { + return "", nil, fmt.Errorf("failed to create temporary git config directory: %w", err) + } + return filepath.Join(dir, ".gitconfig"), func() { + if err := os.RemoveAll(dir); err != nil { + log.Warnf(context.Background(), "Failed to remove temporary git config dir %s: %v", dir, err) + } + }, nil +} + // DirectBackendConfig holds configuration specific to the direct (non-containerized) backend. type DirectBackendConfig struct { WorkspaceRoot string @@ -124,18 +149,24 @@ func (b *DirectBackend) ExecuteTask(ctx context.Context, params *TaskParams) err } log.Infof(ctx, "Created workspace: %s", workspaceDir) } + gitConfigPath, cleanupGitConfig, err := prepareTaskGitConfig(workspaceDir, taskID, usingTargetDir) + if err != nil { + return err + } + defer cleanupGitConfig() + gitConfigEnv := []string{fmt.Sprintf("GIT_CONFIG_GLOBAL=%s", gitConfigPath)} defer func() { if usingTargetDir { // Don't clean up the shared target directory. - b.runTeardownIfConfigured(ctx, taskID, workspaceDir) + b.runTeardownIfConfigured(ctx, taskID, workspaceDir, gitConfigPath) return } if b.config.NoCleanup { log.Infof(ctx, "Skipping cleanup for workspace: %s", workspaceDir) return } - b.cleanup(ctx, taskID, workspaceDir) + b.cleanup(ctx, taskID, workspaceDir, gitConfigPath) }() // 2. Create temp environment file for setup script to write to. @@ -160,6 +191,7 @@ func (b *DirectBackend) ExecuteTask(ctx context.Context, params *TaskParams) err envVars = append(envVars, fmt.Sprintf("%s=%s", key, value)) } envVars = mergeEnvVars(envVars, harnessEnvVars(workspaceDir, params)) + envVars = mergeEnvVars(envVars, gitConfigEnv) // 4. Run setup command if configured. if b.config.SetupCommand != "" { @@ -187,6 +219,7 @@ func (b *DirectBackend) ExecuteTask(ctx context.Context, params *TaskParams) err setupScriptVars = append(setupScriptVars, fmt.Sprintf("%s=%s", key, value)) } envVars = mergeEnvVars(envVars, setupScriptVars) + envVars = mergeEnvVars(envVars, gitConfigEnv) // 6. Invoke oz CLI with base args. // Start from a minimal host base (HOME, TMPDIR, PATH) and overlay task env vars, @@ -235,12 +268,13 @@ func (b *DirectBackend) PreservesTasksOnShutdown() bool { } // runTeardownIfConfigured runs the teardown command if one is configured. -func (b *DirectBackend) runTeardownIfConfigured(ctx context.Context, taskID, workspaceDir string) { +func (b *DirectBackend) runTeardownIfConfigured(ctx context.Context, taskID, workspaceDir, gitConfigPath string) { if b.config.TeardownCommand == "" { return } teardownEnv := []string{ fmt.Sprintf("OZ_WORKSPACE_ROOT=%s", workspaceDir), + fmt.Sprintf("GIT_CONFIG_GLOBAL=%s", gitConfigPath), "OZ_WORKER_BACKEND=direct", fmt.Sprintf("OZ_RUN_ID=%s", taskID), } @@ -255,8 +289,8 @@ func (b *DirectBackend) runTeardownIfConfigured(ctx context.Context, taskID, wor } // cleanup runs the teardown command (if configured) and removes the workspace directory. -func (b *DirectBackend) cleanup(ctx context.Context, taskID, workspaceDir string) { - b.runTeardownIfConfigured(ctx, taskID, workspaceDir) +func (b *DirectBackend) cleanup(ctx context.Context, taskID, workspaceDir, gitConfigPath string) { + b.runTeardownIfConfigured(ctx, taskID, workspaceDir, gitConfigPath) log.Infof(ctx, "Removing workspace: %s", workspaceDir) if err := os.RemoveAll(workspaceDir); err != nil { diff --git a/internal/worker/direct_test.go b/internal/worker/direct_test.go index e5a3494..909bfd1 100644 --- a/internal/worker/direct_test.go +++ b/internal/worker/direct_test.go @@ -1,6 +1,9 @@ package worker import ( + "context" + "os" + "os/exec" "path/filepath" "strings" "testing" @@ -74,3 +77,76 @@ func envMap(values []string) map[string]string { } return result } + +func TestDirectBackendRedirectsGlobalGitConfig(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available on PATH") + } + + hostHome := t.TempDir() + t.Setenv("HOME", hostHome) + + testDir := t.TempDir() + homeCapture := filepath.Join(testDir, "home.txt") + cfgCapture := filepath.Join(testDir, "git_config_global.txt") + ozPath := filepath.Join(testDir, "oz") + script := `#!/bin/sh +set -eu +printf '%s' "$HOME" > "$OZ_HOME_CAPTURE" +printf '%s' "$GIT_CONFIG_GLOBAL" > "$OZ_CFG_CAPTURE" +git config --global --add url."https://x-access-token:tok@github.com/".insteadOf "ssh://git@github.com/" +` + if err := os.WriteFile(ozPath, []byte(script), 0o755); err != nil { + t.Fatalf("failed to write fake oz script: %v", err) + } + + workspaceRoot := filepath.Join(testDir, "workspaces") + backend, err := NewDirectBackend(context.Background(), DirectBackendConfig{ + WorkspaceRoot: workspaceRoot, + OzPath: ozPath, + NoCleanup: true, + Env: map[string]string{ + "OZ_HOME_CAPTURE": homeCapture, + "OZ_CFG_CAPTURE": cfgCapture, + }, + }) + if err != nil { + t.Fatalf("failed to create direct backend: %v", err) + } + + if err := backend.ExecuteTask(context.Background(), &TaskParams{TaskID: "task-1"}); err != nil { + t.Fatalf("failed to execute task: %v", err) + } + + // HOME is intentionally left untouched: only git's global config is redirected. + if got, err := os.ReadFile(homeCapture); err != nil { + t.Fatalf("failed to read captured HOME: %v", err) + } else if string(got) != hostHome { + t.Fatalf("HOME = %q, want host home %q (HOME must not be repointed)", string(got), hostHome) + } + + // GIT_CONFIG_GLOBAL must point inside the per-task workspace. + wantCfg := filepath.Join(workspaceRoot, "task-1", ".gitconfig") + if got, err := os.ReadFile(cfgCapture); err != nil { + t.Fatalf("failed to read captured GIT_CONFIG_GLOBAL: %v", err) + } else if string(got) != wantCfg { + t.Fatalf("GIT_CONFIG_GLOBAL = %q, want %q", string(got), wantCfg) + } + + // The insteadOf rewrite must land in the isolated config... + data, err := os.ReadFile(wantCfg) + if err != nil { + t.Fatalf("isolated git config was not written: %v", err) + } + if !strings.Contains(strings.ToLower(string(data)), "insteadof") { + t.Fatalf("isolated git config missing insteadOf rewrite:\n%s", data) + } + + // ...and must NOT pollute the developer's real global git config. + if _, err := os.Stat(filepath.Join(hostHome, ".gitconfig")); !os.IsNotExist(err) { + t.Fatalf("host ~/.gitconfig should not exist; stat err = %v", err) + } + if _, err := os.Stat(filepath.Join(hostHome, ".config", "git", "config")); !os.IsNotExist(err) { + t.Fatalf("host XDG git config should not exist; stat err = %v", err) + } +} From 12c79c2626aaf7051092c651db41ba1cb56a70e5 Mon Sep 17 00:00:00 2001 From: jasonkeung Date: Fri, 29 May 2026 13:25:01 -0400 Subject: [PATCH 2/3] Validate task ID to prevent path traversal The direct backend joins the server-provided task ID with the workspace root and uses it as a temp-dir name component. A task ID that is absolute or contains ".." could escape the intended directory (CodeQL go/path-injection). Reject non-local task IDs via filepath.IsLocal before any path is constructed, and add a regression test. Co-Authored-By: Oz --- internal/worker/direct.go | 8 +++----- internal/worker/direct_test.go | 31 ++++++++++++++++++++++++++----- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/internal/worker/direct.go b/internal/worker/direct.go index 98145c4..93b4763 100644 --- a/internal/worker/direct.go +++ b/internal/worker/direct.go @@ -52,16 +52,14 @@ func hostBaseEnv() []string { // global config keeps writes like `git config --global url..insteadOf` out of // the developer's real ~/.gitconfig (and $XDG_CONFIG_HOME/git/config) without // repointing HOME for every tool the agent runs. -func prepareTaskGitConfig(workspaceDir, taskID string, usingTargetDir bool) (string, func(), error) { +func prepareTaskGitConfig(workspaceDir string, usingTargetDir bool) (string, func(), error) { if !usingTargetDir { - // The per-task workspace is removed after the task, so the config goes with it. return filepath.Join(workspaceDir, ".gitconfig"), func() {}, nil } // In shared target-dir mode the workspace is the user's real checkout, so keep // the throwaway global config in a temporary directory outside of it. - sanitizedTaskID := strings.NewReplacer("/", "_", "\\", "_").Replace(taskID) - dir, err := os.MkdirTemp("", fmt.Sprintf("oz-gitconfig-%s-", sanitizedTaskID)) + dir, err := os.MkdirTemp("", "oz-gitconfig-") if err != nil { return "", nil, fmt.Errorf("failed to create temporary git config directory: %w", err) } @@ -149,7 +147,7 @@ func (b *DirectBackend) ExecuteTask(ctx context.Context, params *TaskParams) err } log.Infof(ctx, "Created workspace: %s", workspaceDir) } - gitConfigPath, cleanupGitConfig, err := prepareTaskGitConfig(workspaceDir, taskID, usingTargetDir) + gitConfigPath, cleanupGitConfig, err := prepareTaskGitConfig(workspaceDir, usingTargetDir) if err != nil { return err } diff --git a/internal/worker/direct_test.go b/internal/worker/direct_test.go index 909bfd1..59702ae 100644 --- a/internal/worker/direct_test.go +++ b/internal/worker/direct_test.go @@ -118,22 +118,18 @@ git config --global --add url."https://x-access-token:tok@github.com/".insteadOf t.Fatalf("failed to execute task: %v", err) } - // HOME is intentionally left untouched: only git's global config is redirected. if got, err := os.ReadFile(homeCapture); err != nil { t.Fatalf("failed to read captured HOME: %v", err) } else if string(got) != hostHome { t.Fatalf("HOME = %q, want host home %q (HOME must not be repointed)", string(got), hostHome) } - // GIT_CONFIG_GLOBAL must point inside the per-task workspace. wantCfg := filepath.Join(workspaceRoot, "task-1", ".gitconfig") if got, err := os.ReadFile(cfgCapture); err != nil { t.Fatalf("failed to read captured GIT_CONFIG_GLOBAL: %v", err) } else if string(got) != wantCfg { t.Fatalf("GIT_CONFIG_GLOBAL = %q, want %q", string(got), wantCfg) } - - // The insteadOf rewrite must land in the isolated config... data, err := os.ReadFile(wantCfg) if err != nil { t.Fatalf("isolated git config was not written: %v", err) @@ -142,7 +138,6 @@ git config --global --add url."https://x-access-token:tok@github.com/".insteadOf t.Fatalf("isolated git config missing insteadOf rewrite:\n%s", data) } - // ...and must NOT pollute the developer's real global git config. if _, err := os.Stat(filepath.Join(hostHome, ".gitconfig")); !os.IsNotExist(err) { t.Fatalf("host ~/.gitconfig should not exist; stat err = %v", err) } @@ -150,3 +145,29 @@ git config --global --add url."https://x-access-token:tok@github.com/".insteadOf t.Fatalf("host XDG git config should not exist; stat err = %v", err) } } + +func TestDirectBackendRejectsUnsafeTaskID(t *testing.T) { + testDir := t.TempDir() + ozPath := filepath.Join(testDir, "oz") + if err := os.WriteFile(ozPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatalf("failed to write fake oz script: %v", err) + } + + workspaceRoot := filepath.Join(testDir, "workspaces") + backend, err := NewDirectBackend(context.Background(), DirectBackendConfig{ + WorkspaceRoot: workspaceRoot, + OzPath: ozPath, + }) + if err != nil { + t.Fatalf("failed to create direct backend: %v", err) + } + + for _, badID := range []string{"../escape", "a/../../escape", "/abs/path", "..", ""} { + if err := backend.ExecuteTask(context.Background(), &TaskParams{TaskID: badID}); err == nil { + t.Fatalf("expected error for unsafe task ID %q, got nil", badID) + } + } + if _, err := os.Stat(filepath.Join(testDir, "escape")); !os.IsNotExist(err) { + t.Fatalf("traversal path should not exist; stat err = %v", err) + } +} From fceda8d5fced2f0db5b10896794dede84a94f384 Mon Sep 17 00:00:00 2001 From: jasonkeung Date: Fri, 29 May 2026 15:41:41 -0400 Subject: [PATCH 3/3] Add direct backend git config setup tests Co-Authored-By: Oz --- internal/worker/direct_test.go | 230 +++++++++++++++++++++++++++++++++ 1 file changed, 230 insertions(+) diff --git a/internal/worker/direct_test.go b/internal/worker/direct_test.go index 59702ae..5f0709e 100644 --- a/internal/worker/direct_test.go +++ b/internal/worker/direct_test.go @@ -78,6 +78,215 @@ func envMap(values []string) map[string]string { return result } +func TestDirectBackendSetupCommandDoesNotReadHostGlobalGitConfig(t *testing.T) { + requireGit(t) + + hostHome := t.TempDir() + t.Setenv("HOME", hostHome) + + testDir := t.TempDir() + originPath := filepath.Join(testDir, "origin.git") + runGit(t, nil, "init", "--bare", originPath) + + rewriteURL := "rewrite-test://repo" + fileURL := gitFileURL(originPath) + runGit(t, []string{"HOME=" + hostHome}, "config", "--global", "--add", "url."+fileURL+".insteadOf", rewriteURL) + + ozPath := filepath.Join(testDir, "oz") + if err := os.WriteFile(ozPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatalf("failed to write fake oz script: %v", err) + } + + workspaceRoot := filepath.Join(testDir, "workspaces") + backend, err := NewDirectBackend(context.Background(), DirectBackendConfig{ + WorkspaceRoot: workspaceRoot, + OzPath: ozPath, + SetupCommand: `git ls-remote "$TEST_REWRITE_URL"`, + Env: map[string]string{ + "TEST_REWRITE_URL": rewriteURL, + }, + }) + if err != nil { + t.Fatalf("failed to create direct backend: %v", err) + } + + if err := backend.ExecuteTask(context.Background(), &TaskParams{TaskID: "task-unseeded"}); err == nil { + t.Fatal("expected setup git command to fail because host global git config is hidden") + } + + backend, err = NewDirectBackend(context.Background(), DirectBackendConfig{ + WorkspaceRoot: workspaceRoot, + OzPath: ozPath, + NoCleanup: true, + SetupCommand: strings.Join([]string{ + `git config --global --add url."$TEST_FILE_REPO_URL".insteadOf "$TEST_REWRITE_URL"`, + `git ls-remote "$TEST_REWRITE_URL"`, + }, "\n"), + Env: map[string]string{ + "TEST_FILE_REPO_URL": fileURL, + "TEST_REWRITE_URL": rewriteURL, + }, + }) + if err != nil { + t.Fatalf("failed to create seeded direct backend: %v", err) + } + if err := backend.ExecuteTask(context.Background(), &TaskParams{TaskID: "task-seeded"}); err != nil { + t.Fatalf("expected setup git command to succeed after seeding isolated git config: %v", err) + } +} + +func TestDirectBackendSetupCommandReceivesIsolatedGitConfig(t *testing.T) { + requireGit(t) + + hostHome := t.TempDir() + t.Setenv("HOME", hostHome) + + testDir := t.TempDir() + cfgCapture := filepath.Join(testDir, "setup_git_config_global.txt") + ozPath := filepath.Join(testDir, "oz") + if err := os.WriteFile(ozPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatalf("failed to write fake oz script: %v", err) + } + + workspaceRoot := filepath.Join(testDir, "workspaces") + backend, err := NewDirectBackend(context.Background(), DirectBackendConfig{ + WorkspaceRoot: workspaceRoot, + OzPath: ozPath, + NoCleanup: true, + SetupCommand: strings.Join([]string{ + `printf '%s' "$GIT_CONFIG_GLOBAL" > "$SETUP_CFG_CAPTURE"`, + `git config --global user.email setup@example.com`, + }, "\n"), + Env: map[string]string{ + "SETUP_CFG_CAPTURE": cfgCapture, + }, + }) + if err != nil { + t.Fatalf("failed to create direct backend: %v", err) + } + if err := backend.ExecuteTask(context.Background(), &TaskParams{TaskID: "task-setup"}); err != nil { + t.Fatalf("failed to execute task: %v", err) + } + + wantCfg := filepath.Join(workspaceRoot, "task-setup", ".gitconfig") + if got, err := os.ReadFile(cfgCapture); err != nil { + t.Fatalf("failed to read captured setup GIT_CONFIG_GLOBAL: %v", err) + } else if string(got) != wantCfg { + t.Fatalf("setup GIT_CONFIG_GLOBAL = %q, want %q", string(got), wantCfg) + } + data, err := os.ReadFile(wantCfg) + if err != nil { + t.Fatalf("isolated git config was not written by setup: %v", err) + } + if !strings.Contains(string(data), "setup@example.com") { + t.Fatalf("isolated git config missing setup user.email:\\n%s", data) + } + if _, err := os.Stat(filepath.Join(hostHome, ".gitconfig")); !os.IsNotExist(err) { + t.Fatalf("host ~/.gitconfig should not exist; stat err = %v", err) + } +} + +func TestDirectBackendSetupEnvFileCannotOverrideGitConfigGlobal(t *testing.T) { + testDir := t.TempDir() + mainCfgCapture := filepath.Join(testDir, "main_git_config_global.txt") + overrideCfg := filepath.Join(testDir, "override.gitconfig") + ozPath := filepath.Join(testDir, "oz") + script := `#!/bin/sh +set -eu +printf '%s' "$GIT_CONFIG_GLOBAL" > "$MAIN_CFG_CAPTURE" +git config --global user.email main@example.com +` + if err := os.WriteFile(ozPath, []byte(script), 0o755); err != nil { + t.Fatalf("failed to write fake oz script: %v", err) + } + + workspaceRoot := filepath.Join(testDir, "workspaces") + backend, err := NewDirectBackend(context.Background(), DirectBackendConfig{ + WorkspaceRoot: workspaceRoot, + OzPath: ozPath, + NoCleanup: true, + SetupCommand: `printf 'GIT_CONFIG_GLOBAL=%s\n' "$OVERRIDE_CFG" > "$OZ_ENVIRONMENT_FILE"`, + Env: map[string]string{ + "MAIN_CFG_CAPTURE": mainCfgCapture, + "OVERRIDE_CFG": overrideCfg, + }, + }) + if err != nil { + t.Fatalf("failed to create direct backend: %v", err) + } + if err := backend.ExecuteTask(context.Background(), &TaskParams{TaskID: "task-envfile"}); err != nil { + t.Fatalf("failed to execute task: %v", err) + } + + wantCfg := filepath.Join(workspaceRoot, "task-envfile", ".gitconfig") + if got, err := os.ReadFile(mainCfgCapture); err != nil { + t.Fatalf("failed to read captured main GIT_CONFIG_GLOBAL: %v", err) + } else if string(got) != wantCfg { + t.Fatalf("main GIT_CONFIG_GLOBAL = %q, want %q", string(got), wantCfg) + } + data, err := os.ReadFile(wantCfg) + if err != nil { + t.Fatalf("isolated git config was not written by main oz process: %v", err) + } + if !strings.Contains(string(data), "main@example.com") { + t.Fatalf("isolated git config missing main user.email:\\n%s", data) + } + if _, err := os.Stat(overrideCfg); !os.IsNotExist(err) { + t.Fatalf("setup-provided GIT_CONFIG_GLOBAL override should not be used; stat err = %v", err) + } +} + +func TestDirectBackendGitConfigIsolationSmoke(t *testing.T) { + requireGit(t) + + hostHome := t.TempDir() + t.Setenv("HOME", hostHome) + + testDir := t.TempDir() + originPath := filepath.Join(testDir, "origin.git") + runGit(t, nil, "init", "--bare", originPath) + + rewriteURL := "rewrite-smoke://repo" + fileURL := gitFileURL(originPath) + ozPath := filepath.Join(testDir, "oz") + script := `#!/bin/sh +set -eu +git ls-remote "$TEST_REWRITE_URL" +` + if err := os.WriteFile(ozPath, []byte(script), 0o755); err != nil { + t.Fatalf("failed to write fake oz script: %v", err) + } + + workspaceRoot := filepath.Join(testDir, "workspaces") + backend, err := NewDirectBackend(context.Background(), DirectBackendConfig{ + WorkspaceRoot: workspaceRoot, + OzPath: ozPath, + NoCleanup: true, + SetupCommand: `git config --global --add url."$TEST_FILE_REPO_URL".insteadOf "$TEST_REWRITE_URL"`, + Env: map[string]string{ + "TEST_FILE_REPO_URL": fileURL, + "TEST_REWRITE_URL": rewriteURL, + }, + }) + if err != nil { + t.Fatalf("failed to create direct backend: %v", err) + } + if err := backend.ExecuteTask(context.Background(), &TaskParams{TaskID: "task-smoke"}); err != nil { + t.Fatalf("expected main oz git command to use setup-seeded isolated git config: %v", err) + } + + wantCfg := filepath.Join(workspaceRoot, "task-smoke", ".gitconfig") + data, err := os.ReadFile(wantCfg) + if err != nil { + t.Fatalf("isolated git config was not written: %v", err) + } + if !strings.Contains(strings.ToLower(string(data)), "insteadof") { + t.Fatalf("isolated git config missing rewrite seeded by setup:\\n%s", data) + } + if _, err := os.Stat(filepath.Join(hostHome, ".gitconfig")); !os.IsNotExist(err) { + t.Fatalf("host ~/.gitconfig should not exist; stat err = %v", err) + } +} func TestDirectBackendRedirectsGlobalGitConfig(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git not available on PATH") @@ -171,3 +380,24 @@ func TestDirectBackendRejectsUnsafeTaskID(t *testing.T) { t.Fatalf("traversal path should not exist; stat err = %v", err) } } + +func requireGit(t *testing.T) { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available on PATH") + } +} + +func gitFileURL(path string) string { + return "file://" + filepath.ToSlash(path) +} + +func runGit(t *testing.T, env []string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Env = append(os.Environ(), env...) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %s failed: %v\n%s", strings.Join(args, " "), err, output) + } +}