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}