477 lines
16 KiB
Rust
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"
|
|
);
|
|
}
|
|
}
|