//! Periodic orphan sweep — removes worktrees whose stories are done, archived, //! or absent from the CRDT. use crate::config::ProjectConfig; use crate::pipeline_state::{Stage, read_typed}; use std::path::Path; use super::{list_worktrees, remove_worktree_by_story_id}; /// Returns `true` if a worktree for the given pipeline stage should be removed /// by the orphan sweep. /// /// A worktree is swept when its story is `Done`, `Archived`, or not present in /// the CRDT at all (i.e. `stage` is `None`). Active stages (`Backlog`, /// `Coding`, `Qa`, `Merge`) are left alone. pub fn worktree_should_be_swept(stage: Option<&Stage>) -> bool { match stage { None => true, Some(Stage::Done { .. }) | Some(Stage::Archived { .. }) => true, Some(_) => false, } } /// Remove orphaned worktrees whose stories are done, archived, or absent from /// the CRDT. /// /// Walks `.huskies/worktrees/`, checks each story's stage via `lookup`, and /// calls [`remove_worktree_by_story_id`] for any that should be swept. /// Failures are logged individually; the sweep continues regardless. /// /// Returns the number of worktrees successfully removed. pub async fn sweep_orphaned_worktrees(project_root: &Path, config: &ProjectConfig) -> usize { sweep_with_lookup(project_root, config, |story_id| { read_typed(story_id).ok().flatten().map(|item| item.stage) }) .await } /// Internal sweep implementation that accepts a custom CRDT lookup function. /// /// Accepts a `lookup` closure `fn(&str) -> Option` that returns the /// stage for a given story ID, or `None` if the story is not in the CRDT. /// This indirection makes the sweep testable without a real CRDT. pub(crate) async fn sweep_with_lookup( project_root: &Path, config: &ProjectConfig, lookup: F, ) -> usize where F: Fn(&str) -> Option, { let entries = match list_worktrees(project_root) { Ok(e) => e, Err(err) => { crate::slog_error!("[worktree-sweep] Failed to list worktrees: {err}"); return 0; } }; let mut removed = 0usize; for entry in entries { let stage = lookup(&entry.story_id); if !worktree_should_be_swept(stage.as_ref()) { continue; } crate::slog!( "[worktree-sweep] Removing orphaned worktree for '{}' (stage: {})", entry.story_id, stage .as_ref() .map(|s| format!("{:?}", s)) .unwrap_or_else(|| "not in CRDT".to_string()) ); match remove_worktree_by_story_id(project_root, &entry.story_id, config).await { Ok(()) => removed += 1, Err(err) => { crate::slog_error!( "[worktree-sweep] Failed to remove worktree for '{}': {err}", entry.story_id ); } } } removed } #[cfg(test)] mod tests { use super::*; use crate::config::WatcherConfig; use chrono::Utc; use std::fs; use std::num::NonZeroU32; use std::path::PathBuf; 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 done_stage() -> Stage { Stage::Done { merged_at: Utc::now(), merge_commit: crate::pipeline_state::GitSha("abc123".to_string()), } } fn archived_stage() -> Stage { Stage::Archived { archived_at: Utc::now(), reason: crate::pipeline_state::ArchiveReason::Completed, } } // ── worktree_should_be_swept unit tests ───────────────────────────────── #[test] fn should_sweep_when_not_in_crdt() { assert!(worktree_should_be_swept(None)); } #[test] fn should_sweep_done() { assert!(worktree_should_be_swept(Some(&done_stage()))); } #[test] fn should_sweep_archived() { assert!(worktree_should_be_swept(Some(&archived_stage()))); } #[test] fn should_not_sweep_backlog() { assert!(!worktree_should_be_swept(Some(&Stage::Backlog))); } #[test] fn should_not_sweep_coding() { assert!(!worktree_should_be_swept(Some(&Stage::Coding))); } #[test] fn should_not_sweep_qa() { assert!(!worktree_should_be_swept(Some(&Stage::Qa))); } #[test] fn should_not_sweep_merge() { let stage = Stage::Merge { feature_branch: crate::pipeline_state::BranchName("feature/x".to_string()), commits_ahead: NonZeroU32::new(1).unwrap(), }; assert!(!worktree_should_be_swept(Some(&stage))); } // ── Integration tests: sweep_with_lookup ──────────────────────────────── fn setup_project_with_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); // Create a bare worktree directory (simulates a worktree without full git checkout) let worktrees_dir = project_root.join(".huskies").join("worktrees"); fs::create_dir_all(worktrees_dir.join(story_id)).unwrap(); (tmp, project_root) } #[tokio::test] async fn sweep_removes_done_worktree() { let story_id = "100_done_story"; let (_tmp, project_root) = setup_project_with_worktree(story_id); let wt_dir = project_root .join(".huskies") .join("worktrees") .join(story_id); assert!(wt_dir.exists(), "worktree should exist before sweep"); // We can't remove via git worktree because it's not a real git worktree, // so mock the removal: create a worktree directory and confirm sweep detects it. // Instead, test that the sweep logic identifies the worktree as removable. let config = empty_config(); let removed = sweep_with_lookup(&project_root, &config, |id| { if id == story_id { Some(done_stage()) } else { None } }) .await; // The worktree dir exists but is not a real git worktree, so remove_worktree_by_story_id // will fail (not a git worktree). We can't assert removed == 1, but we can verify // it was attempted (removed == 0 due to error is fine; what matters is no panic and // the sweep continued). // For a stronger test, use a real git worktree below. let _ = removed; // sweep ran without panic } #[tokio::test] async fn sweep_removes_real_done_worktree() { 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 story_id = "101_done_real"; let config = empty_config(); // Create a real git worktree. super::super::create::create_worktree(&project_root, story_id, &config, 3001) .await .unwrap(); let wt_dir = project_root .join(".huskies") .join("worktrees") .join(story_id); assert!(wt_dir.exists(), "worktree must exist before sweep"); let removed = sweep_with_lookup(&project_root, &config, |id| { if id == story_id { Some(done_stage()) } else { None } }) .await; assert_eq!(removed, 1, "sweep should remove the done worktree"); assert!( !wt_dir.exists(), "worktree directory should be gone after sweep" ); } #[tokio::test] async fn sweep_does_not_remove_current_worktree() { 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 story_id = "102_current_story"; let config = empty_config(); super::super::create::create_worktree(&project_root, story_id, &config, 3001) .await .unwrap(); let wt_dir = project_root .join(".huskies") .join("worktrees") .join(story_id); assert!(wt_dir.exists()); let removed = sweep_with_lookup(&project_root, &config, |id| { if id == story_id { Some(Stage::Coding) } else { None } }) .await; assert_eq!(removed, 0, "sweep must not remove current/coding worktrees"); assert!(wt_dir.exists(), "worktree directory must still exist"); } #[tokio::test] async fn sweep_does_not_remove_qa_worktree() { 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 story_id = "103_qa_story"; let config = empty_config(); super::super::create::create_worktree(&project_root, story_id, &config, 3001) .await .unwrap(); let wt_dir = project_root .join(".huskies") .join("worktrees") .join(story_id); let removed = sweep_with_lookup(&project_root, &config, |id| { if id == story_id { Some(Stage::Qa) } else { None } }) .await; assert_eq!(removed, 0, "sweep must not remove qa worktrees"); assert!(wt_dir.exists()); } #[tokio::test] async fn sweep_does_not_remove_merge_worktree() { 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 story_id = "104_merge_story"; let config = empty_config(); super::super::create::create_worktree(&project_root, story_id, &config, 3001) .await .unwrap(); let wt_dir = project_root .join(".huskies") .join("worktrees") .join(story_id); let removed = sweep_with_lookup(&project_root, &config, |id| { if id == story_id { Some(Stage::Merge { feature_branch: crate::pipeline_state::BranchName( "feature/story-104_merge_story".to_string(), ), commits_ahead: NonZeroU32::new(1).unwrap(), }) } else { None } }) .await; assert_eq!(removed, 0, "sweep must not remove merge worktrees"); assert!(wt_dir.exists()); } #[tokio::test] async fn sweep_removes_worktree_not_in_crdt() { 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 story_id = "105_absent_story"; let config = empty_config(); super::super::create::create_worktree(&project_root, story_id, &config, 3001) .await .unwrap(); let wt_dir = project_root .join(".huskies") .join("worktrees") .join(story_id); assert!(wt_dir.exists()); // lookup returns None → story not in CRDT let removed = sweep_with_lookup(&project_root, &config, |_| None).await; assert_eq!( removed, 1, "sweep should remove worktree with no CRDT entry" ); assert!(!wt_dir.exists()); } #[tokio::test] async fn sweep_continues_on_individual_failure() { // One worktree that's a bare directory (not a real git worktree — removal fails) // and one real worktree. The sweep should skip the failed one and remove the real one. 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 bad_id = "106_bad_wt"; let good_id = "107_good_wt"; let config = empty_config(); // Bad: bare directory, not a real git worktree. fs::create_dir_all(project_root.join(".huskies").join("worktrees").join(bad_id)).unwrap(); // Good: real worktree. super::super::create::create_worktree(&project_root, good_id, &config, 3001) .await .unwrap(); let removed = sweep_with_lookup(&project_root, &config, |_| None).await; // The good one was removed; bad one failed but sweep continued. assert!( removed >= 1, "at least the real worktree should be removed; got {removed}" ); let good_wt = project_root .join(".huskies") .join("worktrees") .join(good_id); assert!(!good_wt.exists(), "real worktree should be gone"); } }