1package tools
2
3import (
4 "context"
5 "os"
6 "os/exec"
7 "path/filepath"
8 "strings"
9 "testing"
10)
11
12func TestWorktreeToolDispatchesListAndCreate(t *testing.T) {
13 if _, err := exec.LookPath("git"); err != nil {
14 t.Skip("git is required")
15 }
16 repo := initWorktreeTestRepo(t)
17 root := t.TempDir()
18 tool := &WorktreeTool{
19 Create: &CreateWorktreeTool{CWD: repo, Sandbox: NewSandbox(repo), DefaultRoot: root},
20 Manage: &ManageWorktreesTool{CWD: repo, Sandbox: NewSandbox(repo)},
21 }
22 if _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"action": "list"}), nil); err != nil {
23 t.Fatal(err)
24 }
25 if _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"action": "create", "branch": "feature/unified"}), nil); err != nil {
26 t.Fatal(err)
27 }
28 matches, err := filepath.Glob(filepath.Join(root, "*", "feature", "unified"))
29 if err != nil || len(matches) != 1 {
30 t.Fatalf("created paths = %v, error = %v", matches, err)
31 }
32}
33
34func TestWorktreeToolRequiresAction(t *testing.T) {
35 tool := &WorktreeTool{Create: &CreateWorktreeTool{}, Manage: &ManageWorktreesTool{}}
36 if _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"branch": "missing-action"}), nil); err == nil {
37 t.Fatal("missing action was accepted")
38 }
39}
40
41// nestedWorktreeFixture creates feat/x and feat/x-api (based on feat/x)
42// through the public tool and returns their paths.
43func nestedWorktreeFixture(t *testing.T) (tool *WorktreeTool, repo, featPath, apiPath string) {
44 t.Helper()
45 if _, err := exec.LookPath("git"); err != nil {
46 t.Skip("git is required")
47 }
48 repo = initWorktreeTestRepo(t)
49 root := t.TempDir()
50 tool = &WorktreeTool{
51 Create: &CreateWorktreeTool{CWD: repo, Sandbox: NewSandbox(repo), DefaultRoot: root},
52 Manage: &ManageWorktreesTool{CWD: repo, Sandbox: NewSandbox(repo)},
53 }
54 ctx := context.Background()
55 if _, err := tool.Execute(ctx, mustJSON(t, map[string]any{"action": "create", "branch": "feat/x"}), nil); err != nil {
56 t.Fatal(err)
57 }
58 featPath = filepath.Join(root, testWorktreeRepoID(t, repo), "feat", "x")
59 // Advance the feature branch so HEAD of the primary checkout differs.
60 if err := os.WriteFile(filepath.Join(featPath, "feature.txt"), []byte("feature\n"), 0o644); err != nil {
61 t.Fatal(err)
62 }
63 gitTestOutput(t, featPath, "add", "feature.txt")
64 gitTestOutput(t, featPath, "commit", "-qm", "feature work")
65
66 result, err := tool.Execute(ctx, mustJSON(t, map[string]any{"action": "create", "branch": "feat/x-api", "base": "feat/x"}), nil)
67 if err != nil {
68 t.Fatal(err)
69 }
70 details := result.Details.(map[string]any)
71 featHead := gitTestOutput(t, featPath, "rev-parse", "HEAD")
72 if details["base"] != featHead || details["base_ref"] != "feat/x" {
73 t.Fatalf("details = %#v, want base %s from feat/x", details, featHead)
74 }
75 if text := resultText(result); !strings.Contains(text, "base: feat/x ("+featHead+")") {
76 t.Fatalf("result text missing input ref and sha:\n%s", text)
77 }
78 apiPath = filepath.Join(root, testWorktreeRepoID(t, repo), "feat", "x-api")
79 if got := gitTestOutput(t, apiPath, "rev-parse", "HEAD"); got != featHead {
80 t.Fatalf("sub-worktree HEAD = %s, want %s", got, featHead)
81 }
82 return tool, repo, featPath, apiPath
83}
84
85func commitInWorktree(t *testing.T, path, name string) {
86 t.Helper()
87 if err := os.WriteFile(filepath.Join(path, name), []byte(name+"\n"), 0o644); err != nil {
88 t.Fatal(err)
89 }
90 gitTestOutput(t, path, "add", name)
91 gitTestOutput(t, path, "commit", "-qm", name)
92}
93
94func TestWorktreeToolRejectsInvalidBase(t *testing.T) {
95 if _, err := exec.LookPath("git"); err != nil {
96 t.Skip("git is required")
97 }
98 repo := initWorktreeTestRepo(t)
99 tool := &WorktreeTool{Create: &CreateWorktreeTool{CWD: repo, Sandbox: NewSandbox(repo), DefaultRoot: t.TempDir()}}
100 for _, base := range []string{"no-such-branch", "--output=/tmp/x", "-h"} {
101 _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"action": "create", "branch": "feat/bad", "base": base}), nil)
102 if err == nil || !strings.HasPrefix(err.Error(), "worktree: ") || !strings.Contains(err.Error(), "invalid base") {
103 t.Fatalf("base %q: error = %v, want worktree invalid base", base, err)
104 }
105 }
106 if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feat/bad"); got.exitCode != 1 {
107 t.Fatalf("branch was created: %#v", got)
108 }
109}
110
111func TestWorktreeToolBasePreviewDetectsMovedBase(t *testing.T) {
112 tool, _, featPath, _ := nestedWorktreeFixture(t)
113 args := mustJSON(t, map[string]any{"action": "create", "branch": "feat/x-ui", "base": "feat/x"})
114 if _, err := tool.Preview(context.Background(), args); err != nil {
115 t.Fatal(err)
116 }
117 commitInWorktree(t, featPath, "moved.txt")
118 _, err := tool.Execute(context.Background(), args, nil)
119 if err == nil || !strings.Contains(err.Error(), "changed since preview") {
120 t.Fatalf("error = %v, want stale preview rejection", err)
121 }
122}
123
124func TestWorktreeToolCleanupIntoFeatureBranchAndDeleteBranch(t *testing.T) {
125 tool, repo, featPath, apiPath := nestedWorktreeFixture(t)
126 ctx := context.Background()
127 commitInWorktree(t, apiPath, "api.txt")
128 cleanup := map[string]any{"action": "cleanup", "paths": []string{apiPath}}
129
130 // Unmerged: blocked for both the default target and the feature branch.
131 for _, args := range []map[string]any{cleanup, {"action": "cleanup", "paths": []string{apiPath}, "into": "feat/x"}} {
132 _, err := tool.Preview(ctx, mustJSON(t, args))
133 if err == nil || !strings.Contains(err.Error(), "not merged") {
134 t.Fatalf("args %v: error = %v, want unmerged rejection", args, err)
135 }
136 }
137 gitTestOutput(t, featPath, "merge", "--no-edit", "feat/x-api")
138
139 // Default HEAD (primary checkout) does not contain the merge.
140 _, err := tool.Preview(ctx, mustJSON(t, cleanup))
141 if err == nil || !strings.Contains(err.Error(), "not merged into the current branch") {
142 t.Fatalf("error = %v, want current-branch rejection", err)
143 }
144 // list with into reports eligibility against the feature branch.
145 listed, err := tool.Execute(ctx, mustJSON(t, map[string]any{"action": "list", "into": "feat/x"}), nil)
146 if err != nil || !strings.Contains(resultText(listed), `clean and merged into "feat/x"; cleanup eligible`) {
147 t.Fatalf("list = %v, %q", err, resultText(listed))
148 }
149 _, err = tool.Preview(ctx, mustJSON(t, map[string]any{"action": "cleanup", "paths": []string{apiPath}, "into": "-bad"}))
150 if err == nil || !strings.Contains(err.Error(), "invalid into") {
151 t.Fatalf("error = %v, want invalid into", err)
152 }
153
154 args := mustJSON(t, map[string]any{"action": "cleanup", "paths": []string{apiPath}, "into": "feat/x", "delete_branch": true})
155 preview, err := tool.Preview(ctx, args)
156 if err != nil || !strings.Contains(resultText(preview), "delete branches:\n- feat/x-api") {
157 t.Fatalf("preview = %v, %q", err, resultText(preview))
158 }
159 result, err := tool.Execute(ctx, args, nil)
160 if err != nil {
161 t.Fatal(err)
162 }
163 if _, err := os.Stat(apiPath); !os.IsNotExist(err) {
164 t.Fatalf("sub-worktree remains: %v", err)
165 }
166 if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feat/x-api"); got.exitCode != 1 {
167 t.Fatalf("branch remains: %#v", got)
168 }
169 if got := gitTestOutput(t, repo, "show-ref", "--verify", "refs/heads/feat/x"); got == "" {
170 t.Fatal("feature branch was deleted")
171 }
172 if text := resultText(result); !strings.Contains(text, "deleted branches:\n- feat/x-api") {
173 t.Fatalf("result text missing deleted branch:\n%s", text)
174 }
175 details := result.Details.(map[string]any)
176 if deleted, _ := details["deleted_branches"].([]string); len(deleted) != 1 || deleted[0] != "feat/x-api" {
177 t.Fatalf("details = %#v", details)
178 }
179}
180
181func TestWorktreeToolCleanupWithoutDeleteBranchKeepsBranch(t *testing.T) {
182 tool, repo, _, apiPath := nestedWorktreeFixture(t)
183 args := mustJSON(t, map[string]any{"action": "cleanup", "paths": []string{apiPath}, "into": "feat/x"})
184 if _, err := tool.Execute(context.Background(), args, nil); err != nil {
185 t.Fatal(err)
186 }
187 if got := gitTestOutput(t, repo, "show-ref", "--verify", "refs/heads/feat/x-api"); got == "" {
188 t.Fatal("branch removed without delete_branch")
189 }
190}
191
192func TestWorktreeToolDeleteBranchRefusesMergeTarget(t *testing.T) {
193 tool, repo, featPath, _ := nestedWorktreeFixture(t)
194 args := mustJSON(t, map[string]any{"action": "cleanup", "paths": []string{featPath}, "into": "feat/x", "delete_branch": true})
195 _, err := tool.Preview(context.Background(), args)
196 if err == nil || !strings.Contains(err.Error(), "merge target") {
197 t.Fatalf("error = %v, want merge-target rejection", err)
198 }
199 if _, statErr := os.Stat(featPath); statErr != nil {
200 t.Fatal(statErr)
201 }
202 _ = repo
203}
204
205func TestWorktreeToolDeleteBranchRejectsMovedBranchAfterPreview(t *testing.T) {
206 tool, repo, featPath, apiPath := nestedWorktreeFixture(t)
207 args := mustJSON(t, map[string]any{"action": "cleanup", "paths": []string{apiPath}, "into": "feat/x", "delete_branch": true})
208 if _, err := tool.Preview(context.Background(), args); err != nil {
209 t.Fatal(err)
210 }
211 // A new commit after preview leaves the branch unmerged, so execution
212 // must fail closed without removing the worktree or deleting the branch.
213 commitInWorktree(t, apiPath, "late.txt")
214 _, err := tool.Execute(context.Background(), args, nil)
215 if err == nil || !strings.Contains(err.Error(), "not merged") {
216 t.Fatalf("error = %v, want unmerged rejection", err)
217 }
218 if got := gitTestOutput(t, repo, "show-ref", "--verify", "refs/heads/feat/x-api"); got == "" {
219 t.Fatal("branch was deleted")
220 }
221 if _, statErr := os.Stat(apiPath); statErr != nil {
222 t.Fatalf("worktree was removed: %v", statErr)
223 }
224 _, _ = repo, featPath
225}
226
227func TestDeleteMergedBranchKeepsBranchThatMoved(t *testing.T) {
228 _, repo, _, apiPath := nestedWorktreeFixture(t)
229 inspected := gitTestOutput(t, apiPath, "rev-parse", "HEAD")
230 commitInWorktree(t, apiPath, "moved.txt")
231 gitTestOutput(t, repo, "worktree", "remove", apiPath)
232
233 err := deleteMergedBranch(context.Background(), repo, "feat/x-api", inspected)
234 if err == nil || !strings.Contains(err.Error(), "changed since inspection") {
235 t.Fatalf("error = %v, want moved-branch rejection", err)
236 }
237 if got := gitTestOutput(t, repo, "show-ref", "--verify", "refs/heads/feat/x-api"); got == "" {
238 t.Fatal("moved branch was deleted")
239 }
240 current := gitTestOutput(t, repo, "rev-parse", "refs/heads/feat/x-api")
241 if err := deleteMergedBranch(context.Background(), repo, "feat/x-api", current); err != nil {
242 t.Fatal(err)
243 }
244}
245
246func TestWorktreeToolReportsBranchDeletionFailureAfterRemoval(t *testing.T) {
247 tool, repo, _, apiPath := nestedWorktreeFixture(t)
248 ctx := context.Background()
249 args := mustJSON(t, map[string]any{"action": "cleanup", "paths": []string{apiPath}, "into": "feat/x", "delete_branch": true})
250 // Lock the branch ref so update-ref cannot delete it after worktree removal.
251 lock := filepath.Join(repo, ".git", "refs", "heads", "feat", "x-api.lock")
252 if err := os.WriteFile(lock, nil, 0o644); err != nil {
253 t.Fatal(err)
254 }
255 _, err := tool.Execute(ctx, args, nil)
256 if err == nil || !strings.HasPrefix(err.Error(), "worktree: ") || !strings.Contains(err.Error(), `delete branch "feat/x-api"`) || !strings.Contains(err.Error(), "worktrees were removed") {
257 t.Fatalf("error = %v, want clear branch-deletion failure", err)
258 }
259 if _, statErr := os.Stat(apiPath); !os.IsNotExist(statErr) {
260 t.Fatalf("worktree remains: %v", statErr)
261 }
262}