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