//! 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, status_push_enabled: true, } } #[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_by_story_id_cleans_git_metadata_and_branch() { 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 = "89_regression_remove"; let expected_branch = format!("feature/story-{story_id}"); super::super::create::create_worktree(&project_root, story_id, &empty_config(), 3001) .await .unwrap(); let wt_path = super::super::worktree_path(&project_root, story_id); assert!(wt_path.exists(), "worktree must exist before removal"); let result = remove_worktree_by_story_id(&project_root, story_id, &empty_config()).await; assert!( result.is_ok(), "Expected removal to succeed: {:?}", result.err() ); // Regression: `git worktree list` must not show the removed worktree. let wt_list_output = std::process::Command::new("git") .args(["worktree", "list", "--porcelain"]) .current_dir(&project_root) .output() .expect("git worktree list"); let wt_list = String::from_utf8_lossy(&wt_list_output.stdout); assert!( !wt_list.contains(&*wt_path.to_string_lossy()), "git worktree list should not contain the removed worktree path, but got:\n{wt_list}" ); // Regression: feature branch must be deleted after worktree removal. let branch_output = std::process::Command::new("git") .args(["branch", "--list", &expected_branch]) .current_dir(&project_root) .output() .expect("git branch --list"); let branch_list = String::from_utf8_lossy(&branch_output.stdout); assert!( branch_list.trim().is_empty(), "Feature branch '{expected_branch}' should be deleted, but found: {branch_list}" ); } #[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()); } }