//! 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(()) } /// Install a pre-commit git hook in an agent worktree. /// /// Creates `{wt_path}/.git-hooks/pre-commit` containing a shell script that /// runs `script/check` and aborts the commit if it fails. Configures the /// worktree's `core.hooksPath` (via `git config --worktree`) so only this /// worktree uses the per-worktree hooks directory. pub fn install_pre_commit_hook(wt_path: &Path) -> Result<(), String> { #[cfg(unix)] use std::os::unix::fs::PermissionsExt; let hooks_dir = wt_path.join(".git-hooks"); std::fs::create_dir_all(&hooks_dir).map_err(|e| format!("create .git-hooks dir: {e}"))?; let hook = "#!/bin/sh\n\ #\n\ # Pre-commit hook installed by huskies.\n\ # Runs script/check (fmt-check, clippy, cargo check, source-map-check)\n\ # before every commit. Aborts if any gate fails.\n\ #\n\ # Emergency bypass: git commit --no-verify (see AGENT.md — avoid this)\n\ \n\ REPO_ROOT=\"$(git rev-parse --show-toplevel)\"\n\ \n\ printf '[pre-commit] Running script/check ...\\n'\n\ OUTPUT=$(\"$REPO_ROOT/script/check\" 2>&1)\n\ STATUS=$?\n\ \n\ if [ \"$STATUS\" -ne 0 ]; then\n\ printf '\\n=== PRE-COMMIT HOOK FAILED ===\\n\\n'\n\ printf '%s\\n' \"$OUTPUT\"\n\ printf '\\nFix the issues above, then re-validate with:\\n'\n\ printf ' script/check\\n'\n\ printf '\\nEmergency bypass (see AGENT.md -- avoid this):\\n'\n\ printf ' git commit --no-verify\\n\\n'\n\ exit 1\n\ fi\n"; let hook_path = hooks_dir.join("pre-commit"); std::fs::write(&hook_path, hook).map_err(|e| format!("write pre-commit hook: {e}"))?; #[cfg(unix)] std::fs::set_permissions(&hook_path, std::fs::Permissions::from_mode(0o755)) .map_err(|e| format!("chmod pre-commit hook: {e}"))?; // Point git at the per-worktree hooks dir so only this worktree uses // these hooks (not the main repo or other worktrees). // Requires extensions.worktreeConfig = true in the repository config. let output = std::process::Command::new("git") .args(["config", "--worktree", "core.hooksPath", ".git-hooks"]) .current_dir(wt_path) .output() .map_err(|e| format!("git config --worktree core.hooksPath: {e}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(format!( "git config --worktree core.hooksPath failed: {stderr}" )); } 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() ); } #[test] fn install_pre_commit_hook_creates_executable_hook_and_sets_hookspath() { let tmp = TempDir::new().unwrap(); let project_root = tmp.path().join("main-repo"); fs::create_dir_all(&project_root).unwrap(); init_git_repo(&project_root); // Enable per-worktree config so git config --worktree works. Command::new("git") .args(["config", "extensions.worktreeConfig", "true"]) .current_dir(&project_root) .output() .expect("enable extensions.worktreeConfig"); // Create a linked worktree to simulate what huskies does for agents. let wt_path = tmp.path().join("linked-wt"); let out = Command::new("git") .args([ "worktree", "add", wt_path.to_str().unwrap(), "-b", "feature/hook-test", ]) .current_dir(&project_root) .output() .expect("git worktree add"); assert!( out.status.success(), "git worktree add failed: {}", String::from_utf8_lossy(&out.stderr) ); install_pre_commit_hook(&wt_path).expect("install_pre_commit_hook must succeed"); // Hook file must exist. let hook_path = wt_path.join(".git-hooks").join("pre-commit"); assert!(hook_path.exists(), "pre-commit hook must be created"); // Hook must be executable. #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mode = fs::metadata(&hook_path).unwrap().permissions().mode(); assert!(mode & 0o111 != 0, "pre-commit hook must be executable"); } // Hook content must reference script/check and --no-verify. let content = fs::read_to_string(&hook_path).unwrap(); assert!( content.contains("script/check"), "hook must invoke script/check; got:\n{content}" ); assert!( content.contains("--no-verify"), "hook must mention --no-verify bypass; got:\n{content}" ); // git config core.hooksPath for the worktree must be .git-hooks. let cfg_out = Command::new("git") .args(["config", "--worktree", "core.hooksPath"]) .current_dir(&wt_path) .output() .expect("git config --worktree core.hooksPath"); assert!( cfg_out.status.success(), "git config --worktree core.hooksPath lookup failed" ); let value = String::from_utf8_lossy(&cfg_out.stdout).trim().to_string(); assert_eq!( value, ".git-hooks", "core.hooksPath must be set to .git-hooks" ); } }