v0.16.0 042538b

workspace.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close