package executor import ( "context" "encoding/json" "fmt" "io" "log/slog" "os" "os/exec" "path/filepath" "strings" "testing" "github.com/thepeterstone/claudomator/internal/storage" "github.com/thepeterstone/claudomator/internal/task" ) func TestContainerRunner_BuildDockerArgs(t *testing.T) { runner := &ContainerRunner{ APIURL: "http://localhost:8484", DropsDir: "/data/drops", SSHAuthSock: "/tmp/ssh.sock", } workspace := "/tmp/ws" taskID := "task-123" agentHome := "/tmp/ws/.agent-home" args := runner.buildDockerArgs(workspace, agentHome, taskID) expected := []string{ "run", "--rm", "--add-host=host.docker.internal:host-gateway", fmt.Sprintf("--user=%d:%d", os.Getuid(), os.Getgid()), "-v", "/tmp/ws:/workspace", "-v", "/tmp/ws/.agent-home:/home/agent", "-w", "/workspace", "--env-file", "/tmp/ws/.claudomator-env", "-e", "HOME=/home/agent", "-e", "IS_SANDBOX=1", "-e", "CLAUDOMATOR_API_URL=http://host.docker.internal:8484", "-e", "CLAUDOMATOR_TASK_ID=task-123", "-e", "CLAUDOMATOR_DROP_DIR=/data/drops", "-v", "/tmp/ssh.sock:/tmp/ssh-auth.sock", "-e", "SSH_AUTH_SOCK=/tmp/ssh-auth.sock", } if len(args) != len(expected) { t.Fatalf("expected %d args, got %d. Got: %v", len(expected), len(args), args) } for i, v := range args { if v != expected[i] { t.Errorf("arg %d: expected %q, got %q", i, expected[i], v) } } } func TestBuildAgentInstructions(t *testing.T) { t.Run("mcp enabled prepends preamble", func(t *testing.T) { tk := &task.Task{Agent: task.AgentConfig{Instructions: "do the thing"}} got := buildAgentInstructions(tk, true) if !strings.HasPrefix(got, planningPreamble) || !strings.HasSuffix(got, "do the thing") { t.Errorf("expected preamble + instructions, got %q", got) } }) t.Run("mcp disabled keeps raw instructions", func(t *testing.T) { tk := &task.Task{Agent: task.AgentConfig{Instructions: "do the thing"}} if got := buildAgentInstructions(tk, false); got != "do the thing" { t.Errorf("expected raw instructions, got %q", got) } }) t.Run("skip planning keeps raw instructions even with mcp", func(t *testing.T) { tk := &task.Task{Agent: task.AgentConfig{Instructions: "do the thing", SkipPlanning: true}} if got := buildAgentInstructions(tk, true); got != "do the thing" { t.Errorf("expected raw instructions when skip_planning, got %q", got) } }) } func TestWriteMCPConfig(t *testing.T) { dir := t.TempDir() if err := writeMCPConfig(dir, "http://host.docker.internal:8484/mcp", "tok-abc"); err != nil { t.Fatalf("writeMCPConfig: %v", err) } data, err := os.ReadFile(filepath.Join(dir, ".claudomator-mcp.json")) if err != nil { t.Fatalf("read config: %v", err) } var parsed struct { MCPServers map[string]struct { Type string `json:"type"` URL string `json:"url"` Headers map[string]string `json:"headers"` } `json:"mcpServers"` } if err := json.Unmarshal(data, &parsed); err != nil { t.Fatalf("config is not valid JSON: %v", err) } srv, ok := parsed.MCPServers["claudomator"] if !ok { t.Fatal("expected claudomator server entry") } if srv.Type != "http" || srv.URL != "http://host.docker.internal:8484/mcp" { t.Errorf("unexpected server config: %+v", srv) } if srv.Headers["Authorization"] != "Bearer tok-abc" { t.Errorf("expected bearer header, got %q", srv.Headers["Authorization"]) } } func TestWriteGeminiMCPSettings(t *testing.T) { agentHome := t.TempDir() if err := writeGeminiMCPSettings(agentHome, "http://host.docker.internal:8484/mcp", "tok-xyz"); err != nil { t.Fatalf("writeGeminiMCPSettings: %v", err) } data, err := os.ReadFile(filepath.Join(agentHome, ".gemini", "settings.json")) if err != nil { t.Fatalf("read settings: %v", err) } var parsed struct { MCPServers map[string]struct { HTTPURL string `json:"httpUrl"` Headers map[string]string `json:"headers"` } `json:"mcpServers"` } if err := json.Unmarshal(data, &parsed); err != nil { t.Fatalf("settings is not valid JSON: %v", err) } srv, ok := parsed.MCPServers["claudomator"] if !ok { t.Fatal("expected claudomator server entry") } if srv.HTTPURL != "http://host.docker.internal:8484/mcp" { t.Errorf("unexpected httpUrl: %q", srv.HTTPURL) } if srv.Headers["Authorization"] != "Bearer tok-xyz" { t.Errorf("expected bearer header, got %q", srv.Headers["Authorization"]) } } func TestContainerRunner_BuildInnerCmd(t *testing.T) { runner := &ContainerRunner{} t.Run("claude-fresh", func(t *testing.T) { tk := &task.Task{Agent: task.AgentConfig{Type: "claude"}} exec := &storage.Execution{} cmd := runner.buildInnerCmd(tk, exec, false, false) cmdStr := strings.Join(cmd, " ") if strings.Contains(cmdStr, "--resume") { t.Errorf("unexpected --resume flag in fresh run: %q", cmdStr) } if !strings.Contains(cmdStr, "INST=$(cat /workspace/.claudomator-instructions.txt); claude -p \"$INST\"") { t.Errorf("expected cat instructions in sh command, got %q", cmdStr) } }) t.Run("claude-resume", func(t *testing.T) { tk := &task.Task{Agent: task.AgentConfig{Type: "claude"}} exec := &storage.Execution{ResumeSessionID: "orig-session-123"} cmd := runner.buildInnerCmd(tk, exec, true, false) cmdStr := strings.Join(cmd, " ") if !strings.Contains(cmdStr, "--resume orig-session-123") { t.Errorf("expected --resume flag with correct session ID, got %q", cmdStr) } }) t.Run("claude-mcp-enabled", func(t *testing.T) { tk := &task.Task{Agent: task.AgentConfig{Type: "claude"}} cmdStr := strings.Join(runner.buildInnerCmd(tk, &storage.Execution{}, false, true), " ") if !strings.Contains(cmdStr, "--mcp-config "+mcpConfigContainerPath) { t.Errorf("expected --mcp-config flag when MCP enabled, got %q", cmdStr) } }) t.Run("claude-mcp-disabled", func(t *testing.T) { tk := &task.Task{Agent: task.AgentConfig{Type: "claude"}} cmdStr := strings.Join(runner.buildInnerCmd(tk, &storage.Execution{}, false, false), " ") if strings.Contains(cmdStr, "--mcp-config") { t.Errorf("did not expect --mcp-config flag when MCP disabled, got %q", cmdStr) } }) t.Run("gemini", func(t *testing.T) { tk := &task.Task{Agent: task.AgentConfig{Type: "gemini"}} exec := &storage.Execution{} cmd := runner.buildInnerCmd(tk, exec, false, false) cmdStr := strings.Join(cmd, " ") if !strings.Contains(cmdStr, "gemini -p \"$INST\"") { t.Errorf("expected gemini command with safer quoting, got %q", cmdStr) } }) t.Run("custom-binaries", func(t *testing.T) { runnerCustom := &ContainerRunner{ ClaudeBinary: "/usr/bin/claude-v2", GeminiBinary: "/usr/local/bin/gemini-pro", } tkClaude := &task.Task{Agent: task.AgentConfig{Type: "claude"}} cmdClaude := runnerCustom.buildInnerCmd(tkClaude, &storage.Execution{}, false, false) if !strings.Contains(strings.Join(cmdClaude, " "), "/usr/bin/claude-v2 -p") { t.Errorf("expected custom claude binary, got %q", cmdClaude) } tkGemini := &task.Task{Agent: task.AgentConfig{Type: "gemini"}} cmdGemini := runnerCustom.buildInnerCmd(tkGemini, &storage.Execution{}, false, false) if !strings.Contains(strings.Join(cmdGemini, " "), "/usr/local/bin/gemini-pro -p") { t.Errorf("expected custom gemini binary, got %q", cmdGemini) } }) } func TestContainerRunner_Run_PreservesWorkspaceOnFailure(t *testing.T) { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) runner := &ContainerRunner{ Logger: logger, Image: "busybox", Command: func(ctx context.Context, name string, arg ...string) *exec.Cmd { // Mock docker run to exit 1 if name == "docker" { return exec.Command("sh", "-c", "exit 1") } // Mock git clone to succeed and create the directory if name == "git" && len(arg) > 0 && arg[0] == "clone" { dir := arg[len(arg)-1] os.MkdirAll(dir, 0755) return exec.Command("true") } return exec.Command("true") }, } tk := &task.Task{ ID: "test-task", RepositoryURL: "https://github.com/example/repo.git", Agent: task.AgentConfig{Type: "claude"}, } exec := &storage.Execution{ID: "test-exec", TaskID: "test-task"} err := runner.Run(context.Background(), tk, exec, newStoreChannel(nil, tk.ID)) if err == nil { t.Fatal("expected error due to mocked docker failure") } // Verify SandboxDir was set and directory exists. if exec.SandboxDir == "" { t.Fatal("expected SandboxDir to be set even on failure") } if _, statErr := os.Stat(exec.SandboxDir); statErr != nil { t.Errorf("expected sandbox directory to be preserved, but stat failed: %v", statErr) } else { os.RemoveAll(exec.SandboxDir) } } func TestBlockedError_IncludesSandboxDir(t *testing.T) { // This test requires mocking 'docker run' or the whole Run() which is hard. // But we can test that returning BlockedError works. err := &BlockedError{ QuestionJSON: `{"text":"?"}`, SessionID: "s1", SandboxDir: "/tmp/s1", } if !strings.Contains(err.Error(), "task blocked") { t.Errorf("wrong error message: %v", err) } } func TestTailFile_ReturnsLastNLines(t *testing.T) { f, err := os.CreateTemp("", "tailfile-*") if err != nil { t.Fatal(err) } defer os.Remove(f.Name()) for i := 1; i <= 30; i++ { fmt.Fprintf(f, "line %d\n", i) } f.Close() got := tailFile(f.Name(), 5) lines := strings.Split(strings.TrimSpace(got), "\n") if len(lines) != 5 { t.Fatalf("want 5 lines, got %d: %q", len(lines), got) } if lines[0] != "line 26" || lines[4] != "line 30" { t.Errorf("want lines 26-30, got: %q", got) } } func TestDetectUncommittedChanges_ModifiedFile(t *testing.T) { dir := t.TempDir() run := func(args ...string) { cmd := exec.Command(args[0], args[1:]...) cmd.Dir = dir if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("%v: %s", args, out) } } run("git", "init", dir) run("git", "config", "user.email", "test@test.com") run("git", "config", "user.name", "Test") // Create and commit a file if err := os.WriteFile(dir+"/main.go", []byte("package main"), 0644); err != nil { t.Fatal(err) } run("git", "add", "main.go") run("git", "commit", "-m", "init") // Now modify without committing — simulates agent that forgot to commit if err := os.WriteFile(dir+"/main.go", []byte("package main\n// changed"), 0644); err != nil { t.Fatal(err) } err := detectUncommittedChanges(dir) if err == nil { t.Fatal("expected error for modified uncommitted file, got nil") } if !strings.Contains(err.Error(), "uncommitted") { t.Errorf("error should mention uncommitted, got: %v", err) } } func TestDetectUncommittedChanges_NewUntrackedSourceFile(t *testing.T) { dir := t.TempDir() run := func(args ...string) { cmd := exec.Command(args[0], args[1:]...) cmd.Dir = dir if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("%v: %s", args, out) } } run("git", "init", dir) run("git", "config", "user.email", "test@test.com") run("git", "config", "user.name", "Test") run("git", "commit", "--allow-empty", "-m", "init") // Agent wrote a new file but never committed it if err := os.WriteFile(dir+"/newfile.go", []byte("package main"), 0644); err != nil { t.Fatal(err) } err := detectUncommittedChanges(dir) if err == nil { t.Fatal("expected error for new untracked source file, got nil") } } func TestDetectUncommittedChanges_ScaffoldFilesIgnored(t *testing.T) { dir := t.TempDir() run := func(args ...string) { cmd := exec.Command(args[0], args[1:]...) cmd.Dir = dir if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("%v: %s", args, out) } } run("git", "init", dir) run("git", "config", "user.email", "test@test.com") run("git", "config", "user.name", "Test") run("git", "commit", "--allow-empty", "-m", "init") // Write only scaffold files that the harness injects — should not trigger error _ = os.WriteFile(dir+"/.claudomator-env", []byte("KEY=val"), 0600) _ = os.WriteFile(dir+"/.claudomator-instructions.txt", []byte("do stuff"), 0644) _ = os.MkdirAll(dir+"/.agent-home/.claude", 0755) err := detectUncommittedChanges(dir) if err != nil { t.Errorf("scaffold files should not trigger uncommitted error, got: %v", err) } } func TestDetectUncommittedChanges_CleanRepo(t *testing.T) { dir := t.TempDir() run := func(args ...string) { cmd := exec.Command(args[0], args[1:]...) cmd.Dir = dir if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("%v: %s", args, out) } } run("git", "init", dir) run("git", "config", "user.email", "test@test.com") run("git", "config", "user.name", "Test") if err := os.WriteFile(dir+"/main.go", []byte("package main"), 0644); err != nil { t.Fatal(err) } run("git", "add", "main.go") run("git", "commit", "-m", "init") // No modifications — should pass err := detectUncommittedChanges(dir) if err != nil { t.Errorf("clean repo should not error, got: %v", err) } } func TestGitSafe_PrependsSafeDirectory(t *testing.T) { got := gitSafe("-C", "/some/path", "status") want := []string{"-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-c", "tag.gpgsign=false", "-C", "/some/path", "status"} if len(got) != len(want) { t.Fatalf("gitSafe() = %v, want %v", got, want) } for i := range want { if got[i] != want[i] { t.Errorf("gitSafe()[%d] = %q, want %q", i, got[i], want[i]) } } } func TestContainerRunner_MissingCredentials_FailsFast(t *testing.T) { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) claudeConfigDir := t.TempDir() // Set up ClaudeConfigDir with MISSING credentials (so pre-flight fails) // Don't create .credentials.json // But DO create .claude.json so the test isolates the credentials check if err := os.WriteFile(filepath.Join(claudeConfigDir, ".claude.json"), []byte("{}"), 0644); err != nil { t.Fatal(err) } runner := &ContainerRunner{ Logger: logger, Image: "busybox", ClaudeConfigDir: claudeConfigDir, Command: func(ctx context.Context, name string, arg ...string) *exec.Cmd { if name == "git" && len(arg) > 0 && arg[0] == "clone" { dir := arg[len(arg)-1] os.MkdirAll(dir, 0755) return exec.Command("true") } return exec.Command("true") }, } tk := &task.Task{ ID: "test-missing-creds", RepositoryURL: "https://github.com/example/repo.git", Agent: task.AgentConfig{Type: "claude"}, } e := &storage.Execution{ID: "test-exec", TaskID: "test-missing-creds"} err := runner.Run(context.Background(), tk, e, newStoreChannel(nil, tk.ID)) if err == nil { t.Fatal("expected error due to missing credentials, got nil") } if !strings.Contains(err.Error(), "credentials not found") { t.Errorf("expected 'credentials not found' error, got: %v", err) } } func TestContainerRunner_MissingSettings_FailsFast(t *testing.T) { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) claudeConfigDir := t.TempDir() // Only create credentials but NOT .claude.json if err := os.WriteFile(filepath.Join(claudeConfigDir, ".credentials.json"), []byte("{}"), 0600); err != nil { t.Fatal(err) } runner := &ContainerRunner{ Logger: logger, Image: "busybox", ClaudeConfigDir: claudeConfigDir, Command: func(ctx context.Context, name string, arg ...string) *exec.Cmd { if name == "git" && len(arg) > 0 && arg[0] == "clone" { dir := arg[len(arg)-1] os.MkdirAll(dir, 0755) return exec.Command("true") } return exec.Command("true") }, } tk := &task.Task{ ID: "test-missing-settings", RepositoryURL: "https://github.com/example/repo.git", Agent: task.AgentConfig{Type: "claude"}, } e := &storage.Execution{ID: "test-exec-2", TaskID: "test-missing-settings"} err := runner.Run(context.Background(), tk, e, newStoreChannel(nil, tk.ID)) if err == nil { t.Fatal("expected error due to missing settings, got nil") } if !strings.Contains(err.Error(), "claude settings") { t.Errorf("expected 'claude settings' error, got: %v", err) } } func TestIsAuthError_DetectsAllVariants(t *testing.T) { tests := []struct { msg string want bool }{ {"Not logged in", true}, {"OAuth token has expired", true}, {"authentication_error: invalid token", true}, {"Please run /login to authenticate", true}, {"container execution failed: exit status 1", false}, {"git clone failed", false}, {"", false}, } for _, tt := range tests { var err error if tt.msg != "" { err = fmt.Errorf("%s", tt.msg) } got := isAuthError(err) if got != tt.want { t.Errorf("isAuthError(%q) = %v, want %v", tt.msg, got, tt.want) } } } func TestContainerRunner_AuthError_SyncsAndRetries(t *testing.T) { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) // Create a sync script that creates a marker file syncDir := t.TempDir() syncMarker := filepath.Join(syncDir, "sync-called") syncScript := filepath.Join(syncDir, "sync-creds") os.WriteFile(syncScript, []byte("#!/bin/sh\ntouch "+syncMarker+"\n"), 0755) claudeConfigDir := t.TempDir() // Create both credential files in ClaudeConfigDir os.WriteFile(filepath.Join(claudeConfigDir, ".credentials.json"), []byte(`{"token":"fresh"}`), 0600) os.WriteFile(filepath.Join(claudeConfigDir, ".claude.json"), []byte("{}"), 0644) callCount := 0 runner := &ContainerRunner{ Logger: logger, Image: "busybox", ClaudeConfigDir: claudeConfigDir, CredentialSyncCmd: syncScript, Command: func(ctx context.Context, name string, arg ...string) *exec.Cmd { if name == "git" { if len(arg) > 0 && arg[0] == "clone" { dir := arg[len(arg)-1] os.MkdirAll(dir, 0755) } return exec.Command("true") } if name == "docker" { callCount++ if callCount == 1 { // First docker call fails with auth error return exec.Command("sh", "-c", "echo 'Not logged in' >&2; exit 1") } // Second docker call "succeeds" return exec.Command("sh", "-c", "exit 0") } if name == syncScript { return exec.Command("sh", "-c", "touch "+syncMarker) } return exec.Command("true") }, } tk := &task.Task{ ID: "auth-retry-test", RepositoryURL: "https://github.com/example/repo.git", Agent: task.AgentConfig{Type: "claude", Instructions: "test"}, } e := &storage.Execution{ID: "auth-retry-exec", TaskID: "auth-retry-test"} // Run — first attempt will fail with auth error, triggering sync+retry runner.Run(context.Background(), tk, e, newStoreChannel(nil, tk.ID)) // We don't check error strictly since second run may also fail (git push etc.) // What we care about is that docker was called twice and sync was called if callCount < 2 { t.Errorf("expected docker to be called at least twice (original + retry), got %d", callCount) } if _, err := os.Stat(syncMarker); os.IsNotExist(err) { t.Error("expected sync-credentials to be called, but marker file not found") } } func TestContainerRunner_ClonesStoryBranch(t *testing.T) { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) var checkoutArgs []string runner := &ContainerRunner{ Logger: logger, Image: "busybox", Command: func(ctx context.Context, name string, arg ...string) *exec.Cmd { if name == "git" && len(arg) > 0 && arg[0] == "clone" { dir := arg[len(arg)-1] os.MkdirAll(dir, 0755) return exec.Command("true") } // Capture checkout calls: both "git checkout " and "git -C checkout " for i, a := range arg { if a == "checkout" { checkoutArgs = append([]string{}, arg[i:]...) break } } if name == "docker" { return exec.Command("sh", "-c", "exit 1") } return exec.Command("true") }, } tk := &task.Task{ ID: "story-branch-test", RepositoryURL: "https://example.com/repo.git", BranchName: "story/my-feature", Agent: task.AgentConfig{Type: "claude"}, } e := &storage.Execution{ID: "exec-1", TaskID: "story-branch-test"} runner.Run(context.Background(), tk, e, newStoreChannel(nil, tk.ID)) os.RemoveAll(e.SandboxDir) // Assert git checkout was called with the story branch name. if len(checkoutArgs) == 0 { t.Fatal("expected git checkout to be called for story branch, but it was not") } found := false for _, a := range checkoutArgs { if a == "story/my-feature" { found = true break } } if !found { t.Errorf("expected git checkout story/my-feature, got args: %v", checkoutArgs) } } func TestContainerRunner_ClonesDefaultBranchWhenNoBranchName(t *testing.T) { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) var cloneArgs []string runner := &ContainerRunner{ Logger: logger, Image: "busybox", Command: func(ctx context.Context, name string, arg ...string) *exec.Cmd { if name == "git" && len(arg) > 0 && arg[0] == "clone" { cloneArgs = append([]string{}, arg...) dir := arg[len(arg)-1] os.MkdirAll(dir, 0755) return exec.Command("true") } if name == "docker" { return exec.Command("sh", "-c", "exit 1") } return exec.Command("true") }, } tk := &task.Task{ ID: "no-branch-test", RepositoryURL: "https://example.com/repo.git", Agent: task.AgentConfig{Type: "claude"}, } e := &storage.Execution{ID: "exec-2", TaskID: "no-branch-test"} runner.Run(context.Background(), tk, e, newStoreChannel(nil, tk.ID)) os.RemoveAll(e.SandboxDir) for _, a := range cloneArgs { if a == "--branch" { t.Errorf("expected no --branch flag for task without BranchName, got args: %v", cloneArgs) } } } // TestContainerRunner_ClonesFromLocalPathForCITask verifies that when a task has // an HTTPS repository_url but the project has a known local path, the runner // clones from the local path to avoid HTTPS auth failures. func TestContainerRunner_ClonesFromLocalPathForCITask(t *testing.T) { dir := t.TempDir() localRepo := filepath.Join(dir, "local-repo.git") if out, err := exec.Command("git", "init", "--bare", localRepo).CombinedOutput(); err != nil { t.Fatalf("git init bare: %v\n%s", err, out) } store := testStore(t) proj := &task.Project{ ID: "nav", Name: "nav", RemoteURL: localRepo, LocalPath: localRepo, } if err := store.CreateProject(proj); err != nil { t.Fatalf("CreateProject: %v", err) } var cloneSrc string logger := slog.New(slog.NewTextHandler(io.Discard, nil)) runner := &ContainerRunner{ Logger: logger, Image: "busybox", Store: store, Command: func(ctx context.Context, name string, arg ...string) *exec.Cmd { if name == "git" && len(arg) > 0 && arg[0] == "clone" { // Record what source was cloned from. for i, a := range arg { if a != "clone" && a != "--reference" && i > 0 && arg[i-1] != "--reference" { // last non-dest arg before workspace is the source } _ = a } // source is second-to-last arg (before workspace dir) cloneSrc = arg[len(arg)-2] dir := arg[len(arg)-1] os.MkdirAll(dir, 0755) return exec.Command("true") } if name == "docker" { return exec.Command("sh", "-c", "exit 1") } return exec.Command("true") }, } tk := &task.Task{ ID: "ci-task-1", RepositoryURL: "https://github.com/thepeterstone/nav.git", Project: "nav", Agent: task.AgentConfig{Type: "claude"}, } e := &storage.Execution{ID: "exec-ci-1", TaskID: "ci-task-1"} runner.Run(context.Background(), tk, e, noopChannel{}) os.RemoveAll(e.SandboxDir) if cloneSrc == "https://github.com/thepeterstone/nav.git" { t.Error("runner cloned from GitHub HTTPS URL; expected local path to avoid auth failure") } if cloneSrc != localRepo { t.Errorf("expected clone source %q, got %q", localRepo, cloneSrc) } } func TestEnsureStoryBranch_CreatesMissingBranch(t *testing.T) { // Set up a bare repo and a local clone to test branch creation. dir := t.TempDir() bare := filepath.Join(dir, "bare.git") local := filepath.Join(dir, "local") // Create bare repo with an initial commit. if out, err := exec.Command("git", "init", "--bare", bare).CombinedOutput(); err != nil { t.Fatalf("git init bare: %v\n%s", err, out) } if out, err := exec.Command("git", "clone", bare, local).CombinedOutput(); err != nil { t.Fatalf("git clone: %v\n%s", err, out) } if out, err := exec.Command("git", "-C", local, "commit", "--allow-empty", "-m", "init").CombinedOutput(); err != nil { t.Fatalf("git commit: %v\n%s", err, out) } if out, err := exec.Command("git", "-C", local, "push", "origin", "main").CombinedOutput(); err != nil { // try master if out2, err2 := exec.Command("git", "-C", local, "push", "origin", "HEAD:main").CombinedOutput(); err2 != nil { t.Fatalf("git push main: %v\n%s\n%s", err, out, out2) } } runner := &ContainerRunner{Logger: slog.Default()} branch := "story/test-branch" // Branch should not exist yet. out, _ := exec.Command("git", "ls-remote", "--heads", bare, branch).CombinedOutput() if len(strings.TrimSpace(string(out))) > 0 { t.Fatal("branch should not exist before ensureStoryBranch") } if err := runner.ensureStoryBranch(context.Background(), bare, branch, ""); err != nil { t.Fatalf("ensureStoryBranch: %v", err) } // Branch should now exist in the bare repo. out, err := exec.Command("git", "ls-remote", "--heads", bare, branch).CombinedOutput() if err != nil || len(strings.TrimSpace(string(out))) == 0 { t.Errorf("branch %q not found in bare repo after ensureStoryBranch: %s", branch, out) } } func TestEnsureStoryBranch_IdempotentIfExists(t *testing.T) { dir := t.TempDir() bare := filepath.Join(dir, "bare.git") local := filepath.Join(dir, "local") if out, err := exec.Command("git", "init", "--bare", bare).CombinedOutput(); err != nil { t.Fatalf("git init bare: %v\n%s", err, out) } if out, err := exec.Command("git", "clone", bare, local).CombinedOutput(); err != nil { t.Fatalf("git clone: %v\n%s", err, out) } if out, err := exec.Command("git", "-C", local, "commit", "--allow-empty", "-m", "init").CombinedOutput(); err != nil { t.Fatalf("git commit: %v\n%s", err, out) } if _, err := exec.Command("git", "-C", local, "push", "origin", "HEAD:main").CombinedOutput(); err != nil { t.Fatalf("push main: %v", err) } branch := "story/existing-branch" // Pre-create the branch. if out, err := exec.Command("git", "-C", local, "checkout", "-b", branch).CombinedOutput(); err != nil { t.Fatalf("checkout -b: %v\n%s", err, out) } if out, err := exec.Command("git", "-C", local, "push", "origin", branch).CombinedOutput(); err != nil { t.Fatalf("push branch: %v\n%s", err, out) } runner := &ContainerRunner{Logger: slog.Default()} // Should be a no-op, not an error. if err := runner.ensureStoryBranch(context.Background(), bare, branch, ""); err != nil { t.Fatalf("ensureStoryBranch on existing branch: %v", err) } } // noopChannel satisfies AgentChannel with no-op implementations for tests. type noopChannel struct{} func (noopChannel) AskUser(_ context.Context, _ string) (string, error) { return "", nil } func (noopChannel) ReportSummary(_ context.Context, _ string) error { return nil } func (noopChannel) SpawnSubtask(_ context.Context, _ SubtaskSpec) (string, error) { return "", nil } func (noopChannel) RecordProgress(_ context.Context, _ string) error { return nil }