package executor import ( "fmt" "io" "log/slog" "os" "os/exec" "path/filepath" "strings" "testing" "github.com/thepeterstone/claudomator/internal/storage" ) func TestSandboxCloneSource_PrefersLocalRemote(t *testing.T) { dir := t.TempDir() initGitRepo(t, dir) // Add a "local" remote pointing to a bare repo. bare := t.TempDir() exec.Command("git", "init", "--bare", bare).Run() exec.Command("git", "-C", dir, "remote", "add", "local", bare).Run() exec.Command("git", "-C", dir, "remote", "add", "origin", "https://example.com/repo").Run() got := sandboxCloneSource(dir) if got != bare { t.Errorf("expected bare repo path %q, got %q", bare, got) } } func TestSandboxCloneSource_FallsBackToOrigin(t *testing.T) { dir := t.TempDir() initGitRepo(t, dir) // sandboxCloneSource intentionally filters to local-FS remotes (so // `git clone ` doesn't go over the network). Use a local path // for origin to verify the fallback semantics. originURL := t.TempDir() exec.Command("git", "-C", dir, "remote", "add", "origin", originURL).Run() got := sandboxCloneSource(dir) if got != originURL { t.Errorf("expected origin URL %q, got %q", originURL, got) } } func TestSandboxCloneSource_FallsBackToProjectDir(t *testing.T) { dir := t.TempDir() initGitRepo(t, dir) // No remotes configured. got := sandboxCloneSource(dir) if got != dir { t.Errorf("expected projectDir %q (no remotes), got %q", dir, got) } } func TestSetupSandbox_ClonesGitRepo(t *testing.T) { src := t.TempDir() initGitRepo(t, src) sandbox, err := setupSandbox(src, slog.Default()) if err != nil { t.Fatalf("setupSandbox: %v", err) } t.Cleanup(func() { os.RemoveAll(sandbox) }) // Force sandbox to master if it cloned as main exec.Command("git", gitSafe("-C", sandbox, "checkout", "master")...).Run() // Debug sandbox logOut, _ := exec.Command("git", "-C", sandbox, "log", "-1").CombinedOutput() fmt.Printf("DEBUG: sandbox log: %s\n", string(logOut)) // Verify sandbox is a git repo with at least one commit. out, err := exec.Command("git", "-C", sandbox, "log", "--oneline").Output() if err != nil { t.Fatalf("git log in sandbox: %v", err) } if len(strings.TrimSpace(string(out))) == 0 { t.Error("expected at least one commit in sandbox, got empty log") } } func TestSetupSandbox_InitialisesNonGitDir(t *testing.T) { // A plain directory (not a git repo) should be initialised then cloned. src := t.TempDir() sandbox, err := setupSandbox(src, slog.Default()) if err != nil { t.Fatalf("setupSandbox on plain dir: %v", err) } t.Cleanup(func() { os.RemoveAll(sandbox) }) if _, err := os.Stat(filepath.Join(sandbox, ".git")); err != nil { t.Errorf("sandbox should be a git repo: %v", err) } } func TestTeardownSandbox_AutocommitsChanges(t *testing.T) { // Create a bare repo as origin so push succeeds. bare := t.TempDir() if out, err := exec.Command("git", "init", "--bare", "-b", "main", bare).CombinedOutput(); err != nil { t.Fatalf("git init bare: %v\n%s", err, out) } // Create a sandbox directly. sandbox := t.TempDir() initGitRepo(t, sandbox) if out, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "remote", "add", "origin", bare).CombinedOutput(); err != nil { t.Fatalf("git remote add: %v\n%s", err, out) } // Initial push to establish origin/main if out, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "push", "origin", "main").CombinedOutput(); err != nil { t.Fatalf("git push initial: %v\n%s", err, out) } // Capture startHEAD headOut, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "rev-parse", "HEAD").Output() if err != nil { t.Fatalf("rev-parse HEAD: %v", err) } startHEAD := strings.TrimSpace(string(headOut)) // Leave an uncommitted file in the sandbox. if err := os.WriteFile(filepath.Join(sandbox, "dirty.txt"), []byte("autocommit me"), 0644); err != nil { t.Fatal(err) } logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})) execRecord := &storage.Execution{} err = teardownSandbox("", sandbox, startHEAD, logger, execRecord) if err != nil { t.Fatalf("expected autocommit to succeed, got error: %v", err) } // Sandbox should be removed after successful autocommit and push. if _, statErr := os.Stat(sandbox); !os.IsNotExist(statErr) { t.Error("sandbox should have been removed after successful autocommit and push") } // Verify the commit exists in the bare repo. out, err := exec.Command("git", "-C", bare, "log", "-1", "--pretty=%B").Output() if err != nil { t.Fatalf("git log in bare repo: %v", err) } if !strings.Contains(string(out), "chore: autocommit uncommitted changes") { t.Errorf("expected autocommit message in log, got: %q", string(out)) } // Verify the commit was captured in execRecord. if len(execRecord.Commits) == 0 { t.Error("expected at least one commit in execRecord") } else if !strings.Contains(execRecord.Commits[0].Message, "chore: autocommit uncommitted changes") { t.Errorf("unexpected commit message: %q", execRecord.Commits[0].Message) } } func TestTeardownSandbox_BuildFailure_BlocksAutocommit(t *testing.T) { bare := t.TempDir() if out, err := exec.Command("git", "init", "--bare", "-b", "main", bare).CombinedOutput(); err != nil { t.Fatalf("git init bare: %v\n%s", err, out) } sandbox := t.TempDir() initGitRepo(t, sandbox) if out, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "remote", "add", "origin", bare).CombinedOutput(); err != nil { t.Fatalf("git remote add: %v\n%s", err, out) } // Capture startHEAD headOut, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "rev-parse", "HEAD").Output() if err != nil { t.Fatalf("rev-parse HEAD: %v", err) } startHEAD := strings.TrimSpace(string(headOut)) // Leave an uncommitted file. if err := os.WriteFile(filepath.Join(sandbox, "dirty.txt"), []byte("dirty"), 0644); err != nil { t.Fatal(err) } // Add a failing Makefile. makefile := "build:\n\t@echo 'build failed'\n\texit 1\n" if err := os.WriteFile(filepath.Join(sandbox, "Makefile"), []byte(makefile), 0644); err != nil { t.Fatal(err) } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) execRecord := &storage.Execution{} err = teardownSandbox("", sandbox, startHEAD, logger, execRecord) if err == nil { t.Error("expected teardown to fail due to build failure, but it succeeded") } else if !strings.Contains(err.Error(), "build failed before autocommit") { t.Errorf("expected build failure error message, got: %v", err) } // Sandbox should NOT be removed if teardown failed. if _, statErr := os.Stat(sandbox); os.IsNotExist(statErr) { t.Error("sandbox should have been preserved after build failure") } // Verify no new commit in bare repo. out, err := exec.Command("git", "-C", bare, "log", "HEAD").CombinedOutput() if strings.Contains(string(out), "chore: autocommit uncommitted changes") { t.Error("autocommit should not have been pushed after build failure") } } func TestTeardownSandbox_BuildSuccess_ProceedsToAutocommit(t *testing.T) { bare := t.TempDir() if out, err := exec.Command("git", "init", "--bare", "-b", "main", bare).CombinedOutput(); err != nil { t.Fatalf("git init bare: %v\n%s", err, out) } sandbox := t.TempDir() initGitRepo(t, sandbox) if out, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "remote", "add", "origin", bare).CombinedOutput(); err != nil { t.Fatalf("git remote add: %v\n%s", err, out) } // Capture startHEAD headOut, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "rev-parse", "HEAD").Output() if err != nil { t.Fatalf("rev-parse HEAD: %v", err) } startHEAD := strings.TrimSpace(string(headOut)) // Leave an uncommitted file. if err := os.WriteFile(filepath.Join(sandbox, "dirty.txt"), []byte("dirty"), 0644); err != nil { t.Fatal(err) } // Add a successful Makefile. makefile := "build:\n\t@echo 'build succeeded'\n" if err := os.WriteFile(filepath.Join(sandbox, "Makefile"), []byte(makefile), 0644); err != nil { t.Fatal(err) } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) execRecord := &storage.Execution{} err = teardownSandbox("", sandbox, startHEAD, logger, execRecord) if err != nil { t.Fatalf("expected teardown to succeed after build success, got error: %v", err) } // Sandbox should be removed after success. if _, statErr := os.Stat(sandbox); !os.IsNotExist(statErr) { t.Error("sandbox should have been removed after successful build and autocommit") } // Verify new commit in bare repo. out, err := exec.Command("git", "-C", bare, "log", "-1", "--pretty=%B").Output() if err != nil { t.Fatalf("git log in bare repo: %v", err) } if !strings.Contains(string(out), "chore: autocommit uncommitted changes") { t.Errorf("expected autocommit message in log, got: %q", string(out)) } } func TestTeardownSandbox_CapturesExplicitCommits(t *testing.T) { bare := t.TempDir() if out, err := exec.Command("git", "init", "--bare", "-b", "main", bare).CombinedOutput(); err != nil { t.Fatalf("git init bare: %v\n%s", err, out) } sandbox := t.TempDir() initGitRepo(t, sandbox) if out, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "remote", "add", "origin", bare).CombinedOutput(); err != nil { t.Fatalf("git remote add: %v\n%s", err, out) } if out, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "push", "origin", "main").CombinedOutput(); err != nil { t.Fatalf("git push initial: %v\n%s", err, out) } headOut, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "rev-parse", "HEAD").Output() if err != nil { t.Fatalf("rev-parse HEAD: %v", err) } startHEAD := strings.TrimSpace(string(headOut)) // Simulate Claude explicitly committing changes. if err := os.WriteFile(filepath.Join(sandbox, "work.txt"), []byte("done"), 0644); err != nil { t.Fatal(err) } for _, args := range [][]string{ {"-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "add", "-A"}, {"-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "commit", "-m", "feat: implement the feature"}, } { if out, err := exec.Command("git", args...).CombinedOutput(); err != nil { t.Fatalf("git %v: %v\n%s", args, err, out) } } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) execRecord := &storage.Execution{} if err := teardownSandbox("", sandbox, startHEAD, logger, execRecord); err != nil { t.Fatalf("teardownSandbox: %v", err) } if len(execRecord.Commits) == 0 { t.Fatal("expected commits to be captured in execRecord") } if !strings.Contains(execRecord.Commits[0].Message, "feat: implement the feature") { t.Errorf("unexpected commit message: %q", execRecord.Commits[0].Message) } if execRecord.Commits[0].Hash == "" { t.Error("commit hash should not be empty") } } func TestTeardownSandbox_CleanSandboxWithNoNewCommits_RemovesSandbox(t *testing.T) { src := t.TempDir() initGitRepo(t, src) sandbox, err := setupSandbox(src, slog.Default()) if err != nil { t.Fatalf("setupSandbox: %v", err) } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) execRecord := &storage.Execution{} headOut, _ := exec.Command("git", "-C", sandbox, "rev-parse", "HEAD").Output() startHEAD := strings.TrimSpace(string(headOut)) // Sandbox has no new commits beyond origin; teardown should succeed and remove it. if err := teardownSandbox(src, sandbox, startHEAD, logger, execRecord); err != nil { t.Fatalf("teardownSandbox: %v", err) } if _, statErr := os.Stat(sandbox); !os.IsNotExist(statErr) { t.Error("sandbox should have been removed after clean teardown") os.RemoveAll(sandbox) } } func TestTailFile_MissingFile_ReturnsEmpty(t *testing.T) { got := tailFile("/nonexistent/path/file.log", 10) if got != "" { t.Errorf("want empty string for missing file, got %q", got) } } func initGitRepo(t *testing.T, dir string) { t.Helper() cmds := [][]string{ {"git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", dir, "init", "-b", "main"}, {"git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", dir, "config", "user.email", "test@test"}, {"git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", dir, "config", "user.name", "test"}, } for _, args := range cmds { if out, err := exec.Command(args[0], args[1:]...).CombinedOutput(); err != nil { t.Fatalf("%v: %v\n%s", args, err, out) } } if err := os.WriteFile(filepath.Join(dir, "init.txt"), []byte("init"), 0644); err != nil { t.Fatal(err) } if out, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", dir, "add", ".").CombinedOutput(); err != nil { t.Fatalf("git add: %v\n%s", err, out) } if out, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", dir, "commit", "-m", "init").CombinedOutput(); err != nil { t.Fatalf("git commit: %v\n%s", err, out) } }