summaryrefslogtreecommitdiff
path: root/internal/executor/sandbox.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/executor/sandbox.go')
-rw-r--r--internal/executor/sandbox.go187
1 files changed, 187 insertions, 0 deletions
diff --git a/internal/executor/sandbox.go b/internal/executor/sandbox.go
new file mode 100644
index 0000000..a9248d1
--- /dev/null
+++ b/internal/executor/sandbox.go
@@ -0,0 +1,187 @@
+package executor
+
+import (
+ "fmt"
+ "log/slog"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+
+ "github.com/thepeterstone/claudomator/internal/storage"
+ "github.com/thepeterstone/claudomator/internal/task"
+)
+
+// sandboxCloneSource returns the URL to clone the sandbox from. It prefers a
+// remote named "local" (a local bare repo that accepts pushes cleanly), then
+// falls back to "origin", then to the working copy path itself.
+func sandboxCloneSource(projectDir string) string {
+ for _, remote := range []string{"local", "origin"} {
+ out, err := exec.Command("git", gitSafe("-C", projectDir, "remote", "get-url", remote)...).Output()
+ if err == nil {
+ u := strings.TrimSpace(string(out))
+ if u != "" && (strings.HasPrefix(u, "/") || strings.HasPrefix(u, "file://")) {
+ return u
+ }
+ }
+ }
+ return projectDir
+}
+
+// setupSandbox prepares a temporary git clone of projectDir.
+// If projectDir is not a git repo it is initialised with an initial commit first.
+func setupSandbox(projectDir string, logger *slog.Logger) (string, error) {
+ // Ensure projectDir is a git repo; initialise if not.
+ if err := exec.Command("git", gitSafe("-C", projectDir, "rev-parse", "--git-dir")...).Run(); err != nil {
+ cmds := [][]string{
+ gitSafe("-C", projectDir, "init"),
+ gitSafe("-C", projectDir, "add", "-A"),
+ gitSafe("-C", projectDir, "commit", "--allow-empty", "-m", "chore: initial commit"),
+ }
+ for _, args := range cmds {
+ if out, err := exec.Command("git", args...).CombinedOutput(); err != nil { //nolint:gosec
+ return "", fmt.Errorf("git init %s: %w\n%s", projectDir, err, out)
+ }
+ }
+ }
+
+ src := sandboxCloneSource(projectDir)
+
+ tempDir, err := os.MkdirTemp("", "claudomator-sandbox-*")
+ if err != nil {
+ return "", fmt.Errorf("creating sandbox dir: %w", err)
+ }
+ // git clone requires the target to not exist; remove the placeholder first.
+ if err := os.Remove(tempDir); err != nil {
+ return "", fmt.Errorf("removing temp dir placeholder: %w", err)
+ }
+ out, err := exec.Command("git", gitSafe("clone", "--no-hardlinks", src, tempDir)...).CombinedOutput()
+ if err != nil {
+ return "", fmt.Errorf("git clone: %w\n%s", err, out)
+ }
+ return tempDir, nil
+}
+
+// teardownSandbox verifies the sandbox is clean and pushes new commits to the
+// canonical bare repo. If the push is rejected because another task pushed
+// concurrently, it fetches and rebases then retries once.
+//
+// The working copy (projectDir) is NOT updated automatically — it is the
+// developer's workspace and is pulled manually. This avoids permission errors
+// from mixed-owner .git/objects directories.
+func teardownSandbox(projectDir, sandboxDir, startHEAD string, logger *slog.Logger, execRecord *storage.Execution) error {
+ // Automatically commit uncommitted changes.
+ out, err := exec.Command("git", "-C", sandboxDir, "status", "--porcelain").Output()
+ if err != nil {
+ return fmt.Errorf("git status: %w", err)
+ }
+ if len(strings.TrimSpace(string(out))) > 0 {
+ logger.Info("autocommitting uncommitted changes", "sandbox", sandboxDir)
+
+ // Run build before autocommitting.
+ if _, err := os.Stat(filepath.Join(sandboxDir, "Makefile")); err == nil {
+ logger.Info("running 'make build' before autocommit", "sandbox", sandboxDir)
+ if buildOut, buildErr := exec.Command("make", "-C", sandboxDir, "build").CombinedOutput(); buildErr != nil {
+ return fmt.Errorf("build failed before autocommit: %w\n%s", buildErr, buildOut)
+ }
+ } else if _, err := os.Stat(filepath.Join(sandboxDir, "gradlew")); err == nil {
+ logger.Info("running './gradlew build' before autocommit", "sandbox", sandboxDir)
+ cmd := exec.Command("./gradlew", "build")
+ cmd.Dir = sandboxDir
+ if buildOut, buildErr := cmd.CombinedOutput(); buildErr != nil {
+ return fmt.Errorf("build failed before autocommit: %w\n%s", buildErr, buildOut)
+ }
+ } else if _, err := os.Stat(filepath.Join(sandboxDir, "go.mod")); err == nil {
+ logger.Info("running 'go build ./...' before autocommit", "sandbox", sandboxDir)
+ cmd := exec.Command("go", "build", "./...")
+ cmd.Dir = sandboxDir
+ if buildOut, buildErr := cmd.CombinedOutput(); buildErr != nil {
+ return fmt.Errorf("build failed before autocommit: %w\n%s", buildErr, buildOut)
+ }
+ }
+
+ cmds := [][]string{
+ gitSafe("-C", sandboxDir, "add", "-A"),
+ gitSafe("-C", sandboxDir, "commit", "-m", "chore: autocommit uncommitted changes"),
+ }
+ for _, args := range cmds {
+ if out, err := exec.Command("git", args...).CombinedOutput(); err != nil {
+ return fmt.Errorf("autocommit failed (%v): %w\n%s", args, err, out)
+ }
+ }
+ }
+
+ // Capture commits before pushing/deleting.
+ // Use startHEAD..HEAD to find all commits made during this execution.
+ logRange := "origin/HEAD..HEAD"
+ if startHEAD != "" && startHEAD != "HEAD" {
+ logRange = startHEAD + "..HEAD"
+ }
+
+ logCmd := exec.Command("git", gitSafe("-C", sandboxDir, "log", logRange, "--pretty=format:%H|%s")...)
+ logOut, logErr := logCmd.CombinedOutput()
+ if logErr == nil {
+ lines := strings.Split(strings.TrimSpace(string(logOut)), "\n")
+ logger.Debug("captured commits", "count", len(lines), "range", logRange)
+ for _, line := range lines {
+ if line == "" {
+ continue
+ }
+ parts := strings.SplitN(line, "|", 2)
+ if len(parts) == 2 {
+ execRecord.Commits = append(execRecord.Commits, task.GitCommit{
+ Hash: parts[0],
+ Message: parts[1],
+ })
+ }
+ }
+ } else {
+ logger.Warn("failed to capture commits", "err", logErr, "range", logRange, "output", string(logOut))
+ }
+
+ // Check whether there are any new commits to push.
+ ahead, err := exec.Command("git", gitSafe("-C", sandboxDir, "rev-list", "--count", logRange)...).Output()
+ if err != nil {
+ logger.Warn("could not determine commits ahead of origin; proceeding", "err", err, "range", logRange)
+ }
+ if strings.TrimSpace(string(ahead)) == "0" {
+ os.RemoveAll(sandboxDir)
+ return nil
+ }
+
+ // Push from sandbox → bare repo (sandbox's origin is the bare repo).
+ if out, err := exec.Command("git", "-C", sandboxDir, "push", "origin", "HEAD").CombinedOutput(); err != nil {
+ // If rejected due to concurrent push, fetch+rebase and retry once.
+ if strings.Contains(string(out), "fetch first") || strings.Contains(string(out), "non-fast-forward") {
+ logger.Info("push rejected (concurrent task); rebasing and retrying", "sandbox", sandboxDir)
+ if out2, err2 := exec.Command("git", "-C", sandboxDir, "pull", "--rebase", "origin", "master").CombinedOutput(); err2 != nil {
+ return fmt.Errorf("git rebase before retry push: %w\n%s", err2, out2)
+ }
+ // Re-capture commits after rebase (hashes might have changed)
+ execRecord.Commits = nil
+ logOut, logErr = exec.Command("git", "-C", sandboxDir, "log", logRange, "--pretty=format:%H|%s").Output()
+ if logErr == nil {
+ lines := strings.Split(strings.TrimSpace(string(logOut)), "\n")
+ for _, line := range lines {
+ parts := strings.SplitN(line, "|", 2)
+ if len(parts) == 2 {
+ execRecord.Commits = append(execRecord.Commits, task.GitCommit{
+ Hash: parts[0],
+ Message: parts[1],
+ })
+ }
+ }
+ }
+
+ if out3, err3 := exec.Command("git", "-C", sandboxDir, "push", "origin", "HEAD").CombinedOutput(); err3 != nil {
+ return fmt.Errorf("git push to origin (after rebase): %w\n%s", err3, out3)
+ }
+ } else {
+ return fmt.Errorf("git push to origin: %w\n%s", err, out)
+ }
+ }
+
+ logger.Info("sandbox pushed to bare repo", "sandbox", sandboxDir)
+ os.RemoveAll(sandboxDir)
+ return nil
+}