detached dd4a900

fix(tools): harden worktree branch deletion

Refuse symbolic branch refs and delete with --no-deref, recheck that the branch is not checked out right before deletion, require write access to the Git common directory, pin the resolved merge target for preview staleness checks, and keep branch errors in every partial-failure report.
commit
dd4a900fb6dd339c5d38589f9be844175ca1a92f
author
brice · · 2026-10-18 18:04 UTC
parent
2c1ebcb
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +280 -11
M packages/agent/tools/manage_worktrees.go +78 -11
@@ -49,9 +49,14 @@ type manageWorktreesPlan struct {
4949 // into is the requested merge target; empty means the current HEAD.
5050 into string
5151 deleteBranch bool
52- selected []managedWorktree
53- all []managedWorktree
54- text string
52+ // intoCommit and intoRef pin the resolved merge target so a target that
53+ // moves between preview and execute is detected. intoRef is the symbolic
54+ // full name (empty for a commit or detached HEAD).
55+ intoCommit string
56+ intoRef string
57+ selected []managedWorktree
58+ all []managedWorktree
59+ text string
5560 }
5661
5762 type manageWorktreesPreview struct {
@@ -104,8 +109,13 @@ func (t *ManageWorktreesTool) Execute(ctx context.Context, raw json.RawMessage,
104109 }
105110 var removed, deleted []string
106111 var branchErrs []string
112+ // summary reports progress made so far; it is attached to every failure
113+ // return so a partial cleanup is never silently dropped.
107114 summary := func() string {
108115 var parts []string
116+ if len(branchErrs) > 0 {
117+ parts = append(parts, strings.Join(branchErrs, "; "))
118+ }
109119 if len(removed) > 0 {
110120 parts = append(parts, "removed worktrees: "+strings.Join(removed, ", "))
111121 }
@@ -140,7 +150,7 @@ func (t *ManageWorktreesTool) Execute(ctx context.Context, raw json.RawMessage,
140150 }
141151 _, _ = manageWorktreesGit(ctx, plan.repoRoot, "worktree", "prune")
142152 if len(branchErrs) > 0 {
143- return core.ToolResult{}, fmt.Errorf("manage_worktrees: %s (worktrees were removed; the listed branches were kept)%s", strings.Join(branchErrs, "; "), summary())
153+ return core.ToolResult{}, fmt.Errorf("manage_worktrees: worktrees were removed but some branches were kept%s", summary())
144154 }
145155 result := manageWorktreesResult(plan, "removed")
146156 if len(deleted) > 0 {
@@ -150,25 +160,64 @@ func (t *ManageWorktreesTool) Execute(ctx context.Context, raw json.RawMessage,
150160 }
151161 result.Content = []provider.Content{provider.TextBlock{Text: text}}
152162 }
153- details := result.Details.(map[string]any)
154- details["removed"] = removed
155- details["deleted_branches"] = deleted
163+ if details, ok := result.Details.(map[string]any); ok {
164+ details["removed"] = removed
165+ details["deleted_branches"] = deleted
166+ }
156167 return result, nil
157168 }
158169
159170 // deleteMergedBranch deletes refs/heads/<branch> only while it still points at
160-// head, so a branch that moved after inspection is never discarded.
171+// head, is a plain (non-symbolic) ref, and is not checked out in any worktree.
172+// The checks run immediately before deletion because state can change after
173+// planning and after the worktree was removed.
161174 func deleteMergedBranch(ctx context.Context, repoRoot, branch, head string) error {
162175 if branch == "" || head == "" {
163176 return errors.New("branch or inspected commit is unknown")
164177 }
165- _, err := manageWorktreesGit(ctx, repoRoot, "update-ref", "-d", "refs/heads/"+branch, head)
178+ ref := "refs/heads/" + branch
179+ // A symbolic branch ref would make update-ref delete its target.
180+ if _, err := manageWorktreesGit(ctx, repoRoot, "symbolic-ref", "-q", ref); err == nil {
181+ return errors.New("branch ref is symbolic and was kept")
182+ } else if createWorktreeGitExitCode(err) != 1 {
183+ return fmt.Errorf("inspect branch ref: %w", err)
184+ }
185+ where, err := branchCheckedOutAt(ctx, repoRoot, ref)
166186 if err != nil {
187+ return err
188+ }
189+ if where != "" {
190+ return fmt.Errorf("branch is checked out in %q", filepath.ToSlash(where))
191+ }
192+ if _, err := manageWorktreesGit(ctx, repoRoot, "update-ref", "--no-deref", "-d", ref, head); err != nil {
167193 return fmt.Errorf("branch changed since inspection or cannot be deleted: %w", err)
168194 }
169195 return nil
170196 }
171197
198+// branchCheckedOutAt returns the path of a worktree (primary included) that has
199+// ref checked out, or "" when none does.
200+func branchCheckedOutAt(ctx context.Context, repoRoot, ref string) (string, error) {
201+ output, err := manageWorktreesGit(ctx, repoRoot, "worktree", "list", "--porcelain")
202+ if err != nil {
203+ return "", fmt.Errorf("list worktrees: %w", err)
204+ }
205+ for _, block := range strings.Split(strings.TrimSpace(output), "\n\n") {
206+ path, checkedOut := "", false
207+ for _, line := range strings.Split(block, "\n") {
208+ if strings.HasPrefix(line, "worktree ") {
209+ path = strings.TrimPrefix(line, "worktree ")
210+ } else if line == "branch "+ref {
211+ checkedOut = true
212+ }
213+ }
214+ if checkedOut {
215+ return path, nil
216+ }
217+ }
218+ return "", nil
219+}
220+
172221 func (t *ManageWorktreesTool) plan(ctx context.Context, raw json.RawMessage) (manageWorktreesPlan, error) {
173222 var args manageWorktreesArgs
174223 if err := json.Unmarshal(raw, &args); err != nil {
@@ -228,7 +277,7 @@ func (t *ManageWorktreesTool) plan(ctx context.Context, raw json.RawMessage) (ma
228277 if err != nil {
229278 return manageWorktreesPlan{}, err
230279 }
231- plan := manageWorktreesPlan{repoRoot: repoRoot, action: args.Action, into: into, deleteBranch: args.DeleteBranch, all: items}
280+ plan := manageWorktreesPlan{repoRoot: repoRoot, action: args.Action, into: into, intoCommit: intoCommit, intoRef: intoRef, deleteBranch: args.DeleteBranch, all: items}
232281 if args.Action == "cleanup" {
233282 if len(args.Paths) == 0 {
234283 return manageWorktreesPlan{}, errors.New("manage_worktrees: cleanup requires at least one explicit path")
@@ -266,6 +315,24 @@ func (t *ManageWorktreesTool) plan(ctx context.Context, raw json.RawMessage) (ma
266315 }
267316 }
268317 if args.DeleteBranch {
318+ // Deleting a branch writes to Git ref storage, which lives outside the
319+ // worktree path. Require write access to it and fail closed. Jail mode
320+ // is a guardrail, not a sandbox: it cannot stop git from touching refs
321+ // once permitted, so this only enforces the declared scopes.
322+ commonDir, err := manageWorktreesGit(ctx, repoRoot, "rev-parse", "--git-common-dir")
323+ if err != nil {
324+ return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: resolve git directory: %w", err)
325+ }
326+ if !filepath.IsAbs(commonDir) {
327+ commonDir = filepath.Join(repoRoot, commonDir)
328+ }
329+ commonDir, err = canonical(commonDir)
330+ if err != nil {
331+ return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: resolve git directory: %w", err)
332+ }
333+ if err := t.Sandbox.CheckWritePath(commonDir); err != nil {
334+ return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: %w", err)
335+ }
269336 for _, item := range plan.selected {
270337 if intoRef == "refs/heads/"+item.Branch {
271338 return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: branch %q is the merge target and cannot be deleted", item.Branch)
@@ -441,7 +508,7 @@ func manageWorktreesResult(plan manageWorktreesPlan, state string) core.ToolResu
441508 }
442509
443510 func sameManageWorktreesPlan(a, b manageWorktreesPlan) bool {
444- if a.repoRoot != b.repoRoot || a.action != b.action || a.into != b.into || a.deleteBranch != b.deleteBranch || a.text != b.text || len(a.selected) != len(b.selected) || len(a.all) != len(b.all) {
511+ if a.repoRoot != b.repoRoot || a.action != b.action || a.into != b.into || a.intoCommit != b.intoCommit || a.intoRef != b.intoRef || a.deleteBranch != b.deleteBranch || a.text != b.text || len(a.selected) != len(b.selected) || len(a.all) != len(b.all) {
445512 return false
446513 }
447514 for index := range a.selected {
@@ -49,9 +49,14 @@ type manageWorktreesPlan struct {
49 // into is the requested merge target; empty means the current HEAD.49 // into is the requested merge target; empty means the current HEAD.
50 into string50 into string
51 deleteBranch bool51 deleteBranch bool
52- selected []managedWorktree52+ // intoCommit and intoRef pin the resolved merge target so a target that
53- all []managedWorktree53+ // moves between preview and execute is detected. intoRef is the symbolic
54- text string54+ // full name (empty for a commit or detached HEAD).
55+ intoCommit string
56+ intoRef string
57+ selected []managedWorktree
58+ all []managedWorktree
59+ text string
55 }60 }
56 61
57 type manageWorktreesPreview struct {62 type manageWorktreesPreview struct {
@@ -104,8 +109,13 @@ func (t *ManageWorktreesTool) Execute(ctx context.Context, raw json.RawMessage,
104 }109 }
105 var removed, deleted []string110 var removed, deleted []string
106 var branchErrs []string111 var branchErrs []string
112+ // summary reports progress made so far; it is attached to every failure
113+ // return so a partial cleanup is never silently dropped.
107 summary := func() string {114 summary := func() string {
108 var parts []string115 var parts []string
116+ if len(branchErrs) > 0 {
117+ parts = append(parts, strings.Join(branchErrs, "; "))
118+ }
109 if len(removed) > 0 {119 if len(removed) > 0 {
110 parts = append(parts, "removed worktrees: "+strings.Join(removed, ", "))120 parts = append(parts, "removed worktrees: "+strings.Join(removed, ", "))
111 }121 }
@@ -140,7 +150,7 @@ func (t *ManageWorktreesTool) Execute(ctx context.Context, raw json.RawMessage,
140 }150 }
141 _, _ = manageWorktreesGit(ctx, plan.repoRoot, "worktree", "prune")151 _, _ = manageWorktreesGit(ctx, plan.repoRoot, "worktree", "prune")
142 if len(branchErrs) > 0 {152 if len(branchErrs) > 0 {
143- return core.ToolResult{}, fmt.Errorf("manage_worktrees: %s (worktrees were removed; the listed branches were kept)%s", strings.Join(branchErrs, "; "), summary())153+ return core.ToolResult{}, fmt.Errorf("manage_worktrees: worktrees were removed but some branches were kept%s", summary())
144 }154 }
145 result := manageWorktreesResult(plan, "removed")155 result := manageWorktreesResult(plan, "removed")
146 if len(deleted) > 0 {156 if len(deleted) > 0 {
@@ -150,25 +160,64 @@ func (t *ManageWorktreesTool) Execute(ctx context.Context, raw json.RawMessage,
150 }160 }
151 result.Content = []provider.Content{provider.TextBlock{Text: text}}161 result.Content = []provider.Content{provider.TextBlock{Text: text}}
152 }162 }
153- details := result.Details.(map[string]any)163+ if details, ok := result.Details.(map[string]any); ok {
154- details["removed"] = removed164+ details["removed"] = removed
155- details["deleted_branches"] = deleted165+ details["deleted_branches"] = deleted
166+ }
156 return result, nil167 return result, nil
157 }168 }
158 169
159 // deleteMergedBranch deletes refs/heads/<branch> only while it still points at170 // deleteMergedBranch deletes refs/heads/<branch> only while it still points at
160-// head, so a branch that moved after inspection is never discarded.171+// head, is a plain (non-symbolic) ref, and is not checked out in any worktree.
172+// The checks run immediately before deletion because state can change after
173+// planning and after the worktree was removed.
161 func deleteMergedBranch(ctx context.Context, repoRoot, branch, head string) error {174 func deleteMergedBranch(ctx context.Context, repoRoot, branch, head string) error {
162 if branch == "" || head == "" {175 if branch == "" || head == "" {
163 return errors.New("branch or inspected commit is unknown")176 return errors.New("branch or inspected commit is unknown")
164 }177 }
165- _, err := manageWorktreesGit(ctx, repoRoot, "update-ref", "-d", "refs/heads/"+branch, head)178+ ref := "refs/heads/" + branch
179+ // A symbolic branch ref would make update-ref delete its target.
180+ if _, err := manageWorktreesGit(ctx, repoRoot, "symbolic-ref", "-q", ref); err == nil {
181+ return errors.New("branch ref is symbolic and was kept")
182+ } else if createWorktreeGitExitCode(err) != 1 {
183+ return fmt.Errorf("inspect branch ref: %w", err)
184+ }
185+ where, err := branchCheckedOutAt(ctx, repoRoot, ref)
166 if err != nil {186 if err != nil {
187+ return err
188+ }
189+ if where != "" {
190+ return fmt.Errorf("branch is checked out in %q", filepath.ToSlash(where))
191+ }
192+ if _, err := manageWorktreesGit(ctx, repoRoot, "update-ref", "--no-deref", "-d", ref, head); err != nil {
167 return fmt.Errorf("branch changed since inspection or cannot be deleted: %w", err)193 return fmt.Errorf("branch changed since inspection or cannot be deleted: %w", err)
168 }194 }
169 return nil195 return nil
170 }196 }
171 197
198+// branchCheckedOutAt returns the path of a worktree (primary included) that has
199+// ref checked out, or "" when none does.
200+func branchCheckedOutAt(ctx context.Context, repoRoot, ref string) (string, error) {
201+ output, err := manageWorktreesGit(ctx, repoRoot, "worktree", "list", "--porcelain")
202+ if err != nil {
203+ return "", fmt.Errorf("list worktrees: %w", err)
204+ }
205+ for _, block := range strings.Split(strings.TrimSpace(output), "\n\n") {
206+ path, checkedOut := "", false
207+ for _, line := range strings.Split(block, "\n") {
208+ if strings.HasPrefix(line, "worktree ") {
209+ path = strings.TrimPrefix(line, "worktree ")
210+ } else if line == "branch "+ref {
211+ checkedOut = true
212+ }
213+ }
214+ if checkedOut {
215+ return path, nil
216+ }
217+ }
218+ return "", nil
219+}
220+
172 func (t *ManageWorktreesTool) plan(ctx context.Context, raw json.RawMessage) (manageWorktreesPlan, error) {221 func (t *ManageWorktreesTool) plan(ctx context.Context, raw json.RawMessage) (manageWorktreesPlan, error) {
173 var args manageWorktreesArgs222 var args manageWorktreesArgs
174 if err := json.Unmarshal(raw, &args); err != nil {223 if err := json.Unmarshal(raw, &args); err != nil {
@@ -228,7 +277,7 @@ func (t *ManageWorktreesTool) plan(ctx context.Context, raw json.RawMessage) (ma
228 if err != nil {277 if err != nil {
229 return manageWorktreesPlan{}, err278 return manageWorktreesPlan{}, err
230 }279 }
231- plan := manageWorktreesPlan{repoRoot: repoRoot, action: args.Action, into: into, deleteBranch: args.DeleteBranch, all: items}280+ plan := manageWorktreesPlan{repoRoot: repoRoot, action: args.Action, into: into, intoCommit: intoCommit, intoRef: intoRef, deleteBranch: args.DeleteBranch, all: items}
232 if args.Action == "cleanup" {281 if args.Action == "cleanup" {
233 if len(args.Paths) == 0 {282 if len(args.Paths) == 0 {
234 return manageWorktreesPlan{}, errors.New("manage_worktrees: cleanup requires at least one explicit path")283 return manageWorktreesPlan{}, errors.New("manage_worktrees: cleanup requires at least one explicit path")
@@ -266,6 +315,24 @@ func (t *ManageWorktreesTool) plan(ctx context.Context, raw json.RawMessage) (ma
266 }315 }
267 }316 }
268 if args.DeleteBranch {317 if args.DeleteBranch {
318+ // Deleting a branch writes to Git ref storage, which lives outside the
319+ // worktree path. Require write access to it and fail closed. Jail mode
320+ // is a guardrail, not a sandbox: it cannot stop git from touching refs
321+ // once permitted, so this only enforces the declared scopes.
322+ commonDir, err := manageWorktreesGit(ctx, repoRoot, "rev-parse", "--git-common-dir")
323+ if err != nil {
324+ return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: resolve git directory: %w", err)
325+ }
326+ if !filepath.IsAbs(commonDir) {
327+ commonDir = filepath.Join(repoRoot, commonDir)
328+ }
329+ commonDir, err = canonical(commonDir)
330+ if err != nil {
331+ return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: resolve git directory: %w", err)
332+ }
333+ if err := t.Sandbox.CheckWritePath(commonDir); err != nil {
334+ return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: %w", err)
335+ }
269 for _, item := range plan.selected {336 for _, item := range plan.selected {
270 if intoRef == "refs/heads/"+item.Branch {337 if intoRef == "refs/heads/"+item.Branch {
271 return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: branch %q is the merge target and cannot be deleted", item.Branch)338 return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: branch %q is the merge target and cannot be deleted", item.Branch)
@@ -441,7 +508,7 @@ func manageWorktreesResult(plan manageWorktreesPlan, state string) core.ToolResu
441 }508 }
442 509
443 func sameManageWorktreesPlan(a, b manageWorktreesPlan) bool {510 func sameManageWorktreesPlan(a, b manageWorktreesPlan) bool {
444- if a.repoRoot != b.repoRoot || a.action != b.action || a.into != b.into || a.deleteBranch != b.deleteBranch || a.text != b.text || len(a.selected) != len(b.selected) || len(a.all) != len(b.all) {511+ if a.repoRoot != b.repoRoot || a.action != b.action || a.into != b.into || a.intoCommit != b.intoCommit || a.intoRef != b.intoRef || a.deleteBranch != b.deleteBranch || a.text != b.text || len(a.selected) != len(b.selected) || len(a.all) != len(b.all) {
445 return false512 return false
446 }513 }
447 for index := range a.selected {514 for index := range a.selected {
M packages/agent/tools/manage_worktrees_test.go +202 -0
@@ -140,3 +140,205 @@ func TestManageWorktreesRejectsCleanupWithoutExplicitPaths(t *testing.T) {
140140 func newManageWorktreesTestTool(cwd string) *ManageWorktreesTool {
141141 return &ManageWorktreesTool{CWD: cwd, Sandbox: NewSandbox(cwd)}
142142 }
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.
146+func 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+
153+func 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+
158+func 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+
177+func 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+
203+func 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+
234+func 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+
280+func 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+
310+func 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+}
@@ -140,3 +140,205 @@ func TestManageWorktreesRejectsCleanupWithoutExplicitPaths(t *testing.T) {
140 func newManageWorktreesTestTool(cwd string) *ManageWorktreesTool {140 func newManageWorktreesTestTool(cwd string) *ManageWorktreesTool {
141 return &ManageWorktreesTool{CWD: cwd, Sandbox: NewSandbox(cwd)}141 return &ManageWorktreesTool{CWD: cwd, Sandbox: NewSandbox(cwd)}
142 }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.
146+func 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+
153+func 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+
158+func 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+
177+func 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+
203+func 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+
234+func 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+
280+func 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+
310+func 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 476

↑↓ moveenter openesc close