1package tools
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "runtime"
11 "strings"
12 "testing"
13 "time"
14
15 "github.com/bnema/zut/packages/provider"
16)
17
18func TestCreateWorktreeBootstrapsDefaultAtRepositoryRoot(t *testing.T) {
19 if _, err := exec.LookPath("git"); err != nil {
20 t.Skip("git is required")
21 }
22 repo := initWorktreeTestRepo(t)
23 nested := filepath.Join(repo, "nested", "dir")
24 if err := os.MkdirAll(nested, 0o755); err != nil {
25 t.Fatal(err)
26 }
27 if err := os.WriteFile(filepath.Join(repo, ".gitignore"), []byte("*.local\n"), 0o644); err != nil {
28 t.Fatal(err)
29 }
30 branchBefore := gitTestOutput(t, repo, "branch", "--show-current")
31 sandbox := NewSandbox(repo)
32 sandbox.Lock()
33
34 tool := &CreateWorktreeTool{CWD: nested, Sandbox: sandbox}
35 args, err := json.Marshal(map[string]string{"branch": "feature/new-checkout", "bootstrap_root": ".worktrees"})
36 if err != nil {
37 t.Fatal(err)
38 }
39 if _, err := tool.Execute(context.Background(), args, nil); err != nil {
40 t.Fatal(err)
41 }
42
43 worktree := filepath.Join(repo, ".worktrees", "feature", "new-checkout")
44 if info, err := os.Stat(worktree); err != nil || !info.IsDir() {
45 t.Fatalf("worktree %q was not created: %v", worktree, err)
46 }
47 if got := gitTestOutput(t, worktree, "branch", "--show-current"); got != "feature/new-checkout" {
48 t.Fatalf("worktree branch = %q, want %q", got, "feature/new-checkout")
49 }
50 if got := gitTestOutput(t, repo, "branch", "--show-current"); got != branchBefore {
51 t.Fatalf("source branch changed from %q to %q", branchBefore, got)
52 }
53 ignore, err := os.ReadFile(filepath.Join(repo, ".gitignore"))
54 if err != nil {
55 t.Fatal(err)
56 }
57 if count := strings.Count(string(ignore), "/.worktrees/\n"); count != 1 {
58 t.Fatalf("/.worktrees/ ignore entries = %d, want 1:\n%s", count, ignore)
59 }
60 if got := gitTestOutput(t, repo, "config", "--local", "--get", worktreeConfigKey); got != defaultWorktreeRoot {
61 t.Fatalf("saved worktree root = %q, want %q", got, defaultWorktreeRoot)
62 }
63 followupArgs := mustJSON(t, map[string]string{
64 "branch": "feature/default-followup",
65 "bootstrap_root": defaultWorktreeRoot,
66 })
67 if _, err := tool.Preview(context.Background(), followupArgs); err != nil {
68 t.Fatal(err)
69 }
70 if _, err := tool.Execute(context.Background(), followupArgs, nil); err != nil {
71 t.Fatal(err)
72 }
73 if _, err := os.Stat(filepath.Join(repo, defaultWorktreeRoot, "feature", "default-followup")); err != nil {
74 t.Fatalf("configured default root was not reused: %v", err)
75 }
76}
77
78func TestCreateWorktreeRequiresBootstrapForUnconfiguredRepository(t *testing.T) {
79 if _, err := exec.LookPath("git"); err != nil {
80 t.Skip("git is required")
81 }
82 repo := initWorktreeTestRepo(t)
83 tool := newCreateWorktreeTestTool(repo)
84
85 result, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/needs-bootstrap"}`), nil)
86 if err != nil {
87 t.Fatal(err)
88 }
89 text := result.Content[0].(provider.TextBlock).Text
90 if !strings.Contains(text, "Worktree bootstrap is required") {
91 t.Fatalf("bootstrap result = %q", text)
92 }
93 if state, _ := result.Details.(map[string]any)["state"].(string); state != "bootstrap_required" {
94 t.Fatalf("bootstrap state = %q", state)
95 }
96 for _, path := range []string{".worktrees", ".gitignore"} {
97 if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) {
98 t.Fatalf("bootstrap created %s: %v", path, statErr)
99 }
100 }
101 if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {
102 t.Fatalf("local worktree config exit = %d, want 1", got.exitCode)
103 }
104 if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/needs-bootstrap"); got.exitCode != 1 {
105 t.Fatalf("bootstrap created branch: %#v", got)
106 }
107}
108
109func TestCreateWorktreeAcceptsDefaultBootstrapRootWhenDefaultRootExists(t *testing.T) {
110 if _, err := exec.LookPath("git"); err != nil {
111 t.Skip("git is required")
112 }
113 repo := initWorktreeTestRepo(t)
114 if err := os.Mkdir(filepath.Join(repo, defaultWorktreeRoot), 0o755); err != nil {
115 t.Fatal(err)
116 }
117 tool := newCreateWorktreeTestTool(repo)
118 args := mustJSON(t, map[string]string{
119 "branch": "feature/existing-default-root",
120 "bootstrap_root": defaultWorktreeRoot,
121 })
122
123 if _, err := tool.Preview(context.Background(), args); err != nil {
124 t.Fatal(err)
125 }
126 result, err := tool.Execute(context.Background(), args, nil)
127 if err != nil {
128 t.Fatal(err)
129 }
130 if _, err := os.Stat(filepath.Join(repo, defaultWorktreeRoot, "feature", "existing-default-root")); err != nil {
131 t.Fatalf("worktree was not created: %v", err)
132 }
133 if source, _ := result.Details.(map[string]any)["root_source"].(string); source != "existing .worktrees directory" {
134 t.Fatalf("root source = %q", source)
135 }
136 if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {
137 t.Fatalf("existing default root saved config: %#v", got)
138 }
139}
140
141func TestCreateWorktreeRejectsConflictingBootstrapRootWhenDefaultRootExists(t *testing.T) {
142 if _, err := exec.LookPath("git"); err != nil {
143 t.Skip("git is required")
144 }
145 repo := initWorktreeTestRepo(t)
146 if err := os.Mkdir(filepath.Join(repo, defaultWorktreeRoot), 0o755); err != nil {
147 t.Fatal(err)
148 }
149 tool := newCreateWorktreeTestTool(repo)
150
151 _, err := tool.Execute(context.Background(), mustJSON(t, map[string]string{
152 "branch": "feature/conflicting-bootstrap",
153 "bootstrap_root": t.TempDir(),
154 }), nil)
155 if err == nil || !strings.Contains(err.Error(), "already established") {
156 t.Fatalf("error = %v, want established-root error", err)
157 }
158 if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {
159 t.Fatalf("conflicting bootstrap saved config: %#v", got)
160 }
161 if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/conflicting-bootstrap"); got.exitCode != 1 {
162 t.Fatalf("conflicting bootstrap created branch: %#v", got)
163 }
164}
165
166func TestCreateWorktreeRejectsConflictingBootstrapRootWhenRootIsConfigured(t *testing.T) {
167 if _, err := exec.LookPath("git"); err != nil {
168 t.Skip("git is required")
169 }
170 repo := initWorktreeTestRepo(t)
171 gitTestOutput(t, repo, "config", "--local", worktreeConfigKey, defaultWorktreeRoot)
172 tool := newCreateWorktreeTestTool(repo)
173 args := mustJSON(t, map[string]string{
174 "branch": "feature/conflicting-configured-bootstrap",
175 "bootstrap_root": t.TempDir(),
176 })
177
178 if _, err := tool.Preview(context.Background(), args); err == nil || !strings.Contains(err.Error(), "already configured") {
179 t.Fatalf("preview error = %v, want configured-root error", err)
180 }
181 _, err := tool.Execute(context.Background(), args, nil)
182 if err == nil || !strings.Contains(err.Error(), "already configured") {
183 t.Fatalf("error = %v, want configured-root error", err)
184 }
185 if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/conflicting-configured-bootstrap"); got.exitCode != 1 {
186 t.Fatalf("conflicting bootstrap created branch: %#v", got)
187 }
188}
189
190func TestCreateWorktreeBootstrapsExternalRootWithoutChangingGitignore(t *testing.T) {
191 if _, err := exec.LookPath("git"); err != nil {
192 t.Skip("git is required")
193 }
194 repo := initWorktreeTestRepo(t)
195 externalRoot, err := canonicalOrParent(filepath.Join(t.TempDir(), "company-worktrees"))
196 if err != nil {
197 t.Fatal(err)
198 }
199 tool := newCreateWorktreeTestTool(repo)
200 args := mustJSON(t, map[string]string{"branch": "feature/external", "bootstrap_root": externalRoot})
201
202 result, err := tool.Execute(context.Background(), args, nil)
203 if err != nil {
204 t.Fatal(err)
205 }
206 worktree := filepath.Join(externalRoot, "feature", "external")
207 if info, err := os.Stat(worktree); err != nil || !info.IsDir() {
208 t.Fatalf("external worktree %q was not created: %v", worktree, err)
209 }
210 if got := gitTestOutput(t, worktree, "branch", "--show-current"); got != "feature/external" {
211 t.Fatalf("external branch = %q", got)
212 }
213 if got := gitTestOutput(t, repo, "config", "--local", "--get", worktreeConfigKey); got != externalRoot {
214 t.Fatalf("saved external root = %q, want %q", got, externalRoot)
215 }
216 if _, err := os.Stat(filepath.Join(repo, ".gitignore")); !os.IsNotExist(err) {
217 t.Fatalf("external bootstrap changed .gitignore: %v", err)
218 }
219 if source, _ := result.Details.(map[string]any)["root_source"].(string); source != "bootstrap selection" {
220 t.Fatalf("root source = %q", source)
221 }
222 if state, _ := result.Details.(map[string]any)["state"].(string); state != "created" {
223 t.Fatalf("state = %q, want created", state)
224 }
225 followupArgs := mustJSON(t, map[string]string{
226 "branch": "feature/external-followup",
227 "bootstrap_root": externalRoot,
228 })
229 if _, err := tool.Preview(context.Background(), followupArgs); err != nil {
230 t.Fatal(err)
231 }
232 followup, err := tool.Execute(context.Background(), followupArgs, nil)
233 if err != nil {
234 t.Fatal(err)
235 }
236 if _, err := os.Stat(filepath.Join(externalRoot, "feature", "external-followup")); err != nil {
237 t.Fatalf("configured external root was not reused: %v", err)
238 }
239 if source, _ := followup.Details.(map[string]any)["root_source"].(string); source != "local Git config" {
240 t.Fatalf("follow-up root source = %q", source)
241 }
242}
243
244func TestCreateWorktreeRejectsInvalidBootstrapRoot(t *testing.T) {
245 if _, err := exec.LookPath("git"); err != nil {
246 t.Skip("git is required")
247 }
248 repo := initWorktreeTestRepo(t)
249 tool := newCreateWorktreeTestTool(repo)
250 for _, root := range []string{"relative-worktrees", filepath.Join(repo, "company-worktrees")} {
251 _, err := tool.Execute(context.Background(), mustJSON(t, map[string]string{"branch": "feature/invalid", "bootstrap_root": root}), nil)
252 if err == nil || !strings.Contains(err.Error(), "bootstrap_root") {
253 t.Fatalf("bootstrap_root %q error = %v", root, err)
254 }
255 }
256 if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {
257 t.Fatalf("invalid bootstrap saved config: %#v", got)
258 }
259}
260
261func TestCreateWorktreeRejectsBranchSyntaxThatGitCanInterpret(t *testing.T) {
262 if _, err := exec.LookPath("git"); err != nil {
263 t.Skip("git is required")
264 }
265 repo := initWorktreeTestRepo(t)
266 tool := newCreateWorktreeTestTool(repo)
267 for _, branch := range []string{"-option", "topic@{1}"} {
268 _, err := tool.Execute(context.Background(), mustJSON(t, map[string]string{"branch": branch}), nil)
269 if err == nil || !strings.Contains(err.Error(), "invalid branch") {
270 t.Fatalf("branch %q error = %v", branch, err)
271 }
272 }
273}
274
275func TestCreateWorktreeRejectsConfiguredExternalRootResolvingInsideRepository(t *testing.T) {
276 if _, err := exec.LookPath("git"); err != nil {
277 t.Skip("git is required")
278 }
279 repo := initWorktreeTestRepo(t)
280 link := filepath.Join(t.TempDir(), "repository-link")
281 if err := os.Symlink(repo, link); err != nil {
282 t.Skipf("symbolic links are unavailable: %v", err)
283 }
284 root := filepath.Join(link, "external-worktrees")
285 gitTestOutput(t, repo, "config", "--local", worktreeConfigKey, root)
286 tool := newCreateWorktreeTestTool(repo)
287
288 _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/inside-repository"}`), nil)
289 if err == nil || !strings.Contains(err.Error(), "external worktree root must be outside the repository") {
290 t.Fatalf("error = %v, want external-root error", err)
291 }
292}
293
294func TestCreateWorktreeDisablesPostCheckoutHook(t *testing.T) {
295 if runtime.GOOS == "windows" {
296 t.Skip("Git hook fixture uses a POSIX shell")
297 }
298 if _, err := exec.LookPath("git"); err != nil {
299 t.Skip("git is required")
300 }
301 repo := initWorktreeTestRepo(t)
302 if err := os.Mkdir(filepath.Join(repo, ".worktrees"), 0o755); err != nil {
303 t.Fatal(err)
304 }
305 sentinel := filepath.Join(repo, "hook-ran")
306 hook := filepath.Join(repo, ".git", "hooks", "post-checkout")
307 script := "#!/bin/sh\ntouch " + shellQuoteForTest(sentinel) + "\n"
308 if err := os.WriteFile(hook, []byte(script), 0o755); err != nil {
309 t.Fatal(err)
310 }
311 tool := newCreateWorktreeTestTool(repo)
312
313 if _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-hook"}`), nil); err != nil {
314 t.Fatal(err)
315 }
316 worktree := filepath.Join(repo, ".worktrees", "feature", "no-hook")
317 if info, err := os.Stat(worktree); err != nil || !info.IsDir() {
318 t.Fatalf("worktree %q was not created: %v", worktree, err)
319 }
320 if _, err := os.Stat(sentinel); !os.IsNotExist(err) {
321 t.Fatalf("post-checkout hook ran: %v", err)
322 }
323}
324
325func TestCreateWorktreeCleanupRemovesPartialCheckoutAndBranch(t *testing.T) {
326 if _, err := exec.LookPath("git"); err != nil {
327 t.Skip("git is required")
328 }
329 repo := initWorktreeTestRepo(t)
330 worktreePath := filepath.Join(repo, ".worktrees", "partial")
331 if err := os.MkdirAll(filepath.Dir(worktreePath), 0o755); err != nil {
332 t.Fatal(err)
333 }
334 base := gitTestOutput(t, repo, "rev-parse", "HEAD")
335 gitTestOutput(t, repo, "worktree", "add", "-b", "feature/partial", worktreePath, base)
336 plan := createWorktreePlan{
337 repoRoot: repo,
338 branch: "feature/partial",
339 base: base,
340 worktreePath: worktreePath,
341 }
342
343 if err := plan.cleanupFailedCheckout(context.Background()); err != nil {
344 t.Fatal(err)
345 }
346 if _, err := os.Stat(worktreePath); !os.IsNotExist(err) {
347 t.Fatalf("partial worktree remains: %v", err)
348 }
349 if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/partial"); got.exitCode != 1 {
350 t.Fatalf("partial branch remains: %#v", got)
351 }
352}
353
354func TestExecuteCreateWorktreePlanRemovesCreatedParentsOnCheckoutFailure(t *testing.T) {
355 if _, err := exec.LookPath("git"); err != nil {
356 t.Skip("git is required")
357 }
358 repo := initWorktreeTestRepo(t)
359 branch := "feature/checkout-failure"
360 plan := createWorktreePlan{
361 repoRoot: repo,
362 branch: branch,
363 base: "not-a-commit",
364 worktreePath: filepath.Join(repo, defaultWorktreeRoot, "feature", "checkout-failure"),
365 configValue: defaultWorktreeRoot,
366 setConfig: true,
367 }
368
369 _, err := executeCreateWorktreePlan(context.Background(), plan, nil)
370 if err == nil || !strings.Contains(err.Error(), "create worktree") {
371 t.Fatalf("error = %v, want worktree creation error", err)
372 }
373 if _, err := os.Stat(filepath.Join(repo, defaultWorktreeRoot)); !os.IsNotExist(err) {
374 t.Fatalf("failed checkout left worktree directories: %v", err)
375 }
376 if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {
377 t.Fatalf("failed checkout kept local config: %#v", got)
378 }
379 if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/"+branch); got.exitCode != 1 {
380 t.Fatalf("failed checkout created branch: %#v", got)
381 }
382}
383
384func TestExecuteCreateWorktreePlanRollsBackBootstrapConfigOnIgnoreFailure(t *testing.T) {
385 if _, err := exec.LookPath("git"); err != nil {
386 t.Skip("git is required")
387 }
388 repo := initWorktreeTestRepo(t)
389 ignorePath := filepath.Join(repo, ".gitignore")
390 before := []byte("generated/\n")
391 if err := os.WriteFile(ignorePath, before, 0o644); err != nil {
392 t.Fatal(err)
393 }
394 if err := os.WriteFile(ignorePath, []byte("changed by another process\n"), 0o644); err != nil {
395 t.Fatal(err)
396 }
397 base := gitTestOutput(t, repo, "rev-parse", "HEAD")
398 plan := createWorktreePlan{
399 repoRoot: repo,
400 branch: "feature/ignore-failure",
401 base: base,
402 worktreePath: filepath.Join(repo, ".worktrees", "feature", "ignore-failure"),
403 configValue: defaultWorktreeRoot,
404 setConfig: true,
405 ignore: worktreesIgnoreUpdate{
406 path: ignorePath,
407 before: before,
408 after: append(append([]byte(nil), before...), []byte(worktreesIgnoreEntry+"\n")...),
409 existed: true,
410 mode: 0o644,
411 changed: true,
412 },
413 }
414
415 _, err := executeCreateWorktreePlan(context.Background(), plan, nil)
416 if err == nil || !strings.Contains(err.Error(), ".gitignore changed since preview") {
417 t.Fatalf("error = %v, want stale .gitignore error", err)
418 }
419 if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {
420 t.Fatalf("failed bootstrap kept local config: %#v", got)
421 }
422 if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/ignore-failure"); got.exitCode != 1 {
423 t.Fatalf("failed bootstrap created branch: %#v", got)
424 }
425 contents, err := os.ReadFile(ignorePath)
426 if err != nil {
427 t.Fatal(err)
428 }
429 if got, want := string(contents), "changed by another process\n"; got != want {
430 t.Fatalf(".gitignore = %q, want %q", got, want)
431 }
432}
433
434func TestCreateWorktreeAcceptsViableGitignoreEntries(t *testing.T) {
435 if _, err := exec.LookPath("git"); err != nil {
436 t.Skip("git is required")
437 }
438 for _, test := range []struct {
439 name string
440 entry string
441 }{
442 {name: "root-anchored", entry: worktreesIgnoreEntry},
443 {name: "unanchored", entry: worktreesUnanchoredIgnoreEntry},
444 } {
445 t.Run(test.name, func(t *testing.T) {
446 repo := initWorktreeTestRepo(t)
447 ignorePath := filepath.Join(repo, ".gitignore")
448 before := []byte(test.entry + "\n")
449 if err := os.WriteFile(ignorePath, before, 0o644); err != nil {
450 t.Fatal(err)
451 }
452 tool := newCreateWorktreeTestTool(repo)
453 args := mustJSON(t, map[string]string{
454 "branch": "feature/" + test.name + "-ignore",
455 "bootstrap_root": defaultWorktreeRoot,
456 })
457
458 result, err := tool.Execute(context.Background(), args, nil)
459 if err != nil {
460 t.Fatal(err)
461 }
462 text := result.Content[0].(provider.TextBlock).Text
463 if !strings.Contains(text, ".gitignore: already ignores .worktrees/") {
464 t.Fatalf("result did not recognize existing ignore entry:\n%s", text)
465 }
466 ignore, err := os.ReadFile(ignorePath)
467 if err != nil {
468 t.Fatal(err)
469 }
470 if string(ignore) != string(before) {
471 t.Fatalf(".gitignore changed from %q to %q", before, ignore)
472 }
473 })
474 }
475}
476
477func TestNewWorktreesIgnoreUpdateRejectsLookalikeEntries(t *testing.T) {
478 for _, entry := range []string{
479 "!.worktrees/\n",
480 "nested/.worktrees/\n",
481 "#.worktrees/\n",
482 ".worktrees-other/\n",
483 } {
484 t.Run(strings.TrimSpace(entry), func(t *testing.T) {
485 ignorePath := filepath.Join(t.TempDir(), ".gitignore")
486 if err := os.WriteFile(ignorePath, []byte(entry), 0o644); err != nil {
487 t.Fatal(err)
488 }
489
490 update, err := newWorktreesIgnoreUpdate(ignorePath)
491 if err != nil {
492 t.Fatal(err)
493 }
494 if !update.changed {
495 t.Fatalf("entry %q was treated as ignoring the root .worktrees directory", strings.TrimSpace(entry))
496 }
497 if got, want := string(update.after), entry+worktreesIgnoreEntry+"\n"; got != want {
498 t.Fatalf("updated .gitignore = %q, want %q", got, want)
499 }
500 })
501 }
502}
503
504func TestCreateWorktreePreservesGitignoreLineEndings(t *testing.T) {
505 if _, err := exec.LookPath("git"); err != nil {
506 t.Skip("git is required")
507 }
508 repo := initWorktreeTestRepo(t)
509 ignorePath := filepath.Join(repo, ".gitignore")
510 if err := os.WriteFile(ignorePath, []byte("generated/\r\n"), 0o644); err != nil {
511 t.Fatal(err)
512 }
513 tool := newCreateWorktreeTestTool(repo)
514
515 if _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/crlf","bootstrap_root":".worktrees"}`), nil); err != nil {
516 t.Fatal(err)
517 }
518 ignore, err := os.ReadFile(ignorePath)
519 if err != nil {
520 t.Fatal(err)
521 }
522 if got, want := string(ignore), "generated/\r\n/.worktrees/\r\n"; got != want {
523 t.Fatalf(".gitignore = %q, want %q", got, want)
524 }
525}
526
527func TestCreateWorktreeRejectsGitignoreSymbolicLink(t *testing.T) {
528 if _, err := exec.LookPath("git"); err != nil {
529 t.Skip("git is required")
530 }
531 repo := initWorktreeTestRepo(t)
532 target := filepath.Join(repo, "ignore-target")
533 if err := os.WriteFile(target, []byte("generated/\n"), 0o644); err != nil {
534 t.Fatal(err)
535 }
536 ignorePath := filepath.Join(repo, ".gitignore")
537 if err := os.Symlink(target, ignorePath); err != nil {
538 t.Skipf("symbolic links are unavailable: %v", err)
539 }
540 tool := newCreateWorktreeTestTool(repo)
541
542 _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/ignore-symlink","bootstrap_root":".worktrees"}`), nil)
543 if err == nil || !strings.Contains(err.Error(), ".gitignore must not be a symbolic link") {
544 t.Fatalf("error = %v, want symbolic-link error", err)
545 }
546 info, err := os.Lstat(ignorePath)
547 if err != nil {
548 t.Fatal(err)
549 }
550 if info.Mode()&os.ModeSymlink == 0 {
551 t.Fatal("tool replaced .gitignore symbolic link")
552 }
553 if _, statErr := os.Stat(filepath.Join(repo, ".worktrees")); !os.IsNotExist(statErr) {
554 t.Fatalf("symbolic-link rejection created .worktrees: %v", statErr)
555 }
556}
557
558func TestCreateWorktreeRejectsNonRegularGitignore(t *testing.T) {
559 if _, err := exec.LookPath("git"); err != nil {
560 t.Skip("git is required")
561 }
562 repo := initWorktreeTestRepo(t)
563 if err := os.Mkdir(filepath.Join(repo, ".gitignore"), 0o755); err != nil {
564 t.Fatal(err)
565 }
566 tool := newCreateWorktreeTestTool(repo)
567
568 _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/nonregular","bootstrap_root":".worktrees"}`), nil)
569 if err == nil || !strings.Contains(err.Error(), ".gitignore must be a regular file") {
570 t.Fatalf("error = %v, want regular-file error", err)
571 }
572 if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {
573 t.Fatalf("non-regular ignore saved config: %#v", got)
574 }
575}
576
577func TestCreateWorktreeGitOutputKeepsStderrOutOfSuccessfulOutput(t *testing.T) {
578 if runtime.GOOS == "windows" {
579 t.Skip("Git fixture uses a POSIX shell")
580 }
581 binDir := t.TempDir()
582 gitPath := filepath.Join(binDir, "git")
583 if err := os.WriteFile(gitPath, []byte("#!/bin/sh\nprintf 'value\\n'\nprintf 'warning\\n' >&2\n"), 0o755); err != nil {
584 t.Fatal(err)
585 }
586 t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
587
588 output, err := createWorktreeGitOutput(context.Background(), t.TempDir(), "status")
589 if err != nil {
590 t.Fatal(err)
591 }
592 if output != "value" {
593 t.Fatalf("output = %q, want value", output)
594 }
595}
596
597func TestCreateWorktreeGitCancelsDescendants(t *testing.T) {
598 if runtime.GOOS == "windows" {
599 t.Skip("process-group fixture uses a POSIX shell")
600 }
601 binDir := t.TempDir()
602 started := filepath.Join(t.TempDir(), "git-started")
603 childPID := filepath.Join(t.TempDir(), "child-pid")
604 gitPath := filepath.Join(binDir, "git")
605 script := "#!/bin/sh\nsleep 30 &\nprintf '%s' \"$!\" > " + shellQuoteForTest(childPID) + "\nprintf started > " + shellQuoteForTest(started) + "\nwait\n"
606 if err := os.WriteFile(gitPath, []byte(script), 0o755); err != nil {
607 t.Fatal(err)
608 }
609 t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
610 ctx, cancel := context.WithCancel(context.Background())
611 defer cancel()
612 cwd := t.TempDir()
613 done := make(chan error, 1)
614 go func() {
615 _, err := createWorktreeGitOutput(ctx, cwd, "status")
616 done <- err
617 }()
618
619 deadline := time.NewTimer(5 * time.Second)
620 defer deadline.Stop()
621 poll := time.NewTicker(10 * time.Millisecond)
622 defer poll.Stop()
623 for {
624 if _, err := os.Stat(started); err == nil {
625 break
626 } else if !os.IsNotExist(err) {
627 t.Fatal(err)
628 }
629 select {
630 case <-deadline.C:
631 t.Fatal("fake git did not start")
632 case <-poll.C:
633 }
634 }
635 cancel()
636 select {
637 case err := <-done:
638 if !errors.Is(err, context.Canceled) {
639 t.Fatalf("error = %v, want context canceled", err)
640 }
641 case <-time.After(5 * time.Second):
642 t.Fatal("Git process group did not exit after cancellation")
643 }
644 pid, err := os.ReadFile(childPID)
645 if err != nil {
646 t.Fatal(err)
647 }
648 exitDeadline := time.NewTimer(5 * time.Second)
649 defer exitDeadline.Stop()
650 for {
651 err := exec.Command("kill", "-0", strings.TrimSpace(string(pid))).Run()
652 if err != nil {
653 var exitErr *exec.ExitError
654 if errors.As(err, &exitErr) {
655 break
656 }
657 t.Fatalf("check child process: %v", err)
658 }
659 select {
660 case <-exitDeadline.C:
661 t.Fatalf("Git descendant %q remained after cancellation", pid)
662 case <-poll.C:
663 }
664 }
665}
666
667func TestCreateWorktreePreviewDoesNotModifyRepository(t *testing.T) {
668 if _, err := exec.LookPath("git"); err != nil {
669 t.Skip("git is required")
670 }
671 repo := initWorktreeTestRepo(t)
672 tool := newCreateWorktreeTestTool(repo)
673 args := json.RawMessage(`{"branch":"feature/preview"}`)
674
675 preview, err := tool.Preview(context.Background(), args)
676 if err != nil {
677 t.Fatal(err)
678 }
679 text := preview.Content[0].(provider.TextBlock).Text
680 for _, want := range []string{"Worktree bootstrap is required", "feature/preview", "bootstrap_root"} {
681 if !strings.Contains(text, want) {
682 t.Fatalf("preview missing %q:\n%s", want, text)
683 }
684 }
685 if _, err := os.Stat(filepath.Join(repo, ".worktrees")); !os.IsNotExist(err) {
686 t.Fatalf("preview created .worktrees: %v", err)
687 }
688 if _, err := os.Stat(filepath.Join(repo, ".gitignore")); !os.IsNotExist(err) {
689 t.Fatalf("preview created .gitignore: %v", err)
690 }
691 cmd := exec.Command("git", "-C", repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/preview")
692 if err := cmd.Run(); err == nil {
693 t.Fatal("preview created the branch")
694 }
695}
696
697func TestCreateWorktreeRejectsStateChangedAfterPreview(t *testing.T) {
698 if _, err := exec.LookPath("git"); err != nil {
699 t.Skip("git is required")
700 }
701 repo := initWorktreeTestRepo(t)
702 if err := os.Mkdir(filepath.Join(repo, ".worktrees"), 0o755); err != nil {
703 t.Fatal(err)
704 }
705 tool := newCreateWorktreeTestTool(repo)
706 args := json.RawMessage(`{"branch":"feature/stale-preview"}`)
707 if _, err := tool.Preview(context.Background(), args); err != nil {
708 t.Fatal(err)
709 }
710 if err := os.WriteFile(filepath.Join(repo, "after-preview.txt"), []byte("changed\n"), 0o644); err != nil {
711 t.Fatal(err)
712 }
713 gitTestOutput(t, repo, "add", "after-preview.txt")
714 gitTestOutput(t, repo, "commit", "-qm", "change after preview")
715
716 _, err := tool.Execute(context.Background(), args, nil)
717 if err == nil || !strings.Contains(err.Error(), "state changed since preview") {
718 t.Fatalf("error = %v, want stale-preview error", err)
719 }
720 if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/stale-preview"); got.exitCode != 1 {
721 t.Fatalf("stale preview created branch: %#v", got)
722 }
723}
724
725func TestCreateWorktreeRejectsNonRepository(t *testing.T) {
726 dir := t.TempDir()
727 tool := newCreateWorktreeTestTool(dir)
728
729 _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/new"}`), nil)
730 if err == nil || !strings.Contains(err.Error(), "not a Git repository") {
731 t.Fatalf("error = %v, want repository error", err)
732 }
733 for _, path := range []string{".worktrees", ".gitignore"} {
734 if _, statErr := os.Stat(filepath.Join(dir, path)); !os.IsNotExist(statErr) {
735 t.Fatalf("non-repository call created %s: %v", path, statErr)
736 }
737 }
738}
739
740func TestCreateWorktreeRequiresSandbox(t *testing.T) {
741 tool := &CreateWorktreeTool{CWD: t.TempDir()}
742
743 _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-sandbox"}`), nil)
744 if err == nil || !strings.Contains(err.Error(), "sandbox is required") {
745 t.Fatalf("error = %v, want sandbox error", err)
746 }
747}
748
749func TestCreateWorktreeRejectsExistingBranchWithoutChangingGitignore(t *testing.T) {
750 if _, err := exec.LookPath("git"); err != nil {
751 t.Skip("git is required")
752 }
753 repo := initWorktreeTestRepo(t)
754 tool := newCreateWorktreeTestTool(repo)
755 bootstrapArgs := json.RawMessage(`{"branch":"feature/existing","bootstrap_root":".worktrees"}`)
756 if _, err := tool.Execute(context.Background(), bootstrapArgs, nil); err != nil {
757 t.Fatal(err)
758 }
759 ignoreBefore, err := os.ReadFile(filepath.Join(repo, ".gitignore"))
760 if err != nil {
761 t.Fatal(err)
762 }
763
764 _, err = tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/existing"}`), nil)
765 if err == nil || !strings.Contains(err.Error(), "already exists") {
766 t.Fatalf("error = %v, want collision error", err)
767 }
768 ignoreAfter, err := os.ReadFile(filepath.Join(repo, ".gitignore"))
769 if err != nil {
770 t.Fatal(err)
771 }
772 if string(ignoreAfter) != string(ignoreBefore) {
773 t.Fatalf("collision changed .gitignore:\nbefore: %q\nafter: %q", ignoreBefore, ignoreAfter)
774 }
775}
776
777func TestCreateWorktreeRespectsJail(t *testing.T) {
778 if _, err := exec.LookPath("git"); err != nil {
779 t.Skip("git is required")
780 }
781 repo := initWorktreeTestRepo(t)
782 nested := filepath.Join(repo, "nested")
783 if err := os.Mkdir(nested, 0o755); err != nil {
784 t.Fatal(err)
785 }
786 sandbox := NewSandbox(nested)
787 sandbox.Lock()
788 tool := &CreateWorktreeTool{CWD: nested, Sandbox: sandbox}
789
790 _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/jailed"}`), nil)
791 if err == nil || !strings.Contains(err.Error(), "jailed:") {
792 t.Fatalf("error = %v, want jailed error", err)
793 }
794 for _, path := range []string{".worktrees", ".gitignore"} {
795 if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) {
796 t.Fatalf("jailed call created %s: %v", path, statErr)
797 }
798 }
799}
800
801func TestCreateWorktreeRejectsJailedAncestorBeforeGitDiscovery(t *testing.T) {
802 if runtime.GOOS == "windows" {
803 t.Skip("Git invocation fixture uses a POSIX shell")
804 }
805 if _, err := exec.LookPath("git"); err != nil {
806 t.Skip("git is required")
807 }
808 repo := initWorktreeTestRepo(t)
809 nested := filepath.Join(repo, "nested")
810 if err := os.Mkdir(nested, 0o755); err != nil {
811 t.Fatal(err)
812 }
813 sentinel := filepath.Join(repo, "git-was-invoked")
814 binDir := t.TempDir()
815 fakeGit := filepath.Join(binDir, "git")
816 if err := os.WriteFile(fakeGit, []byte("#!/bin/sh\ntouch "+shellQuoteForTest(sentinel)+"\nexit 1\n"), 0o755); err != nil {
817 t.Fatal(err)
818 }
819 t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
820 sandbox := NewSandbox(nested)
821 sandbox.Lock()
822 tool := &CreateWorktreeTool{CWD: nested, Sandbox: sandbox}
823
824 _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/jailed-discovery"}`), nil)
825 if err == nil || !strings.Contains(err.Error(), "jailed:") {
826 t.Fatalf("error = %v, want jailed error", err)
827 }
828 if _, err := os.Stat(sentinel); !os.IsNotExist(err) {
829 t.Fatalf("Git ran before jailed ancestor was rejected: %v", err)
830 }
831}
832
833func TestCreateWorktreeRequiresFilesystemPermissions(t *testing.T) {
834 if _, err := exec.LookPath("git"); err != nil {
835 t.Skip("git is required")
836 }
837 repo := initWorktreeTestRepo(t)
838 sandbox := NewSandbox(repo)
839 permissions := &PermissionSet{}
840 permissions.Bash.Mode = "allowlist"
841 permissions.Bash.Allow = []string{"git"}
842 sandbox.SetPermissions(permissions)
843 tool := &CreateWorktreeTool{CWD: repo, Sandbox: sandbox}
844
845 _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/restricted"}`), nil)
846 if err == nil || !strings.Contains(err.Error(), "no filesystem read permission") {
847 t.Fatalf("error = %v, want filesystem permission error", err)
848 }
849 for _, path := range []string{".worktrees", ".gitignore"} {
850 if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) {
851 t.Fatalf("restricted call created %s: %v", path, statErr)
852 }
853 }
854}
855
856func TestCreateWorktreeRequiresGitBashPermission(t *testing.T) {
857 if _, err := exec.LookPath("git"); err != nil {
858 t.Skip("git is required")
859 }
860 repo := initWorktreeTestRepo(t)
861 sandbox := NewSandbox(repo)
862 permissions := &PermissionSet{}
863 permissions.FS.Read = []string{repo}
864 permissions.FS.Write = []string{repo}
865 sandbox.SetPermissions(permissions)
866 tool := &CreateWorktreeTool{CWD: repo, Sandbox: sandbox}
867
868 _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-git"}`), nil)
869 if err == nil || !strings.Contains(err.Error(), "no bash permission") {
870 t.Fatalf("error = %v, want git permission error", err)
871 }
872 for _, path := range []string{".worktrees", ".gitignore"} {
873 if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) {
874 t.Fatalf("restricted call created %s: %v", path, statErr)
875 }
876 }
877}
878
879func TestCreateWorktreeRejectsInvalidWorktreePathBeforeGitignoreUpdate(t *testing.T) {
880 if _, err := exec.LookPath("git"); err != nil {
881 t.Skip("git is required")
882 }
883 repo := initWorktreeTestRepo(t)
884 worktrees := filepath.Join(repo, ".worktrees")
885 if err := os.Mkdir(worktrees, 0o755); err != nil {
886 t.Fatal(err)
887 }
888 if err := os.WriteFile(filepath.Join(worktrees, "blocked"), []byte("not a directory"), 0o644); err != nil {
889 t.Fatal(err)
890 }
891 tool := newCreateWorktreeTestTool(repo)
892
893 _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"blocked/new"}`), nil)
894 if err == nil || !strings.Contains(err.Error(), "not a directory") {
895 t.Fatalf("error = %v, want non-directory path error", err)
896 }
897 if _, statErr := os.Stat(filepath.Join(repo, ".gitignore")); !os.IsNotExist(statErr) {
898 t.Fatalf("invalid path left .gitignore behind: %v", statErr)
899 }
900 cmd := exec.Command("git", "-C", repo, "show-ref", "--verify", "--quiet", "refs/heads/blocked/new")
901 if err := cmd.Run(); err == nil {
902 t.Fatal("invalid path created the branch")
903 }
904}
905
906func newCreateWorktreeTestTool(cwd string) *CreateWorktreeTool {
907 return &CreateWorktreeTool{CWD: cwd, Sandbox: NewSandbox(cwd)}
908}
909
910func initWorktreeTestRepo(t *testing.T) string {
911 t.Helper()
912 t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
913 t.Setenv("GIT_CONFIG_SYSTEM", os.DevNull)
914 t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull)
915 repo := t.TempDir()
916 gitTestOutput(t, repo, "init", "-q")
917 gitTestOutput(t, repo, "config", "user.email", "test@example.invalid")
918 gitTestOutput(t, repo, "config", "user.name", "Test User")
919 if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("before\n"), 0o644); err != nil {
920 t.Fatal(err)
921 }
922 gitTestOutput(t, repo, "add", "README.md")
923 gitTestOutput(t, repo, "commit", "-qm", "initial")
924 return repo
925}
926
927func shellQuoteForTest(value string) string {
928 return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
929}
930
931type gitTestResult struct {
932 output string
933 exitCode int
934}
935
936func gitTestOutputAllowExit(t *testing.T, dir string, args ...string) gitTestResult {
937 t.Helper()
938 cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
939 output, err := cmd.CombinedOutput()
940 if err == nil {
941 return gitTestResult{output: strings.TrimSpace(string(output))}
942 }
943 if exitErr, ok := err.(*exec.ExitError); ok {
944 return gitTestResult{output: strings.TrimSpace(string(output)), exitCode: exitErr.ExitCode()}
945 }
946 t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, output)
947 return gitTestResult{}
948}
949
950func gitTestOutput(t *testing.T, dir string, args ...string) string {
951 t.Helper()
952 cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
953 output, err := cmd.CombinedOutput()
954 if err != nil {
955 t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, output)
956 }
957 return strings.TrimSpace(string(output))
958}