v0.22.0 fae6c20

create_worktree_test.go RawHistoryFolder

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close