Files
huskies/server/src/worktree/create.rs
T
2026-05-13 14:44:17 +00:00

477 lines
16 KiB
Rust

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