Files
huskies/server/src/worktree/create.rs
T

339 lines
11 KiB
Rust
Raw Normal View History

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()
);
}
}