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