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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 46 | 46 | ||
| 47 | 47 | type createWorktreeArgs struct { | |
| 48 | 48 | Branch string `json:"branch"` | |
| 49 | + | Base string `json:"base,omitempty"` | |
| 49 | 50 | } | |
| 50 | 51 | ||
| 51 | 52 | const createWorktreeSchema = `{ | |
| @@ -54,6 +55,10 @@ const createWorktreeSchema = `{ | |||
| 54 | 55 | "branch":{ | |
| 55 | 56 | "type":"string", | |
| 56 | 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 | 64 | "required":["branch"] | |
| @@ -152,7 +157,8 @@ func (t *CreateWorktreeTool) planForExecute(ctx context.Context, raw json.RawMes | |||
| 152 | 157 | type createWorktreePlan struct { | |
| 153 | 158 | repoRoot string | |
| 154 | 159 | branch string | |
| 155 | - | base string | |
| 160 | + | base string // resolved commit | |
| 161 | + | baseRef string // requested base ref; empty for the current HEAD | |
| 156 | 162 | worktreeRoot string | |
| 157 | 163 | worktreePath string | |
| 158 | 164 | rootSource string | |
| @@ -206,9 +212,16 @@ func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (cre | |||
| 206 | 212 | if branch != branchInput { | |
| 207 | 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 | 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 | 226 | commonDir, err := createWorktreeGitOutput(ctx, repoRoot, "rev-parse", "--git-common-dir") | |
| 214 | 227 | if err != nil { | |
| @@ -283,11 +296,16 @@ func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (cre | |||
| 283 | 296 | ||
| 284 | 297 | displayRoot := filepath.ToSlash(worktreeRoot) | |
| 285 | 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 | 304 | return createWorktreePlan{ | |
| 288 | 305 | repoRoot: repoRoot, | |
| 289 | 306 | branch: branch, | |
| 290 | 307 | base: base, | |
| 308 | + | baseRef: baseRef, | |
| 291 | 309 | worktreeRoot: worktreeRoot, | |
| 292 | 310 | worktreePath: worktreePath, | |
| 293 | 311 | rootSource: rootSource, | |
| @@ -299,6 +317,7 @@ func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (cre | |||
| 299 | 317 | "state": "ready", | |
| 300 | 318 | "branch": branch, | |
| 301 | 319 | "base": base, | |
| 320 | + | "base_ref": baseRef, | |
| 302 | 321 | "worktree_root": displayRoot, | |
| 303 | 322 | "root_source": rootSource, | |
| 304 | 323 | "repo_id": repoID, | |
| @@ -308,6 +327,15 @@ func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (cre | |||
| 308 | 327 | }, 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 | 339 | func (t *CreateWorktreeTool) defaultRoot() (string, error) { | |
| 312 | 340 | root := strings.TrimSpace(t.DefaultRoot) | |
| 313 | 341 | if root == "" { | |
| @@ -449,6 +477,7 @@ func sameCreateWorktreePlan(a, b createWorktreePlan) bool { | |||
| 449 | 477 | return a.repoRoot == b.repoRoot && | |
| 450 | 478 | a.branch == b.branch && | |
| 451 | 479 | a.base == b.base && | |
| 480 | + | a.baseRef == b.baseRef && | |
| 452 | 481 | a.worktreeRoot == b.worktreeRoot && | |
| 453 | 482 | a.worktreePath == b.worktreePath && | |
| 454 | 483 | a.rootSource == b.rootSource && | |
| @@ -548,7 +577,7 @@ func createWorktreeBranchExists(ctx context.Context, repoRoot, branch string) (b | |||
| 548 | 577 | } | |
| 549 | 578 | ||
| 550 | 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 | 581 | if err == nil { | |
| 553 | 582 | return output, true, nil | |
| 554 | 583 | } | |
| @@ -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 string | 158 | repoRoot string | ||
| 154 | branch string | 159 | branch string | ||
| 155 | - | base string | 160 | + | base string // resolved commit |
| 161 | + | baseRef string // requested base ref; empty for the current HEAD | |||
| 156 | worktreeRoot string | 162 | worktreeRoot string | ||
| 157 | worktreePath string | 163 | worktreePath string | ||
| 158 | rootSource string | 164 | 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 | }, nil | 327 | }, 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, nil | 582 | 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) { | |||
| 265 | 265 | if _, ok := parsed.Properties["branch"]; !ok { | |
| 266 | 266 | t.Fatalf("schema lacks branch: %s", schema) | |
| 267 | 267 | } | |
| 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) | |
| 270 | 273 | } | |
| 271 | 274 | if len(parsed.Required) != 1 || parsed.Required[0] != "branch" { | |
| 272 | 275 | 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 { | |||
| 25 | 25 | } | |
| 26 | 26 | ||
| 27 | 27 | 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"` | |
| 30 | 32 | } | |
| 31 | 33 | ||
| 32 | 34 | type managedWorktree struct { | |
| @@ -44,9 +46,12 @@ type managedWorktree struct { | |||
| 44 | 46 | type manageWorktreesPlan struct { | |
| 45 | 47 | repoRoot string | |
| 46 | 48 | 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 | |
| 50 | 55 | } | |
| 51 | 56 | ||
| 52 | 57 | type manageWorktreesPreview struct { | |
| @@ -58,7 +63,9 @@ const manageWorktreesSchema = `{ | |||
| 58 | 63 | "type":"object", | |
| 59 | 64 | "properties":{ | |
| 60 | 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 | 70 | "required":["action"] | |
| 64 | 71 | }` | |
| @@ -95,16 +102,71 @@ func (t *ManageWorktreesTool) Execute(ctx context.Context, raw json.RawMessage, | |||
| 95 | 102 | if plan.action == "list" { | |
| 96 | 103 | 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 | 120 | for _, worktree := range plan.selected { | |
| 99 | 121 | if progress != nil { | |
| 100 | 122 | progress("Removing worktree " + filepath.ToSlash(worktree.Path) + "...\n") | |
| 101 | 123 | } | |
| 102 | 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 | 141 | _, _ = 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 | |
| 108 | 170 | } | |
| 109 | 171 | ||
| 110 | 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 | 205 | if err := t.Sandbox.CheckReadPath(repoRoot); err != nil { | |
| 144 | 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 | 228 | if err != nil { | |
| 148 | 229 | 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 | 232 | if args.Action == "cleanup" { | |
| 152 | 233 | if len(args.Paths) == 0 { | |
| 153 | 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 | 265 | 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 | 281 | plan.text = renderManagedWorktrees(plan) | |
| 189 | 282 | return plan, nil | |
| @@ -238,7 +331,10 @@ func WorktreeInventoryContext(ctx context.Context, cwd string) string { | |||
| 238 | 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 | 338 | output, err := manageWorktreesGit(ctx, repoRoot, "worktree", "list", "--porcelain") | |
| 243 | 339 | if err != nil { | |
| 244 | 340 | return nil, fmt.Errorf("manage_worktrees: list: %w", err) | |
| @@ -279,8 +375,15 @@ func inspectManagedWorktrees(ctx context.Context, repoRoot string) ([]managedWor | |||
| 279 | 375 | } else if status != "" { | |
| 280 | 376 | item.Dirty = true | |
| 281 | 377 | 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 | + | } | |
| 284 | 387 | } else { | |
| 285 | 388 | item.Merged = true | |
| 286 | 389 | item.CleanupOK = true | |
| @@ -306,6 +409,9 @@ func renderManagedWorktrees(plan manageWorktreesPlan) string { | |||
| 306 | 409 | state := item.BlockedBy | |
| 307 | 410 | if item.CleanupOK { | |
| 308 | 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 | 416 | if state != "" { | |
| 311 | 417 | fmt.Fprintf(&b, ": %s", state) | |
| @@ -317,6 +423,12 @@ func renderManagedWorktrees(plan manageWorktreesPlan) string { | |||
| 317 | 423 | for _, item := range plan.selected { | |
| 318 | 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 | 433 | return b.String() | |
| 322 | 434 | } | |
| @@ -329,7 +441,7 @@ func manageWorktreesResult(plan manageWorktreesPlan, state string) core.ToolResu | |||
| 329 | 441 | } | |
| 330 | 442 | ||
| 331 | 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 | 445 | return false | |
| 334 | 446 | } | |
| 335 | 447 | 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 string | 47 | repoRoot string | ||
| 46 | action string | 48 | action string | ||
| 47 | - | selected []managedWorktree | 49 | + | // into is the requested merge target; empty means the current HEAD. |
| 48 | - | all []managedWorktree | 50 | + | into string |
| 49 | - | text string | 51 | + | 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"), nil | 103 | 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"), 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 | |||
| 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{}, err | 229 | 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] = true | 265 | 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, nil | 282 | 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 = true | 376 | 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 = true | 388 | item.Merged = true | ||
| 286 | item.CleanupOK = true | 389 | item.CleanupOK = true | ||
| @@ -306,6 +409,9 @@ func renderManagedWorktrees(plan manageWorktreesPlan) string { | |||||
| 306 | state := item.BlockedBy | 409 | 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 false | 445 | 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 = `{ | |||
| 22 | 22 | "properties":{ | |
| 23 | 23 | "action":{"type":"string","enum":["create","list","cleanup"],"description":"Create a branch worktree, list repository worktrees, or remove explicitly selected safe worktrees."}, | |
| 24 | 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 | 30 | "required":["action"] | |
| 28 | 31 | }` | |
| 29 | 32 | ||
| 30 | 33 | func (t *WorktreeTool) Name() string { return "worktree" } | |
| 31 | 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 | 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 | 92 | return "", nil, errors.New("worktree: action is required") | |
| 90 | 93 | } | |
| 91 | 94 | 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"), | |
| 99 | 105 | } | |
| 100 | - | return action, encoded, nil | |
| 106 | + | 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 | 110 | return action, encoded, err | |
| 104 | 111 | } | |
| 105 | 112 | ||
| @@ -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, err | 98 | + | 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, nil | 106 | + | 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, err | 110 | return action, encoded, err | ||
| 104 | } | 111 | } | ||
| 105 | 112 | ||||
M packages/agent/tools/worktree_test.go +225 -0
| @@ -2,8 +2,10 @@ package tools | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "os" | |
| 5 | 6 | "os/exec" | |
| 6 | 7 | "path/filepath" | |
| 8 | + | "strings" | |
| 7 | 9 | "testing" | |
| 8 | 10 | ) | |
| 9 | 11 | ||
| @@ -35,3 +37,226 @@ func TestWorktreeToolRequiresAction(t *testing.T) { | |||
| 35 | 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 | + | } | |
| @@ -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 | + | } | |||