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::i18n;
20use crate::json;
21use crate::output;
22use crate::tui;
23
24pub struct RunArgs<'a> {
29 pub target_path: Option<&'a str>,
31 pub dry_run: bool,
33 pub force: bool,
35 pub yes: bool,
37 pub daemon: bool,
39 pub only: Option<&'a str>,
41 pub skip: Option<&'a str>,
43 pub min_size_mb: Option<u64>,
45 pub except: Option<&'a str>,
47 pub json: bool,
49 pub explain: bool,
51}
52
53pub fn run(args: RunArgs<'_>) -> Result<()> {
58 let filter = AdapterFilter::new(args.only, args.skip)?;
59
60 if args.json && !args.dry_run && !args.yes {
64 return Err(anyhow::Error::new(crate::UsageError(
65 "`--json` cannot ask for confirmation. Pass `--dry-run` to analyse, or `--yes` to delete."
66 .to_string(),
67 )));
68 }
69
70 if !args.json {
71 output::print_banner();
72 if args.force {
73 print_ignore_idle_notice();
74 }
75 }
76
77 if args.explain {
78 return run_explain(&args, &filter);
79 }
80
81 if let Some(target_str) = args.target_path {
82 return run_targeted(&args, &filter, target_str);
83 }
84 run_registry(&args, &filter)
85}
86
87fn print_ignore_idle_notice() {
94 output::print_warning(
95 "Idle check bypassed — repositories you are working in right now are fair game.",
96 );
97 println!(
98 " Still enforced: lockfile verification, `ignore.devprune.json`, `\"ignore\": true`,"
99 );
100 println!(
101 " symlinked directories, and nested repositories. This flag does not turn those off."
102 );
103 println!();
104 println!(" If you reached for this because something would not prune, it is usually:");
105 println!(" • \"lockfile verification failed\" → run the fix command printed next to it;");
106 println!(" it regenerates the lockfile so the reinstall is guaranteed to work.");
107 println!(" • nothing listed at all → the project is deeper than `scan_depth`,");
108 println!(" or under `min_size_mb`. Try `devp status` to see what dev-prune can see.");
109 println!(" • \"could not be examined\" → `.devprune.json` has a syntax error.");
110 println!();
111 println!(" Still stuck? Point your AI assistant at the bundled skill — `devp skill`");
112 println!(" exports a SKILL.md that teaches it this tool, exit codes and all. It has");
113 println!(" read the manual more recently than either of us.");
114 println!();
115}
116
117fn run_targeted(args: &RunArgs<'_>, filter: &AdapterFilter, target_str: &str) -> Result<()> {
119 let raw = std::path::Path::new(target_str);
120 let path = if raw.exists() {
121 raw.canonicalize().unwrap_or_else(|_| raw.to_path_buf())
122 } else {
123 raw.to_path_buf()
124 };
125
126 let clean = output::clean_path(&path);
127 if !crate::scanner::is_git_repo(&path) {
128 anyhow::bail!("{clean} is not a Git repository — dev-prune only prunes Git repos.");
131 }
132
133 let registry = Registry::load().ok();
137 let idle_days = registry
138 .as_ref()
139 .map(|r| {
140 r.repositories
141 .get(&path)
142 .and_then(|e| e.override_idle_days)
143 .unwrap_or(r.settings.idle_days)
144 })
145 .unwrap_or(constants::DEFAULT_IDLE_DAYS);
146
147 let opts = PruneOptions {
148 idle_days,
149 dry_run: args.dry_run,
150 force: args.force,
151 only_dirs: None,
152 adapters: filter.clone(),
153 min_size_bytes: resolve_min_size(args, registry.as_ref()),
154 scan_depth: resolve_scan_depth(registry.as_ref()),
155 allow_manifest_rewrite: resolve_manifest_rewrite(registry.as_ref()),
156 command_timeout_secs: resolve_command_timeout(registry.as_ref()),
157 build_idle_days: resolve_build_idle_days(registry.as_ref()),
158 adapter_idle_days: resolve_adapter_idle_days(registry.as_ref()),
159 };
160
161 let results = engine::prune_repo_with(&path, &opts);
162
163 let error_count = results
167 .iter()
168 .filter(|r| {
169 matches!(
170 r.status,
171 PruneStatus::LockfileError(_)
172 | PruneStatus::ActivityCheckError(_)
173 | PruneStatus::DeleteError(_)
174 | PruneStatus::ConfigError(_)
175 )
176 })
177 .count();
178
179 record_targeted_prune(&path, &results, args.dry_run);
184
185 if args.json {
186 json::emit(&json::run_document(&results, args.dry_run))?;
187 if error_count > 0 {
188 anyhow::bail!("{error_count} directories in {clean} could not be pruned.");
189 }
190 return Ok(());
191 }
192
193 output::print_header(&i18n::tf(
194 "run.header.targeted",
195 &[("path", clean.as_str())],
196 ));
197 if let Some(desc) = filter.describe() {
198 output::print_info(&format!("Adapter filter: {desc}"));
199 }
200
201 if results.is_empty() {
202 output::print_info(&i18n::tf(
203 "run.nothing.bloat.targeted",
204 &[("path", clean.as_str())],
205 ));
206 return Ok(());
207 }
208
209 let mut total_freed = 0;
210 for result in results {
211 match &result.status {
212 PruneStatus::Pruned => {
213 total_freed += result.size_freed;
214 output::print_success(&format!(
215 "{} → {} ({}) — {}{}",
216 output::clean_path(&result.repo_path),
217 result.bloat_dir,
218 output::format_bytes(result.size_freed),
219 result.adapter_name,
220 output::shared_note(result.shared_bytes, &result.adapter_name)
221 ));
222 }
223 PruneStatus::SkippedDryRun => {
224 output::print_info(&format!(
225 " • {} → {} ({}) [{}] (Dry Run){}",
226 output::clean_path(&result.repo_path),
227 result.bloat_dir,
228 output::format_bytes(result.size_freed),
229 result.adapter_name,
230 output::shared_note(result.shared_bytes, &result.adapter_name)
231 ));
232 }
233 PruneStatus::SkippedActive => {
234 output::print_info(&format!(
235 "{clean} is currently active (not idle). Use `devp --ignore-idle run` to override."
236 ));
237 }
238 PruneStatus::LockfileError(e) => report_lockfile_failure(&result, e),
239 PruneStatus::ActivityCheckError(e) => {
240 output::print_error(&format!(
241 "{clean} skipped — its activity could not be determined:\n {}",
242 e.trim()
243 ));
244 }
245 PruneStatus::DeleteError(e) => {
246 output::print_error(&format!("{clean} delete error: {e}"));
247 }
248 PruneStatus::ConfigError(e) => {
249 output::print_error(&format!(
250 "{clean} skipped — its .devprune.json could not be read:\n {}\n \
251 Fix it, or run `devp config {clean} --update` to reset it.",
252 e.trim()
253 ));
254 }
255 PruneStatus::SkippedSymlink(e) => {
256 output::print_warning(&format!("{clean} → {}", e.trim()));
257 }
258 PruneStatus::SkippedDeclaration(e) => {
259 output::print_warning(&format!("{clean} → {}", e.trim()));
260 }
261 _ => {}
262 }
263 }
264
265 if !args.dry_run && total_freed > 0 {
266 output::print_success(&i18n::tf(
267 "run.freed.targeted",
268 &[
269 ("size", &output::format_bytes(total_freed)),
270 ("path", clean.as_str()),
271 ],
272 ));
273 }
274
275 if error_count > 0 {
276 anyhow::bail!("{error_count} directories in {clean} could not be pruned.");
277 }
278
279 Ok(())
280}
281
282fn record_targeted_prune(path: &std::path::Path, results: &[PruneResult], dry_run: bool) {
288 if dry_run {
289 return;
290 }
291
292 let pruned: Vec<crate::config::PrunedDir> = results
295 .iter()
296 .filter(|r| {
297 matches!(r.status, PruneStatus::Pruned)
298 || (matches!(r.status, PruneStatus::DeleteError(_)) && r.size_freed > 0)
299 })
300 .map(|r| crate::config::PrunedDir {
301 repo_path: r.repo_path.clone(),
302 bloat_dir: r.bloat_dir.clone(),
303 adapter: r.adapter_name.clone(),
304 size_freed: r.size_freed,
305 runtime: r.runtime.clone(),
306 })
307 .collect();
308
309 if pruned.is_empty() {
310 return;
311 }
312
313 let freed: u64 = pruned.iter().map(|d| d.size_freed).sum();
314 if let Ok(mut registry) = Registry::load() {
315 registry.mark_pruned(path, freed);
316 registry.record_prune(pruned);
317 let _ = registry.save();
318 }
319}
320
321fn resolve_min_size(args: &RunArgs<'_>, registry: Option<&Registry>) -> u64 {
326 let mb = args
327 .min_size_mb
328 .or_else(|| registry.map(|r| r.settings.min_size_mb))
329 .unwrap_or(constants::DEFAULT_MIN_SIZE_MB);
330 mb.saturating_mul(engine::BYTES_PER_MIB)
331}
332
333fn parse_except(spec: Option<&str>) -> Vec<String> {
340 spec.map(|s| {
341 s.split(',')
342 .map(|part| {
343 crate::config::expand_tilde(part.trim())
344 .trim_end_matches(['/', '\\'])
345 .to_lowercase()
346 })
347 .filter(|part| !part.is_empty())
348 .collect()
349 })
350 .unwrap_or_default()
351}
352
353fn is_excepted(repo_path: &Path, except: &[String]) -> bool {
360 if except.is_empty() {
361 return false;
362 }
363 let full = output::clean_path(repo_path)
364 .to_lowercase()
365 .replace('\\', "/");
366 let name = repo_path
367 .file_name()
368 .map(|n| n.to_string_lossy().to_lowercase())
369 .unwrap_or_default();
370
371 except.iter().any(|want| {
372 let want = want.replace('\\', "/");
373 name == want || full == want || full.ends_with(&format!("/{want}"))
374 })
375}
376
377fn resolve_scan_depth(registry: Option<&Registry>) -> usize {
379 registry
380 .map(|r| r.settings.scan_depth)
381 .unwrap_or(constants::DEFAULT_SCAN_DEPTH)
382}
383
384fn resolve_build_idle_days(registry: Option<&Registry>) -> u64 {
386 registry
387 .map(|r| r.settings.build_idle_days)
388 .unwrap_or(constants::DEFAULT_BUILD_IDLE_DAYS)
389}
390
391fn resolve_adapter_idle_days(
393 registry: Option<&Registry>,
394) -> std::collections::BTreeMap<String, u64> {
395 registry
396 .map(|r| r.settings.adapter_idle_days.clone())
397 .unwrap_or_default()
398}
399
400fn resolve_command_timeout(registry: Option<&Registry>) -> u64 {
401 registry
402 .map(|r| r.settings.command_timeout_secs)
403 .unwrap_or(constants::DEFAULT_COMMAND_TIMEOUT_SECS)
404}
405
406fn resolve_manifest_rewrite(registry: Option<&Registry>) -> bool {
408 registry
409 .map(|r| r.settings.allow_manifest_rewrite)
410 .unwrap_or(constants::DEFAULT_ALLOW_MANIFEST_REWRITE)
411}
412
413fn run_registry(args: &RunArgs<'_>, filter: &AdapterFilter) -> Result<()> {
415 if !args.json {
416 if args.dry_run {
417 output::print_header(i18n::t("run.header.dry"));
418 } else {
419 output::print_header(i18n::t("run.header"));
420 }
421 }
422
423 let mut registry = Registry::load()?;
424
425 let adopted = crate::commands::link::adopt_enclosing_repo(&mut registry);
429 if adopted.is_some() {
430 registry.save()?;
431 }
432 if let Some(path) = &adopted
433 && !args.json
434 {
435 crate::commands::link::report_cwd_adoption(path);
436 println!();
437 }
438
439 if !args.json && crate::commands::update::notify_if_outdated(&mut registry) {
442 let _ = registry.save();
443 }
444
445 if registry.repo_count() == 0 {
446 if args.json {
447 return json::emit(&json::run_document(&[], args.dry_run));
448 }
449 output::print_warning("No repositories registered. Run `dev-prune init` first.");
450 return Ok(());
451 }
452
453 let except = parse_except(args.except);
457 if !except.is_empty() {
458 let unmatched: Vec<&String> = except
459 .iter()
460 .filter(|want| {
461 !registry
462 .repositories
463 .keys()
464 .any(|p| is_excepted(p, std::slice::from_ref(*want)))
465 })
466 .collect();
467 if !unmatched.is_empty() {
468 anyhow::bail!(
469 "`--except` names no registered repository: {}\n \
470 Run `devp status` to see the registered names.",
471 unmatched
472 .iter()
473 .map(|s| s.as_str())
474 .collect::<Vec<_>>()
475 .join(", ")
476 );
477 }
478 }
479
480 let min_size_bytes = resolve_min_size(args, Some(®istry));
481 let analysis = PruneOptions {
482 idle_days: 0, dry_run: true,
484 force: args.force,
485 only_dirs: None,
486 adapters: filter.clone(),
487 min_size_bytes,
488 scan_depth: resolve_scan_depth(Some(®istry)),
489 allow_manifest_rewrite: resolve_manifest_rewrite(Some(®istry)),
490 command_timeout_secs: resolve_command_timeout(Some(®istry)),
491 build_idle_days: resolve_build_idle_days(Some(®istry)),
492 adapter_idle_days: resolve_adapter_idle_days(Some(®istry)),
493 };
494
495 if !args.json {
496 output::print_info(&format!(
497 "Scanning {} registered repositories for prune candidates...",
498 registry.repo_count()
499 ));
500 if let Some(desc) = filter.describe() {
501 output::print_info(&format!("Adapter filter: {desc}"));
502 }
503 if min_size_bytes > 0 {
504 output::print_info(&format!(
505 "Size floor: ignoring directories under {}",
506 output::format_bytes(min_size_bytes)
507 ));
508 }
509 }
510
511 let mut candidates: Vec<PruneResult> = Vec::new();
520 let mut blocked: Vec<PruneResult> = Vec::new();
521 let mut left_alone: Vec<PruneResult> = Vec::new();
522 let mut missing: Vec<PruneResult> = Vec::new();
523 for result in engine::prune_all_with(&mut registry, &analysis) {
524 if is_excepted(&result.repo_path, &except) {
528 continue;
529 }
530 match result.status {
531 PruneStatus::SkippedDryRun => candidates.push(result),
532 PruneStatus::ConfigError(_)
533 | PruneStatus::LockfileError(_)
534 | PruneStatus::ActivityCheckError(_)
535 | PruneStatus::DeleteError(_) => blocked.push(result),
536 PruneStatus::SkippedSymlink(_) | PruneStatus::SkippedDeclaration(_) => {
541 left_alone.push(result)
542 }
543 PruneStatus::PathMissing => missing.push(result),
546 _ => {}
547 }
548 }
549
550 if !args.json && !except.is_empty() {
551 output::print_info(&format!("Leaving alone: {}", except.join(", ")));
552 }
553
554 if args.daemon {
555 let before = candidates.len();
556 candidates.retain(|c| {
557 match crate::config::PerRepoConfig::load_with_diagnostics(&c.repo_path) {
561 Ok(Some(cfg)) => !cfg.disable_daemon,
562 Ok(None) => true,
563 Err(_) => false,
564 }
565 });
566 let skipped = before - candidates.len();
567 if skipped > 0 && !args.json {
568 output::print_info(&format!(
569 "Skipped {skipped} bloat directories in repositories that set `disable_daemon`."
570 ));
571 }
572 }
573
574 if args.dry_run {
576 if args.json {
577 json::emit(&json::run_document(
578 &[candidates, blocked, left_alone, missing].concat(),
579 true,
580 ))?;
581 return Ok(());
582 }
583 if candidates.is_empty()
584 && blocked.is_empty()
585 && left_alone.is_empty()
586 && missing.is_empty()
587 {
588 output::print_info(i18n::t("run.nothing"));
589 return Ok(());
590 }
591 if !candidates.is_empty() {
592 report_candidates(&candidates);
593 }
594 let total: u64 = candidates.iter().map(|c| c.size_freed).sum();
595 output::print_header(i18n::t("run.summary.dry"));
596 output::print_info(&i18n::tf(
597 "run.would_free",
598 &[
599 ("size", &output::format_bytes(total)),
600 ("count", &candidates.len().to_string()),
601 ],
602 ));
603 report_blocked(&blocked);
606 report_left_alone(&left_alone);
607 report_missing(&missing);
608 return Ok(());
609 }
610
611 if candidates.is_empty() {
612 if args.json {
613 json::emit(&json::run_document(
614 &[blocked.clone(), left_alone, missing].concat(),
615 false,
616 ))?;
617 return fail_if_blocked(&blocked);
618 }
619 if blocked.is_empty() && left_alone.is_empty() && missing.is_empty() {
620 output::print_info(i18n::t("run.nothing"));
621 return Ok(());
622 }
623 output::print_info(i18n::t("run.nothing.bloat"));
624 report_blocked(&blocked);
625 report_left_alone(&left_alone);
626 report_missing(&missing);
627 return fail_if_blocked(&blocked);
628 }
629
630 let total_reclaimable: u64 = candidates.iter().map(|c| c.size_freed).sum();
631
632 if !args.json {
633 report_binaries(&candidates);
634 report_candidates(&candidates);
635 output::print_info(&i18n::tf(
636 "run.reclaimable",
637 &[("size", &output::format_bytes_styled(total_reclaimable))],
638 ));
639 report_blocked(&blocked);
640 report_left_alone(&left_alone);
641 report_missing(&missing);
642 }
643
644 let target_candidates: Vec<PruneResult> = if args.json
647 || args.yes
648 || !registry.settings.require_confirmation
649 {
650 candidates
651 } else if io::stdout().is_terminal() && io::stdin().is_terminal() {
652 eprintln!();
653 eprintln!(
654 " Loading interactive selector... (↑↓ navigate, Space toggle, Enter confirm, q cancel)"
655 );
656 eprintln!();
657 let selected = tui::selection_view::select_candidates_tui(&candidates)?;
658 if selected.is_empty() {
659 output::print_info("Prune pass cancelled by user (0 candidates selected).");
660 return Ok(());
661 }
662 selected
663 } else {
664 if !io::stdin().is_terminal() {
668 anyhow::bail!(
669 "Deleting {} directories ({}) needs confirmation, and there is no \
670 terminal to ask on. Re-run with `--yes` to confirm, or `--dry-run` \
671 to only analyse.",
672 candidates.len(),
673 output::format_bytes(total_reclaimable)
674 );
675 }
676 println!();
677 output::print_warning("CAUTION: Deleting bloat directories cannot be undone directly.");
678 output::print_info(
679 "Note: You can re-install missing dependencies anytime using `dev-prune restore`.",
680 );
681 eprint!(
684 "Proceed with deletion of {} directories ({})? [y/N]: ",
685 candidates.len(),
686 output::format_bytes(total_reclaimable)
687 );
688 io::stderr().flush()?;
689
690 let mut input = String::new();
691 io::stdin().read_line(&mut input)?;
692 let trimmed = input.trim().to_lowercase();
693 if trimmed != "y" && trimmed != "yes" {
694 output::print_info("Prune pass aborted by user.");
695 return Ok(());
696 }
697 candidates
698 };
699
700 if !args.json {
701 let selected_total_bytes: u64 = target_candidates.iter().map(|c| c.size_freed).sum();
702 output::print_header(&i18n::tf(
703 "run.header.deleting",
704 &[
705 ("repos", &target_candidates.len().to_string()),
706 ("size", &output::format_bytes(selected_total_bytes)),
707 ],
708 ));
709 }
710
711 let mut selection: Vec<(std::path::PathBuf, Vec<String>)> = Vec::new();
717 for candidate in &target_candidates {
718 match selection
719 .iter_mut()
720 .find(|(p, _)| *p == candidate.repo_path)
721 {
722 Some((_, dirs)) => dirs.push(candidate.bloat_dir.clone()),
723 None => selection.push((
724 candidate.repo_path.clone(),
725 vec![candidate.bloat_dir.clone()],
726 )),
727 }
728 }
729
730 let mut error_count = blocked.len();
734 let mut all_results: Vec<PruneResult> = blocked;
735 all_results.extend(left_alone);
736 all_results.extend(missing);
737 let mut total_freed: u64 = 0;
738 let mut pruned_count = 0;
739 let mut pruned_dirs: Vec<crate::config::PrunedDir> = Vec::new();
740 let pass_at = chrono::Utc::now();
743
744 for (repo_path, dirs) in &selection {
745 let recorded_before = pruned_dirs.len();
746 let idle_days = registry
751 .repositories
752 .get(repo_path)
753 .and_then(|e| e.override_idle_days)
754 .unwrap_or(registry.settings.idle_days);
755 let single_results = engine::prune_repo_with(
756 repo_path,
757 &PruneOptions {
758 idle_days,
759 dry_run: false,
760 force: args.force,
761 only_dirs: Some(dirs.clone()),
762 adapters: filter.clone(),
763 min_size_bytes: 0,
764 scan_depth: analysis.scan_depth,
765 allow_manifest_rewrite: analysis.allow_manifest_rewrite,
766 command_timeout_secs: analysis.command_timeout_secs,
767 build_idle_days: analysis.build_idle_days,
768 adapter_idle_days: analysis.adapter_idle_days.clone(),
769 },
770 );
771 for result in single_results {
772 match &result.status {
773 PruneStatus::Pruned => {
774 total_freed += result.size_freed;
775 pruned_count += 1;
776 registry.mark_pruned(&result.repo_path, result.size_freed);
777 pruned_dirs.push(crate::config::PrunedDir {
778 repo_path: result.repo_path.clone(),
779 bloat_dir: result.bloat_dir.clone(),
780 adapter: result.adapter_name.clone(),
781 size_freed: result.size_freed,
782 runtime: result.runtime.clone(),
783 });
784 if !args.json {
785 output::print_success(&format!(
786 "{} → {} ({}) — {}{}",
787 output::clean_path(&result.repo_path),
788 result.bloat_dir,
789 output::format_bytes(result.size_freed),
790 result.adapter_name,
791 output::shared_note(result.shared_bytes, &result.adapter_name)
792 ));
793 }
794 }
795 PruneStatus::LockfileError(e) => {
796 error_count += 1;
797 if !args.json {
798 report_lockfile_failure(&result, e);
799 }
800 }
801 PruneStatus::ActivityCheckError(e) => {
802 error_count += 1;
803 if !args.json {
804 output::print_error(&format!(
805 "{} skipped — its activity could not be determined:\n {}",
806 output::clean_path(&result.repo_path),
807 e.trim()
808 ));
809 }
810 }
811 PruneStatus::DeleteError(e) => {
812 error_count += 1;
813 if result.size_freed > 0 {
818 pruned_dirs.push(crate::config::PrunedDir {
819 repo_path: result.repo_path.clone(),
820 bloat_dir: result.bloat_dir.clone(),
821 adapter: result.adapter_name.clone(),
822 size_freed: result.size_freed,
823 runtime: result.runtime.clone(),
824 });
825 }
826 if !args.json {
827 output::print_error(&format!(
828 "{} → delete failed: {}",
829 output::clean_path(&result.repo_path),
830 e,
831 ));
832 }
833 }
834 PruneStatus::ConfigError(e) => {
835 error_count += 1;
836 if !args.json {
837 let clean_p = output::clean_path(&result.repo_path);
838 output::print_error(&format!(
839 "{clean_p} skipped — its .devprune.json could not be read:\n {}",
840 e.trim()
841 ));
842 output::print_info(&format!(
843 " Fix command: devp config {clean_p} --update"
844 ));
845 }
846 }
847 PruneStatus::SkippedActive if !args.json => {
850 output::print_info(&format!(
851 "{} became active since the analysis — left alone. \
852 Use `--ignore-idle` to prune it anyway.",
853 output::clean_path(&result.repo_path)
854 ));
855 }
856 _ => {}
857 }
858 all_results.push(result);
859 }
860
861 if pruned_dirs.len() > recorded_before {
867 registry.record_prune_progress(pass_at, pruned_dirs.clone());
868 let _ = registry.save();
869 }
870 }
871
872 registry.record_prune_progress(pass_at, pruned_dirs);
873 registry.save()?;
874
875 if args.json {
876 json::emit(&json::run_document(&all_results, false))?;
877 if error_count > 0 {
880 anyhow::bail!("{error_count} repositories could not be pruned.");
881 }
882 return Ok(());
883 }
884
885 output::print_header(i18n::t("run.summary"));
886 output::print_success(&i18n::tf(
887 "run.freed",
888 &[
889 ("size", &output::format_bytes_styled(total_freed)),
890 ("count", &pruned_count.to_string()),
891 ],
892 ));
893
894 if error_count > 0 {
895 output::print_warning(&i18n::tf(
896 "run.not_pruned",
897 &[("count", &error_count.to_string())],
898 ));
899
900 if all_results
904 .iter()
905 .any(|r| matches!(r.status, PruneStatus::LockfileError(_)))
906 {
907 output::print_info(
911 "Lockfile verification cannot be bypassed: without a lockfile the deleted \
912 dependencies could not be reinstalled. Run the fix command shown above for \
913 each repo, then re-run `devp run`.",
914 );
915 }
916 anyhow::bail!("{error_count} repositories could not be pruned.");
918 }
919
920 crate::commands::update::maybe_auto_update(®istry);
923
924 Ok(())
925}
926
927#[derive(Debug, PartialEq, Eq, Clone, Copy)]
938enum ActivityFailure {
939 UntrustedOwner,
941 NotARepository,
943 Individual,
945}
946
947impl ActivityFailure {
948 fn classify(message: &str) -> Self {
955 let lower = message.to_lowercase();
956 if lower.contains(constants::GIT_DUBIOUS_OWNERSHIP) {
957 Self::UntrustedOwner
958 } else if lower.contains(constants::GIT_NOT_A_REPOSITORY) {
959 Self::NotARepository
960 } else {
961 Self::Individual
962 }
963 }
964}
965
966const GROUPED_PATHS_SHOWN: usize = 8;
971
972fn report_blocked(blocked: &[PruneResult]) {
976 if blocked.is_empty() {
977 return;
978 }
979 output::print_header(&i18n::tf(
980 "run.header.blocked",
981 &[("count", &blocked.len().to_string())],
982 ));
983
984 let grouped = |failure: ActivityFailure| -> Vec<&PruneResult> {
985 blocked
986 .iter()
987 .filter(|r| match &r.status {
988 PruneStatus::ActivityCheckError(e) => ActivityFailure::classify(e) == failure,
989 _ => false,
990 })
991 .collect()
992 };
993
994 let untrusted = grouped(ActivityFailure::UntrustedOwner);
995 if !untrusted.is_empty() {
996 let n = untrusted.len();
997 output::print_error(&format!(
998 "{n} {} owned by a different account — Git will not read {}.",
999 output::plural(n, "repository is", "repositories are"),
1000 output::plural(n, "it", "them")
1001 ));
1002 list_paths(&untrusted);
1003 output::print_wrapped(
1004 " ",
1005 "Nothing is wrong with the repositories themselves. The owner recorded on \
1006 disk is usually one a Windows reinstall, a restored backup or a drive moved \
1007 between machines left behind.",
1008 );
1009 output::print_wrapped(
1010 " ",
1011 "dev-prune dates a repository by its last commit, so one Git will not open \
1012 has no known age — and nothing is ever deleted from a repository whose age is \
1013 unknown.",
1014 );
1015 output::print_info(&format!(
1016 " Fix all {n} at once: devp trust --fix-ownership"
1017 ));
1018 }
1019
1020 let orphaned = grouped(ActivityFailure::NotARepository);
1021 if !orphaned.is_empty() {
1022 if !untrusted.is_empty() {
1023 println!();
1024 }
1025 let n = orphaned.len();
1026 output::print_error(&format!(
1027 "{n} registered {} not {} git {} any more.",
1028 output::plural(n, "path is", "paths are"),
1029 output::plural(n, "a", ""),
1030 output::plural(n, "repository", "repositories")
1031 ));
1032 list_paths(&orphaned);
1033 output::print_wrapped(
1034 " ",
1035 "The directory is still there; its `.git` is not — a clone deleted and \
1036 recreated by hand, or a worktree `git worktree prune` has since removed. The \
1037 registry entry outlived what it pointed at.",
1038 );
1039 output::print_info(&format!(
1043 " Drop {} from the registry: devp unlink <path>",
1044 output::plural(n, "it", "them")
1045 ));
1046 }
1047
1048 for result in blocked {
1049 let clean_p = output::clean_path(&result.repo_path);
1050 match &result.status {
1051 PruneStatus::ConfigError(e) => {
1052 output::print_error(&format!(
1053 "{clean_p} skipped — its .devprune.json could not be read:
1054 {}",
1055 e.trim()
1056 ));
1057 output::print_info(&format!(
1058 " Fix command: devp config {clean_p} --update"
1059 ));
1060 }
1061 PruneStatus::LockfileError(e) => report_lockfile_failure(result, e),
1062 PruneStatus::ActivityCheckError(e)
1063 if ActivityFailure::classify(e) == ActivityFailure::Individual =>
1064 {
1065 output::print_error(&format!(
1066 "{clean_p} skipped — its activity could not be determined:
1067 {}",
1068 output::condense_tool_output(e, 4)
1069 ));
1070 }
1071 PruneStatus::DeleteError(e) => {
1072 output::print_error(&format!("{clean_p} → delete failed: {e}"));
1073 }
1074 _ => {}
1076 }
1077 }
1078}
1079
1080fn list_paths(results: &[&PruneResult]) {
1085 for result in results.iter().take(GROUPED_PATHS_SHOWN) {
1086 println!(" {}", output::styled_path(&result.repo_path));
1087 }
1088 if let Some(rest) = results
1089 .len()
1090 .checked_sub(GROUPED_PATHS_SHOWN)
1091 .filter(|n| *n > 0)
1092 {
1093 output::print_dimmed(&format!(
1094 " … and {rest} more — `devp run --dry-run --json` lists every one."
1095 ));
1096 }
1097}
1098
1099fn report_left_alone(left_alone: &[PruneResult]) {
1107 for result in left_alone {
1108 if let PruneStatus::SkippedSymlink(e) | PruneStatus::SkippedDeclaration(e) = &result.status
1109 {
1110 output::print_warning(&format!(
1111 "{} → {}",
1112 output::clean_path(&result.repo_path),
1113 e.trim()
1114 ));
1115 }
1116 }
1117}
1118
1119fn report_missing(missing: &[PruneResult]) {
1125 if missing.is_empty() {
1126 return;
1127 }
1128 println!();
1129 let n = missing.len();
1130 output::print_warning(&format!(
1131 "{n} registered {} no longer {} on disk.",
1132 output::plural(n, "path", "paths"),
1133 output::plural(n, "exists", "exist")
1134 ));
1135 for result in missing.iter().take(GROUPED_PATHS_SHOWN) {
1140 println!(" {}", output::styled_path(&result.repo_path));
1141 }
1142 if let Some(rest) = missing
1143 .len()
1144 .checked_sub(GROUPED_PATHS_SHOWN)
1145 .filter(|n| *n > 0)
1146 {
1147 output::print_dimmed(&format!(
1148 " … and {rest} more — `devp run --dry-run --json` lists every one."
1149 ));
1150 }
1151 output::print_info(&format!(
1152 " Clear {} from the registry: devp unlink --missing",
1153 output::plural(n, "it", "them all")
1154 ));
1155}
1156
1157fn fail_if_blocked(blocked: &[PruneResult]) -> Result<()> {
1162 if blocked.is_empty() {
1163 return Ok(());
1164 }
1165 anyhow::bail!("{} repositories could not be examined.", blocked.len());
1166}
1167
1168fn report_binaries(candidates: &[PruneResult]) {
1170 let adapter_names: Vec<String> = candidates.iter().map(|c| c.adapter_name.clone()).collect();
1171 let binary_statuses = adapters::scan_required_binaries(&adapter_names);
1172 if binary_statuses.is_empty() {
1173 return;
1174 }
1175 output::print_header(i18n::t("run.header.binaries"));
1176 for b in &binary_statuses {
1177 if b.available {
1178 output::print_success(&format!(
1179 " {} — available ({})",
1180 b.name,
1181 b.version.as_deref().unwrap_or("detected")
1182 ));
1183 } else {
1184 output::print_warning(&format!(
1185 " {} — missing (lockfile fallback active)",
1186 b.name
1187 ));
1188 }
1189 }
1190}
1191
1192fn report_candidates(candidates: &[PruneResult]) {
1193 output::print_header(i18n::t("run.header.candidates"));
1194 for candidate in candidates {
1195 output::print_info(&format!(
1196 " • {} → {} ({}) [{}]{}",
1197 output::styled_path(&candidate.repo_path),
1198 candidate.bloat_dir,
1199 output::format_bytes_styled(candidate.size_freed),
1200 output::styled_adapter(&candidate.adapter_name),
1201 output::shared_note(candidate.shared_bytes, &candidate.adapter_name)
1202 ));
1203 }
1204}
1205
1206pub(crate) fn report_lockfile_failure(result: &PruneResult, error: &str) {
1207 let project = output::clean_path(result.project_dir());
1210
1211 output::print_error(&format!(
1212 "{} → {} lockfile sync failed:\n {}",
1213 project,
1214 result.adapter_name,
1215 error.trim(),
1216 ));
1217 match json::lockfile_fix_command(&result.adapter_name) {
1218 Some(sync_cmd) => {
1219 #[cfg(windows)]
1222 let manual_cmd = format!("cd \"{project}\"; {sync_cmd}");
1223 #[cfg(not(windows))]
1224 let manual_cmd = format!("cd \"{project}\" && {sync_cmd}");
1225 output::print_info(&format!(" Fix command: {manual_cmd}"));
1226 }
1227 None => output::print_info(&format!(" Fix it in: {project}")),
1230 }
1231 output::print_info(&format!(
1232 " Troubleshooting: {}",
1233 constants::TROUBLESHOOTING_URL
1234 ));
1235}
1236
1237fn run_explain(args: &RunArgs<'_>, filter: &AdapterFilter) -> Result<()> {
1248 output::print_header(i18n::t("run.header.reasons"));
1249 if let Some(desc) = filter.describe() {
1250 output::print_info(&format!("Adapter filter: {desc}"));
1251 }
1252
1253 if let Some(target_str) = args.target_path {
1254 let raw = Path::new(target_str);
1255 let path = if raw.exists() {
1256 raw.canonicalize().unwrap_or_else(|_| raw.to_path_buf())
1257 } else {
1258 raw.to_path_buf()
1259 };
1260 if !crate::scanner::is_git_repo(&path) {
1261 anyhow::bail!(
1262 "{} is not a Git repository — dev-prune only prunes Git repos.",
1263 output::clean_path(&path)
1264 );
1265 }
1266 let registry = Registry::load().ok();
1267 let idle_days = registry
1268 .as_ref()
1269 .map(|r| {
1270 r.repositories
1271 .get(&path)
1272 .and_then(|e| e.override_idle_days)
1273 .unwrap_or(r.settings.idle_days)
1274 })
1275 .unwrap_or(constants::DEFAULT_IDLE_DAYS);
1276 let floor = resolve_min_size(args, registry.as_ref());
1277 let results = engine::prune_repo_with(
1278 &path,
1279 &PruneOptions {
1280 idle_days,
1281 dry_run: true,
1282 force: args.force,
1283 only_dirs: None,
1284 adapters: filter.clone(),
1285 min_size_bytes: 0,
1286 scan_depth: resolve_scan_depth(registry.as_ref()),
1287 allow_manifest_rewrite: resolve_manifest_rewrite(registry.as_ref()),
1288 command_timeout_secs: resolve_command_timeout(registry.as_ref()),
1289 build_idle_days: resolve_build_idle_days(registry.as_ref()),
1290 adapter_idle_days: resolve_adapter_idle_days(registry.as_ref()),
1291 },
1292 );
1293 let refs: Vec<&PruneResult> = results.iter().collect();
1294 explain_repo(&path, &refs, floor, idle_days);
1295 print_explain_footer();
1296 return Ok(());
1297 }
1298
1299 let mut registry = Registry::load()?;
1300 if registry.repo_count() == 0 {
1301 output::print_warning("No repositories registered. Run `dev-prune init` first.");
1302 return Ok(());
1303 }
1304
1305 let except = parse_except(args.except);
1306 let global_floor = resolve_min_size(args, Some(®istry));
1307 let analysis = PruneOptions {
1308 idle_days: 0, dry_run: true,
1310 force: args.force,
1311 only_dirs: None,
1312 adapters: filter.clone(),
1313 min_size_bytes: 0,
1314 scan_depth: resolve_scan_depth(Some(®istry)),
1315 allow_manifest_rewrite: resolve_manifest_rewrite(Some(®istry)),
1316 command_timeout_secs: resolve_command_timeout(Some(®istry)),
1317 build_idle_days: resolve_build_idle_days(Some(®istry)),
1318 adapter_idle_days: resolve_adapter_idle_days(Some(®istry)),
1319 };
1320 let results = engine::prune_all_with(&mut registry, &analysis);
1321
1322 let mut by_repo: std::collections::HashMap<&Path, Vec<&PruneResult>> =
1323 std::collections::HashMap::new();
1324 for r in &results {
1325 by_repo.entry(r.repo_path.as_path()).or_default().push(r);
1326 }
1327
1328 let mut repos: Vec<&std::path::PathBuf> = registry.repositories.keys().collect();
1329 repos.sort();
1330 for path in repos {
1331 if is_excepted(path, &except) {
1332 println!();
1333 output::print_info(&output::clean_path(path));
1334 println!(" • left completely alone this pass (`--except`)");
1335 continue;
1336 }
1337 let idle_days = registry
1338 .repositories
1339 .get(path)
1340 .and_then(|e| e.override_idle_days)
1341 .unwrap_or(registry.settings.idle_days);
1342 let empty = Vec::new();
1343 let repo_results = by_repo.get(path.as_path()).unwrap_or(&empty);
1344 explain_repo(path, repo_results, global_floor, idle_days);
1345 }
1346 print_explain_footer();
1347 Ok(())
1348}
1349
1350fn explain_repo(path: &Path, results: &[&PruneResult], floor: u64, idle_days: u64) {
1352 println!();
1353 output::print_info(&output::clean_path(path));
1354
1355 if results.is_empty() {
1356 println!(
1357 " • idle, but no known bloat directories were found. A project deeper than \
1358 `scan_depth` is not examined — `devp status` shows what dev-prune can see."
1359 );
1360 return;
1361 }
1362
1363 for r in results {
1364 match &r.status {
1365 PruneStatus::SkippedDryRun => {
1366 if r.size_freed >= floor {
1367 output::print_success(&format!(
1368 "would prune {} ({}) [{}]{}",
1369 r.bloat_dir,
1370 output::format_bytes(r.size_freed),
1371 r.adapter_name,
1372 output::shared_note(r.shared_bytes, &r.adapter_name)
1373 ));
1374 } else {
1375 println!(
1376 " • {} ({}) is under the size floor of {} — the reinstall would \
1377 cost more than the space is worth. `--min-size 0` includes it.",
1378 r.bloat_dir,
1379 output::format_bytes(r.size_freed),
1380 output::format_bytes(floor)
1381 );
1382 }
1383 }
1384 PruneStatus::SkippedActive => {
1385 let age = crate::scanner::git::get_last_activity(path)
1386 .ok()
1387 .flatten()
1388 .and_then(|t| std::time::SystemTime::now().duration_since(t).ok())
1389 .map(|d| d.as_secs() / 86_400);
1390 match age {
1391 Some(0) => println!(
1392 " • active — there was activity today, and the idle \
1393 threshold is {idle_days} days. `--ignore-idle` overrides."
1394 ),
1395 Some(days) => println!(
1396 " • active — last activity {days} day{} ago, and the idle \
1397 threshold is {idle_days} days. `--ignore-idle` overrides.",
1398 if days == 1 { "" } else { "s" }
1399 ),
1400 None => println!(
1401 " • active (not idle for {idle_days} days yet). \
1402 `--ignore-idle` overrides."
1403 ),
1404 }
1405 }
1406 other => println!(" • {other}"),
1407 }
1408 }
1409}
1410
1411fn print_explain_footer() {
1413 println!();
1414 output::print_info(
1415 "Nothing was verified or deleted. `devp run --dry-run` verifies candidates; \
1416 `devp run` prunes.",
1417 );
1418}
1419
1420#[cfg(test)]
1421mod tests {
1422 use super::*;
1423
1424 #[test]
1425 fn gits_ownership_refusal_is_recognised_whatever_the_path() {
1426 let message = "git could not read `V:/x`: fatal: detected dubious ownership in repository \
1430 at 'V:/x'";
1431 assert_eq!(
1432 ActivityFailure::classify(message),
1433 ActivityFailure::UntrustedOwner
1434 );
1435 }
1436
1437 #[test]
1438 fn a_path_that_lost_its_git_directory_is_its_own_cause() {
1439 let message = "fatal: not a git repository (or any of the parent directories): .git";
1443 assert_eq!(
1444 ActivityFailure::classify(message),
1445 ActivityFailure::NotARepository
1446 );
1447 }
1448
1449 #[test]
1450 fn an_unfamiliar_failure_is_still_printed_in_full() {
1451 assert_eq!(
1452 ActivityFailure::classify("fatal: unable to read tree"),
1453 ActivityFailure::Individual
1454 );
1455 }
1456}