//! Orphaned-worktree cleanup: dry-run discovery and confirmed removal. //! //! Both the chat-bot command and the MCP tool delegate to [`run_cleanup`], which //! walks `.huskies/worktrees/`, compares each directory against the CRDT, and //! optionally removes those whose story is missing or in `Done`/`Archived`. use crate::config::ProjectConfig; use crate::pipeline_state::{Stage, read_typed}; use crate::worktree::sweep::worktree_should_be_swept; use crate::worktree::{WorktreeListEntry, list_worktrees, remove_worktree_by_story_id}; use std::path::Path; /// Result of a cleanup pass — describes what was found and what was done. pub struct CleanupReport { /// Story IDs of all orphaned worktrees that were found. pub orphaned: Vec, /// Story IDs that were successfully removed (`confirm = true` only). pub removed: Vec, /// Story IDs that failed to remove, paired with the error message. pub failed: Vec<(String, String)>, } /// Run an orphaned-worktree cleanup using the live CRDT state. /// /// When `confirm` is `false`, worktrees are only discovered — nothing is /// removed. When `confirm` is `true`, every orphaned worktree is removed via /// [`remove_worktree_by_story_id`]. /// /// A worktree is considered orphaned when its story is absent from the CRDT, /// or is in the `Done` or `Archived` stage. pub async fn run_cleanup( project_root: &Path, config: &ProjectConfig, confirm: bool, ) -> CleanupReport { run_cleanup_with_lookup(project_root, config, confirm, |story_id| { read_typed(story_id).ok().flatten().map(|item| item.stage) }) .await } /// Internal implementation that accepts an injectable CRDT lookup for testing. pub(crate) async fn run_cleanup_with_lookup( project_root: &Path, config: &ProjectConfig, confirm: bool, lookup: F, ) -> CleanupReport where F: Fn(&str) -> Option, { let all_entries = match list_worktrees(project_root) { Ok(e) => e, Err(err) => { crate::slog_error!("[worktree-cleanup] Failed to list worktrees: {err}"); return CleanupReport { orphaned: Vec::new(), removed: Vec::new(), failed: Vec::new(), }; } }; let orphaned: Vec = all_entries .into_iter() .filter(|e| { let stage = lookup(&e.story_id); worktree_should_be_swept(stage.as_ref()) }) .collect(); let orphaned_ids: Vec = orphaned.iter().map(|e| e.story_id.clone()).collect(); if !confirm { return CleanupReport { orphaned: orphaned_ids, removed: Vec::new(), failed: Vec::new(), }; } let mut removed = Vec::new(); let mut failed = Vec::new(); for entry in orphaned { match remove_worktree_by_story_id(project_root, &entry.story_id, config).await { Ok(()) => { crate::slog!( "[worktree-cleanup] Removed orphaned worktree '{}'", entry.story_id ); removed.push(entry.story_id); } Err(err) => { crate::slog_error!( "[worktree-cleanup] Failed to remove worktree '{}': {err}", entry.story_id ); failed.push((entry.story_id, err)); } } } CleanupReport { orphaned: orphaned_ids, removed, failed, } } /// Format a [`CleanupReport`] as a Markdown string suitable for chat or MCP output. pub fn format_report(report: &CleanupReport, confirm: bool) -> String { if report.orphaned.is_empty() { return "No orphaned worktrees found.".to_string(); } if !confirm { let list = report .orphaned .iter() .map(|id| format!("- `{id}`")) .collect::>() .join("\n"); return format!( "Found {} orphaned worktree(s):\n{list}\n\nRun with `--confirm` to remove them.", report.orphaned.len() ); } let mut parts = Vec::new(); if !report.removed.is_empty() { let list = report .removed .iter() .map(|id| format!("- `{id}`")) .collect::>() .join("\n"); parts.push(format!( "Removed {} worktree(s):\n{list}", report.removed.len() )); } if !report.failed.is_empty() { let list = report .failed .iter() .map(|(id, err)| format!("- `{id}`: {err}")) .collect::>() .join("\n"); parts.push(format!( "Failed to remove {} worktree(s):\n{list}", report.failed.len() )); } if parts.is_empty() { "Nothing to do.".to_string() } else { parts.join("\n\n") } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::config::WatcherConfig; use chrono::Utc; use std::fs; use std::path::PathBuf; 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, } } fn done_stage() -> Stage { Stage::Done { merged_at: Utc::now(), merge_commit: crate::pipeline_state::GitSha("abc123".to_string()), } } async fn setup_project_with_real_worktree(story_id: &str) -> (TempDir, PathBuf) { let tmp = TempDir::new().unwrap(); let project_root = tmp.path().join("project"); fs::create_dir_all(&project_root).unwrap(); init_git_repo(&project_root); let config = empty_config(); super::super::create::create_worktree(&project_root, story_id, &config, 3001) .await .unwrap(); (tmp, project_root) } // -- dry-run (confirm = false) ------------------------------------------- #[tokio::test] async fn dry_run_lists_orphaned_without_removing() { let story_id = "200_done_story"; let (_tmp, project_root) = setup_project_with_real_worktree(story_id).await; let wt_dir = project_root .join(".huskies") .join("worktrees") .join(story_id); assert!(wt_dir.exists()); let config = empty_config(); let report = run_cleanup_with_lookup(&project_root, &config, false, |id| { if id == story_id { Some(done_stage()) } else { None } }) .await; assert!( report.orphaned.contains(&story_id.to_string()), "orphaned list should include the done story" ); assert!( report.removed.is_empty(), "dry run should not remove anything" ); assert!(report.failed.is_empty()); assert!(wt_dir.exists(), "worktree must still exist after dry run"); } // -- confirmed removal --------------------------------------------------- #[tokio::test] async fn confirm_removes_orphaned_worktree() { let story_id = "201_purged_story"; let (_tmp, project_root) = setup_project_with_real_worktree(story_id).await; let wt_dir = project_root .join(".huskies") .join("worktrees") .join(story_id); assert!(wt_dir.exists()); let config = empty_config(); // lookup returns None → story not in CRDT (purged) let report = run_cleanup_with_lookup(&project_root, &config, true, |_| None).await; assert!( report.orphaned.contains(&story_id.to_string()), "purged story should be in orphaned list" ); assert!( report.removed.contains(&story_id.to_string()), "purged story worktree should be removed" ); assert!(report.failed.is_empty()); assert!(!wt_dir.exists(), "worktree directory should be gone"); } // -- running story preserved --------------------------------------------- #[tokio::test] async fn confirm_preserves_running_story_worktree() { let story_id = "202_running_story"; let (_tmp, project_root) = setup_project_with_real_worktree(story_id).await; let wt_dir = project_root .join(".huskies") .join("worktrees") .join(story_id); assert!(wt_dir.exists()); let config = empty_config(); let report = run_cleanup_with_lookup(&project_root, &config, true, |id| { if id == story_id { Some(Stage::Coding) } else { None } }) .await; assert!( report.orphaned.is_empty(), "coding story should not be orphaned" ); assert!( report.removed.is_empty(), "coding story worktree must not be removed" ); assert!(wt_dir.exists(), "worktree must still exist"); } // -- format_report ------------------------------------------------------- #[test] fn format_report_no_orphans() { let report = CleanupReport { orphaned: vec![], removed: vec![], failed: vec![], }; let out = format_report(&report, false); assert!(out.contains("No orphaned")); } #[test] fn format_report_dry_run_lists_orphans() { let report = CleanupReport { orphaned: vec!["100_old_story".to_string()], removed: vec![], failed: vec![], }; let out = format_report(&report, false); assert!(out.contains("100_old_story")); assert!(out.contains("--confirm")); } #[test] fn format_report_confirm_lists_removed() { let report = CleanupReport { orphaned: vec!["101_removed".to_string()], removed: vec!["101_removed".to_string()], failed: vec![], }; let out = format_report(&report, true); assert!(out.contains("Removed")); assert!(out.contains("101_removed")); } }