cascade_cli/stack/
cleanup.rs

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/// Information about a branch that can be cleaned up
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct CleanupCandidate {
11    /// Branch name
12    pub branch_name: String,
13    /// Stack entry ID if this branch is part of a stack
14    pub entry_id: Option<uuid::Uuid>,
15    /// Stack ID if this branch is part of a stack
16    pub stack_id: Option<uuid::Uuid>,
17    /// Whether the branch is merged to the base branch
18    pub is_merged: bool,
19    /// Whether the branch has a remote tracking branch
20    pub has_remote: bool,
21    /// Whether the branch is the current branch
22    pub is_current: bool,
23    /// Reason this branch is a cleanup candidate
24    pub reason: CleanupReason,
25    /// Additional safety information
26    pub safety_info: String,
27}
28
29impl CleanupCandidate {
30    /// Get a human-readable string for the cleanup reason
31    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/// Reason why a branch is a cleanup candidate
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
43pub enum CleanupReason {
44    /// Branch is fully merged to base branch
45    FullyMerged,
46    /// Stack entry was merged via PR
47    StackEntryMerged,
48    /// Branch is stale (old and no recent activity)
49    Stale,
50    /// Branch is a duplicate or orphaned branch
51    Orphaned,
52}
53
54/// Options for cleanup operations
55#[derive(Debug, Clone)]
56pub struct CleanupOptions {
57    /// Whether to run in dry-run mode (don't actually delete)
58    pub dry_run: bool,
59    /// Whether to skip confirmation prompts
60    pub force: bool,
61    /// Whether to include stale branches in cleanup
62    pub include_stale: bool,
63    /// Whether to cleanup remote tracking branches
64    pub cleanup_remote: bool,
65    /// Age threshold for stale branches (days)
66    pub stale_threshold_days: u32,
67    /// Whether to cleanup branches not in any stack
68    pub cleanup_non_stack: bool,
69}
70
71/// Result of cleanup operation
72#[derive(Debug, Clone)]
73pub struct CleanupResult {
74    /// Branches that were successfully cleaned up
75    pub cleaned_branches: Vec<String>,
76    /// Branches that failed to be cleaned up
77    pub failed_branches: Vec<(String, String)>, // (branch_name, error)
78    /// Branches that were skipped
79    pub skipped_branches: Vec<(String, String)>, // (branch_name, reason)
80    /// Total number of candidates found
81    pub total_candidates: usize,
82}
83
84/// Manages cleanup operations for merged and stale branches
85pub 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    /// Create a new cleanup manager
106    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    /// Find all branches that are candidates for cleanup
119    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        // Get all stacks to identify stack branches
127        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            // Skip the current branch for safety
140            if current_branch.as_ref() == Some(branch_name) {
141                continue;
142            }
143
144            // Skip protected branches
145            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            // Check if this branch is part of a stack
153            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            // Check different cleanup reasons
161            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    /// Evaluate a single branch for cleanup
178    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        // First check if branch is fully merged
188        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        // Never clean up active stack branches - they're in use!
204        if stack_id.is_some() {
205            return Ok(None);
206        }
207
208        // Check for stale branches (if enabled) - only for NON-stack branches
209        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        // Check for orphaned branches (if enabled)
218        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    /// Check if a branch is stale based on last activity
230    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    /// Check if a branch is orphaned (not part of any stack)
257    fn check_orphaned_branch(
258        &self,
259        _branch_name: &str,
260        _has_remote: bool,
261        _is_current: bool,
262    ) -> Result<Option<CleanupCandidate>> {
263        // For now, we'll be conservative and not automatically clean up non-stack branches
264        // Users can explicitly enable this with --cleanup-non-stack
265        Ok(None)
266    }
267
268    /// Perform cleanup based on the candidates found
269    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    /// Clean up a single branch
313    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        // Safety check: don't delete current branch
320        if candidate.is_current {
321            return Ok(false);
322        }
323
324        // In dry-run mode, just report what would be done
325        if self.options.dry_run {
326            info!("DRY RUN: Would delete branch '{}'", candidate.branch_name);
327            return Ok(true);
328        }
329
330        // Delete the branch
331        match candidate.reason {
332            CleanupReason::FullyMerged | CleanupReason::StackEntryMerged => {
333                // Safe to delete merged branches
334                self.git_repo.delete_branch(&candidate.branch_name)?;
335            }
336            CleanupReason::Stale | CleanupReason::Orphaned => {
337                // Run through standard safety checks even for stale/orphaned branches
338                // This prevents accidental deletion when unpushed commits are present.
339                self.git_repo.delete_branch(&candidate.branch_name)?;
340            }
341        }
342
343        // Remove from stack metadata if it's a stack branch
344        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    /// Remove an entry from stack metadata
352    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    /// Check if a branch is merged to its base branch
380    fn is_branch_merged_to_base(&self, branch_name: &str, base_branch: &str) -> Result<bool> {
381        // Get the commits between base and the branch
382        match self.git_repo.get_commits_between(base_branch, branch_name) {
383            Ok(commits) => Ok(commits.is_empty()),
384            Err(_) => {
385                // If we can't determine, assume not merged for safety
386                Ok(false)
387            }
388        }
389    }
390
391    /// Get the base branch for a given branch
392    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        // Default to main/master if not in a stack
405        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    /// Check if a branch is protected (shouldn't be deleted)
418    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    /// Get the age of the last commit on a branch in days
433    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) // Convert to days
446    }
447
448    /// Get cleanup statistics
449    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        // Count protected branches
471        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/// Statistics about cleanup candidates
482#[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        // Initialize git repository
509        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}