v0.51.0 a9d674e

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	Into         string   `json:"into,omitempty"`
31	DeleteBranch bool     `json:"delete_branch,omitempty"`
32}
33
34type managedWorktree struct {
35	Path      string `json:"path"`
36	Branch    string `json:"branch,omitempty"`
37	Head      string `json:"head,omitempty"`
38	Primary   bool   `json:"primary"`
39	Dirty     bool   `json:"dirty"`
40	Prunable  bool   `json:"prunable"`
41	Merged    bool   `json:"merged"`
42	CleanupOK bool   `json:"cleanup_eligible"`
43	BlockedBy string `json:"blocked_by,omitempty"`
44}
45
46type manageWorktreesPlan struct {
47	repoRoot string
48	action   string
49	// into is the requested merge target; empty means the current HEAD.
50	into         string
51	deleteBranch bool
52	// intoCommit and intoRef pin the resolved merge target so a target that
53	// moves between preview and execute is detected. intoRef is the symbolic
54	// full name (empty for a commit or detached HEAD).
55	intoCommit string
56	intoRef    string
57	selected   []managedWorktree
58	all        []managedWorktree
59	text       string
60}
61
62type manageWorktreesPreview struct {
63	raw  string
64	plan manageWorktreesPlan
65}
66
67const manageWorktreesSchema = `{
68  "type":"object",
69  "properties":{
70    "action":{"type":"string","enum":["list","cleanup"],"description":"List repository worktrees, or remove explicitly selected worktrees that are clean and merged."},
71    "paths":{"type":"array","items":{"type":"string"},"description":"Exact worktree paths to remove. Required for cleanup."},
72    "into":{"type":"string","description":"Branch, tag, or commit the worktree branches must be merged into. Defaults to the current HEAD."},
73    "delete_branch":{"type":"boolean","description":"Also delete each removed worktree's local branch if it is still at the inspected commit."}
74  },
75  "required":["action"]
76}`
77
78func (t *ManageWorktreesTool) Name() string { return "manage_worktrees" }
79func (t *ManageWorktreesTool) Description() string {
80	return "List repository worktrees and safely remove explicitly selected clean, merged worktrees."
81}
82func (t *ManageWorktreesTool) Schema() json.RawMessage { return json.RawMessage(manageWorktreesSchema) }
83
84func (t *ManageWorktreesTool) Preview(ctx context.Context, raw json.RawMessage) (core.ToolResult, error) {
85	plan, err := t.plan(ctx, raw)
86	if err != nil {
87		return core.ToolResult{}, err
88	}
89	t.previewMu.Lock()
90	t.preview = &manageWorktreesPreview{raw: string(raw), plan: plan}
91	t.previewMu.Unlock()
92	return manageWorktreesResult(plan, "preview"), nil
93}
94
95func (t *ManageWorktreesTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
96	plan, err := t.plan(ctx, raw)
97	if err != nil {
98		return core.ToolResult{}, err
99	}
100	t.previewMu.Lock()
101	cached := t.preview
102	t.preview = nil
103	t.previewMu.Unlock()
104	if cached != nil && cached.raw == string(raw) && !sameManageWorktreesPlan(cached.plan, plan) {
105		return core.ToolResult{}, errors.New("manage_worktrees: repository state changed since preview; inspect and retry")
106	}
107	if plan.action == "list" {
108		return manageWorktreesResult(plan, "listed"), nil
109	}
110	var removed, deleted []string
111	var branchErrs []string
112	// summary reports progress made so far; it is attached to every failure
113	// return so a partial cleanup is never silently dropped.
114	summary := func() string {
115		var parts []string
116		if len(branchErrs) > 0 {
117			parts = append(parts, strings.Join(branchErrs, "; "))
118		}
119		if len(removed) > 0 {
120			parts = append(parts, "removed worktrees: "+strings.Join(removed, ", "))
121		}
122		if len(deleted) > 0 {
123			parts = append(parts, "deleted branches: "+strings.Join(deleted, ", "))
124		}
125		if len(parts) == 0 {
126			return ""
127		}
128		return "; " + strings.Join(parts, "; ")
129	}
130	for _, worktree := range plan.selected {
131		if progress != nil {
132			progress("Removing worktree " + filepath.ToSlash(worktree.Path) + "...\n")
133		}
134		if _, err := manageWorktreesGit(ctx, plan.repoRoot, "worktree", "remove", worktree.Path); err != nil {
135			_, _ = manageWorktreesGit(ctx, plan.repoRoot, "worktree", "prune")
136			return core.ToolResult{}, fmt.Errorf("manage_worktrees: remove %q: %w%s", filepath.ToSlash(worktree.Path), err, summary())
137		}
138		removed = append(removed, filepath.ToSlash(worktree.Path))
139		if !plan.deleteBranch {
140			continue
141		}
142		if progress != nil {
143			progress("Deleting branch " + worktree.Branch + "...\n")
144		}
145		if err := deleteMergedBranch(ctx, plan.repoRoot, worktree.Branch, worktree.Head); err != nil {
146			branchErrs = append(branchErrs, fmt.Sprintf("delete branch %q: %v", worktree.Branch, err))
147			continue
148		}
149		deleted = append(deleted, worktree.Branch)
150	}
151	_, _ = manageWorktreesGit(ctx, plan.repoRoot, "worktree", "prune")
152	if len(branchErrs) > 0 {
153		return core.ToolResult{}, fmt.Errorf("manage_worktrees: worktrees were removed but some branches were kept%s", summary())
154	}
155	result := manageWorktreesResult(plan, "removed")
156	if len(deleted) > 0 {
157		text := plan.text + "deleted branches:\n"
158		for _, branch := range deleted {
159			text += "- " + branch + "\n"
160		}
161		result.Content = []provider.Content{provider.TextBlock{Text: text}}
162	}
163	if details, ok := result.Details.(map[string]any); ok {
164		details["removed"] = removed
165		details["deleted_branches"] = deleted
166	}
167	return result, nil
168}
169
170// deleteMergedBranch deletes refs/heads/<branch> only while it still points at
171// head, is a plain (non-symbolic) ref, and is not checked out in any worktree.
172// The checks run immediately before deletion because state can change after
173// planning and after the worktree was removed.
174func deleteMergedBranch(ctx context.Context, repoRoot, branch, head string) error {
175	if branch == "" || head == "" {
176		return errors.New("branch or inspected commit is unknown")
177	}
178	ref := "refs/heads/" + branch
179	// A symbolic branch ref would make update-ref delete its target.
180	if _, err := manageWorktreesGit(ctx, repoRoot, "symbolic-ref", "-q", ref); err == nil {
181		return errors.New("branch ref is symbolic and was kept")
182	} else if createWorktreeGitExitCode(err) != 1 {
183		return fmt.Errorf("inspect branch ref: %w", err)
184	}
185	where, err := branchCheckedOutAt(ctx, repoRoot, ref)
186	if err != nil {
187		return err
188	}
189	if where != "" {
190		return fmt.Errorf("branch is checked out in %q", filepath.ToSlash(where))
191	}
192	if _, err := manageWorktreesGit(ctx, repoRoot, "update-ref", "--no-deref", "-d", ref, head); err != nil {
193		return fmt.Errorf("branch changed since inspection or cannot be deleted: %w", err)
194	}
195	return nil
196}
197
198// branchCheckedOutAt returns the path of a worktree (primary included) that has
199// ref checked out, or "" when none does.
200func branchCheckedOutAt(ctx context.Context, repoRoot, ref string) (string, error) {
201	output, err := manageWorktreesGit(ctx, repoRoot, "worktree", "list", "--porcelain")
202	if err != nil {
203		return "", fmt.Errorf("list worktrees: %w", err)
204	}
205	for _, block := range strings.Split(strings.TrimSpace(output), "\n\n") {
206		path, checkedOut := "", false
207		for _, line := range strings.Split(block, "\n") {
208			if strings.HasPrefix(line, "worktree ") {
209				path = strings.TrimPrefix(line, "worktree ")
210			} else if line == "branch "+ref {
211				checkedOut = true
212			}
213		}
214		if checkedOut {
215			return path, nil
216		}
217	}
218	return "", nil
219}
220
221func (t *ManageWorktreesTool) plan(ctx context.Context, raw json.RawMessage) (manageWorktreesPlan, error) {
222	var args manageWorktreesArgs
223	if err := json.Unmarshal(raw, &args); err != nil {
224		return manageWorktreesPlan{}, fmt.Errorf("invalid args: %w", err)
225	}
226	if args.Action != "list" && args.Action != "cleanup" {
227		return manageWorktreesPlan{}, errors.New("manage_worktrees: action must be list or cleanup")
228	}
229	if t.Sandbox == nil {
230		return manageWorktreesPlan{}, errors.New("manage_worktrees: sandbox is required")
231	}
232	if err := t.Sandbox.CheckBashPermission("git"); err != nil {
233		return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: %w", err)
234	}
235	cwd := t.CWD
236	if cwd == "" {
237		var err error
238		cwd, err = os.Getwd()
239		if err != nil {
240			return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: working directory: %w", err)
241		}
242	}
243	if err := t.Sandbox.CheckReadPath(cwd); err != nil {
244		return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: %w", err)
245	}
246	repoRoot, err := manageWorktreesGit(ctx, cwd, "rev-parse", "--show-toplevel")
247	if err != nil {
248		return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: verify repository: %w", err)
249	}
250	repoRoot, err = filepath.Abs(repoRoot)
251	if err != nil {
252		return manageWorktreesPlan{}, err
253	}
254	if err := t.Sandbox.CheckReadPath(repoRoot); err != nil {
255		return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: %w", err)
256	}
257	if args.Action == "list" && args.DeleteBranch {
258		return manageWorktreesPlan{}, errors.New("manage_worktrees: delete_branch applies only to cleanup")
259	}
260	into := strings.TrimSpace(args.Into)
261	if strings.HasPrefix(into, "-") {
262		return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: invalid into %q", into)
263	}
264	target := into
265	if target == "" {
266		target = "HEAD"
267	}
268	intoCommit, err := manageWorktreesGit(ctx, repoRoot, "rev-parse", "--verify", target+"^{commit}")
269	if err != nil {
270		if into == "" {
271			return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: resolve current commit: %w", err)
272		}
273		return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: invalid into %q: %w", into, err)
274	}
275	intoRef, _ := manageWorktreesGit(ctx, repoRoot, "rev-parse", "--symbolic-full-name", target)
276	items, err := inspectManagedWorktrees(ctx, repoRoot, intoCommit, into)
277	if err != nil {
278		return manageWorktreesPlan{}, err
279	}
280	plan := manageWorktreesPlan{repoRoot: repoRoot, action: args.Action, into: into, intoCommit: intoCommit, intoRef: intoRef, deleteBranch: args.DeleteBranch, all: items}
281	if args.Action == "cleanup" {
282		if len(args.Paths) == 0 {
283			return manageWorktreesPlan{}, errors.New("manage_worktrees: cleanup requires at least one explicit path")
284		}
285		byPath := make(map[string]managedWorktree, len(items))
286		for _, item := range items {
287			canonicalPath, err := canonicalOrParent(item.Path)
288			if err != nil {
289				return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: resolve registered path: %w", err)
290			}
291			byPath[canonicalPath] = item
292		}
293		seen := map[string]bool{}
294		for _, requested := range args.Paths {
295			if !filepath.IsAbs(requested) {
296				requested = filepath.Join(cwd, requested)
297			}
298			path, err := canonicalOrParent(requested)
299			if err != nil {
300				return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: resolve path: %w", err)
301			}
302			item, ok := byPath[path]
303			if !ok {
304				return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: %q is not a registered worktree", filepath.ToSlash(path))
305			}
306			if !item.CleanupOK {
307				return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: %q cannot be removed: %s", filepath.ToSlash(path), item.BlockedBy)
308			}
309			if err := t.Sandbox.CheckWritePath(item.Path); err != nil {
310				return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: %w", err)
311			}
312			if !seen[item.Path] {
313				plan.selected = append(plan.selected, item)
314				seen[item.Path] = true
315			}
316		}
317		if args.DeleteBranch {
318			// Deleting a branch writes to Git ref storage, which lives outside the
319			// worktree path. Require write access to it and fail closed. Jail mode
320			// is a guardrail, not a sandbox: it cannot stop git from touching refs
321			// once permitted, so this only enforces the declared scopes.
322			commonDir, err := manageWorktreesGit(ctx, repoRoot, "rev-parse", "--git-common-dir")
323			if err != nil {
324				return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: resolve git directory: %w", err)
325			}
326			if !filepath.IsAbs(commonDir) {
327				commonDir = filepath.Join(repoRoot, commonDir)
328			}
329			commonDir, err = canonical(commonDir)
330			if err != nil {
331				return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: resolve git directory: %w", err)
332			}
333			if err := t.Sandbox.CheckWritePath(commonDir); err != nil {
334				return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: %w", err)
335			}
336			for _, item := range plan.selected {
337				if intoRef == "refs/heads/"+item.Branch {
338					return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: branch %q is the merge target and cannot be deleted", item.Branch)
339				}
340				for _, other := range items {
341					if other.Branch == item.Branch && !seen[other.Path] {
342						return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: branch %q is checked out in %q and cannot be deleted", item.Branch, filepath.ToSlash(other.Path))
343					}
344				}
345			}
346		}
347	}
348	plan.text = renderManagedWorktrees(plan)
349	return plan, nil
350}
351
352// WorktreeInventoryContext returns a model-readable repository inventory. A
353// non-Git working directory has no inventory and is not an error.
354func WorktreeInventoryContext(ctx context.Context, cwd string) string {
355	root, err := manageWorktreesGit(ctx, cwd, "rev-parse", "--show-toplevel")
356	if err != nil {
357		return ""
358	}
359	output, err := manageWorktreesGit(ctx, root, "worktree", "list", "--porcelain")
360	if err != nil {
361		return ""
362	}
363	const maxInventoryItems = 20
364	var b strings.Builder
365	b.WriteString("[Repository worktrees]\n")
366	count := 0
367	for _, block := range strings.Split(strings.TrimSpace(output), "\n\n") {
368		if count == maxInventoryItems {
369			break
370		}
371		path, branch := "", ""
372		for _, line := range strings.Split(block, "\n") {
373			if strings.HasPrefix(line, "worktree ") {
374				path = strings.TrimPrefix(line, "worktree ")
375			}
376			if strings.HasPrefix(line, "branch refs/heads/") {
377				branch = strings.TrimPrefix(line, "branch refs/heads/")
378			}
379		}
380		if path == "" {
381			continue
382		}
383		fmt.Fprintf(&b, "- %s", filepath.ToSlash(path))
384		if branch != "" {
385			fmt.Fprintf(&b, " [%s]", branch)
386		}
387		b.WriteByte('\n')
388		count++
389	}
390	total := len(strings.Split(strings.TrimSpace(output), "\n\n"))
391	if total <= 1 {
392		return ""
393	}
394	if total > count {
395		fmt.Fprintf(&b, "- ... %d more worktrees\n", total-count)
396	}
397	b.WriteString("Use worktree action=list for current safety status before cleanup.")
398	return b.String()
399}
400
401// inspectManagedWorktrees reports each worktree's state. A branch is merged
402// when its head is an ancestor of intoCommit; into names that target for
403// messages and is empty for the current HEAD.
404func inspectManagedWorktrees(ctx context.Context, repoRoot, intoCommit, into string) ([]managedWorktree, error) {
405	output, err := manageWorktreesGit(ctx, repoRoot, "worktree", "list", "--porcelain")
406	if err != nil {
407		return nil, fmt.Errorf("manage_worktrees: list: %w", err)
408	}
409	blocks := strings.Split(strings.TrimSpace(output), "\n\n")
410	items := make([]managedWorktree, 0, len(blocks))
411	for index, block := range blocks {
412		item := managedWorktree{Primary: index == 0}
413		for _, line := range strings.Split(block, "\n") {
414			switch {
415			case strings.HasPrefix(line, "worktree "):
416				item.Path = strings.TrimPrefix(line, "worktree ")
417			case strings.HasPrefix(line, "HEAD "):
418				item.Head = strings.TrimPrefix(line, "HEAD ")
419			case strings.HasPrefix(line, "branch refs/heads/"):
420				item.Branch = strings.TrimPrefix(line, "branch refs/heads/")
421			case strings.HasPrefix(line, "prunable"):
422				item.Prunable = true
423			}
424		}
425		if item.Path == "" {
426			continue
427		}
428		if item.Primary {
429			item.BlockedBy = "primary worktree"
430		} else if item.Prunable {
431			item.BlockedBy = "missing worktree; prune metadata instead"
432		} else if item.Branch == "" {
433			item.BlockedBy = "detached HEAD"
434		} else if _, submoduleErr := os.Stat(filepath.Join(item.Path, ".gitmodules")); submoduleErr == nil {
435			item.BlockedBy = "worktree contains submodules"
436		} else if !os.IsNotExist(submoduleErr) {
437			item.BlockedBy = "cannot inspect submodules: " + firstErrorLine(submoduleErr)
438		} else {
439			status, statusErr := manageWorktreesGit(ctx, item.Path, "status", "--porcelain", "--ignored=matching")
440			if statusErr != nil {
441				item.BlockedBy = "status unavailable: " + firstErrorLine(statusErr)
442			} else if status != "" {
443				item.Dirty = true
444				item.BlockedBy = "uncommitted changes"
445			} else if _, mergeErr := manageWorktreesGit(ctx, repoRoot, "merge-base", "--is-ancestor", item.Head, intoCommit); mergeErr != nil {
446				target := "the current branch"
447				if into != "" {
448					target = fmt.Sprintf("%q", into)
449				}
450				item.BlockedBy = "branch is not merged into " + target
451				if createWorktreeGitExitCode(mergeErr) != 1 {
452					item.BlockedBy += ": " + firstErrorLine(mergeErr)
453				}
454			} else {
455				item.Merged = true
456				item.CleanupOK = true
457			}
458		}
459		items = append(items, item)
460	}
461	return items, nil
462}
463
464func renderManagedWorktrees(plan manageWorktreesPlan) string {
465	var b strings.Builder
466	if plan.action == "cleanup" {
467		b.WriteString("remove worktrees\n")
468	} else {
469		b.WriteString("repository worktrees\n")
470	}
471	for _, item := range plan.all {
472		fmt.Fprintf(&b, "- %s", filepath.ToSlash(item.Path))
473		if item.Branch != "" {
474			fmt.Fprintf(&b, " [%s]", item.Branch)
475		}
476		state := item.BlockedBy
477		if item.CleanupOK {
478			state = "clean and merged; cleanup eligible"
479			if plan.into != "" {
480				state = fmt.Sprintf("clean and merged into %q; cleanup eligible", plan.into)
481			}
482		}
483		if state != "" {
484			fmt.Fprintf(&b, ": %s", state)
485		}
486		b.WriteByte('\n')
487	}
488	if plan.action == "cleanup" {
489		b.WriteString("selected:\n")
490		for _, item := range plan.selected {
491			fmt.Fprintf(&b, "- %s\n", filepath.ToSlash(item.Path))
492		}
493		if plan.deleteBranch {
494			b.WriteString("delete branches:\n")
495			for _, item := range plan.selected {
496				fmt.Fprintf(&b, "- %s\n", item.Branch)
497			}
498		}
499	}
500	return b.String()
501}
502
503func manageWorktreesResult(plan manageWorktreesPlan, state string) core.ToolResult {
504	return core.ToolResult{
505		Content: []provider.Content{provider.TextBlock{Text: plan.text}},
506		Details: map[string]any{"state": state, "worktrees": plan.all},
507	}
508}
509
510func sameManageWorktreesPlan(a, b manageWorktreesPlan) bool {
511	if a.repoRoot != b.repoRoot || a.action != b.action || a.into != b.into || a.intoCommit != b.intoCommit || a.intoRef != b.intoRef || a.deleteBranch != b.deleteBranch || a.text != b.text || len(a.selected) != len(b.selected) || len(a.all) != len(b.all) {
512		return false
513	}
514	for index := range a.selected {
515		if a.selected[index] != b.selected[index] {
516			return false
517		}
518	}
519	for index := range a.all {
520		if a.all[index] != b.all[index] {
521			return false
522		}
523	}
524	return true
525}
526
527func firstErrorLine(err error) string {
528	line, _, _ := strings.Cut(err.Error(), "\n")
529	return line
530}
531
532func manageWorktreesGit(ctx context.Context, dir string, args ...string) (string, error) {
533	return createWorktreeGitOutput(ctx, dir, args...)
534}

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