1use anyhow::Result;
12use std::io::{self, IsTerminal, Write};
13use std::path::Path;
14
15use crate::adapters;
16use crate::config::Registry;
17use crate::constants;
18use crate::engine::{self, AdapterFilter, PruneOptions, PruneResult, PruneStatus};
19use crate::json;
20use crate::output;
21use crate::tui;
22
23pub struct RunArgs<'a> {
28 pub target_path: Option<&'a str>,
30 pub dry_run: bool,
32 pub force: bool,
34 pub yes: bool,
36 pub daemon: bool,
38 pub only: Option<&'a str>,
40 pub skip: Option<&'a str>,
42 pub min_size_mb: Option<u64>,
44 pub except: Option<&'a str>,
46 pub json: bool,
48 pub explain: bool,
50}
51
52pub fn run(args: RunArgs<'_>) -> Result<()> {
57 let filter = AdapterFilter::new(args.only, args.skip)?;
58
59 if args.json && !args.dry_run && !args.yes {
63 return Err(anyhow::Error::new(crate::UsageError(
64 "`--json` cannot ask for confirmation. Pass `--dry-run` to analyse, or `--yes` to delete."
65 .to_string(),
66 )));
67 }
68
69 if !args.json {
70 output::print_banner();
71 if args.force {
72 print_ignore_idle_notice();
73 }
74 }
75
76 if args.explain {
77 return run_explain(&args, &filter);
78 }
79
80 if let Some(target_str) = args.target_path {
81 return run_targeted(&args, &filter, target_str);
82 }
83 run_registry(&args, &filter)
84}
85
86fn print_ignore_idle_notice() {
93 output::print_warning(
94 "Idle check bypassed — repositories you are working in right now are fair game.",
95 );
96 println!(
97 " Still enforced: lockfile verification, `ignore.devprune.json`, `\"ignore\": true`,"
98 );
99 println!(
100 " symlinked directories, and nested repositories. This flag does not turn those off."
101 );
102 println!();
103 println!(" If you reached for this because something would not prune, it is usually:");
104 println!(" • \"lockfile verification failed\" → run the fix command printed next to it;");
105 println!(" it regenerates the lockfile so the reinstall is guaranteed to work.");
106 println!(" • nothing listed at all → the project is deeper than `scan_depth`,");
107 println!(" or under `min_size_mb`. Try `devp status` to see what dev-prune can see.");
108 println!(" • \"could not be examined\" → `.devprune.json` has a syntax error.");
109 println!();
110 println!(" Still stuck? Point your AI assistant at the bundled skill — `devp skill`");
111 println!(" exports a SKILL.md that teaches it this tool, exit codes and all. It has");
112 println!(" read the manual more recently than either of us.");
113 println!();
114}
115
116fn run_targeted(args: &RunArgs<'_>, filter: &AdapterFilter, target_str: &str) -> Result<()> {
118 let raw = std::path::Path::new(target_str);
119 let path = if raw.exists() {
120 raw.canonicalize().unwrap_or_else(|_| raw.to_path_buf())
121 } else {
122 raw.to_path_buf()
123 };
124
125 let clean = output::clean_path(&path);
126 if !crate::scanner::is_git_repo(&path) {
127 anyhow::bail!("{clean} is not a Git repository — dev-prune only prunes Git repos.");
130 }
131
132 let registry = Registry::load().ok();
136 let idle_days = registry
137 .as_ref()
138 .map(|r| {
139 r.repositories
140 .get(&path)
141 .and_then(|e| e.override_idle_days)
142 .unwrap_or(r.settings.idle_days)
143 })
144 .unwrap_or(constants::DEFAULT_IDLE_DAYS);
145
146 let opts = PruneOptions {
147 idle_days,
148 dry_run: args.dry_run,
149 force: args.force,
150 only_dirs: None,
151 adapters: filter.clone(),
152 min_size_bytes: resolve_min_size(args, registry.as_ref()),
153 scan_depth: resolve_scan_depth(registry.as_ref()),
154 allow_manifest_rewrite: resolve_manifest_rewrite(registry.as_ref()),
155 command_timeout_secs: resolve_command_timeout(registry.as_ref()),
156 build_idle_days: resolve_build_idle_days(registry.as_ref()),
157 };
158
159 let results = engine::prune_repo_with(&path, &opts);
160
161 let error_count = results
165 .iter()
166 .filter(|r| {
167 matches!(
168 r.status,
169 PruneStatus::LockfileError(_)
170 | PruneStatus::ActivityCheckError(_)
171 | PruneStatus::DeleteError(_)
172 | PruneStatus::ConfigError(_)
173 )
174 })
175 .count();
176
177 record_targeted_prune(&path, &results, args.dry_run);
182
183 if args.json {
184 json::emit(&json::run_document(&results, args.dry_run))?;
185 if error_count > 0 {
186 anyhow::bail!("{error_count} directories in {clean} could not be pruned.");
187 }
188 return Ok(());
189 }
190
191 output::print_header(&format!("dev-prune Targeted Run ({clean})"));
192 if let Some(desc) = filter.describe() {
193 output::print_info(&format!("Adapter filter: {desc}"));
194 }
195
196 if results.is_empty() {
197 output::print_info(&format!("No pruneable bloat directories found in {clean}."));
198 return Ok(());
199 }
200
201 let mut total_freed = 0;
202 for result in results {
203 match &result.status {
204 PruneStatus::Pruned => {
205 total_freed += result.size_freed;
206 output::print_success(&format!(
207 "{} → {} ({}) — {}{}",
208 output::clean_path(&result.repo_path),
209 result.bloat_dir,
210 output::format_bytes(result.size_freed),
211 result.adapter_name,
212 output::shared_note(result.shared_bytes, &result.adapter_name)
213 ));
214 }
215 PruneStatus::SkippedDryRun => {
216 output::print_info(&format!(
217 " • {} → {} ({}) [{}] (Dry Run){}",
218 output::clean_path(&result.repo_path),
219 result.bloat_dir,
220 output::format_bytes(result.size_freed),
221 result.adapter_name,
222 output::shared_note(result.shared_bytes, &result.adapter_name)
223 ));
224 }
225 PruneStatus::SkippedActive => {
226 output::print_info(&format!(
227 "{clean} is currently active (not idle). Use `devp --ignore-idle run` to override."
228 ));
229 }
230 PruneStatus::LockfileError(e) => report_lockfile_failure(&result, e),
231 PruneStatus::ActivityCheckError(e) => {
232 output::print_error(&format!(
233 "{clean} skipped — its activity could not be determined:\n {}",
234 e.trim()
235 ));
236 }
237 PruneStatus::DeleteError(e) => {
238 output::print_error(&format!("{clean} delete error: {e}"));
239 }
240 PruneStatus::ConfigError(e) => {
241 output::print_error(&format!(
242 "{clean} skipped — its .devprune.json could not be read:\n {}\n \
243 Fix it, or run `devp config {clean} --update` to reset it.",
244 e.trim()
245 ));
246 }
247 PruneStatus::SkippedSymlink(e) => {
248 output::print_warning(&format!("{clean} → {}", e.trim()));
249 }
250 _ => {}
251 }
252 }
253
254 if !args.dry_run && total_freed > 0 {
255 output::print_success(&format!(
256 "Freed: {} in {clean}",
257 output::format_bytes(total_freed)
258 ));
259 }
260
261 if error_count > 0 {
262 anyhow::bail!("{error_count} directories in {clean} could not be pruned.");
263 }
264
265 Ok(())
266}
267
268fn record_targeted_prune(path: &std::path::Path, results: &[PruneResult], dry_run: bool) {
274 if dry_run {
275 return;
276 }
277
278 let pruned: Vec<crate::config::PrunedDir> = results
281 .iter()
282 .filter(|r| {
283 matches!(r.status, PruneStatus::Pruned)
284 || (matches!(r.status, PruneStatus::DeleteError(_)) && r.size_freed > 0)
285 })
286 .map(|r| crate::config::PrunedDir {
287 repo_path: r.repo_path.clone(),
288 bloat_dir: r.bloat_dir.clone(),
289 adapter: r.adapter_name.clone(),
290 size_freed: r.size_freed,
291 runtime: r.runtime.clone(),
292 })
293 .collect();
294
295 if pruned.is_empty() {
296 return;
297 }
298
299 let freed: u64 = pruned.iter().map(|d| d.size_freed).sum();
300 if let Ok(mut registry) = Registry::load() {
301 registry.mark_pruned(path, freed);
302 registry.record_prune(pruned);
303 let _ = registry.save();
304 }
305}
306
307fn resolve_min_size(args: &RunArgs<'_>, registry: Option<&Registry>) -> u64 {
312 let mb = args
313 .min_size_mb
314 .or_else(|| registry.map(|r| r.settings.min_size_mb))
315 .unwrap_or(constants::DEFAULT_MIN_SIZE_MB);
316 mb.saturating_mul(engine::BYTES_PER_MIB)
317}
318
319fn parse_except(spec: Option<&str>) -> Vec<String> {
326 spec.map(|s| {
327 s.split(',')
328 .map(|part| {
329 crate::config::expand_tilde(part.trim())
330 .trim_end_matches(['/', '\\'])
331 .to_lowercase()
332 })
333 .filter(|part| !part.is_empty())
334 .collect()
335 })
336 .unwrap_or_default()
337}
338
339fn is_excepted(repo_path: &Path, except: &[String]) -> bool {
346 if except.is_empty() {
347 return false;
348 }
349 let full = output::clean_path(repo_path)
350 .to_lowercase()
351 .replace('\\', "/");
352 let name = repo_path
353 .file_name()
354 .map(|n| n.to_string_lossy().to_lowercase())
355 .unwrap_or_default();
356
357 except.iter().any(|want| {
358 let want = want.replace('\\', "/");
359 name == want || full == want || full.ends_with(&format!("/{want}"))
360 })
361}
362
363fn resolve_scan_depth(registry: Option<&Registry>) -> usize {
365 registry
366 .map(|r| r.settings.scan_depth)
367 .unwrap_or(constants::DEFAULT_SCAN_DEPTH)
368}
369
370fn resolve_build_idle_days(registry: Option<&Registry>) -> u64 {
372 registry
373 .map(|r| r.settings.build_idle_days)
374 .unwrap_or(constants::DEFAULT_BUILD_IDLE_DAYS)
375}
376
377fn resolve_command_timeout(registry: Option<&Registry>) -> u64 {
378 registry
379 .map(|r| r.settings.command_timeout_secs)
380 .unwrap_or(constants::DEFAULT_COMMAND_TIMEOUT_SECS)
381}
382
383fn resolve_manifest_rewrite(registry: Option<&Registry>) -> bool {
385 registry
386 .map(|r| r.settings.allow_manifest_rewrite)
387 .unwrap_or(constants::DEFAULT_ALLOW_MANIFEST_REWRITE)
388}
389
390fn run_registry(args: &RunArgs<'_>, filter: &AdapterFilter) -> Result<()> {
392 if !args.json {
393 if args.dry_run {
394 output::print_header("dev-prune run (DRY RUN)");
395 } else {
396 output::print_header("dev-prune run");
397 }
398 }
399
400 let mut registry = Registry::load()?;
401
402 if !args.json && crate::commands::update::notify_if_outdated(&mut registry) {
405 let _ = registry.save();
406 }
407
408 if registry.repo_count() == 0 {
409 if args.json {
410 return json::emit(&json::run_document(&[], args.dry_run));
411 }
412 output::print_warning("No repositories registered. Run `dev-prune init` first.");
413 return Ok(());
414 }
415
416 let except = parse_except(args.except);
420 if !except.is_empty() {
421 let unmatched: Vec<&String> = except
422 .iter()
423 .filter(|want| {
424 !registry
425 .repositories
426 .keys()
427 .any(|p| is_excepted(p, std::slice::from_ref(*want)))
428 })
429 .collect();
430 if !unmatched.is_empty() {
431 anyhow::bail!(
432 "`--except` names no registered repository: {}\n \
433 Run `devp status` to see the registered names.",
434 unmatched
435 .iter()
436 .map(|s| s.as_str())
437 .collect::<Vec<_>>()
438 .join(", ")
439 );
440 }
441 }
442
443 let min_size_bytes = resolve_min_size(args, Some(®istry));
444 let analysis = PruneOptions {
445 idle_days: 0, dry_run: true,
447 force: args.force,
448 only_dirs: None,
449 adapters: filter.clone(),
450 min_size_bytes,
451 scan_depth: resolve_scan_depth(Some(®istry)),
452 allow_manifest_rewrite: resolve_manifest_rewrite(Some(®istry)),
453 command_timeout_secs: resolve_command_timeout(Some(®istry)),
454 build_idle_days: resolve_build_idle_days(Some(®istry)),
455 };
456
457 if !args.json {
458 output::print_info(&format!(
459 "Scanning {} registered repositories for prune candidates...",
460 registry.repo_count()
461 ));
462 if let Some(desc) = filter.describe() {
463 output::print_info(&format!("Adapter filter: {desc}"));
464 }
465 if min_size_bytes > 0 {
466 output::print_info(&format!(
467 "Size floor: ignoring directories under {}",
468 output::format_bytes(min_size_bytes)
469 ));
470 }
471 }
472
473 let mut candidates: Vec<PruneResult> = Vec::new();
482 let mut blocked: Vec<PruneResult> = Vec::new();
483 let mut linked: Vec<PruneResult> = Vec::new();
484 let mut missing: Vec<PruneResult> = Vec::new();
485 for result in engine::prune_all_with(&mut registry, &analysis) {
486 if is_excepted(&result.repo_path, &except) {
490 continue;
491 }
492 match result.status {
493 PruneStatus::SkippedDryRun => candidates.push(result),
494 PruneStatus::ConfigError(_)
495 | PruneStatus::LockfileError(_)
496 | PruneStatus::ActivityCheckError(_)
497 | PruneStatus::DeleteError(_) => blocked.push(result),
498 PruneStatus::SkippedSymlink(_) => linked.push(result),
501 PruneStatus::PathMissing => missing.push(result),
504 _ => {}
505 }
506 }
507
508 if !args.json && !except.is_empty() {
509 output::print_info(&format!("Leaving alone: {}", except.join(", ")));
510 }
511
512 if args.daemon {
513 let before = candidates.len();
514 candidates.retain(|c| {
515 match crate::config::PerRepoConfig::load_with_diagnostics(&c.repo_path) {
519 Ok(Some(cfg)) => !cfg.disable_daemon,
520 Ok(None) => true,
521 Err(_) => false,
522 }
523 });
524 let skipped = before - candidates.len();
525 if skipped > 0 && !args.json {
526 output::print_info(&format!(
527 "Skipped {skipped} bloat directories in repositories that set `disable_daemon`."
528 ));
529 }
530 }
531
532 if args.dry_run {
534 if args.json {
535 json::emit(&json::run_document(
536 &[candidates, blocked, linked, missing].concat(),
537 true,
538 ))?;
539 return Ok(());
540 }
541 if candidates.is_empty() && blocked.is_empty() && linked.is_empty() && missing.is_empty() {
542 output::print_info("No idle repositories or pruneable bloat directories found.");
543 return Ok(());
544 }
545 if !candidates.is_empty() {
546 report_candidates(&candidates);
547 }
548 let total: u64 = candidates.iter().map(|c| c.size_freed).sum();
549 output::print_header("Summary (Dry Run)");
550 output::print_info(&format!(
551 "Would free {} across {} bloat directories.",
552 output::format_bytes(total),
553 candidates.len()
554 ));
555 report_blocked(&blocked);
558 report_linked(&linked);
559 report_missing(&missing);
560 return Ok(());
561 }
562
563 if candidates.is_empty() {
564 if args.json {
565 json::emit(&json::run_document(
566 &[blocked.clone(), linked, missing].concat(),
567 false,
568 ))?;
569 return fail_if_blocked(&blocked);
570 }
571 if blocked.is_empty() && linked.is_empty() && missing.is_empty() {
572 output::print_info("No idle repositories or pruneable bloat directories found.");
573 return Ok(());
574 }
575 output::print_info("No pruneable bloat directories found.");
576 report_blocked(&blocked);
577 report_linked(&linked);
578 report_missing(&missing);
579 return fail_if_blocked(&blocked);
580 }
581
582 let total_reclaimable: u64 = candidates.iter().map(|c| c.size_freed).sum();
583
584 if !args.json {
585 report_binaries(&candidates);
586 report_candidates(&candidates);
587 output::print_info(&format!(
588 "Total Reclaimable Space: {}",
589 output::format_bytes_styled(total_reclaimable)
590 ));
591 report_blocked(&blocked);
592 report_linked(&linked);
593 report_missing(&missing);
594 }
595
596 let target_candidates: Vec<PruneResult> = if args.json
599 || args.yes
600 || !registry.settings.require_confirmation
601 {
602 candidates
603 } else if io::stdout().is_terminal() && io::stdin().is_terminal() {
604 eprintln!();
605 eprintln!(
606 " Loading interactive selector... (↑↓ navigate, Space toggle, Enter confirm, q cancel)"
607 );
608 eprintln!();
609 let selected = tui::selection_view::select_candidates_tui(&candidates)?;
610 if selected.is_empty() {
611 output::print_info("Prune pass cancelled by user (0 candidates selected).");
612 return Ok(());
613 }
614 selected
615 } else {
616 if !io::stdin().is_terminal() {
620 anyhow::bail!(
621 "Deleting {} directories ({}) needs confirmation, and there is no \
622 terminal to ask on. Re-run with `--yes` to confirm, or `--dry-run` \
623 to only analyse.",
624 candidates.len(),
625 output::format_bytes(total_reclaimable)
626 );
627 }
628 println!();
629 output::print_warning("CAUTION: Deleting bloat directories cannot be undone directly.");
630 output::print_info(
631 "Note: You can re-install missing dependencies anytime using `dev-prune restore`.",
632 );
633 eprint!(
636 "Proceed with deletion of {} directories ({})? [y/N]: ",
637 candidates.len(),
638 output::format_bytes(total_reclaimable)
639 );
640 io::stderr().flush()?;
641
642 let mut input = String::new();
643 io::stdin().read_line(&mut input)?;
644 let trimmed = input.trim().to_lowercase();
645 if trimmed != "y" && trimmed != "yes" {
646 output::print_info("Prune pass aborted by user.");
647 return Ok(());
648 }
649 candidates
650 };
651
652 if !args.json {
653 let selected_total_bytes: u64 = target_candidates.iter().map(|c| c.size_freed).sum();
654 output::print_header(&format!(
655 "Executing Progressive Deletion ({} repos, {})",
656 target_candidates.len(),
657 output::format_bytes(selected_total_bytes)
658 ));
659 }
660
661 let mut selection: Vec<(std::path::PathBuf, Vec<String>)> = Vec::new();
667 for candidate in &target_candidates {
668 match selection
669 .iter_mut()
670 .find(|(p, _)| *p == candidate.repo_path)
671 {
672 Some((_, dirs)) => dirs.push(candidate.bloat_dir.clone()),
673 None => selection.push((
674 candidate.repo_path.clone(),
675 vec![candidate.bloat_dir.clone()],
676 )),
677 }
678 }
679
680 let mut error_count = blocked.len();
684 let mut all_results: Vec<PruneResult> = blocked;
685 all_results.extend(linked);
686 all_results.extend(missing);
687 let mut total_freed: u64 = 0;
688 let mut pruned_count = 0;
689 let mut pruned_dirs: Vec<crate::config::PrunedDir> = Vec::new();
690 let pass_at = chrono::Utc::now();
693
694 for (repo_path, dirs) in &selection {
695 let recorded_before = pruned_dirs.len();
696 let idle_days = registry
701 .repositories
702 .get(repo_path)
703 .and_then(|e| e.override_idle_days)
704 .unwrap_or(registry.settings.idle_days);
705 let single_results = engine::prune_repo_with(
706 repo_path,
707 &PruneOptions {
708 idle_days,
709 dry_run: false,
710 force: args.force,
711 only_dirs: Some(dirs.clone()),
712 adapters: filter.clone(),
713 min_size_bytes: 0,
714 scan_depth: analysis.scan_depth,
715 allow_manifest_rewrite: analysis.allow_manifest_rewrite,
716 command_timeout_secs: analysis.command_timeout_secs,
717 build_idle_days: analysis.build_idle_days,
718 },
719 );
720 for result in single_results {
721 match &result.status {
722 PruneStatus::Pruned => {
723 total_freed += result.size_freed;
724 pruned_count += 1;
725 registry.mark_pruned(&result.repo_path, result.size_freed);
726 pruned_dirs.push(crate::config::PrunedDir {
727 repo_path: result.repo_path.clone(),
728 bloat_dir: result.bloat_dir.clone(),
729 adapter: result.adapter_name.clone(),
730 size_freed: result.size_freed,
731 runtime: result.runtime.clone(),
732 });
733 if !args.json {
734 output::print_success(&format!(
735 "{} → {} ({}) — {}{}",
736 output::clean_path(&result.repo_path),
737 result.bloat_dir,
738 output::format_bytes(result.size_freed),
739 result.adapter_name,
740 output::shared_note(result.shared_bytes, &result.adapter_name)
741 ));
742 }
743 }
744 PruneStatus::LockfileError(e) => {
745 error_count += 1;
746 if !args.json {
747 report_lockfile_failure(&result, e);
748 }
749 }
750 PruneStatus::ActivityCheckError(e) => {
751 error_count += 1;
752 if !args.json {
753 output::print_error(&format!(
754 "{} skipped — its activity could not be determined:\n {}",
755 output::clean_path(&result.repo_path),
756 e.trim()
757 ));
758 }
759 }
760 PruneStatus::DeleteError(e) => {
761 error_count += 1;
762 if result.size_freed > 0 {
767 pruned_dirs.push(crate::config::PrunedDir {
768 repo_path: result.repo_path.clone(),
769 bloat_dir: result.bloat_dir.clone(),
770 adapter: result.adapter_name.clone(),
771 size_freed: result.size_freed,
772 runtime: result.runtime.clone(),
773 });
774 }
775 if !args.json {
776 output::print_error(&format!(
777 "{} → delete failed: {}",
778 output::clean_path(&result.repo_path),
779 e,
780 ));
781 }
782 }
783 PruneStatus::ConfigError(e) => {
784 error_count += 1;
785 if !args.json {
786 let clean_p = output::clean_path(&result.repo_path);
787 output::print_error(&format!(
788 "{clean_p} skipped — its .devprune.json could not be read:\n {}",
789 e.trim()
790 ));
791 output::print_info(&format!(
792 " Fix command: devp config {clean_p} --update"
793 ));
794 }
795 }
796 PruneStatus::SkippedActive if !args.json => {
799 output::print_info(&format!(
800 "{} became active since the analysis — left alone. \
801 Use `--ignore-idle` to prune it anyway.",
802 output::clean_path(&result.repo_path)
803 ));
804 }
805 _ => {}
806 }
807 all_results.push(result);
808 }
809
810 if pruned_dirs.len() > recorded_before {
816 registry.record_prune_progress(pass_at, pruned_dirs.clone());
817 let _ = registry.save();
818 }
819 }
820
821 registry.record_prune_progress(pass_at, pruned_dirs);
822 registry.save()?;
823
824 if args.json {
825 json::emit(&json::run_document(&all_results, false))?;
826 if error_count > 0 {
829 anyhow::bail!("{error_count} repositories could not be pruned.");
830 }
831 return Ok(());
832 }
833
834 output::print_header("Summary");
835 output::print_success(&format!(
836 "Freed: {} across {pruned_count} directories",
837 output::format_bytes_styled(total_freed)
838 ));
839
840 if error_count > 0 {
841 output::print_warning(&format!("{error_count} repos were not pruned."));
842
843 if all_results
847 .iter()
848 .any(|r| matches!(r.status, PruneStatus::LockfileError(_)))
849 {
850 output::print_info(
854 "Lockfile verification cannot be bypassed: without a lockfile the deleted \
855 dependencies could not be reinstalled. Run the fix command shown above for \
856 each repo, then re-run `devp run`.",
857 );
858 }
859 anyhow::bail!("{error_count} repositories could not be pruned.");
861 }
862
863 crate::commands::update::maybe_auto_update(®istry);
866
867 Ok(())
868}
869
870fn report_blocked(blocked: &[PruneResult]) {
874 if blocked.is_empty() {
875 return;
876 }
877 output::print_header("Repositories That Could Not Be Examined");
878 for result in blocked {
879 let clean_p = output::clean_path(&result.repo_path);
880 match &result.status {
881 PruneStatus::ConfigError(e) => {
882 output::print_error(&format!(
883 "{clean_p} skipped — its .devprune.json could not be read:\n {}",
884 e.trim()
885 ));
886 output::print_info(&format!(
887 " Fix command: devp config {clean_p} --update"
888 ));
889 }
890 PruneStatus::LockfileError(e) => report_lockfile_failure(result, e),
891 PruneStatus::ActivityCheckError(e) => {
892 output::print_error(&format!(
893 "{clean_p} skipped — its activity could not be determined:\n {}",
894 e.trim()
895 ));
896 }
897 PruneStatus::DeleteError(e) => {
898 output::print_error(&format!("{clean_p} → delete failed: {e}"));
899 }
900 _ => {}
902 }
903 }
904}
905
906fn report_linked(linked: &[PruneResult]) {
912 for result in linked {
913 if let PruneStatus::SkippedSymlink(e) = &result.status {
914 output::print_warning(&format!(
915 "{} → {}",
916 output::clean_path(&result.repo_path),
917 e.trim()
918 ));
919 }
920 }
921}
922
923fn report_missing(missing: &[PruneResult]) {
929 for result in missing {
930 output::print_warning(&format!(
931 "{} no longer exists — `devp unlink --missing` clears such entries.",
932 output::clean_path(&result.repo_path)
933 ));
934 }
935}
936
937fn fail_if_blocked(blocked: &[PruneResult]) -> Result<()> {
942 if blocked.is_empty() {
943 return Ok(());
944 }
945 anyhow::bail!("{} repositories could not be examined.", blocked.len());
946}
947
948fn report_binaries(candidates: &[PruneResult]) {
950 let adapter_names: Vec<String> = candidates.iter().map(|c| c.adapter_name.clone()).collect();
951 let binary_statuses = adapters::scan_required_binaries(&adapter_names);
952 if binary_statuses.is_empty() {
953 return;
954 }
955 output::print_header("Required Ecosystem Binaries Pre-Check");
956 for b in &binary_statuses {
957 if b.available {
958 output::print_success(&format!(
959 " {} — available ({})",
960 b.name,
961 b.version.as_deref().unwrap_or("detected")
962 ));
963 } else {
964 output::print_warning(&format!(
965 " {} — missing (lockfile fallback active)",
966 b.name
967 ));
968 }
969 }
970}
971
972fn report_candidates(candidates: &[PruneResult]) {
973 output::print_header("Prune Candidates & Space Savings Calculation");
974 for candidate in candidates {
975 output::print_info(&format!(
976 " • {} → {} ({}) [{}]{}",
977 output::styled_path(&candidate.repo_path),
978 candidate.bloat_dir,
979 output::format_bytes_styled(candidate.size_freed),
980 output::styled_adapter(&candidate.adapter_name),
981 output::shared_note(candidate.shared_bytes, &candidate.adapter_name)
982 ));
983 }
984}
985
986pub(crate) fn report_lockfile_failure(result: &PruneResult, error: &str) {
987 let project = output::clean_path(result.project_dir());
990
991 output::print_error(&format!(
992 "{} → {} lockfile sync failed:\n {}",
993 project,
994 result.adapter_name,
995 error.trim(),
996 ));
997 match json::lockfile_fix_command(&result.adapter_name) {
998 Some(sync_cmd) => {
999 #[cfg(windows)]
1002 let manual_cmd = format!("cd \"{project}\"; {sync_cmd}");
1003 #[cfg(not(windows))]
1004 let manual_cmd = format!("cd \"{project}\" && {sync_cmd}");
1005 output::print_info(&format!(" Fix command: {manual_cmd}"));
1006 }
1007 None => output::print_info(&format!(" Fix it in: {project}")),
1010 }
1011 output::print_info(&format!(
1012 " Troubleshooting: {}",
1013 constants::TROUBLESHOOTING_URL
1014 ));
1015}
1016
1017fn run_explain(args: &RunArgs<'_>, filter: &AdapterFilter) -> Result<()> {
1028 output::print_header("Why each repository would or would not be pruned");
1029 if let Some(desc) = filter.describe() {
1030 output::print_info(&format!("Adapter filter: {desc}"));
1031 }
1032
1033 if let Some(target_str) = args.target_path {
1034 let raw = Path::new(target_str);
1035 let path = if raw.exists() {
1036 raw.canonicalize().unwrap_or_else(|_| raw.to_path_buf())
1037 } else {
1038 raw.to_path_buf()
1039 };
1040 if !crate::scanner::is_git_repo(&path) {
1041 anyhow::bail!(
1042 "{} is not a Git repository — dev-prune only prunes Git repos.",
1043 output::clean_path(&path)
1044 );
1045 }
1046 let registry = Registry::load().ok();
1047 let idle_days = registry
1048 .as_ref()
1049 .map(|r| {
1050 r.repositories
1051 .get(&path)
1052 .and_then(|e| e.override_idle_days)
1053 .unwrap_or(r.settings.idle_days)
1054 })
1055 .unwrap_or(constants::DEFAULT_IDLE_DAYS);
1056 let floor = resolve_min_size(args, registry.as_ref());
1057 let results = engine::prune_repo_with(
1058 &path,
1059 &PruneOptions {
1060 idle_days,
1061 dry_run: true,
1062 force: args.force,
1063 only_dirs: None,
1064 adapters: filter.clone(),
1065 min_size_bytes: 0,
1066 scan_depth: resolve_scan_depth(registry.as_ref()),
1067 allow_manifest_rewrite: resolve_manifest_rewrite(registry.as_ref()),
1068 command_timeout_secs: resolve_command_timeout(registry.as_ref()),
1069 build_idle_days: resolve_build_idle_days(registry.as_ref()),
1070 },
1071 );
1072 let refs: Vec<&PruneResult> = results.iter().collect();
1073 explain_repo(&path, &refs, floor, idle_days);
1074 print_explain_footer();
1075 return Ok(());
1076 }
1077
1078 let mut registry = Registry::load()?;
1079 if registry.repo_count() == 0 {
1080 output::print_warning("No repositories registered. Run `dev-prune init` first.");
1081 return Ok(());
1082 }
1083
1084 let except = parse_except(args.except);
1085 let global_floor = resolve_min_size(args, Some(®istry));
1086 let analysis = PruneOptions {
1087 idle_days: 0, dry_run: true,
1089 force: args.force,
1090 only_dirs: None,
1091 adapters: filter.clone(),
1092 min_size_bytes: 0,
1093 scan_depth: resolve_scan_depth(Some(®istry)),
1094 allow_manifest_rewrite: resolve_manifest_rewrite(Some(®istry)),
1095 command_timeout_secs: resolve_command_timeout(Some(®istry)),
1096 build_idle_days: resolve_build_idle_days(Some(®istry)),
1097 };
1098 let results = engine::prune_all_with(&mut registry, &analysis);
1099
1100 let mut by_repo: std::collections::HashMap<&Path, Vec<&PruneResult>> =
1101 std::collections::HashMap::new();
1102 for r in &results {
1103 by_repo.entry(r.repo_path.as_path()).or_default().push(r);
1104 }
1105
1106 let mut repos: Vec<&std::path::PathBuf> = registry.repositories.keys().collect();
1107 repos.sort();
1108 for path in repos {
1109 if is_excepted(path, &except) {
1110 println!();
1111 output::print_info(&output::clean_path(path));
1112 println!(" • left completely alone this pass (`--except`)");
1113 continue;
1114 }
1115 let idle_days = registry
1116 .repositories
1117 .get(path)
1118 .and_then(|e| e.override_idle_days)
1119 .unwrap_or(registry.settings.idle_days);
1120 let empty = Vec::new();
1121 let repo_results = by_repo.get(path.as_path()).unwrap_or(&empty);
1122 explain_repo(path, repo_results, global_floor, idle_days);
1123 }
1124 print_explain_footer();
1125 Ok(())
1126}
1127
1128fn explain_repo(path: &Path, results: &[&PruneResult], floor: u64, idle_days: u64) {
1130 println!();
1131 output::print_info(&output::clean_path(path));
1132
1133 if results.is_empty() {
1134 println!(
1135 " • idle, but no known bloat directories were found. A project deeper than \
1136 `scan_depth` is not examined — `devp status` shows what dev-prune can see."
1137 );
1138 return;
1139 }
1140
1141 for r in results {
1142 match &r.status {
1143 PruneStatus::SkippedDryRun => {
1144 if r.size_freed >= floor {
1145 output::print_success(&format!(
1146 "would prune {} ({}) [{}]{}",
1147 r.bloat_dir,
1148 output::format_bytes(r.size_freed),
1149 r.adapter_name,
1150 output::shared_note(r.shared_bytes, &r.adapter_name)
1151 ));
1152 } else {
1153 println!(
1154 " • {} ({}) is under the size floor of {} — the reinstall would \
1155 cost more than the space is worth. `--min-size 0` includes it.",
1156 r.bloat_dir,
1157 output::format_bytes(r.size_freed),
1158 output::format_bytes(floor)
1159 );
1160 }
1161 }
1162 PruneStatus::SkippedActive => {
1163 let age = crate::scanner::git::get_last_activity(path)
1164 .ok()
1165 .flatten()
1166 .and_then(|t| std::time::SystemTime::now().duration_since(t).ok())
1167 .map(|d| d.as_secs() / 86_400);
1168 match age {
1169 Some(0) => println!(
1170 " • active — there was activity today, and the idle \
1171 threshold is {idle_days} days. `--ignore-idle` overrides."
1172 ),
1173 Some(days) => println!(
1174 " • active — last activity {days} day{} ago, and the idle \
1175 threshold is {idle_days} days. `--ignore-idle` overrides.",
1176 if days == 1 { "" } else { "s" }
1177 ),
1178 None => println!(
1179 " • active (not idle for {idle_days} days yet). \
1180 `--ignore-idle` overrides."
1181 ),
1182 }
1183 }
1184 other => println!(" • {other}"),
1185 }
1186 }
1187}
1188
1189fn print_explain_footer() {
1191 println!();
1192 output::print_info(
1193 "Nothing was verified or deleted. `devp run --dry-run` verifies candidates; \
1194 `devp run` prunes.",
1195 );
1196}