diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-03-08 22:51:55 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-03-08 22:51:55 +0000 |
| commit | cfbcc7b921c48fd2eaebcd814b49f3b8a02d7823 (patch) | |
| tree | b1243e37edbb39e13b5d87489ce33c1c33635b15 /internal/executor | |
| parent | f135ab89ce6710a4f20049e6d0d8e914d8e2e402 (diff) | |
executor: push sandbox commits via bare repo, pull into working copy
Instead of git fetch/merge INTO the working copy (which fails with
mixed-owner .git/objects), clone FROM a bare repo, push BACK to it,
then pull into the working copy:
sandbox clone ← bare repo (local remote or origin)
agent commits in sandbox
git push sandbox → bare repo
git pull bare repo → working copy
sandboxCloneSource() prefers a remote named "local" (local bare repo),
then "origin", then falls back to the working copy path.
Set up: git remote add local /site/git.terst.org/repos/claudomator.git
The bare repo was created with: git clone --bare /workspace/claudomator
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/executor')
| -rw-r--r-- | internal/executor/claude.go | 55 |
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 } |
