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        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        // 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        info!("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        // Check if this is a stack entry that was merged via PR
204        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, // Not git-merged, but PR-merged
212                        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        // Check for stale branches (if enabled)
222        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        // Check for orphaned branches (if enabled)
231        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    /// Check if a branch is stale based on last activity
243    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    /// Check if a branch is orphaned (not part of any stack)
270    fn check_orphaned_branch(
271        &self,
272        _branch_name: &str,
273        _has_remote: bool,
274        _is_current: bool,
275    ) -> Result<Option<CleanupCandidate>> {
276        // For now, we'll be conservative and not automatically clean up non-stack branches
277        // Users can explicitly enable this with --cleanup-non-stack
278        Ok(None)
279    }
280
281    /// Perform cleanup based on the candidates found
282    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    /// Clean up a single branch
326    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        // Safety check: don't delete current branch
333        if candidate.is_current {
334            return Ok(false);
335        }
336
337        // In dry-run mode, just report what would be done
338        if self.options.dry_run {
339            info!("DRY RUN: Would delete branch '{}'", candidate.branch_name);
340            return Ok(true);
341        }
342
343        // Delete the branch
344        match candidate.reason {
345            CleanupReason::FullyMerged | CleanupReason::StackEntryMerged => {
346                // Safe to delete merged branches
347                self.git_repo.delete_branch(&candidate.branch_name)?;
348            }
349            CleanupReason::Stale | CleanupReason::Orphaned => {
350                // Use unsafe delete for stale/orphaned branches (they might not be merged)
351                self.git_repo.delete_branch_unsafe(&candidate.branch_name)?;
352            }
353        }
354
355        // Remove from stack metadata if it's a stack branch
356        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    /// Remove an entry from stack metadata
364    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 the stack is now empty, we might want to remove it
375            if stack.entries.is_empty() {
376                info!("Stack '{}' is now empty after cleanup", stack.name);
377            }
378        }
379
380        Ok(())
381    }
382
383    /// Check if a branch is merged to its base branch
384    fn is_branch_merged_to_base(&self, branch_name: &str, base_branch: &str) -> Result<bool> {
385        // Get the commits between base and the branch
386        match self.git_repo.get_commits_between(base_branch, branch_name) {
387            Ok(commits) => Ok(commits.is_empty()),
388            Err(_) => {
389                // If we can't determine, assume not merged for safety
390                Ok(false)
391            }
392        }
393    }
394
395    /// Check if a stack entry was merged via PR
396    fn is_stack_entry_merged(&self, stack_id: uuid::Uuid, entry_id: uuid::Uuid) -> Result<bool> {
397        // Check if the entry is marked as submitted and has been merged in the stack metadata
398        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                // For now, consider it merged if it's submitted
401                // In a full implementation, we'd check the PR status via Bitbucket API
402                return Ok(entry.is_submitted);
403            }
404        }
405        Ok(false)
406    }
407
408    /// Get the base branch for a given branch
409    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        // Default to main/master if not in a stack
422        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    /// Check if a branch is protected (shouldn't be deleted)
435    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    /// Get the age of the last commit on a branch in days
450    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) // Convert to days
463    }
464
465    /// Get cleanup statistics
466    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        // Count protected branches
488        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/// Statistics about cleanup candidates
499#[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        // Initialize git repository
526        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}