v0.45.0 cb619f7

manage_worktrees.go RawHistoryFolder

1package tools
2
3import (
4	"context"
5	"encoding/json"
6	"errors"
7	"fmt"
8	"os"
9	"path/filepath"
10	"strings"
11	"sync"
12
13	"github.com/bnema/zut/packages/core"
14	"github.com/bnema/zut/packages/provider"
15)
16
17// ManageWorktreesTool inventories repository worktrees and removes only clean,
18// merged, explicitly selected secondary worktrees.
19type ManageWorktreesTool struct {
20	CWD     string
21	Sandbox *Sandbox
22
23	previewMu sync.Mutex
24	preview   *manageWorktreesPreview
25}
26
27type manageWorktreesArgs struct {
28	Action string   `json:"action"`
29	Paths  []string `json:"paths,omitempty"`
30}
31
32type managedWorktree struct {
33	Path      string `json:"path"`
34	Branch    string `json:"branch,omitempty"`
35	Head      string `json:"head,omitempty"`
36	Primary   bool   `json:"primary"`
37	Dirty     bool   `json:"dirty"`
38	Prunable  bool   `json:"prunable"`
39	Merged    bool   `json:"merged"`
40	CleanupOK bool   `json:"cleanup_eligible"`
41	BlockedBy string `json:"blocked_by,omitempty"`
42}
43
44type manageWorktreesPlan struct {
45	repoRoot string
46	action   string
47	selected []managedWorktree
48	all      []managedWorktree
49	text     string
50}
51
52type manageWorktreesPreview struct {
53	raw  string
54	plan manageWorktreesPlan
55}
56
57const manageWorktreesSchema = `{
58  "type":"object",
59  "properties":{
60    "action":{"type":"string","enum":["list","cleanup"],"description":"List repository worktrees, or remove explicitly selected worktrees that are clean and merged."},
61    "paths":{"type":"array","items":{"type":"string"},"description":"Exact worktree paths to remove. Required for cleanup."}
62  },
63  "required":["action"]
64}`
65
66func (t *ManageWorktreesTool) Name() string { return "manage_worktrees" }
67func (t *ManageWorktreesTool) Description() string {
68	return "List repository worktrees and safely remove explicitly selected clean, merged worktrees."
69}
70func (t *ManageWorktreesTool) Schema() json.RawMessage { return json.RawMessage(manageWorktreesSchema) }
71
72func (t *ManageWorktreesTool) Preview(ctx context.Context, raw json.RawMessage) (core.ToolResult, error) {
73	plan, err := t.plan(ctx, raw)
74	if err != nil {
75		return core.ToolResult{}, err
76	}
77	t.previewMu.Lock()
78	t.preview = &manageWorktreesPreview{raw: string(raw), plan: plan}
79	t.previewMu.Unlock()
80	return manageWorktreesResult(plan, "preview"), nil
81}
82
83func (t *ManageWorktreesTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
84	plan, err := t.plan(ctx, raw)
85	if err != nil {
86		return core.ToolResult{}, err
87	}
88	t.previewMu.Lock()
89	cached := t.preview
90	t.preview = nil
91	t.previewMu.Unlock()
92	if cached != nil && cached.raw == string(raw) && !sameManageWorktreesPlan(cached.plan, plan) {
93		return core.ToolResult{}, errors.New("manage_worktrees: repository state changed since preview; inspect and retry")
94	}
95	if plan.action == "list" {
96		return manageWorktreesResult(plan, "listed"), nil
97	}
98	for _, worktree := range plan.selected {
99		if progress != nil {
100			progress("Removing worktree " + filepath.ToSlash(worktree.Path) + "...\n")
101		}
102		if _, err := manageWorktreesGit(ctx, plan.repoRoot, "worktree", "remove", worktree.Path); err != nil {
103			return core.ToolResult{}, fmt.Errorf("manage_worktrees: remove %q: %w", filepath.ToSlash(worktree.Path), err)
104		}
105	}
106	_, _ = manageWorktreesGit(ctx, plan.repoRoot, "worktree", "prune")
107	return manageWorktreesResult(plan, "removed"), nil
108}
109
110func (t *ManageWorktreesTool) plan(ctx context.Context, raw json.RawMessage) (manageWorktreesPlan, error) {
111	var args manageWorktreesArgs
112	if err := json.Unmarshal(raw, &args); err != nil {
113		return manageWorktreesPlan{}, fmt.Errorf("invalid args: %w", err)
114	}
115	if args.Action != "list" && args.Action != "cleanup" {
116		return manageWorktreesPlan{}, errors.New("manage_worktrees: action must be list or cleanup")
117	}
118	if t.Sandbox == nil {
119		return manageWorktreesPlan{}, errors.New("manage_worktrees: sandbox is required")
120	}
121	if err := t.Sandbox.CheckBashPermission("git"); err != nil {
122		return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: %w", err)
123	}
124	cwd := t.CWD
125	if cwd == "" {
126		var err error
127		cwd, err = os.Getwd()
128		if err != nil {
129			return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: working directory: %w", err)
130		}
131	}
132	if err := t.Sandbox.CheckReadPath(cwd); err != nil {
133		return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: %w", err)
134	}
135	repoRoot, err := manageWorktreesGit(ctx, cwd, "rev-parse", "--show-toplevel")
136	if err != nil {
137		return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: verify repository: %w", err)
138	}
139	repoRoot, err = filepath.Abs(repoRoot)
140	if err != nil {
141		return manageWorktreesPlan{}, err
142	}
143	if err := t.Sandbox.CheckReadPath(repoRoot); err != nil {
144		return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: %w", err)
145	}
146	items, err := inspectManagedWorktrees(ctx, repoRoot)
147	if err != nil {
148		return manageWorktreesPlan{}, err
149	}
150	plan := manageWorktreesPlan{repoRoot: repoRoot, action: args.Action, all: items}
151	if args.Action == "cleanup" {
152		if len(args.Paths) == 0 {
153			return manageWorktreesPlan{}, errors.New("manage_worktrees: cleanup requires at least one explicit path")
154		}
155		byPath := make(map[string]managedWorktree, len(items))
156		for _, item := range items {
157			canonicalPath, err := canonicalOrParent(item.Path)
158			if err != nil {
159				return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: resolve registered path: %w", err)
160			}
161			byPath[canonicalPath] = item
162		}
163		seen := map[string]bool{}
164		for _, requested := range args.Paths {
165			if !filepath.IsAbs(requested) {
166				requested = filepath.Join(cwd, requested)
167			}
168			path, err := canonicalOrParent(requested)
169			if err != nil {
170				return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: resolve path: %w", err)
171			}
172			item, ok := byPath[path]
173			if !ok {
174				return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: %q is not a registered worktree", filepath.ToSlash(path))
175			}
176			if !item.CleanupOK {
177				return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: %q cannot be removed: %s", filepath.ToSlash(path), item.BlockedBy)
178			}
179			if err := t.Sandbox.CheckWritePath(item.Path); err != nil {
180				return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: %w", err)
181			}
182			if !seen[item.Path] {
183				plan.selected = append(plan.selected, item)
184				seen[item.Path] = true
185			}
186		}
187	}
188	plan.text = renderManagedWorktrees(plan)
189	return plan, nil
190}
191
192// WorktreeInventoryContext returns a model-readable repository inventory. A
193// non-Git working directory has no inventory and is not an error.
194func WorktreeInventoryContext(ctx context.Context, cwd string) string {
195	root, err := manageWorktreesGit(ctx, cwd, "rev-parse", "--show-toplevel")
196	if err != nil {
197		return ""
198	}
199	output, err := manageWorktreesGit(ctx, root, "worktree", "list", "--porcelain")
200	if err != nil {
201		return ""
202	}
203	const maxInventoryItems = 20
204	var b strings.Builder
205	b.WriteString("[Repository worktrees]\n")
206	count := 0
207	for _, block := range strings.Split(strings.TrimSpace(output), "\n\n") {
208		if count == maxInventoryItems {
209			break
210		}
211		path, branch := "", ""
212		for _, line := range strings.Split(block, "\n") {
213			if strings.HasPrefix(line, "worktree ") {
214				path = strings.TrimPrefix(line, "worktree ")
215			}
216			if strings.HasPrefix(line, "branch refs/heads/") {
217				branch = strings.TrimPrefix(line, "branch refs/heads/")
218			}
219		}
220		if path == "" {
221			continue
222		}
223		fmt.Fprintf(&b, "- %s", filepath.ToSlash(path))
224		if branch != "" {
225			fmt.Fprintf(&b, " [%s]", branch)
226		}
227		b.WriteByte('\n')
228		count++
229	}
230	total := len(strings.Split(strings.TrimSpace(output), "\n\n"))
231	if total <= 1 {
232		return ""
233	}
234	if total > count {
235		fmt.Fprintf(&b, "- ... %d more worktrees\n", total-count)
236	}
237	b.WriteString("Use worktree action=list for current safety status before cleanup.")
238	return b.String()
239}
240
241func inspectManagedWorktrees(ctx context.Context, repoRoot string) ([]managedWorktree, error) {
242	output, err := manageWorktreesGit(ctx, repoRoot, "worktree", "list", "--porcelain")
243	if err != nil {
244		return nil, fmt.Errorf("manage_worktrees: list: %w", err)
245	}
246	blocks := strings.Split(strings.TrimSpace(output), "\n\n")
247	items := make([]managedWorktree, 0, len(blocks))
248	for index, block := range blocks {
249		item := managedWorktree{Primary: index == 0}
250		for _, line := range strings.Split(block, "\n") {
251			switch {
252			case strings.HasPrefix(line, "worktree "):
253				item.Path = strings.TrimPrefix(line, "worktree ")
254			case strings.HasPrefix(line, "HEAD "):
255				item.Head = strings.TrimPrefix(line, "HEAD ")
256			case strings.HasPrefix(line, "branch refs/heads/"):
257				item.Branch = strings.TrimPrefix(line, "branch refs/heads/")
258			case strings.HasPrefix(line, "prunable"):
259				item.Prunable = true
260			}
261		}
262		if item.Path == "" {
263			continue
264		}
265		if item.Primary {
266			item.BlockedBy = "primary worktree"
267		} else if item.Prunable {
268			item.BlockedBy = "missing worktree; prune metadata instead"
269		} else if item.Branch == "" {
270			item.BlockedBy = "detached HEAD"
271		} else if _, submoduleErr := os.Stat(filepath.Join(item.Path, ".gitmodules")); submoduleErr == nil {
272			item.BlockedBy = "worktree contains submodules"
273		} else if !os.IsNotExist(submoduleErr) {
274			item.BlockedBy = "cannot inspect submodules: " + firstErrorLine(submoduleErr)
275		} else {
276			status, statusErr := manageWorktreesGit(ctx, item.Path, "status", "--porcelain", "--ignored=matching")
277			if statusErr != nil {
278				item.BlockedBy = "status unavailable: " + firstErrorLine(statusErr)
279			} else if status != "" {
280				item.Dirty = true
281				item.BlockedBy = "uncommitted changes"
282			} else if _, mergeErr := manageWorktreesGit(ctx, repoRoot, "merge-base", "--is-ancestor", item.Head, "HEAD"); mergeErr != nil {
283				item.BlockedBy = "branch is not merged into the current branch: " + firstErrorLine(mergeErr)
284			} else {
285				item.Merged = true
286				item.CleanupOK = true
287			}
288		}
289		items = append(items, item)
290	}
291	return items, nil
292}
293
294func renderManagedWorktrees(plan manageWorktreesPlan) string {
295	var b strings.Builder
296	if plan.action == "cleanup" {
297		b.WriteString("remove worktrees\n")
298	} else {
299		b.WriteString("repository worktrees\n")
300	}
301	for _, item := range plan.all {
302		fmt.Fprintf(&b, "- %s", filepath.ToSlash(item.Path))
303		if item.Branch != "" {
304			fmt.Fprintf(&b, " [%s]", item.Branch)
305		}
306		state := item.BlockedBy
307		if item.CleanupOK {
308			state = "clean and merged; cleanup eligible"
309		}
310		if state != "" {
311			fmt.Fprintf(&b, ": %s", state)
312		}
313		b.WriteByte('\n')
314	}
315	if plan.action == "cleanup" {
316		b.WriteString("selected:\n")
317		for _, item := range plan.selected {
318			fmt.Fprintf(&b, "- %s\n", filepath.ToSlash(item.Path))
319		}
320	}
321	return b.String()
322}
323
324func manageWorktreesResult(plan manageWorktreesPlan, state string) core.ToolResult {
325	return core.ToolResult{
326		Content: []provider.Content{provider.TextBlock{Text: plan.text}},
327		Details: map[string]any{"state": state, "worktrees": plan.all},
328	}
329}
330
331func sameManageWorktreesPlan(a, b manageWorktreesPlan) bool {
332	if a.repoRoot != b.repoRoot || a.action != b.action || a.text != b.text || len(a.selected) != len(b.selected) || len(a.all) != len(b.all) {
333		return false
334	}
335	for index := range a.selected {
336		if a.selected[index] != b.selected[index] {
337			return false
338		}
339	}
340	for index := range a.all {
341		if a.all[index] != b.all[index] {
342			return false
343		}
344	}
345	return true
346}
347
348func firstErrorLine(err error) string {
349	line, _, _ := strings.Cut(err.Error(), "\n")
350	return line
351}
352
353func manageWorktreesGit(ctx context.Context, dir string, args ...string) (string, error) {
354	return createWorktreeGitOutput(ctx, dir, args...)
355}

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