1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
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
}
|