//! Async worktree creation and component setup/teardown commands. use crate::config::ProjectConfig; use crate::slog; use std::path::Path; use std::process::Command; use super::git::{ branch_name, configure_sparse_checkout, create_worktree_sync, detect_base_branch, }; use super::{WorktreeInfo, worktree_path, write_mcp_json}; /// Create a git worktree for the given story. /// /// - Creates the worktree at `{project_root}/.huskies/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 = config .base_branch .clone() .unwrap_or_else(|| 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, }) } pub(crate) async fn run_setup_commands(wt_path: &Path, config: &ProjectConfig) { for component in &config.component { let cmd_dir = wt_path.join(&component.path); for cmd in &component.setup { if let Err(e) = run_shell_command(cmd, &cmd_dir).await { slog!("[worktree] setup warning for {}: {e}", component.name); } } } } pub(crate) 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 { slog!("[worktree] teardown warning for {}: {e}", component.name); } } } Ok(()) } pub(crate) 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 || { slog!("[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 crate::config::{ComponentConfig, WatcherConfig}; use std::fs; use std::process::Command; use tempfile::TempDir; 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"); } fn empty_config() -> ProjectConfig { ProjectConfig { component: vec![], agent: vec![], watcher: WatcherConfig::default(), default_qa: "server".to_string(), default_coder_model: None, max_coders: None, max_retries: 2, base_branch: None, rate_limit_notifications: true, web_ui_status_consumer: true, matrix_status_consumer: true, slack_status_consumer: true, discord_status_consumer: true, whatsapp_status_consumer: true, timezone: None, rendezvous: None, trusted_keys: Vec::new(), crdt_require_token: false, crdt_tokens: Vec::new(), max_mesh_peers: 3, gateway_url: None, gateway_project: None, status_push_enabled: true, } } fn failing_setup_config() -> ProjectConfig { ProjectConfig { component: vec![ComponentConfig { name: "broken-build".to_string(), path: ".".to_string(), setup: vec!["exit 1".to_string()], teardown: vec![], }], ..empty_config() } } #[tokio::test] async fn run_shell_command_succeeds_for_echo() { let tmp = TempDir::new().unwrap(); let result = run_shell_command("echo hello", tmp.path()).await; assert!(result.is_ok(), "Expected success: {:?}", result.err()); } #[tokio::test] async fn run_shell_command_fails_for_nonzero_exit() { let tmp = TempDir::new().unwrap(); let result = run_shell_command("exit 1", tmp.path()).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("failed")); } #[tokio::test] async fn run_setup_commands_no_components_succeeds() { let tmp = TempDir::new().unwrap(); // Should complete without panic run_setup_commands(tmp.path(), &empty_config()).await; } #[tokio::test] async fn run_setup_commands_runs_each_command_successfully() { let tmp = TempDir::new().unwrap(); let config = ProjectConfig { component: vec![ComponentConfig { name: "test".to_string(), path: ".".to_string(), setup: vec!["echo setup_ok".to_string()], teardown: vec![], }], ..empty_config() }; // Should complete without panic run_setup_commands(tmp.path(), &config).await; } #[tokio::test] async fn run_setup_commands_ignores_failures() { let tmp = TempDir::new().unwrap(); let config = ProjectConfig { component: vec![ComponentConfig { name: "test".to_string(), path: ".".to_string(), setup: vec!["exit 1".to_string()], teardown: vec![], }], ..empty_config() }; // Setup command failures are non-fatal — should not panic or propagate run_setup_commands(tmp.path(), &config).await; } #[tokio::test] async fn run_teardown_commands_ignores_failures() { let tmp = TempDir::new().unwrap(); let config = ProjectConfig { component: vec![ComponentConfig { name: "test".to_string(), path: ".".to_string(), setup: vec![], teardown: vec!["exit 1".to_string()], }], ..empty_config() }; // Teardown failures are best-effort — should not propagate assert!(run_teardown_commands(tmp.path(), &config).await.is_ok()); } #[tokio::test] async fn create_worktree_fresh_creates_dir_and_mcp_json() { 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 info = create_worktree(&project_root, "42_fresh_test", &empty_config(), 3001) .await .unwrap(); assert!(info.path.exists()); assert!(info.path.join(".mcp.json").exists()); let mcp = fs::read_to_string(info.path.join(".mcp.json")).unwrap(); assert!(mcp.contains("3001")); assert_eq!(info.branch, "feature/story-42_fresh_test"); } #[tokio::test] async fn create_worktree_reuses_existing_path_and_updates_port() { 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); // First creation let _info1 = create_worktree(&project_root, "43_reuse_test", &empty_config(), 3001) .await .unwrap(); // Second call — worktree already exists, reuse path, update port let info2 = create_worktree(&project_root, "43_reuse_test", &empty_config(), 3002) .await .unwrap(); let mcp = fs::read_to_string(info2.path.join(".mcp.json")).unwrap(); assert!( mcp.contains("3002"), "MCP json should be updated to new port" ); } #[tokio::test] async fn create_worktree_succeeds_despite_setup_failure() { 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); // Even though setup commands fail, create_worktree must succeed // so the agent can start and fix the problem itself. let result = create_worktree( &project_root, "172_setup_fail", &failing_setup_config(), 3001, ) .await; assert!( result.is_ok(), "create_worktree must succeed even if setup commands fail: {:?}", result.err() ); let info = result.unwrap(); assert!(info.path.exists()); assert!(info.path.join(".mcp.json").exists()); } #[tokio::test] async fn create_worktree_reuse_succeeds_despite_setup_failure() { 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); // First creation — no setup commands, should succeed create_worktree(&project_root, "173_reuse_fail", &empty_config(), 3001) .await .unwrap(); // Second call — worktree exists, setup commands fail, must still succeed let result = create_worktree( &project_root, "173_reuse_fail", &failing_setup_config(), 3002, ) .await; assert!( result.is_ok(), "create_worktree reuse must succeed even if setup commands fail: {:?}", result.err() ); } }