huskies: merge 784

This commit is contained in:
dave
2026-04-28 14:01:24 +00:00
parent cf470f5048
commit 3772c0d03c
5 changed files with 1060 additions and 1170 deletions
File diff suppressed because it is too large Load Diff
+337
View File
@@ -0,0 +1,337 @@
//! 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,
}
}
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()
);
}
}
+434
View File
@@ -0,0 +1,434 @@
//! Low-level synchronous git operations for worktree management.
use crate::slog;
use std::path::Path;
use std::process::Command;
pub(crate) fn branch_name(story_id: &str) -> String {
format!("feature/story-{story_id}")
}
/// Detect the current branch of the project root (the base branch worktrees fork from).
pub(crate) fn detect_base_branch(project_root: &Path) -> String {
Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(project_root)
.output()
.ok()
.and_then(|o| {
if o.status.success() {
Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
} else {
None
}
})
.unwrap_or_else(|| "master".to_string())
}
/// Placeholder for worktree isolation of `.huskies/work/`.
///
/// Previous approaches (sparse checkout, skip-worktree) all leaked state
/// from worktrees back to the main checkout's config/index. For now this
/// is a no-op — merge conflicts from pipeline file moves are handled at
/// merge time by the mergemaster (squash merge ignores work/ diffs).
pub(crate) fn configure_sparse_checkout(_wt_path: &Path) -> Result<(), String> {
Ok(())
}
/// Create a git worktree at `wt_path` on `branch`, pruning stale references first.
pub(crate) fn create_worktree_sync(
project_root: &Path,
wt_path: &Path,
branch: &str,
) -> Result<(), String> {
// Ensure the parent directory exists
if let Some(parent) = wt_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("Create worktree dir: {e}"))?;
}
// Prune stale worktree references (e.g. directories deleted externally)
let _ = Command::new("git")
.args(["worktree", "prune"])
.current_dir(project_root)
.output();
// Try to create branch. If it already exists that's fine.
let _ = Command::new("git")
.args(["branch", branch])
.current_dir(project_root)
.output();
// Create worktree
let output = Command::new("git")
.args(["worktree", "add", &wt_path.to_string_lossy(), branch])
.current_dir(project_root)
.output()
.map_err(|e| format!("git worktree add: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
// If it says already checked out, that's fine
if stderr.contains("already checked out") || stderr.contains("already exists") {
return Ok(());
}
return Err(format!("git worktree add failed: {stderr}"));
}
// Enable sparse checkout to exclude pipeline files from the worktree.
// This prevents .huskies/work/ changes from ending up in feature branches,
// which cause rename/delete merge conflicts when merging back to master.
configure_sparse_checkout(wt_path)?;
Ok(())
}
/// Remove a git worktree directory and delete its branch.
pub(crate) fn remove_worktree_sync(
project_root: &Path,
wt_path: &Path,
branch: &str,
) -> Result<(), String> {
// Remove worktree
let output = Command::new("git")
.args(["worktree", "remove", "--force", &wt_path.to_string_lossy()])
.current_dir(project_root)
.output()
.map_err(|e| format!("git worktree remove: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("not a working tree") {
// Orphaned directory: git doesn't recognise it as a worktree.
// Remove the directory directly and prune stale git metadata.
slog!(
"[worktree] orphaned worktree detected, removing directory: {}",
wt_path.display()
);
if let Err(e) = std::fs::remove_dir_all(wt_path) {
slog!("[worktree] failed to remove orphaned directory: {e}");
}
let _ = Command::new("git")
.args(["worktree", "prune"])
.current_dir(project_root)
.output();
} else {
slog!("[worktree] remove warning: {stderr}");
}
}
// Delete branch (best effort)
let _ = Command::new("git")
.args(["branch", "-d", branch])
.current_dir(project_root)
.output();
Ok(())
}
/// Remove the git worktree for a story if it exists, deriving the path and
/// branch name deterministically from `project_root` and `story_id`.
///
/// Returns `Ok(())` if the worktree was removed or did not exist.
/// Removal is best-effort: `remove_worktree_sync` logs failures internally
/// but always returns `Ok`.
pub fn prune_worktree_sync(project_root: &Path, story_id: &str) -> Result<(), String> {
let wt_path = super::worktree_path(project_root, story_id);
if !wt_path.exists() {
return Ok(());
}
let branch = branch_name(story_id);
remove_worktree_sync(project_root, &wt_path, &branch)
}
/// Migrate filesystem artifacts for story IDs that were rewritten from slug form
/// (`664_story_my_feature`) to numeric-only form (`664`).
///
/// For each `(old_id, new_id)` pair (as returned by
/// `crdt_state::migrate_story_ids_to_numeric`), this function:
/// 1. Moves the git worktree directory via `git worktree move` when it exists.
/// 2. Renames the git branch from `feature/story-{old_id}` to `feature/story-{new_id}`.
/// 3. Renames the log directory at `.huskies/logs/{old_id}` when it exists.
///
/// All steps are best-effort: failures are logged but do not abort the migration.
/// Operations are skipped when the destination path already exists (idempotent).
pub fn migrate_slug_paths(project_root: &Path, migrations: &[(String, String)]) {
for (old_id, new_id) in migrations {
// ── Worktree directory ──────────────────────────────────────────────
let old_wt = super::worktree_path(project_root, old_id);
let new_wt = super::worktree_path(project_root, new_id);
if old_wt.exists() && !new_wt.exists() {
let out = Command::new("git")
.args([
"worktree",
"move",
&old_wt.to_string_lossy(),
&new_wt.to_string_lossy(),
])
.current_dir(project_root)
.output();
match out {
Ok(o) if o.status.success() => {
slog!("[migrate] Moved worktree {old_id} → {new_id}");
}
Ok(o) => {
let stderr = String::from_utf8_lossy(&o.stderr);
slog!(
"[migrate] git worktree move failed for {old_id}: {stderr}; \
falling back to directory rename"
);
if let Err(e) = std::fs::rename(&old_wt, &new_wt) {
slog!("[migrate] Directory rename for worktree {old_id} failed: {e}");
} else {
slog!("[migrate] Renamed worktree directory {old_id} → {new_id}");
}
}
Err(e) => {
slog!("[migrate] git worktree move error for {old_id}: {e}");
}
}
}
// ── Git branch ──────────────────────────────────────────────────────
let old_branch = branch_name(old_id);
let new_branch = branch_name(new_id);
let out = Command::new("git")
.args(["branch", "-m", &old_branch, &new_branch])
.current_dir(project_root)
.output();
match out {
Ok(o) if o.status.success() => {
slog!("[migrate] Renamed branch {old_branch} → {new_branch}");
}
Ok(o) => {
let stderr = String::from_utf8_lossy(&o.stderr);
// Branch may not exist (story already merged/archived) — log at debug level.
slog!("[migrate] Branch rename skipped for {old_id}: {stderr}");
}
Err(e) => {
slog!("[migrate] Branch rename error for {old_id}: {e}");
}
}
// ── Log directory ───────────────────────────────────────────────────
let old_log = project_root.join(".huskies").join("logs").join(old_id);
let new_log = project_root.join(".huskies").join("logs").join(new_id);
if old_log.exists() && !new_log.exists() {
if let Err(e) = std::fs::rename(&old_log, &new_log) {
slog!("[migrate] Log directory rename for {old_id} failed: {e}");
} else {
slog!("[migrate] Moved log directory {old_id} → {new_id}");
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
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");
}
#[test]
fn branch_name_format() {
assert_eq!(branch_name("42_my_story"), "feature/story-42_my_story");
assert_eq!(branch_name("1_test"), "feature/story-1_test");
}
#[test]
fn numeric_id_branch_name_uses_number_only() {
assert_eq!(branch_name("664"), "feature/story-664");
assert_eq!(branch_name("730"), "feature/story-730");
}
#[test]
fn numeric_id_worktree_path_uses_number_only() {
let project_root = Path::new("/home/user/my-project");
let path = super::super::worktree_path(project_root, "664");
assert_eq!(
path,
Path::new("/home/user/my-project/.huskies/worktrees/664")
);
}
#[test]
fn detect_base_branch_returns_branch_in_git_repo() {
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 branch = detect_base_branch(&project_root);
assert!(!branch.is_empty());
}
#[test]
fn detect_base_branch_falls_back_to_master_for_non_git_dir() {
let tmp = TempDir::new().unwrap();
let branch = detect_base_branch(tmp.path());
assert_eq!(branch, "master");
}
#[test]
fn configure_sparse_checkout_is_noop() {
let tmp = TempDir::new().unwrap();
assert!(configure_sparse_checkout(tmp.path()).is_ok());
}
#[test]
fn create_worktree_after_stale_reference() {
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 wt_path = tmp.path().join("my-worktree");
let branch = "feature/test-stale";
// First creation should succeed
create_worktree_sync(&project_root, &wt_path, branch).unwrap();
assert!(wt_path.exists());
// Simulate external deletion (e.g., rm -rf by another agent)
fs::remove_dir_all(&wt_path).unwrap();
assert!(!wt_path.exists());
// Second creation should succeed despite stale git reference.
// Without `git worktree prune`, this fails with "already checked out"
// or "already exists".
let result = create_worktree_sync(&project_root, &wt_path, branch);
assert!(
result.is_ok(),
"Expected worktree creation to succeed after stale reference, got: {:?}",
result.err()
);
assert!(wt_path.exists());
}
#[test]
fn worktree_has_all_files_including_work() {
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);
// Create a tracked file under .huskies/work/ on the initial branch
let work_dir = project_root.join(".huskies").join("work");
fs::create_dir_all(&work_dir).unwrap();
fs::write(work_dir.join("test_story.md"), "# Test").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(&project_root)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "add work file"])
.current_dir(&project_root)
.output()
.unwrap();
let wt_path = tmp.path().join("my-worktree");
let branch = "feature/test-sparse";
create_worktree_sync(&project_root, &wt_path, branch).unwrap();
// Worktree should have all files including .huskies/work/
assert!(wt_path.join(".huskies").join("work").exists());
assert!(wt_path.join(".git").exists());
// Main checkout must NOT be affected by worktree creation.
assert!(
work_dir.exists(),
".huskies/work/ must still exist in the main checkout"
);
}
#[test]
fn remove_worktree_sync_removes_orphaned_directory() {
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);
// Create a directory that looks like a worktree but isn't registered with git
let wt_path = project_root
.join(".huskies")
.join("worktrees")
.join("orphan");
fs::create_dir_all(&wt_path).unwrap();
fs::write(wt_path.join("some_file.txt"), "stale").unwrap();
assert!(wt_path.exists());
// git worktree remove will fail with "not a working tree",
// but the fallback should rm -rf the directory
remove_worktree_sync(&project_root, &wt_path, "feature/orphan").unwrap();
assert!(!wt_path.exists());
}
#[test]
fn remove_worktree_sync_cleans_up_directory() {
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 wt_path = project_root
.join(".huskies")
.join("worktrees")
.join("test_rm");
create_worktree_sync(&project_root, &wt_path, "feature/test-rm").unwrap();
assert!(wt_path.exists());
remove_worktree_sync(&project_root, &wt_path, "feature/test-rm").unwrap();
assert!(!wt_path.exists());
}
#[test]
fn prune_worktree_sync_noop_when_no_worktree_dir() {
let tmp = TempDir::new().unwrap();
// No worktree directory exists — must return Ok without touching git.
let result = prune_worktree_sync(tmp.path(), "42_story_nonexistent");
assert!(
result.is_ok(),
"Expected Ok when worktree dir absent: {:?}",
result.err()
);
}
#[test]
fn prune_worktree_sync_removes_real_worktree() {
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 story_id = "55_story_prune_test";
let wt_path = super::super::worktree_path(&project_root, story_id);
create_worktree_sync(
&project_root,
&wt_path,
&format!("feature/story-{story_id}"),
)
.unwrap();
assert!(wt_path.exists(), "worktree dir should exist before prune");
let result = prune_worktree_sync(&project_root, story_id);
assert!(
result.is_ok(),
"prune_worktree_sync must return Ok: {:?}",
result.err()
);
assert!(!wt_path.exists(), "worktree dir should be gone after prune");
}
}
+130
View File
@@ -0,0 +1,130 @@
//! Git worktree management — creates, lists, and removes worktrees for agent isolation.
use std::path::{Path, PathBuf};
mod create;
mod git;
mod remove;
pub use create::create_worktree;
pub use git::{migrate_slug_paths, prune_worktree_sync};
pub use remove::remove_worktree_by_story_id;
#[derive(Debug, Clone)]
pub struct WorktreeInfo {
pub path: PathBuf,
pub branch: String,
pub base_branch: String,
}
#[derive(Debug, Clone)]
pub struct WorktreeListEntry {
pub story_id: String,
pub path: PathBuf,
}
/// Worktree path inside the project: `{project_root}/.huskies/worktrees/{story_id}`.
pub fn worktree_path(project_root: &Path, story_id: &str) -> PathBuf {
project_root
.join(".huskies")
.join("worktrees")
.join(story_id)
}
/// Write a `.mcp.json` file in the given directory pointing to the MCP server
/// at the given port.
pub fn write_mcp_json(dir: &Path, port: u16) -> Result<(), String> {
let content = format!(
"{{\n \"mcpServers\": {{\n \"huskies\": {{\n \"type\": \"http\",\n \"url\": \"http://localhost:{port}/mcp\"\n }}\n }}\n}}\n"
);
std::fs::write(dir.join(".mcp.json"), content).map_err(|e| format!("Write .mcp.json: {e}"))
}
/// Find the worktree path for a given story ID, if it exists.
pub fn find_worktree_path(project_root: &Path, story_id: &str) -> Option<PathBuf> {
let wt_path = project_root
.join(".huskies")
.join("worktrees")
.join(story_id);
if wt_path.is_dir() {
Some(wt_path)
} else {
None
}
}
/// List all worktrees under `{project_root}/.huskies/worktrees/`.
pub fn list_worktrees(project_root: &Path) -> Result<Vec<WorktreeListEntry>, String> {
let worktrees_dir = project_root.join(".huskies").join("worktrees");
if !worktrees_dir.exists() {
return Ok(Vec::new());
}
let mut entries = Vec::new();
for entry in std::fs::read_dir(&worktrees_dir).map_err(|e| format!("list worktrees: {e}"))? {
let entry = entry.map_err(|e| format!("list worktrees entry: {e}"))?;
let path = entry.path();
if path.is_dir() {
let story_id = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
entries.push(WorktreeListEntry { story_id, path });
}
}
entries.sort_by(|a, b| a.story_id.cmp(&b.story_id));
Ok(entries)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn write_mcp_json_uses_given_port() {
let tmp = TempDir::new().unwrap();
write_mcp_json(tmp.path(), 4242).unwrap();
let content = std::fs::read_to_string(tmp.path().join(".mcp.json")).unwrap();
assert!(content.contains("http://localhost:4242/mcp"));
}
#[test]
fn write_mcp_json_default_port() {
let tmp = TempDir::new().unwrap();
write_mcp_json(tmp.path(), 3001).unwrap();
let content = std::fs::read_to_string(tmp.path().join(".mcp.json")).unwrap();
assert!(content.contains("http://localhost:3001/mcp"));
}
#[test]
fn worktree_path_is_inside_project() {
let project_root = Path::new("/home/user/my-project");
let path = worktree_path(project_root, "42_my_story");
assert_eq!(
path,
Path::new("/home/user/my-project/.huskies/worktrees/42_my_story")
);
}
#[test]
fn list_worktrees_empty_when_no_dir() {
let tmp = TempDir::new().unwrap();
let entries = list_worktrees(tmp.path()).unwrap();
assert!(entries.is_empty());
}
#[test]
fn list_worktrees_returns_subdirs() {
let tmp = TempDir::new().unwrap();
let worktrees_dir = tmp.path().join(".huskies").join("worktrees");
fs::create_dir_all(worktrees_dir.join("42_story_a")).unwrap();
fs::create_dir_all(worktrees_dir.join("43_story_b")).unwrap();
// A file (not dir) — should be ignored
fs::write(worktrees_dir.join("readme.txt"), "").unwrap();
let entries = list_worktrees(tmp.path()).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].story_id, "42_story_a");
assert_eq!(entries[1].story_id, "43_story_b");
}
}
+159
View File
@@ -0,0 +1,159 @@
//! Async worktree removal operations.
use crate::config::ProjectConfig;
use std::path::Path;
use super::create::run_teardown_commands;
use super::git::{branch_name, detect_base_branch, remove_worktree_sync};
use super::{WorktreeInfo, worktree_path};
/// Remove a git worktree and its branch.
pub async fn remove_worktree(
project_root: &Path,
info: &WorktreeInfo,
config: &ProjectConfig,
) -> Result<(), String> {
run_teardown_commands(&info.path, config).await?;
let root = project_root.to_path_buf();
let wt_path = info.path.clone();
let branch = info.branch.clone();
tokio::task::spawn_blocking(move || remove_worktree_sync(&root, &wt_path, &branch))
.await
.map_err(|e| format!("spawn_blocking: {e}"))?
}
/// Remove a git worktree by story ID, deriving the path and branch deterministically.
pub async fn remove_worktree_by_story_id(
project_root: &Path,
story_id: &str,
config: &ProjectConfig,
) -> Result<(), String> {
let path = worktree_path(project_root, story_id);
if !path.exists() {
return Err(format!("Worktree not found for story: {story_id}"));
}
let branch = branch_name(story_id);
let base_branch = config
.base_branch
.clone()
.unwrap_or_else(|| detect_base_branch(project_root));
let info = WorktreeInfo {
path,
branch,
base_branch,
};
remove_worktree(project_root, &info, config).await
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::WatcherConfig;
use std::fs;
use std::process::Command;
use tempfile::TempDir;
fn init_git_repo(dir: &std::path::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,
}
}
#[tokio::test]
async fn remove_worktree_by_story_id_returns_err_when_not_found() {
let tmp = TempDir::new().unwrap();
let result =
remove_worktree_by_story_id(tmp.path(), "99_nonexistent", &empty_config()).await;
assert!(result.is_err());
assert!(
result
.unwrap_err()
.contains("Worktree not found for story: 99_nonexistent")
);
}
#[tokio::test]
async fn remove_worktree_by_story_id_removes_existing_worktree() {
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);
super::super::create::create_worktree(
&project_root,
"88_remove_by_id",
&empty_config(),
3001,
)
.await
.unwrap();
let result =
remove_worktree_by_story_id(&project_root, "88_remove_by_id", &empty_config()).await;
assert!(
result.is_ok(),
"Expected removal to succeed: {:?}",
result.err()
);
}
#[tokio::test]
async fn remove_worktree_async_removes_directory() {
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 = super::super::create::create_worktree(
&project_root,
"77_remove_async",
&empty_config(),
3001,
)
.await
.unwrap();
let path = info.path.clone();
assert!(path.exists());
remove_worktree(&project_root, &info, &empty_config())
.await
.unwrap();
assert!(!path.exists());
}
}