Skip to main content

vibe_workspace/worktree/
cleanup.rs

1//! Cleanup strategies and safety mechanisms for worktree management
2//!
3//! This module provides safe cleanup operations for merged worktrees with
4//! multiple validation layers and recovery options to prevent data loss.
5
6use anyhow::{bail, Result};
7use colored::*;
8use serde::{Deserialize, Serialize};
9use std::path::{Path, PathBuf};
10use std::time::Duration;
11use tokio::process::Command;
12use tracing::{info, warn};
13
14use crate::worktree::config::WorktreeConfig;
15use crate::worktree::operations::{RemoveOptions, WorktreeOperations};
16use crate::worktree::status::WorktreeInfo;
17
18/// Different strategies for cleaning up worktrees
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub enum CleanupStrategy {
21    /// Simply remove the worktree (safest, requires manual verification)
22    Discard,
23    /// Merge worktree changes into the feature branch before removal
24    MergeToFeature,
25    /// Push worktree branch to origin before removal (backup)
26    BackupToOrigin,
27    /// Stash changes and remove worktree
28    StashAndDiscard,
29}
30
31/// Options for cleanup operations
32#[derive(Debug, Clone)]
33pub struct CleanupOptions {
34    /// Strategy to use for cleanup
35    pub strategy: CleanupStrategy,
36
37    /// Minimum age threshold for cleanup eligibility
38    pub min_age_hours: Option<u64>,
39
40    /// Force cleanup even with safety violations
41    pub force: bool,
42
43    /// Show what would be done without executing
44    pub dry_run: bool,
45
46    /// Skip interactive confirmations
47    pub auto_confirm: bool,
48
49    /// Only clean worktrees with specific branch prefix
50    pub branch_prefix_filter: Option<String>,
51
52    /// Only clean worktrees that are confirmed merged
53    pub merged_only: bool,
54
55    /// Minimum merge confidence required (0.0-1.0)
56    pub min_merge_confidence: f32,
57}
58
59/// Result of cleanup operations
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct CleanupReport {
62    /// Total worktrees evaluated
63    pub total_evaluated: usize,
64
65    /// Number of worktrees successfully cleaned
66    pub cleaned_count: usize,
67
68    /// Number of worktrees skipped due to safety checks
69    pub skipped_count: usize,
70
71    /// Number of worktrees that failed during cleanup
72    pub failed_count: usize,
73
74    /// Detailed results for each worktree
75    pub worktree_results: Vec<WorktreeCleanupResult>,
76
77    /// Overall cleanup strategy used
78    pub strategy_used: CleanupStrategy,
79
80    /// Whether this was a dry run
81    pub was_dry_run: bool,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct WorktreeCleanupResult {
86    /// Path to the worktree
87    pub path: PathBuf,
88
89    /// Branch name
90    pub branch: String,
91
92    /// Cleanup action taken
93    pub action: CleanupAction,
94
95    /// Reason for the action (or why it was skipped)
96    pub reason: String,
97
98    /// Any error that occurred
99    pub error: Option<String>,
100
101    /// Safety violations that were ignored (if force was used)
102    pub safety_violations: Vec<SafetyViolation>,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106pub enum CleanupAction {
107    Cleaned,
108    Skipped,
109    Failed,
110    StashCreated,
111    MergedToFeature,
112    BackedUpToOrigin,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct SafetyViolation {
117    pub violation_type: SafetyViolationType,
118    pub description: String,
119    pub severity: ViolationSeverity,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123pub enum SafetyViolationType {
124    UncommittedChanges,
125    UnpushedCommits,
126    BranchTooNew,
127    NoRemoteTracking,
128    LowMergeConfidence,
129    RemoteBranchMissing,
130    WorktreeInUse,
131}
132
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134pub enum ViolationSeverity {
135    Warning,  // Can be overridden with --force
136    Critical, // Should never be overridden
137}
138
139/// Main cleanup orchestrator
140pub struct WorktreeCleanup {
141    config: WorktreeConfig,
142    operations: WorktreeOperations,
143}
144
145impl WorktreeCleanup {
146    pub fn new(config: WorktreeConfig, operations: WorktreeOperations) -> Self {
147        Self { config, operations }
148    }
149
150    /// Execute cleanup operation on all eligible worktrees
151    pub async fn cleanup_worktrees(&self, options: CleanupOptions) -> Result<CleanupReport> {
152        info!(
153            "Starting worktree cleanup with strategy: {:?}",
154            options.strategy
155        );
156
157        let all_worktrees = self.operations.list_worktrees().await?;
158        let mut report = CleanupReport {
159            total_evaluated: all_worktrees.len(),
160            cleaned_count: 0,
161            skipped_count: 0,
162            failed_count: 0,
163            worktree_results: Vec::new(),
164            strategy_used: options.strategy.clone(),
165            was_dry_run: options.dry_run,
166        };
167
168        for worktree in all_worktrees {
169            let result = self
170                .evaluate_and_cleanup_worktree(&worktree, &options)
171                .await;
172
173            match result {
174                Ok(cleanup_result) => {
175                    match cleanup_result.action {
176                        CleanupAction::Cleaned
177                        | CleanupAction::MergedToFeature
178                        | CleanupAction::BackedUpToOrigin => {
179                            report.cleaned_count += 1;
180                        }
181                        CleanupAction::Skipped => {
182                            report.skipped_count += 1;
183                        }
184                        CleanupAction::Failed => {
185                            report.failed_count += 1;
186                        }
187                        CleanupAction::StashCreated => {
188                            // Count as cleaned if the worktree was also removed
189                            report.cleaned_count += 1;
190                        }
191                    }
192                    report.worktree_results.push(cleanup_result);
193                }
194                Err(e) => {
195                    warn!(
196                        "Failed to process worktree {}: {}",
197                        worktree.path.display(),
198                        e
199                    );
200                    report.failed_count += 1;
201                    report.worktree_results.push(WorktreeCleanupResult {
202                        path: worktree.path.clone(),
203                        branch: worktree.branch.clone(),
204                        action: CleanupAction::Failed,
205                        reason: "Processing error".to_string(),
206                        error: Some(e.to_string()),
207                        safety_violations: Vec::new(),
208                    });
209                }
210            }
211        }
212
213        info!(
214            "Cleanup complete: {} cleaned, {} skipped, {} failed",
215            report.cleaned_count, report.skipped_count, report.failed_count
216        );
217
218        Ok(report)
219    }
220
221    /// Evaluate and potentially clean up a single worktree
222    async fn evaluate_and_cleanup_worktree(
223        &self,
224        worktree: &WorktreeInfo,
225        options: &CleanupOptions,
226    ) -> Result<WorktreeCleanupResult> {
227        // Skip the main repository worktree
228        if self.is_main_repository_worktree(worktree).await? {
229            return Ok(WorktreeCleanupResult {
230                path: worktree.path.clone(),
231                branch: worktree.branch.clone(),
232                action: CleanupAction::Skipped,
233                reason: "Main repository worktree".to_string(),
234                error: None,
235                safety_violations: Vec::new(),
236            });
237        }
238
239        // Apply filters
240        if !self.matches_filters(worktree, options) {
241            return Ok(WorktreeCleanupResult {
242                path: worktree.path.clone(),
243                branch: worktree.branch.clone(),
244                action: CleanupAction::Skipped,
245                reason: "Does not match cleanup filters".to_string(),
246                error: None,
247                safety_violations: Vec::new(),
248            });
249        }
250
251        // Perform safety checks
252        let safety_violations = self.check_safety_violations(worktree, options).await;
253
254        // Determine if we should proceed
255        let critical_violations: Vec<_> = safety_violations
256            .iter()
257            .filter(|v| v.severity == ViolationSeverity::Critical)
258            .collect();
259
260        if !critical_violations.is_empty() {
261            return Ok(WorktreeCleanupResult {
262                path: worktree.path.clone(),
263                branch: worktree.branch.clone(),
264                action: CleanupAction::Skipped,
265                reason: format!(
266                    "Critical safety violations: {}",
267                    critical_violations
268                        .iter()
269                        .map(|v| v.description.as_str())
270                        .collect::<Vec<_>>()
271                        .join(", ")
272                ),
273                error: None,
274                safety_violations,
275            });
276        }
277
278        let warning_violations: Vec<_> = safety_violations
279            .iter()
280            .filter(|v| v.severity == ViolationSeverity::Warning)
281            .collect();
282
283        if !warning_violations.is_empty() && !options.force {
284            return Ok(WorktreeCleanupResult {
285                path: worktree.path.clone(),
286                branch: worktree.branch.clone(),
287                action: CleanupAction::Skipped,
288                reason: format!(
289                    "Safety violations (use --force to override): {}",
290                    warning_violations
291                        .iter()
292                        .map(|v| v.description.as_str())
293                        .collect::<Vec<_>>()
294                        .join(", ")
295                ),
296                error: None,
297                safety_violations,
298            });
299        }
300
301        // Ask for confirmation if not auto-confirming
302        if !options.auto_confirm && !options.dry_run {
303            if !self
304                .confirm_cleanup(worktree, options, &safety_violations)
305                .await?
306            {
307                return Ok(WorktreeCleanupResult {
308                    path: worktree.path.clone(),
309                    branch: worktree.branch.clone(),
310                    action: CleanupAction::Skipped,
311                    reason: "User declined cleanup".to_string(),
312                    error: None,
313                    safety_violations,
314                });
315            }
316        }
317
318        // Perform the cleanup operation
319        if options.dry_run {
320            Ok(WorktreeCleanupResult {
321                path: worktree.path.clone(),
322                branch: worktree.branch.clone(),
323                action: CleanupAction::Cleaned,
324                reason: "Would be cleaned (dry run)".to_string(),
325                error: None,
326                safety_violations,
327            })
328        } else {
329            self.execute_cleanup_strategy(worktree, options, safety_violations)
330                .await
331        }
332    }
333
334    /// Execute the specific cleanup strategy
335    async fn execute_cleanup_strategy(
336        &self,
337        worktree: &WorktreeInfo,
338        options: &CleanupOptions,
339        safety_violations: Vec<SafetyViolation>,
340    ) -> Result<WorktreeCleanupResult> {
341        match options.strategy {
342            CleanupStrategy::Discard => {
343                self.execute_discard_strategy(worktree, options, safety_violations)
344                    .await
345            }
346            CleanupStrategy::MergeToFeature => {
347                self.execute_merge_to_feature_strategy(worktree, options, safety_violations)
348                    .await
349            }
350            CleanupStrategy::BackupToOrigin => {
351                self.execute_backup_to_origin_strategy(worktree, options, safety_violations)
352                    .await
353            }
354            CleanupStrategy::StashAndDiscard => {
355                self.execute_stash_and_discard_strategy(worktree, options, safety_violations)
356                    .await
357            }
358        }
359    }
360
361    /// Execute discard strategy (simple removal)
362    async fn execute_discard_strategy(
363        &self,
364        worktree: &WorktreeInfo,
365        _options: &CleanupOptions,
366        safety_violations: Vec<SafetyViolation>,
367    ) -> Result<WorktreeCleanupResult> {
368        let remove_options = RemoveOptions {
369            target: worktree.branch.clone(),
370            force: true, // We've already done safety checks
371            delete_branch: self.config.cleanup.auto_delete_branch,
372        };
373
374        match self.operations.remove_worktree(remove_options).await {
375            Ok(_) => Ok(WorktreeCleanupResult {
376                path: worktree.path.clone(),
377                branch: worktree.branch.clone(),
378                action: CleanupAction::Cleaned,
379                reason: "Worktree removed".to_string(),
380                error: None,
381                safety_violations,
382            }),
383            Err(e) => Ok(WorktreeCleanupResult {
384                path: worktree.path.clone(),
385                branch: worktree.branch.clone(),
386                action: CleanupAction::Failed,
387                reason: "Failed to remove worktree".to_string(),
388                error: Some(e.to_string()),
389                safety_violations,
390            }),
391        }
392    }
393
394    /// Execute merge to feature branch strategy
395    async fn execute_merge_to_feature_strategy(
396        &self,
397        worktree: &WorktreeInfo,
398        _options: &CleanupOptions,
399        safety_violations: Vec<SafetyViolation>,
400    ) -> Result<WorktreeCleanupResult> {
401        // Determine the target feature branch (remove worktree prefix)
402        let feature_branch = self.extract_feature_branch_name(&worktree.branch)?;
403
404        // Ensure target branch exists
405        if !self.branch_exists(&feature_branch).await? {
406            return Ok(WorktreeCleanupResult {
407                path: worktree.path.clone(),
408                branch: worktree.branch.clone(),
409                action: CleanupAction::Failed,
410                reason: format!("Target feature branch '{}' does not exist", feature_branch),
411                error: None,
412                safety_violations,
413            });
414        }
415
416        // Perform the merge
417        match self
418            .merge_worktree_to_branch(worktree, &feature_branch)
419            .await
420        {
421            Ok(merge_result) => {
422                if merge_result.has_conflicts {
423                    // Don't remove worktree if there are conflicts
424                    Ok(WorktreeCleanupResult {
425                        path: worktree.path.clone(),
426                        branch: worktree.branch.clone(),
427                        action: CleanupAction::Failed,
428                        reason: format!(
429                            "Merge conflicts detected: {}",
430                            merge_result.conflict_summary
431                        ),
432                        error: None,
433                        safety_violations,
434                    })
435                } else {
436                    // Merge successful, remove worktree
437                    let remove_options = RemoveOptions {
438                        target: worktree.branch.clone(),
439                        force: true,
440                        delete_branch: true, // Remove the worktree branch after successful merge
441                    };
442
443                    self.operations.remove_worktree(remove_options).await?;
444
445                    Ok(WorktreeCleanupResult {
446                        path: worktree.path.clone(),
447                        branch: worktree.branch.clone(),
448                        action: CleanupAction::MergedToFeature,
449                        reason: format!("Merged to '{}' and cleaned", feature_branch),
450                        error: None,
451                        safety_violations,
452                    })
453                }
454            }
455            Err(e) => Ok(WorktreeCleanupResult {
456                path: worktree.path.clone(),
457                branch: worktree.branch.clone(),
458                action: CleanupAction::Failed,
459                reason: "Failed to merge to feature branch".to_string(),
460                error: Some(e.to_string()),
461                safety_violations,
462            }),
463        }
464    }
465
466    /// Execute backup to origin strategy
467    async fn execute_backup_to_origin_strategy(
468        &self,
469        worktree: &WorktreeInfo,
470        _options: &CleanupOptions,
471        safety_violations: Vec<SafetyViolation>,
472    ) -> Result<WorktreeCleanupResult> {
473        // Push the branch to origin
474        match self.push_branch_to_origin(worktree).await {
475            Ok(_) => {
476                // After successful backup, remove the worktree
477                let remove_options = RemoveOptions {
478                    target: worktree.branch.clone(),
479                    force: true,
480                    delete_branch: false, // Keep the branch since it's backed up
481                };
482
483                self.operations.remove_worktree(remove_options).await?;
484
485                Ok(WorktreeCleanupResult {
486                    path: worktree.path.clone(),
487                    branch: worktree.branch.clone(),
488                    action: CleanupAction::BackedUpToOrigin,
489                    reason: "Backed up to origin and cleaned".to_string(),
490                    error: None,
491                    safety_violations,
492                })
493            }
494            Err(e) => Ok(WorktreeCleanupResult {
495                path: worktree.path.clone(),
496                branch: worktree.branch.clone(),
497                action: CleanupAction::Failed,
498                reason: "Failed to backup to origin".to_string(),
499                error: Some(e.to_string()),
500                safety_violations,
501            }),
502        }
503    }
504
505    /// Execute stash and discard strategy
506    async fn execute_stash_and_discard_strategy(
507        &self,
508        worktree: &WorktreeInfo,
509        _options: &CleanupOptions,
510        safety_violations: Vec<SafetyViolation>,
511    ) -> Result<WorktreeCleanupResult> {
512        // Create a stash with uncommitted changes
513        let stash_name = format!(
514            "vibe-cleanup-{}-{}",
515            worktree.branch,
516            chrono::Utc::now().format("%Y%m%d-%H%M%S")
517        );
518
519        let stash_result = self.create_stash(worktree, &stash_name).await;
520
521        match stash_result {
522            Ok(stash_created) => {
523                // Remove the worktree
524                let remove_options = RemoveOptions {
525                    target: worktree.branch.clone(),
526                    force: true,
527                    delete_branch: self.config.cleanup.auto_delete_branch,
528                };
529
530                match self.operations.remove_worktree(remove_options).await {
531                    Ok(_) => {
532                        let reason = if stash_created {
533                            format!("Stashed changes as '{}' and cleaned", stash_name)
534                        } else {
535                            "No changes to stash, worktree cleaned".to_string()
536                        };
537
538                        Ok(WorktreeCleanupResult {
539                            path: worktree.path.clone(),
540                            branch: worktree.branch.clone(),
541                            action: CleanupAction::StashCreated,
542                            reason,
543                            error: None,
544                            safety_violations,
545                        })
546                    }
547                    Err(e) => Ok(WorktreeCleanupResult {
548                        path: worktree.path.clone(),
549                        branch: worktree.branch.clone(),
550                        action: CleanupAction::Failed,
551                        reason: "Stash created but failed to remove worktree".to_string(),
552                        error: Some(e.to_string()),
553                        safety_violations,
554                    }),
555                }
556            }
557            Err(e) => Ok(WorktreeCleanupResult {
558                path: worktree.path.clone(),
559                branch: worktree.branch.clone(),
560                action: CleanupAction::Failed,
561                reason: "Failed to create stash".to_string(),
562                error: Some(e.to_string()),
563                safety_violations,
564            }),
565        }
566    }
567
568    // Helper methods for safety checks and operations
569
570    async fn check_safety_violations(
571        &self,
572        worktree: &WorktreeInfo,
573        options: &CleanupOptions,
574    ) -> Vec<SafetyViolation> {
575        let mut violations = Vec::new();
576
577        // Check age threshold
578        if let Some(min_hours) = options
579            .min_age_hours
580            .or(Some(self.config.cleanup.age_threshold_hours))
581        {
582            let min_age = Duration::from_secs(min_hours * 3600);
583            if worktree.age < min_age {
584                violations.push(SafetyViolation {
585                    violation_type: SafetyViolationType::BranchTooNew,
586                    description: format!(
587                        "Worktree is only {} old (minimum: {} hours)",
588                        format_duration(worktree.age),
589                        min_hours
590                    ),
591                    severity: ViolationSeverity::Warning,
592                });
593            }
594        }
595
596        // Check for uncommitted changes
597        if !worktree.status.uncommitted_changes.is_empty()
598            || !worktree.status.untracked_files.is_empty()
599        {
600            violations.push(SafetyViolation {
601                violation_type: SafetyViolationType::UncommittedChanges,
602                description: format!(
603                    "{} uncommitted changes, {} untracked files",
604                    worktree.status.uncommitted_changes.len(),
605                    worktree.status.untracked_files.len()
606                ),
607                severity: ViolationSeverity::Warning,
608            });
609        }
610
611        // Check for unpushed commits
612        if !worktree.status.unpushed_commits.is_empty() {
613            violations.push(SafetyViolation {
614                violation_type: SafetyViolationType::UnpushedCommits,
615                description: format!(
616                    "{} unpushed commits",
617                    worktree.status.unpushed_commits.len()
618                ),
619                severity: ViolationSeverity::Warning,
620            });
621        }
622
623        // Check merge confidence if filtering by merged branches
624        if options.merged_only {
625            if let Some(merge_info) = &worktree.status.merge_info {
626                if !merge_info.is_merged {
627                    violations.push(SafetyViolation {
628                        violation_type: SafetyViolationType::LowMergeConfidence,
629                        description: "Branch does not appear to be merged".to_string(),
630                        severity: ViolationSeverity::Critical,
631                    });
632                } else if merge_info.confidence < options.min_merge_confidence {
633                    violations.push(SafetyViolation {
634                        violation_type: SafetyViolationType::LowMergeConfidence,
635                        description: format!(
636                            "Merge confidence too low: {:.0}% (minimum: {:.0}%)",
637                            merge_info.confidence * 100.0,
638                            options.min_merge_confidence * 100.0
639                        ),
640                        severity: ViolationSeverity::Warning,
641                    });
642                }
643            } else {
644                violations.push(SafetyViolation {
645                    violation_type: SafetyViolationType::LowMergeConfidence,
646                    description: "No merge information available".to_string(),
647                    severity: ViolationSeverity::Critical,
648                });
649            }
650        }
651
652        // Check if worktree is currently in use (current working directory)
653        if let Ok(current_dir) = std::env::current_dir() {
654            if current_dir.starts_with(&worktree.path) {
655                violations.push(SafetyViolation {
656                    violation_type: SafetyViolationType::WorktreeInUse,
657                    description: "Worktree is currently in use (current directory)".to_string(),
658                    severity: ViolationSeverity::Critical,
659                });
660            }
661        }
662
663        violations
664    }
665
666    async fn is_main_repository_worktree(&self, worktree: &WorktreeInfo) -> Result<bool> {
667        // Check if this worktree is the main repository (contains .git directory)
668        Ok(worktree.path.join(".git").is_dir())
669    }
670
671    fn matches_filters(&self, worktree: &WorktreeInfo, options: &CleanupOptions) -> bool {
672        // Check branch prefix filter
673        if let Some(ref prefix) = options.branch_prefix_filter {
674            if !worktree.branch.starts_with(prefix) {
675                return false;
676            }
677        }
678
679        true
680    }
681
682    async fn confirm_cleanup(
683        &self,
684        worktree: &WorktreeInfo,
685        options: &CleanupOptions,
686        violations: &[SafetyViolation],
687    ) -> Result<bool> {
688        println!(
689            "{} Cleanup worktree: {}",
690            "?".yellow(),
691            worktree.branch.cyan()
692        );
693        println!("  Path: {}", worktree.path.display().to_string().blue());
694        println!("  Strategy: {:?}", options.strategy);
695
696        if !violations.is_empty() {
697            println!("  {} Safety concerns:", "⚠️".yellow());
698            for violation in violations {
699                let severity_icon = match violation.severity {
700                    ViolationSeverity::Warning => "⚠️",
701                    ViolationSeverity::Critical => "🚨",
702                };
703                println!("    {} {}", severity_icon, violation.description);
704            }
705        }
706
707        use std::io::{self, Write};
708        print!("  Proceed? (y/N): ");
709        io::stdout().flush()?;
710
711        let mut input = String::new();
712        io::stdin().read_line(&mut input)?;
713
714        Ok(input.trim().to_lowercase() == "y" || input.trim().to_lowercase() == "yes")
715    }
716
717    // Strategy implementation helpers
718
719    fn extract_feature_branch_name(&self, worktree_branch: &str) -> Result<String> {
720        if let Some(suffix) = worktree_branch.strip_prefix(&self.config.prefix) {
721            Ok(suffix.to_string())
722        } else {
723            bail!(
724                "Branch '{}' does not have expected prefix '{}'",
725                worktree_branch,
726                self.config.prefix
727            );
728        }
729    }
730
731    async fn branch_exists(&self, branch_name: &str) -> Result<bool> {
732        let output = Command::new("git")
733            .args(&[
734                "show-ref",
735                "--verify",
736                "--quiet",
737                &format!("refs/heads/{}", branch_name),
738            ])
739            .output()
740            .await?;
741
742        Ok(output.status.success())
743    }
744
745    async fn merge_worktree_to_branch(
746        &self,
747        worktree: &WorktreeInfo,
748        target_branch: &str,
749    ) -> Result<MergeResult> {
750        // Switch to target branch
751        let checkout_output = Command::new("git")
752            .args(&["checkout", target_branch])
753            .current_dir(&worktree.path.parent().unwrap_or(&worktree.path))
754            .output()
755            .await?;
756
757        if !checkout_output.status.success() {
758            bail!(
759                "Failed to checkout target branch: {}",
760                String::from_utf8_lossy(&checkout_output.stderr)
761            );
762        }
763
764        // Attempt merge
765        let merge_output = Command::new("git")
766            .args(&["merge", &worktree.branch])
767            .current_dir(&worktree.path.parent().unwrap_or(&worktree.path))
768            .output()
769            .await?;
770
771        if merge_output.status.success() {
772            Ok(MergeResult {
773                success: true,
774                has_conflicts: false,
775                conflict_summary: String::new(),
776            })
777        } else {
778            // Check if it's a merge conflict
779            let stderr = String::from_utf8_lossy(&merge_output.stderr);
780            if stderr.contains("conflict") || stderr.contains("CONFLICT") {
781                let conflict_summary = self.get_merge_conflict_summary(&worktree.path).await?;
782                Ok(MergeResult {
783                    success: false,
784                    has_conflicts: true,
785                    conflict_summary,
786                })
787            } else {
788                bail!("Merge failed: {}", stderr);
789            }
790        }
791    }
792
793    async fn get_merge_conflict_summary(&self, worktree_path: &Path) -> Result<String> {
794        let output = Command::new("git")
795            .args(&["diff", "--name-only", "--diff-filter=U"])
796            .current_dir(worktree_path)
797            .output()
798            .await?;
799
800        if output.status.success() {
801            let conflicted_files = String::from_utf8_lossy(&output.stdout);
802            let file_count = conflicted_files.lines().count();
803            Ok(format!("{} conflicted files", file_count))
804        } else {
805            Ok("Unknown conflicts".to_string())
806        }
807    }
808
809    async fn push_branch_to_origin(&self, worktree: &WorktreeInfo) -> Result<()> {
810        let output = Command::new("git")
811            .args(&["push", "origin", &worktree.branch])
812            .current_dir(&worktree.path)
813            .output()
814            .await?;
815
816        if output.status.success() {
817            Ok(())
818        } else {
819            let stderr = String::from_utf8_lossy(&output.stderr);
820            bail!("Failed to push to origin: {}", stderr);
821        }
822    }
823
824    async fn create_stash(&self, worktree: &WorktreeInfo, stash_name: &str) -> Result<bool> {
825        let output = Command::new("git")
826            .args(&["stash", "push", "-m", stash_name])
827            .current_dir(&worktree.path)
828            .output()
829            .await?;
830
831        if output.status.success() {
832            let stdout = String::from_utf8_lossy(&output.stdout);
833            // Git returns different messages if there's nothing to stash
834            Ok(!stdout.contains("No local changes to save"))
835        } else {
836            let stderr = String::from_utf8_lossy(&output.stderr);
837            bail!("Failed to create stash: {}", stderr);
838        }
839    }
840}
841
842#[derive(Debug)]
843struct MergeResult {
844    #[allow(dead_code)]
845    success: bool,
846    has_conflicts: bool,
847    conflict_summary: String,
848}
849
850impl Default for CleanupOptions {
851    fn default() -> Self {
852        Self {
853            strategy: CleanupStrategy::Discard,
854            min_age_hours: Some(24),
855            force: false,
856            dry_run: false,
857            auto_confirm: false,
858            branch_prefix_filter: None,
859            merged_only: false,
860            min_merge_confidence: 0.8,
861        }
862    }
863}
864
865/// Format a duration for human-readable display
866fn format_duration(duration: Duration) -> String {
867    let hours = duration.as_secs() / 3600;
868    let days = hours / 24;
869
870    if days > 0 {
871        format!("{} days", days)
872    } else if hours > 0 {
873        format!("{} hours", hours)
874    } else {
875        format!("{} minutes", duration.as_secs() / 60)
876    }
877}
878
879/// Convenience function to create cleanup options for merged worktrees only
880pub fn merged_worktrees_cleanup_options() -> CleanupOptions {
881    CleanupOptions {
882        merged_only: true,
883        min_merge_confidence: 0.7,
884        ..Default::default()
885    }
886}
887
888/// Convenience function to create cleanup options for old worktrees
889pub fn old_worktrees_cleanup_options(min_age_days: u64) -> CleanupOptions {
890    CleanupOptions {
891        min_age_hours: Some(min_age_days * 24),
892        ..Default::default()
893    }
894}
895
896#[cfg(test)]
897mod tests {
898    use super::*;
899
900    #[test]
901    fn test_cleanup_options_defaults() {
902        let options = CleanupOptions::default();
903        assert_eq!(options.strategy, CleanupStrategy::Discard);
904        assert_eq!(options.min_age_hours, Some(24));
905        assert!(!options.force);
906        assert!(!options.dry_run);
907    }
908
909    #[test]
910    fn test_format_duration() {
911        let minutes = Duration::from_secs(30 * 60);
912        let hours = Duration::from_secs(5 * 3600);
913        let days = Duration::from_secs(3 * 24 * 3600);
914
915        assert_eq!(format_duration(minutes), "30 minutes");
916        assert_eq!(format_duration(hours), "5 hours");
917        assert_eq!(format_duration(days), "3 days");
918    }
919
920    #[test]
921    fn test_safety_violation_severity() {
922        let warning = SafetyViolation {
923            violation_type: SafetyViolationType::UncommittedChanges,
924            description: "test".to_string(),
925            severity: ViolationSeverity::Warning,
926        };
927
928        let critical = SafetyViolation {
929            violation_type: SafetyViolationType::WorktreeInUse,
930            description: "test".to_string(),
931            severity: ViolationSeverity::Critical,
932        };
933
934        assert_eq!(warning.severity, ViolationSeverity::Warning);
935        assert_eq!(critical.severity, ViolationSeverity::Critical);
936    }
937}