v0.51.0 a9d674e

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

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