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) => {
231 output::print_error(&format!("{clean} lockfile sync failed:\n {}", e.trim()));
232 }
233 PruneStatus::ActivityCheckError(e) => {
234 output::print_error(&format!(
235 "{clean} skipped — its activity could not be determined:\n {}",
236 e.trim()
237 ));
238 }
239 PruneStatus::DeleteError(e) => {
240 output::print_error(&format!("{clean} delete error: {e}"));
241 }
242 PruneStatus::ConfigError(e) => {
243 output::print_error(&format!(
244 "{clean} skipped — its .devprune.json could not be read:\n {}\n \
245 Fix it, or run `devp config {clean} --update` to reset it.",
246 e.trim()
247 ));
248 }
249 PruneStatus::SkippedSymlink(e) => {
250 output::print_warning(&format!("{clean} → {}", e.trim()));
251 }
252 _ => {}
253 }
254 }
255
256 if !args.dry_run && total_freed > 0 {
257 output::print_success(&format!(
258 "Freed: {} in {clean}",
259 output::format_bytes(total_freed)
260 ));
261 }
262
263 if error_count > 0 {
264 anyhow::bail!("{error_count} directories in {clean} could not be pruned.");
265 }
266
267 Ok(())
268}
269
270fn record_targeted_prune(path: &std::path::Path, results: &[PruneResult], dry_run: bool) {
276 if dry_run {
277 return;
278 }
279
280 let pruned: Vec<crate::config::PrunedDir> = results
283 .iter()
284 .filter(|r| {
285 matches!(r.status, PruneStatus::Pruned)
286 || (matches!(r.status, PruneStatus::DeleteError(_)) && r.size_freed > 0)
287 })
288 .map(|r| crate::config::PrunedDir {
289 repo_path: r.repo_path.clone(),
290 bloat_dir: r.bloat_dir.clone(),
291 adapter: r.adapter_name.clone(),
292 size_freed: r.size_freed,
293 })
294 .collect();
295
296 if pruned.is_empty() {
297 return;
298 }
299
300 let freed: u64 = pruned.iter().map(|d| d.size_freed).sum();
301 if let Ok(mut registry) = Registry::load() {
302 registry.mark_pruned(path, freed);
303 registry.record_prune(pruned);
304 let _ = registry.save();
305 }
306}
307
308fn resolve_min_size(args: &RunArgs<'_>, registry: Option<&Registry>) -> u64 {
313 let mb = args
314 .min_size_mb
315 .or_else(|| registry.map(|r| r.settings.min_size_mb))
316 .unwrap_or(constants::DEFAULT_MIN_SIZE_MB);
317 mb.saturating_mul(engine::BYTES_PER_MIB)
318}
319
320fn parse_except(spec: Option<&str>) -> Vec<String> {
327 spec.map(|s| {
328 s.split(',')
329 .map(|part| {
330 crate::config::expand_tilde(part.trim())
331 .trim_end_matches(['/', '\\'])
332 .to_lowercase()
333 })
334 .filter(|part| !part.is_empty())
335 .collect()
336 })
337 .unwrap_or_default()
338}
339
340fn is_excepted(repo_path: &Path, except: &[String]) -> bool {
347 if except.is_empty() {
348 return false;
349 }
350 let full = output::clean_path(repo_path)
351 .to_lowercase()
352 .replace('\\', "/");
353 let name = repo_path
354 .file_name()
355 .map(|n| n.to_string_lossy().to_lowercase())
356 .unwrap_or_default();
357
358 except.iter().any(|want| {
359 let want = want.replace('\\', "/");
360 name == want || full == want || full.ends_with(&format!("/{want}"))
361 })
362}
363
364fn resolve_scan_depth(registry: Option<&Registry>) -> usize {
366 registry
367 .map(|r| r.settings.scan_depth)
368 .unwrap_or(constants::DEFAULT_SCAN_DEPTH)
369}
370
371fn resolve_build_idle_days(registry: Option<&Registry>) -> u64 {
373 registry
374 .map(|r| r.settings.build_idle_days)
375 .unwrap_or(constants::DEFAULT_BUILD_IDLE_DAYS)
376}
377
378fn resolve_command_timeout(registry: Option<&Registry>) -> u64 {
379 registry
380 .map(|r| r.settings.command_timeout_secs)
381 .unwrap_or(constants::DEFAULT_COMMAND_TIMEOUT_SECS)
382}
383
384fn resolve_manifest_rewrite(registry: Option<&Registry>) -> bool {
386 registry
387 .map(|r| r.settings.allow_manifest_rewrite)
388 .unwrap_or(constants::DEFAULT_ALLOW_MANIFEST_REWRITE)
389}
390
391fn run_registry(args: &RunArgs<'_>, filter: &AdapterFilter) -> Result<()> {
393 if !args.json {
394 if args.dry_run {
395 output::print_header("dev-prune run (DRY RUN)");
396 } else {
397 output::print_header("dev-prune run");
398 }
399 }
400
401 let mut registry = Registry::load()?;
402
403 if !args.json && crate::commands::update::notify_if_outdated(&mut registry) {
406 let _ = registry.save();
407 }
408
409 if registry.repo_count() == 0 {
410 if args.json {
411 return json::emit(&json::run_document(&[], args.dry_run));
412 }
413 output::print_warning("No repositories registered. Run `dev-prune init` first.");
414 return Ok(());
415 }
416
417 let except = parse_except(args.except);
421 if !except.is_empty() {
422 let unmatched: Vec<&String> = except
423 .iter()
424 .filter(|want| {
425 !registry
426 .repositories
427 .keys()
428 .any(|p| is_excepted(p, std::slice::from_ref(*want)))
429 })
430 .collect();
431 if !unmatched.is_empty() {
432 anyhow::bail!(
433 "`--except` names no registered repository: {}\n \
434 Run `devp status` to see the registered names.",
435 unmatched
436 .iter()
437 .map(|s| s.as_str())
438 .collect::<Vec<_>>()
439 .join(", ")
440 );
441 }
442 }
443
444 let min_size_bytes = resolve_min_size(args, Some(®istry));
445 let analysis = PruneOptions {
446 idle_days: 0, dry_run: true,
448 force: args.force,
449 only_dirs: None,
450 adapters: filter.clone(),
451 min_size_bytes,
452 scan_depth: resolve_scan_depth(Some(®istry)),
453 allow_manifest_rewrite: resolve_manifest_rewrite(Some(®istry)),
454 command_timeout_secs: resolve_command_timeout(Some(®istry)),
455 build_idle_days: resolve_build_idle_days(Some(®istry)),
456 };
457
458 if !args.json {
459 output::print_info(&format!(
460 "Scanning {} registered repositories for prune candidates...",
461 registry.repo_count()
462 ));
463 if let Some(desc) = filter.describe() {
464 output::print_info(&format!("Adapter filter: {desc}"));
465 }
466 if min_size_bytes > 0 {
467 output::print_info(&format!(
468 "Size floor: ignoring directories under {}",
469 output::format_bytes(min_size_bytes)
470 ));
471 }
472 }
473
474 let mut candidates: Vec<PruneResult> = Vec::new();
483 let mut blocked: Vec<PruneResult> = Vec::new();
484 let mut linked: Vec<PruneResult> = Vec::new();
485 let mut missing: Vec<PruneResult> = Vec::new();
486 for result in engine::prune_all_with(&mut registry, &analysis) {
487 if is_excepted(&result.repo_path, &except) {
491 continue;
492 }
493 match result.status {
494 PruneStatus::SkippedDryRun => candidates.push(result),
495 PruneStatus::ConfigError(_)
496 | PruneStatus::LockfileError(_)
497 | PruneStatus::ActivityCheckError(_)
498 | PruneStatus::DeleteError(_) => blocked.push(result),
499 PruneStatus::SkippedSymlink(_) => linked.push(result),
502 PruneStatus::PathMissing => missing.push(result),
505 _ => {}
506 }
507 }
508
509 if !args.json && !except.is_empty() {
510 output::print_info(&format!("Leaving alone: {}", except.join(", ")));
511 }
512
513 if args.daemon {
514 let before = candidates.len();
515 candidates.retain(|c| {
516 match crate::config::PerRepoConfig::load_with_diagnostics(&c.repo_path) {
520 Ok(Some(cfg)) => !cfg.disable_daemon,
521 Ok(None) => true,
522 Err(_) => false,
523 }
524 });
525 let skipped = before - candidates.len();
526 if skipped > 0 && !args.json {
527 output::print_info(&format!(
528 "Skipped {skipped} bloat directories in repositories that set `disable_daemon`."
529 ));
530 }
531 }
532
533 if args.dry_run {
535 if args.json {
536 json::emit(&json::run_document(
537 &[candidates, blocked, linked, missing].concat(),
538 true,
539 ))?;
540 return Ok(());
541 }
542 if candidates.is_empty() && blocked.is_empty() && linked.is_empty() && missing.is_empty() {
543 output::print_info("No idle repositories or pruneable bloat directories found.");
544 return Ok(());
545 }
546 if !candidates.is_empty() {
547 report_candidates(&candidates);
548 }
549 let total: u64 = candidates.iter().map(|c| c.size_freed).sum();
550 output::print_header("Summary (Dry Run)");
551 output::print_info(&format!(
552 "Would free {} across {} bloat directories.",
553 output::format_bytes(total),
554 candidates.len()
555 ));
556 report_blocked(&blocked);
559 report_linked(&linked);
560 report_missing(&missing);
561 return Ok(());
562 }
563
564 if candidates.is_empty() {
565 if args.json {
566 json::emit(&json::run_document(
567 &[blocked.clone(), linked, missing].concat(),
568 false,
569 ))?;
570 return fail_if_blocked(&blocked);
571 }
572 if blocked.is_empty() && linked.is_empty() && missing.is_empty() {
573 output::print_info("No idle repositories or pruneable bloat directories found.");
574 return Ok(());
575 }
576 output::print_info("No pruneable bloat directories found.");
577 report_blocked(&blocked);
578 report_linked(&linked);
579 report_missing(&missing);
580 return fail_if_blocked(&blocked);
581 }
582
583 let total_reclaimable: u64 = candidates.iter().map(|c| c.size_freed).sum();
584
585 if !args.json {
586 report_binaries(&candidates);
587 report_candidates(&candidates);
588 output::print_info(&format!(
589 "Total Reclaimable Space: {}",
590 output::format_bytes_styled(total_reclaimable)
591 ));
592 report_blocked(&blocked);
593 report_linked(&linked);
594 report_missing(&missing);
595 }
596
597 let target_candidates: Vec<PruneResult> = if args.json
600 || args.yes
601 || !registry.settings.require_confirmation
602 {
603 candidates
604 } else if io::stdout().is_terminal() && io::stdin().is_terminal() {
605 eprintln!();
606 eprintln!(
607 " Loading interactive selector... (↑↓ navigate, Space toggle, Enter confirm, q cancel)"
608 );
609 eprintln!();
610 let selected = tui::selection_view::select_candidates_tui(&candidates)?;
611 if selected.is_empty() {
612 output::print_info("Prune pass cancelled by user (0 candidates selected).");
613 return Ok(());
614 }
615 selected
616 } else {
617 if !io::stdin().is_terminal() {
621 anyhow::bail!(
622 "Deleting {} directories ({}) needs confirmation, and there is no \
623 terminal to ask on. Re-run with `--yes` to confirm, or `--dry-run` \
624 to only analyse.",
625 candidates.len(),
626 output::format_bytes(total_reclaimable)
627 );
628 }
629 println!();
630 output::print_warning("CAUTION: Deleting bloat directories cannot be undone directly.");
631 output::print_info(
632 "Note: You can re-install missing dependencies anytime using `dev-prune restore`.",
633 );
634 eprint!(
637 "Proceed with deletion of {} directories ({})? [y/N]: ",
638 candidates.len(),
639 output::format_bytes(total_reclaimable)
640 );
641 io::stderr().flush()?;
642
643 let mut input = String::new();
644 io::stdin().read_line(&mut input)?;
645 let trimmed = input.trim().to_lowercase();
646 if trimmed != "y" && trimmed != "yes" {
647 output::print_info("Prune pass aborted by user.");
648 return Ok(());
649 }
650 candidates
651 };
652
653 if !args.json {
654 let selected_total_bytes: u64 = target_candidates.iter().map(|c| c.size_freed).sum();
655 output::print_header(&format!(
656 "Executing Progressive Deletion ({} repos, {})",
657 target_candidates.len(),
658 output::format_bytes(selected_total_bytes)
659 ));
660 }
661
662 let mut selection: Vec<(std::path::PathBuf, Vec<String>)> = Vec::new();
668 for candidate in &target_candidates {
669 match selection
670 .iter_mut()
671 .find(|(p, _)| *p == candidate.repo_path)
672 {
673 Some((_, dirs)) => dirs.push(candidate.bloat_dir.clone()),
674 None => selection.push((
675 candidate.repo_path.clone(),
676 vec![candidate.bloat_dir.clone()],
677 )),
678 }
679 }
680
681 let mut error_count = blocked.len();
685 let mut all_results: Vec<PruneResult> = blocked;
686 all_results.extend(linked);
687 all_results.extend(missing);
688 let mut total_freed: u64 = 0;
689 let mut pruned_count = 0;
690 let mut pruned_dirs: Vec<crate::config::PrunedDir> = Vec::new();
691 let pass_at = chrono::Utc::now();
694
695 for (repo_path, dirs) in &selection {
696 let recorded_before = pruned_dirs.len();
697 let idle_days = registry
702 .repositories
703 .get(repo_path)
704 .and_then(|e| e.override_idle_days)
705 .unwrap_or(registry.settings.idle_days);
706 let single_results = engine::prune_repo_with(
707 repo_path,
708 &PruneOptions {
709 idle_days,
710 dry_run: false,
711 force: args.force,
712 only_dirs: Some(dirs.clone()),
713 adapters: filter.clone(),
714 min_size_bytes: 0,
715 scan_depth: analysis.scan_depth,
716 allow_manifest_rewrite: analysis.allow_manifest_rewrite,
717 command_timeout_secs: analysis.command_timeout_secs,
718 build_idle_days: analysis.build_idle_days,
719 },
720 );
721 for result in single_results {
722 match &result.status {
723 PruneStatus::Pruned => {
724 total_freed += result.size_freed;
725 pruned_count += 1;
726 registry.mark_pruned(&result.repo_path, result.size_freed);
727 pruned_dirs.push(crate::config::PrunedDir {
728 repo_path: result.repo_path.clone(),
729 bloat_dir: result.bloat_dir.clone(),
730 adapter: result.adapter_name.clone(),
731 size_freed: result.size_freed,
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 });
773 }
774 if !args.json {
775 output::print_error(&format!(
776 "{} → delete failed: {}",
777 output::clean_path(&result.repo_path),
778 e,
779 ));
780 }
781 }
782 PruneStatus::ConfigError(e) => {
783 error_count += 1;
784 if !args.json {
785 let clean_p = output::clean_path(&result.repo_path);
786 output::print_error(&format!(
787 "{clean_p} skipped — its .devprune.json could not be read:\n {}",
788 e.trim()
789 ));
790 output::print_info(&format!(
791 " Fix command: devp config {clean_p} --update"
792 ));
793 }
794 }
795 PruneStatus::SkippedActive if !args.json => {
798 output::print_info(&format!(
799 "{} became active since the analysis — left alone. \
800 Use `--ignore-idle` to prune it anyway.",
801 output::clean_path(&result.repo_path)
802 ));
803 }
804 _ => {}
805 }
806 all_results.push(result);
807 }
808
809 if pruned_dirs.len() > recorded_before {
815 registry.record_prune_progress(pass_at, pruned_dirs.clone());
816 let _ = registry.save();
817 }
818 }
819
820 registry.record_prune_progress(pass_at, pruned_dirs);
821 registry.save()?;
822
823 if args.json {
824 json::emit(&json::run_document(&all_results, false))?;
825 if error_count > 0 {
828 anyhow::bail!("{error_count} repositories could not be pruned.");
829 }
830 return Ok(());
831 }
832
833 output::print_header("Summary");
834 output::print_success(&format!(
835 "Freed: {} across {pruned_count} directories",
836 output::format_bytes_styled(total_freed)
837 ));
838
839 if error_count > 0 {
840 output::print_warning(&format!("{error_count} repos were not pruned."));
841
842 if all_results
846 .iter()
847 .any(|r| matches!(r.status, PruneStatus::LockfileError(_)))
848 {
849 output::print_info(
853 "Lockfile verification cannot be bypassed: without a lockfile the deleted \
854 dependencies could not be reinstalled. Run the fix command shown above for \
855 each repo, then re-run `devp run`.",
856 );
857 }
858 anyhow::bail!("{error_count} repositories could not be pruned.");
860 }
861
862 crate::commands::update::maybe_auto_update(®istry);
865
866 Ok(())
867}
868
869fn report_blocked(blocked: &[PruneResult]) {
873 if blocked.is_empty() {
874 return;
875 }
876 output::print_header("Repositories That Could Not Be Examined");
877 for result in blocked {
878 let clean_p = output::clean_path(&result.repo_path);
879 match &result.status {
880 PruneStatus::ConfigError(e) => {
881 output::print_error(&format!(
882 "{clean_p} skipped — its .devprune.json could not be read:\n {}",
883 e.trim()
884 ));
885 output::print_info(&format!(
886 " Fix command: devp config {clean_p} --update"
887 ));
888 }
889 PruneStatus::LockfileError(e) => report_lockfile_failure(result, e),
890 PruneStatus::ActivityCheckError(e) => {
891 output::print_error(&format!(
892 "{clean_p} skipped — its activity could not be determined:\n {}",
893 e.trim()
894 ));
895 }
896 PruneStatus::DeleteError(e) => {
897 output::print_error(&format!("{clean_p} → delete failed: {e}"));
898 }
899 _ => {}
901 }
902 }
903}
904
905fn report_linked(linked: &[PruneResult]) {
911 for result in linked {
912 if let PruneStatus::SkippedSymlink(e) = &result.status {
913 output::print_warning(&format!(
914 "{} → {}",
915 output::clean_path(&result.repo_path),
916 e.trim()
917 ));
918 }
919 }
920}
921
922fn report_missing(missing: &[PruneResult]) {
928 for result in missing {
929 output::print_warning(&format!(
930 "{} no longer exists — `devp unlink --missing` clears such entries.",
931 output::clean_path(&result.repo_path)
932 ));
933 }
934}
935
936fn fail_if_blocked(blocked: &[PruneResult]) -> Result<()> {
941 if blocked.is_empty() {
942 return Ok(());
943 }
944 anyhow::bail!("{} repositories could not be examined.", blocked.len());
945}
946
947fn report_binaries(candidates: &[PruneResult]) {
949 let adapter_names: Vec<String> = candidates.iter().map(|c| c.adapter_name.clone()).collect();
950 let binary_statuses = adapters::scan_required_binaries(&adapter_names);
951 if binary_statuses.is_empty() {
952 return;
953 }
954 output::print_header("Required Ecosystem Binaries Pre-Check");
955 for b in &binary_statuses {
956 if b.available {
957 output::print_success(&format!(
958 " {} — available ({})",
959 b.name,
960 b.version.as_deref().unwrap_or("detected")
961 ));
962 } else {
963 output::print_warning(&format!(
964 " {} — missing (lockfile fallback active)",
965 b.name
966 ));
967 }
968 }
969}
970
971fn report_candidates(candidates: &[PruneResult]) {
972 output::print_header("Prune Candidates & Space Savings Calculation");
973 for candidate in candidates {
974 output::print_info(&format!(
975 " • {} → {} ({}) [{}]{}",
976 output::styled_path(&candidate.repo_path),
977 candidate.bloat_dir,
978 output::format_bytes_styled(candidate.size_freed),
979 output::styled_adapter(&candidate.adapter_name),
980 output::shared_note(candidate.shared_bytes, &candidate.adapter_name)
981 ));
982 }
983}
984
985fn report_lockfile_failure(result: &PruneResult, error: &str) {
986 let clean_p = output::clean_path(&result.repo_path);
987 let sync_cmd_help =
988 json::lockfile_fix_command(&result.adapter_name).unwrap_or("check the adapter's docs");
989
990 #[cfg(windows)]
991 let manual_cmd = format!("cd \"{}\"; {}", clean_p, sync_cmd_help);
992 #[cfg(not(windows))]
993 let manual_cmd = format!("cd \"{}\" && {}", clean_p, sync_cmd_help);
994
995 output::print_error(&format!(
996 "{} → {} lockfile sync failed:\n {}",
997 clean_p,
998 result.adapter_name,
999 error.trim(),
1000 ));
1001 output::print_info(&format!(" Fix command: {}", manual_cmd));
1002 output::print_info(&format!(
1003 " Troubleshooting: {}",
1004 constants::TROUBLESHOOTING_URL
1005 ));
1006}
1007
1008fn run_explain(args: &RunArgs<'_>, filter: &AdapterFilter) -> Result<()> {
1019 output::print_header("Why each repository would or would not be pruned");
1020 if let Some(desc) = filter.describe() {
1021 output::print_info(&format!("Adapter filter: {desc}"));
1022 }
1023
1024 if let Some(target_str) = args.target_path {
1025 let raw = Path::new(target_str);
1026 let path = if raw.exists() {
1027 raw.canonicalize().unwrap_or_else(|_| raw.to_path_buf())
1028 } else {
1029 raw.to_path_buf()
1030 };
1031 if !crate::scanner::is_git_repo(&path) {
1032 anyhow::bail!(
1033 "{} is not a Git repository — dev-prune only prunes Git repos.",
1034 output::clean_path(&path)
1035 );
1036 }
1037 let registry = Registry::load().ok();
1038 let idle_days = registry
1039 .as_ref()
1040 .map(|r| {
1041 r.repositories
1042 .get(&path)
1043 .and_then(|e| e.override_idle_days)
1044 .unwrap_or(r.settings.idle_days)
1045 })
1046 .unwrap_or(constants::DEFAULT_IDLE_DAYS);
1047 let floor = resolve_min_size(args, registry.as_ref());
1048 let results = engine::prune_repo_with(
1049 &path,
1050 &PruneOptions {
1051 idle_days,
1052 dry_run: true,
1053 force: args.force,
1054 only_dirs: None,
1055 adapters: filter.clone(),
1056 min_size_bytes: 0,
1057 scan_depth: resolve_scan_depth(registry.as_ref()),
1058 allow_manifest_rewrite: resolve_manifest_rewrite(registry.as_ref()),
1059 command_timeout_secs: resolve_command_timeout(registry.as_ref()),
1060 build_idle_days: resolve_build_idle_days(registry.as_ref()),
1061 },
1062 );
1063 let refs: Vec<&PruneResult> = results.iter().collect();
1064 explain_repo(&path, &refs, floor, idle_days);
1065 print_explain_footer();
1066 return Ok(());
1067 }
1068
1069 let mut registry = Registry::load()?;
1070 if registry.repo_count() == 0 {
1071 output::print_warning("No repositories registered. Run `dev-prune init` first.");
1072 return Ok(());
1073 }
1074
1075 let except = parse_except(args.except);
1076 let global_floor = resolve_min_size(args, Some(®istry));
1077 let analysis = PruneOptions {
1078 idle_days: 0, dry_run: true,
1080 force: args.force,
1081 only_dirs: None,
1082 adapters: filter.clone(),
1083 min_size_bytes: 0,
1084 scan_depth: resolve_scan_depth(Some(®istry)),
1085 allow_manifest_rewrite: resolve_manifest_rewrite(Some(®istry)),
1086 command_timeout_secs: resolve_command_timeout(Some(®istry)),
1087 build_idle_days: resolve_build_idle_days(Some(®istry)),
1088 };
1089 let results = engine::prune_all_with(&mut registry, &analysis);
1090
1091 let mut by_repo: std::collections::HashMap<&Path, Vec<&PruneResult>> =
1092 std::collections::HashMap::new();
1093 for r in &results {
1094 by_repo.entry(r.repo_path.as_path()).or_default().push(r);
1095 }
1096
1097 let mut repos: Vec<&std::path::PathBuf> = registry.repositories.keys().collect();
1098 repos.sort();
1099 for path in repos {
1100 if is_excepted(path, &except) {
1101 println!();
1102 output::print_info(&output::clean_path(path));
1103 println!(" • left completely alone this pass (`--except`)");
1104 continue;
1105 }
1106 let idle_days = registry
1107 .repositories
1108 .get(path)
1109 .and_then(|e| e.override_idle_days)
1110 .unwrap_or(registry.settings.idle_days);
1111 let empty = Vec::new();
1112 let repo_results = by_repo.get(path.as_path()).unwrap_or(&empty);
1113 explain_repo(path, repo_results, global_floor, idle_days);
1114 }
1115 print_explain_footer();
1116 Ok(())
1117}
1118
1119fn explain_repo(path: &Path, results: &[&PruneResult], floor: u64, idle_days: u64) {
1121 println!();
1122 output::print_info(&output::clean_path(path));
1123
1124 if results.is_empty() {
1125 println!(
1126 " • idle, but no known bloat directories were found. A project deeper than \
1127 `scan_depth` is not examined — `devp status` shows what dev-prune can see."
1128 );
1129 return;
1130 }
1131
1132 for r in results {
1133 match &r.status {
1134 PruneStatus::SkippedDryRun => {
1135 if r.size_freed >= floor {
1136 output::print_success(&format!(
1137 "would prune {} ({}) [{}]{}",
1138 r.bloat_dir,
1139 output::format_bytes(r.size_freed),
1140 r.adapter_name,
1141 output::shared_note(r.shared_bytes, &r.adapter_name)
1142 ));
1143 } else {
1144 println!(
1145 " • {} ({}) is under the size floor of {} — the reinstall would \
1146 cost more than the space is worth. `--min-size 0` includes it.",
1147 r.bloat_dir,
1148 output::format_bytes(r.size_freed),
1149 output::format_bytes(floor)
1150 );
1151 }
1152 }
1153 PruneStatus::SkippedActive => {
1154 let age = crate::scanner::git::get_last_activity(path)
1155 .ok()
1156 .flatten()
1157 .and_then(|t| std::time::SystemTime::now().duration_since(t).ok())
1158 .map(|d| d.as_secs() / 86_400);
1159 match age {
1160 Some(0) => println!(
1161 " • active — there was activity today, and the idle \
1162 threshold is {idle_days} days. `--ignore-idle` overrides."
1163 ),
1164 Some(days) => println!(
1165 " • active — last activity {days} day{} ago, and the idle \
1166 threshold is {idle_days} days. `--ignore-idle` overrides.",
1167 if days == 1 { "" } else { "s" }
1168 ),
1169 None => println!(
1170 " • active (not idle for {idle_days} days yet). \
1171 `--ignore-idle` overrides."
1172 ),
1173 }
1174 }
1175 other => println!(" • {other}"),
1176 }
1177 }
1178}
1179
1180fn print_explain_footer() {
1182 println!();
1183 output::print_info(
1184 "Nothing was verified or deleted. `devp run --dry-run` verifies candidates; \
1185 `devp run` prunes.",
1186 );
1187}