1use crate::errors::{CascadeError, Result};
2use crate::git::GitRepository;
3use crate::stack::{Stack, StackManager};
4use serde::{Deserialize, Serialize};
5use std::collections::HashSet;
6use tracing::{debug, info, warn};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct CleanupCandidate {
11 pub branch_name: String,
13 pub entry_id: Option<uuid::Uuid>,
15 pub stack_id: Option<uuid::Uuid>,
17 pub is_merged: bool,
19 pub has_remote: bool,
21 pub is_current: bool,
23 pub reason: CleanupReason,
25 pub safety_info: String,
27}
28
29impl CleanupCandidate {
30 pub fn reason_to_string(&self) -> &str {
32 match self.reason {
33 CleanupReason::FullyMerged => "fully merged",
34 CleanupReason::StackEntryMerged => "PR merged",
35 CleanupReason::Stale => "stale",
36 CleanupReason::Orphaned => "orphaned",
37 }
38 }
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
43pub enum CleanupReason {
44 FullyMerged,
46 StackEntryMerged,
48 Stale,
50 Orphaned,
52}
53
54#[derive(Debug, Clone)]
56pub struct CleanupOptions {
57 pub dry_run: bool,
59 pub force: bool,
61 pub include_stale: bool,
63 pub cleanup_remote: bool,
65 pub stale_threshold_days: u32,
67 pub cleanup_non_stack: bool,
69}
70
71#[derive(Debug, Clone)]
73pub struct CleanupResult {
74 pub cleaned_branches: Vec<String>,
76 pub failed_branches: Vec<(String, String)>, pub skipped_branches: Vec<(String, String)>, pub total_candidates: usize,
82}
83
84pub struct CleanupManager {
86 stack_manager: StackManager,
87 git_repo: GitRepository,
88 options: CleanupOptions,
89}
90
91impl Default for CleanupOptions {
92 fn default() -> Self {
93 Self {
94 dry_run: false,
95 force: false,
96 include_stale: false,
97 cleanup_remote: false,
98 stale_threshold_days: 30,
99 cleanup_non_stack: false,
100 }
101 }
102}
103
104impl CleanupManager {
105 pub fn new(
107 stack_manager: StackManager,
108 git_repo: GitRepository,
109 options: CleanupOptions,
110 ) -> Self {
111 Self {
112 stack_manager,
113 git_repo,
114 options,
115 }
116 }
117
118 pub fn find_cleanup_candidates(&self) -> Result<Vec<CleanupCandidate>> {
120 debug!("Scanning for cleanup candidates...");
121
122 let mut candidates = Vec::new();
123 let all_branches = self.git_repo.list_branches()?;
124 let current_branch = self.git_repo.get_current_branch().ok();
125
126 let stacks = self.stack_manager.get_all_stacks_objects()?;
128 let mut stack_branches = HashSet::new();
129 let mut stack_branch_to_entry = std::collections::HashMap::new();
130
131 for stack in &stacks {
132 for entry in &stack.entries {
133 stack_branches.insert(entry.branch.clone());
134 stack_branch_to_entry.insert(entry.branch.clone(), (stack.id, entry.id));
135 }
136 }
137
138 for branch_name in &all_branches {
139 if current_branch.as_ref() == Some(branch_name) {
141 continue;
142 }
143
144 if self.is_protected_branch(branch_name) {
146 continue;
147 }
148
149 let is_current = current_branch.as_ref() == Some(branch_name);
150 let has_remote = self.git_repo.get_upstream_branch(branch_name)?.is_some();
151
152 let (stack_id, entry_id) =
154 if let Some((stack_id, entry_id)) = stack_branch_to_entry.get(branch_name) {
155 (Some(*stack_id), Some(*entry_id))
156 } else {
157 (None, None)
158 };
159
160 if let Some(candidate) = self.evaluate_branch_for_cleanup(
162 branch_name,
163 stack_id,
164 entry_id,
165 is_current,
166 has_remote,
167 &stacks,
168 )? {
169 candidates.push(candidate);
170 }
171 }
172
173 debug!("Found {} cleanup candidates", candidates.len());
174 Ok(candidates)
175 }
176
177 fn evaluate_branch_for_cleanup(
179 &self,
180 branch_name: &str,
181 stack_id: Option<uuid::Uuid>,
182 entry_id: Option<uuid::Uuid>,
183 is_current: bool,
184 has_remote: bool,
185 stacks: &[Stack],
186 ) -> Result<Option<CleanupCandidate>> {
187 if let Ok(base_branch) = self.get_base_branch_for_branch(branch_name, stack_id, stacks) {
189 if self.is_branch_merged_to_base(branch_name, &base_branch)? {
190 return Ok(Some(CleanupCandidate {
191 branch_name: branch_name.to_string(),
192 entry_id,
193 stack_id,
194 is_merged: true,
195 has_remote,
196 is_current,
197 reason: CleanupReason::FullyMerged,
198 safety_info: format!("Branch fully merged to '{base_branch}'"),
199 }));
200 }
201 }
202
203 if stack_id.is_some() {
205 return Ok(None);
206 }
207
208 if self.options.include_stale {
210 if let Some(candidate) =
211 self.check_stale_branch(branch_name, stack_id, entry_id, has_remote, is_current)?
212 {
213 return Ok(Some(candidate));
214 }
215 }
216
217 if self.options.cleanup_non_stack {
219 if let Some(candidate) =
220 self.check_orphaned_branch(branch_name, has_remote, is_current)?
221 {
222 return Ok(Some(candidate));
223 }
224 }
225
226 Ok(None)
227 }
228
229 fn check_stale_branch(
231 &self,
232 branch_name: &str,
233 stack_id: Option<uuid::Uuid>,
234 entry_id: Option<uuid::Uuid>,
235 _has_remote: bool,
236 _is_current: bool,
237 ) -> Result<Option<CleanupCandidate>> {
238 let last_commit_age = self.get_branch_last_commit_age_days(branch_name)?;
239
240 if last_commit_age > self.options.stale_threshold_days {
241 return Ok(Some(CleanupCandidate {
242 branch_name: branch_name.to_string(),
243 entry_id,
244 stack_id,
245 is_merged: false,
246 has_remote: _has_remote,
247 is_current: _is_current,
248 reason: CleanupReason::Stale,
249 safety_info: format!("No activity for {last_commit_age} days"),
250 }));
251 }
252
253 Ok(None)
254 }
255
256 fn check_orphaned_branch(
258 &self,
259 _branch_name: &str,
260 _has_remote: bool,
261 _is_current: bool,
262 ) -> Result<Option<CleanupCandidate>> {
263 Ok(None)
266 }
267
268 pub fn perform_cleanup(&mut self, candidates: &[CleanupCandidate]) -> Result<CleanupResult> {
270 let mut result = CleanupResult {
271 cleaned_branches: Vec::new(),
272 failed_branches: Vec::new(),
273 skipped_branches: Vec::new(),
274 total_candidates: candidates.len(),
275 };
276
277 if candidates.is_empty() {
278 info!("No cleanup candidates found");
279 return Ok(result);
280 }
281
282 info!("Processing {} cleanup candidates", candidates.len());
283
284 for candidate in candidates {
285 match self.cleanup_single_branch(candidate) {
286 Ok(true) => {
287 result.cleaned_branches.push(candidate.branch_name.clone());
288 info!("✅ Cleaned up branch: {}", candidate.branch_name);
289 }
290 Ok(false) => {
291 result.skipped_branches.push((
292 candidate.branch_name.clone(),
293 "Skipped by user or safety check".to_string(),
294 ));
295 debug!("⏭️ Skipped branch: {}", candidate.branch_name);
296 }
297 Err(e) => {
298 result
299 .failed_branches
300 .push((candidate.branch_name.clone(), e.to_string()));
301 warn!(
302 "❌ Failed to clean up branch {}: {}",
303 candidate.branch_name, e
304 );
305 }
306 }
307 }
308
309 Ok(result)
310 }
311
312 fn cleanup_single_branch(&mut self, candidate: &CleanupCandidate) -> Result<bool> {
314 debug!(
315 "Cleaning up branch: {} ({:?})",
316 candidate.branch_name, candidate.reason
317 );
318
319 if candidate.is_current {
321 return Ok(false);
322 }
323
324 if self.options.dry_run {
326 info!("DRY RUN: Would delete branch '{}'", candidate.branch_name);
327 return Ok(true);
328 }
329
330 match candidate.reason {
332 CleanupReason::FullyMerged | CleanupReason::StackEntryMerged => {
333 self.git_repo.delete_branch(&candidate.branch_name)?;
335 }
336 CleanupReason::Stale | CleanupReason::Orphaned => {
337 self.git_repo.delete_branch(&candidate.branch_name)?;
340 }
341 }
342
343 if let (Some(stack_id), Some(entry_id)) = (candidate.stack_id, candidate.entry_id) {
345 self.remove_entry_from_stack(stack_id, entry_id)?;
346 }
347
348 Ok(true)
349 }
350
351 fn remove_entry_from_stack(
353 &mut self,
354 stack_id: uuid::Uuid,
355 entry_id: uuid::Uuid,
356 ) -> Result<()> {
357 debug!("Removing entry {} from stack {}", entry_id, stack_id);
358
359 match self.stack_manager.remove_stack_entry(&stack_id, &entry_id) {
360 Ok(Some(_entry)) => {
361 if let Some(stack) = self.stack_manager.get_stack(&stack_id) {
362 if stack.entries.is_empty() {
363 info!("Stack '{}' is now empty after cleanup", stack.name);
364 }
365 }
366 Ok(())
367 }
368 Ok(None) => {
369 warn!(
370 "Skip removing entry {} from stack {} (entry not found or still has dependents)",
371 entry_id, stack_id
372 );
373 Ok(())
374 }
375 Err(e) => Err(e),
376 }
377 }
378
379 fn is_branch_merged_to_base(&self, branch_name: &str, base_branch: &str) -> Result<bool> {
381 match self.git_repo.get_commits_between(base_branch, branch_name) {
383 Ok(commits) => Ok(commits.is_empty()),
384 Err(_) => {
385 Ok(false)
387 }
388 }
389 }
390
391 fn get_base_branch_for_branch(
393 &self,
394 _branch_name: &str,
395 stack_id: Option<uuid::Uuid>,
396 stacks: &[Stack],
397 ) -> Result<String> {
398 if let Some(stack_id) = stack_id {
399 if let Some(stack) = stacks.iter().find(|s| s.id == stack_id) {
400 return Ok(stack.base_branch.clone());
401 }
402 }
403
404 let main_branches = ["main", "master", "develop"];
406 for branch in &main_branches {
407 if self.git_repo.branch_exists(branch) {
408 return Ok(branch.to_string());
409 }
410 }
411
412 Err(CascadeError::config(
413 "Could not determine base branch".to_string(),
414 ))
415 }
416
417 fn is_protected_branch(&self, branch_name: &str) -> bool {
419 let protected_branches = [
420 "main",
421 "master",
422 "develop",
423 "development",
424 "staging",
425 "production",
426 "release",
427 ];
428
429 protected_branches.contains(&branch_name)
430 }
431
432 fn get_branch_last_commit_age_days(&self, branch_name: &str) -> Result<u32> {
434 let commit_hash = self.git_repo.get_branch_commit_hash(branch_name)?;
435 let commit = self.git_repo.get_commit(&commit_hash)?;
436
437 let now = std::time::SystemTime::now();
438 let commit_time =
439 std::time::UNIX_EPOCH + std::time::Duration::from_secs(commit.time().seconds() as u64);
440
441 let age = now
442 .duration_since(commit_time)
443 .unwrap_or_else(|_| std::time::Duration::from_secs(0));
444
445 Ok((age.as_secs() / 86400) as u32) }
447
448 pub fn get_cleanup_stats(&self) -> Result<CleanupStats> {
450 let candidates = self.find_cleanup_candidates()?;
451
452 let mut stats = CleanupStats {
453 total_branches: self.git_repo.list_branches()?.len(),
454 fully_merged: 0,
455 stack_entry_merged: 0,
456 stale: 0,
457 orphaned: 0,
458 protected: 0,
459 };
460
461 for candidate in &candidates {
462 match candidate.reason {
463 CleanupReason::FullyMerged => stats.fully_merged += 1,
464 CleanupReason::StackEntryMerged => stats.stack_entry_merged += 1,
465 CleanupReason::Stale => stats.stale += 1,
466 CleanupReason::Orphaned => stats.orphaned += 1,
467 }
468 }
469
470 let all_branches = self.git_repo.list_branches()?;
472 stats.protected = all_branches
473 .iter()
474 .filter(|branch| self.is_protected_branch(branch))
475 .count();
476
477 Ok(stats)
478 }
479}
480
481#[derive(Debug, Clone)]
483pub struct CleanupStats {
484 pub total_branches: usize,
485 pub fully_merged: usize,
486 pub stack_entry_merged: usize,
487 pub stale: usize,
488 pub orphaned: usize,
489 pub protected: usize,
490}
491
492impl CleanupStats {
493 pub fn cleanup_candidates(&self) -> usize {
494 self.fully_merged + self.stack_entry_merged + self.stale + self.orphaned
495 }
496}
497
498#[cfg(test)]
499mod tests {
500 use super::*;
501 use std::process::Command;
502 use tempfile::TempDir;
503
504 fn create_test_repo() -> (TempDir, std::path::PathBuf) {
505 let temp_dir = TempDir::new().unwrap();
506 let repo_path = temp_dir.path().to_path_buf();
507
508 Command::new("git")
510 .args(["init"])
511 .current_dir(&repo_path)
512 .output()
513 .unwrap();
514
515 Command::new("git")
516 .args(["config", "user.name", "Test"])
517 .current_dir(&repo_path)
518 .output()
519 .unwrap();
520
521 Command::new("git")
522 .args(["config", "user.email", "test@example.com"])
523 .current_dir(&repo_path)
524 .output()
525 .unwrap();
526
527 (temp_dir, repo_path)
528 }
529
530 #[test]
531 fn test_cleanup_reason_serialization() {
532 let reason = CleanupReason::FullyMerged;
533 let serialized = serde_json::to_string(&reason).unwrap();
534 let deserialized: CleanupReason = serde_json::from_str(&serialized).unwrap();
535 assert_eq!(reason, deserialized);
536 }
537
538 #[test]
539 fn test_cleanup_options_default() {
540 let options = CleanupOptions::default();
541 assert!(!options.dry_run);
542 assert!(!options.force);
543 assert!(!options.include_stale);
544 assert_eq!(options.stale_threshold_days, 30);
545 }
546
547 #[test]
548 fn test_protected_branches() {
549 let (_temp_dir, repo_path) = create_test_repo();
550 let git_repo = crate::git::GitRepository::open(&repo_path).unwrap();
551 let stack_manager = crate::stack::StackManager::new(&repo_path).unwrap();
552 let options = CleanupOptions::default();
553
554 let cleanup_manager = CleanupManager::new(stack_manager, git_repo, options);
555
556 assert!(cleanup_manager.is_protected_branch("main"));
557 assert!(cleanup_manager.is_protected_branch("master"));
558 assert!(cleanup_manager.is_protected_branch("develop"));
559 assert!(!cleanup_manager.is_protected_branch("feature-branch"));
560 }
561
562 #[test]
563 fn test_cleanup_stats() {
564 let stats = CleanupStats {
565 total_branches: 10,
566 fully_merged: 3,
567 stack_entry_merged: 2,
568 stale: 1,
569 orphaned: 0,
570 protected: 4,
571 };
572
573 assert_eq!(stats.cleanup_candidates(), 6);
574 }
575}