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