summaryrefslogtreecommitdiff
path: root/internal/executor/claude.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/executor/claude.go')
-rw-r--r--internal/executor/claude.go55
1 files changed, 33 insertions, 22 deletions
diff --git a/internal/executor/claude.go b/internal/executor/claude.go
index d8032ab..d6d92cb 100644
--- a/internal/executor/claude.go
+++ b/internal/executor/claude.go
@@ -143,13 +143,24 @@ func (r *ClaudeRunner) Run(ctx context.Context, t *task.Task, e *storage.Executi
return nil
}
+// 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", "-C", projectDir, "remote", "get-url", remote).Output()
+ if err == nil && len(strings.TrimSpace(string(out))) > 0 {
+ return strings.TrimSpace(string(out))
+ }
+ }
+ 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) (string, error) {
// Ensure projectDir is a git repo; initialise if not.
- check := exec.Command("git", "-C", projectDir, "rev-parse", "--git-dir")
- if err := check.Run(); err != nil {
- // Not a git repo — init and commit everything.
+ if err := exec.Command("git", "-C", projectDir, "rev-parse", "--git-dir").Run(); err != nil {
cmds := [][]string{
{"git", "-C", projectDir, "init"},
{"git", "-C", projectDir, "add", "-A"},
@@ -162,25 +173,27 @@ func setupSandbox(projectDir string) (string, error) {
}
}
+ src := sandboxCloneSource(projectDir)
+
tempDir, err := os.MkdirTemp("", "claudomator-sandbox-*")
if err != nil {
return "", fmt.Errorf("creating sandbox dir: %w", err)
}
-
- // Clone into the pre-created dir (git clone requires the target to not exist,
- // so remove it first and let git recreate it).
+ // 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", "clone", "--no-hardlinks", projectDir, tempDir).CombinedOutput()
+ out, err := exec.Command("git", "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, merges commits back to
-// projectDir via fast-forward, then removes the sandbox.
+// teardownSandbox verifies the sandbox is clean, pushes new commits to the
+// canonical remote (the bare repo), then fast-forward pulls them into the
+// working copy. This avoids writing git objects directly into the working copy
+// which causes permission errors on shared/mixed-owner repos.
func teardownSandbox(projectDir, sandboxDir string, logger *slog.Logger) error {
// Fail if agent left uncommitted changes.
out, err := exec.Command("git", "-C", sandboxDir, "status", "--porcelain").Output()
@@ -191,30 +204,28 @@ func teardownSandbox(projectDir, sandboxDir string, logger *slog.Logger) error {
return fmt.Errorf("uncommitted changes in sandbox (agent must commit all work):\n%s", out)
}
- // Check whether there are any new commits to merge.
+ // Check whether there are any new commits.
ahead, err := exec.Command("git", "-C", sandboxDir, "rev-list", "--count", "origin/HEAD..HEAD").Output()
if err != nil {
- // No origin/HEAD (e.g. fresh init with no prior commits) — proceed anyway.
- logger.Warn("could not determine commits ahead of origin; proceeding with merge", "err", err)
+ logger.Warn("could not determine commits ahead of origin; proceeding", "err", err)
}
if strings.TrimSpace(string(ahead)) == "0" {
- // Nothing to merge — clean up and return.
os.RemoveAll(sandboxDir)
return nil
}
- // Fetch new commits from sandbox into project_dir and fast-forward merge.
- // Use file:// prefix to force pack-protocol transfer instead of the local
- // optimization that hard-links objects — hard-linking fails across devices
- // and can fail with permission errors when the repo has mixed-owner objects.
- if out, err := exec.Command("git", "-C", projectDir, "fetch", "file://"+sandboxDir, "HEAD").CombinedOutput(); err != nil {
- return fmt.Errorf("git fetch from sandbox: %w\n%s", err, out)
+ // 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 {
+ return fmt.Errorf("git push to origin: %w\n%s", err, out)
}
- if out, err := exec.Command("git", "-C", projectDir, "merge", "--ff-only", "FETCH_HEAD").CombinedOutput(); err != nil {
- return fmt.Errorf("git merge --ff-only FETCH_HEAD: %w\n%s", err, out)
+
+ // Pull bare repo → working copy.
+ src := sandboxCloneSource(projectDir)
+ if out, err := exec.Command("git", "-C", projectDir, "pull", "--ff-only", src).CombinedOutput(); err != nil {
+ return fmt.Errorf("git pull into project_dir: %w\n%s", err, out)
}
- logger.Info("sandbox merged and cleaned up", "sandbox", sandboxDir, "project_dir", projectDir)
+ logger.Info("sandbox pushed and working copy updated", "sandbox", sandboxDir, "project_dir", projectDir)
os.RemoveAll(sandboxDir)
return nil
}