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 }