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 TestCreateWorktreePreservesGitignoreLineEndings(t *testing.T) {
435 if _, err := exec.LookPath("git"); err != nil {
436 t.Skip("git is required")
437 }
438 repo := initWorktreeTestRepo(t)
439 ignorePath := filepath.Join(repo, ".gitignore")
440 if err := os.WriteFile(ignorePath, []byte("generated/\r\n"), 0o644); err != nil {
441 t.Fatal(err)
442 }
443 tool := newCreateWorktreeTestTool(repo)
444
445 if _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/crlf","bootstrap_root":".worktrees"}`), nil); err != nil {
446 t.Fatal(err)
447 }
448 ignore, err := os.ReadFile(ignorePath)
449 if err != nil {
450 t.Fatal(err)
451 }
452 if got, want := string(ignore), "generated/\r\n/.worktrees/\r\n"; got != want {
453 t.Fatalf(".gitignore = %q, want %q", got, want)
454 }
455}
456
457func TestCreateWorktreeRejectsGitignoreSymbolicLink(t *testing.T) {
458 if _, err := exec.LookPath("git"); err != nil {
459 t.Skip("git is required")
460 }
461 repo := initWorktreeTestRepo(t)
462 target := filepath.Join(repo, "ignore-target")
463 if err := os.WriteFile(target, []byte("generated/\n"), 0o644); err != nil {
464 t.Fatal(err)
465 }
466 ignorePath := filepath.Join(repo, ".gitignore")
467 if err := os.Symlink(target, ignorePath); err != nil {
468 t.Skipf("symbolic links are unavailable: %v", err)
469 }
470 tool := newCreateWorktreeTestTool(repo)
471
472 _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/ignore-symlink","bootstrap_root":".worktrees"}`), nil)
473 if err == nil || !strings.Contains(err.Error(), ".gitignore must not be a symbolic link") {
474 t.Fatalf("error = %v, want symbolic-link error", err)
475 }
476 info, err := os.Lstat(ignorePath)
477 if err != nil {
478 t.Fatal(err)
479 }
480 if info.Mode()&os.ModeSymlink == 0 {
481 t.Fatal("tool replaced .gitignore symbolic link")
482 }
483 if _, statErr := os.Stat(filepath.Join(repo, ".worktrees")); !os.IsNotExist(statErr) {
484 t.Fatalf("symbolic-link rejection created .worktrees: %v", statErr)
485 }
486}
487
488func TestCreateWorktreeRejectsNonRegularGitignore(t *testing.T) {
489 if _, err := exec.LookPath("git"); err != nil {
490 t.Skip("git is required")
491 }
492 repo := initWorktreeTestRepo(t)
493 if err := os.Mkdir(filepath.Join(repo, ".gitignore"), 0o755); err != nil {
494 t.Fatal(err)
495 }
496 tool := newCreateWorktreeTestTool(repo)
497
498 _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/nonregular","bootstrap_root":".worktrees"}`), nil)
499 if err == nil || !strings.Contains(err.Error(), ".gitignore must be a regular file") {
500 t.Fatalf("error = %v, want regular-file error", err)
501 }
502 if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {
503 t.Fatalf("non-regular ignore saved config: %#v", got)
504 }
505}
506
507func TestCreateWorktreeGitOutputKeepsStderrOutOfSuccessfulOutput(t *testing.T) {
508 if runtime.GOOS == "windows" {
509 t.Skip("Git fixture uses a POSIX shell")
510 }
511 binDir := t.TempDir()
512 gitPath := filepath.Join(binDir, "git")
513 if err := os.WriteFile(gitPath, []byte("#!/bin/sh\nprintf 'value\\n'\nprintf 'warning\\n' >&2\n"), 0o755); err != nil {
514 t.Fatal(err)
515 }
516 t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
517
518 output, err := createWorktreeGitOutput(context.Background(), t.TempDir(), "status")
519 if err != nil {
520 t.Fatal(err)
521 }
522 if output != "value" {
523 t.Fatalf("output = %q, want value", output)
524 }
525}
526
527func TestCreateWorktreeGitCancelsDescendants(t *testing.T) {
528 if runtime.GOOS == "windows" {
529 t.Skip("process-group fixture uses a POSIX shell")
530 }
531 binDir := t.TempDir()
532 started := filepath.Join(t.TempDir(), "git-started")
533 childPID := filepath.Join(t.TempDir(), "child-pid")
534 gitPath := filepath.Join(binDir, "git")
535 script := "#!/bin/sh\nsleep 30 &\nprintf '%s' \"$!\" > " + shellQuoteForTest(childPID) + "\nprintf started > " + shellQuoteForTest(started) + "\nwait\n"
536 if err := os.WriteFile(gitPath, []byte(script), 0o755); err != nil {
537 t.Fatal(err)
538 }
539 t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
540 ctx, cancel := context.WithCancel(context.Background())
541 defer cancel()
542 cwd := t.TempDir()
543 done := make(chan error, 1)
544 go func() {
545 _, err := createWorktreeGitOutput(ctx, cwd, "status")
546 done <- err
547 }()
548
549 deadline := time.NewTimer(5 * time.Second)
550 defer deadline.Stop()
551 poll := time.NewTicker(10 * time.Millisecond)
552 defer poll.Stop()
553 for {
554 if _, err := os.Stat(started); err == nil {
555 break
556 } else if !os.IsNotExist(err) {
557 t.Fatal(err)
558 }
559 select {
560 case <-deadline.C:
561 t.Fatal("fake git did not start")
562 case <-poll.C:
563 }
564 }
565 cancel()
566 select {
567 case err := <-done:
568 if !errors.Is(err, context.Canceled) {
569 t.Fatalf("error = %v, want context canceled", err)
570 }
571 case <-time.After(5 * time.Second):
572 t.Fatal("Git process group did not exit after cancellation")
573 }
574 pid, err := os.ReadFile(childPID)
575 if err != nil {
576 t.Fatal(err)
577 }
578 exitDeadline := time.NewTimer(5 * time.Second)
579 defer exitDeadline.Stop()
580 for {
581 err := exec.Command("kill", "-0", strings.TrimSpace(string(pid))).Run()
582 if err != nil {
583 var exitErr *exec.ExitError
584 if errors.As(err, &exitErr) {
585 break
586 }
587 t.Fatalf("check child process: %v", err)
588 }
589 select {
590 case <-exitDeadline.C:
591 t.Fatalf("Git descendant %q remained after cancellation", pid)
592 case <-poll.C:
593 }
594 }
595}
596
597func TestCreateWorktreePreviewDoesNotModifyRepository(t *testing.T) {
598 if _, err := exec.LookPath("git"); err != nil {
599 t.Skip("git is required")
600 }
601 repo := initWorktreeTestRepo(t)
602 tool := newCreateWorktreeTestTool(repo)
603 args := json.RawMessage(`{"branch":"feature/preview"}`)
604
605 preview, err := tool.Preview(context.Background(), args)
606 if err != nil {
607 t.Fatal(err)
608 }
609 text := preview.Content[0].(provider.TextBlock).Text
610 for _, want := range []string{"Worktree bootstrap is required", "feature/preview", "bootstrap_root"} {
611 if !strings.Contains(text, want) {
612 t.Fatalf("preview missing %q:\n%s", want, text)
613 }
614 }
615 if _, err := os.Stat(filepath.Join(repo, ".worktrees")); !os.IsNotExist(err) {
616 t.Fatalf("preview created .worktrees: %v", err)
617 }
618 if _, err := os.Stat(filepath.Join(repo, ".gitignore")); !os.IsNotExist(err) {
619 t.Fatalf("preview created .gitignore: %v", err)
620 }
621 cmd := exec.Command("git", "-C", repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/preview")
622 if err := cmd.Run(); err == nil {
623 t.Fatal("preview created the branch")
624 }
625}
626
627func TestCreateWorktreeRejectsStateChangedAfterPreview(t *testing.T) {
628 if _, err := exec.LookPath("git"); err != nil {
629 t.Skip("git is required")
630 }
631 repo := initWorktreeTestRepo(t)
632 if err := os.Mkdir(filepath.Join(repo, ".worktrees"), 0o755); err != nil {
633 t.Fatal(err)
634 }
635 tool := newCreateWorktreeTestTool(repo)
636 args := json.RawMessage(`{"branch":"feature/stale-preview"}`)
637 if _, err := tool.Preview(context.Background(), args); err != nil {
638 t.Fatal(err)
639 }
640 if err := os.WriteFile(filepath.Join(repo, "after-preview.txt"), []byte("changed\n"), 0o644); err != nil {
641 t.Fatal(err)
642 }
643 gitTestOutput(t, repo, "add", "after-preview.txt")
644 gitTestOutput(t, repo, "commit", "-qm", "change after preview")
645
646 _, err := tool.Execute(context.Background(), args, nil)
647 if err == nil || !strings.Contains(err.Error(), "state changed since preview") {
648 t.Fatalf("error = %v, want stale-preview error", err)
649 }
650 if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/stale-preview"); got.exitCode != 1 {
651 t.Fatalf("stale preview created branch: %#v", got)
652 }
653}
654
655func TestCreateWorktreeRejectsNonRepository(t *testing.T) {
656 dir := t.TempDir()
657 tool := newCreateWorktreeTestTool(dir)
658
659 _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/new"}`), nil)
660 if err == nil || !strings.Contains(err.Error(), "not a Git repository") {
661 t.Fatalf("error = %v, want repository error", err)
662 }
663 for _, path := range []string{".worktrees", ".gitignore"} {
664 if _, statErr := os.Stat(filepath.Join(dir, path)); !os.IsNotExist(statErr) {
665 t.Fatalf("non-repository call created %s: %v", path, statErr)
666 }
667 }
668}
669
670func TestCreateWorktreeRequiresSandbox(t *testing.T) {
671 tool := &CreateWorktreeTool{CWD: t.TempDir()}
672
673 _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-sandbox"}`), nil)
674 if err == nil || !strings.Contains(err.Error(), "sandbox is required") {
675 t.Fatalf("error = %v, want sandbox error", err)
676 }
677}
678
679func TestCreateWorktreeRejectsExistingBranchWithoutChangingGitignore(t *testing.T) {
680 if _, err := exec.LookPath("git"); err != nil {
681 t.Skip("git is required")
682 }
683 repo := initWorktreeTestRepo(t)
684 tool := newCreateWorktreeTestTool(repo)
685 bootstrapArgs := json.RawMessage(`{"branch":"feature/existing","bootstrap_root":".worktrees"}`)
686 if _, err := tool.Execute(context.Background(), bootstrapArgs, nil); err != nil {
687 t.Fatal(err)
688 }
689 ignoreBefore, err := os.ReadFile(filepath.Join(repo, ".gitignore"))
690 if err != nil {
691 t.Fatal(err)
692 }
693
694 _, err = tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/existing"}`), nil)
695 if err == nil || !strings.Contains(err.Error(), "already exists") {
696 t.Fatalf("error = %v, want collision error", err)
697 }
698 ignoreAfter, err := os.ReadFile(filepath.Join(repo, ".gitignore"))
699 if err != nil {
700 t.Fatal(err)
701 }
702 if string(ignoreAfter) != string(ignoreBefore) {
703 t.Fatalf("collision changed .gitignore:\nbefore: %q\nafter: %q", ignoreBefore, ignoreAfter)
704 }
705}
706
707func TestCreateWorktreeRespectsJail(t *testing.T) {
708 if _, err := exec.LookPath("git"); err != nil {
709 t.Skip("git is required")
710 }
711 repo := initWorktreeTestRepo(t)
712 nested := filepath.Join(repo, "nested")
713 if err := os.Mkdir(nested, 0o755); err != nil {
714 t.Fatal(err)
715 }
716 sandbox := NewSandbox(nested)
717 sandbox.Lock()
718 tool := &CreateWorktreeTool{CWD: nested, Sandbox: sandbox}
719
720 _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/jailed"}`), nil)
721 if err == nil || !strings.Contains(err.Error(), "jailed:") {
722 t.Fatalf("error = %v, want jailed error", err)
723 }
724 for _, path := range []string{".worktrees", ".gitignore"} {
725 if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) {
726 t.Fatalf("jailed call created %s: %v", path, statErr)
727 }
728 }
729}
730
731func TestCreateWorktreeRejectsJailedAncestorBeforeGitDiscovery(t *testing.T) {
732 if runtime.GOOS == "windows" {
733 t.Skip("Git invocation fixture uses a POSIX shell")
734 }
735 if _, err := exec.LookPath("git"); err != nil {
736 t.Skip("git is required")
737 }
738 repo := initWorktreeTestRepo(t)
739 nested := filepath.Join(repo, "nested")
740 if err := os.Mkdir(nested, 0o755); err != nil {
741 t.Fatal(err)
742 }
743 sentinel := filepath.Join(repo, "git-was-invoked")
744 binDir := t.TempDir()
745 fakeGit := filepath.Join(binDir, "git")
746 if err := os.WriteFile(fakeGit, []byte("#!/bin/sh\ntouch "+shellQuoteForTest(sentinel)+"\nexit 1\n"), 0o755); err != nil {
747 t.Fatal(err)
748 }
749 t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
750 sandbox := NewSandbox(nested)
751 sandbox.Lock()
752 tool := &CreateWorktreeTool{CWD: nested, Sandbox: sandbox}
753
754 _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/jailed-discovery"}`), nil)
755 if err == nil || !strings.Contains(err.Error(), "jailed:") {
756 t.Fatalf("error = %v, want jailed error", err)
757 }
758 if _, err := os.Stat(sentinel); !os.IsNotExist(err) {
759 t.Fatalf("Git ran before jailed ancestor was rejected: %v", err)
760 }
761}
762
763func TestCreateWorktreeRequiresFilesystemPermissions(t *testing.T) {
764 if _, err := exec.LookPath("git"); err != nil {
765 t.Skip("git is required")
766 }
767 repo := initWorktreeTestRepo(t)
768 sandbox := NewSandbox(repo)
769 permissions := &PermissionSet{}
770 permissions.Bash.Mode = "allowlist"
771 permissions.Bash.Allow = []string{"git"}
772 sandbox.SetPermissions(permissions)
773 tool := &CreateWorktreeTool{CWD: repo, Sandbox: sandbox}
774
775 _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/restricted"}`), nil)
776 if err == nil || !strings.Contains(err.Error(), "no filesystem read permission") {
777 t.Fatalf("error = %v, want filesystem permission error", err)
778 }
779 for _, path := range []string{".worktrees", ".gitignore"} {
780 if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) {
781 t.Fatalf("restricted call created %s: %v", path, statErr)
782 }
783 }
784}
785
786func TestCreateWorktreeRequiresGitBashPermission(t *testing.T) {
787 if _, err := exec.LookPath("git"); err != nil {
788 t.Skip("git is required")
789 }
790 repo := initWorktreeTestRepo(t)
791 sandbox := NewSandbox(repo)
792 permissions := &PermissionSet{}
793 permissions.FS.Read = []string{repo}
794 permissions.FS.Write = []string{repo}
795 sandbox.SetPermissions(permissions)
796 tool := &CreateWorktreeTool{CWD: repo, Sandbox: sandbox}
797
798 _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-git"}`), nil)
799 if err == nil || !strings.Contains(err.Error(), "no bash permission") {
800 t.Fatalf("error = %v, want git permission error", err)
801 }
802 for _, path := range []string{".worktrees", ".gitignore"} {
803 if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) {
804 t.Fatalf("restricted call created %s: %v", path, statErr)
805 }
806 }
807}
808
809func TestCreateWorktreeRejectsInvalidWorktreePathBeforeGitignoreUpdate(t *testing.T) {
810 if _, err := exec.LookPath("git"); err != nil {
811 t.Skip("git is required")
812 }
813 repo := initWorktreeTestRepo(t)
814 worktrees := filepath.Join(repo, ".worktrees")
815 if err := os.Mkdir(worktrees, 0o755); err != nil {
816 t.Fatal(err)
817 }
818 if err := os.WriteFile(filepath.Join(worktrees, "blocked"), []byte("not a directory"), 0o644); err != nil {
819 t.Fatal(err)
820 }
821 tool := newCreateWorktreeTestTool(repo)
822
823 _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"blocked/new"}`), nil)
824 if err == nil || !strings.Contains(err.Error(), "not a directory") {
825 t.Fatalf("error = %v, want non-directory path error", err)
826 }
827 if _, statErr := os.Stat(filepath.Join(repo, ".gitignore")); !os.IsNotExist(statErr) {
828 t.Fatalf("invalid path left .gitignore behind: %v", statErr)
829 }
830 cmd := exec.Command("git", "-C", repo, "show-ref", "--verify", "--quiet", "refs/heads/blocked/new")
831 if err := cmd.Run(); err == nil {
832 t.Fatal("invalid path created the branch")
833 }
834}
835
836func newCreateWorktreeTestTool(cwd string) *CreateWorktreeTool {
837 return &CreateWorktreeTool{CWD: cwd, Sandbox: NewSandbox(cwd)}
838}
839
840func initWorktreeTestRepo(t *testing.T) string {
841 t.Helper()
842 t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
843 t.Setenv("GIT_CONFIG_SYSTEM", os.DevNull)
844 t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull)
845 repo := t.TempDir()
846 gitTestOutput(t, repo, "init", "-q")
847 gitTestOutput(t, repo, "config", "user.email", "test@example.invalid")
848 gitTestOutput(t, repo, "config", "user.name", "Test User")
849 if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("before\n"), 0o644); err != nil {
850 t.Fatal(err)
851 }
852 gitTestOutput(t, repo, "add", "README.md")
853 gitTestOutput(t, repo, "commit", "-qm", "initial")
854 return repo
855}
856
857func shellQuoteForTest(value string) string {
858 return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
859}
860
861type gitTestResult struct {
862 output string
863 exitCode int
864}
865
866func gitTestOutputAllowExit(t *testing.T, dir string, args ...string) gitTestResult {
867 t.Helper()
868 cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
869 output, err := cmd.CombinedOutput()
870 if err == nil {
871 return gitTestResult{output: strings.TrimSpace(string(output))}
872 }
873 if exitErr, ok := err.(*exec.ExitError); ok {
874 return gitTestResult{output: strings.TrimSpace(string(output)), exitCode: exitErr.ExitCode()}
875 }
876 t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, output)
877 return gitTestResult{}
878}
879
880func gitTestOutput(t *testing.T, dir string, args ...string) string {
881 t.Helper()
882 cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
883 output, err := cmd.CombinedOutput()
884 if err != nil {
885 t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, output)
886 }
887 return strings.TrimSpace(string(output))
888}