v0.45.0 cb619f7

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

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