//! MCP worktree tools — create, list, remove, get editor command, and read worktree commits. use serde_json::{Value, json}; use crate::config::ProjectConfig; use crate::http::context::AppContext; use crate::service::settings::get_editor_command; use crate::worktree; pub(crate) async fn tool_create_worktree(args: &Value, ctx: &AppContext) -> Result { let story_id = args .get("story_id") .and_then(|v| v.as_str()) .ok_or("Missing required argument: story_id")?; let project_root = ctx.services.agents.get_project_root(&ctx.state)?; let info = ctx .services .agents .create_worktree(&project_root, story_id) .await?; serde_json::to_string_pretty(&json!({ "story_id": story_id, "worktree_path": info.path.to_string_lossy(), "branch": info.branch, "base_branch": info.base_branch, })) .map_err(|e| format!("Serialization error: {e}")) } pub(crate) fn tool_list_worktrees(ctx: &AppContext) -> Result { let project_root = ctx.services.agents.get_project_root(&ctx.state)?; let entries = worktree::list_worktrees(&project_root)?; serde_json::to_string_pretty(&json!( entries .iter() .map(|e| json!({ "story_id": e.story_id, "path": e.path.to_string_lossy(), })) .collect::>() )) .map_err(|e| format!("Serialization error: {e}")) } pub(crate) async fn tool_remove_worktree(args: &Value, ctx: &AppContext) -> Result { let story_id = args .get("story_id") .and_then(|v| v.as_str()) .ok_or("Missing required argument: story_id")?; let project_root = ctx.services.agents.get_project_root(&ctx.state)?; let config = ProjectConfig::load(&project_root)?; worktree::remove_worktree_by_story_id(&project_root, story_id, &config).await?; Ok(format!("Worktree for story '{story_id}' removed.")) } /// MCP tool handler for `cleanup_worktrees` — removes stale worktrees whose stories are done or archived. pub(crate) async fn tool_cleanup_worktrees( args: &Value, ctx: &AppContext, ) -> Result { let confirm = args .get("confirm") .and_then(|v| v.as_bool()) .unwrap_or(false); let project_root = ctx.services.agents.get_project_root(&ctx.state)?; let config = crate::config::ProjectConfig::load(&project_root)?; let report = worktree::run_cleanup(&project_root, &config, confirm).await; Ok(worktree::format_report(&report, confirm)) } pub(crate) fn tool_get_editor_command(args: &Value, ctx: &AppContext) -> Result { let worktree_path = args .get("worktree_path") .and_then(|v| v.as_str()) .ok_or("Missing required argument: worktree_path")?; let editor = get_editor_command(&*ctx.store) .ok_or_else(|| "No editor configured. Set one via PUT /api/settings/editor.".to_string())?; Ok(format!("{editor} {worktree_path}")) } /// Run `git log ..HEAD --oneline` in the worktree and return the commit pub(crate) async fn get_worktree_commits( worktree_path: &str, base_branch: &str, ) -> Option> { let wt = worktree_path.to_string(); let base = base_branch.to_string(); tokio::task::spawn_blocking(move || { let output = std::process::Command::new("git") .args(["log", &format!("{base}..HEAD"), "--oneline"]) .current_dir(&wt) .output() .ok()?; if output.status.success() { let lines: Vec = String::from_utf8(output.stdout) .ok()? .lines() .filter(|l| !l.is_empty()) .map(|l| l.to_string()) .collect(); Some(lines) } else { None } }) .await .ok() .flatten() } #[cfg(test)] mod tests { use super::*; use crate::http::test_helpers::test_ctx; use crate::store::StoreOps; #[tokio::test] async fn tool_create_worktree_missing_story_id() { let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); let result = tool_create_worktree(&json!({}), &ctx).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("story_id")); } #[tokio::test] async fn tool_remove_worktree_missing_story_id() { let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); let result = tool_remove_worktree(&json!({}), &ctx).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("story_id")); } #[test] fn tool_list_worktrees_empty_dir() { let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); let result = tool_list_worktrees(&ctx).unwrap(); let parsed: Vec = serde_json::from_str(&result).unwrap(); assert!(parsed.is_empty()); } // ── Editor command tool tests ───────────────────────────────── #[test] fn tool_get_editor_command_missing_worktree_path() { let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); let result = tool_get_editor_command(&json!({}), &ctx); assert!(result.is_err()); assert!(result.unwrap_err().contains("worktree_path")); } #[test] fn tool_get_editor_command_no_editor_configured() { let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); let result = tool_get_editor_command(&json!({"worktree_path": "/some/path"}), &ctx); assert!(result.is_err()); assert!(result.unwrap_err().contains("No editor configured")); } #[test] fn tool_get_editor_command_formats_correctly() { let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); ctx.store.set("editor_command", json!("zed")); let result = tool_get_editor_command( &json!({"worktree_path": "/home/user/worktrees/37_my_story"}), &ctx, ) .unwrap(); assert_eq!(result, "zed /home/user/worktrees/37_my_story"); } #[test] fn tool_get_editor_command_works_with_vscode() { let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); ctx.store.set("editor_command", json!("code")); let result = tool_get_editor_command(&json!({"worktree_path": "/path/to/worktree"}), &ctx).unwrap(); assert_eq!(result, "code /path/to/worktree"); } #[test] fn get_editor_command_in_tools_list() { use super::super::super::tools_list::handle_tools_list; let resp = handle_tools_list(Some(json!(1))); let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone(); let tool = tools.iter().find(|t| t["name"] == "get_editor_command"); assert!(tool.is_some(), "get_editor_command missing from tools list"); let t = tool.unwrap(); assert!(t["description"].is_string()); let required = t["inputSchema"]["required"].as_array().unwrap(); let req_names: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect(); assert!(req_names.contains(&"worktree_path")); } }