miyabi_worktree/
cleanup.rs

1//! Worktree cleanup and maintenance
2//!
3//! Provides automatic cleanup of orphaned, idle, and stuck worktrees
4//! based on configurable policies.
5
6use crate::state::{WorktreeState, WorktreeStateManager, WorktreeStatusDetailed};
7use miyabi_types::error::Result;
8use serde::{Deserialize, Serialize};
9use std::path::PathBuf;
10use std::time::Duration;
11
12/// Worktree cleanup policy configuration
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct WorktreeCleanupPolicy {
15    /// Delete worktree immediately after task completion
16    pub delete_on_completion: bool,
17    /// Delete orphaned worktrees after this duration
18    pub delete_orphaned_after: Duration,
19    /// Delete idle worktrees after this duration
20    pub delete_idle_after: Duration,
21    /// Maximum number of worktrees to keep
22    pub max_worktrees: Option<usize>,
23}
24
25impl Default for WorktreeCleanupPolicy {
26    fn default() -> Self {
27        Self {
28            delete_on_completion: true,
29            delete_orphaned_after: Duration::from_secs(86400), // 24 hours
30            delete_idle_after: Duration::from_secs(604800),    // 7 days
31            max_worktrees: Some(10),
32        }
33    }
34}
35
36/// Cleanup report with statistics
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct CleanupReport {
39    /// Worktrees that were deleted
40    pub deleted_worktrees: Vec<PathBuf>,
41    /// Total disk space freed (bytes)
42    pub freed_disk_space: u64,
43    /// Errors encountered during cleanup
44    pub errors: Vec<String>,
45    /// Number of orphaned worktrees cleaned
46    pub orphaned_cleaned: usize,
47    /// Number of idle worktrees cleaned
48    pub idle_cleaned: usize,
49    /// Number of stuck worktrees cleaned
50    pub stuck_cleaned: usize,
51}
52
53impl CleanupReport {
54    /// Create a new empty cleanup report
55    pub fn new() -> Self {
56        Self {
57            deleted_worktrees: Vec::new(),
58            freed_disk_space: 0,
59            errors: Vec::new(),
60            orphaned_cleaned: 0,
61            idle_cleaned: 0,
62            stuck_cleaned: 0,
63        }
64    }
65
66    /// Total number of worktrees cleaned
67    pub fn total_cleaned(&self) -> usize {
68        self.orphaned_cleaned + self.idle_cleaned + self.stuck_cleaned
69    }
70}
71
72impl Default for CleanupReport {
73    fn default() -> Self {
74        Self::new()
75    }
76}
77
78/// Worktree cleanup manager
79pub struct WorktreeCleanupManager {
80    state_manager: WorktreeStateManager,
81    policy: WorktreeCleanupPolicy,
82}
83
84impl WorktreeCleanupManager {
85    /// Create a new cleanup manager
86    pub fn new(state_manager: WorktreeStateManager, policy: WorktreeCleanupPolicy) -> Self {
87        Self {
88            state_manager,
89            policy,
90        }
91    }
92
93    /// Create a cleanup manager with default policy
94    pub fn with_defaults(state_manager: WorktreeStateManager) -> Self {
95        Self::new(state_manager, WorktreeCleanupPolicy::default())
96    }
97
98    /// Run cleanup based on policy
99    pub async fn run_cleanup(&self) -> Result<CleanupReport> {
100        let mut report = CleanupReport::new();
101
102        // Get all worktrees
103        let worktrees = self.state_manager.scan_worktrees()?;
104
105        tracing::info!("Starting worktree cleanup scan (found {} worktrees)", worktrees.len());
106
107        // Clean up orphaned worktrees
108        for worktree in worktrees.iter().filter(|w| {
109            w.status == WorktreeStatusDetailed::Orphaned && self.should_clean_orphaned(w)
110        }) {
111            match self.cleanup_single(&mut report, worktree, "orphaned").await {
112                Ok(_) => report.orphaned_cleaned += 1,
113                Err(e) => report.errors.push(format!(
114                    "Failed to clean orphaned worktree {}: {}",
115                    worktree.path.display(),
116                    e
117                )),
118            }
119        }
120
121        // Clean up idle worktrees
122        for worktree in worktrees
123            .iter()
124            .filter(|w| w.status == WorktreeStatusDetailed::Idle && self.should_clean_idle(w))
125        {
126            match self.cleanup_single(&mut report, worktree, "idle").await {
127                Ok(_) => report.idle_cleaned += 1,
128                Err(e) => report.errors.push(format!(
129                    "Failed to clean idle worktree {}: {}",
130                    worktree.path.display(),
131                    e
132                )),
133            }
134        }
135
136        // Clean up stuck worktrees
137        for worktree in worktrees.iter().filter(|w| w.status == WorktreeStatusDetailed::Stuck) {
138            match self.cleanup_single(&mut report, worktree, "stuck").await {
139                Ok(_) => report.stuck_cleaned += 1,
140                Err(e) => report.errors.push(format!(
141                    "Failed to clean stuck worktree {}: {}",
142                    worktree.path.display(),
143                    e
144                )),
145            }
146        }
147
148        // Enforce max_worktrees limit
149        if let Some(max) = self.policy.max_worktrees {
150            let active_count =
151                worktrees.iter().filter(|w| w.status == WorktreeStatusDetailed::Active).count();
152
153            if active_count + report.total_cleaned() > max {
154                // Clean oldest idle worktrees first
155                let mut idle_worktrees: Vec<_> =
156                    worktrees.iter().filter(|w| w.status == WorktreeStatusDetailed::Idle).collect();
157                idle_worktrees.sort_by_key(|w| w.last_accessed);
158
159                let to_remove = active_count + report.total_cleaned() - max;
160                for worktree in idle_worktrees.iter().take(to_remove) {
161                    match self.cleanup_single(&mut report, worktree, "excess").await {
162                        Ok(_) => report.idle_cleaned += 1,
163                        Err(e) => report.errors.push(format!(
164                            "Failed to clean excess worktree {}: {}",
165                            worktree.path.display(),
166                            e
167                        )),
168                    }
169                }
170            }
171        }
172
173        tracing::info!(
174            "Cleanup complete: {} worktrees cleaned, {} MB freed",
175            report.total_cleaned(),
176            report.freed_disk_space / 1024 / 1024
177        );
178
179        Ok(report)
180    }
181
182    /// Start periodic cleanup task
183    pub async fn start_periodic_cleanup(&self, interval: Duration) {
184        loop {
185            tokio::time::sleep(interval).await;
186
187            match self.run_cleanup().await {
188                Ok(report) => {
189                    if report.total_cleaned() > 0 {
190                        tracing::info!(
191                            "Periodic cleanup: {} worktrees cleaned",
192                            report.total_cleaned()
193                        );
194                    }
195                },
196                Err(e) => {
197                    tracing::error!("Periodic cleanup failed: {}", e);
198                },
199            }
200        }
201    }
202
203    /// Clean up a single worktree
204    async fn cleanup_single(
205        &self,
206        report: &mut CleanupReport,
207        worktree: &WorktreeState,
208        reason: &str,
209    ) -> Result<()> {
210        tracing::info!(
211            "Cleaning up {} worktree: {} (issue: {:?})",
212            reason,
213            worktree.path.display(),
214            worktree.issue_number
215        );
216
217        let disk_usage = worktree.disk_usage;
218
219        self.state_manager.cleanup_worktree(&worktree.path)?;
220
221        report.deleted_worktrees.push(worktree.path.clone());
222        report.freed_disk_space += disk_usage;
223
224        Ok(())
225    }
226
227    /// Check if orphaned worktree should be cleaned
228    fn should_clean_orphaned(&self, worktree: &WorktreeState) -> bool {
229        let age = chrono::Utc::now() - worktree.last_accessed;
230        age.num_seconds() > self.policy.delete_orphaned_after.as_secs() as i64
231    }
232
233    /// Check if idle worktree should be cleaned
234    fn should_clean_idle(&self, worktree: &WorktreeState) -> bool {
235        let age = chrono::Utc::now() - worktree.last_accessed;
236        age.num_seconds() > self.policy.delete_idle_after.as_secs() as i64
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use tempfile::TempDir;
244
245    #[test]
246    fn test_cleanup_policy_default() {
247        let policy = WorktreeCleanupPolicy::default();
248        assert!(policy.delete_on_completion);
249        assert_eq!(policy.delete_orphaned_after.as_secs(), 86400); // 24h
250        assert_eq!(policy.delete_idle_after.as_secs(), 604800); // 7 days
251        assert_eq!(policy.max_worktrees, Some(10));
252    }
253
254    #[test]
255    fn test_cleanup_report_new() {
256        let report = CleanupReport::new();
257        assert_eq!(report.total_cleaned(), 0);
258        assert_eq!(report.freed_disk_space, 0);
259        assert!(report.errors.is_empty());
260    }
261
262    #[test]
263    fn test_cleanup_manager_creation() {
264        let temp_dir = TempDir::new().unwrap();
265        let state_manager = WorktreeStateManager::new(temp_dir.path().to_path_buf()).unwrap();
266        let _manager = WorktreeCleanupManager::with_defaults(state_manager);
267    }
268}