1package subagents
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "fmt"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "strings"
12)
13
14// WorkspaceCapture is durable output collected before an isolated workspace
15// is removed. The patch is intentionally kept as bytes so the caller can
16// write it atomically beside the agent result.
17type WorkspaceCapture struct {
18 Patch []byte
19 ChangedFiles []string
20 Base string
21 Branch string
22}
23
24// WorkspaceRequest describes one workspace allocation.
25type WorkspaceRequest struct {
26 Mode WorkspaceMode
27 RepositoryRoot string
28 StateDir string
29 AgentID string
30 Base string
31 Capture CaptureMode
32 AllowedRoots []string
33
34 // ExistingPath is set only when resuming a detached worktree. A valid
35 // path is reused so uncommitted edits survive a supervisor restart;
36 // invalid or out-of-scope paths are rejected rather than treated as a
37 // request to remove arbitrary files.
38 ExistingPath string
39}
40
41// WorkspaceHandle is the narrow seam used by the manager. SharedWorkspace
42// and GitWorktreeWorkspace are the two supported implementations in v1.
43type WorkspaceHandle interface {
44 Dir() string
45 RepositoryRoot() string
46 Mode() WorkspaceMode
47 Capture(context.Context) (WorkspaceCapture, error)
48 Cleanup(context.Context) error
49}
50
51// SharedWorkspace leaves the host checkout untouched by the supervisor; a
52// child may edit it exactly as older subagent workers did.
53type SharedWorkspace struct {
54 Root string
55}
56
57func (w SharedWorkspace) Prepare(context.Context, WorkspaceRequest) (WorkspaceHandle, error) {
58 root := w.Root
59 if root == "" {
60 return nil, errors.New("subagents: shared workspace root is empty")
61 }
62 return &sharedWorkspaceHandle{root: root}, nil
63}
64
65type sharedWorkspaceHandle struct{ root string }
66
67func (w *sharedWorkspaceHandle) Dir() string { return w.root }
68func (w *sharedWorkspaceHandle) RepositoryRoot() string { return w.root }
69func (w *sharedWorkspaceHandle) Mode() WorkspaceMode { return WorkspaceShared }
70func (w *sharedWorkspaceHandle) Capture(context.Context) (WorkspaceCapture, error) {
71 return WorkspaceCapture{}, nil
72}
73func (w *sharedWorkspaceHandle) Cleanup(context.Context) error { return nil }
74
75// GitWorktreeWorkspace creates a detached temporary worktree from Base and
76// captures its diff before cleanup. It never merges or changes the host
77// checkout's branch.
78type GitWorktreeWorkspace struct{}
79
80func (GitWorktreeWorkspace) Prepare(ctx context.Context, req WorkspaceRequest) (WorkspaceHandle, error) {
81 if ctx == nil {
82 ctx = context.Background()
83 }
84 root, err := filepath.Abs(req.RepositoryRoot)
85 if err != nil {
86 return nil, fmt.Errorf("subagents worktree root: %w", err)
87 }
88 if _, err := os.Stat(root); err != nil {
89 return nil, fmt.Errorf("subagents worktree root: %w", err)
90 }
91 actualRoot, err := gitOutput(ctx, root, "rev-parse", "--show-toplevel")
92 if err != nil {
93 return nil, fmt.Errorf("subagents worktree verify repository: %w", err)
94 }
95 actualRoot = filepath.Clean(actualRoot)
96 if len(req.AllowedRoots) > 0 {
97 allowed := false
98 for _, root := range req.AllowedRoots {
99 if pathWithin(actualRoot, root) {
100 allowed = true
101 break
102 }
103 }
104 if !allowed {
105 return nil, errors.New("subagents worktree repository is outside allowed roots")
106 }
107 }
108 base := strings.TrimSpace(req.Base)
109 if base == "" {
110 base = "HEAD"
111 }
112 if _, err := gitOutput(ctx, actualRoot, "rev-parse", "--verify", base+"^{commit}"); err != nil {
113 return nil, fmt.Errorf("subagents worktree verify base %q: %w", base, err)
114 }
115 stateDir := req.StateDir
116 if stateDir == "" {
117 return nil, errors.New("subagents: worktree state directory is empty")
118 }
119 worktreeDir := filepath.Join(stateDir, "worktree")
120 if err := os.MkdirAll(stateDir, 0o700); err != nil {
121 return nil, fmt.Errorf("subagents worktree state dir: %w", err)
122 }
123 reuse := false
124 if existing := strings.TrimSpace(req.ExistingPath); existing != "" {
125 var err error
126 reuse, err = validateReusableWorktree(ctx, actualRoot, stateDir, worktreeDir, existing)
127 if err != nil {
128 return nil, err
129 }
130 if reuse {
131 worktreeDir = filepath.Clean(existing)
132 }
133 }
134 if !reuse {
135 _ = os.RemoveAll(worktreeDir)
136 _, _ = gitOutput(ctx, actualRoot, "worktree", "prune")
137 if _, err := gitOutput(ctx, actualRoot, "worktree", "add", "--detach", worktreeDir, base); err != nil {
138 _ = os.RemoveAll(worktreeDir)
139 _, _ = gitOutput(ctx, actualRoot, "worktree", "prune")
140 return nil, fmt.Errorf("subagents worktree create: %w", err)
141 }
142 }
143 return &gitWorktreeHandle{
144 root: actualRoot,
145 dir: worktreeDir,
146 base: base,
147 capture: req.Capture,
148 agentID: req.AgentID,
149 }, nil
150}
151
152func validateReusableWorktree(ctx context.Context, repositoryRoot, stateDir, expectedPath, existingPath string) (bool, error) {
153 if filepath.Clean(existingPath) != filepath.Clean(expectedPath) {
154 return false, errors.New("subagents worktree resume path is outside the agent state directory")
155 }
156 info, err := os.Stat(existingPath)
157 if errors.Is(err, os.ErrNotExist) {
158 return false, nil
159 }
160 if err != nil {
161 return false, fmt.Errorf("subagents worktree resume path: %w", err)
162 }
163 if !info.IsDir() {
164 return false, errors.New("subagents worktree resume path is not a directory")
165 }
166 stateCanonical, err := containmentPath(stateDir)
167 if err != nil {
168 return false, fmt.Errorf("subagents worktree resume state path: %w", err)
169 }
170 expectedCanonical, err := containmentPath(expectedPath)
171 if err != nil {
172 return false, fmt.Errorf("subagents worktree resume expected path: %w", err)
173 }
174 existingCanonical, err := containmentPath(existingPath)
175 if err != nil {
176 return false, fmt.Errorf("subagents worktree resume path: %w", err)
177 }
178 if existingCanonical != expectedCanonical || !pathWithin(existingCanonical, stateCanonical) {
179 return false, errors.New("subagents worktree resume path escapes the agent state directory")
180 }
181 top, err := gitOutput(ctx, existingPath, "rev-parse", "--show-toplevel")
182 if err != nil {
183 return false, fmt.Errorf("subagents worktree resume verify checkout: %w", err)
184 }
185 topCanonical, err := containmentPath(top)
186 if err != nil {
187 return false, fmt.Errorf("subagents worktree resume checkout path: %w", err)
188 }
189 if topCanonical != existingCanonical {
190 return false, errors.New("subagents worktree resume checkout path mismatch")
191 }
192 registered, err := gitOutput(ctx, repositoryRoot, "worktree", "list", "--porcelain")
193 if err != nil {
194 return false, fmt.Errorf("subagents worktree resume list: %w", err)
195 }
196 for _, line := range strings.Split(registered, "\n") {
197 if !strings.HasPrefix(line, "worktree ") {
198 continue
199 }
200 registeredPath, pathErr := containmentPath(strings.TrimPrefix(line, "worktree "))
201 if pathErr == nil && registeredPath == existingCanonical {
202 return true, nil
203 }
204 }
205 return false, errors.New("subagents worktree resume checkout is not registered with Git")
206}
207
208type gitWorktreeHandle struct {
209 root string
210 dir string
211 base string
212 capture CaptureMode
213 agentID string
214}
215
216func (w *gitWorktreeHandle) Dir() string { return w.dir }
217func (w *gitWorktreeHandle) RepositoryRoot() string { return w.root }
218func (w *gitWorktreeHandle) Mode() WorkspaceMode { return WorkspaceWorktree }
219
220func (w *gitWorktreeHandle) Capture(ctx context.Context) (WorkspaceCapture, error) {
221 if ctx == nil {
222 ctx = context.Background()
223 }
224 statusBytes, err := gitOutputBytes(ctx, w.dir, "status", "--porcelain", "-z")
225 if err != nil {
226 return WorkspaceCapture{}, fmt.Errorf("subagents worktree status: %w", err)
227 }
228 changed := changedFilesFromStatus(statusBytes)
229 args := []string{"diff"}
230 if w.capture == CapturePatch || w.capture == "" {
231 args = append(args, "--binary")
232 }
233 args = append(args, w.base, "--")
234 patch, err := gitOutputBytes(ctx, w.dir, args...)
235 if err != nil {
236 return WorkspaceCapture{}, fmt.Errorf("subagents worktree diff: %w", err)
237 }
238
239 // `git diff <base>` does not include untracked files. Add a normal
240 // no-index creation patch for each untracked file so a worktree result can
241 // reproduce new files as well as edits to tracked files.
242 untracked, err := gitOutputBytes(ctx, w.dir, "ls-files", "--others", "--exclude-standard", "-z")
243 if err != nil {
244 return WorkspaceCapture{}, fmt.Errorf("subagents worktree untracked files: %w", err)
245 }
246 for _, name := range splitNUL(untracked) {
247 extra, diffErr := gitUntrackedDiff(ctx, w.dir, name, w.capture == CapturePatch || w.capture == "")
248 if diffErr != nil {
249 return WorkspaceCapture{}, fmt.Errorf("subagents worktree untracked diff %q: %w", name, diffErr)
250 }
251 if len(extra) == 0 {
252 continue
253 }
254 if len(patch) > 0 && patch[len(patch)-1] != '\n' {
255 patch = append(patch, '\n')
256 }
257 patch = append(patch, extra...)
258 }
259 return WorkspaceCapture{Patch: patch, ChangedFiles: changed, Base: w.base}, nil
260}
261
262func (w *gitWorktreeHandle) Cleanup(ctx context.Context) error {
263 if ctx == nil {
264 ctx = context.Background()
265 }
266 if w.dir == "" {
267 return nil
268 }
269 _, err := gitOutput(ctx, w.root, "worktree", "remove", "--force", w.dir)
270 if err != nil {
271 // A failed child can leave a partially registered worktree. Remove
272 // only the isolated path; never delete files in the host checkout,
273 // then prune stale registration metadata.
274 _ = os.RemoveAll(w.dir)
275 _, _ = gitOutput(ctx, w.root, "worktree", "prune")
276 return fmt.Errorf("subagents worktree cleanup: %w", err)
277 }
278 return nil
279}
280
281func PrepareWorkspace(ctx context.Context, req WorkspaceRequest) (WorkspaceHandle, error) {
282 mode := req.Mode
283 if mode == "" {
284 mode = WorkspaceShared
285 }
286 switch mode {
287 case WorkspaceShared:
288 return SharedWorkspace{Root: req.RepositoryRoot}.Prepare(ctx, req)
289 case WorkspaceWorktree:
290 return GitWorktreeWorkspace{}.Prepare(ctx, req)
291 default:
292 return nil, fmt.Errorf("subagents: unknown workspace mode %q", mode)
293 }
294}
295
296func gitOutput(ctx context.Context, dir string, args ...string) (string, error) {
297 b, err := gitOutputBytes(ctx, dir, args...)
298 return strings.TrimSpace(string(b)), err
299}
300
301func gitOutputBytes(ctx context.Context, dir string, args ...string) ([]byte, error) {
302 cmd := exec.CommandContext(ctx, "git", append([]string{"-C", dir}, args...)...)
303 var stdout, stderr bytes.Buffer
304 cmd.Stdout = &stdout
305 cmd.Stderr = &stderr
306 if err := cmd.Run(); err != nil {
307 message := strings.TrimSpace(stderr.String())
308 if message == "" {
309 message = err.Error()
310 }
311 return nil, errors.New(message)
312 }
313 return stdout.Bytes(), nil
314}
315
316func gitUntrackedDiff(ctx context.Context, dir, name string, binary bool) ([]byte, error) {
317 args := []string{"-C", dir, "diff", "--no-index"}
318 if binary {
319 args = append(args, "--binary")
320 }
321 args = append(args, os.DevNull, "--", name)
322 cmd := exec.CommandContext(ctx, "git", args...)
323 var stdout, stderr bytes.Buffer
324 cmd.Stdout = &stdout
325 cmd.Stderr = &stderr
326 err := cmd.Run()
327 if err == nil {
328 return stdout.Bytes(), nil
329 }
330 var exitErr *exec.ExitError
331 if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
332 return stdout.Bytes(), nil
333 }
334 message := strings.TrimSpace(stderr.String())
335 if message == "" {
336 message = err.Error()
337 }
338 return nil, errors.New(message)
339}
340
341func splitNUL(data []byte) []string {
342 parts := strings.Split(string(data), "\x00")
343 out := make([]string, 0, len(parts))
344 for _, part := range parts {
345 if part != "" {
346 out = append(out, part)
347 }
348 }
349 return out
350}
351
352func changedFilesFromStatus(status []byte) []string {
353 var files []string
354 for i := 0; i < len(status); {
355 end := bytes.IndexByte(status[i:], 0)
356 if end < 0 {
357 end = len(status) - i
358 }
359 record := status[i : i+end]
360 i += end + 1
361 if len(record) < 4 {
362 continue
363 }
364 // With `git status --porcelain -z`, rename and copy records contain
365 // the new path in the status record, followed by the old path as the
366 // next NUL-delimited field.
367 path := record[3:]
368 if len(path) == 0 {
369 continue
370 }
371 files = append(files, string(path))
372 isRename := record[0] == 'R' || record[0] == 'C' || record[1] == 'R' || record[1] == 'C'
373 if isRename {
374 if i < len(status) {
375 if end := bytes.IndexByte(status[i:], 0); end >= 0 {
376 i += end + 1
377 } else {
378 i = len(status)
379 }
380 }
381 }
382 }
383 return files
384}