main 4509ec7

worktree_test.go RawHistoryFolder

1package tools
2
3import (
4	"context"
5	"os"
6	"os/exec"
7	"path/filepath"
8	"strings"
9	"testing"
10)
11
12func TestWorktreeToolDispatchesListAndCreate(t *testing.T) {
13	if _, err := exec.LookPath("git"); err != nil {
14		t.Skip("git is required")
15	}
16	repo := initWorktreeTestRepo(t)
17	root := t.TempDir()
18	tool := &WorktreeTool{
19		Create: &CreateWorktreeTool{CWD: repo, Sandbox: NewSandbox(repo), DefaultRoot: root},
20		Manage: &ManageWorktreesTool{CWD: repo, Sandbox: NewSandbox(repo)},
21	}
22	if _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"action": "list"}), nil); err != nil {
23		t.Fatal(err)
24	}
25	if _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"action": "create", "branch": "feature/unified"}), nil); err != nil {
26		t.Fatal(err)
27	}
28	matches, err := filepath.Glob(filepath.Join(root, "*", "feature", "unified"))
29	if err != nil || len(matches) != 1 {
30		t.Fatalf("created paths = %v, error = %v", matches, err)
31	}
32}
33
34func TestWorktreeToolRequiresAction(t *testing.T) {
35	tool := &WorktreeTool{Create: &CreateWorktreeTool{}, Manage: &ManageWorktreesTool{}}
36	if _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"branch": "missing-action"}), nil); err == nil {
37		t.Fatal("missing action was accepted")
38	}
39}
40
41// nestedWorktreeFixture creates feat/x and feat/x-api (based on feat/x)
42// through the public tool and returns their paths.
43func nestedWorktreeFixture(t *testing.T) (tool *WorktreeTool, repo, featPath, apiPath string) {
44	t.Helper()
45	if _, err := exec.LookPath("git"); err != nil {
46		t.Skip("git is required")
47	}
48	repo = initWorktreeTestRepo(t)
49	root := t.TempDir()
50	tool = &WorktreeTool{
51		Create: &CreateWorktreeTool{CWD: repo, Sandbox: NewSandbox(repo), DefaultRoot: root},
52		Manage: &ManageWorktreesTool{CWD: repo, Sandbox: NewSandbox(repo)},
53	}
54	ctx := context.Background()
55	if _, err := tool.Execute(ctx, mustJSON(t, map[string]any{"action": "create", "branch": "feat/x"}), nil); err != nil {
56		t.Fatal(err)
57	}
58	featPath = filepath.Join(root, testWorktreeRepoID(t, repo), "feat", "x")
59	// Advance the feature branch so HEAD of the primary checkout differs.
60	if err := os.WriteFile(filepath.Join(featPath, "feature.txt"), []byte("feature\n"), 0o644); err != nil {
61		t.Fatal(err)
62	}
63	gitTestOutput(t, featPath, "add", "feature.txt")
64	gitTestOutput(t, featPath, "commit", "-qm", "feature work")
65
66	result, err := tool.Execute(ctx, mustJSON(t, map[string]any{"action": "create", "branch": "feat/x-api", "base": "feat/x"}), nil)
67	if err != nil {
68		t.Fatal(err)
69	}
70	details := result.Details.(map[string]any)
71	featHead := gitTestOutput(t, featPath, "rev-parse", "HEAD")
72	if details["base"] != featHead || details["base_ref"] != "feat/x" {
73		t.Fatalf("details = %#v, want base %s from feat/x", details, featHead)
74	}
75	if text := resultText(result); !strings.Contains(text, "base: feat/x ("+featHead+")") {
76		t.Fatalf("result text missing input ref and sha:\n%s", text)
77	}
78	apiPath = filepath.Join(root, testWorktreeRepoID(t, repo), "feat", "x-api")
79	if got := gitTestOutput(t, apiPath, "rev-parse", "HEAD"); got != featHead {
80		t.Fatalf("sub-worktree HEAD = %s, want %s", got, featHead)
81	}
82	return tool, repo, featPath, apiPath
83}
84
85func commitInWorktree(t *testing.T, path, name string) {
86	t.Helper()
87	if err := os.WriteFile(filepath.Join(path, name), []byte(name+"\n"), 0o644); err != nil {
88		t.Fatal(err)
89	}
90	gitTestOutput(t, path, "add", name)
91	gitTestOutput(t, path, "commit", "-qm", name)
92}
93
94func TestWorktreeToolRejectsInvalidBase(t *testing.T) {
95	if _, err := exec.LookPath("git"); err != nil {
96		t.Skip("git is required")
97	}
98	repo := initWorktreeTestRepo(t)
99	tool := &WorktreeTool{Create: &CreateWorktreeTool{CWD: repo, Sandbox: NewSandbox(repo), DefaultRoot: t.TempDir()}}
100	for _, base := range []string{"no-such-branch", "--output=/tmp/x", "-h"} {
101		_, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"action": "create", "branch": "feat/bad", "base": base}), nil)
102		if err == nil || !strings.HasPrefix(err.Error(), "worktree: ") || !strings.Contains(err.Error(), "invalid base") {
103			t.Fatalf("base %q: error = %v, want worktree invalid base", base, err)
104		}
105	}
106	if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feat/bad"); got.exitCode != 1 {
107		t.Fatalf("branch was created: %#v", got)
108	}
109}
110
111func TestWorktreeToolBasePreviewDetectsMovedBase(t *testing.T) {
112	tool, _, featPath, _ := nestedWorktreeFixture(t)
113	args := mustJSON(t, map[string]any{"action": "create", "branch": "feat/x-ui", "base": "feat/x"})
114	if _, err := tool.Preview(context.Background(), args); err != nil {
115		t.Fatal(err)
116	}
117	commitInWorktree(t, featPath, "moved.txt")
118	_, err := tool.Execute(context.Background(), args, nil)
119	if err == nil || !strings.Contains(err.Error(), "changed since preview") {
120		t.Fatalf("error = %v, want stale preview rejection", err)
121	}
122}
123
124func TestWorktreeToolCleanupIntoFeatureBranchAndDeleteBranch(t *testing.T) {
125	tool, repo, featPath, apiPath := nestedWorktreeFixture(t)
126	ctx := context.Background()
127	commitInWorktree(t, apiPath, "api.txt")
128	cleanup := map[string]any{"action": "cleanup", "paths": []string{apiPath}}
129
130	// Unmerged: blocked for both the default target and the feature branch.
131	for _, args := range []map[string]any{cleanup, {"action": "cleanup", "paths": []string{apiPath}, "into": "feat/x"}} {
132		_, err := tool.Preview(ctx, mustJSON(t, args))
133		if err == nil || !strings.Contains(err.Error(), "not merged") {
134			t.Fatalf("args %v: error = %v, want unmerged rejection", args, err)
135		}
136	}
137	gitTestOutput(t, featPath, "merge", "--no-edit", "feat/x-api")
138
139	// Default HEAD (primary checkout) does not contain the merge.
140	_, err := tool.Preview(ctx, mustJSON(t, cleanup))
141	if err == nil || !strings.Contains(err.Error(), "not merged into the current branch") {
142		t.Fatalf("error = %v, want current-branch rejection", err)
143	}
144	// list with into reports eligibility against the feature branch.
145	listed, err := tool.Execute(ctx, mustJSON(t, map[string]any{"action": "list", "into": "feat/x"}), nil)
146	if err != nil || !strings.Contains(resultText(listed), `clean and merged into "feat/x"; cleanup eligible`) {
147		t.Fatalf("list = %v, %q", err, resultText(listed))
148	}
149	_, err = tool.Preview(ctx, mustJSON(t, map[string]any{"action": "cleanup", "paths": []string{apiPath}, "into": "-bad"}))
150	if err == nil || !strings.Contains(err.Error(), "invalid into") {
151		t.Fatalf("error = %v, want invalid into", err)
152	}
153
154	args := mustJSON(t, map[string]any{"action": "cleanup", "paths": []string{apiPath}, "into": "feat/x", "delete_branch": true})
155	preview, err := tool.Preview(ctx, args)
156	if err != nil || !strings.Contains(resultText(preview), "delete branches:\n- feat/x-api") {
157		t.Fatalf("preview = %v, %q", err, resultText(preview))
158	}
159	result, err := tool.Execute(ctx, args, nil)
160	if err != nil {
161		t.Fatal(err)
162	}
163	if _, err := os.Stat(apiPath); !os.IsNotExist(err) {
164		t.Fatalf("sub-worktree remains: %v", err)
165	}
166	if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feat/x-api"); got.exitCode != 1 {
167		t.Fatalf("branch remains: %#v", got)
168	}
169	if got := gitTestOutput(t, repo, "show-ref", "--verify", "refs/heads/feat/x"); got == "" {
170		t.Fatal("feature branch was deleted")
171	}
172	if text := resultText(result); !strings.Contains(text, "deleted branches:\n- feat/x-api") {
173		t.Fatalf("result text missing deleted branch:\n%s", text)
174	}
175	details := result.Details.(map[string]any)
176	if deleted, _ := details["deleted_branches"].([]string); len(deleted) != 1 || deleted[0] != "feat/x-api" {
177		t.Fatalf("details = %#v", details)
178	}
179}
180
181func TestWorktreeToolCleanupWithoutDeleteBranchKeepsBranch(t *testing.T) {
182	tool, repo, _, apiPath := nestedWorktreeFixture(t)
183	args := mustJSON(t, map[string]any{"action": "cleanup", "paths": []string{apiPath}, "into": "feat/x"})
184	if _, err := tool.Execute(context.Background(), args, nil); err != nil {
185		t.Fatal(err)
186	}
187	if got := gitTestOutput(t, repo, "show-ref", "--verify", "refs/heads/feat/x-api"); got == "" {
188		t.Fatal("branch removed without delete_branch")
189	}
190}
191
192func TestWorktreeToolDeleteBranchRefusesMergeTarget(t *testing.T) {
193	tool, repo, featPath, _ := nestedWorktreeFixture(t)
194	args := mustJSON(t, map[string]any{"action": "cleanup", "paths": []string{featPath}, "into": "feat/x", "delete_branch": true})
195	_, err := tool.Preview(context.Background(), args)
196	if err == nil || !strings.Contains(err.Error(), "merge target") {
197		t.Fatalf("error = %v, want merge-target rejection", err)
198	}
199	if _, statErr := os.Stat(featPath); statErr != nil {
200		t.Fatal(statErr)
201	}
202	_ = repo
203}
204
205func TestWorktreeToolDeleteBranchRejectsMovedBranchAfterPreview(t *testing.T) {
206	tool, repo, featPath, apiPath := nestedWorktreeFixture(t)
207	args := mustJSON(t, map[string]any{"action": "cleanup", "paths": []string{apiPath}, "into": "feat/x", "delete_branch": true})
208	if _, err := tool.Preview(context.Background(), args); err != nil {
209		t.Fatal(err)
210	}
211	// A new commit after preview leaves the branch unmerged, so execution
212	// must fail closed without removing the worktree or deleting the branch.
213	commitInWorktree(t, apiPath, "late.txt")
214	_, err := tool.Execute(context.Background(), args, nil)
215	if err == nil || !strings.Contains(err.Error(), "not merged") {
216		t.Fatalf("error = %v, want unmerged rejection", err)
217	}
218	if got := gitTestOutput(t, repo, "show-ref", "--verify", "refs/heads/feat/x-api"); got == "" {
219		t.Fatal("branch was deleted")
220	}
221	if _, statErr := os.Stat(apiPath); statErr != nil {
222		t.Fatalf("worktree was removed: %v", statErr)
223	}
224	_, _ = repo, featPath
225}
226
227func TestDeleteMergedBranchKeepsBranchThatMoved(t *testing.T) {
228	_, repo, _, apiPath := nestedWorktreeFixture(t)
229	inspected := gitTestOutput(t, apiPath, "rev-parse", "HEAD")
230	commitInWorktree(t, apiPath, "moved.txt")
231	gitTestOutput(t, repo, "worktree", "remove", apiPath)
232
233	err := deleteMergedBranch(context.Background(), repo, "feat/x-api", inspected)
234	if err == nil || !strings.Contains(err.Error(), "changed since inspection") {
235		t.Fatalf("error = %v, want moved-branch rejection", err)
236	}
237	if got := gitTestOutput(t, repo, "show-ref", "--verify", "refs/heads/feat/x-api"); got == "" {
238		t.Fatal("moved branch was deleted")
239	}
240	current := gitTestOutput(t, repo, "rev-parse", "refs/heads/feat/x-api")
241	if err := deleteMergedBranch(context.Background(), repo, "feat/x-api", current); err != nil {
242		t.Fatal(err)
243	}
244}
245
246func TestWorktreeToolReportsBranchDeletionFailureAfterRemoval(t *testing.T) {
247	tool, repo, _, apiPath := nestedWorktreeFixture(t)
248	ctx := context.Background()
249	args := mustJSON(t, map[string]any{"action": "cleanup", "paths": []string{apiPath}, "into": "feat/x", "delete_branch": true})
250	// Lock the branch ref so update-ref cannot delete it after worktree removal.
251	lock := filepath.Join(repo, ".git", "refs", "heads", "feat", "x-api.lock")
252	if err := os.WriteFile(lock, nil, 0o644); err != nil {
253		t.Fatal(err)
254	}
255	_, err := tool.Execute(ctx, args, nil)
256	if err == nil || !strings.HasPrefix(err.Error(), "worktree: ") || !strings.Contains(err.Error(), `delete branch "feat/x-api"`) || !strings.Contains(err.Error(), "worktrees were removed") {
257		t.Fatalf("error = %v, want clear branch-deletion failure", err)
258	}
259	if _, statErr := os.Stat(apiPath); !os.IsNotExist(statErr) {
260		t.Fatalf("worktree remains: %v", statErr)
261	}
262}

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