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 let Some(entry_id) = entry_id {
205 if let Some(stack_id) = stack_id {
206 if self.is_stack_entry_merged(stack_id, entry_id)? {
207 return Ok(Some(CleanupCandidate {
208 branch_name: branch_name.to_string(),
209 entry_id: Some(entry_id),
210 stack_id: Some(stack_id),
211 is_merged: false, has_remote,
213 is_current,
214 reason: CleanupReason::StackEntryMerged,
215 safety_info: "Stack entry was merged via pull request".to_string(),
216 }));
217 }
218 }
219 }
220
221 if stack_id.is_some() {
223 return Ok(None);
224 }
225
226 if self.options.include_stale {
228 if let Some(candidate) =
229 self.check_stale_branch(branch_name, stack_id, entry_id, has_remote, is_current)?
230 {
231 return Ok(Some(candidate));
232 }
233 }
234
235 if self.options.cleanup_non_stack {
237 if let Some(candidate) =
238 self.check_orphaned_branch(branch_name, has_remote, is_current)?
239 {
240 return Ok(Some(candidate));
241 }
242 }
243
244 Ok(None)
245 }
246
247 fn check_stale_branch(
249 &self,
250 branch_name: &str,
251 stack_id: Option<uuid::Uuid>,
252 entry_id: Option<uuid::Uuid>,
253 _has_remote: bool,
254 _is_current: bool,
255 ) -> Result<Option<CleanupCandidate>> {
256 let last_commit_age = self.get_branch_last_commit_age_days(branch_name)?;
257
258 if last_commit_age > self.options.stale_threshold_days {
259 return Ok(Some(CleanupCandidate {
260 branch_name: branch_name.to_string(),
261 entry_id,
262 stack_id,
263 is_merged: false,
264 has_remote: _has_remote,
265 is_current: _is_current,
266 reason: CleanupReason::Stale,
267 safety_info: format!("No activity for {last_commit_age} days"),
268 }));
269 }
270
271 Ok(None)
272 }
273
274 fn check_orphaned_branch(
276 &self,
277 _branch_name: &str,
278 _has_remote: bool,
279 _is_current: bool,
280 ) -> Result<Option<CleanupCandidate>> {
281 Ok(None)
284 }
285
286 pub fn perform_cleanup(&mut self, candidates: &[CleanupCandidate]) -> Result<CleanupResult> {
288 let mut result = CleanupResult {
289 cleaned_branches: Vec::new(),
290 failed_branches: Vec::new(),
291 skipped_branches: Vec::new(),
292 total_candidates: candidates.len(),
293 };
294
295 if candidates.is_empty() {
296 info!("No cleanup candidates found");
297 return Ok(result);
298 }
299
300 info!("Processing {} cleanup candidates", candidates.len());
301
302 for candidate in candidates {
303 match self.cleanup_single_branch(candidate) {
304 Ok(true) => {
305 result.cleaned_branches.push(candidate.branch_name.clone());
306 info!("✅ Cleaned up branch: {}", candidate.branch_name);
307 }
308 Ok(false) => {
309 result.skipped_branches.push((
310 candidate.branch_name.clone(),
311 "Skipped by user or safety check".to_string(),
312 ));
313 debug!("⏭️ Skipped branch: {}", candidate.branch_name);
314 }
315 Err(e) => {
316 result
317 .failed_branches
318 .push((candidate.branch_name.clone(), e.to_string()));
319 warn!(
320 "❌ Failed to clean up branch {}: {}",
321 candidate.branch_name, e
322 );
323 }
324 }
325 }
326
327 Ok(result)
328 }
329
330 fn cleanup_single_branch(&mut self, candidate: &CleanupCandidate) -> Result<bool> {
332 debug!(
333 "Cleaning up branch: {} ({:?})",
334 candidate.branch_name, candidate.reason
335 );
336
337 if candidate.is_current {
339 return Ok(false);
340 }
341
342 if self.options.dry_run {
344 info!("DRY RUN: Would delete branch '{}'", candidate.branch_name);
345 return Ok(true);
346 }
347
348 match candidate.reason {
350 CleanupReason::FullyMerged | CleanupReason::StackEntryMerged => {
351 self.git_repo.delete_branch(&candidate.branch_name)?;
353 }
354 CleanupReason::Stale | CleanupReason::Orphaned => {
355 self.git_repo.delete_branch_unsafe(&candidate.branch_name)?;
357 }
358 }
359
360 if let (Some(stack_id), Some(entry_id)) = (candidate.stack_id, candidate.entry_id) {
362 self.remove_entry_from_stack(stack_id, entry_id)?;
363 }
364
365 Ok(true)
366 }
367
368 fn remove_entry_from_stack(
370 &mut self,
371 stack_id: uuid::Uuid,
372 entry_id: uuid::Uuid,
373 ) -> Result<()> {
374 debug!("Removing entry {} from stack {}", entry_id, stack_id);
375
376 if let Some(stack) = self.stack_manager.get_stack_mut(&stack_id) {
377 stack.entries.retain(|entry| entry.id != entry_id);
378
379 if stack.entries.is_empty() {
381 info!("Stack '{}' is now empty after cleanup", stack.name);
382 }
383 }
384
385 Ok(())
386 }
387
388 fn is_branch_merged_to_base(&self, branch_name: &str, base_branch: &str) -> Result<bool> {
390 match self.git_repo.get_commits_between(base_branch, branch_name) {
392 Ok(commits) => Ok(commits.is_empty()),
393 Err(_) => {
394 Ok(false)
396 }
397 }
398 }
399
400 fn is_stack_entry_merged(&self, stack_id: uuid::Uuid, entry_id: uuid::Uuid) -> Result<bool> {
402 if let Some(stack) = self.stack_manager.get_stack(&stack_id) {
404 if let Some(entry) = stack.entries.iter().find(|e| e.id == entry_id) {
405 return Ok(entry.is_submitted);
408 }
409 }
410 Ok(false)
411 }
412
413 fn get_base_branch_for_branch(
415 &self,
416 _branch_name: &str,
417 stack_id: Option<uuid::Uuid>,
418 stacks: &[Stack],
419 ) -> Result<String> {
420 if let Some(stack_id) = stack_id {
421 if let Some(stack) = stacks.iter().find(|s| s.id == stack_id) {
422 return Ok(stack.base_branch.clone());
423 }
424 }
425
426 let main_branches = ["main", "master", "develop"];
428 for branch in &main_branches {
429 if self.git_repo.branch_exists(branch) {
430 return Ok(branch.to_string());
431 }
432 }
433
434 Err(CascadeError::config(
435 "Could not determine base branch".to_string(),
436 ))
437 }
438
439 fn is_protected_branch(&self, branch_name: &str) -> bool {
441 let protected_branches = [
442 "main",
443 "master",
444 "develop",
445 "development",
446 "staging",
447 "production",
448 "release",
449 ];
450
451 protected_branches.contains(&branch_name)
452 }
453
454 fn get_branch_last_commit_age_days(&self, branch_name: &str) -> Result<u32> {
456 let commit_hash = self.git_repo.get_branch_commit_hash(branch_name)?;
457 let commit = self.git_repo.get_commit(&commit_hash)?;
458
459 let now = std::time::SystemTime::now();
460 let commit_time =
461 std::time::UNIX_EPOCH + std::time::Duration::from_secs(commit.time().seconds() as u64);
462
463 let age = now
464 .duration_since(commit_time)
465 .unwrap_or_else(|_| std::time::Duration::from_secs(0));
466
467 Ok((age.as_secs() / 86400) as u32) }
469
470 pub fn get_cleanup_stats(&self) -> Result<CleanupStats> {
472 let candidates = self.find_cleanup_candidates()?;
473
474 let mut stats = CleanupStats {
475 total_branches: self.git_repo.list_branches()?.len(),
476 fully_merged: 0,
477 stack_entry_merged: 0,
478 stale: 0,
479 orphaned: 0,
480 protected: 0,
481 };
482
483 for candidate in &candidates {
484 match candidate.reason {
485 CleanupReason::FullyMerged => stats.fully_merged += 1,
486 CleanupReason::StackEntryMerged => stats.stack_entry_merged += 1,
487 CleanupReason::Stale => stats.stale += 1,
488 CleanupReason::Orphaned => stats.orphaned += 1,
489 }
490 }
491
492 let all_branches = self.git_repo.list_branches()?;
494 stats.protected = all_branches
495 .iter()
496 .filter(|branch| self.is_protected_branch(branch))
497 .count();
498
499 Ok(stats)
500 }
501}
502
503#[derive(Debug, Clone)]
505pub struct CleanupStats {
506 pub total_branches: usize,
507 pub fully_merged: usize,
508 pub stack_entry_merged: usize,
509 pub stale: usize,
510 pub orphaned: usize,
511 pub protected: usize,
512}
513
514impl CleanupStats {
515 pub fn cleanup_candidates(&self) -> usize {
516 self.fully_merged + self.stack_entry_merged + self.stale + self.orphaned
517 }
518}
519
520#[cfg(test)]
521mod tests {
522 use super::*;
523 use std::process::Command;
524 use tempfile::TempDir;
525
526 fn create_test_repo() -> (TempDir, std::path::PathBuf) {
527 let temp_dir = TempDir::new().unwrap();
528 let repo_path = temp_dir.path().to_path_buf();
529
530 Command::new("git")
532 .args(["init"])
533 .current_dir(&repo_path)
534 .output()
535 .unwrap();
536
537 Command::new("git")
538 .args(["config", "user.name", "Test"])
539 .current_dir(&repo_path)
540 .output()
541 .unwrap();
542
543 Command::new("git")
544 .args(["config", "user.email", "test@example.com"])
545 .current_dir(&repo_path)
546 .output()
547 .unwrap();
548
549 (temp_dir, repo_path)
550 }
551
552 #[test]
553 fn test_cleanup_reason_serialization() {
554 let reason = CleanupReason::FullyMerged;
555 let serialized = serde_json::to_string(&reason).unwrap();
556 let deserialized: CleanupReason = serde_json::from_str(&serialized).unwrap();
557 assert_eq!(reason, deserialized);
558 }
559
560 #[test]
561 fn test_cleanup_options_default() {
562 let options = CleanupOptions::default();
563 assert!(!options.dry_run);
564 assert!(!options.force);
565 assert!(!options.include_stale);
566 assert_eq!(options.stale_threshold_days, 30);
567 }
568
569 #[test]
570 fn test_protected_branches() {
571 let (_temp_dir, repo_path) = create_test_repo();
572 let git_repo = crate::git::GitRepository::open(&repo_path).unwrap();
573 let stack_manager = crate::stack::StackManager::new(&repo_path).unwrap();
574 let options = CleanupOptions::default();
575
576 let cleanup_manager = CleanupManager::new(stack_manager, git_repo, options);
577
578 assert!(cleanup_manager.is_protected_branch("main"));
579 assert!(cleanup_manager.is_protected_branch("master"));
580 assert!(cleanup_manager.is_protected_branch("develop"));
581 assert!(!cleanup_manager.is_protected_branch("feature-branch"));
582 }
583
584 #[test]
585 fn test_cleanup_stats() {
586 let stats = CleanupStats {
587 total_branches: 10,
588 fully_merged: 3,
589 stack_entry_merged: 2,
590 stale: 1,
591 orphaned: 0,
592 protected: 4,
593 };
594
595 assert_eq!(stats.cleanup_candidates(), 6);
596 }
597}