//! Orphaned build-dir GC (story 1199) — reclaims disk space from dead //! worktree `target/` directories and a stale `.huskies/merge_workspace`, //! without ever touching the main project's or any live worktree's //! `target/` (the warm caches that keep rebuilds fast). //! //! Follows service-module conventions: this file holds pure orphan-selection //! logic (no I/O); `io.rs` is the only place performing side effects //! (directory-size walks, `fs::remove_dir_all`, `spawn_blocking`, the pass //! orchestration entry point). /// Side effects for the GC pass: directory-size walks, recursive removal via /// `spawn_blocking`, and the pass orchestration entry point. pub mod io; use std::path::PathBuf; use crate::pipeline_state::Stage; use crate::worktree::WorktreeListEntry; /// A single directory identified as safe to reclaim by a GC pass. #[derive(Debug, Clone, PartialEq)] pub struct GcCandidate { /// Human-readable label for reporting (story ID, or `"merge_workspace"`). pub label: String, pub path: PathBuf, } /// Summary of one GC pass: what was reclaimed, what was skipped (raced or /// errored), how many bytes were freed, and how long the pass took. #[derive(Debug, Clone, Default)] pub struct GcReport { pub bytes_reclaimed: u64, pub reclaimed: Vec, pub skipped: Vec, pub duration: std::time::Duration, } /// Select `target/` directories under worktrees whose story is no longer /// active in the pool (terminal stage, or absent from the CRDT entirely). /// /// Reuses [`crate::worktree::worktree_should_be_swept`] — the same predicate /// that decides whether a whole worktree should be swept — so a `target/` /// dir is only ever reclaimed here for a worktree that full cleanup would /// also remove. Live worktrees (Backlog, Coding, Qa, Merge) are never /// touched, preserving their warm build cache. pub fn select_orphaned_worktree_targets( entries: &[WorktreeListEntry], lookup: F, ) -> Vec where F: Fn(&str) -> Option, { entries .iter() .filter(|e| crate::worktree::worktree_should_be_swept(lookup(&e.story_id).as_ref())) .map(|e| GcCandidate { label: e.story_id.clone(), path: e.path.join("target"), }) .collect() } /// Select the `.huskies/merge_workspace` candidate, if it's safe to reclaim. /// /// `merge_lock_free` should come from [`crate::agents::merge::merge_lock_is_free`] /// — a momentary snapshot of whether a squash-merge is currently running. /// When a merge is running, `merge_workspace` is live state, not an orphan. pub fn select_merge_workspace( project_root: &std::path::Path, merge_lock_free: bool, ) -> Option { if !merge_lock_free { return None; } Some(GcCandidate { label: "merge_workspace".to_string(), path: project_root.join(".huskies").join("merge_workspace"), }) } /// Returns `true` if an automatic GC pass should run given the configured /// threshold and current free space. /// /// `gc_min_free_gb == 0` disables automatic GC entirely — the on-demand `gc` /// MCP tool remains available regardless. pub fn should_auto_run(gc_min_free_gb: u64, free_bytes: u64) -> bool { gc_min_free_gb > 0 && free_bytes < gc_min_free_gb * 1_000_000_000 } #[cfg(test)] mod tests { use super::*; use chrono::Utc; fn done_stage() -> Stage { Stage::Done { merged_at: Utc::now(), merge_commit: crate::pipeline_state::GitSha("abc123".to_string()), } } fn entry(story_id: &str) -> WorktreeListEntry { WorktreeListEntry { story_id: story_id.to_string(), path: PathBuf::from(format!("/project/.huskies/worktrees/{story_id}")), } } // ── select_orphaned_worktree_targets ───────────────────────────────── #[test] fn orphaned_target_is_selected_when_story_is_terminal() { let entries = vec![entry("100_done")]; let candidates = select_orphaned_worktree_targets(&entries, |_| Some(done_stage())); assert_eq!(candidates.len(), 1); assert_eq!(candidates[0].label, "100_done"); assert_eq!( candidates[0].path, PathBuf::from("/project/.huskies/worktrees/100_done/target") ); } #[test] fn orphaned_target_is_selected_when_story_absent_from_crdt() { let entries = vec![entry("101_purged")]; let candidates = select_orphaned_worktree_targets(&entries, |_| None); assert_eq!(candidates.len(), 1); assert_eq!(candidates[0].label, "101_purged"); } #[test] fn live_worktree_target_is_kept_not_selected() { let entries = vec![entry("102_coding")]; let candidates = select_orphaned_worktree_targets(&entries, |_| { Some(Stage::Coding { claim: None, plan: Default::default(), retries: 0, }) }); assert!( candidates.is_empty(), "a live (Coding) worktree's target/ must never be selected for GC" ); } #[test] fn mixed_pool_only_selects_the_orphan() { let entries = vec![entry("103_coding"), entry("104_done")]; let candidates = select_orphaned_worktree_targets(&entries, |id| { if id == "104_done" { Some(done_stage()) } else { Some(Stage::Coding { claim: None, plan: Default::default(), retries: 0, }) } }); assert_eq!(candidates.len(), 1); assert_eq!(candidates[0].label, "104_done"); } // ── select_merge_workspace ──────────────────────────────────────────── #[test] fn merge_workspace_selected_when_lock_free() { let root = PathBuf::from("/project"); let candidate = select_merge_workspace(&root, true); assert_eq!( candidate, Some(GcCandidate { label: "merge_workspace".to_string(), path: PathBuf::from("/project/.huskies/merge_workspace"), }) ); } #[test] fn merge_workspace_not_selected_when_merge_running() { let root = PathBuf::from("/project"); assert_eq!(select_merge_workspace(&root, false), None); } // ── should_auto_run (disabled config) ────────────────────────────────── #[test] fn auto_run_disabled_when_threshold_is_zero() { assert!(!should_auto_run(0, 0)); assert!(!should_auto_run(0, u64::MAX)); } #[test] fn auto_run_triggers_below_threshold() { let gb = 1_000_000_000; assert!(should_auto_run(50, 10 * gb)); assert!(!should_auto_run(50, 60 * gb)); } }