1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub enum CleanupStrategy {
21 Discard,
23 MergeToFeature,
25 BackupToOrigin,
27 StashAndDiscard,
29}
30
31#[derive(Debug, Clone)]
33pub struct CleanupOptions {
34 pub strategy: CleanupStrategy,
36
37 pub min_age_hours: Option<u64>,
39
40 pub force: bool,
42
43 pub dry_run: bool,
45
46 pub auto_confirm: bool,
48
49 pub branch_prefix_filter: Option<String>,
51
52 pub merged_only: bool,
54
55 pub min_merge_confidence: f32,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct CleanupReport {
62 pub total_evaluated: usize,
64
65 pub cleaned_count: usize,
67
68 pub skipped_count: usize,
70
71 pub failed_count: usize,
73
74 pub worktree_results: Vec<WorktreeCleanupResult>,
76
77 pub strategy_used: CleanupStrategy,
79
80 pub was_dry_run: bool,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct WorktreeCleanupResult {
86 pub path: PathBuf,
88
89 pub branch: String,
91
92 pub action: CleanupAction,
94
95 pub reason: String,
97
98 pub error: Option<String>,
100
101 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, Critical, }
138
139pub 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 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 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 async fn evaluate_and_cleanup_worktree(
223 &self,
224 worktree: &WorktreeInfo,
225 options: &CleanupOptions,
226 ) -> Result<WorktreeCleanupResult> {
227 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 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 let safety_violations = self.check_safety_violations(worktree, options).await;
253
254 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 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 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 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 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, 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 async fn execute_merge_to_feature_strategy(
396 &self,
397 worktree: &WorktreeInfo,
398 _options: &CleanupOptions,
399 safety_violations: Vec<SafetyViolation>,
400 ) -> Result<WorktreeCleanupResult> {
401 let feature_branch = self.extract_feature_branch_name(&worktree.branch)?;
403
404 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 match self
418 .merge_worktree_to_branch(worktree, &feature_branch)
419 .await
420 {
421 Ok(merge_result) => {
422 if merge_result.has_conflicts {
423 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 let remove_options = RemoveOptions {
438 target: worktree.branch.clone(),
439 force: true,
440 delete_branch: true, };
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 async fn execute_backup_to_origin_strategy(
468 &self,
469 worktree: &WorktreeInfo,
470 _options: &CleanupOptions,
471 safety_violations: Vec<SafetyViolation>,
472 ) -> Result<WorktreeCleanupResult> {
473 match self.push_branch_to_origin(worktree).await {
475 Ok(_) => {
476 let remove_options = RemoveOptions {
478 target: worktree.branch.clone(),
479 force: true,
480 delete_branch: false, };
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 async fn execute_stash_and_discard_strategy(
507 &self,
508 worktree: &WorktreeInfo,
509 _options: &CleanupOptions,
510 safety_violations: Vec<SafetyViolation>,
511 ) -> Result<WorktreeCleanupResult> {
512 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 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 async fn check_safety_violations(
571 &self,
572 worktree: &WorktreeInfo,
573 options: &CleanupOptions,
574 ) -> Vec<SafetyViolation> {
575 let mut violations = Vec::new();
576
577 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 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 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 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 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 Ok(worktree.path.join(".git").is_dir())
669 }
670
671 fn matches_filters(&self, worktree: &WorktreeInfo, options: &CleanupOptions) -> bool {
672 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 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 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 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 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 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
865fn 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
879pub fn merged_worktrees_cleanup_options() -> CleanupOptions {
881 CleanupOptions {
882 merged_only: true,
883 min_merge_confidence: 0.7,
884 ..Default::default()
885 }
886}
887
888pub 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}