2026-04-28 14:01:24 +00:00
|
|
|
//! 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<WorktreeInfo, String> {
|
|
|
|
|
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,
|
2026-04-29 21:28:41 +00:00
|
|
|
status_push_enabled: true,
|
2026-04-28 14:01:24 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|