//! Async worktree removal operations. use crate::config::ProjectConfig; use std::path::Path; use super::create::run_teardown_commands; use super::git::{branch_name, remove_worktree_sync, resolve_base_branch}; use super::{WorktreeInfo, worktree_path}; /// Remove a git worktree and its branch. /// /// Story 1199: measures the worktree's `target/` size before removal (once /// removed, there's nothing left to measure) and logs bytes reclaimed on /// success, so terminal-story worktree cleanup reports its disk impact the /// same way the on-demand `gc` pass does. pub async fn remove_worktree( project_root: &Path, info: &WorktreeInfo, config: &ProjectConfig, ) -> Result<(), String> { run_teardown_commands(&info.path, config).await?; let target_path = info.path.join("target"); let target_bytes = tokio::task::spawn_blocking(move || { crate::service::disk_watch::io::dir_size_bytes(&target_path) }) .await .unwrap_or(0); let root = project_root.to_path_buf(); let wt_path = info.path.clone(); let branch = info.branch.clone(); let result = tokio::task::spawn_blocking(move || remove_worktree_sync(&root, &wt_path, &branch)) .await .map_err(|e| format!("spawn_blocking: {e}"))?; if result.is_ok() && target_bytes > 0 { crate::slog!( "[worktree-remove] Reclaimed {target_bytes} bytes from '{}' target/ on removal", info.path.display() ); } result } /// 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 = resolve_base_branch(project_root, config.base_branch.as_deref()); 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) { use crate::git_test_support::git_ok; git_ok( Command::new("git").args(["init"]).current_dir(dir).output(), "git init", ); git_ok( Command::new("git") .args(["config", "user.email", "test@test.com"]) .current_dir(dir) .output(), "git config user.email", ); git_ok( Command::new("git") .args(["config", "user.name", "Test"]) .current_dir(dir) .output(), "git config user.name", ); git_ok( Command::new("git") .args(["commit", "--allow-empty", "-m", "init"]) .current_dir(dir) .output(), "git commit", ); } fn empty_config() -> ProjectConfig { ProjectConfig { component: vec![], agent: vec![], watcher: WatcherConfig::default(), disk_watch: Default::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, merge_failure_block_threshold: 3, gc_min_free_gb: 0, } } #[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()); } /// Story 1199 AC4: removal reclaims a populated `target/` dir along with /// the rest of the worktree. #[tokio::test] async fn remove_worktree_reclaims_populated_target_dir() { 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, "90_reclaim_target", &empty_config(), 3001, ) .await .unwrap(); let target_dir = info.path.join("target"); fs::create_dir_all(&target_dir).unwrap(); fs::write(target_dir.join("build_artifact.bin"), vec![0u8; 1024]).unwrap(); assert!(target_dir.exists()); remove_worktree(&project_root, &info, &empty_config()) .await .unwrap(); assert!(!info.path.exists(), "whole worktree must be gone"); assert!(!target_dir.exists(), "target/ must be reclaimed"); } }