miyabi_worktree/
cleanup.rs1use crate::state::{WorktreeState, WorktreeStateManager, WorktreeStatusDetailed};
7use miyabi_types::error::Result;
8use serde::{Deserialize, Serialize};
9use std::path::PathBuf;
10use std::time::Duration;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct WorktreeCleanupPolicy {
15 pub delete_on_completion: bool,
17 pub delete_orphaned_after: Duration,
19 pub delete_idle_after: Duration,
21 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), delete_idle_after: Duration::from_secs(604800), max_worktrees: Some(10),
32 }
33 }
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct CleanupReport {
39 pub deleted_worktrees: Vec<PathBuf>,
41 pub freed_disk_space: u64,
43 pub errors: Vec<String>,
45 pub orphaned_cleaned: usize,
47 pub idle_cleaned: usize,
49 pub stuck_cleaned: usize,
51}
52
53impl CleanupReport {
54 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 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
78pub struct WorktreeCleanupManager {
80 state_manager: WorktreeStateManager,
81 policy: WorktreeCleanupPolicy,
82}
83
84impl WorktreeCleanupManager {
85 pub fn new(state_manager: WorktreeStateManager, policy: WorktreeCleanupPolicy) -> Self {
87 Self {
88 state_manager,
89 policy,
90 }
91 }
92
93 pub fn with_defaults(state_manager: WorktreeStateManager) -> Self {
95 Self::new(state_manager, WorktreeCleanupPolicy::default())
96 }
97
98 pub async fn run_cleanup(&self) -> Result<CleanupReport> {
100 let mut report = CleanupReport::new();
101
102 let worktrees = self.state_manager.scan_worktrees()?;
104
105 tracing::info!("Starting worktree cleanup scan (found {} worktrees)", worktrees.len());
106
107 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 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 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 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 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 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 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 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 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); assert_eq!(policy.delete_idle_after.as_secs(), 604800); 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}