use crate::config::ProjectConfig; use std::path::{Path, PathBuf}; use std::process::Command; /// Write a `.mcp.json` file in the given directory pointing to the MCP server /// at the given port. pub fn write_mcp_json(dir: &Path, port: u16) -> Result<(), String> { let content = format!( "{{\n \"mcpServers\": {{\n \"story-kit\": {{\n \"type\": \"http\",\n \"url\": \"http://localhost:{port}/mcp\"\n }}\n }}\n}}\n" ); std::fs::write(dir.join(".mcp.json"), content).map_err(|e| format!("Write .mcp.json: {e}")) } #[derive(Debug, Clone)] #[allow(dead_code)] pub struct WorktreeInfo { pub path: PathBuf, pub branch: String, pub base_branch: String, } /// Worktree path as a sibling of the project root: `{project_root}-story-{id}`. /// E.g. `/path/to/story-kit-app` → `/path/to/story-kit-app-story-42_foo`. fn worktree_path(project_root: &Path, story_id: &str) -> PathBuf { let parent = project_root.parent().unwrap_or(project_root); let dir_name = project_root .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| "project".to_string()); parent.join(format!("{dir_name}-story-{story_id}")) } fn branch_name(story_id: &str) -> String { format!("feature/story-{story_id}") } /// Detect the current branch of the project root (the base branch worktrees fork from). fn detect_base_branch(project_root: &Path) -> String { Command::new("git") .args(["rev-parse", "--abbrev-ref", "HEAD"]) .current_dir(project_root) .output() .ok() .and_then(|o| { if o.status.success() { Some(String::from_utf8_lossy(&o.stdout).trim().to_string()) } else { None } }) .unwrap_or_else(|| "master".to_string()) } /// Create a git worktree for the given story. /// /// - Creates the worktree at `{project_root}-story-{story_id}` (sibling directory) /// on branch `feature/story-{story_id}`. /// - Writes `.mcp.json` in the worktree pointing to the MCP server at `port`. /// - Runs setup commands from the config for each component. /// - If the worktree/branch already exists, reuses rather than errors. pub async fn create_worktree( project_root: &Path, story_id: &str, config: &ProjectConfig, port: u16, ) -> Result { let wt_path = worktree_path(project_root, story_id); let branch = branch_name(story_id); let base_branch = detect_base_branch(project_root); let root = project_root.to_path_buf(); // Already exists — reuse if wt_path.exists() { write_mcp_json(&wt_path, port)?; run_setup_commands(&wt_path, config).await?; return Ok(WorktreeInfo { path: wt_path, branch, base_branch, }); } let wt = wt_path.clone(); let br = branch.clone(); tokio::task::spawn_blocking(move || create_worktree_sync(&root, &wt, &br)) .await .map_err(|e| format!("spawn_blocking: {e}"))??; write_mcp_json(&wt_path, port)?; run_setup_commands(&wt_path, config).await?; Ok(WorktreeInfo { path: wt_path, branch, base_branch, }) } fn create_worktree_sync( project_root: &Path, wt_path: &Path, branch: &str, ) -> Result<(), String> { // Ensure the parent directory exists if let Some(parent) = wt_path.parent() { std::fs::create_dir_all(parent) .map_err(|e| format!("Create worktree dir: {e}"))?; } // Prune stale worktree references (e.g. directories deleted externally) let _ = Command::new("git") .args(["worktree", "prune"]) .current_dir(project_root) .output(); // Try to create branch. If it already exists that's fine. let _ = Command::new("git") .args(["branch", branch]) .current_dir(project_root) .output(); // Create worktree let output = Command::new("git") .args([ "worktree", "add", &wt_path.to_string_lossy(), branch, ]) .current_dir(project_root) .output() .map_err(|e| format!("git worktree add: {e}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); // If it says already checked out, that's fine if stderr.contains("already checked out") || stderr.contains("already exists") { return Ok(()); } return Err(format!("git worktree add failed: {stderr}")); } Ok(()) } /// Remove a git worktree and its branch. #[allow(dead_code)] pub async fn remove_worktree( project_root: &Path, info: &WorktreeInfo, config: &ProjectConfig, ) -> Result<(), String> { run_teardown_commands(&info.path, config).await?; let root = project_root.to_path_buf(); let wt_path = info.path.clone(); let branch = info.branch.clone(); tokio::task::spawn_blocking(move || remove_worktree_sync(&root, &wt_path, &branch)) .await .map_err(|e| format!("spawn_blocking: {e}"))? } #[allow(dead_code)] fn remove_worktree_sync( project_root: &Path, wt_path: &Path, branch: &str, ) -> Result<(), String> { // Remove worktree let output = Command::new("git") .args([ "worktree", "remove", "--force", &wt_path.to_string_lossy(), ]) .current_dir(project_root) .output() .map_err(|e| format!("git worktree remove: {e}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); eprintln!("[worktree] remove warning: {stderr}"); } // Delete branch (best effort) let _ = Command::new("git") .args(["branch", "-d", branch]) .current_dir(project_root) .output(); Ok(()) } async fn run_setup_commands(wt_path: &Path, config: &ProjectConfig) -> Result<(), String> { for component in &config.component { let cmd_dir = wt_path.join(&component.path); for cmd in &component.setup { run_shell_command(cmd, &cmd_dir).await?; } } Ok(()) } #[allow(dead_code)] async fn run_teardown_commands(wt_path: &Path, config: &ProjectConfig) -> Result<(), String> { for component in &config.component { let cmd_dir = wt_path.join(&component.path); for cmd in &component.teardown { // Best effort — don't fail teardown if let Err(e) = run_shell_command(cmd, &cmd_dir).await { eprintln!("[worktree] teardown warning for {}: {e}", component.name); } } } Ok(()) } async fn run_shell_command(cmd: &str, cwd: &Path) -> Result<(), String> { let cmd = cmd.to_string(); let cwd = cwd.to_path_buf(); tokio::task::spawn_blocking(move || { eprintln!("[worktree] Running: {cmd} in {}", cwd.display()); let output = Command::new("sh") .args(["-c", &cmd]) .current_dir(&cwd) .output() .map_err(|e| format!("Run '{cmd}': {e}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(format!("Command '{cmd}' failed: {stderr}")); } Ok(()) }) .await .map_err(|e| format!("spawn_blocking: {e}"))? } #[cfg(test)] mod tests { use super::*; use std::fs; use tempfile::TempDir; /// Initialise a bare-minimum git repo so worktree operations work. fn init_git_repo(dir: &Path) { Command::new("git") .args(["init"]) .current_dir(dir) .output() .expect("git init"); Command::new("git") .args(["commit", "--allow-empty", "-m", "init"]) .current_dir(dir) .output() .expect("git commit"); } #[test] fn write_mcp_json_uses_given_port() { let tmp = TempDir::new().unwrap(); write_mcp_json(tmp.path(), 4242).unwrap(); let content = std::fs::read_to_string(tmp.path().join(".mcp.json")).unwrap(); assert!(content.contains("http://localhost:4242/mcp")); } #[test] fn write_mcp_json_default_port() { let tmp = TempDir::new().unwrap(); write_mcp_json(tmp.path(), 3001).unwrap(); let content = std::fs::read_to_string(tmp.path().join(".mcp.json")).unwrap(); assert!(content.contains("http://localhost:3001/mcp")); } #[test] fn create_worktree_after_stale_reference() { let tmp = TempDir::new().unwrap(); let project_root = tmp.path().join("my-project"); fs::create_dir_all(&project_root).unwrap(); init_git_repo(&project_root); let wt_path = tmp.path().join("my-worktree"); let branch = "feature/test-stale"; // First creation should succeed create_worktree_sync(&project_root, &wt_path, branch).unwrap(); assert!(wt_path.exists()); // Simulate external deletion (e.g., rm -rf by another agent) fs::remove_dir_all(&wt_path).unwrap(); assert!(!wt_path.exists()); // Second creation should succeed despite stale git reference. // Without `git worktree prune`, this fails with "already checked out" // or "already exists". let result = create_worktree_sync(&project_root, &wt_path, branch); assert!( result.is_ok(), "Expected worktree creation to succeed after stale reference, got: {:?}", result.err() ); assert!(wt_path.exists()); } }