From 3772c0d03cfe778a030e4253a4093601fa13b9ad Mon Sep 17 00:00:00 2001 From: dave Date: Tue, 28 Apr 2026 14:01:24 +0000 Subject: [PATCH] huskies: merge 784 --- server/src/worktree.rs | 1170 --------------------------------- server/src/worktree/create.rs | 337 ++++++++++ server/src/worktree/git.rs | 434 ++++++++++++ server/src/worktree/mod.rs | 130 ++++ server/src/worktree/remove.rs | 159 +++++ 5 files changed, 1060 insertions(+), 1170 deletions(-) delete mode 100644 server/src/worktree.rs create mode 100644 server/src/worktree/create.rs create mode 100644 server/src/worktree/git.rs create mode 100644 server/src/worktree/mod.rs create mode 100644 server/src/worktree/remove.rs diff --git a/server/src/worktree.rs b/server/src/worktree.rs deleted file mode 100644 index 40412880..00000000 --- a/server/src/worktree.rs +++ /dev/null @@ -1,1170 +0,0 @@ -//! Git worktree management — creates, lists, and removes worktrees for agent isolation. -use crate::config::ProjectConfig; -use crate::slog; -use std::path::{Path, PathBuf}; -use std::process::Command; - -/// 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}")) -} - -#[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) -} - -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). -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()) -} - -/// 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 { - 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, - }) -} - -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(()) -} - -/// 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). -fn configure_sparse_checkout(_wt_path: &Path) -> Result<(), String> { - 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 = 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) -} - -/// 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 -} - -/// 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 = worktree_path(project_root, old_id); - let new_wt = 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}"); - } - } - } -} - -/// List all worktrees under `{project_root}/.huskies/worktrees/`. -/// Find the worktree path for a given story ID, if it exists. -pub fn find_worktree_path(project_root: &Path, story_id: &str) -> Option { - let wt_path = project_root - .join(".huskies") - .join("worktrees") - .join(story_id); - if wt_path.is_dir() { - Some(wt_path) - } else { - None - } -} - -pub fn list_worktrees(project_root: &Path) -> Result, 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) -} - -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(()) -} - -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); - } - } - } -} - -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(()) -} - -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 tempfile::TempDir; - - /// Initialise a bare-minimum git repo so worktree operations work. - 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 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"); - } - - #[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 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_worktree_path_uses_number_only() { - let project_root = Path::new("/home/user/my-project"); - let path = worktree_path(project_root, "664"); - assert_eq!( - path, - Path::new("/home/user/my-project/.huskies/worktrees/664") - ); - } - - #[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 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()); - } - - #[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(); - let config = 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, - }; - // Should complete without panic - run_setup_commands(tmp.path(), &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![], - }], - 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, - }; - // 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![], - }], - 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, - }; - // 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()], - }], - 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, - }; - // 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 config = 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, - }; - let info = create_worktree(&project_root, "42_fresh_test", &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); - - let config = 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, - }; - // First creation - let _info1 = create_worktree(&project_root, "43_reuse_test", &config, 3001) - .await - .unwrap(); - // Second call — worktree already exists, reuse path, update port - let info2 = create_worktree(&project_root, "43_reuse_test", &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" - ); - } - - #[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()); - } - - #[tokio::test] - async fn remove_worktree_by_story_id_returns_err_when_not_found() { - let tmp = TempDir::new().unwrap(); - let config = 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, - }; - - let result = remove_worktree_by_story_id(tmp.path(), "99_nonexistent", &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); - - let config = 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, - }; - create_worktree(&project_root, "88_remove_by_id", &config, 3001) - .await - .unwrap(); - - let result = remove_worktree_by_story_id(&project_root, "88_remove_by_id", &config).await; - assert!( - result.is_ok(), - "Expected removal to succeed: {:?}", - result.err() - ); - } - - // ── prune_worktree_sync ────────────────────────────────────────────────── - - #[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 = 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"); - } - - #[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); - - let config = ProjectConfig { - component: vec![ComponentConfig { - name: "broken-build".to_string(), - path: ".".to_string(), - setup: vec!["exit 1".to_string()], - teardown: 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, - }; - // 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", &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); - - let empty_config = 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, - }; - // First creation — no setup commands, should succeed - create_worktree(&project_root, "173_reuse_fail", &empty_config, 3001) - .await - .unwrap(); - - let failing_config = ProjectConfig { - component: vec![ComponentConfig { - name: "broken-build".to_string(), - path: ".".to_string(), - setup: vec!["exit 1".to_string()], - teardown: 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, - }; - // Second call — worktree exists, setup commands fail, must still succeed - let result = create_worktree(&project_root, "173_reuse_fail", &failing_config, 3002).await; - assert!( - result.is_ok(), - "create_worktree reuse must succeed even if setup commands fail: {:?}", - 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 config = 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, - }; - let info = create_worktree(&project_root, "77_remove_async", &config, 3001) - .await - .unwrap(); - - let path = info.path.clone(); - assert!(path.exists()); - remove_worktree(&project_root, &info, &config) - .await - .unwrap(); - assert!(!path.exists()); - } -} diff --git a/server/src/worktree/create.rs b/server/src/worktree/create.rs new file mode 100644 index 00000000..043e6472 --- /dev/null +++ b/server/src/worktree/create.rs @@ -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 { + 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() + ); + } +} diff --git a/server/src/worktree/git.rs b/server/src/worktree/git.rs new file mode 100644 index 00000000..9db981bd --- /dev/null +++ b/server/src/worktree/git.rs @@ -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"); + } +} diff --git a/server/src/worktree/mod.rs b/server/src/worktree/mod.rs new file mode 100644 index 00000000..80f937c4 --- /dev/null +++ b/server/src/worktree/mod.rs @@ -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 { + 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, 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"); + } +} diff --git a/server/src/worktree/remove.rs b/server/src/worktree/remove.rs new file mode 100644 index 00000000..f6cd65bc --- /dev/null +++ b/server/src/worktree/remove.rs @@ -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()); + } +}