main 4509ec7

manage_worktrees_test.go RawHistoryFolder

1package tools
2
3import (
4	"context"
5	"os"
6	"os/exec"
7	"path/filepath"
8	"strings"
9	"testing"
10)
11
12func TestManageWorktreesListsCleanupEligibility(t *testing.T) {
13	if _, err := exec.LookPath("git"); err != nil {
14		t.Skip("git is required")
15	}
16	repo := initWorktreeTestRepo(t)
17	mergedPath := filepath.Join(t.TempDir(), "merged")
18	gitTestOutput(t, repo, "branch", "merged")
19	gitTestOutput(t, repo, "worktree", "add", mergedPath, "merged")
20	dirtyPath := filepath.Join(t.TempDir(), "dirty")
21	gitTestOutput(t, repo, "worktree", "add", "-b", "dirty", dirtyPath)
22	if err := os.WriteFile(filepath.Join(dirtyPath, "dirty.txt"), []byte("dirty"), 0o644); err != nil {
23		t.Fatal(err)
24	}
25
26	tool := newManageWorktreesTestTool(repo)
27	result, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"action": "list"}), nil)
28	if err != nil {
29		t.Fatal(err)
30	}
31	got := resultText(result)
32	for _, want := range []string{"repository worktrees", "merged", "cleanup eligible", "dirty", "uncommitted changes", "primary worktree"} {
33		if !strings.Contains(got, want) {
34			t.Fatalf("result missing %q:\n%s", want, got)
35		}
36	}
37}
38
39func TestManageWorktreesCleanupRemovesOnlyExplicitCleanMergedWorktree(t *testing.T) {
40	if _, err := exec.LookPath("git"); err != nil {
41		t.Skip("git is required")
42	}
43	repo := initWorktreeTestRepo(t)
44	path := filepath.Join(t.TempDir(), "merged")
45	gitTestOutput(t, repo, "branch", "merged")
46	gitTestOutput(t, repo, "worktree", "add", path, "merged")
47	tool := newManageWorktreesTestTool(repo)
48	args := mustJSON(t, map[string]any{"action": "cleanup", "paths": []string{path}})
49
50	if _, err := tool.Preview(context.Background(), args); err != nil {
51		t.Fatal(err)
52	}
53	if _, err := os.Stat(path); err != nil {
54		t.Fatalf("preview removed worktree: %v", err)
55	}
56	if _, err := tool.Execute(context.Background(), args, nil); err != nil {
57		t.Fatal(err)
58	}
59	if _, err := os.Stat(path); !os.IsNotExist(err) {
60		t.Fatalf("worktree remains: %v", err)
61	}
62	if got := gitTestOutput(t, repo, "show-ref", "--verify", "refs/heads/merged"); got == "" {
63		t.Fatal("cleanup unexpectedly removed branch")
64	}
65}
66
67func TestManageWorktreesRejectsDirtyCleanup(t *testing.T) {
68	if _, err := exec.LookPath("git"); err != nil {
69		t.Skip("git is required")
70	}
71	repo := initWorktreeTestRepo(t)
72	path := filepath.Join(t.TempDir(), "dirty")
73	gitTestOutput(t, repo, "worktree", "add", "-b", "dirty", path)
74	if err := os.WriteFile(filepath.Join(path, "dirty.txt"), []byte("dirty"), 0o644); err != nil {
75		t.Fatal(err)
76	}
77	tool := newManageWorktreesTestTool(repo)
78	_, err := tool.Preview(context.Background(), mustJSON(t, map[string]any{"action": "cleanup", "paths": []string{path}}))
79	if err == nil || !strings.Contains(err.Error(), "uncommitted changes") {
80		t.Fatalf("error = %v, want dirty-worktree rejection", err)
81	}
82}
83
84func TestWorktreeInventoryContextReportsRepositoryWorktrees(t *testing.T) {
85	if _, err := exec.LookPath("git"); err != nil {
86		t.Skip("git is required")
87	}
88	repo := initWorktreeTestRepo(t)
89	path := filepath.Join(t.TempDir(), "merged")
90	gitTestOutput(t, repo, "branch", "merged")
91	gitTestOutput(t, repo, "worktree", "add", path, "merged")
92
93	contextText := WorktreeInventoryContext(context.Background(), repo)
94	wantPath, err := canonicalOrParent(path)
95	if err != nil {
96		t.Fatal(err)
97	}
98	for _, want := range []string{"[Repository worktrees]", filepath.ToSlash(wantPath), "worktree action=list"} {
99		if !strings.Contains(contextText, want) {
100			t.Fatalf("context missing %q:\n%s", want, contextText)
101		}
102	}
103}
104
105func TestManageWorktreesRejectsIgnoredFilesCleanup(t *testing.T) {
106	if _, err := exec.LookPath("git"); err != nil {
107		t.Skip("git is required")
108	}
109	repo := initWorktreeTestRepo(t)
110	path := filepath.Join(t.TempDir(), "ignored")
111	gitTestOutput(t, repo, "worktree", "add", "-b", "ignored", path)
112	if err := os.WriteFile(filepath.Join(path, ".gitignore"), []byte("local.env\n"), 0o644); err != nil {
113		t.Fatal(err)
114	}
115	gitTestOutput(t, path, "add", ".gitignore")
116	gitTestOutput(t, path, "commit", "-m", "ignore local file")
117	gitTestOutput(t, repo, "merge", "--ff-only", "ignored")
118	if err := os.WriteFile(filepath.Join(path, "local.env"), []byte("private"), 0o600); err != nil {
119		t.Fatal(err)
120	}
121	tool := newManageWorktreesTestTool(repo)
122	_, err := tool.Preview(context.Background(), mustJSON(t, map[string]any{"action": "cleanup", "paths": []string{path}}))
123	if err == nil || !strings.Contains(err.Error(), "uncommitted changes") {
124		t.Fatalf("error = %v, want ignored-file rejection", err)
125	}
126}
127
128func TestManageWorktreesRejectsCleanupWithoutExplicitPaths(t *testing.T) {
129	if _, err := exec.LookPath("git"); err != nil {
130		t.Skip("git is required")
131	}
132	repo := initWorktreeTestRepo(t)
133	tool := newManageWorktreesTestTool(repo)
134	_, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"action": "cleanup"}), nil)
135	if err == nil || !strings.Contains(err.Error(), "explicit path") {
136		t.Fatalf("error = %v, want explicit-path rejection", err)
137	}
138}
139
140func newManageWorktreesTestTool(cwd string) *ManageWorktreesTool {
141	return &ManageWorktreesTool{CWD: cwd, Sandbox: NewSandbox(cwd)}
142}
143
144// addMergedSideWorktree creates branch "side" at the primary HEAD in a new
145// worktree, so it is merged into HEAD and eligible for cleanup.
146func addMergedSideWorktree(t *testing.T, repo, branch string) string {
147	t.Helper()
148	path := filepath.Join(t.TempDir(), branch)
149	gitTestOutput(t, repo, "worktree", "add", "-b", branch, path)
150	return path
151}
152
153func branchExists(t *testing.T, repo, branch string) bool {
154	t.Helper()
155	return gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/"+branch).exitCode == 0
156}
157
158func TestDeleteMergedBranchRefusesSymbolicRef(t *testing.T) {
159	if _, err := exec.LookPath("git"); err != nil {
160		t.Skip("git is required")
161	}
162	repo := initWorktreeTestRepo(t)
163	mainBranch := gitTestOutput(t, repo, "branch", "--show-current")
164	head := gitTestOutput(t, repo, "rev-parse", "HEAD")
165	gitTestOutput(t, repo, "branch", "side")
166	gitTestOutput(t, repo, "symbolic-ref", "refs/heads/side", "refs/heads/"+mainBranch)
167
168	err := deleteMergedBranch(context.Background(), repo, "side", head)
169	if err == nil || !strings.Contains(err.Error(), "symbolic") {
170		t.Fatalf("error = %v, want symbolic-ref rejection", err)
171	}
172	if !branchExists(t, repo, mainBranch) {
173		t.Fatal("symbolic ref deletion removed the target branch")
174	}
175}
176
177func TestManageWorktreesDeleteBranchKeepsTargetWhenBranchBecomesSymref(t *testing.T) {
178	if _, err := exec.LookPath("git"); err != nil {
179		t.Skip("git is required")
180	}
181	repo := initWorktreeTestRepo(t)
182	mainBranch := gitTestOutput(t, repo, "branch", "--show-current")
183	path := addMergedSideWorktree(t, repo, "side")
184	tool := newManageWorktreesTestTool(repo)
185	args := mustJSON(t, map[string]any{"action": "cleanup", "paths": []string{path}, "delete_branch": true})
186
187	// The worktree is already removed when the delete progress is reported;
188	// swap the branch for a symref to the primary branch at the same commit.
189	progress := func(msg string) {
190		if strings.HasPrefix(msg, "Deleting branch") {
191			gitTestOutput(t, repo, "symbolic-ref", "refs/heads/side", "refs/heads/"+mainBranch)
192		}
193	}
194	_, err := tool.Execute(context.Background(), args, progress)
195	if err == nil || !strings.Contains(err.Error(), "symbolic") || !strings.Contains(err.Error(), "removed worktrees:") {
196		t.Fatalf("error = %v, want symbolic-ref branch failure", err)
197	}
198	if !branchExists(t, repo, mainBranch) {
199		t.Fatal("primary branch was deleted through a symbolic ref")
200	}
201}
202
203func TestManageWorktreesDeleteBranchRefusesBranchCheckedOutAfterRemoval(t *testing.T) {
204	if _, err := exec.LookPath("git"); err != nil {
205		t.Skip("git is required")
206	}
207	for _, where := range []string{"primary", "worktree"} {
208		t.Run(where, func(t *testing.T) {
209			repo := initWorktreeTestRepo(t)
210			path := addMergedSideWorktree(t, repo, "side")
211			tool := newManageWorktreesTestTool(repo)
212			args := mustJSON(t, map[string]any{"action": "cleanup", "paths": []string{path}, "delete_branch": true})
213			progress := func(msg string) {
214				if !strings.HasPrefix(msg, "Deleting branch") {
215					return
216				}
217				if where == "primary" {
218					gitTestOutput(t, repo, "checkout", "-q", "side")
219				} else {
220					gitTestOutput(t, repo, "worktree", "add", filepath.Join(t.TempDir(), "again"), "side")
221				}
222			}
223			_, err := tool.Execute(context.Background(), args, progress)
224			if err == nil || !strings.Contains(err.Error(), `delete branch "side"`) || !strings.Contains(err.Error(), "checked out") {
225				t.Fatalf("error = %v, want checked-out branch failure", err)
226			}
227			if !branchExists(t, repo, "side") {
228				t.Fatal("checked-out branch was deleted")
229			}
230		})
231	}
232}
233
234func TestManageWorktreesDeleteBranchRequiresRefStorageWriteAccess(t *testing.T) {
235	if _, err := exec.LookPath("git"); err != nil {
236		t.Skip("git is required")
237	}
238	repo := initWorktreeTestRepo(t)
239	path := addMergedSideWorktree(t, repo, "side")
240	scoped := func(write ...string) *ManageWorktreesTool {
241		var p PermissionSet
242		p.Bash.Mode = "ask"
243		p.FS.Read = []string{repo, filepath.Dir(path)}
244		p.FS.Write = write
245		sandbox := NewSandbox(repo)
246		sandbox.SetPermissions(&p)
247		return &ManageWorktreesTool{CWD: repo, Sandbox: sandbox}
248	}
249	args := mustJSON(t, map[string]any{"action": "cleanup", "paths": []string{path}, "delete_branch": true})
250
251	tool := scoped(path)
252	if _, err := tool.Preview(context.Background(), args); err == nil || !strings.Contains(err.Error(), "outside declared scopes") {
253		t.Fatalf("preview error = %v, want ref-storage write denial", err)
254	}
255	if _, err := tool.Execute(context.Background(), args, nil); err == nil || !strings.Contains(err.Error(), "outside declared scopes") {
256		t.Fatalf("execute error = %v, want ref-storage write denial", err)
257	}
258	if _, err := os.Stat(path); err != nil {
259		t.Fatalf("worktree was removed despite denial: %v", err)
260	}
261	if !branchExists(t, repo, "side") {
262		t.Fatal("branch was deleted despite denial")
263	}
264
265	// Without delete_branch only the worktree path needs write access.
266	keep := mustJSON(t, map[string]any{"action": "cleanup", "paths": []string{path}})
267	if _, err := tool.Preview(context.Background(), keep); err != nil {
268		t.Fatalf("cleanup without delete_branch: %v", err)
269	}
270	// Granting the common git directory allows the full operation.
271	tool = scoped(path, filepath.Join(repo, ".git"))
272	if _, err := tool.Execute(context.Background(), args, nil); err != nil {
273		t.Fatal(err)
274	}
275	if branchExists(t, repo, "side") {
276		t.Fatal("branch remains after permitted cleanup")
277	}
278}
279
280func TestManageWorktreesDetectsMovedMergeTargetAfterPreview(t *testing.T) {
281	if _, err := exec.LookPath("git"); err != nil {
282		t.Skip("git is required")
283	}
284	repo := initWorktreeTestRepo(t)
285	path := addMergedSideWorktree(t, repo, "side")
286	gitTestOutput(t, repo, "branch", "target")
287	tool := newManageWorktreesTestTool(repo)
288	args := mustJSON(t, map[string]any{"action": "cleanup", "paths": []string{path}, "into": "target", "delete_branch": true})
289	if _, err := tool.Preview(context.Background(), args); err != nil {
290		t.Fatal(err)
291	}
292	// Advance the unchecked-out target; side remains an ancestor, so the
293	// worktree stays eligible and only the pinned target differs.
294	tree := gitTestOutput(t, repo, "rev-parse", "target^{tree}")
295	moved := gitTestOutput(t, repo, "commit-tree", tree, "-p", "target", "-m", "advance")
296	gitTestOutput(t, repo, "update-ref", "refs/heads/target", moved)
297
298	_, err := tool.Execute(context.Background(), args, nil)
299	if err == nil || !strings.Contains(err.Error(), "changed since preview") {
300		t.Fatalf("error = %v, want stale-state rejection", err)
301	}
302	if _, statErr := os.Stat(path); statErr != nil {
303		t.Fatalf("worktree was removed: %v", statErr)
304	}
305	if !branchExists(t, repo, "side") {
306		t.Fatal("branch was deleted")
307	}
308}
309
310func TestManageWorktreesPartialFailureReportsBranchErrorsAndProgress(t *testing.T) {
311	if _, err := exec.LookPath("git"); err != nil {
312		t.Skip("git is required")
313	}
314	repo := initWorktreeTestRepo(t)
315	first := addMergedSideWorktree(t, repo, "first")
316	second := addMergedSideWorktree(t, repo, "second")
317	// A stale lock makes deleting "first" fail after its worktree is removed.
318	lock := filepath.Join(repo, ".git", "refs", "heads", "first.lock")
319	if err := os.WriteFile(lock, nil, 0o644); err != nil {
320		t.Fatal(err)
321	}
322	tool := newManageWorktreesTestTool(repo)
323	args := mustJSON(t, map[string]any{"action": "cleanup", "paths": []string{first, second}, "delete_branch": true})
324	// Dirty the second worktree so its removal fails.
325	progress := func(msg string) {
326		if strings.HasPrefix(msg, "Removing worktree") && strings.Contains(msg, filepath.ToSlash(second)) {
327			if err := os.WriteFile(filepath.Join(second, "untracked.txt"), []byte("x"), 0o644); err != nil {
328				t.Error(err)
329			}
330		}
331	}
332	_, err := tool.Execute(context.Background(), args, progress)
333	if err == nil {
334		t.Fatal("expected partial failure")
335	}
336	for _, want := range []string{"remove ", `delete branch "first"`, "removed worktrees: " + filepath.ToSlash(first)} {
337		if !strings.Contains(err.Error(), want) {
338			t.Fatalf("error missing %q:\n%v", want, err)
339		}
340	}
341	if _, statErr := os.Stat(second); statErr != nil {
342		t.Fatalf("second worktree should remain: %v", statErr)
343	}
344}

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