v0.38.0 58133ea

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 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}

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 476

↑↓ moveenter openesc close