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, ¶ms).await,
123 "remove" => remove_action(&cwd, &repo_root, ¶ms).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 ¤t_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(¤t).contains("main worktree")
755 || extract_text(¤t).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}