v0.6.0 01ec125

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

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