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 _, ok := parsed.Properties["base"]; !ok {
269 t.Fatalf("schema lacks base: %s", schema)
270 }
271 if len(parsed.Properties) != 2 {
272 t.Fatalf("schema properties = %v, want only branch and base", parsed.Properties)
273 }
274 if len(parsed.Required) != 1 || parsed.Required[0] != "branch" {
275 t.Fatalf("schema required = %v", parsed.Required)
276 }
277}
278
279func TestWorktreeRepoIDIsReadableStableAndDistinct(t *testing.T) {
280 tests := []struct {
281 name string
282 commonDir string
283 slug string
284 }{
285 {name: "worktree metadata", commonDir: filepath.Join("home", "u", "src", "my-repo", ".git"), slug: "my-repo"},
286 {name: "bare repository", commonDir: filepath.Join("srv", "git", "tools.git"), slug: "tools"},
287 {name: "unusual characters", commonDir: filepath.Join("home", "u", "My Project!", ".git"), slug: "my-project"},
288 {name: "empty slug", commonDir: filepath.Join("home", "@@@", ".git"), slug: "repo"},
289 }
290 for _, test := range tests {
291 t.Run(test.name, func(t *testing.T) {
292 id := worktreeRepoID(test.commonDir)
293 if want := test.slug + "-"; !strings.HasPrefix(id, want) {
294 t.Fatalf("repo ID = %q, want prefix %q", id, want)
295 }
296 if got := len(id) - len(test.slug) - 1; got != worktreeRepoIDHashLength {
297 t.Fatalf("repo ID hash length = %d, want %d", got, worktreeRepoIDHashLength)
298 }
299 if again := worktreeRepoID(test.commonDir); again != id {
300 t.Fatalf("repo ID is not stable: %q then %q", id, again)
301 }
302 })
303 }
304 if a, b := worktreeRepoID(filepath.Join("a", "shared", ".git")), worktreeRepoID(filepath.Join("b", "shared", ".git")); a == b {
305 t.Fatalf("distinct repositories share repo ID %q", a)
306 }
307 if a, b := worktreeRepoID(filepath.Join("a", "Shared", ".git")), worktreeRepoID(filepath.Join("a", "shared", ".git")); a == b {
308 t.Fatalf("case-different repositories share repo ID %q", a)
309 }
310}
311
312func TestCreateWorktreeRejectsBranchSyntaxThatGitCanInterpret(t *testing.T) {
313 if _, err := exec.LookPath("git"); err != nil {
314 t.Skip("git is required")
315 }
316 repo := initWorktreeTestRepo(t)
317 tool := newCreateWorktreeTestTool(t, repo, filepath.Join(t.TempDir(), "worktrees"))
318 for _, branch := range []string{"-option", "topic@{1}"} {
319 _, err := tool.Execute(context.Background(), mustJSON(t, map[string]string{"branch": branch}), nil)
320 if err == nil || !strings.Contains(err.Error(), "invalid branch") {
321 t.Fatalf("branch %q error = %v", branch, err)
322 }
323 }
324}
325
326func TestCreateWorktreeRejectsNonRepository(t *testing.T) {
327 dir := t.TempDir()
328 defaultRoot := filepath.Join(t.TempDir(), "worktrees")
329 tool := newCreateWorktreeTestTool(t, dir, defaultRoot)
330
331 _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/new"}`), nil)
332 if err == nil || !strings.Contains(err.Error(), "not a Git repository") {
333 t.Fatalf("error = %v, want repository error", err)
334 }
335 if _, statErr := os.Stat(defaultRoot); !os.IsNotExist(statErr) {
336 t.Fatalf("non-repository call created the default root: %v", statErr)
337 }
338}
339
340func TestCreateWorktreeRequiresSandbox(t *testing.T) {
341 tool := &CreateWorktreeTool{CWD: t.TempDir(), DefaultRoot: filepath.Join(t.TempDir(), "worktrees")}
342
343 _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-sandbox"}`), nil)
344 if err == nil || !strings.Contains(err.Error(), "sandbox is required") {
345 t.Fatalf("error = %v, want sandbox error", err)
346 }
347}
348
349func TestCreateWorktreeRejectsExistingBranch(t *testing.T) {
350 if _, err := exec.LookPath("git"); err != nil {
351 t.Skip("git is required")
352 }
353 repo := initWorktreeTestRepo(t)
354 tool := newCreateWorktreeTestTool(t, repo, filepath.Join(t.TempDir(), "worktrees"))
355 args := json.RawMessage(`{"branch":"feature/existing"}`)
356 if _, err := tool.Execute(context.Background(), args, nil); err != nil {
357 t.Fatal(err)
358 }
359
360 _, err := tool.Execute(context.Background(), args, nil)
361 if err == nil || !strings.Contains(err.Error(), "already exists") {
362 t.Fatalf("error = %v, want collision error", err)
363 }
364}
365
366func TestCreateWorktreeDisablesPostCheckoutHook(t *testing.T) {
367 if runtime.GOOS == "windows" {
368 t.Skip("Git hook fixture uses a POSIX shell")
369 }
370 if _, err := exec.LookPath("git"); err != nil {
371 t.Skip("git is required")
372 }
373 repo := initWorktreeTestRepo(t)
374 sentinel := filepath.Join(repo, "hook-ran")
375 hook := filepath.Join(repo, ".git", "hooks", "post-checkout")
376 script := "#!/bin/sh\ntouch " + shellQuoteForTest(sentinel) + "\n"
377 if err := os.WriteFile(hook, []byte(script), 0o755); err != nil {
378 t.Fatal(err)
379 }
380 defaultRoot := filepath.Join(t.TempDir(), "worktrees")
381 tool := newCreateWorktreeTestTool(t, repo, defaultRoot)
382
383 if _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-hook"}`), nil); err != nil {
384 t.Fatal(err)
385 }
386 worktree := filepath.Join(defaultRoot, testWorktreeRepoID(t, repo), "feature", "no-hook")
387 if info, err := os.Stat(worktree); err != nil || !info.IsDir() {
388 t.Fatalf("worktree %q was not created: %v", worktree, err)
389 }
390 if _, err := os.Stat(sentinel); !os.IsNotExist(err) {
391 t.Fatalf("post-checkout hook ran: %v", err)
392 }
393}
394
395func TestCreateWorktreeCleanupRemovesPartialCheckoutAndBranch(t *testing.T) {
396 if _, err := exec.LookPath("git"); err != nil {
397 t.Skip("git is required")
398 }
399 repo := initWorktreeTestRepo(t)
400 worktreePath := filepath.Join(t.TempDir(), "worktrees", "partial")
401 if err := os.MkdirAll(filepath.Dir(worktreePath), 0o755); err != nil {
402 t.Fatal(err)
403 }
404 base := gitTestOutput(t, repo, "rev-parse", "HEAD")
405 gitTestOutput(t, repo, "worktree", "add", "-b", "feature/partial", worktreePath, base)
406 plan := createWorktreePlan{
407 repoRoot: repo,
408 branch: "feature/partial",
409 base: base,
410 worktreePath: worktreePath,
411 }
412
413 if err := plan.cleanupFailedCheckout(context.Background()); err != nil {
414 t.Fatal(err)
415 }
416 if _, err := os.Stat(worktreePath); !os.IsNotExist(err) {
417 t.Fatalf("partial worktree remains: %v", err)
418 }
419 if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/partial"); got.exitCode != 1 {
420 t.Fatalf("partial branch remains: %#v", got)
421 }
422}
423
424func TestExecuteCreateWorktreePlanRemovesCreatedParentsOnCheckoutFailure(t *testing.T) {
425 if _, err := exec.LookPath("git"); err != nil {
426 t.Skip("git is required")
427 }
428 repo := initWorktreeTestRepo(t)
429 branch := "feature/checkout-failure"
430 root := filepath.Join(t.TempDir(), "worktrees", "repo-id")
431 plan := createWorktreePlan{
432 repoRoot: repo,
433 branch: branch,
434 base: "not-a-commit",
435 worktreeRoot: root,
436 worktreePath: filepath.Join(root, "feature", "checkout-failure"),
437 rootSource: "global default root",
438 }
439
440 _, err := executeCreateWorktreePlan(context.Background(), plan, nil)
441 if err == nil || !strings.Contains(err.Error(), "create worktree") {
442 t.Fatalf("error = %v, want worktree creation error", err)
443 }
444 if _, err := os.Stat(root); !os.IsNotExist(err) {
445 t.Fatalf("failed checkout left worktree directories: %v", err)
446 }
447 if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/"+branch); got.exitCode != 1 {
448 t.Fatalf("failed checkout created branch: %#v", got)
449 }
450}
451
452func TestCreateWorktreePreviewDoesNotModifyRepository(t *testing.T) {
453 if _, err := exec.LookPath("git"); err != nil {
454 t.Skip("git is required")
455 }
456 repo := initWorktreeTestRepo(t)
457 defaultRoot := filepath.Join(t.TempDir(), "worktrees")
458 tool := newCreateWorktreeTestTool(t, repo, defaultRoot)
459 args := json.RawMessage(`{"branch":"feature/preview"}`)
460
461 preview, err := tool.Preview(context.Background(), args)
462 if err != nil {
463 t.Fatal(err)
464 }
465 text := preview.Content[0].(provider.TextBlock).Text
466 for _, want := range []string{"create worktree", "feature/preview", "global default root", testWorktreeRepoID(t, repo)} {
467 if !strings.Contains(text, want) {
468 t.Fatalf("preview missing %q:\n%s", want, text)
469 }
470 }
471 if state, _ := preview.Details.(map[string]any)["state"].(string); state != "ready" {
472 t.Fatalf("preview state = %q, want ready", state)
473 }
474 if _, err := os.Stat(defaultRoot); !os.IsNotExist(err) {
475 t.Fatalf("preview created the default root: %v", err)
476 }
477 for _, path := range []string{".worktrees", ".gitignore"} {
478 if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) {
479 t.Fatalf("preview created repository file %s: %v", path, statErr)
480 }
481 }
482 cmd := exec.Command("git", "-C", repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/preview")
483 if err := cmd.Run(); err == nil {
484 t.Fatal("preview created the branch")
485 }
486}
487
488func TestCreateWorktreePreviewThenExecuteCreatesWorktree(t *testing.T) {
489 if _, err := exec.LookPath("git"); err != nil {
490 t.Skip("git is required")
491 }
492 repo := initWorktreeTestRepo(t)
493 defaultRoot := filepath.Join(t.TempDir(), "worktrees")
494 tool := newCreateWorktreeTestTool(t, repo, defaultRoot)
495 args := json.RawMessage(`{"branch":"feature/previewed"}`)
496
497 if _, err := tool.Preview(context.Background(), args); err != nil {
498 t.Fatal(err)
499 }
500 result, err := tool.Execute(context.Background(), args, nil)
501 if err != nil {
502 t.Fatal(err)
503 }
504 worktree := filepath.Join(defaultRoot, testWorktreeRepoID(t, repo), "feature", "previewed")
505 if info, err := os.Stat(worktree); err != nil || !info.IsDir() {
506 t.Fatalf("worktree %q was not created: %v", worktree, err)
507 }
508 if state, _ := result.Details.(map[string]any)["state"].(string); state != "created" {
509 t.Fatalf("state = %q, want created", state)
510 }
511}
512
513func TestCreateWorktreeRejectsStateChangedAfterPreview(t *testing.T) {
514 if _, err := exec.LookPath("git"); err != nil {
515 t.Skip("git is required")
516 }
517 repo := initWorktreeTestRepo(t)
518 tool := newCreateWorktreeTestTool(t, repo, filepath.Join(t.TempDir(), "worktrees"))
519 args := json.RawMessage(`{"branch":"feature/stale-preview"}`)
520 if _, err := tool.Preview(context.Background(), args); err != nil {
521 t.Fatal(err)
522 }
523 if err := os.WriteFile(filepath.Join(repo, "after-preview.txt"), []byte("changed\n"), 0o644); err != nil {
524 t.Fatal(err)
525 }
526 gitTestOutput(t, repo, "add", "after-preview.txt")
527 gitTestOutput(t, repo, "commit", "-qm", "change after preview")
528
529 _, err := tool.Execute(context.Background(), args, nil)
530 if err == nil || !strings.Contains(err.Error(), "state changed since preview") {
531 t.Fatalf("error = %v, want stale-preview error", err)
532 }
533 if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/stale-preview"); got.exitCode != 1 {
534 t.Fatalf("stale preview created branch: %#v", got)
535 }
536}
537
538func TestCreateWorktreeRespectsJail(t *testing.T) {
539 if _, err := exec.LookPath("git"); err != nil {
540 t.Skip("git is required")
541 }
542 repo := initWorktreeTestRepo(t)
543 nested := filepath.Join(repo, "nested")
544 if err := os.Mkdir(nested, 0o755); err != nil {
545 t.Fatal(err)
546 }
547 defaultRoot := filepath.Join(t.TempDir(), "worktrees")
548 sandbox := NewSandbox(nested)
549 sandbox.Lock()
550 tool := &CreateWorktreeTool{CWD: nested, Sandbox: sandbox, DefaultRoot: defaultRoot}
551
552 _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/jailed"}`), nil)
553 if err == nil || !strings.Contains(err.Error(), "jailed:") {
554 t.Fatalf("error = %v, want jailed error", err)
555 }
556 for _, path := range []string{filepath.Join(repo, ".worktrees"), filepath.Join(repo, ".gitignore")} {
557 if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
558 t.Fatalf("jailed call created %s: %v", path, statErr)
559 }
560 }
561 if _, statErr := os.Stat(defaultRoot); !os.IsNotExist(statErr) {
562 t.Fatalf("jailed call created the default root: %v", statErr)
563 }
564}
565
566func TestCreateWorktreeRejectsJailedAncestorBeforeGitDiscovery(t *testing.T) {
567 if runtime.GOOS == "windows" {
568 t.Skip("Git invocation fixture uses a POSIX shell")
569 }
570 if _, err := exec.LookPath("git"); err != nil {
571 t.Skip("git is required")
572 }
573 repo := initWorktreeTestRepo(t)
574 nested := filepath.Join(repo, "nested")
575 if err := os.Mkdir(nested, 0o755); err != nil {
576 t.Fatal(err)
577 }
578 sentinel := filepath.Join(repo, "git-was-invoked")
579 binDir := t.TempDir()
580 fakeGit := filepath.Join(binDir, "git")
581 if err := os.WriteFile(fakeGit, []byte("#!/bin/sh\ntouch "+shellQuoteForTest(sentinel)+"\nexit 1\n"), 0o755); err != nil {
582 t.Fatal(err)
583 }
584 t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
585 sandbox := NewSandbox(nested)
586 sandbox.Lock()
587 tool := &CreateWorktreeTool{CWD: nested, Sandbox: sandbox, DefaultRoot: filepath.Join(t.TempDir(), "worktrees")}
588
589 _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/jailed-discovery"}`), nil)
590 if err == nil || !strings.Contains(err.Error(), "jailed:") {
591 t.Fatalf("error = %v, want jailed error", err)
592 }
593 if _, err := os.Stat(sentinel); !os.IsNotExist(err) {
594 t.Fatalf("Git ran before jailed ancestor was rejected: %v", err)
595 }
596}
597
598func TestCreateWorktreeRequiresFilesystemPermissions(t *testing.T) {
599 if _, err := exec.LookPath("git"); err != nil {
600 t.Skip("git is required")
601 }
602 repo := initWorktreeTestRepo(t)
603 defaultRoot := filepath.Join(t.TempDir(), "worktrees")
604 sandbox := NewSandbox(repo)
605 permissions := &PermissionSet{}
606 permissions.Bash.Mode = "allowlist"
607 permissions.Bash.Allow = []string{"git"}
608 sandbox.SetPermissions(permissions)
609 tool := &CreateWorktreeTool{CWD: repo, Sandbox: sandbox, DefaultRoot: defaultRoot}
610
611 _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/restricted"}`), nil)
612 if err == nil || !strings.Contains(err.Error(), "no filesystem read permission") {
613 t.Fatalf("error = %v, want filesystem permission error", err)
614 }
615 if _, statErr := os.Stat(defaultRoot); !os.IsNotExist(statErr) {
616 t.Fatalf("restricted call created the default root: %v", statErr)
617 }
618}
619
620func TestCreateWorktreeRequiresGitBashPermission(t *testing.T) {
621 if _, err := exec.LookPath("git"); err != nil {
622 t.Skip("git is required")
623 }
624 repo := initWorktreeTestRepo(t)
625 defaultRoot := filepath.Join(t.TempDir(), "worktrees")
626 sandbox := NewSandbox(repo)
627 permissions := &PermissionSet{}
628 permissions.FS.Read = []string{repo}
629 permissions.FS.Write = []string{repo}
630 sandbox.SetPermissions(permissions)
631 tool := &CreateWorktreeTool{CWD: repo, Sandbox: sandbox, DefaultRoot: defaultRoot}
632
633 _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-git"}`), nil)
634 if err == nil || !strings.Contains(err.Error(), "no bash permission") {
635 t.Fatalf("error = %v, want git permission error", err)
636 }
637 if _, statErr := os.Stat(defaultRoot); !os.IsNotExist(statErr) {
638 t.Fatalf("restricted call created the default root: %v", statErr)
639 }
640}
641
642func TestCreateWorktreeWritesOutsideDeclaredScopesAreDenied(t *testing.T) {
643 if _, err := exec.LookPath("git"); err != nil {
644 t.Skip("git is required")
645 }
646 repo := initWorktreeTestRepo(t)
647 defaultRoot := filepath.Join(t.TempDir(), "worktrees")
648 sandbox := NewSandbox(repo)
649 permissions := &PermissionSet{}
650 permissions.FS.Read = []string{repo}
651 permissions.FS.Write = []string{repo}
652 permissions.Bash.Mode = "allowlist"
653 permissions.Bash.Allow = []string{"git"}
654 sandbox.SetPermissions(permissions)
655 tool := &CreateWorktreeTool{CWD: repo, Sandbox: sandbox, DefaultRoot: defaultRoot}
656
657 _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/out-of-scope"}`), nil)
658 if err == nil || !strings.Contains(err.Error(), "outside declared scopes") {
659 t.Fatalf("error = %v, want scope-denied error", err)
660 }
661 if _, statErr := os.Stat(defaultRoot); !os.IsNotExist(statErr) {
662 t.Fatalf("denied call created the default root: %v", statErr)
663 }
664}
665
666func TestCreateWorktreeRejectsInvalidWorktreePathBeforeCheckout(t *testing.T) {
667 if _, err := exec.LookPath("git"); err != nil {
668 t.Skip("git is required")
669 }
670 repo := initWorktreeTestRepo(t)
671 defaultRoot := filepath.Join(t.TempDir(), "worktrees")
672 repoRoot := filepath.Join(defaultRoot, testWorktreeRepoID(t, repo))
673 if err := os.MkdirAll(repoRoot, 0o755); err != nil {
674 t.Fatal(err)
675 }
676 if err := os.WriteFile(filepath.Join(repoRoot, "blocked"), []byte("not a directory"), 0o644); err != nil {
677 t.Fatal(err)
678 }
679 tool := newCreateWorktreeTestTool(t, repo, defaultRoot)
680
681 _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"blocked/new"}`), nil)
682 if err == nil || !strings.Contains(err.Error(), "not a directory") {
683 t.Fatalf("error = %v, want non-directory path error", err)
684 }
685 cmd := exec.Command("git", "-C", repo, "show-ref", "--verify", "--quiet", "refs/heads/blocked/new")
686 if err := cmd.Run(); err == nil {
687 t.Fatal("invalid path created the branch")
688 }
689}
690
691func TestCreateWorktreeGitOutputKeepsStderrOutOfSuccessfulOutput(t *testing.T) {
692 if runtime.GOOS == "windows" {
693 t.Skip("Git fixture uses a POSIX shell")
694 }
695 binDir := t.TempDir()
696 gitPath := filepath.Join(binDir, "git")
697 if err := os.WriteFile(gitPath, []byte("#!/bin/sh\nprintf 'value\\n'\nprintf 'warning\\n' >&2\n"), 0o755); err != nil {
698 t.Fatal(err)
699 }
700 t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
701
702 output, err := createWorktreeGitOutput(context.Background(), t.TempDir(), "status")
703 if err != nil {
704 t.Fatal(err)
705 }
706 if output != "value" {
707 t.Fatalf("output = %q, want value", output)
708 }
709}
710
711func TestCreateWorktreeGitCancelsDescendants(t *testing.T) {
712 if runtime.GOOS == "windows" {
713 t.Skip("process-group fixture uses a POSIX shell")
714 }
715 binDir := t.TempDir()
716 started := filepath.Join(t.TempDir(), "git-started")
717 childPID := filepath.Join(t.TempDir(), "child-pid")
718 gitPath := filepath.Join(binDir, "git")
719 script := "#!/bin/sh\nsleep 30 &\nprintf '%s' \"$!\" > " + shellQuoteForTest(childPID) + "\nprintf started > " + shellQuoteForTest(started) + "\nwait\n"
720 if err := os.WriteFile(gitPath, []byte(script), 0o755); err != nil {
721 t.Fatal(err)
722 }
723 t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
724 ctx, cancel := context.WithCancel(context.Background())
725 defer cancel()
726 cwd := t.TempDir()
727 done := make(chan error, 1)
728 go func() {
729 _, err := createWorktreeGitOutput(ctx, cwd, "status")
730 done <- err
731 }()
732
733 deadline := time.NewTimer(5 * time.Second)
734 defer deadline.Stop()
735 poll := time.NewTicker(10 * time.Millisecond)
736 defer poll.Stop()
737 for {
738 if _, err := os.Stat(started); err == nil {
739 break
740 } else if !os.IsNotExist(err) {
741 t.Fatal(err)
742 }
743 select {
744 case <-deadline.C:
745 t.Fatal("fake git did not start")
746 case <-poll.C:
747 }
748 }
749 cancel()
750 select {
751 case err := <-done:
752 if !errors.Is(err, context.Canceled) {
753 t.Fatalf("error = %v, want context canceled", err)
754 }
755 case <-time.After(5 * time.Second):
756 t.Fatal("Git process group did not exit after cancellation")
757 }
758 pid, err := os.ReadFile(childPID)
759 if err != nil {
760 t.Fatal(err)
761 }
762 exitDeadline := time.NewTimer(5 * time.Second)
763 defer exitDeadline.Stop()
764 for {
765 err := exec.Command("kill", "-0", strings.TrimSpace(string(pid))).Run()
766 if err != nil {
767 var exitErr *exec.ExitError
768 if errors.As(err, &exitErr) {
769 break
770 }
771 t.Fatalf("check child process: %v", err)
772 }
773 select {
774 case <-exitDeadline.C:
775 t.Fatalf("Git descendant %q remained after cancellation", pid)
776 case <-poll.C:
777 }
778 }
779}
780
781func newCreateWorktreeTestTool(t *testing.T, cwd, defaultRoot string) *CreateWorktreeTool {
782 t.Helper()
783 return &CreateWorktreeTool{CWD: cwd, Sandbox: NewSandbox(cwd), DefaultRoot: defaultRoot}
784}
785
786func testWorktreeRepoID(t *testing.T, repo string) string {
787 t.Helper()
788 real, err := canonical(repo)
789 if err != nil {
790 t.Fatal(err)
791 }
792 commonDir, err := canonical(filepath.Join(real, ".git"))
793 if err != nil {
794 t.Fatal(err)
795 }
796 return worktreeRepoID(commonDir)
797}
798
799func initWorktreeTestRepo(t *testing.T) string {
800 t.Helper()
801 return initWorktreeTestRepoAt(t, t.TempDir())
802}
803
804func initWorktreeTestRepoAt(t *testing.T, repo string) string {
805 t.Helper()
806 t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
807 t.Setenv("GIT_CONFIG_SYSTEM", os.DevNull)
808 t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull)
809 gitTestOutput(t, repo, "init", "-q")
810 gitTestOutput(t, repo, "config", "user.email", "test@example.invalid")
811 gitTestOutput(t, repo, "config", "user.name", "Test User")
812 if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("before\n"), 0o644); err != nil {
813 t.Fatal(err)
814 }
815 gitTestOutput(t, repo, "add", "README.md")
816 gitTestOutput(t, repo, "commit", "-qm", "initial")
817 return repo
818}
819
820func shellQuoteForTest(value string) string {
821 return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
822}
823
824type gitTestResult struct {
825 output string
826 exitCode int
827}
828
829func gitTestOutputAllowExit(t *testing.T, dir string, args ...string) gitTestResult {
830 t.Helper()
831 cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
832 output, err := cmd.CombinedOutput()
833 if err == nil {
834 return gitTestResult{output: strings.TrimSpace(string(output))}
835 }
836 if exitErr, ok := err.(*exec.ExitError); ok {
837 return gitTestResult{output: strings.TrimSpace(string(output)), exitCode: exitErr.ExitCode()}
838 }
839 t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, output)
840 return gitTestResult{}
841}
842
843func gitTestOutput(t *testing.T, dir string, args ...string) string {
844 t.Helper()
845 cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
846 output, err := cmd.CombinedOutput()
847 if err != nil {
848 t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, output)
849 }
850 return strings.TrimSpace(string(output))
851}