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::setup;
23use crate::tui;
24
25pub struct RunArgs<'a> {
30 pub target_path: Option<&'a str>,
32 pub dry_run: bool,
34 pub force: bool,
36 pub yes: bool,
38 pub daemon: bool,
40 pub only: Option<&'a str>,
42 pub skip: Option<&'a str>,
44 pub min_size_mb: Option<u64>,
46 pub except: Option<&'a str>,
48 pub json: bool,
50 pub explain: bool,
52}
53
54pub fn run(args: RunArgs<'_>) -> Result<()> {
59 let filter = AdapterFilter::new(args.only, args.skip)?;
60
61 if args.json && !args.dry_run && !args.yes {
65 return Err(anyhow::Error::new(crate::UsageError(
66 "`--json` cannot ask for confirmation. Pass `--dry-run` to analyse, or `--yes` to delete."
67 .to_string(),
68 )));
69 }
70
71 if !args.json {
72 output::print_banner();
73 if args.force {
74 print_ignore_idle_notice();
75 }
76 }
77
78 if args.explain {
79 return run_explain(&args, &filter);
80 }
81
82 if let Some(target_str) = args.target_path {
83 return run_targeted(&args, &filter, target_str);
84 }
85 run_registry(&args, &filter)
86}
87
88fn print_ignore_idle_notice() {
95 output::print_warning(
96 "Idle check bypassed — repositories you are working in right now are fair game.",
97 );
98 println!(
99 " Still enforced: lockfile verification, `ignore.devprune.json`, `\"ignore\": true`,"
100 );
101 println!(
102 " symlinked directories, and nested repositories. This flag does not turn those off."
103 );
104 println!();
105 println!(" If you reached for this because something would not prune, it is usually:");
106 println!(" • \"lockfile verification failed\" → run the fix command printed next to it;");
107 println!(" it regenerates the lockfile so the reinstall is guaranteed to work.");
108 println!(" • nothing listed at all → the project is deeper than `scan_depth`,");
109 println!(" or under `min_size_mb`. Try `devp status` to see what dev-prune can see.");
110 println!(" • \"could not be examined\" → `.devprune.json` has a syntax error.");
111 println!();
112 println!(" Still stuck? Point your AI assistant at the bundled skill — `devp skill`");
113 println!(" exports a SKILL.md that teaches it this tool, exit codes and all. It has");
114 println!(" read the manual more recently than either of us.");
115 println!();
116}
117
118fn run_targeted(args: &RunArgs<'_>, filter: &AdapterFilter, target_str: &str) -> Result<()> {
120 let raw = std::path::Path::new(target_str);
121 let path = if raw.exists() {
122 raw.canonicalize().unwrap_or_else(|_| raw.to_path_buf())
123 } else {
124 raw.to_path_buf()
125 };
126
127 let clean = output::clean_path(&path);
128 if !crate::scanner::is_git_repo(&path) {
129 anyhow::bail!("{clean} is not a Git repository — dev-prune only prunes Git repos.");
132 }
133
134 let registry = Registry::load().ok();
138 let idle_days = registry
139 .as_ref()
140 .map(|r| {
141 r.repositories
142 .get(&path)
143 .and_then(|e| e.override_idle_days)
144 .unwrap_or(r.settings.idle_days)
145 })
146 .unwrap_or(constants::DEFAULT_IDLE_DAYS);
147
148 let mut opts = PruneOptions {
149 idle_days,
150 dry_run: args.dry_run,
151 force: args.force,
152 only_dirs: None,
153 adapters: filter.clone(),
154 min_size_bytes: resolve_min_size(args, registry.as_ref()),
155 scan_depth: resolve_scan_depth(registry.as_ref()),
156 allow_manifest_rewrite: resolve_manifest_rewrite(registry.as_ref()),
157 command_timeout_secs: resolve_command_timeout(registry.as_ref()),
158 build_idle_days: resolve_build_idle_days(registry.as_ref()),
159 adapter_idle_days: resolve_adapter_idle_days(registry.as_ref()),
160 };
161
162 if !args.json {
163 output::print_header(&i18n::tf(
164 "run.header.targeted",
165 &[("path", clean.as_str())],
166 ));
167 if let Some(desc) = filter.describe() {
168 output::print_info(&format!("Adapter filter: {desc}"));
169 }
170 }
171
172 if !args.dry_run
178 && !args.json
179 && !args.yes
180 && registry
181 .as_ref()
182 .is_none_or(|r| r.settings.require_confirmation)
183 {
184 let preview = engine::prune_repo_with(
185 &path,
186 &PruneOptions {
187 dry_run: true,
188 ..opts.clone()
189 },
190 );
191 let candidates: Vec<PruneResult> = preview
192 .into_iter()
193 .filter(|r| matches!(r.status, PruneStatus::SkippedDryRun))
194 .collect();
195 if !candidates.is_empty() {
198 let total: u64 = candidates.iter().map(|c| c.size_freed).sum();
199 report_candidates(&candidates);
200 output::print_info(&i18n::tf(
201 "run.reclaimable",
202 &[("size", &output::format_bytes_styled(total))],
203 ));
204 output::print_info(
205 "Everything above is rebuilt from a lockfile — `devp restore` brings it back.",
206 );
207 if !io::stdin().is_terminal() {
208 anyhow::bail!(
209 "Deleting {} directories ({}) needs confirmation, and there is no \
210 terminal to ask on. Re-run with `--yes` to confirm, or `--dry-run` \
211 to only analyse.",
212 candidates.len(),
213 output::format_bytes(total)
214 );
215 }
216 eprint!(
219 "Proceed with deletion of {} directories ({})? [y/N]: ",
220 candidates.len(),
221 output::format_bytes(total)
222 );
223 io::stderr().flush()?;
224 let mut input = String::new();
225 io::stdin().read_line(&mut input)?;
226 let trimmed = input.trim().to_lowercase();
227 if trimmed != "y" && trimmed != "yes" {
228 output::print_info("Prune pass aborted by user.");
229 return Ok(());
230 }
231 opts.only_dirs = Some(candidates.iter().map(|c| c.bloat_dir.clone()).collect());
235 }
236 }
237
238 let results = engine::prune_repo_with(&path, &opts);
239
240 let error_count = results
244 .iter()
245 .filter(|r| {
246 matches!(
247 r.status,
248 PruneStatus::LockfileError(_)
249 | PruneStatus::ActivityCheckError(_)
250 | PruneStatus::DeleteError(_)
251 | PruneStatus::ConfigError(_)
252 )
253 })
254 .count();
255
256 record_targeted_prune(&path, &results, args.dry_run);
261
262 if args.json {
263 json::emit(&json::run_document(&results, args.dry_run))?;
264 if error_count > 0 {
265 anyhow::bail!("{error_count} directories in {clean} could not be pruned.");
266 }
267 return Ok(());
268 }
269
270 if results.is_empty() {
271 output::print_info(&i18n::tf(
272 "run.nothing.bloat.targeted",
273 &[("path", clean.as_str())],
274 ));
275 return Ok(());
276 }
277
278 let mut total_freed = 0;
279 for result in results {
280 match &result.status {
281 PruneStatus::Pruned => {
282 total_freed += result.size_freed;
283 output::print_success(&format!(
284 "{} → {} ({}) — {}{}",
285 output::clean_path(&result.repo_path),
286 result.bloat_dir,
287 output::format_bytes(result.size_freed),
288 result.adapter_name,
289 output::shared_note(result.shared_bytes, &result.adapter_name)
290 ));
291 }
292 PruneStatus::SkippedDryRun => {
293 output::print_info(&format!(
294 " • {} → {} ({}) [{}] (Dry Run){}",
295 output::clean_path(&result.repo_path),
296 result.bloat_dir,
297 output::format_bytes(result.size_freed),
298 result.adapter_name,
299 output::shared_note(result.shared_bytes, &result.adapter_name)
300 ));
301 }
302 PruneStatus::SkippedActive => {
303 output::print_info(&format!(
304 "{clean} is currently active (not idle). Use `devp --ignore-idle run` to override."
305 ));
306 }
307 PruneStatus::LockfileError(e) => report_lockfile_failure(&result, e),
308 PruneStatus::ActivityCheckError(e) => {
309 output::print_error(&format!(
310 "{clean} skipped — its activity could not be determined:\n {}",
311 e.trim()
312 ));
313 }
314 PruneStatus::DeleteError(e) => {
315 output::print_error(&format!("{clean} delete error: {e}"));
316 }
317 PruneStatus::ConfigError(e) => {
318 output::print_error(&format!(
319 "{clean} skipped — its .devprune.json could not be read:\n {}\n \
320 Fix it, or run `devp config {clean} --update` to reset it.",
321 e.trim()
322 ));
323 }
324 PruneStatus::SkippedSymlink(e) => {
325 output::print_warning(&format!("{clean} → {}", e.trim()));
326 }
327 PruneStatus::SkippedDeclaration(e) => {
328 output::print_warning(&format!("{clean} → {}", e.trim()));
329 }
330 PruneStatus::SkippedNestedRepo(e) => {
331 output::print_warning(&format!("{clean} → {}", e.trim()));
332 }
333 _ => {}
334 }
335 }
336
337 if !args.dry_run && total_freed > 0 {
338 output::print_success(&i18n::tf(
339 "run.freed.targeted",
340 &[
341 ("size", &output::format_bytes(total_freed)),
342 ("path", clean.as_str()),
343 ],
344 ));
345 }
346
347 if error_count > 0 {
348 anyhow::bail!("{error_count} directories in {clean} could not be pruned.");
349 }
350
351 Ok(())
352}
353
354fn record_targeted_prune(path: &std::path::Path, results: &[PruneResult], dry_run: bool) {
360 if dry_run {
361 return;
362 }
363
364 let pruned: Vec<crate::config::PrunedDir> = results
367 .iter()
368 .filter(|r| {
369 matches!(r.status, PruneStatus::Pruned)
370 || (matches!(r.status, PruneStatus::DeleteError(_)) && r.size_freed > 0)
371 })
372 .map(|r| crate::config::PrunedDir {
373 repo_path: r.repo_path.clone(),
374 bloat_dir: r.bloat_dir.clone(),
375 adapter: r.adapter_name.clone(),
376 size_freed: r.size_freed,
377 runtime: r.runtime.clone(),
378 })
379 .collect();
380
381 if pruned.is_empty() {
382 return;
383 }
384
385 let freed: u64 = pruned.iter().map(|d| d.size_freed).sum();
386 if let Ok(mut registry) = Registry::load() {
387 registry.mark_pruned(path, freed);
388 registry.record_prune(pruned);
389 let _ = registry.save();
390 }
391}
392
393fn resolve_min_size(args: &RunArgs<'_>, registry: Option<&Registry>) -> u64 {
398 let mb = args
399 .min_size_mb
400 .or_else(|| registry.map(|r| r.settings.min_size_mb))
401 .unwrap_or(constants::DEFAULT_MIN_SIZE_MB);
402 mb.saturating_mul(engine::BYTES_PER_MIB)
403}
404
405fn parse_except(spec: Option<&str>) -> Vec<String> {
412 spec.map(|s| {
413 s.split(',')
414 .map(|part| {
415 crate::config::expand_tilde(part.trim())
416 .trim_end_matches(['/', '\\'])
417 .to_lowercase()
418 })
419 .filter(|part| !part.is_empty())
420 .collect()
421 })
422 .unwrap_or_default()
423}
424
425fn is_excepted(repo_path: &Path, except: &[String]) -> bool {
432 if except.is_empty() {
433 return false;
434 }
435 let full = output::clean_path(repo_path)
436 .to_lowercase()
437 .replace('\\', "/");
438 let name = repo_path
439 .file_name()
440 .map(|n| n.to_string_lossy().to_lowercase())
441 .unwrap_or_default();
442
443 except.iter().any(|want| {
444 let want = want.replace('\\', "/");
445 name == want || full == want || full.ends_with(&format!("/{want}"))
446 })
447}
448
449fn resolve_scan_depth(registry: Option<&Registry>) -> usize {
451 registry
452 .map(|r| r.settings.scan_depth)
453 .unwrap_or(constants::DEFAULT_SCAN_DEPTH)
454}
455
456fn resolve_build_idle_days(registry: Option<&Registry>) -> u64 {
458 registry
459 .map(|r| r.settings.build_idle_days)
460 .unwrap_or(constants::DEFAULT_BUILD_IDLE_DAYS)
461}
462
463fn resolve_adapter_idle_days(
465 registry: Option<&Registry>,
466) -> std::collections::BTreeMap<String, u64> {
467 registry
468 .map(|r| r.settings.adapter_idle_days.clone())
469 .unwrap_or_default()
470}
471
472fn resolve_command_timeout(registry: Option<&Registry>) -> u64 {
473 registry
474 .map(|r| r.settings.command_timeout_secs)
475 .unwrap_or(constants::DEFAULT_COMMAND_TIMEOUT_SECS)
476}
477
478fn resolve_manifest_rewrite(registry: Option<&Registry>) -> bool {
480 registry
481 .map(|r| r.settings.allow_manifest_rewrite)
482 .unwrap_or(constants::DEFAULT_ALLOW_MANIFEST_REWRITE)
483}
484
485fn run_registry(args: &RunArgs<'_>, filter: &AdapterFilter) -> Result<()> {
487 if !args.json {
488 if args.dry_run {
489 output::print_header(i18n::t("run.header.dry"));
490 } else {
491 output::print_header(i18n::t("run.header"));
492 }
493 }
494
495 let mut registry = Registry::load()?;
496
497 let adopted = crate::commands::link::adopt_enclosing_repo(&mut registry);
501 if adopted.is_some() {
502 registry.save()?;
503 }
504 if let Some(path) = &adopted
505 && !args.json
506 {
507 crate::commands::link::report_cwd_adoption(path);
508 println!();
509 }
510
511 if !args.json && crate::commands::update::notify_if_outdated(&mut registry) {
514 let _ = registry.save();
515 }
516
517 if args.daemon && registry.settings.auto_discover && !setup::no_auto_setup_requested() {
529 let found = crate::discovery::discover(®istry)?;
530 let added = found
531 .found
532 .into_iter()
533 .filter(|repo| registry.add_repo(repo.clone()))
534 .count();
535 if added > 0 {
536 registry.save()?;
537 if !args.json {
538 output::print_info(&format!(
539 "Discovered and registered {added} new {}.",
540 output::plural(added, "repository", "repositories")
541 ));
542 }
543 }
544 }
545
546 if registry.repo_count() == 0 {
547 if args.json {
548 return json::emit(&json::run_document(&[], args.dry_run));
549 }
550 output::print_warning(
551 "No repositories registered. Run `devp init` here, or `devp init --auto` to find them.",
552 );
553 return Ok(());
554 }
555
556 let except = parse_except(args.except);
560 if !except.is_empty() {
561 let unmatched: Vec<&String> = except
562 .iter()
563 .filter(|want| {
564 !registry
565 .repositories
566 .keys()
567 .any(|p| is_excepted(p, std::slice::from_ref(*want)))
568 })
569 .collect();
570 if !unmatched.is_empty() {
571 anyhow::bail!(
572 "`--except` names no registered repository: {}\n \
573 Run `devp status` to see the registered names.",
574 unmatched
575 .iter()
576 .map(|s| s.as_str())
577 .collect::<Vec<_>>()
578 .join(", ")
579 );
580 }
581 }
582
583 let min_size_bytes = resolve_min_size(args, Some(®istry));
584 let analysis = PruneOptions {
585 idle_days: 0, dry_run: true,
587 force: args.force,
588 only_dirs: None,
589 adapters: filter.clone(),
590 min_size_bytes,
591 scan_depth: resolve_scan_depth(Some(®istry)),
592 allow_manifest_rewrite: resolve_manifest_rewrite(Some(®istry)),
593 command_timeout_secs: resolve_command_timeout(Some(®istry)),
594 build_idle_days: resolve_build_idle_days(Some(®istry)),
595 adapter_idle_days: resolve_adapter_idle_days(Some(®istry)),
596 };
597
598 if !args.json {
599 output::print_info(&format!(
600 "Scanning {} registered repositories for prune candidates...",
601 registry.repo_count()
602 ));
603 if let Some(desc) = filter.describe() {
604 output::print_info(&format!("Adapter filter: {desc}"));
605 }
606 if min_size_bytes > 0 {
607 output::print_info(&format!(
608 "Size floor: ignoring directories under {}",
609 output::format_bytes(min_size_bytes)
610 ));
611 }
612 }
613
614 let mut candidates: Vec<PruneResult> = Vec::new();
623 let mut blocked: Vec<PruneResult> = Vec::new();
624 let mut left_alone: Vec<PruneResult> = Vec::new();
625 let mut missing: Vec<PruneResult> = Vec::new();
626 for result in engine::prune_all_with(&mut registry, &analysis) {
627 if is_excepted(&result.repo_path, &except) {
631 continue;
632 }
633 match result.status {
634 PruneStatus::SkippedDryRun => candidates.push(result),
635 PruneStatus::ConfigError(_)
636 | PruneStatus::LockfileError(_)
637 | PruneStatus::ActivityCheckError(_)
638 | PruneStatus::DeleteError(_) => blocked.push(result),
639 PruneStatus::SkippedSymlink(_)
647 | PruneStatus::SkippedDeclaration(_)
648 | PruneStatus::SkippedNestedRepo(_) => left_alone.push(result),
649 PruneStatus::PathMissing => missing.push(result),
652 _ => {}
653 }
654 }
655
656 if !args.json && !except.is_empty() {
657 output::print_info(&format!("Leaving alone: {}", except.join(", ")));
658 }
659
660 if args.daemon {
661 let before = candidates.len();
662 candidates.retain(|c| {
663 match crate::config::PerRepoConfig::load_with_diagnostics(&c.repo_path) {
667 Ok(Some(cfg)) => !cfg.disable_daemon,
668 Ok(None) => true,
669 Err(_) => false,
670 }
671 });
672 let skipped = before - candidates.len();
673 if skipped > 0 && !args.json {
674 output::print_info(&format!(
675 "Skipped {skipped} bloat directories in repositories that set `disable_daemon`."
676 ));
677 }
678 }
679
680 if args.dry_run {
682 if args.json {
683 json::emit(&json::run_document(
684 &[candidates, blocked, left_alone, missing].concat(),
685 true,
686 ))?;
687 return Ok(());
688 }
689 if candidates.is_empty()
690 && blocked.is_empty()
691 && left_alone.is_empty()
692 && missing.is_empty()
693 {
694 output::print_info(i18n::t("run.nothing"));
695 return Ok(());
696 }
697 if !candidates.is_empty() {
698 report_candidates(&candidates);
699 }
700 let total: u64 = candidates.iter().map(|c| c.size_freed).sum();
701 output::print_header(i18n::t("run.summary.dry"));
702 output::print_info(&i18n::tf(
703 "run.would_free",
704 &[
705 ("size", &output::format_bytes(total)),
706 ("count", &candidates.len().to_string()),
707 ],
708 ));
709 report_blocked(&blocked);
712 report_left_alone(&left_alone);
713 report_missing(&missing);
714 return Ok(());
715 }
716
717 if candidates.is_empty() {
718 if args.json {
719 json::emit(&json::run_document(
720 &[blocked.clone(), left_alone, missing].concat(),
721 false,
722 ))?;
723 return fail_if_blocked(&blocked);
724 }
725 if blocked.is_empty() && left_alone.is_empty() && missing.is_empty() {
726 output::print_info(i18n::t("run.nothing"));
727 return Ok(());
728 }
729 output::print_info(i18n::t("run.nothing.bloat"));
730 report_blocked(&blocked);
731 report_left_alone(&left_alone);
732 report_missing(&missing);
733 return fail_if_blocked(&blocked);
734 }
735
736 let total_reclaimable: u64 = candidates.iter().map(|c| c.size_freed).sum();
737
738 if !args.json {
739 report_binaries(&candidates);
740 report_candidates(&candidates);
741 output::print_info(&i18n::tf(
742 "run.reclaimable",
743 &[("size", &output::format_bytes_styled(total_reclaimable))],
744 ));
745 report_blocked(&blocked);
746 report_left_alone(&left_alone);
747 report_missing(&missing);
748 }
749
750 let target_candidates: Vec<PruneResult> = if args.json
753 || args.yes
754 || !registry.settings.require_confirmation
755 {
756 candidates
757 } else if io::stdout().is_terminal() && io::stdin().is_terminal() {
758 eprintln!();
759 eprintln!(
760 " Loading interactive selector... (↑↓ navigate, Space toggle, Enter confirm, q cancel)"
761 );
762 eprintln!();
763 let selected = tui::selection_view::select_candidates_tui(&candidates)?;
764 if selected.is_empty() {
765 output::print_info("Prune pass cancelled by user (0 candidates selected).");
766 return Ok(());
767 }
768 selected
769 } else {
770 if !io::stdin().is_terminal() {
774 anyhow::bail!(
775 "Deleting {} directories ({}) needs confirmation, and there is no \
776 terminal to ask on. Re-run with `--yes` to confirm, or `--dry-run` \
777 to only analyse.",
778 candidates.len(),
779 output::format_bytes(total_reclaimable)
780 );
781 }
782 println!();
783 output::print_warning("CAUTION: Deleting bloat directories cannot be undone directly.");
784 output::print_info(
785 "Note: You can re-install missing dependencies anytime using `dev-prune restore`.",
786 );
787 eprint!(
790 "Proceed with deletion of {} directories ({})? [y/N]: ",
791 candidates.len(),
792 output::format_bytes(total_reclaimable)
793 );
794 io::stderr().flush()?;
795
796 let mut input = String::new();
797 io::stdin().read_line(&mut input)?;
798 let trimmed = input.trim().to_lowercase();
799 if trimmed != "y" && trimmed != "yes" {
800 output::print_info("Prune pass aborted by user.");
801 return Ok(());
802 }
803 candidates
804 };
805
806 if !args.json {
807 let selected_total_bytes: u64 = target_candidates.iter().map(|c| c.size_freed).sum();
808 output::print_header(&i18n::tf(
809 "run.header.deleting",
810 &[
811 ("repos", &target_candidates.len().to_string()),
812 ("size", &output::format_bytes(selected_total_bytes)),
813 ],
814 ));
815 }
816
817 let mut selection: Vec<(std::path::PathBuf, Vec<String>)> = Vec::new();
823 for candidate in &target_candidates {
824 match selection
825 .iter_mut()
826 .find(|(p, _)| *p == candidate.repo_path)
827 {
828 Some((_, dirs)) => dirs.push(candidate.bloat_dir.clone()),
829 None => selection.push((
830 candidate.repo_path.clone(),
831 vec![candidate.bloat_dir.clone()],
832 )),
833 }
834 }
835
836 let mut error_count = blocked.len();
840 let mut all_results: Vec<PruneResult> = blocked;
841 all_results.extend(left_alone);
842 all_results.extend(missing);
843 let mut total_freed: u64 = 0;
844 let mut pruned_count = 0;
845 let mut pruned_dirs: Vec<crate::config::PrunedDir> = Vec::new();
846 let pass_at = chrono::Utc::now();
849
850 for (repo_path, dirs) in &selection {
851 let recorded_before = pruned_dirs.len();
852 let idle_days = registry
857 .repositories
858 .get(repo_path)
859 .and_then(|e| e.override_idle_days)
860 .unwrap_or(registry.settings.idle_days);
861 let single_results = engine::prune_repo_with(
862 repo_path,
863 &PruneOptions {
864 idle_days,
865 dry_run: false,
866 force: args.force,
867 only_dirs: Some(dirs.clone()),
868 adapters: filter.clone(),
869 min_size_bytes: 0,
870 scan_depth: analysis.scan_depth,
871 allow_manifest_rewrite: analysis.allow_manifest_rewrite,
872 command_timeout_secs: analysis.command_timeout_secs,
873 build_idle_days: analysis.build_idle_days,
874 adapter_idle_days: analysis.adapter_idle_days.clone(),
875 },
876 );
877 for result in single_results {
878 match &result.status {
879 PruneStatus::Pruned => {
880 total_freed += result.size_freed;
881 pruned_count += 1;
882 registry.mark_pruned(&result.repo_path, result.size_freed);
883 pruned_dirs.push(crate::config::PrunedDir {
884 repo_path: result.repo_path.clone(),
885 bloat_dir: result.bloat_dir.clone(),
886 adapter: result.adapter_name.clone(),
887 size_freed: result.size_freed,
888 runtime: result.runtime.clone(),
889 });
890 if !args.json {
891 output::print_success(&format!(
892 "{} → {} ({}) — {}{}",
893 output::clean_path(&result.repo_path),
894 result.bloat_dir,
895 output::format_bytes(result.size_freed),
896 result.adapter_name,
897 output::shared_note(result.shared_bytes, &result.adapter_name)
898 ));
899 }
900 }
901 PruneStatus::LockfileError(e) => {
902 error_count += 1;
903 if !args.json {
904 report_lockfile_failure(&result, e);
905 }
906 }
907 PruneStatus::ActivityCheckError(e) => {
908 error_count += 1;
909 if !args.json {
910 output::print_error(&format!(
911 "{} skipped — its activity could not be determined:\n {}",
912 output::clean_path(&result.repo_path),
913 e.trim()
914 ));
915 }
916 }
917 PruneStatus::DeleteError(e) => {
918 error_count += 1;
919 if result.size_freed > 0 {
924 pruned_dirs.push(crate::config::PrunedDir {
925 repo_path: result.repo_path.clone(),
926 bloat_dir: result.bloat_dir.clone(),
927 adapter: result.adapter_name.clone(),
928 size_freed: result.size_freed,
929 runtime: result.runtime.clone(),
930 });
931 }
932 if !args.json {
933 output::print_error(&format!(
934 "{} → delete failed: {}",
935 output::clean_path(&result.repo_path),
936 e,
937 ));
938 }
939 }
940 PruneStatus::ConfigError(e) => {
941 error_count += 1;
942 if !args.json {
943 let clean_p = output::clean_path(&result.repo_path);
944 output::print_error(&format!(
945 "{clean_p} skipped — its .devprune.json could not be read:\n {}",
946 e.trim()
947 ));
948 output::print_info(&format!(
949 " Fix command: devp config {clean_p} --update"
950 ));
951 }
952 }
953 PruneStatus::SkippedActive if !args.json => {
956 output::print_info(&format!(
957 "{} became active since the analysis — left alone. \
958 Use `--ignore-idle` to prune it anyway.",
959 output::clean_path(&result.repo_path)
960 ));
961 }
962 PruneStatus::SkippedDeclaration(_) => continue,
967 _ => {}
968 }
969 all_results.push(result);
970 }
971
972 if pruned_dirs.len() > recorded_before {
978 registry.record_prune_progress(pass_at, pruned_dirs.clone());
979 let _ = registry.save();
980 }
981 }
982
983 registry.record_prune_progress(pass_at, pruned_dirs);
984 let saved = registry.save();
988
989 if args.json {
990 json::emit(&json::run_document(&all_results, false))?;
991 saved?;
992 if error_count > 0 {
995 anyhow::bail!("{error_count} repositories could not be pruned.");
996 }
997 return Ok(());
998 }
999 saved?;
1000
1001 output::print_header(i18n::t("run.summary"));
1002 output::print_success(&i18n::tf(
1003 "run.freed",
1004 &[
1005 ("size", &output::format_bytes_styled(total_freed)),
1006 ("count", &pruned_count.to_string()),
1007 ],
1008 ));
1009
1010 if error_count > 0 {
1011 output::print_warning(&i18n::tf(
1012 "run.not_pruned",
1013 &[("count", &error_count.to_string())],
1014 ));
1015
1016 if all_results
1020 .iter()
1021 .any(|r| matches!(r.status, PruneStatus::LockfileError(_)))
1022 {
1023 output::print_info(
1027 "Lockfile verification cannot be bypassed: without a lockfile the deleted \
1028 dependencies could not be reinstalled. Run the fix command shown above for \
1029 each repo, then re-run `devp run`.",
1030 );
1031 }
1032 anyhow::bail!("{error_count} repositories could not be pruned.");
1034 }
1035
1036 crate::commands::update::maybe_auto_update(®istry);
1039
1040 Ok(())
1041}
1042
1043#[derive(Debug, PartialEq, Eq, Clone, Copy)]
1054enum ActivityFailure {
1055 UntrustedOwner,
1057 NotARepository,
1059 Individual,
1061}
1062
1063impl ActivityFailure {
1064 fn classify(message: &str) -> Self {
1071 let lower = message.to_lowercase();
1072 if lower.contains(constants::GIT_DUBIOUS_OWNERSHIP) {
1073 Self::UntrustedOwner
1074 } else if lower.contains(constants::GIT_NOT_A_REPOSITORY) {
1075 Self::NotARepository
1076 } else {
1077 Self::Individual
1078 }
1079 }
1080}
1081
1082const GROUPED_PATHS_SHOWN: usize = 8;
1087
1088fn report_blocked(blocked: &[PruneResult]) {
1092 if blocked.is_empty() {
1093 return;
1094 }
1095 output::print_header(&i18n::tf(
1096 "run.header.blocked",
1097 &[("count", &blocked.len().to_string())],
1098 ));
1099
1100 let grouped = |failure: ActivityFailure| -> Vec<&PruneResult> {
1101 blocked
1102 .iter()
1103 .filter(|r| match &r.status {
1104 PruneStatus::ActivityCheckError(e) => ActivityFailure::classify(e) == failure,
1105 _ => false,
1106 })
1107 .collect()
1108 };
1109
1110 let untrusted = grouped(ActivityFailure::UntrustedOwner);
1111 if !untrusted.is_empty() {
1112 let n = untrusted.len();
1113 output::print_error(&format!(
1114 "{n} {} owned by a different account — Git will not read {}.",
1115 output::plural(n, "repository is", "repositories are"),
1116 output::plural(n, "it", "them")
1117 ));
1118 list_paths(&untrusted);
1119 output::print_wrapped(
1120 " ",
1121 "Nothing is wrong with the repositories themselves. The owner recorded on \
1122 disk is usually one a Windows reinstall, a restored backup or a drive moved \
1123 between machines left behind.",
1124 );
1125 output::print_wrapped(
1126 " ",
1127 "dev-prune dates a repository by its last commit, so one Git will not open \
1128 has no known age — and nothing is ever deleted from a repository whose age is \
1129 unknown.",
1130 );
1131 output::print_info(&format!(
1132 " Fix all {n} at once: devp trust --fix-ownership"
1133 ));
1134 }
1135
1136 let orphaned = grouped(ActivityFailure::NotARepository);
1137 if !orphaned.is_empty() {
1138 if !untrusted.is_empty() {
1139 println!();
1140 }
1141 let n = orphaned.len();
1142 output::print_error(&format!(
1143 "{n} registered {} not {} git {} any more.",
1144 output::plural(n, "path is", "paths are"),
1145 output::plural(n, "a", ""),
1146 output::plural(n, "repository", "repositories")
1147 ));
1148 list_paths(&orphaned);
1149 output::print_wrapped(
1150 " ",
1151 "The directory is still there; its `.git` is not — a clone deleted and \
1152 recreated by hand, or a worktree `git worktree prune` has since removed. The \
1153 registry entry outlived what it pointed at.",
1154 );
1155 output::print_info(&format!(
1159 " Drop {} from the registry: devp unlink <path>",
1160 output::plural(n, "it", "them")
1161 ));
1162 }
1163
1164 for result in blocked {
1165 let clean_p = output::clean_path(&result.repo_path);
1166 match &result.status {
1167 PruneStatus::ConfigError(e) => {
1168 output::print_error(&format!(
1169 "{clean_p} skipped — its .devprune.json could not be read:
1170 {}",
1171 e.trim()
1172 ));
1173 output::print_info(&format!(
1174 " Fix command: devp config {clean_p} --update"
1175 ));
1176 }
1177 PruneStatus::LockfileError(e) => report_lockfile_failure(result, e),
1178 PruneStatus::ActivityCheckError(e)
1179 if ActivityFailure::classify(e) == ActivityFailure::Individual =>
1180 {
1181 output::print_error(&format!(
1182 "{clean_p} skipped — its activity could not be determined:
1183 {}",
1184 output::condense_tool_output(e, 4)
1185 ));
1186 }
1187 PruneStatus::DeleteError(e) => {
1188 output::print_error(&format!("{clean_p} → delete failed: {e}"));
1189 }
1190 _ => {}
1192 }
1193 }
1194}
1195
1196fn list_paths(results: &[&PruneResult]) {
1201 for result in results.iter().take(GROUPED_PATHS_SHOWN) {
1202 println!(" {}", output::styled_path(&result.repo_path));
1203 }
1204 if let Some(rest) = results
1205 .len()
1206 .checked_sub(GROUPED_PATHS_SHOWN)
1207 .filter(|n| *n > 0)
1208 {
1209 output::print_dimmed(&format!(
1210 " … and {rest} more — `devp run --dry-run --json` lists every one."
1211 ));
1212 }
1213}
1214
1215fn report_left_alone(left_alone: &[PruneResult]) {
1223 for result in left_alone {
1224 if let PruneStatus::SkippedSymlink(e)
1225 | PruneStatus::SkippedDeclaration(e)
1226 | PruneStatus::SkippedNestedRepo(e) = &result.status
1227 {
1228 output::print_warning(&format!(
1229 "{} → {}",
1230 output::clean_path(&result.repo_path),
1231 e.trim()
1232 ));
1233 }
1234 }
1235}
1236
1237fn report_missing(missing: &[PruneResult]) {
1243 if missing.is_empty() {
1244 return;
1245 }
1246 println!();
1247 let n = missing.len();
1248 output::print_warning(&format!(
1249 "{n} registered {} no longer {} on disk.",
1250 output::plural(n, "path", "paths"),
1251 output::plural(n, "exists", "exist")
1252 ));
1253 for result in missing.iter().take(GROUPED_PATHS_SHOWN) {
1258 println!(" {}", output::styled_path(&result.repo_path));
1259 }
1260 if let Some(rest) = missing
1261 .len()
1262 .checked_sub(GROUPED_PATHS_SHOWN)
1263 .filter(|n| *n > 0)
1264 {
1265 output::print_dimmed(&format!(
1266 " … and {rest} more — `devp run --dry-run --json` lists every one."
1267 ));
1268 }
1269 output::print_info(&format!(
1270 " Clear {} from the registry: devp unlink --missing",
1271 output::plural(n, "it", "them all")
1272 ));
1273}
1274
1275fn fail_if_blocked(blocked: &[PruneResult]) -> Result<()> {
1280 if blocked.is_empty() {
1281 return Ok(());
1282 }
1283 anyhow::bail!("{} repositories could not be examined.", blocked.len());
1284}
1285
1286fn report_binaries(candidates: &[PruneResult]) {
1288 let adapter_names: Vec<String> = candidates.iter().map(|c| c.adapter_name.clone()).collect();
1289 let binary_statuses = adapters::scan_required_binaries(&adapter_names);
1290 if binary_statuses.is_empty() {
1291 return;
1292 }
1293 output::print_header(i18n::t("run.header.binaries"));
1294 for b in &binary_statuses {
1295 if b.available {
1296 output::print_success(&format!(
1297 " {} — available ({})",
1298 b.name,
1299 b.version.as_deref().unwrap_or("detected")
1300 ));
1301 } else {
1302 output::print_warning(&format!(
1303 " {} — missing (lockfile fallback active)",
1304 b.name
1305 ));
1306 }
1307 }
1308}
1309
1310fn report_candidates(candidates: &[PruneResult]) {
1311 output::print_header(i18n::t("run.header.candidates"));
1312 for candidate in candidates {
1313 output::print_info(&format!(
1314 " • {} → {} ({}) [{}]{}",
1315 output::styled_path(&candidate.repo_path),
1316 candidate.bloat_dir,
1317 output::format_bytes_styled(candidate.size_freed),
1318 output::styled_adapter(&candidate.adapter_name),
1319 output::shared_note(candidate.shared_bytes, &candidate.adapter_name)
1320 ));
1321 }
1322}
1323
1324pub(crate) fn report_lockfile_failure(result: &PruneResult, error: &str) {
1325 let project = output::clean_path(result.project_dir());
1328
1329 output::print_error(&format!(
1330 "{} → {} lockfile sync failed:\n {}",
1331 project,
1332 result.adapter_name,
1333 error.trim(),
1334 ));
1335 match json::lockfile_fix_command(&result.adapter_name) {
1336 Some(sync_cmd) => {
1337 #[cfg(windows)]
1340 let manual_cmd = format!("cd \"{project}\"; {sync_cmd}");
1341 #[cfg(not(windows))]
1342 let manual_cmd = format!("cd \"{project}\" && {sync_cmd}");
1343 output::print_info(&format!(" Fix command: {manual_cmd}"));
1344 }
1345 None => output::print_info(&format!(" Fix it in: {project}")),
1348 }
1349 output::print_info(&format!(
1350 " Troubleshooting: {}",
1351 constants::TROUBLESHOOTING_URL
1352 ));
1353}
1354
1355fn run_explain(args: &RunArgs<'_>, filter: &AdapterFilter) -> Result<()> {
1366 output::print_header(i18n::t("run.header.reasons"));
1367 if let Some(desc) = filter.describe() {
1368 output::print_info(&format!("Adapter filter: {desc}"));
1369 }
1370
1371 if let Some(target_str) = args.target_path {
1372 let raw = Path::new(target_str);
1373 let path = if raw.exists() {
1374 raw.canonicalize().unwrap_or_else(|_| raw.to_path_buf())
1375 } else {
1376 raw.to_path_buf()
1377 };
1378 if !crate::scanner::is_git_repo(&path) {
1379 anyhow::bail!(
1380 "{} is not a Git repository — dev-prune only prunes Git repos.",
1381 output::clean_path(&path)
1382 );
1383 }
1384 let registry = Registry::load().ok();
1385 let idle_days = registry
1386 .as_ref()
1387 .map(|r| {
1388 r.repositories
1389 .get(&path)
1390 .and_then(|e| e.override_idle_days)
1391 .unwrap_or(r.settings.idle_days)
1392 })
1393 .unwrap_or(constants::DEFAULT_IDLE_DAYS);
1394 let floor = resolve_min_size(args, registry.as_ref());
1395 let results = engine::prune_repo_with(
1396 &path,
1397 &PruneOptions {
1398 idle_days,
1399 dry_run: true,
1400 force: args.force,
1401 only_dirs: None,
1402 adapters: filter.clone(),
1403 min_size_bytes: 0,
1404 scan_depth: resolve_scan_depth(registry.as_ref()),
1405 allow_manifest_rewrite: resolve_manifest_rewrite(registry.as_ref()),
1406 command_timeout_secs: resolve_command_timeout(registry.as_ref()),
1407 build_idle_days: resolve_build_idle_days(registry.as_ref()),
1408 adapter_idle_days: resolve_adapter_idle_days(registry.as_ref()),
1409 },
1410 );
1411 let refs: Vec<&PruneResult> = results.iter().collect();
1412 explain_repo(&path, &refs, floor, idle_days);
1413 print_explain_footer();
1414 return Ok(());
1415 }
1416
1417 let mut registry = Registry::load()?;
1418 if registry.repo_count() == 0 {
1419 output::print_warning("No repositories registered. Run `dev-prune init` first.");
1420 return Ok(());
1421 }
1422
1423 let except = parse_except(args.except);
1424 let global_floor = resolve_min_size(args, Some(®istry));
1425 let analysis = PruneOptions {
1426 idle_days: 0, dry_run: true,
1428 force: args.force,
1429 only_dirs: None,
1430 adapters: filter.clone(),
1431 min_size_bytes: 0,
1432 scan_depth: resolve_scan_depth(Some(®istry)),
1433 allow_manifest_rewrite: resolve_manifest_rewrite(Some(®istry)),
1434 command_timeout_secs: resolve_command_timeout(Some(®istry)),
1435 build_idle_days: resolve_build_idle_days(Some(®istry)),
1436 adapter_idle_days: resolve_adapter_idle_days(Some(®istry)),
1437 };
1438 let results = engine::prune_all_with(&mut registry, &analysis);
1439
1440 let mut by_repo: std::collections::HashMap<&Path, Vec<&PruneResult>> =
1441 std::collections::HashMap::new();
1442 for r in &results {
1443 by_repo.entry(r.repo_path.as_path()).or_default().push(r);
1444 }
1445
1446 let mut repos: Vec<&std::path::PathBuf> = registry.repositories.keys().collect();
1447 repos.sort();
1448 for path in repos {
1449 if is_excepted(path, &except) {
1450 println!();
1451 output::print_info(&output::clean_path(path));
1452 println!(" • left completely alone this pass (`--except`)");
1453 continue;
1454 }
1455 let idle_days = registry
1456 .repositories
1457 .get(path)
1458 .and_then(|e| e.override_idle_days)
1459 .unwrap_or(registry.settings.idle_days);
1460 let empty = Vec::new();
1461 let repo_results = by_repo.get(path.as_path()).unwrap_or(&empty);
1462 explain_repo(path, repo_results, global_floor, idle_days);
1463 }
1464 print_explain_footer();
1465 Ok(())
1466}
1467
1468fn explain_repo(path: &Path, results: &[&PruneResult], floor: u64, idle_days: u64) {
1470 println!();
1471 output::print_info(&output::clean_path(path));
1472
1473 if results.is_empty() {
1474 println!(
1475 " • idle, but no known bloat directories were found. A project deeper than \
1476 `scan_depth` is not examined — `devp status` shows what dev-prune can see."
1477 );
1478 return;
1479 }
1480
1481 for r in results {
1482 match &r.status {
1483 PruneStatus::SkippedDryRun => {
1484 if r.size_freed >= floor {
1485 output::print_success(&format!(
1486 "would prune {} ({}) [{}]{}",
1487 r.bloat_dir,
1488 output::format_bytes(r.size_freed),
1489 r.adapter_name,
1490 output::shared_note(r.shared_bytes, &r.adapter_name)
1491 ));
1492 } else {
1493 println!(
1494 " • {} ({}) is under the size floor of {} — the reinstall would \
1495 cost more than the space is worth. `--min-size 0` includes it.",
1496 r.bloat_dir,
1497 output::format_bytes(r.size_freed),
1498 output::format_bytes(floor)
1499 );
1500 }
1501 }
1502 PruneStatus::SkippedActive => {
1503 let age = crate::scanner::git::get_last_activity(path)
1504 .ok()
1505 .flatten()
1506 .and_then(|t| std::time::SystemTime::now().duration_since(t).ok())
1507 .map(|d| d.as_secs() / 86_400);
1508 match age {
1509 Some(0) => println!(
1510 " • active — there was activity today, and the idle \
1511 threshold is {idle_days} days. `--ignore-idle` overrides."
1512 ),
1513 Some(days) => println!(
1514 " • active — last activity {days} day{} ago, and the idle \
1515 threshold is {idle_days} days. `--ignore-idle` overrides.",
1516 if days == 1 { "" } else { "s" }
1517 ),
1518 None => println!(
1519 " • active (not idle for {idle_days} days yet). \
1520 `--ignore-idle` overrides."
1521 ),
1522 }
1523 }
1524 other => println!(" • {other}"),
1525 }
1526 }
1527}
1528
1529fn print_explain_footer() {
1531 println!();
1532 output::print_info(
1533 "Nothing was verified or deleted. `devp run --dry-run` verifies candidates; \
1534 `devp run` prunes.",
1535 );
1536}
1537
1538#[cfg(test)]
1539mod tests {
1540 use super::*;
1541
1542 #[test]
1543 fn gits_ownership_refusal_is_recognised_whatever_the_path() {
1544 let message = "git could not read `V:/x`: fatal: detected dubious ownership in repository \
1548 at 'V:/x'";
1549 assert_eq!(
1550 ActivityFailure::classify(message),
1551 ActivityFailure::UntrustedOwner
1552 );
1553 }
1554
1555 #[test]
1556 fn a_path_that_lost_its_git_directory_is_its_own_cause() {
1557 let message = "fatal: not a git repository (or any of the parent directories): .git";
1561 assert_eq!(
1562 ActivityFailure::classify(message),
1563 ActivityFailure::NotARepository
1564 );
1565 }
1566
1567 #[test]
1568 fn an_unfamiliar_failure_is_still_printed_in_full() {
1569 assert_eq!(
1570 ActivityFailure::classify("fatal: unable to read tree"),
1571 ActivityFailure::Individual
1572 );
1573 }
1574}