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 host 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 manager; a
52// child may edit it when the caller intentionally chooses shared mode.
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
208func pathWithin(path, root string) bool {
209 pathAbs, err := containmentPath(path)
210 if err != nil {
211 return false
212 }
213 rootAbs, err := containmentPath(root)
214 if err != nil {
215 return false
216 }
217 rel, err := filepath.Rel(filepath.Clean(rootAbs), filepath.Clean(pathAbs))
218 return err == nil && (rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))))
219}
220
221// containmentPath resolves the nearest existing ancestor so validation also
222// works for durable paths whose final component has not been created yet.
223func containmentPath(path string) (string, error) {
224 absolute, err := filepath.Abs(path)
225 if err != nil {
226 return "", err
227 }
228 absolute = filepath.Clean(absolute)
229 current := absolute
230 var missing []string
231 for {
232 if _, statErr := os.Lstat(current); statErr == nil {
233 evaluated, evalErr := filepath.EvalSymlinks(current)
234 if evalErr != nil {
235 return "", evalErr
236 }
237 for index := len(missing) - 1; index >= 0; index-- {
238 evaluated = filepath.Join(evaluated, missing[index])
239 }
240 return filepath.Clean(evaluated), nil
241 } else if !os.IsNotExist(statErr) {
242 return "", statErr
243 }
244 parent := filepath.Dir(current)
245 if parent == current {
246 return absolute, nil
247 }
248 missing = append(missing, filepath.Base(current))
249 current = parent
250 }
251}
252
253type gitWorktreeHandle struct {
254 root string
255 dir string
256 base string
257 capture CaptureMode
258 agentID string
259}
260
261func (w *gitWorktreeHandle) Dir() string { return w.dir }
262func (w *gitWorktreeHandle) RepositoryRoot() string { return w.root }
263func (w *gitWorktreeHandle) Mode() WorkspaceMode { return WorkspaceWorktree }
264
265func (w *gitWorktreeHandle) Capture(ctx context.Context) (WorkspaceCapture, error) {
266 if ctx == nil {
267 ctx = context.Background()
268 }
269 statusBytes, err := gitOutputBytes(ctx, w.dir, "status", "--porcelain", "-z")
270 if err != nil {
271 return WorkspaceCapture{}, fmt.Errorf("subagents worktree status: %w", err)
272 }
273 changed := changedFilesFromStatus(statusBytes)
274 args := []string{"diff"}
275 if w.capture == CapturePatch || w.capture == "" {
276 args = append(args, "--binary")
277 }
278 args = append(args, w.base, "--")
279 patch, err := gitOutputBytes(ctx, w.dir, args...)
280 if err != nil {
281 return WorkspaceCapture{}, fmt.Errorf("subagents worktree diff: %w", err)
282 }
283
284 // `git diff <base>` does not include untracked files. Add a normal
285 // no-index creation patch for each untracked file so a worktree result can
286 // reproduce new files as well as edits to tracked files.
287 untracked, err := gitOutputBytes(ctx, w.dir, "ls-files", "--others", "--exclude-standard", "-z")
288 if err != nil {
289 return WorkspaceCapture{}, fmt.Errorf("subagents worktree untracked files: %w", err)
290 }
291 for _, name := range splitNUL(untracked) {
292 extra, diffErr := gitUntrackedDiff(ctx, w.dir, name, w.capture == CapturePatch || w.capture == "")
293 if diffErr != nil {
294 return WorkspaceCapture{}, fmt.Errorf("subagents worktree untracked diff %q: %w", name, diffErr)
295 }
296 if len(extra) == 0 {
297 continue
298 }
299 if len(patch) > 0 && patch[len(patch)-1] != '\n' {
300 patch = append(patch, '\n')
301 }
302 patch = append(patch, extra...)
303 }
304 return WorkspaceCapture{Patch: patch, ChangedFiles: changed, Base: w.base}, nil
305}
306
307func (w *gitWorktreeHandle) Cleanup(ctx context.Context) error {
308 if ctx == nil {
309 ctx = context.Background()
310 }
311 if w.dir == "" {
312 return nil
313 }
314 _, err := gitOutput(ctx, w.root, "worktree", "remove", "--force", w.dir)
315 if err != nil {
316 // A failed child can leave a partially registered worktree. Remove
317 // only the isolated path; never delete files in the host checkout,
318 // then prune stale registration metadata.
319 _ = os.RemoveAll(w.dir)
320 _, _ = gitOutput(ctx, w.root, "worktree", "prune")
321 return fmt.Errorf("subagents worktree cleanup: %w", err)
322 }
323 return nil
324}
325
326func PrepareWorkspace(ctx context.Context, req WorkspaceRequest) (WorkspaceHandle, error) {
327 mode := req.Mode
328 if mode == "" {
329 mode = WorkspaceShared
330 }
331 switch mode {
332 case WorkspaceShared:
333 return SharedWorkspace{Root: req.RepositoryRoot}.Prepare(ctx, req)
334 case WorkspaceWorktree:
335 return GitWorktreeWorkspace{}.Prepare(ctx, req)
336 default:
337 return nil, fmt.Errorf("subagents: unknown workspace mode %q", mode)
338 }
339}
340
341func gitOutput(ctx context.Context, dir string, args ...string) (string, error) {
342 b, err := gitOutputBytes(ctx, dir, args...)
343 return strings.TrimSpace(string(b)), err
344}
345
346func gitOutputBytes(ctx context.Context, dir string, args ...string) ([]byte, error) {
347 cmd := exec.CommandContext(ctx, "git", append([]string{"-C", dir}, args...)...)
348 var stdout, stderr bytes.Buffer
349 cmd.Stdout = &stdout
350 cmd.Stderr = &stderr
351 if err := cmd.Run(); err != nil {
352 message := strings.TrimSpace(stderr.String())
353 if message == "" {
354 message = err.Error()
355 }
356 return nil, errors.New(message)
357 }
358 return stdout.Bytes(), nil
359}
360
361func gitUntrackedDiff(ctx context.Context, dir, name string, binary bool) ([]byte, error) {
362 args := []string{"-C", dir, "diff", "--no-index"}
363 if binary {
364 args = append(args, "--binary")
365 }
366 args = append(args, os.DevNull, "--", name)
367 cmd := exec.CommandContext(ctx, "git", args...)
368 var stdout, stderr bytes.Buffer
369 cmd.Stdout = &stdout
370 cmd.Stderr = &stderr
371 err := cmd.Run()
372 if err == nil {
373 return stdout.Bytes(), nil
374 }
375 var exitErr *exec.ExitError
376 if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
377 return stdout.Bytes(), nil
378 }
379 message := strings.TrimSpace(stderr.String())
380 if message == "" {
381 message = err.Error()
382 }
383 return nil, errors.New(message)
384}
385
386func splitNUL(data []byte) []string {
387 parts := strings.Split(string(data), "\x00")
388 out := make([]string, 0, len(parts))
389 for _, part := range parts {
390 if part != "" {
391 out = append(out, part)
392 }
393 }
394 return out
395}
396
397func changedFilesFromStatus(status []byte) []string {
398 var files []string
399 for i := 0; i < len(status); {
400 end := bytes.IndexByte(status[i:], 0)
401 if end < 0 {
402 end = len(status) - i
403 }
404 record := status[i : i+end]
405 i += end + 1
406 if len(record) < 4 {
407 continue
408 }
409 // With `git status --porcelain -z`, rename and copy records contain
410 // the new path in the status record, followed by the old path as the
411 // next NUL-delimited field.
412 path := record[3:]
413 if len(path) == 0 {
414 continue
415 }
416 files = append(files, string(path))
417 isRename := record[0] == 'R' || record[0] == 'C' || record[1] == 'R' || record[1] == 'C'
418 if isRename {
419 if i < len(status) {
420 if end := bytes.IndexByte(status[i:], 0); end >= 0 {
421 i += end + 1
422 } else {
423 i = len(status)
424 }
425 }
426 }
427 }
428 return files
429}