use crate::config::ProjectConfig; use std::path::{Path, PathBuf}; use std::process::Command; #[derive(Debug, Clone)] #[allow(dead_code)] pub struct WorktreeInfo { pub path: PathBuf, pub 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}") } /// 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}`. /// - 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, ) -> Result { let wt_path = worktree_path(project_root, story_id); let branch = branch_name(story_id); let root = project_root.to_path_buf(); // Already exists — reuse if wt_path.exists() { run_setup_commands(&wt_path, config).await?; return Ok(WorktreeInfo { path: wt_path, 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}"))??; run_setup_commands(&wt_path, config).await?; Ok(WorktreeInfo { path: wt_path, 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}"))?; } // 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}"))? }