Skip to main content

imp_core/tools/
worktree.rs

1use std::path::{Path, PathBuf};
2use std::process::Stdio;
3
4use async_trait::async_trait;
5use serde_json::json;
6use tokio::process::Command;
7
8use super::{resolve_path, Tool, ToolContext, ToolOutput};
9use crate::config::AgentMode;
10use crate::error::Result;
11
12pub struct WorktreeTool;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15enum WorktreeActionClass {
16    ReadOnly,
17    Mutating,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq)]
21struct ParsedWorktreeEntry {
22    path: String,
23    branch: Option<String>,
24    is_bare: bool,
25    is_detached: bool,
26}
27
28#[async_trait]
29impl Tool for WorktreeTool {
30    fn name(&self) -> &str {
31        "worktree"
32    }
33
34    fn label(&self) -> &str {
35        "Worktree"
36    }
37
38    fn description(&self) -> &str {
39        "Git worktree list/add/remove."
40    }
41
42    fn parameters(&self) -> serde_json::Value {
43        json!({
44            "type": "object",
45            "properties": {
46                "action": {
47                    "type": "string",
48                    "enum": ["list", "add", "remove"],
49                    "description": "Worktree action"
50                },
51                "path": {
52                    "type": "string",
53                    "description": "Repo/worktree path"
54                },
55                "worktree_path": {
56                    "type": "string",
57                    "description": "Worktree path"
58                },
59                "branch": {
60                    "type": "string",
61                    "description": "Branch name"
62                },
63                "start_point": {
64                    "type": "string",
65                    "description": "Starting ref"
66                },
67                "force": {
68                    "type": "boolean",
69                    "description": "Force remove"
70                },
71                "delete_branch": {
72                    "type": "boolean",
73                    "description": "Also delete branch"
74                }
75            },
76            "required": ["action"]
77        })
78    }
79
80    fn is_readonly(&self) -> bool {
81        false
82    }
83
84    async fn execute(
85        &self,
86        _call_id: &str,
87        params: serde_json::Value,
88        ctx: ToolContext,
89    ) -> Result<ToolOutput> {
90        let action = match params["action"].as_str() {
91            Some(action) => action,
92            None => return Ok(ToolOutput::error("Missing required parameter: action")),
93        };
94
95        let Some(class) = action_class(action) else {
96            return Ok(ToolOutput::error(format!(
97                "Unknown worktree action \"{action}\""
98            )));
99        };
100
101        if matches!(class, WorktreeActionClass::Mutating)
102            && !matches!(ctx.mode, AgentMode::Full | AgentMode::Worker)
103        {
104            return Ok(ToolOutput::error(format!(
105                "worktree action `{action}` is not permitted in {:?} mode; mutating worktree actions are limited to full/worker execution",
106                ctx.mode
107            )));
108        }
109
110        let cwd = match resolve_git_cwd(&ctx.cwd, params.get("path").and_then(|v| v.as_str())) {
111            Ok(path) => path,
112            Err(err) => return Ok(ToolOutput::error(err)),
113        };
114
115        let repo_root = match repo_root(&cwd).await {
116            Ok(path) => path,
117            Err(err) => return Ok(ToolOutput::error(err)),
118        };
119
120        match action {
121            "list" => list_action(&cwd, &repo_root).await,
122            "add" => add_action(&cwd, &repo_root, &params).await,
123            "remove" => remove_action(&cwd, &repo_root, &params).await,
124            _ => Ok(ToolOutput::error(format!(
125                "Unsupported worktree action `{action}`"
126            ))),
127        }
128    }
129}
130
131fn action_class(action: &str) -> Option<WorktreeActionClass> {
132    match action {
133        "list" => Some(WorktreeActionClass::ReadOnly),
134        "add" | "remove" => Some(WorktreeActionClass::Mutating),
135        _ => None,
136    }
137}
138
139fn resolve_git_cwd(session_cwd: &Path, raw: Option<&str>) -> std::result::Result<PathBuf, String> {
140    let path = match raw {
141        Some(raw) if !raw.trim().is_empty() => resolve_path(session_cwd, raw),
142        _ => session_cwd.to_path_buf(),
143    };
144
145    if path.is_dir() {
146        return Ok(path);
147    }
148
149    if path.is_file() {
150        return path.parent().map(Path::to_path_buf).ok_or_else(|| {
151            format!(
152                "Could not determine a working directory from file path: {}",
153                path.display()
154            )
155        });
156    }
157
158    Err(format!(
159        "git path not found or not accessible: {}",
160        path.display()
161    ))
162}
163
164async fn repo_root(cwd: &Path) -> std::result::Result<PathBuf, String> {
165    let output = run_git(cwd, ["rev-parse", "--show-toplevel"])
166        .await
167        .map_err(|err| format!("Failed to run git in {}: {err}", cwd.display()))?;
168    if !output.status.success() {
169        return Err(not_git_repo_message(cwd, &output));
170    }
171
172    let root = stdout_trimmed(&output);
173    if root.is_empty() {
174        return Err(format!(
175            "Failed to determine git repo root from {}",
176            cwd.display()
177        ));
178    }
179
180    Ok(PathBuf::from(root))
181}
182
183async fn list_action(cwd: &Path, repo_root: &Path) -> Result<ToolOutput> {
184    let output = run_git(cwd, ["worktree", "list", "--porcelain"]).await?;
185    if !output.status.success() {
186        return Ok(git_failure("git worktree list failed", &output));
187    }
188
189    let entries = parse_worktree_list(&stdout_lossy(&output));
190    let current_secondary = mana_core::worktree::detect_worktree(cwd).ok().flatten();
191    let mut text = String::new();
192    text.push_str(&format!("repo: {}\n", repo_root.display()));
193    match &current_secondary {
194        Some(info) => {
195            text.push_str(&format!(
196                "current worktree: secondary ({}) at {}\n",
197                info.branch,
198                info.worktree_path.display()
199            ));
200            text.push_str(&format!("main worktree: {}\n", info.main_path.display()));
201        }
202        None => {
203            text.push_str("current worktree: main\n");
204        }
205    }
206    if entries.is_empty() {
207        text.push_str("registered worktrees: none\n");
208    } else {
209        text.push_str("registered worktrees:\n");
210        for entry in &entries {
211            let branch = entry.branch.as_deref().unwrap_or("(detached)");
212            let mut flags = Vec::new();
213            if entry.is_bare {
214                flags.push("bare");
215            }
216            if entry.is_detached {
217                flags.push("detached");
218            }
219            if flags.is_empty() {
220                text.push_str(&format!("- {} [{}]\n", entry.path, branch));
221            } else {
222                text.push_str(&format!(
223                    "- {} [{}] ({})\n",
224                    entry.path,
225                    branch,
226                    flags.join(", ")
227                ));
228            }
229        }
230    }
231
232    Ok(ToolOutput {
233        content: vec![imp_llm::ContentBlock::Text { text }],
234        details: json!({
235            "action": "list",
236            "repo_root": repo_root.display().to_string(),
237            "current_secondary_worktree": current_secondary.as_ref().map(|info| json!({
238                "main_path": info.main_path.display().to_string(),
239                "worktree_path": info.worktree_path.display().to_string(),
240                "branch": info.branch,
241            })),
242            "worktrees": entries.iter().map(|entry| json!({
243                "path": entry.path,
244                "branch": entry.branch,
245                "is_bare": entry.is_bare,
246                "is_detached": entry.is_detached,
247            })).collect::<Vec<_>>(),
248        }),
249        is_error: false,
250    })
251}
252
253async fn add_action(
254    cwd: &Path,
255    repo_root: &Path,
256    params: &serde_json::Value,
257) -> Result<ToolOutput> {
258    let Some(raw_worktree_path) = non_empty_param(params, "worktree_path") else {
259        return Ok(ToolOutput::error(
260            "Missing required parameter: worktree_path",
261        ));
262    };
263    if let Err(err) = validate_path_string(raw_worktree_path, "worktree_path") {
264        return Ok(ToolOutput::error(err.to_string()));
265    }
266    let Some(branch) = non_empty_param(params, "branch") else {
267        return Ok(ToolOutput::error("Missing required parameter: branch"));
268    };
269    if let Err(err) = validate_ref(branch, "branch") {
270        return Ok(ToolOutput::error(err.to_string()));
271    }
272
273    let start_point = non_empty_param(params, "start_point").unwrap_or("HEAD");
274    if let Err(err) = validate_ref(start_point, "start_point") {
275        return Ok(ToolOutput::error(err.to_string()));
276    }
277    let worktree_path = resolve_path(cwd, raw_worktree_path);
278
279    let output = run_git_owned(
280        cwd,
281        vec![
282            "worktree".to_string(),
283            "add".to_string(),
284            "-b".to_string(),
285            branch.to_string(),
286            worktree_path.display().to_string(),
287            start_point.to_string(),
288        ],
289    )
290    .await?;
291
292    if !output.status.success() {
293        return Ok(git_failure("git worktree add failed", &output));
294    }
295
296    let summary = format!(
297        "Created worktree {} on branch {}",
298        worktree_path.display(),
299        branch
300    );
301
302    Ok(ToolOutput {
303        content: vec![imp_llm::ContentBlock::Text {
304            text: summary.clone(),
305        }],
306        details: json!({
307            "action": "add",
308            "repo_root": repo_root.display().to_string(),
309            "worktree_path": worktree_path.display().to_string(),
310            "branch": branch,
311            "start_point": start_point,
312            "recovery": {
313                "undo": "worktree remove",
314                "worktree_path": worktree_path.display().to_string(),
315                "branch": branch,
316                "delete_branch": true,
317            },
318            "summary": summary,
319        }),
320        is_error: false,
321    })
322}
323
324async fn remove_action(
325    cwd: &Path,
326    repo_root: &Path,
327    params: &serde_json::Value,
328) -> Result<ToolOutput> {
329    let Some(raw_worktree_path) = non_empty_param(params, "worktree_path") else {
330        return Ok(ToolOutput::error(
331            "Missing required parameter: worktree_path",
332        ));
333    };
334    if let Err(err) = validate_path_string(raw_worktree_path, "worktree_path") {
335        return Ok(ToolOutput::error(err.to_string()));
336    }
337    let worktree_path = resolve_path(cwd, raw_worktree_path);
338    let force = params["force"].as_bool().unwrap_or(false);
339    let delete_branch = params["delete_branch"].as_bool().unwrap_or(false);
340
341    if same_path(&worktree_path, repo_root) {
342        return Ok(ToolOutput::error(
343            "Refusing to remove the main worktree/root checkout",
344        ));
345    }
346    if same_path(&worktree_path, cwd) {
347        return Ok(ToolOutput::error(
348            "Refusing to remove the current working directory worktree",
349        ));
350    }
351
352    let entries_output = run_git(cwd, ["worktree", "list", "--porcelain"]).await?;
353    if !entries_output.status.success() {
354        return Ok(git_failure("git worktree list failed", &entries_output));
355    }
356    let entries = parse_worktree_list(&stdout_lossy(&entries_output));
357    let explicit_branch = non_empty_param(params, "branch");
358    if let Some(branch) = explicit_branch {
359        if let Err(err) = validate_ref(branch, "branch") {
360            return Ok(ToolOutput::error(err.to_string()));
361        }
362    }
363    if delete_branch && explicit_branch.is_none() {
364        return Ok(ToolOutput::error(
365            "delete_branch=true requires explicit branch",
366        ));
367    }
368    let matched_branch = explicit_branch.map(str::to_string).or_else(|| {
369        entries
370            .iter()
371            .find(|entry| same_path(Path::new(&entry.path), &worktree_path))
372            .and_then(|entry| entry.branch.clone())
373    });
374
375    let mut args = vec!["worktree".to_string(), "remove".to_string()];
376    if force {
377        args.push("--force".to_string());
378    }
379    args.push(worktree_path.display().to_string());
380
381    let output = run_git_owned(cwd, args).await?;
382    if !output.status.success() {
383        return Ok(git_failure("git worktree remove failed", &output));
384    }
385
386    let mut branch_deleted = false;
387    if delete_branch {
388        if let Some(branch) = matched_branch.as_deref() {
389            let branch_output = run_git_owned(
390                cwd,
391                vec![
392                    "branch".to_string(),
393                    if force { "-D" } else { "-d" }.to_string(),
394                    branch.to_string(),
395                ],
396            )
397            .await?;
398            if !branch_output.status.success() {
399                return Ok(git_failure("git branch delete failed", &branch_output));
400            }
401            branch_deleted = true;
402        }
403    }
404
405    let summary = if branch_deleted {
406        format!(
407            "Removed worktree {} and deleted branch {}",
408            worktree_path.display(),
409            matched_branch.as_deref().unwrap_or("(unknown)")
410        )
411    } else {
412        format!("Removed worktree {}", worktree_path.display())
413    };
414
415    Ok(ToolOutput {
416        content: vec![imp_llm::ContentBlock::Text {
417            text: summary.clone(),
418        }],
419        details: json!({
420            "action": "remove",
421            "repo_root": repo_root.display().to_string(),
422            "worktree_path": worktree_path.display().to_string(),
423            "force": force,
424            "delete_branch": delete_branch,
425            "branch": matched_branch,
426            "branch_deleted": branch_deleted,
427            "recovery": {
428                "guidance": "Recreate removed worktree with worktree add if needed; deleted branches may be recoverable from reflog.",
429                "worktree_path": worktree_path.display().to_string(),
430                "branch_deleted": branch_deleted,
431            },
432            "summary": summary,
433        }),
434        is_error: false,
435    })
436}
437
438fn non_empty_param<'a>(params: &'a serde_json::Value, field_name: &str) -> Option<&'a str> {
439    params
440        .get(field_name)?
441        .as_str()
442        .map(str::trim)
443        .filter(|s| !s.is_empty())
444}
445
446fn validate_path_string(
447    value: &str,
448    field_name: &str,
449) -> std::result::Result<(), crate::error::Error> {
450    if value.chars().any(|c| c == '\0' || c.is_control()) {
451        return Err(crate::error::Error::Tool(format!(
452            "{field_name} must be a safe path string"
453        )));
454    }
455    Ok(())
456}
457
458fn validate_ref(value: &str, field_name: &str) -> std::result::Result<(), crate::error::Error> {
459    if value.starts_with('-') || value.chars().any(|c| c == '\0' || c.is_control()) {
460        return Err(crate::error::Error::Tool(format!(
461            "{field_name} must be a safe git ref"
462        )));
463    }
464    Ok(())
465}
466
467async fn run_git<I, S>(cwd: &Path, args: I) -> std::io::Result<std::process::Output>
468where
469    I: IntoIterator<Item = S>,
470    S: AsRef<std::ffi::OsStr>,
471{
472    let mut command = Command::new("git");
473    command
474        .args(args)
475        .current_dir(cwd)
476        .stdin(Stdio::null())
477        .stdout(Stdio::piped())
478        .stderr(Stdio::piped());
479    command.output().await
480}
481
482async fn run_git_owned(cwd: &Path, args: Vec<String>) -> std::io::Result<std::process::Output> {
483    run_git(cwd, args).await
484}
485
486fn stdout_lossy(output: &std::process::Output) -> String {
487    String::from_utf8_lossy(&output.stdout).replace('\r', "")
488}
489
490fn stderr_lossy(output: &std::process::Output) -> String {
491    String::from_utf8_lossy(&output.stderr).replace('\r', "")
492}
493
494fn stdout_trimmed(output: &std::process::Output) -> String {
495    stdout_lossy(output).trim().to_string()
496}
497
498fn stderr_trimmed(output: &std::process::Output) -> String {
499    stderr_lossy(output).trim().to_string()
500}
501
502fn not_git_repo_message(cwd: &Path, output: &std::process::Output) -> String {
503    let stderr = stderr_trimmed(output);
504    if stderr.is_empty() {
505        format!("Not inside a git repository: {}", cwd.display())
506    } else {
507        format!("Not inside a git repository: {}\n{}", cwd.display(), stderr)
508    }
509}
510
511fn git_failure(prefix: &str, output: &std::process::Output) -> ToolOutput {
512    let stdout = stdout_trimmed(output);
513    let stderr = stderr_trimmed(output);
514    let combined = match (stdout.is_empty(), stderr.is_empty()) {
515        (true, true) => prefix.to_string(),
516        (false, true) => format!("{prefix}: {stdout}"),
517        (true, false) => format!("{prefix}: {stderr}"),
518        (false, false) => format!("{prefix}: {stdout}\n{stderr}"),
519    };
520    ToolOutput {
521        content: vec![imp_llm::ContentBlock::Text { text: combined }],
522        details: json!({
523            "success": false,
524            "exit_code": output.status.code(),
525            "stdout": stdout,
526            "stderr": stderr,
527        }),
528        is_error: true,
529    }
530}
531
532fn parse_worktree_list(output: &str) -> Vec<ParsedWorktreeEntry> {
533    let mut entries = Vec::new();
534    let mut current_path: Option<String> = None;
535    let mut current_branch: Option<String> = None;
536    let mut is_bare = false;
537    let mut is_detached = false;
538
539    let push_current = |entries: &mut Vec<ParsedWorktreeEntry>,
540                        current_path: &mut Option<String>,
541                        current_branch: &mut Option<String>,
542                        is_bare: &mut bool,
543                        is_detached: &mut bool| {
544        if let Some(path) = current_path.take() {
545            entries.push(ParsedWorktreeEntry {
546                path,
547                branch: current_branch.take(),
548                is_bare: *is_bare,
549                is_detached: *is_detached,
550            });
551        }
552        *is_bare = false;
553        *is_detached = false;
554    };
555
556    for line in output.lines() {
557        if let Some(path) = line.strip_prefix("worktree ") {
558            push_current(
559                &mut entries,
560                &mut current_path,
561                &mut current_branch,
562                &mut is_bare,
563                &mut is_detached,
564            );
565            current_path = Some(path.to_string());
566        } else if let Some(branch_ref) = line.strip_prefix("branch ") {
567            current_branch = Some(
568                branch_ref
569                    .strip_prefix("refs/heads/")
570                    .unwrap_or(branch_ref)
571                    .to_string(),
572            );
573        } else if line == "bare" {
574            is_bare = true;
575        } else if line == "detached" {
576            is_detached = true;
577        }
578    }
579
580    push_current(
581        &mut entries,
582        &mut current_path,
583        &mut current_branch,
584        &mut is_bare,
585        &mut is_detached,
586    );
587    entries
588}
589
590fn same_path(a: &Path, b: &Path) -> bool {
591    match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
592        (Ok(a), Ok(b)) => a == b,
593        _ => a == b,
594    }
595}
596
597#[cfg(test)]
598mod tests {
599    use super::*;
600    use crate::mana_review::TurnManaReviewAccumulator;
601    use crate::tools::{CheckpointState, FileCache, FileTracker};
602    use std::fs;
603    use std::path::Path;
604    use std::sync::Arc;
605
606    fn test_ctx(dir: &Path, mode: AgentMode) -> ToolContext {
607        let (tx, _rx) = tokio::sync::mpsc::channel(16);
608        let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::channel(16);
609        ToolContext {
610            cwd: dir.to_path_buf(),
611            cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
612            update_tx: tx,
613            command_tx: cmd_tx,
614            ui: Arc::new(crate::ui::NullInterface),
615            file_cache: Arc::new(FileCache::new()),
616            checkpoint_state: Arc::new(CheckpointState::new()),
617            file_tracker: Arc::new(std::sync::Mutex::new(FileTracker::new())),
618            anchor_store: Arc::new(crate::tools::AnchorStore::new()),
619            lua_tool_loader: None,
620            mode,
621            read_max_lines: 500,
622            turn_mana_review: Arc::new(std::sync::Mutex::new(TurnManaReviewAccumulator::default())),
623            config: Arc::new(crate::config::Config::default()),
624            run_policy: Default::default(),
625            supporting_provenance: Vec::new(),
626        }
627    }
628
629    fn run_git(dir: &Path, args: &[&str]) {
630        let output = std::process::Command::new("git")
631            .args(args)
632            .current_dir(dir)
633            .output()
634            .unwrap_or_else(|e| panic!("git {:?} failed to execute: {e}", args));
635        assert!(
636            output.status.success(),
637            "git {:?} in {} failed (exit {:?}):\nstdout: {}\nstderr: {}",
638            args,
639            dir.display(),
640            output.status.code(),
641            String::from_utf8_lossy(&output.stdout),
642            String::from_utf8_lossy(&output.stderr)
643        );
644    }
645
646    fn setup_repo() -> tempfile::TempDir {
647        let dir = tempfile::tempdir().unwrap();
648        run_git(dir.path(), &["init"]);
649        run_git(dir.path(), &["config", "user.email", "test@test.com"]);
650        run_git(dir.path(), &["config", "user.name", "Test User"]);
651        fs::write(dir.path().join("note.txt"), "hello\n").unwrap();
652        run_git(dir.path(), &["add", "-A"]);
653        run_git(dir.path(), &["commit", "-m", "initial"]);
654        dir
655    }
656
657    fn extract_text(result: &ToolOutput) -> String {
658        result.text_content().unwrap_or_default().to_string()
659    }
660
661    #[test]
662    fn worktree_schema_exposes_list_add_remove() {
663        let schema = WorktreeTool.parameters();
664        let properties = schema["properties"].as_object().unwrap();
665        let actions = properties["action"]["enum"].as_array().unwrap();
666        assert!(actions.iter().any(|value| value == "list"));
667        assert!(actions.iter().any(|value| value == "add"));
668        assert!(actions.iter().any(|value| value == "remove"));
669        assert!(properties.contains_key("worktree_path"));
670        assert!(properties.contains_key("start_point"));
671        assert!(properties.contains_key("delete_branch"));
672        assert!(!properties.contains_key("worktreePath"));
673        assert!(!properties.contains_key("deleteBranch"));
674    }
675
676    #[tokio::test]
677    async fn worktree_add_list_and_remove_work() {
678        let dir = setup_repo();
679        let tool = WorktreeTool;
680        let worktree_path = dir.path().join("../repo-worktree");
681        let worktree_path_str = worktree_path.display().to_string();
682
683        let add = tool
684            .execute(
685                "c-add",
686                json!({
687                    "action": "add",
688                    "worktree_path": worktree_path_str,
689                    "branch": "feature/test",
690                }),
691                test_ctx(dir.path(), AgentMode::Worker),
692            )
693            .await
694            .unwrap();
695        assert!(!add.is_error);
696        assert!(worktree_path.exists());
697        assert_eq!(add.details["recovery"]["delete_branch"], json!(true));
698
699        let list = tool
700            .execute(
701                "c-list",
702                json!({"action": "list"}),
703                test_ctx(dir.path(), AgentMode::Worker),
704            )
705            .await
706            .unwrap();
707        assert!(!list.is_error);
708        assert!(list.details["worktrees"].as_array().unwrap().len() >= 2);
709
710        let remove = tool
711            .execute(
712                "c-remove",
713                json!({
714                    "action": "remove",
715                    "worktree_path": worktree_path.display().to_string(),
716                    "branch": "feature/test",
717                    "delete_branch": true,
718                }),
719                test_ctx(dir.path(), AgentMode::Worker),
720            )
721            .await
722            .unwrap();
723        assert!(!remove.is_error);
724        assert!(!worktree_path.exists());
725        assert_eq!(remove.details["branch_deleted"], json!(true));
726    }
727
728    #[tokio::test]
729    async fn worktree_refuses_removing_main_or_current_worktree() {
730        let dir = setup_repo();
731        let tool = WorktreeTool;
732
733        let main = tool
734            .execute(
735                "c-main",
736                json!({"action": "remove", "worktree_path": dir.path().display().to_string()}),
737                test_ctx(dir.path(), AgentMode::Worker),
738            )
739            .await
740            .unwrap();
741        assert!(main.is_error);
742        assert!(extract_text(&main).contains("main worktree"));
743
744        let current = tool
745            .execute(
746                "c-current",
747                json!({"action": "remove", "worktree_path": "."}),
748                test_ctx(dir.path(), AgentMode::Worker),
749            )
750            .await
751            .unwrap();
752        assert!(current.is_error);
753        assert!(
754            extract_text(&current).contains("main worktree")
755                || extract_text(&current).contains("current working directory")
756        );
757    }
758
759    #[tokio::test]
760    async fn worktree_delete_branch_requires_explicit_branch() {
761        let dir = setup_repo();
762        let tool = WorktreeTool;
763        let worktree_path = dir.path().join("../repo-worktree-no-delete");
764        let add = tool
765            .execute(
766                "c-add",
767                json!({
768                    "action": "add",
769                    "worktree_path": worktree_path.display().to_string(),
770                    "branch": "feature/no-delete",
771                }),
772                test_ctx(dir.path(), AgentMode::Worker),
773            )
774            .await
775            .unwrap();
776        assert!(!add.is_error);
777
778        let remove = tool
779            .execute(
780                "c-remove",
781                json!({
782                    "action": "remove",
783                    "worktree_path": worktree_path.display().to_string(),
784                    "delete_branch": true,
785                }),
786                test_ctx(dir.path(), AgentMode::Worker),
787            )
788            .await
789            .unwrap();
790        assert!(remove.is_error);
791        assert!(extract_text(&remove).contains("requires explicit branch"));
792
793        let cleanup = tool
794            .execute(
795                "c-cleanup",
796                json!({
797                    "action": "remove",
798                    "worktree_path": worktree_path.display().to_string(),
799                }),
800                test_ctx(dir.path(), AgentMode::Worker),
801            )
802            .await
803            .unwrap();
804        assert!(!cleanup.is_error);
805    }
806
807    #[tokio::test]
808    async fn worktree_validates_branch_and_path() {
809        let dir = setup_repo();
810        let tool = WorktreeTool;
811
812        let branch = tool
813            .execute(
814                "c-branch",
815                json!({"action": "add", "worktree_path": "../bad", "branch": "-bad"}),
816                test_ctx(dir.path(), AgentMode::Worker),
817            )
818            .await
819            .unwrap();
820        assert!(branch.is_error);
821
822        let path = tool
823            .execute(
824                "c-path",
825                json!({"action": "add", "worktree_path": "bad\npath", "branch": "feature/bad"}),
826                test_ctx(dir.path(), AgentMode::Worker),
827            )
828            .await
829            .unwrap();
830        assert!(path.is_error);
831    }
832
833    #[tokio::test]
834    async fn planner_mode_blocks_mutating_worktree_actions() {
835        let dir = setup_repo();
836        let tool = WorktreeTool;
837
838        let result = tool
839            .execute(
840                "c-add",
841                json!({"action": "add", "worktree_path": "../blocked", "branch": "feature/blocked"}),
842                test_ctx(dir.path(), AgentMode::Planner),
843            )
844            .await
845            .unwrap();
846
847        assert!(result.is_error);
848        assert!(extract_text(&result).contains("not permitted"));
849    }
850
851    #[test]
852    fn parse_worktree_list_handles_multiple_entries() {
853        let entries = parse_worktree_list(
854            "worktree /repo\nHEAD abc\nbranch refs/heads/main\n\nworktree /repo-wt\nHEAD def\nbranch refs/heads/feature\ndetached\n",
855        );
856        assert_eq!(entries.len(), 2);
857        assert_eq!(entries[0].path, "/repo");
858        assert_eq!(entries[0].branch.as_deref(), Some("main"));
859        assert_eq!(entries[1].branch.as_deref(), Some("feature"));
860        assert!(entries[1].is_detached);
861    }
862}