detached 5b5f89f

feat(tools): add worktree base, merge target, and branch deletion

create accepts a base ref for sub-worktrees of a feature branch. list and cleanup accept an into merge target, and cleanup can delete each removed worktree's branch when it is still at the inspected commit.
commit
5b5f89f50c35bc89d333d4a4357b51fdf047759a
author
brice · · 2026-10-18 17:58 UTC
parent
a637010
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +408 -32
M packages/agent/tools/create_worktree.go +34 -5
@@ -46,6 +46,7 @@ type CreateWorktreeTool struct {
4646
4747 type createWorktreeArgs struct {
4848 Branch string `json:"branch"`
49+ Base string `json:"base,omitempty"`
4950 }
5051
5152 const createWorktreeSchema = `{
@@ -54,6 +55,10 @@ const createWorktreeSchema = `{
5455 "branch":{
5556 "type":"string",
5657 "description":"Name of the new Git branch."
58+ },
59+ "base":{
60+ "type":"string",
61+ "description":"Branch name, tag, or commit to start the new branch from. Defaults to the current HEAD. Use a feature branch to create a sub-worktree based on it."
5762 }
5863 },
5964 "required":["branch"]
@@ -152,7 +157,8 @@ func (t *CreateWorktreeTool) planForExecute(ctx context.Context, raw json.RawMes
152157 type createWorktreePlan struct {
153158 repoRoot string
154159 branch string
155- base string
160+ base string // resolved commit
161+ baseRef string // requested base ref; empty for the current HEAD
156162 worktreeRoot string
157163 worktreePath string
158164 rootSource string
@@ -206,9 +212,16 @@ func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (cre
206212 if branch != branchInput {
207213 return createWorktreePlan{}, fmt.Errorf("create_worktree: invalid branch %q", branchInput)
208214 }
209- base, err := createWorktreeGitOutput(ctx, repoRoot, "rev-parse", "--verify", "HEAD^{commit}")
215+ baseRef := strings.TrimSpace(args.Base)
216+ if strings.HasPrefix(baseRef, "-") {
217+ return createWorktreePlan{}, fmt.Errorf("create_worktree: invalid base %q", baseRef)
218+ }
219+ base, err := resolveWorktreeCommit(ctx, repoRoot, baseRef)
210220 if err != nil {
211- return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve current commit: %w", err)
221+ if baseRef == "" {
222+ return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve current commit: %w", err)
223+ }
224+ return createWorktreePlan{}, fmt.Errorf("create_worktree: invalid base %q: %w", baseRef, err)
212225 }
213226 commonDir, err := createWorktreeGitOutput(ctx, repoRoot, "rev-parse", "--git-common-dir")
214227 if err != nil {
@@ -283,11 +296,16 @@ func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (cre
283296
284297 displayRoot := filepath.ToSlash(worktreeRoot)
285298 displayPath := filepath.ToSlash(worktreePath)
286- previewText := fmt.Sprintf("create worktree\nbranch: %s\nbase: %s\nworktree root: %s\nroot source: %s\npath: %s\n", branch, base, displayRoot, rootSource, displayPath)
299+ baseDisplay := base
300+ if baseRef != "" {
301+ baseDisplay = fmt.Sprintf("%s (%s)", baseRef, base)
302+ }
303+ previewText := fmt.Sprintf("create worktree\nbranch: %s\nbase: %s\nworktree root: %s\nroot source: %s\npath: %s\n", branch, baseDisplay, displayRoot, rootSource, displayPath)
287304 return createWorktreePlan{
288305 repoRoot: repoRoot,
289306 branch: branch,
290307 base: base,
308+ baseRef: baseRef,
291309 worktreeRoot: worktreeRoot,
292310 worktreePath: worktreePath,
293311 rootSource: rootSource,
@@ -299,6 +317,7 @@ func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (cre
299317 "state": "ready",
300318 "branch": branch,
301319 "base": base,
320+ "base_ref": baseRef,
302321 "worktree_root": displayRoot,
303322 "root_source": rootSource,
304323 "repo_id": repoID,
@@ -308,6 +327,15 @@ func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (cre
308327 }, nil
309328 }
310329
330+// resolveWorktreeCommit resolves ref (HEAD when empty) to a commit id. Callers
331+// must reject refs that begin with "-" first so a ref cannot become an option.
332+func resolveWorktreeCommit(ctx context.Context, repoRoot, ref string) (string, error) {
333+ if ref == "" {
334+ ref = "HEAD"
335+ }
336+ return createWorktreeGitOutput(ctx, repoRoot, "rev-parse", "--verify", ref+"^{commit}")
337+}
338+
311339 func (t *CreateWorktreeTool) defaultRoot() (string, error) {
312340 root := strings.TrimSpace(t.DefaultRoot)
313341 if root == "" {
@@ -449,6 +477,7 @@ func sameCreateWorktreePlan(a, b createWorktreePlan) bool {
449477 return a.repoRoot == b.repoRoot &&
450478 a.branch == b.branch &&
451479 a.base == b.base &&
480+ a.baseRef == b.baseRef &&
452481 a.worktreeRoot == b.worktreeRoot &&
453482 a.worktreePath == b.worktreePath &&
454483 a.rootSource == b.rootSource &&
@@ -548,7 +577,7 @@ func createWorktreeBranchExists(ctx context.Context, repoRoot, branch string) (b
548577 }
549578
550579 func createWorktreeGitRef(ctx context.Context, repoRoot, ref string) (string, bool, error) {
551- output, err := createWorktreeGitOutput(ctx, repoRoot, "rev-parse", "--verify", "--quiet", ref+"^{commit}")
580+ output, err := createWorktreeGitOutput(ctx, repoRoot, "rev-parse", "--verify", ref+"^{commit}")
552581 if err == nil {
553582 return output, true, nil
554583 }
@@ -46,6 +46,7 @@ type CreateWorktreeTool struct {
46 46
47 type createWorktreeArgs struct {47 type createWorktreeArgs struct {
48 Branch string `json:"branch"`48 Branch string `json:"branch"`
49+ Base string `json:"base,omitempty"`
49 }50 }
50 51
51 const createWorktreeSchema = `{52 const createWorktreeSchema = `{
@@ -54,6 +55,10 @@ const createWorktreeSchema = `{
54 "branch":{55 "branch":{
55 "type":"string",56 "type":"string",
56 "description":"Name of the new Git branch."57 "description":"Name of the new Git branch."
58+ },
59+ "base":{
60+ "type":"string",
61+ "description":"Branch name, tag, or commit to start the new branch from. Defaults to the current HEAD. Use a feature branch to create a sub-worktree based on it."
57 }62 }
58 },63 },
59 "required":["branch"]64 "required":["branch"]
@@ -152,7 +157,8 @@ func (t *CreateWorktreeTool) planForExecute(ctx context.Context, raw json.RawMes
152 type createWorktreePlan struct {157 type createWorktreePlan struct {
153 repoRoot string158 repoRoot string
154 branch string159 branch string
155- base string160+ base string // resolved commit
161+ baseRef string // requested base ref; empty for the current HEAD
156 worktreeRoot string162 worktreeRoot string
157 worktreePath string163 worktreePath string
158 rootSource string164 rootSource string
@@ -206,9 +212,16 @@ func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (cre
206 if branch != branchInput {212 if branch != branchInput {
207 return createWorktreePlan{}, fmt.Errorf("create_worktree: invalid branch %q", branchInput)213 return createWorktreePlan{}, fmt.Errorf("create_worktree: invalid branch %q", branchInput)
208 }214 }
209- base, err := createWorktreeGitOutput(ctx, repoRoot, "rev-parse", "--verify", "HEAD^{commit}")215+ baseRef := strings.TrimSpace(args.Base)
216+ if strings.HasPrefix(baseRef, "-") {
217+ return createWorktreePlan{}, fmt.Errorf("create_worktree: invalid base %q", baseRef)
218+ }
219+ base, err := resolveWorktreeCommit(ctx, repoRoot, baseRef)
210 if err != nil {220 if err != nil {
211- return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve current commit: %w", err)221+ if baseRef == "" {
222+ return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve current commit: %w", err)
223+ }
224+ return createWorktreePlan{}, fmt.Errorf("create_worktree: invalid base %q: %w", baseRef, err)
212 }225 }
213 commonDir, err := createWorktreeGitOutput(ctx, repoRoot, "rev-parse", "--git-common-dir")226 commonDir, err := createWorktreeGitOutput(ctx, repoRoot, "rev-parse", "--git-common-dir")
214 if err != nil {227 if err != nil {
@@ -283,11 +296,16 @@ func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (cre
283 296
284 displayRoot := filepath.ToSlash(worktreeRoot)297 displayRoot := filepath.ToSlash(worktreeRoot)
285 displayPath := filepath.ToSlash(worktreePath)298 displayPath := filepath.ToSlash(worktreePath)
286- previewText := fmt.Sprintf("create worktree\nbranch: %s\nbase: %s\nworktree root: %s\nroot source: %s\npath: %s\n", branch, base, displayRoot, rootSource, displayPath)299+ baseDisplay := base
300+ if baseRef != "" {
301+ baseDisplay = fmt.Sprintf("%s (%s)", baseRef, base)
302+ }
303+ previewText := fmt.Sprintf("create worktree\nbranch: %s\nbase: %s\nworktree root: %s\nroot source: %s\npath: %s\n", branch, baseDisplay, displayRoot, rootSource, displayPath)
287 return createWorktreePlan{304 return createWorktreePlan{
288 repoRoot: repoRoot,305 repoRoot: repoRoot,
289 branch: branch,306 branch: branch,
290 base: base,307 base: base,
308+ baseRef: baseRef,
291 worktreeRoot: worktreeRoot,309 worktreeRoot: worktreeRoot,
292 worktreePath: worktreePath,310 worktreePath: worktreePath,
293 rootSource: rootSource,311 rootSource: rootSource,
@@ -299,6 +317,7 @@ func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (cre
299 "state": "ready",317 "state": "ready",
300 "branch": branch,318 "branch": branch,
301 "base": base,319 "base": base,
320+ "base_ref": baseRef,
302 "worktree_root": displayRoot,321 "worktree_root": displayRoot,
303 "root_source": rootSource,322 "root_source": rootSource,
304 "repo_id": repoID,323 "repo_id": repoID,
@@ -308,6 +327,15 @@ func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (cre
308 }, nil327 }, nil
309 }328 }
310 329
330+// resolveWorktreeCommit resolves ref (HEAD when empty) to a commit id. Callers
331+// must reject refs that begin with "-" first so a ref cannot become an option.
332+func resolveWorktreeCommit(ctx context.Context, repoRoot, ref string) (string, error) {
333+ if ref == "" {
334+ ref = "HEAD"
335+ }
336+ return createWorktreeGitOutput(ctx, repoRoot, "rev-parse", "--verify", ref+"^{commit}")
337+}
338+
311 func (t *CreateWorktreeTool) defaultRoot() (string, error) {339 func (t *CreateWorktreeTool) defaultRoot() (string, error) {
312 root := strings.TrimSpace(t.DefaultRoot)340 root := strings.TrimSpace(t.DefaultRoot)
313 if root == "" {341 if root == "" {
@@ -449,6 +477,7 @@ func sameCreateWorktreePlan(a, b createWorktreePlan) bool {
449 return a.repoRoot == b.repoRoot &&477 return a.repoRoot == b.repoRoot &&
450 a.branch == b.branch &&478 a.branch == b.branch &&
451 a.base == b.base &&479 a.base == b.base &&
480+ a.baseRef == b.baseRef &&
452 a.worktreeRoot == b.worktreeRoot &&481 a.worktreeRoot == b.worktreeRoot &&
453 a.worktreePath == b.worktreePath &&482 a.worktreePath == b.worktreePath &&
454 a.rootSource == b.rootSource &&483 a.rootSource == b.rootSource &&
@@ -548,7 +577,7 @@ func createWorktreeBranchExists(ctx context.Context, repoRoot, branch string) (b
548 }577 }
549 578
550 func createWorktreeGitRef(ctx context.Context, repoRoot, ref string) (string, bool, error) {579 func createWorktreeGitRef(ctx context.Context, repoRoot, ref string) (string, bool, error) {
551- output, err := createWorktreeGitOutput(ctx, repoRoot, "rev-parse", "--verify", "--quiet", ref+"^{commit}")580+ output, err := createWorktreeGitOutput(ctx, repoRoot, "rev-parse", "--verify", ref+"^{commit}")
552 if err == nil {581 if err == nil {
553 return output, true, nil582 return output, true, nil
554 }583 }
M packages/agent/tools/create_worktree_test.go +5 -2
@@ -265,8 +265,11 @@ func TestCreateWorktreeSchemaDropsBootstrapRoot(t *testing.T) {
265265 if _, ok := parsed.Properties["branch"]; !ok {
266266 t.Fatalf("schema lacks branch: %s", schema)
267267 }
268- if len(parsed.Properties) != 1 {
269- t.Fatalf("schema properties = %v, want only branch", parsed.Properties)
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)
270273 }
271274 if len(parsed.Required) != 1 || parsed.Required[0] != "branch" {
272275 t.Fatalf("schema required = %v", parsed.Required)
@@ -265,8 +265,11 @@ func TestCreateWorktreeSchemaDropsBootstrapRoot(t *testing.T) {
265 if _, ok := parsed.Properties["branch"]; !ok {265 if _, ok := parsed.Properties["branch"]; !ok {
266 t.Fatalf("schema lacks branch: %s", schema)266 t.Fatalf("schema lacks branch: %s", schema)
267 }267 }
268- if len(parsed.Properties) != 1 {268+ if _, ok := parsed.Properties["base"]; !ok {
269- t.Fatalf("schema properties = %v, want only branch", parsed.Properties)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)
270 }273 }
271 if len(parsed.Required) != 1 || parsed.Required[0] != "branch" {274 if len(parsed.Required) != 1 || parsed.Required[0] != "branch" {
272 t.Fatalf("schema required = %v", parsed.Required)275 t.Fatalf("schema required = %v", parsed.Required)
M packages/agent/tools/manage_worktrees.go +126 -14
@@ -25,8 +25,10 @@ type ManageWorktreesTool struct {
2525 }
2626
2727 type manageWorktreesArgs struct {
28- Action string `json:"action"`
29- Paths []string `json:"paths,omitempty"`
28+ Action string `json:"action"`
29+ Paths []string `json:"paths,omitempty"`
30+ Into string `json:"into,omitempty"`
31+ DeleteBranch bool `json:"delete_branch,omitempty"`
3032 }
3133
3234 type managedWorktree struct {
@@ -44,9 +46,12 @@ type managedWorktree struct {
4446 type manageWorktreesPlan struct {
4547 repoRoot string
4648 action string
47- selected []managedWorktree
48- all []managedWorktree
49- text string
49+ // into is the requested merge target; empty means the current HEAD.
50+ into string
51+ deleteBranch bool
52+ selected []managedWorktree
53+ all []managedWorktree
54+ text string
5055 }
5156
5257 type manageWorktreesPreview struct {
@@ -58,7 +63,9 @@ const manageWorktreesSchema = `{
5863 "type":"object",
5964 "properties":{
6065 "action":{"type":"string","enum":["list","cleanup"],"description":"List repository worktrees, or remove explicitly selected worktrees that are clean and merged."},
61- "paths":{"type":"array","items":{"type":"string"},"description":"Exact worktree paths to remove. Required for cleanup."}
66+ "paths":{"type":"array","items":{"type":"string"},"description":"Exact worktree paths to remove. Required for cleanup."},
67+ "into":{"type":"string","description":"Branch, tag, or commit the worktree branches must be merged into. Defaults to the current HEAD."},
68+ "delete_branch":{"type":"boolean","description":"Also delete each removed worktree's local branch if it is still at the inspected commit."}
6269 },
6370 "required":["action"]
6471 }`
@@ -95,16 +102,71 @@ func (t *ManageWorktreesTool) Execute(ctx context.Context, raw json.RawMessage,
95102 if plan.action == "list" {
96103 return manageWorktreesResult(plan, "listed"), nil
97104 }
105+ var removed, deleted []string
106+ var branchErrs []string
107+ summary := func() string {
108+ var parts []string
109+ if len(removed) > 0 {
110+ parts = append(parts, "removed worktrees: "+strings.Join(removed, ", "))
111+ }
112+ if len(deleted) > 0 {
113+ parts = append(parts, "deleted branches: "+strings.Join(deleted, ", "))
114+ }
115+ if len(parts) == 0 {
116+ return ""
117+ }
118+ return "; " + strings.Join(parts, "; ")
119+ }
98120 for _, worktree := range plan.selected {
99121 if progress != nil {
100122 progress("Removing worktree " + filepath.ToSlash(worktree.Path) + "...\n")
101123 }
102124 if _, err := manageWorktreesGit(ctx, plan.repoRoot, "worktree", "remove", worktree.Path); err != nil {
103- return core.ToolResult{}, fmt.Errorf("manage_worktrees: remove %q: %w", filepath.ToSlash(worktree.Path), err)
125+ _, _ = manageWorktreesGit(ctx, plan.repoRoot, "worktree", "prune")
126+ return core.ToolResult{}, fmt.Errorf("manage_worktrees: remove %q: %w%s", filepath.ToSlash(worktree.Path), err, summary())
127+ }
128+ removed = append(removed, filepath.ToSlash(worktree.Path))
129+ if !plan.deleteBranch {
130+ continue
131+ }
132+ if progress != nil {
133+ progress("Deleting branch " + worktree.Branch + "...\n")
104134 }
135+ if err := deleteMergedBranch(ctx, plan.repoRoot, worktree.Branch, worktree.Head); err != nil {
136+ branchErrs = append(branchErrs, fmt.Sprintf("delete branch %q: %v", worktree.Branch, err))
137+ continue
138+ }
139+ deleted = append(deleted, worktree.Branch)
105140 }
106141 _, _ = manageWorktreesGit(ctx, plan.repoRoot, "worktree", "prune")
107- return manageWorktreesResult(plan, "removed"), nil
142+ 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())
144+ }
145+ result := manageWorktreesResult(plan, "removed")
146+ if len(deleted) > 0 {
147+ text := plan.text + "deleted branches:\n"
148+ for _, branch := range deleted {
149+ text += "- " + branch + "\n"
150+ }
151+ result.Content = []provider.Content{provider.TextBlock{Text: text}}
152+ }
153+ details := result.Details.(map[string]any)
154+ details["removed"] = removed
155+ details["deleted_branches"] = deleted
156+ return result, nil
157+}
158+
159+// deleteMergedBranch deletes refs/heads/<branch> only while it still points at
160+// head, so a branch that moved after inspection is never discarded.
161+func deleteMergedBranch(ctx context.Context, repoRoot, branch, head string) error {
162+ if branch == "" || head == "" {
163+ return errors.New("branch or inspected commit is unknown")
164+ }
165+ _, err := manageWorktreesGit(ctx, repoRoot, "update-ref", "-d", "refs/heads/"+branch, head)
166+ if err != nil {
167+ return fmt.Errorf("branch changed since inspection or cannot be deleted: %w", err)
168+ }
169+ return nil
108170 }
109171
110172 func (t *ManageWorktreesTool) plan(ctx context.Context, raw json.RawMessage) (manageWorktreesPlan, error) {
@@ -143,11 +205,30 @@ func (t *ManageWorktreesTool) plan(ctx context.Context, raw json.RawMessage) (ma
143205 if err := t.Sandbox.CheckReadPath(repoRoot); err != nil {
144206 return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: %w", err)
145207 }
146- items, err := inspectManagedWorktrees(ctx, repoRoot)
208+ if args.Action == "list" && args.DeleteBranch {
209+ return manageWorktreesPlan{}, errors.New("manage_worktrees: delete_branch applies only to cleanup")
210+ }
211+ into := strings.TrimSpace(args.Into)
212+ if strings.HasPrefix(into, "-") {
213+ return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: invalid into %q", into)
214+ }
215+ target := into
216+ if target == "" {
217+ target = "HEAD"
218+ }
219+ intoCommit, err := manageWorktreesGit(ctx, repoRoot, "rev-parse", "--verify", target+"^{commit}")
220+ if err != nil {
221+ if into == "" {
222+ return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: resolve current commit: %w", err)
223+ }
224+ return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: invalid into %q: %w", into, err)
225+ }
226+ intoRef, _ := manageWorktreesGit(ctx, repoRoot, "rev-parse", "--symbolic-full-name", target)
227+ items, err := inspectManagedWorktrees(ctx, repoRoot, intoCommit, into)
147228 if err != nil {
148229 return manageWorktreesPlan{}, err
149230 }
150- plan := manageWorktreesPlan{repoRoot: repoRoot, action: args.Action, all: items}
231+ plan := manageWorktreesPlan{repoRoot: repoRoot, action: args.Action, into: into, deleteBranch: args.DeleteBranch, all: items}
151232 if args.Action == "cleanup" {
152233 if len(args.Paths) == 0 {
153234 return manageWorktreesPlan{}, errors.New("manage_worktrees: cleanup requires at least one explicit path")
@@ -184,6 +265,18 @@ func (t *ManageWorktreesTool) plan(ctx context.Context, raw json.RawMessage) (ma
184265 seen[item.Path] = true
185266 }
186267 }
268+ if args.DeleteBranch {
269+ for _, item := range plan.selected {
270+ 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)
272+ }
273+ for _, other := range items {
274+ if other.Branch == item.Branch && !seen[other.Path] {
275+ return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: branch %q is checked out in %q and cannot be deleted", item.Branch, filepath.ToSlash(other.Path))
276+ }
277+ }
278+ }
279+ }
187280 }
188281 plan.text = renderManagedWorktrees(plan)
189282 return plan, nil
@@ -238,7 +331,10 @@ func WorktreeInventoryContext(ctx context.Context, cwd string) string {
238331 return b.String()
239332 }
240333
241-func inspectManagedWorktrees(ctx context.Context, repoRoot string) ([]managedWorktree, error) {
334+// inspectManagedWorktrees reports each worktree's state. A branch is merged
335+// when its head is an ancestor of intoCommit; into names that target for
336+// messages and is empty for the current HEAD.
337+func inspectManagedWorktrees(ctx context.Context, repoRoot, intoCommit, into string) ([]managedWorktree, error) {
242338 output, err := manageWorktreesGit(ctx, repoRoot, "worktree", "list", "--porcelain")
243339 if err != nil {
244340 return nil, fmt.Errorf("manage_worktrees: list: %w", err)
@@ -279,8 +375,15 @@ func inspectManagedWorktrees(ctx context.Context, repoRoot string) ([]managedWor
279375 } else if status != "" {
280376 item.Dirty = true
281377 item.BlockedBy = "uncommitted changes"
282- } else if _, mergeErr := manageWorktreesGit(ctx, repoRoot, "merge-base", "--is-ancestor", item.Head, "HEAD"); mergeErr != nil {
283- item.BlockedBy = "branch is not merged into the current branch: " + firstErrorLine(mergeErr)
378+ } else if _, mergeErr := manageWorktreesGit(ctx, repoRoot, "merge-base", "--is-ancestor", item.Head, intoCommit); mergeErr != nil {
379+ target := "the current branch"
380+ if into != "" {
381+ target = fmt.Sprintf("%q", into)
382+ }
383+ item.BlockedBy = "branch is not merged into " + target
384+ if createWorktreeGitExitCode(mergeErr) != 1 {
385+ item.BlockedBy += ": " + firstErrorLine(mergeErr)
386+ }
284387 } else {
285388 item.Merged = true
286389 item.CleanupOK = true
@@ -306,6 +409,9 @@ func renderManagedWorktrees(plan manageWorktreesPlan) string {
306409 state := item.BlockedBy
307410 if item.CleanupOK {
308411 state = "clean and merged; cleanup eligible"
412+ if plan.into != "" {
413+ state = fmt.Sprintf("clean and merged into %q; cleanup eligible", plan.into)
414+ }
309415 }
310416 if state != "" {
311417 fmt.Fprintf(&b, ": %s", state)
@@ -317,6 +423,12 @@ func renderManagedWorktrees(plan manageWorktreesPlan) string {
317423 for _, item := range plan.selected {
318424 fmt.Fprintf(&b, "- %s\n", filepath.ToSlash(item.Path))
319425 }
426+ if plan.deleteBranch {
427+ b.WriteString("delete branches:\n")
428+ for _, item := range plan.selected {
429+ fmt.Fprintf(&b, "- %s\n", item.Branch)
430+ }
431+ }
320432 }
321433 return b.String()
322434 }
@@ -329,7 +441,7 @@ func manageWorktreesResult(plan manageWorktreesPlan, state string) core.ToolResu
329441 }
330442
331443 func sameManageWorktreesPlan(a, b manageWorktreesPlan) bool {
332- if a.repoRoot != b.repoRoot || a.action != b.action || a.text != b.text || len(a.selected) != len(b.selected) || len(a.all) != len(b.all) {
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) {
333445 return false
334446 }
335447 for index := range a.selected {
@@ -25,8 +25,10 @@ type ManageWorktreesTool struct {
25 }25 }
26 26
27 type manageWorktreesArgs struct {27 type manageWorktreesArgs struct {
28- Action string `json:"action"`28+ Action string `json:"action"`
29- Paths []string `json:"paths,omitempty"`29+ Paths []string `json:"paths,omitempty"`
30+ Into string `json:"into,omitempty"`
31+ DeleteBranch bool `json:"delete_branch,omitempty"`
30 }32 }
31 33
32 type managedWorktree struct {34 type managedWorktree struct {
@@ -44,9 +46,12 @@ type managedWorktree struct {
44 type manageWorktreesPlan struct {46 type manageWorktreesPlan struct {
45 repoRoot string47 repoRoot string
46 action string48 action string
47- selected []managedWorktree49+ // into is the requested merge target; empty means the current HEAD.
48- all []managedWorktree50+ into string
49- text string51+ deleteBranch bool
52+ selected []managedWorktree
53+ all []managedWorktree
54+ text string
50 }55 }
51 56
52 type manageWorktreesPreview struct {57 type manageWorktreesPreview struct {
@@ -58,7 +63,9 @@ const manageWorktreesSchema = `{
58 "type":"object",63 "type":"object",
59 "properties":{64 "properties":{
60 "action":{"type":"string","enum":["list","cleanup"],"description":"List repository worktrees, or remove explicitly selected worktrees that are clean and merged."},65 "action":{"type":"string","enum":["list","cleanup"],"description":"List repository worktrees, or remove explicitly selected worktrees that are clean and merged."},
61- "paths":{"type":"array","items":{"type":"string"},"description":"Exact worktree paths to remove. Required for cleanup."}66+ "paths":{"type":"array","items":{"type":"string"},"description":"Exact worktree paths to remove. Required for cleanup."},
67+ "into":{"type":"string","description":"Branch, tag, or commit the worktree branches must be merged into. Defaults to the current HEAD."},
68+ "delete_branch":{"type":"boolean","description":"Also delete each removed worktree's local branch if it is still at the inspected commit."}
62 },69 },
63 "required":["action"]70 "required":["action"]
64 }`71 }`
@@ -95,16 +102,71 @@ func (t *ManageWorktreesTool) Execute(ctx context.Context, raw json.RawMessage,
95 if plan.action == "list" {102 if plan.action == "list" {
96 return manageWorktreesResult(plan, "listed"), nil103 return manageWorktreesResult(plan, "listed"), nil
97 }104 }
105+ var removed, deleted []string
106+ var branchErrs []string
107+ summary := func() string {
108+ var parts []string
109+ if len(removed) > 0 {
110+ parts = append(parts, "removed worktrees: "+strings.Join(removed, ", "))
111+ }
112+ if len(deleted) > 0 {
113+ parts = append(parts, "deleted branches: "+strings.Join(deleted, ", "))
114+ }
115+ if len(parts) == 0 {
116+ return ""
117+ }
118+ return "; " + strings.Join(parts, "; ")
119+ }
98 for _, worktree := range plan.selected {120 for _, worktree := range plan.selected {
99 if progress != nil {121 if progress != nil {
100 progress("Removing worktree " + filepath.ToSlash(worktree.Path) + "...\n")122 progress("Removing worktree " + filepath.ToSlash(worktree.Path) + "...\n")
101 }123 }
102 if _, err := manageWorktreesGit(ctx, plan.repoRoot, "worktree", "remove", worktree.Path); err != nil {124 if _, err := manageWorktreesGit(ctx, plan.repoRoot, "worktree", "remove", worktree.Path); err != nil {
103- return core.ToolResult{}, fmt.Errorf("manage_worktrees: remove %q: %w", filepath.ToSlash(worktree.Path), err)125+ _, _ = manageWorktreesGit(ctx, plan.repoRoot, "worktree", "prune")
126+ return core.ToolResult{}, fmt.Errorf("manage_worktrees: remove %q: %w%s", filepath.ToSlash(worktree.Path), err, summary())
127+ }
128+ removed = append(removed, filepath.ToSlash(worktree.Path))
129+ if !plan.deleteBranch {
130+ continue
131+ }
132+ if progress != nil {
133+ progress("Deleting branch " + worktree.Branch + "...\n")
104 }134 }
135+ if err := deleteMergedBranch(ctx, plan.repoRoot, worktree.Branch, worktree.Head); err != nil {
136+ branchErrs = append(branchErrs, fmt.Sprintf("delete branch %q: %v", worktree.Branch, err))
137+ continue
138+ }
139+ deleted = append(deleted, worktree.Branch)
105 }140 }
106 _, _ = manageWorktreesGit(ctx, plan.repoRoot, "worktree", "prune")141 _, _ = manageWorktreesGit(ctx, plan.repoRoot, "worktree", "prune")
107- return manageWorktreesResult(plan, "removed"), nil142+ 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())
144+ }
145+ result := manageWorktreesResult(plan, "removed")
146+ if len(deleted) > 0 {
147+ text := plan.text + "deleted branches:\n"
148+ for _, branch := range deleted {
149+ text += "- " + branch + "\n"
150+ }
151+ result.Content = []provider.Content{provider.TextBlock{Text: text}}
152+ }
153+ details := result.Details.(map[string]any)
154+ details["removed"] = removed
155+ details["deleted_branches"] = deleted
156+ return result, nil
157+}
158+
159+// deleteMergedBranch deletes refs/heads/<branch> only while it still points at
160+// head, so a branch that moved after inspection is never discarded.
161+func deleteMergedBranch(ctx context.Context, repoRoot, branch, head string) error {
162+ if branch == "" || head == "" {
163+ return errors.New("branch or inspected commit is unknown")
164+ }
165+ _, err := manageWorktreesGit(ctx, repoRoot, "update-ref", "-d", "refs/heads/"+branch, head)
166+ if err != nil {
167+ return fmt.Errorf("branch changed since inspection or cannot be deleted: %w", err)
168+ }
169+ return nil
108 }170 }
109 171
110 func (t *ManageWorktreesTool) plan(ctx context.Context, raw json.RawMessage) (manageWorktreesPlan, error) {172 func (t *ManageWorktreesTool) plan(ctx context.Context, raw json.RawMessage) (manageWorktreesPlan, error) {
@@ -143,11 +205,30 @@ func (t *ManageWorktreesTool) plan(ctx context.Context, raw json.RawMessage) (ma
143 if err := t.Sandbox.CheckReadPath(repoRoot); err != nil {205 if err := t.Sandbox.CheckReadPath(repoRoot); err != nil {
144 return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: %w", err)206 return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: %w", err)
145 }207 }
146- items, err := inspectManagedWorktrees(ctx, repoRoot)208+ if args.Action == "list" && args.DeleteBranch {
209+ return manageWorktreesPlan{}, errors.New("manage_worktrees: delete_branch applies only to cleanup")
210+ }
211+ into := strings.TrimSpace(args.Into)
212+ if strings.HasPrefix(into, "-") {
213+ return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: invalid into %q", into)
214+ }
215+ target := into
216+ if target == "" {
217+ target = "HEAD"
218+ }
219+ intoCommit, err := manageWorktreesGit(ctx, repoRoot, "rev-parse", "--verify", target+"^{commit}")
220+ if err != nil {
221+ if into == "" {
222+ return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: resolve current commit: %w", err)
223+ }
224+ return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: invalid into %q: %w", into, err)
225+ }
226+ intoRef, _ := manageWorktreesGit(ctx, repoRoot, "rev-parse", "--symbolic-full-name", target)
227+ items, err := inspectManagedWorktrees(ctx, repoRoot, intoCommit, into)
147 if err != nil {228 if err != nil {
148 return manageWorktreesPlan{}, err229 return manageWorktreesPlan{}, err
149 }230 }
150- plan := manageWorktreesPlan{repoRoot: repoRoot, action: args.Action, all: items}231+ plan := manageWorktreesPlan{repoRoot: repoRoot, action: args.Action, into: into, deleteBranch: args.DeleteBranch, all: items}
151 if args.Action == "cleanup" {232 if args.Action == "cleanup" {
152 if len(args.Paths) == 0 {233 if len(args.Paths) == 0 {
153 return manageWorktreesPlan{}, errors.New("manage_worktrees: cleanup requires at least one explicit path")234 return manageWorktreesPlan{}, errors.New("manage_worktrees: cleanup requires at least one explicit path")
@@ -184,6 +265,18 @@ func (t *ManageWorktreesTool) plan(ctx context.Context, raw json.RawMessage) (ma
184 seen[item.Path] = true265 seen[item.Path] = true
185 }266 }
186 }267 }
268+ if args.DeleteBranch {
269+ for _, item := range plan.selected {
270+ 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)
272+ }
273+ for _, other := range items {
274+ if other.Branch == item.Branch && !seen[other.Path] {
275+ return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: branch %q is checked out in %q and cannot be deleted", item.Branch, filepath.ToSlash(other.Path))
276+ }
277+ }
278+ }
279+ }
187 }280 }
188 plan.text = renderManagedWorktrees(plan)281 plan.text = renderManagedWorktrees(plan)
189 return plan, nil282 return plan, nil
@@ -238,7 +331,10 @@ func WorktreeInventoryContext(ctx context.Context, cwd string) string {
238 return b.String()331 return b.String()
239 }332 }
240 333
241-func inspectManagedWorktrees(ctx context.Context, repoRoot string) ([]managedWorktree, error) {334+// inspectManagedWorktrees reports each worktree's state. A branch is merged
335+// when its head is an ancestor of intoCommit; into names that target for
336+// messages and is empty for the current HEAD.
337+func inspectManagedWorktrees(ctx context.Context, repoRoot, intoCommit, into string) ([]managedWorktree, error) {
242 output, err := manageWorktreesGit(ctx, repoRoot, "worktree", "list", "--porcelain")338 output, err := manageWorktreesGit(ctx, repoRoot, "worktree", "list", "--porcelain")
243 if err != nil {339 if err != nil {
244 return nil, fmt.Errorf("manage_worktrees: list: %w", err)340 return nil, fmt.Errorf("manage_worktrees: list: %w", err)
@@ -279,8 +375,15 @@ func inspectManagedWorktrees(ctx context.Context, repoRoot string) ([]managedWor
279 } else if status != "" {375 } else if status != "" {
280 item.Dirty = true376 item.Dirty = true
281 item.BlockedBy = "uncommitted changes"377 item.BlockedBy = "uncommitted changes"
282- } else if _, mergeErr := manageWorktreesGit(ctx, repoRoot, "merge-base", "--is-ancestor", item.Head, "HEAD"); mergeErr != nil {378+ } else if _, mergeErr := manageWorktreesGit(ctx, repoRoot, "merge-base", "--is-ancestor", item.Head, intoCommit); mergeErr != nil {
283- item.BlockedBy = "branch is not merged into the current branch: " + firstErrorLine(mergeErr)379+ target := "the current branch"
380+ if into != "" {
381+ target = fmt.Sprintf("%q", into)
382+ }
383+ item.BlockedBy = "branch is not merged into " + target
384+ if createWorktreeGitExitCode(mergeErr) != 1 {
385+ item.BlockedBy += ": " + firstErrorLine(mergeErr)
386+ }
284 } else {387 } else {
285 item.Merged = true388 item.Merged = true
286 item.CleanupOK = true389 item.CleanupOK = true
@@ -306,6 +409,9 @@ func renderManagedWorktrees(plan manageWorktreesPlan) string {
306 state := item.BlockedBy409 state := item.BlockedBy
307 if item.CleanupOK {410 if item.CleanupOK {
308 state = "clean and merged; cleanup eligible"411 state = "clean and merged; cleanup eligible"
412+ if plan.into != "" {
413+ state = fmt.Sprintf("clean and merged into %q; cleanup eligible", plan.into)
414+ }
309 }415 }
310 if state != "" {416 if state != "" {
311 fmt.Fprintf(&b, ": %s", state)417 fmt.Fprintf(&b, ": %s", state)
@@ -317,6 +423,12 @@ func renderManagedWorktrees(plan manageWorktreesPlan) string {
317 for _, item := range plan.selected {423 for _, item := range plan.selected {
318 fmt.Fprintf(&b, "- %s\n", filepath.ToSlash(item.Path))424 fmt.Fprintf(&b, "- %s\n", filepath.ToSlash(item.Path))
319 }425 }
426+ if plan.deleteBranch {
427+ b.WriteString("delete branches:\n")
428+ for _, item := range plan.selected {
429+ fmt.Fprintf(&b, "- %s\n", item.Branch)
430+ }
431+ }
320 }432 }
321 return b.String()433 return b.String()
322 }434 }
@@ -329,7 +441,7 @@ func manageWorktreesResult(plan manageWorktreesPlan, state string) core.ToolResu
329 }441 }
330 442
331 func sameManageWorktreesPlan(a, b manageWorktreesPlan) bool {443 func sameManageWorktreesPlan(a, b manageWorktreesPlan) bool {
332- if a.repoRoot != b.repoRoot || a.action != b.action || a.text != b.text || len(a.selected) != len(b.selected) || len(a.all) != len(b.all) {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) {
333 return false445 return false
334 }446 }
335 for index := range a.selected {447 for index := range a.selected {
M packages/agent/tools/worktree.go +18 -11
@@ -22,14 +22,17 @@ const worktreeSchema = `{
2222 "properties":{
2323 "action":{"type":"string","enum":["create","list","cleanup"],"description":"Create a branch worktree, list repository worktrees, or remove explicitly selected safe worktrees."},
2424 "branch":{"type":"string","description":"New branch name. Required for create."},
25- "paths":{"type":"array","items":{"type":"string"},"description":"Exact registered worktree paths. Required for cleanup."}
25+ "base":{"type":"string","description":"create only: branch, tag, or commit the new branch starts from. Defaults to the current HEAD. For a sub-worktree of a feature branch (for example feat/x-api from feat/x), set base to that feature branch."},
26+ "paths":{"type":"array","items":{"type":"string"},"description":"Exact registered worktree paths. Required for cleanup."},
27+ "into":{"type":"string","description":"cleanup/list: branch, tag, or commit the worktree branches must be merged into to be removable. Defaults to the current HEAD. After merging sub-branches into a feature branch, set into to that feature branch."},
28+ "delete_branch":{"type":"boolean","description":"cleanup only: also delete each removed worktree's local branch, only if it is still at the inspected commit. Never deletes the merge target or a branch checked out elsewhere."}
2629 },
2730 "required":["action"]
2831 }`
2932
3033 func (t *WorktreeTool) Name() string { return "worktree" }
3134 func (t *WorktreeTool) Description() string {
32- return "Create, list, and safely clean up Git worktrees."
35+ return "Create, list, and safely clean up Git worktrees. Supports sub-worktrees based on a feature branch and cleanup of branches merged into it."
3336 }
3437 func (t *WorktreeTool) Schema() json.RawMessage { return json.RawMessage(worktreeSchema) }
3538
@@ -89,17 +92,21 @@ func parseWorktreeAction(raw json.RawMessage) (string, json.RawMessage, error) {
8992 return "", nil, errors.New("worktree: action is required")
9093 }
9194 delete(values, "action")
92- if action == "list" || action == "cleanup" {
93- encoded, err := json.Marshal(map[string]any{"action": action, "paths": rawField(values, "paths")})
94- if err != nil {
95- return "", nil, err
96- }
97- if action == "list" {
98- encoded = json.RawMessage(`{"action":"list"}`)
95+ var fields map[string]any
96+ switch action {
97+ case "list":
98+ fields = map[string]any{"action": action, "into": rawField(values, "into")}
99+ case "cleanup":
100+ fields = map[string]any{
101+ "action": action,
102+ "paths": rawField(values, "paths"),
103+ "into": rawField(values, "into"),
104+ "delete_branch": rawField(values, "delete_branch"),
99105 }
100- return action, encoded, nil
106+ default:
107+ fields = map[string]any{"branch": rawField(values, "branch"), "base": rawField(values, "base")}
101108 }
102- encoded, err := json.Marshal(map[string]any{"branch": rawField(values, "branch")})
109+ encoded, err := json.Marshal(fields)
103110 return action, encoded, err
104111 }
105112
@@ -22,14 +22,17 @@ const worktreeSchema = `{
22 "properties":{22 "properties":{
23 "action":{"type":"string","enum":["create","list","cleanup"],"description":"Create a branch worktree, list repository worktrees, or remove explicitly selected safe worktrees."},23 "action":{"type":"string","enum":["create","list","cleanup"],"description":"Create a branch worktree, list repository worktrees, or remove explicitly selected safe worktrees."},
24 "branch":{"type":"string","description":"New branch name. Required for create."},24 "branch":{"type":"string","description":"New branch name. Required for create."},
25- "paths":{"type":"array","items":{"type":"string"},"description":"Exact registered worktree paths. Required for cleanup."}25+ "base":{"type":"string","description":"create only: branch, tag, or commit the new branch starts from. Defaults to the current HEAD. For a sub-worktree of a feature branch (for example feat/x-api from feat/x), set base to that feature branch."},
26+ "paths":{"type":"array","items":{"type":"string"},"description":"Exact registered worktree paths. Required for cleanup."},
27+ "into":{"type":"string","description":"cleanup/list: branch, tag, or commit the worktree branches must be merged into to be removable. Defaults to the current HEAD. After merging sub-branches into a feature branch, set into to that feature branch."},
28+ "delete_branch":{"type":"boolean","description":"cleanup only: also delete each removed worktree's local branch, only if it is still at the inspected commit. Never deletes the merge target or a branch checked out elsewhere."}
26 },29 },
27 "required":["action"]30 "required":["action"]
28 }`31 }`
29 32
30 func (t *WorktreeTool) Name() string { return "worktree" }33 func (t *WorktreeTool) Name() string { return "worktree" }
31 func (t *WorktreeTool) Description() string {34 func (t *WorktreeTool) Description() string {
32- return "Create, list, and safely clean up Git worktrees."35+ return "Create, list, and safely clean up Git worktrees. Supports sub-worktrees based on a feature branch and cleanup of branches merged into it."
33 }36 }
34 func (t *WorktreeTool) Schema() json.RawMessage { return json.RawMessage(worktreeSchema) }37 func (t *WorktreeTool) Schema() json.RawMessage { return json.RawMessage(worktreeSchema) }
35 38
@@ -89,17 +92,21 @@ func parseWorktreeAction(raw json.RawMessage) (string, json.RawMessage, error) {
89 return "", nil, errors.New("worktree: action is required")92 return "", nil, errors.New("worktree: action is required")
90 }93 }
91 delete(values, "action")94 delete(values, "action")
92- if action == "list" || action == "cleanup" {95+ var fields map[string]any
93- encoded, err := json.Marshal(map[string]any{"action": action, "paths": rawField(values, "paths")})96+ switch action {
94- if err != nil {97+ case "list":
95- return "", nil, err98+ fields = map[string]any{"action": action, "into": rawField(values, "into")}
96- }99+ case "cleanup":
97- if action == "list" {100+ fields = map[string]any{
98- encoded = json.RawMessage(`{"action":"list"}`)101+ "action": action,
102+ "paths": rawField(values, "paths"),
103+ "into": rawField(values, "into"),
104+ "delete_branch": rawField(values, "delete_branch"),
99 }105 }
100- return action, encoded, nil106+ default:
107+ fields = map[string]any{"branch": rawField(values, "branch"), "base": rawField(values, "base")}
101 }108 }
102- encoded, err := json.Marshal(map[string]any{"branch": rawField(values, "branch")})109+ encoded, err := json.Marshal(fields)
103 return action, encoded, err110 return action, encoded, err
104 }111 }
105 112
M packages/agent/tools/worktree_test.go +225 -0
@@ -2,8 +2,10 @@ package tools
22
33 import (
44 "context"
5+ "os"
56 "os/exec"
67 "path/filepath"
8+ "strings"
79 "testing"
810 )
911
@@ -35,3 +37,226 @@ func TestWorktreeToolRequiresAction(t *testing.T) {
3537 t.Fatal("missing action was accepted")
3638 }
3739 }
40+
41+// nestedWorktreeFixture creates feat/x and feat/x-api (based on feat/x)
42+// through the public tool and returns their paths.
43+func 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+
85+func 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+
94+func 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+
111+func 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+
124+func 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+
181+func 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+
192+func 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+
205+func 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+
227+func 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+
246+func 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+}
@@ -2,8 +2,10 @@ package tools
2 2
3 import (3 import (
4 "context"4 "context"
5+ "os"
5 "os/exec"6 "os/exec"
6 "path/filepath"7 "path/filepath"
8+ "strings"
7 "testing"9 "testing"
8 )10 )
9 11
@@ -35,3 +37,226 @@ func TestWorktreeToolRequiresAction(t *testing.T) {
35 t.Fatal("missing action was accepted")37 t.Fatal("missing action was accepted")
36 }38 }
37 }39 }
40+
41+// nestedWorktreeFixture creates feat/x and feat/x-api (based on feat/x)
42+// through the public tool and returns their paths.
43+func 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+
85+func 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+
94+func 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+
111+func 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+
124+func 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+
181+func 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+
192+func 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+
205+func 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+
227+func 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+
246+func 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 476

↑↓ moveenter openesc close