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)] pub struct WorktreeInfo { pub path: PathBuf, pub branch: String, pub base_branch: String, } #[derive(Debug, Clone)] pub struct WorktreeListEntry { pub story_id: String, pub path: PathBuf, } /// Worktree path inside the project: `{project_root}/.story_kit/worktrees/{story_id}`. pub fn worktree_path(project_root: &Path, story_id: &str) -> PathBuf { project_root .join(".story_kit") .join("worktrees") .join(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_kit/worktrees/{story_id}` /// 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 (ensure sparse checkout is configured) if wt_path.exists() { let wt_clone = wt_path.clone(); tokio::task::spawn_blocking(move || configure_sparse_checkout(&wt_clone)) .await .map_err(|e| format!("spawn_blocking: {e}"))??; 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}")); } // Enable sparse checkout to exclude pipeline files from the worktree. // This prevents .story_kit/work/ changes from ending up in feature branches, // which cause rename/delete merge conflicts when merging back to master. configure_sparse_checkout(wt_path)?; Ok(()) } /// Configure sparse checkout on a worktree to exclude `.story_kit/work/`. /// /// This prevents pipeline file moves (upcoming → current → qa → merge → archived) /// from being committed on feature branches, which avoids rename/delete merge /// conflicts when merging back to master. fn configure_sparse_checkout(wt_path: &Path) -> Result<(), String> { // Enable worktree-specific config so sparse checkout settings don't leak // to the main checkout or other worktrees. let _ = Command::new("git") .args(["config", "extensions.worktreeConfig", "true"]) .current_dir(wt_path) .output(); // Enable sparse checkout for THIS worktree only (--worktree flag). // Without --worktree, this writes to the shared .git/config and // enables sparse checkout on the main checkout too. let output = Command::new("git") .args(["config", "--worktree", "core.sparseCheckout", "true"]) .current_dir(wt_path) .output() .map_err(|e| format!("sparse-checkout config: {e}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(format!("sparse-checkout config failed: {stderr}")); } // Resolve the actual git dir (worktrees use a .git file pointing elsewhere) let git_dir_output = Command::new("git") .args(["rev-parse", "--git-dir"]) .current_dir(wt_path) .output() .map_err(|e| format!("git rev-parse --git-dir: {e}"))?; let git_dir = PathBuf::from( String::from_utf8_lossy(&git_dir_output.stdout).trim().to_string(), ); // Write sparse-checkout patterns: include everything, exclude .story_kit/work/ let info_dir = git_dir.join("info"); std::fs::create_dir_all(&info_dir) .map_err(|e| format!("Create sparse-checkout dir: {e}"))?; std::fs::write(info_dir.join("sparse-checkout"), "/*\n!.story_kit/work/\n") .map_err(|e| format!("Write sparse-checkout: {e}"))?; // Re-read the working tree to apply sparse checkout rules let output = Command::new("git") .args(["read-tree", "-mu", "HEAD"]) .current_dir(wt_path) .output() .map_err(|e| format!("git read-tree: {e}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(format!("git read-tree failed: {stderr}")); } Ok(()) } /// Remove a git worktree and its branch. 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}"))? } /// Remove a git worktree by story ID, deriving the path and branch deterministically. pub async fn remove_worktree_by_story_id( project_root: &Path, story_id: &str, config: &ProjectConfig, ) -> Result<(), String> { let path = worktree_path(project_root, story_id); if !path.exists() { return Err(format!("Worktree not found for story: {story_id}")); } let branch = branch_name(story_id); let base_branch = detect_base_branch(project_root); let info = WorktreeInfo { path, branch, base_branch, }; remove_worktree(project_root, &info, config).await } /// List all worktrees under `{project_root}/.story_kit/worktrees/`. pub fn list_worktrees(project_root: &Path) -> Result, String> { let worktrees_dir = project_root.join(".story_kit").join("worktrees"); if !worktrees_dir.exists() { return Ok(Vec::new()); } let mut entries = Vec::new(); for entry in std::fs::read_dir(&worktrees_dir).map_err(|e| format!("list worktrees: {e}"))? { let entry = entry.map_err(|e| format!("list worktrees entry: {e}"))?; let path = entry.path(); if path.is_dir() { let story_id = path .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_default(); entries.push(WorktreeListEntry { story_id, path }); } } entries.sort_by(|a, b| a.story_id.cmp(&b.story_id)); Ok(entries) } 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(()) } 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 worktree_path_is_inside_project() { let project_root = Path::new("/home/user/my-project"); let path = worktree_path(project_root, "42_my_story"); assert_eq!( path, Path::new("/home/user/my-project/.story_kit/worktrees/42_my_story") ); } #[test] fn list_worktrees_empty_when_no_dir() { let tmp = TempDir::new().unwrap(); let entries = list_worktrees(tmp.path()).unwrap(); assert!(entries.is_empty()); } #[test] fn list_worktrees_returns_subdirs() { let tmp = TempDir::new().unwrap(); let worktrees_dir = tmp.path().join(".story_kit").join("worktrees"); fs::create_dir_all(worktrees_dir.join("42_story_a")).unwrap(); fs::create_dir_all(worktrees_dir.join("43_story_b")).unwrap(); // A file (not dir) — should be ignored fs::write(worktrees_dir.join("readme.txt"), "").unwrap(); let entries = list_worktrees(tmp.path()).unwrap(); assert_eq!(entries.len(), 2); assert_eq!(entries[0].story_id, "42_story_a"); assert_eq!(entries[1].story_id, "43_story_b"); } #[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()); } #[test] fn sparse_checkout_excludes_story_kit_work() { 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); // Create a tracked file under .story_kit/work/ on the initial branch let work_dir = project_root.join(".story_kit").join("work"); fs::create_dir_all(&work_dir).unwrap(); fs::write(work_dir.join("test_story.md"), "# Test").unwrap(); Command::new("git") .args(["add", "."]) .current_dir(&project_root) .output() .unwrap(); Command::new("git") .args(["commit", "-m", "add work file"]) .current_dir(&project_root) .output() .unwrap(); let wt_path = tmp.path().join("my-worktree"); let branch = "feature/test-sparse"; create_worktree_sync(&project_root, &wt_path, branch).unwrap(); // .story_kit/work/ should not exist in the worktree assert!( !wt_path.join(".story_kit").join("work").exists(), ".story_kit/work/ should be excluded by sparse checkout" ); // Other files should still exist assert!(wt_path.join(".git").exists()); // Main checkout must NOT be affected by the worktree's sparse checkout. // The .story_kit/work/ directory must still exist in the project root. assert!( work_dir.exists(), ".story_kit/work/ must still exist in the main checkout" ); } }