summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/executor/container.go56
-rw-r--r--internal/executor/container_test.go101
2 files changed, 157 insertions, 0 deletions
diff --git a/internal/executor/container.go b/internal/executor/container.go
index 2c5b7d3..bfce750 100644
--- a/internal/executor/container.go
+++ b/internal/executor/container.go
@@ -271,6 +271,11 @@ func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Exec
return fmt.Errorf("git push failed: %w\n%s", err, string(out))
}
} else {
+ // No commits pushed — check whether the agent left uncommitted work behind.
+ // If so, fail loudly: the work would be silently lost when the sandbox is deleted.
+ if err := detectUncommittedChanges(workspace); err != nil {
+ return err
+ }
r.Logger.Info("no new commits to push", "taskID", t.ID)
}
success = true
@@ -349,3 +354,54 @@ func (r *ContainerRunner) buildInnerCmd(t *task.Task, e *storage.Execution, isRe
return []string{"sh", "-c", claudeCmd.String()}
}
+// scaffoldPrefixes are files/dirs written by the harness into the workspace before the agent
+// runs. They are not part of the repo and must not trigger the uncommitted-changes check.
+var scaffoldPrefixes = []string{
+ ".claudomator-env",
+ ".claudomator-instructions.txt",
+ ".agent-home",
+}
+
+func isScaffold(path string) bool {
+ for _, p := range scaffoldPrefixes {
+ if path == p || strings.HasPrefix(path, p+"/") {
+ return true
+ }
+ }
+ return false
+}
+
+// detectUncommittedChanges returns an error if the workspace contains modified or
+// untracked source files that the agent forgot to commit. Scaffold files written by
+// the harness (.claudomator-env, .claudomator-instructions.txt, .agent-home/) are
+// excluded from the check.
+func detectUncommittedChanges(workspace string) error {
+ // Modified or staged tracked files
+ diffOut, err := exec.Command("git", "-c", "safe.directory=*", "-C", workspace,
+ "diff", "--name-only", "HEAD").CombinedOutput()
+ if err == nil {
+ for _, line := range strings.Split(strings.TrimSpace(string(diffOut)), "\n") {
+ if line != "" && !isScaffold(line) {
+ return fmt.Errorf("agent left uncommitted changes (work would be lost on sandbox deletion):\n%s\nInstructions must include: git add -A && git commit && git push origin master", strings.TrimSpace(string(diffOut)))
+ }
+ }
+ }
+
+ // Untracked new source files (excludes gitignored files)
+ lsOut, err := exec.Command("git", "-c", "safe.directory=*", "-C", workspace,
+ "ls-files", "--others", "--exclude-standard").CombinedOutput()
+ if err == nil {
+ var dirty []string
+ for _, line := range strings.Split(strings.TrimSpace(string(lsOut)), "\n") {
+ if line != "" && !isScaffold(line) {
+ dirty = append(dirty, line)
+ }
+ }
+ if len(dirty) > 0 {
+ return fmt.Errorf("agent left untracked files not committed (work would be lost on sandbox deletion):\n%s\nInstructions must include: git add -A && git commit && git push origin master", strings.Join(dirty, "\n"))
+ }
+ }
+
+ return nil
+}
+
diff --git a/internal/executor/container_test.go b/internal/executor/container_test.go
index f97f2b5..be80b51 100644
--- a/internal/executor/container_test.go
+++ b/internal/executor/container_test.go
@@ -230,6 +230,107 @@ func TestTailFile_ReturnsLastNLines(t *testing.T) {
}
}
+func TestDetectUncommittedChanges_ModifiedFile(t *testing.T) {
+ dir := t.TempDir()
+ run := func(args ...string) {
+ cmd := exec.Command(args[0], args[1:]...)
+ cmd.Dir = dir
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("%v: %s", args, out)
+ }
+ }
+ run("git", "init", dir)
+ run("git", "config", "user.email", "test@test.com")
+ run("git", "config", "user.name", "Test")
+ // Create and commit a file
+ if err := os.WriteFile(dir+"/main.go", []byte("package main"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ run("git", "add", "main.go")
+ run("git", "commit", "-m", "init")
+ // Now modify without committing — simulates agent that forgot to commit
+ if err := os.WriteFile(dir+"/main.go", []byte("package main\n// changed"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ err := detectUncommittedChanges(dir)
+ if err == nil {
+ t.Fatal("expected error for modified uncommitted file, got nil")
+ }
+ if !strings.Contains(err.Error(), "uncommitted") {
+ t.Errorf("error should mention uncommitted, got: %v", err)
+ }
+}
+
+func TestDetectUncommittedChanges_NewUntrackedSourceFile(t *testing.T) {
+ dir := t.TempDir()
+ run := func(args ...string) {
+ cmd := exec.Command(args[0], args[1:]...)
+ cmd.Dir = dir
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("%v: %s", args, out)
+ }
+ }
+ run("git", "init", dir)
+ run("git", "config", "user.email", "test@test.com")
+ run("git", "config", "user.name", "Test")
+ run("git", "commit", "--allow-empty", "-m", "init")
+ // Agent wrote a new file but never committed it
+ if err := os.WriteFile(dir+"/newfile.go", []byte("package main"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ err := detectUncommittedChanges(dir)
+ if err == nil {
+ t.Fatal("expected error for new untracked source file, got nil")
+ }
+}
+
+func TestDetectUncommittedChanges_ScaffoldFilesIgnored(t *testing.T) {
+ dir := t.TempDir()
+ run := func(args ...string) {
+ cmd := exec.Command(args[0], args[1:]...)
+ cmd.Dir = dir
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("%v: %s", args, out)
+ }
+ }
+ run("git", "init", dir)
+ run("git", "config", "user.email", "test@test.com")
+ run("git", "config", "user.name", "Test")
+ run("git", "commit", "--allow-empty", "-m", "init")
+ // Write only scaffold files that the harness injects — should not trigger error
+ _ = os.WriteFile(dir+"/.claudomator-env", []byte("KEY=val"), 0600)
+ _ = os.WriteFile(dir+"/.claudomator-instructions.txt", []byte("do stuff"), 0644)
+ _ = os.MkdirAll(dir+"/.agent-home/.claude", 0755)
+ err := detectUncommittedChanges(dir)
+ if err != nil {
+ t.Errorf("scaffold files should not trigger uncommitted error, got: %v", err)
+ }
+}
+
+func TestDetectUncommittedChanges_CleanRepo(t *testing.T) {
+ dir := t.TempDir()
+ run := func(args ...string) {
+ cmd := exec.Command(args[0], args[1:]...)
+ cmd.Dir = dir
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("%v: %s", args, out)
+ }
+ }
+ run("git", "init", dir)
+ run("git", "config", "user.email", "test@test.com")
+ run("git", "config", "user.name", "Test")
+ if err := os.WriteFile(dir+"/main.go", []byte("package main"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ run("git", "add", "main.go")
+ run("git", "commit", "-m", "init")
+ // No modifications — should pass
+ err := detectUncommittedChanges(dir)
+ if err != nil {
+ t.Errorf("clean repo should not error, got: %v", err)
+ }
+}
+
func TestGitSafe_PrependsSafeDirectory(t *testing.T) {
got := gitSafe("-C", "/some/path", "status")
want := []string{"-c", "safe.directory=*", "-C", "/some/path", "status"}