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        // 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        // Never clean up active stack branches - they're in use!
222        if stack_id.is_some() {
223            return Ok(None);
224        }
225
226        // Check for stale branches (if enabled) - only for NON-stack branches
227        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        // Check for orphaned branches (if enabled)
236        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    /// Check if a branch is stale based on last activity
248    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    /// Check if a branch is orphaned (not part of any stack)
275    fn check_orphaned_branch(
276        &self,
277        _branch_name: &str,
278        _has_remote: bool,
279        _is_current: bool,
280    ) -> Result<Option<CleanupCandidate>> {
281        // For now, we'll be conservative and not automatically clean up non-stack branches
282        // Users can explicitly enable this with --cleanup-non-stack
283        Ok(None)
284    }
285
286    /// Perform cleanup based on the candidates found
287    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    /// Clean up a single branch
331    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        // Safety check: don't delete current branch
338        if candidate.is_current {
339            return Ok(false);
340        }
341
342        // In dry-run mode, just report what would be done
343        if self.options.dry_run {
344            info!("DRY RUN: Would delete branch '{}'", candidate.branch_name);
345            return Ok(true);
346        }
347
348        // Delete the branch
349        match candidate.reason {
350            CleanupReason::FullyMerged | CleanupReason::StackEntryMerged => {
351                // Safe to delete merged branches
352                self.git_repo.delete_branch(&candidate.branch_name)?;
353            }
354            CleanupReason::Stale | CleanupReason::Orphaned => {
355                // Use unsafe delete for stale/orphaned branches (they might not be merged)
356                self.git_repo.delete_branch_unsafe(&candidate.branch_name)?;
357            }
358        }
359
360        // Remove from stack metadata if it's a stack branch
361        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    /// Remove an entry from stack metadata
369    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 the stack is now empty, we might want to remove it
380            if stack.entries.is_empty() {
381                info!("Stack '{}' is now empty after cleanup", stack.name);
382            }
383        }
384
385        Ok(())
386    }
387
388    /// Check if a branch is merged to its base branch
389    fn is_branch_merged_to_base(&self, branch_name: &str, base_branch: &str) -> Result<bool> {
390        // Get the commits between base and the branch
391        match self.git_repo.get_commits_between(base_branch, branch_name) {
392            Ok(commits) => Ok(commits.is_empty()),
393            Err(_) => {
394                // If we can't determine, assume not merged for safety
395                Ok(false)
396            }
397        }
398    }
399
400    /// Check if a stack entry was merged via PR
401    fn is_stack_entry_merged(&self, stack_id: uuid::Uuid, entry_id: uuid::Uuid) -> Result<bool> {
402        // Check if the entry is marked as submitted and has been merged in the stack metadata
403        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                // For now, consider it merged if it's submitted
406                // In a full implementation, we'd check the PR status via Bitbucket API
407                return Ok(entry.is_submitted);
408            }
409        }
410        Ok(false)
411    }
412
413    /// Get the base branch for a given branch
414    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        // Default to main/master if not in a stack
427        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    /// Check if a branch is protected (shouldn't be deleted)
440    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    /// Get the age of the last commit on a branch in days
455    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) // Convert to days
468    }
469
470    /// Get cleanup statistics
471    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        // Count protected branches
493        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/// Statistics about cleanup candidates
504#[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        // Initialize git repository
531        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}