v0.10.0 8a8e2b3

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

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 477

↑↓ moveenter openesc close