1package tools
2
3import (
4 "context"
5 "os"
6 "os/exec"
7 "path/filepath"
8 "strings"
9 "testing"
10)
11
12func TestManageWorktreesListsCleanupEligibility(t *testing.T) {
13 if _, err := exec.LookPath("git"); err != nil {
14 t.Skip("git is required")
15 }
16 repo := initWorktreeTestRepo(t)
17 mergedPath := filepath.Join(t.TempDir(), "merged")
18 gitTestOutput(t, repo, "branch", "merged")
19 gitTestOutput(t, repo, "worktree", "add", mergedPath, "merged")
20 dirtyPath := filepath.Join(t.TempDir(), "dirty")
21 gitTestOutput(t, repo, "worktree", "add", "-b", "dirty", dirtyPath)
22 if err := os.WriteFile(filepath.Join(dirtyPath, "dirty.txt"), []byte("dirty"), 0o644); err != nil {
23 t.Fatal(err)
24 }
25
26 tool := newManageWorktreesTestTool(repo)
27 result, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"action": "list"}), nil)
28 if err != nil {
29 t.Fatal(err)
30 }
31 got := resultText(result)
32 for _, want := range []string{"repository worktrees", "merged", "cleanup eligible", "dirty", "uncommitted changes", "primary worktree"} {
33 if !strings.Contains(got, want) {
34 t.Fatalf("result missing %q:\n%s", want, got)
35 }
36 }
37}
38
39func TestManageWorktreesCleanupRemovesOnlyExplicitCleanMergedWorktree(t *testing.T) {
40 if _, err := exec.LookPath("git"); err != nil {
41 t.Skip("git is required")
42 }
43 repo := initWorktreeTestRepo(t)
44 path := filepath.Join(t.TempDir(), "merged")
45 gitTestOutput(t, repo, "branch", "merged")
46 gitTestOutput(t, repo, "worktree", "add", path, "merged")
47 tool := newManageWorktreesTestTool(repo)
48 args := mustJSON(t, map[string]any{"action": "cleanup", "paths": []string{path}})
49
50 if _, err := tool.Preview(context.Background(), args); err != nil {
51 t.Fatal(err)
52 }
53 if _, err := os.Stat(path); err != nil {
54 t.Fatalf("preview removed worktree: %v", err)
55 }
56 if _, err := tool.Execute(context.Background(), args, nil); err != nil {
57 t.Fatal(err)
58 }
59 if _, err := os.Stat(path); !os.IsNotExist(err) {
60 t.Fatalf("worktree remains: %v", err)
61 }
62 if got := gitTestOutput(t, repo, "show-ref", "--verify", "refs/heads/merged"); got == "" {
63 t.Fatal("cleanup unexpectedly removed branch")
64 }
65}
66
67func TestManageWorktreesRejectsDirtyCleanup(t *testing.T) {
68 if _, err := exec.LookPath("git"); err != nil {
69 t.Skip("git is required")
70 }
71 repo := initWorktreeTestRepo(t)
72 path := filepath.Join(t.TempDir(), "dirty")
73 gitTestOutput(t, repo, "worktree", "add", "-b", "dirty", path)
74 if err := os.WriteFile(filepath.Join(path, "dirty.txt"), []byte("dirty"), 0o644); err != nil {
75 t.Fatal(err)
76 }
77 tool := newManageWorktreesTestTool(repo)
78 _, err := tool.Preview(context.Background(), mustJSON(t, map[string]any{"action": "cleanup", "paths": []string{path}}))
79 if err == nil || !strings.Contains(err.Error(), "uncommitted changes") {
80 t.Fatalf("error = %v, want dirty-worktree rejection", err)
81 }
82}
83
84func TestWorktreeInventoryContextReportsRepositoryWorktrees(t *testing.T) {
85 if _, err := exec.LookPath("git"); err != nil {
86 t.Skip("git is required")
87 }
88 repo := initWorktreeTestRepo(t)
89 path := filepath.Join(t.TempDir(), "merged")
90 gitTestOutput(t, repo, "branch", "merged")
91 gitTestOutput(t, repo, "worktree", "add", path, "merged")
92
93 contextText := WorktreeInventoryContext(context.Background(), repo)
94 wantPath, err := canonicalOrParent(path)
95 if err != nil {
96 t.Fatal(err)
97 }
98 for _, want := range []string{"[Repository worktrees]", filepath.ToSlash(wantPath), "worktree action=list"} {
99 if !strings.Contains(contextText, want) {
100 t.Fatalf("context missing %q:\n%s", want, contextText)
101 }
102 }
103}
104
105func TestManageWorktreesRejectsIgnoredFilesCleanup(t *testing.T) {
106 if _, err := exec.LookPath("git"); err != nil {
107 t.Skip("git is required")
108 }
109 repo := initWorktreeTestRepo(t)
110 path := filepath.Join(t.TempDir(), "ignored")
111 gitTestOutput(t, repo, "worktree", "add", "-b", "ignored", path)
112 if err := os.WriteFile(filepath.Join(path, ".gitignore"), []byte("local.env\n"), 0o644); err != nil {
113 t.Fatal(err)
114 }
115 gitTestOutput(t, path, "add", ".gitignore")
116 gitTestOutput(t, path, "commit", "-m", "ignore local file")
117 gitTestOutput(t, repo, "merge", "--ff-only", "ignored")
118 if err := os.WriteFile(filepath.Join(path, "local.env"), []byte("private"), 0o600); err != nil {
119 t.Fatal(err)
120 }
121 tool := newManageWorktreesTestTool(repo)
122 _, err := tool.Preview(context.Background(), mustJSON(t, map[string]any{"action": "cleanup", "paths": []string{path}}))
123 if err == nil || !strings.Contains(err.Error(), "uncommitted changes") {
124 t.Fatalf("error = %v, want ignored-file rejection", err)
125 }
126}
127
128func TestManageWorktreesRejectsCleanupWithoutExplicitPaths(t *testing.T) {
129 if _, err := exec.LookPath("git"); err != nil {
130 t.Skip("git is required")
131 }
132 repo := initWorktreeTestRepo(t)
133 tool := newManageWorktreesTestTool(repo)
134 _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"action": "cleanup"}), nil)
135 if err == nil || !strings.Contains(err.Error(), "explicit path") {
136 t.Fatalf("error = %v, want explicit-path rejection", err)
137 }
138}
139
140func newManageWorktreesTestTool(cwd string) *ManageWorktreesTool {
141 return &ManageWorktreesTool{CWD: cwd, Sandbox: NewSandbox(cwd)}
142}
143
144// addMergedSideWorktree creates branch "side" at the primary HEAD in a new
145// worktree, so it is merged into HEAD and eligible for cleanup.
146func addMergedSideWorktree(t *testing.T, repo, branch string) string {
147 t.Helper()
148 path := filepath.Join(t.TempDir(), branch)
149 gitTestOutput(t, repo, "worktree", "add", "-b", branch, path)
150 return path
151}
152
153func branchExists(t *testing.T, repo, branch string) bool {
154 t.Helper()
155 return gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/"+branch).exitCode == 0
156}
157
158func TestDeleteMergedBranchRefusesSymbolicRef(t *testing.T) {
159 if _, err := exec.LookPath("git"); err != nil {
160 t.Skip("git is required")
161 }
162 repo := initWorktreeTestRepo(t)
163 mainBranch := gitTestOutput(t, repo, "branch", "--show-current")
164 head := gitTestOutput(t, repo, "rev-parse", "HEAD")
165 gitTestOutput(t, repo, "branch", "side")
166 gitTestOutput(t, repo, "symbolic-ref", "refs/heads/side", "refs/heads/"+mainBranch)
167
168 err := deleteMergedBranch(context.Background(), repo, "side", head)
169 if err == nil || !strings.Contains(err.Error(), "symbolic") {
170 t.Fatalf("error = %v, want symbolic-ref rejection", err)
171 }
172 if !branchExists(t, repo, mainBranch) {
173 t.Fatal("symbolic ref deletion removed the target branch")
174 }
175}
176
177func TestManageWorktreesDeleteBranchKeepsTargetWhenBranchBecomesSymref(t *testing.T) {
178 if _, err := exec.LookPath("git"); err != nil {
179 t.Skip("git is required")
180 }
181 repo := initWorktreeTestRepo(t)
182 mainBranch := gitTestOutput(t, repo, "branch", "--show-current")
183 path := addMergedSideWorktree(t, repo, "side")
184 tool := newManageWorktreesTestTool(repo)
185 args := mustJSON(t, map[string]any{"action": "cleanup", "paths": []string{path}, "delete_branch": true})
186
187 // The worktree is already removed when the delete progress is reported;
188 // swap the branch for a symref to the primary branch at the same commit.
189 progress := func(msg string) {
190 if strings.HasPrefix(msg, "Deleting branch") {
191 gitTestOutput(t, repo, "symbolic-ref", "refs/heads/side", "refs/heads/"+mainBranch)
192 }
193 }
194 _, err := tool.Execute(context.Background(), args, progress)
195 if err == nil || !strings.Contains(err.Error(), "symbolic") || !strings.Contains(err.Error(), "removed worktrees:") {
196 t.Fatalf("error = %v, want symbolic-ref branch failure", err)
197 }
198 if !branchExists(t, repo, mainBranch) {
199 t.Fatal("primary branch was deleted through a symbolic ref")
200 }
201}
202
203func TestManageWorktreesDeleteBranchRefusesBranchCheckedOutAfterRemoval(t *testing.T) {
204 if _, err := exec.LookPath("git"); err != nil {
205 t.Skip("git is required")
206 }
207 for _, where := range []string{"primary", "worktree"} {
208 t.Run(where, func(t *testing.T) {
209 repo := initWorktreeTestRepo(t)
210 path := addMergedSideWorktree(t, repo, "side")
211 tool := newManageWorktreesTestTool(repo)
212 args := mustJSON(t, map[string]any{"action": "cleanup", "paths": []string{path}, "delete_branch": true})
213 progress := func(msg string) {
214 if !strings.HasPrefix(msg, "Deleting branch") {
215 return
216 }
217 if where == "primary" {
218 gitTestOutput(t, repo, "checkout", "-q", "side")
219 } else {
220 gitTestOutput(t, repo, "worktree", "add", filepath.Join(t.TempDir(), "again"), "side")
221 }
222 }
223 _, err := tool.Execute(context.Background(), args, progress)
224 if err == nil || !strings.Contains(err.Error(), `delete branch "side"`) || !strings.Contains(err.Error(), "checked out") {
225 t.Fatalf("error = %v, want checked-out branch failure", err)
226 }
227 if !branchExists(t, repo, "side") {
228 t.Fatal("checked-out branch was deleted")
229 }
230 })
231 }
232}
233
234func TestManageWorktreesDeleteBranchRequiresRefStorageWriteAccess(t *testing.T) {
235 if _, err := exec.LookPath("git"); err != nil {
236 t.Skip("git is required")
237 }
238 repo := initWorktreeTestRepo(t)
239 path := addMergedSideWorktree(t, repo, "side")
240 scoped := func(write ...string) *ManageWorktreesTool {
241 var p PermissionSet
242 p.Bash.Mode = "ask"
243 p.FS.Read = []string{repo, filepath.Dir(path)}
244 p.FS.Write = write
245 sandbox := NewSandbox(repo)
246 sandbox.SetPermissions(&p)
247 return &ManageWorktreesTool{CWD: repo, Sandbox: sandbox}
248 }
249 args := mustJSON(t, map[string]any{"action": "cleanup", "paths": []string{path}, "delete_branch": true})
250
251 tool := scoped(path)
252 if _, err := tool.Preview(context.Background(), args); err == nil || !strings.Contains(err.Error(), "outside declared scopes") {
253 t.Fatalf("preview error = %v, want ref-storage write denial", err)
254 }
255 if _, err := tool.Execute(context.Background(), args, nil); err == nil || !strings.Contains(err.Error(), "outside declared scopes") {
256 t.Fatalf("execute error = %v, want ref-storage write denial", err)
257 }
258 if _, err := os.Stat(path); err != nil {
259 t.Fatalf("worktree was removed despite denial: %v", err)
260 }
261 if !branchExists(t, repo, "side") {
262 t.Fatal("branch was deleted despite denial")
263 }
264
265 // Without delete_branch only the worktree path needs write access.
266 keep := mustJSON(t, map[string]any{"action": "cleanup", "paths": []string{path}})
267 if _, err := tool.Preview(context.Background(), keep); err != nil {
268 t.Fatalf("cleanup without delete_branch: %v", err)
269 }
270 // Granting the common git directory allows the full operation.
271 tool = scoped(path, filepath.Join(repo, ".git"))
272 if _, err := tool.Execute(context.Background(), args, nil); err != nil {
273 t.Fatal(err)
274 }
275 if branchExists(t, repo, "side") {
276 t.Fatal("branch remains after permitted cleanup")
277 }
278}
279
280func TestManageWorktreesDetectsMovedMergeTargetAfterPreview(t *testing.T) {
281 if _, err := exec.LookPath("git"); err != nil {
282 t.Skip("git is required")
283 }
284 repo := initWorktreeTestRepo(t)
285 path := addMergedSideWorktree(t, repo, "side")
286 gitTestOutput(t, repo, "branch", "target")
287 tool := newManageWorktreesTestTool(repo)
288 args := mustJSON(t, map[string]any{"action": "cleanup", "paths": []string{path}, "into": "target", "delete_branch": true})
289 if _, err := tool.Preview(context.Background(), args); err != nil {
290 t.Fatal(err)
291 }
292 // Advance the unchecked-out target; side remains an ancestor, so the
293 // worktree stays eligible and only the pinned target differs.
294 tree := gitTestOutput(t, repo, "rev-parse", "target^{tree}")
295 moved := gitTestOutput(t, repo, "commit-tree", tree, "-p", "target", "-m", "advance")
296 gitTestOutput(t, repo, "update-ref", "refs/heads/target", moved)
297
298 _, err := tool.Execute(context.Background(), args, nil)
299 if err == nil || !strings.Contains(err.Error(), "changed since preview") {
300 t.Fatalf("error = %v, want stale-state rejection", err)
301 }
302 if _, statErr := os.Stat(path); statErr != nil {
303 t.Fatalf("worktree was removed: %v", statErr)
304 }
305 if !branchExists(t, repo, "side") {
306 t.Fatal("branch was deleted")
307 }
308}
309
310func TestManageWorktreesPartialFailureReportsBranchErrorsAndProgress(t *testing.T) {
311 if _, err := exec.LookPath("git"); err != nil {
312 t.Skip("git is required")
313 }
314 repo := initWorktreeTestRepo(t)
315 first := addMergedSideWorktree(t, repo, "first")
316 second := addMergedSideWorktree(t, repo, "second")
317 // A stale lock makes deleting "first" fail after its worktree is removed.
318 lock := filepath.Join(repo, ".git", "refs", "heads", "first.lock")
319 if err := os.WriteFile(lock, nil, 0o644); err != nil {
320 t.Fatal(err)
321 }
322 tool := newManageWorktreesTestTool(repo)
323 args := mustJSON(t, map[string]any{"action": "cleanup", "paths": []string{first, second}, "delete_branch": true})
324 // Dirty the second worktree so its removal fails.
325 progress := func(msg string) {
326 if strings.HasPrefix(msg, "Removing worktree") && strings.Contains(msg, filepath.ToSlash(second)) {
327 if err := os.WriteFile(filepath.Join(second, "untracked.txt"), []byte("x"), 0o644); err != nil {
328 t.Error(err)
329 }
330 }
331 }
332 _, err := tool.Execute(context.Background(), args, progress)
333 if err == nil {
334 t.Fatal("expected partial failure")
335 }
336 for _, want := range []string{"remove ", `delete branch "first"`, "removed worktrees: " + filepath.ToSlash(first)} {
337 if !strings.Contains(err.Error(), want) {
338 t.Fatalf("error missing %q:\n%v", want, err)
339 }
340 }
341 if _, statErr := os.Stat(second); statErr != nil {
342 t.Fatalf("second worktree should remain: %v", statErr)
343 }
344}