use crate::errors::{CascadeError, Result};
use crate::git::GitRepository;
use crate::stack::{Stack, StackManager};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use tracing::{debug, info, warn};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CleanupCandidate {
pub branch_name: String,
pub entry_id: Option<uuid::Uuid>,
pub stack_id: Option<uuid::Uuid>,
pub is_merged: bool,
pub has_remote: bool,
pub is_current: bool,
pub reason: CleanupReason,
pub safety_info: String,
}
impl CleanupCandidate {
pub fn reason_to_string(&self) -> &str {
match self.reason {
CleanupReason::FullyMerged => "fully merged",
CleanupReason::StackEntryMerged => "PR merged",
CleanupReason::Stale => "stale",
CleanupReason::Orphaned => "orphaned",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum CleanupReason {
FullyMerged,
StackEntryMerged,
Stale,
Orphaned,
}
#[derive(Debug, Clone)]
pub struct CleanupOptions {
pub dry_run: bool,
pub force: bool,
pub include_stale: bool,
pub cleanup_remote: bool,
pub stale_threshold_days: u32,
pub cleanup_non_stack: bool,
}
#[derive(Debug, Clone)]
pub struct CleanupResult {
pub cleaned_branches: Vec<String>,
pub failed_branches: Vec<(String, String)>, pub skipped_branches: Vec<(String, String)>, pub total_candidates: usize,
}
pub struct CleanupManager {
stack_manager: StackManager,
git_repo: GitRepository,
options: CleanupOptions,
}
impl Default for CleanupOptions {
fn default() -> Self {
Self {
dry_run: false,
force: false,
include_stale: false,
cleanup_remote: false,
stale_threshold_days: 30,
cleanup_non_stack: false,
}
}
}
impl CleanupManager {
pub fn new(
stack_manager: StackManager,
git_repo: GitRepository,
options: CleanupOptions,
) -> Self {
Self {
stack_manager,
git_repo,
options,
}
}
pub fn find_cleanup_candidates(&self) -> Result<Vec<CleanupCandidate>> {
debug!("Scanning for cleanup candidates...");
let mut candidates = Vec::new();
let all_branches = self.git_repo.list_branches()?;
let current_branch = self.git_repo.get_current_branch().ok();
let stacks = self.stack_manager.get_all_stacks_objects()?;
let mut stack_branches = HashSet::new();
let mut stack_branch_to_entry = std::collections::HashMap::new();
for stack in &stacks {
for entry in &stack.entries {
stack_branches.insert(entry.branch.clone());
stack_branch_to_entry.insert(entry.branch.clone(), (stack.id, entry.id));
}
}
for branch_name in &all_branches {
if current_branch.as_ref() == Some(branch_name) {
continue;
}
if self.is_protected_branch(branch_name) {
continue;
}
let is_current = current_branch.as_ref() == Some(branch_name);
let has_remote = self.git_repo.get_upstream_branch(branch_name)?.is_some();
let (stack_id, entry_id) =
if let Some((stack_id, entry_id)) = stack_branch_to_entry.get(branch_name) {
(Some(*stack_id), Some(*entry_id))
} else {
(None, None)
};
if let Some(candidate) = self.evaluate_branch_for_cleanup(
branch_name,
stack_id,
entry_id,
is_current,
has_remote,
&stacks,
)? {
candidates.push(candidate);
}
}
debug!("Found {} cleanup candidates", candidates.len());
Ok(candidates)
}
fn evaluate_branch_for_cleanup(
&self,
branch_name: &str,
stack_id: Option<uuid::Uuid>,
entry_id: Option<uuid::Uuid>,
is_current: bool,
has_remote: bool,
stacks: &[Stack],
) -> Result<Option<CleanupCandidate>> {
if let Ok(base_branch) = self.get_base_branch_for_branch(branch_name, stack_id, stacks) {
if self.is_branch_merged_to_base(branch_name, &base_branch)? {
return Ok(Some(CleanupCandidate {
branch_name: branch_name.to_string(),
entry_id,
stack_id,
is_merged: true,
has_remote,
is_current,
reason: CleanupReason::FullyMerged,
safety_info: format!("Branch fully merged to '{base_branch}'"),
}));
}
}
if stack_id.is_some() {
return Ok(None);
}
if self.options.include_stale {
if let Some(candidate) =
self.check_stale_branch(branch_name, stack_id, entry_id, has_remote, is_current)?
{
return Ok(Some(candidate));
}
}
if self.options.cleanup_non_stack {
if let Some(candidate) =
self.check_orphaned_branch(branch_name, has_remote, is_current)?
{
return Ok(Some(candidate));
}
}
Ok(None)
}
fn check_stale_branch(
&self,
branch_name: &str,
stack_id: Option<uuid::Uuid>,
entry_id: Option<uuid::Uuid>,
_has_remote: bool,
_is_current: bool,
) -> Result<Option<CleanupCandidate>> {
let last_commit_age = self.get_branch_last_commit_age_days(branch_name)?;
if last_commit_age > self.options.stale_threshold_days {
return Ok(Some(CleanupCandidate {
branch_name: branch_name.to_string(),
entry_id,
stack_id,
is_merged: false,
has_remote: _has_remote,
is_current: _is_current,
reason: CleanupReason::Stale,
safety_info: format!("No activity for {last_commit_age} days"),
}));
}
Ok(None)
}
fn check_orphaned_branch(
&self,
_branch_name: &str,
_has_remote: bool,
_is_current: bool,
) -> Result<Option<CleanupCandidate>> {
Ok(None)
}
pub fn perform_cleanup(&mut self, candidates: &[CleanupCandidate]) -> Result<CleanupResult> {
let mut result = CleanupResult {
cleaned_branches: Vec::new(),
failed_branches: Vec::new(),
skipped_branches: Vec::new(),
total_candidates: candidates.len(),
};
if candidates.is_empty() {
info!("No cleanup candidates found");
return Ok(result);
}
info!("Processing {} cleanup candidates", candidates.len());
for candidate in candidates {
match self.cleanup_single_branch(candidate) {
Ok(true) => {
result.cleaned_branches.push(candidate.branch_name.clone());
info!("✅ Cleaned up branch: {}", candidate.branch_name);
}
Ok(false) => {
result.skipped_branches.push((
candidate.branch_name.clone(),
"Skipped by user or safety check".to_string(),
));
debug!("⏭️ Skipped branch: {}", candidate.branch_name);
}
Err(e) => {
result
.failed_branches
.push((candidate.branch_name.clone(), e.to_string()));
warn!(
"❌ Failed to clean up branch {}: {}",
candidate.branch_name, e
);
}
}
}
Ok(result)
}
fn cleanup_single_branch(&mut self, candidate: &CleanupCandidate) -> Result<bool> {
debug!(
"Cleaning up branch: {} ({:?})",
candidate.branch_name, candidate.reason
);
if candidate.is_current {
return Ok(false);
}
if self.options.dry_run {
info!("DRY RUN: Would delete branch '{}'", candidate.branch_name);
return Ok(true);
}
match candidate.reason {
CleanupReason::FullyMerged | CleanupReason::StackEntryMerged => {
self.git_repo.delete_branch(&candidate.branch_name)?;
}
CleanupReason::Stale | CleanupReason::Orphaned => {
self.git_repo.delete_branch(&candidate.branch_name)?;
}
}
if let (Some(stack_id), Some(entry_id)) = (candidate.stack_id, candidate.entry_id) {
self.remove_entry_from_stack(stack_id, entry_id)?;
}
Ok(true)
}
fn remove_entry_from_stack(
&mut self,
stack_id: uuid::Uuid,
entry_id: uuid::Uuid,
) -> Result<()> {
debug!("Removing entry {} from stack {}", entry_id, stack_id);
match self.stack_manager.remove_stack_entry(&stack_id, &entry_id) {
Ok(Some(_entry)) => {
if let Some(stack) = self.stack_manager.get_stack(&stack_id) {
if stack.entries.is_empty() {
info!("Stack '{}' is now empty after cleanup", stack.name);
}
}
Ok(())
}
Ok(None) => {
warn!(
"Skip removing entry {} from stack {} (entry not found or still has dependents)",
entry_id, stack_id
);
Ok(())
}
Err(e) => Err(e),
}
}
fn is_branch_merged_to_base(&self, branch_name: &str, base_branch: &str) -> Result<bool> {
match self.git_repo.get_commits_between(base_branch, branch_name) {
Ok(commits) => Ok(commits.is_empty()),
Err(_) => {
Ok(false)
}
}
}
fn get_base_branch_for_branch(
&self,
_branch_name: &str,
stack_id: Option<uuid::Uuid>,
stacks: &[Stack],
) -> Result<String> {
if let Some(stack_id) = stack_id {
if let Some(stack) = stacks.iter().find(|s| s.id == stack_id) {
return Ok(stack.base_branch.clone());
}
}
let main_branches = ["main", "master", "develop"];
for branch in &main_branches {
if self.git_repo.branch_exists(branch) {
return Ok(branch.to_string());
}
}
Err(CascadeError::config(
"Could not determine base branch".to_string(),
))
}
fn is_protected_branch(&self, branch_name: &str) -> bool {
let protected_branches = [
"main",
"master",
"develop",
"development",
"staging",
"production",
"release",
];
protected_branches.contains(&branch_name)
}
fn get_branch_last_commit_age_days(&self, branch_name: &str) -> Result<u32> {
let commit_hash = self.git_repo.get_branch_commit_hash(branch_name)?;
let commit = self.git_repo.get_commit(&commit_hash)?;
let now = std::time::SystemTime::now();
let commit_time =
std::time::UNIX_EPOCH + std::time::Duration::from_secs(commit.time().seconds() as u64);
let age = now
.duration_since(commit_time)
.unwrap_or_else(|_| std::time::Duration::from_secs(0));
Ok((age.as_secs() / 86400) as u32) }
pub fn get_cleanup_stats(&self) -> Result<CleanupStats> {
let candidates = self.find_cleanup_candidates()?;
let mut stats = CleanupStats {
total_branches: self.git_repo.list_branches()?.len(),
fully_merged: 0,
stack_entry_merged: 0,
stale: 0,
orphaned: 0,
protected: 0,
};
for candidate in &candidates {
match candidate.reason {
CleanupReason::FullyMerged => stats.fully_merged += 1,
CleanupReason::StackEntryMerged => stats.stack_entry_merged += 1,
CleanupReason::Stale => stats.stale += 1,
CleanupReason::Orphaned => stats.orphaned += 1,
}
}
let all_branches = self.git_repo.list_branches()?;
stats.protected = all_branches
.iter()
.filter(|branch| self.is_protected_branch(branch))
.count();
Ok(stats)
}
}
#[derive(Debug, Clone)]
pub struct CleanupStats {
pub total_branches: usize,
pub fully_merged: usize,
pub stack_entry_merged: usize,
pub stale: usize,
pub orphaned: usize,
pub protected: usize,
}
impl CleanupStats {
pub fn cleanup_candidates(&self) -> usize {
self.fully_merged + self.stack_entry_merged + self.stale + self.orphaned
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
use tempfile::TempDir;
fn create_test_repo() -> (TempDir, std::path::PathBuf) {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
Command::new("git")
.args(["init"])
.current_dir(&repo_path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(&repo_path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(&repo_path)
.output()
.unwrap();
(temp_dir, repo_path)
}
#[test]
fn test_cleanup_reason_serialization() {
let reason = CleanupReason::FullyMerged;
let serialized = serde_json::to_string(&reason).unwrap();
let deserialized: CleanupReason = serde_json::from_str(&serialized).unwrap();
assert_eq!(reason, deserialized);
}
#[test]
fn test_cleanup_options_default() {
let options = CleanupOptions::default();
assert!(!options.dry_run);
assert!(!options.force);
assert!(!options.include_stale);
assert_eq!(options.stale_threshold_days, 30);
}
#[test]
fn test_protected_branches() {
let (_temp_dir, repo_path) = create_test_repo();
let git_repo = crate::git::GitRepository::open(&repo_path).unwrap();
let stack_manager = crate::stack::StackManager::new(&repo_path).unwrap();
let options = CleanupOptions::default();
let cleanup_manager = CleanupManager::new(stack_manager, git_repo, options);
assert!(cleanup_manager.is_protected_branch("main"));
assert!(cleanup_manager.is_protected_branch("master"));
assert!(cleanup_manager.is_protected_branch("develop"));
assert!(!cleanup_manager.is_protected_branch("feature-branch"));
}
#[test]
fn test_cleanup_stats() {
let stats = CleanupStats {
total_branches: 10,
fully_merged: 3,
stack_entry_merged: 2,
stale: 1,
orphaned: 0,
protected: 4,
};
assert_eq!(stats.cleanup_candidates(), 6);
}
}