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}