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 if !args.json {
162 output::print_header(&i18n::tf(
163 "run.header.targeted",
164 &[("path", clean.as_str())],
165 ));
166 if let Some(desc) = filter.describe() {
167 output::print_info(&format!("Adapter filter: {desc}"));
168 }
169 }
170
171 if !args.dry_run
177 && !args.json
178 && !args.yes
179 && registry
180 .as_ref()
181 .is_none_or(|r| r.settings.require_confirmation)
182 {
183 let preview = engine::prune_repo_with(
184 &path,
185 &PruneOptions {
186 dry_run: true,
187 ..opts.clone()
188 },
189 );
190 let candidates: Vec<PruneResult> = preview
191 .into_iter()
192 .filter(|r| matches!(r.status, PruneStatus::SkippedDryRun))
193 .collect();
194 if !candidates.is_empty() {
197 let total: u64 = candidates.iter().map(|c| c.size_freed).sum();
198 report_candidates(&candidates);
199 output::print_info(&i18n::tf(
200 "run.reclaimable",
201 &[("size", &output::format_bytes_styled(total))],
202 ));
203 output::print_info(
204 "Everything above is rebuilt from a lockfile — `devp restore` brings it back.",
205 );
206 if !io::stdin().is_terminal() {
207 anyhow::bail!(
208 "Deleting {} directories ({}) needs confirmation, and there is no \
209 terminal to ask on. Re-run with `--yes` to confirm, or `--dry-run` \
210 to only analyse.",
211 candidates.len(),
212 output::format_bytes(total)
213 );
214 }
215 eprint!(
218 "Proceed with deletion of {} directories ({})? [y/N]: ",
219 candidates.len(),
220 output::format_bytes(total)
221 );
222 io::stderr().flush()?;
223 let mut input = String::new();
224 io::stdin().read_line(&mut input)?;
225 let trimmed = input.trim().to_lowercase();
226 if trimmed != "y" && trimmed != "yes" {
227 output::print_info("Prune pass aborted by user.");
228 return Ok(());
229 }
230 }
231 }
232
233 let results = engine::prune_repo_with(&path, &opts);
234
235 let error_count = results
239 .iter()
240 .filter(|r| {
241 matches!(
242 r.status,
243 PruneStatus::LockfileError(_)
244 | PruneStatus::ActivityCheckError(_)
245 | PruneStatus::DeleteError(_)
246 | PruneStatus::ConfigError(_)
247 )
248 })
249 .count();
250
251 record_targeted_prune(&path, &results, args.dry_run);
256
257 if args.json {
258 json::emit(&json::run_document(&results, args.dry_run))?;
259 if error_count > 0 {
260 anyhow::bail!("{error_count} directories in {clean} could not be pruned.");
261 }
262 return Ok(());
263 }
264
265 if results.is_empty() {
266 output::print_info(&i18n::tf(
267 "run.nothing.bloat.targeted",
268 &[("path", clean.as_str())],
269 ));
270 return Ok(());
271 }
272
273 let mut total_freed = 0;
274 for result in results {
275 match &result.status {
276 PruneStatus::Pruned => {
277 total_freed += result.size_freed;
278 output::print_success(&format!(
279 "{} → {} ({}) — {}{}",
280 output::clean_path(&result.repo_path),
281 result.bloat_dir,
282 output::format_bytes(result.size_freed),
283 result.adapter_name,
284 output::shared_note(result.shared_bytes, &result.adapter_name)
285 ));
286 }
287 PruneStatus::SkippedDryRun => {
288 output::print_info(&format!(
289 " • {} → {} ({}) [{}] (Dry Run){}",
290 output::clean_path(&result.repo_path),
291 result.bloat_dir,
292 output::format_bytes(result.size_freed),
293 result.adapter_name,
294 output::shared_note(result.shared_bytes, &result.adapter_name)
295 ));
296 }
297 PruneStatus::SkippedActive => {
298 output::print_info(&format!(
299 "{clean} is currently active (not idle). Use `devp --ignore-idle run` to override."
300 ));
301 }
302 PruneStatus::LockfileError(e) => report_lockfile_failure(&result, e),
303 PruneStatus::ActivityCheckError(e) => {
304 output::print_error(&format!(
305 "{clean} skipped — its activity could not be determined:\n {}",
306 e.trim()
307 ));
308 }
309 PruneStatus::DeleteError(e) => {
310 output::print_error(&format!("{clean} delete error: {e}"));
311 }
312 PruneStatus::ConfigError(e) => {
313 output::print_error(&format!(
314 "{clean} skipped — its .devprune.json could not be read:\n {}\n \
315 Fix it, or run `devp config {clean} --update` to reset it.",
316 e.trim()
317 ));
318 }
319 PruneStatus::SkippedSymlink(e) => {
320 output::print_warning(&format!("{clean} → {}", e.trim()));
321 }
322 PruneStatus::SkippedDeclaration(e) => {
323 output::print_warning(&format!("{clean} → {}", e.trim()));
324 }
325 _ => {}
326 }
327 }
328
329 if !args.dry_run && total_freed > 0 {
330 output::print_success(&i18n::tf(
331 "run.freed.targeted",
332 &[
333 ("size", &output::format_bytes(total_freed)),
334 ("path", clean.as_str()),
335 ],
336 ));
337 }
338
339 if error_count > 0 {
340 anyhow::bail!("{error_count} directories in {clean} could not be pruned.");
341 }
342
343 Ok(())
344}
345
346fn record_targeted_prune(path: &std::path::Path, results: &[PruneResult], dry_run: bool) {
352 if dry_run {
353 return;
354 }
355
356 let pruned: Vec<crate::config::PrunedDir> = results
359 .iter()
360 .filter(|r| {
361 matches!(r.status, PruneStatus::Pruned)
362 || (matches!(r.status, PruneStatus::DeleteError(_)) && r.size_freed > 0)
363 })
364 .map(|r| crate::config::PrunedDir {
365 repo_path: r.repo_path.clone(),
366 bloat_dir: r.bloat_dir.clone(),
367 adapter: r.adapter_name.clone(),
368 size_freed: r.size_freed,
369 runtime: r.runtime.clone(),
370 })
371 .collect();
372
373 if pruned.is_empty() {
374 return;
375 }
376
377 let freed: u64 = pruned.iter().map(|d| d.size_freed).sum();
378 if let Ok(mut registry) = Registry::load() {
379 registry.mark_pruned(path, freed);
380 registry.record_prune(pruned);
381 let _ = registry.save();
382 }
383}
384
385fn resolve_min_size(args: &RunArgs<'_>, registry: Option<&Registry>) -> u64 {
390 let mb = args
391 .min_size_mb
392 .or_else(|| registry.map(|r| r.settings.min_size_mb))
393 .unwrap_or(constants::DEFAULT_MIN_SIZE_MB);
394 mb.saturating_mul(engine::BYTES_PER_MIB)
395}
396
397fn parse_except(spec: Option<&str>) -> Vec<String> {
404 spec.map(|s| {
405 s.split(',')
406 .map(|part| {
407 crate::config::expand_tilde(part.trim())
408 .trim_end_matches(['/', '\\'])
409 .to_lowercase()
410 })
411 .filter(|part| !part.is_empty())
412 .collect()
413 })
414 .unwrap_or_default()
415}
416
417fn is_excepted(repo_path: &Path, except: &[String]) -> bool {
424 if except.is_empty() {
425 return false;
426 }
427 let full = output::clean_path(repo_path)
428 .to_lowercase()
429 .replace('\\', "/");
430 let name = repo_path
431 .file_name()
432 .map(|n| n.to_string_lossy().to_lowercase())
433 .unwrap_or_default();
434
435 except.iter().any(|want| {
436 let want = want.replace('\\', "/");
437 name == want || full == want || full.ends_with(&format!("/{want}"))
438 })
439}
440
441fn resolve_scan_depth(registry: Option<&Registry>) -> usize {
443 registry
444 .map(|r| r.settings.scan_depth)
445 .unwrap_or(constants::DEFAULT_SCAN_DEPTH)
446}
447
448fn resolve_build_idle_days(registry: Option<&Registry>) -> u64 {
450 registry
451 .map(|r| r.settings.build_idle_days)
452 .unwrap_or(constants::DEFAULT_BUILD_IDLE_DAYS)
453}
454
455fn resolve_adapter_idle_days(
457 registry: Option<&Registry>,
458) -> std::collections::BTreeMap<String, u64> {
459 registry
460 .map(|r| r.settings.adapter_idle_days.clone())
461 .unwrap_or_default()
462}
463
464fn resolve_command_timeout(registry: Option<&Registry>) -> u64 {
465 registry
466 .map(|r| r.settings.command_timeout_secs)
467 .unwrap_or(constants::DEFAULT_COMMAND_TIMEOUT_SECS)
468}
469
470fn resolve_manifest_rewrite(registry: Option<&Registry>) -> bool {
472 registry
473 .map(|r| r.settings.allow_manifest_rewrite)
474 .unwrap_or(constants::DEFAULT_ALLOW_MANIFEST_REWRITE)
475}
476
477fn run_registry(args: &RunArgs<'_>, filter: &AdapterFilter) -> Result<()> {
479 if !args.json {
480 if args.dry_run {
481 output::print_header(i18n::t("run.header.dry"));
482 } else {
483 output::print_header(i18n::t("run.header"));
484 }
485 }
486
487 let mut registry = Registry::load()?;
488
489 let adopted = crate::commands::link::adopt_enclosing_repo(&mut registry);
493 if adopted.is_some() {
494 registry.save()?;
495 }
496 if let Some(path) = &adopted
497 && !args.json
498 {
499 crate::commands::link::report_cwd_adoption(path);
500 println!();
501 }
502
503 if !args.json && crate::commands::update::notify_if_outdated(&mut registry) {
506 let _ = registry.save();
507 }
508
509 if registry.repo_count() == 0 {
510 if args.json {
511 return json::emit(&json::run_document(&[], args.dry_run));
512 }
513 output::print_warning("No repositories registered. Run `dev-prune init` first.");
514 return Ok(());
515 }
516
517 let except = parse_except(args.except);
521 if !except.is_empty() {
522 let unmatched: Vec<&String> = except
523 .iter()
524 .filter(|want| {
525 !registry
526 .repositories
527 .keys()
528 .any(|p| is_excepted(p, std::slice::from_ref(*want)))
529 })
530 .collect();
531 if !unmatched.is_empty() {
532 anyhow::bail!(
533 "`--except` names no registered repository: {}\n \
534 Run `devp status` to see the registered names.",
535 unmatched
536 .iter()
537 .map(|s| s.as_str())
538 .collect::<Vec<_>>()
539 .join(", ")
540 );
541 }
542 }
543
544 let min_size_bytes = resolve_min_size(args, Some(®istry));
545 let analysis = PruneOptions {
546 idle_days: 0, dry_run: true,
548 force: args.force,
549 only_dirs: None,
550 adapters: filter.clone(),
551 min_size_bytes,
552 scan_depth: resolve_scan_depth(Some(®istry)),
553 allow_manifest_rewrite: resolve_manifest_rewrite(Some(®istry)),
554 command_timeout_secs: resolve_command_timeout(Some(®istry)),
555 build_idle_days: resolve_build_idle_days(Some(®istry)),
556 adapter_idle_days: resolve_adapter_idle_days(Some(®istry)),
557 };
558
559 if !args.json {
560 output::print_info(&format!(
561 "Scanning {} registered repositories for prune candidates...",
562 registry.repo_count()
563 ));
564 if let Some(desc) = filter.describe() {
565 output::print_info(&format!("Adapter filter: {desc}"));
566 }
567 if min_size_bytes > 0 {
568 output::print_info(&format!(
569 "Size floor: ignoring directories under {}",
570 output::format_bytes(min_size_bytes)
571 ));
572 }
573 }
574
575 let mut candidates: Vec<PruneResult> = Vec::new();
584 let mut blocked: Vec<PruneResult> = Vec::new();
585 let mut left_alone: Vec<PruneResult> = Vec::new();
586 let mut missing: Vec<PruneResult> = Vec::new();
587 for result in engine::prune_all_with(&mut registry, &analysis) {
588 if is_excepted(&result.repo_path, &except) {
592 continue;
593 }
594 match result.status {
595 PruneStatus::SkippedDryRun => candidates.push(result),
596 PruneStatus::ConfigError(_)
597 | PruneStatus::LockfileError(_)
598 | PruneStatus::ActivityCheckError(_)
599 | PruneStatus::DeleteError(_) => blocked.push(result),
600 PruneStatus::SkippedSymlink(_) | PruneStatus::SkippedDeclaration(_) => {
605 left_alone.push(result)
606 }
607 PruneStatus::PathMissing => missing.push(result),
610 _ => {}
611 }
612 }
613
614 if !args.json && !except.is_empty() {
615 output::print_info(&format!("Leaving alone: {}", except.join(", ")));
616 }
617
618 if args.daemon {
619 let before = candidates.len();
620 candidates.retain(|c| {
621 match crate::config::PerRepoConfig::load_with_diagnostics(&c.repo_path) {
625 Ok(Some(cfg)) => !cfg.disable_daemon,
626 Ok(None) => true,
627 Err(_) => false,
628 }
629 });
630 let skipped = before - candidates.len();
631 if skipped > 0 && !args.json {
632 output::print_info(&format!(
633 "Skipped {skipped} bloat directories in repositories that set `disable_daemon`."
634 ));
635 }
636 }
637
638 if args.dry_run {
640 if args.json {
641 json::emit(&json::run_document(
642 &[candidates, blocked, left_alone, missing].concat(),
643 true,
644 ))?;
645 return Ok(());
646 }
647 if candidates.is_empty()
648 && blocked.is_empty()
649 && left_alone.is_empty()
650 && missing.is_empty()
651 {
652 output::print_info(i18n::t("run.nothing"));
653 return Ok(());
654 }
655 if !candidates.is_empty() {
656 report_candidates(&candidates);
657 }
658 let total: u64 = candidates.iter().map(|c| c.size_freed).sum();
659 output::print_header(i18n::t("run.summary.dry"));
660 output::print_info(&i18n::tf(
661 "run.would_free",
662 &[
663 ("size", &output::format_bytes(total)),
664 ("count", &candidates.len().to_string()),
665 ],
666 ));
667 report_blocked(&blocked);
670 report_left_alone(&left_alone);
671 report_missing(&missing);
672 return Ok(());
673 }
674
675 if candidates.is_empty() {
676 if args.json {
677 json::emit(&json::run_document(
678 &[blocked.clone(), left_alone, missing].concat(),
679 false,
680 ))?;
681 return fail_if_blocked(&blocked);
682 }
683 if blocked.is_empty() && left_alone.is_empty() && missing.is_empty() {
684 output::print_info(i18n::t("run.nothing"));
685 return Ok(());
686 }
687 output::print_info(i18n::t("run.nothing.bloat"));
688 report_blocked(&blocked);
689 report_left_alone(&left_alone);
690 report_missing(&missing);
691 return fail_if_blocked(&blocked);
692 }
693
694 let total_reclaimable: u64 = candidates.iter().map(|c| c.size_freed).sum();
695
696 if !args.json {
697 report_binaries(&candidates);
698 report_candidates(&candidates);
699 output::print_info(&i18n::tf(
700 "run.reclaimable",
701 &[("size", &output::format_bytes_styled(total_reclaimable))],
702 ));
703 report_blocked(&blocked);
704 report_left_alone(&left_alone);
705 report_missing(&missing);
706 }
707
708 let target_candidates: Vec<PruneResult> = if args.json
711 || args.yes
712 || !registry.settings.require_confirmation
713 {
714 candidates
715 } else if io::stdout().is_terminal() && io::stdin().is_terminal() {
716 eprintln!();
717 eprintln!(
718 " Loading interactive selector... (↑↓ navigate, Space toggle, Enter confirm, q cancel)"
719 );
720 eprintln!();
721 let selected = tui::selection_view::select_candidates_tui(&candidates)?;
722 if selected.is_empty() {
723 output::print_info("Prune pass cancelled by user (0 candidates selected).");
724 return Ok(());
725 }
726 selected
727 } else {
728 if !io::stdin().is_terminal() {
732 anyhow::bail!(
733 "Deleting {} directories ({}) needs confirmation, and there is no \
734 terminal to ask on. Re-run with `--yes` to confirm, or `--dry-run` \
735 to only analyse.",
736 candidates.len(),
737 output::format_bytes(total_reclaimable)
738 );
739 }
740 println!();
741 output::print_warning("CAUTION: Deleting bloat directories cannot be undone directly.");
742 output::print_info(
743 "Note: You can re-install missing dependencies anytime using `dev-prune restore`.",
744 );
745 eprint!(
748 "Proceed with deletion of {} directories ({})? [y/N]: ",
749 candidates.len(),
750 output::format_bytes(total_reclaimable)
751 );
752 io::stderr().flush()?;
753
754 let mut input = String::new();
755 io::stdin().read_line(&mut input)?;
756 let trimmed = input.trim().to_lowercase();
757 if trimmed != "y" && trimmed != "yes" {
758 output::print_info("Prune pass aborted by user.");
759 return Ok(());
760 }
761 candidates
762 };
763
764 if !args.json {
765 let selected_total_bytes: u64 = target_candidates.iter().map(|c| c.size_freed).sum();
766 output::print_header(&i18n::tf(
767 "run.header.deleting",
768 &[
769 ("repos", &target_candidates.len().to_string()),
770 ("size", &output::format_bytes(selected_total_bytes)),
771 ],
772 ));
773 }
774
775 let mut selection: Vec<(std::path::PathBuf, Vec<String>)> = Vec::new();
781 for candidate in &target_candidates {
782 match selection
783 .iter_mut()
784 .find(|(p, _)| *p == candidate.repo_path)
785 {
786 Some((_, dirs)) => dirs.push(candidate.bloat_dir.clone()),
787 None => selection.push((
788 candidate.repo_path.clone(),
789 vec![candidate.bloat_dir.clone()],
790 )),
791 }
792 }
793
794 let mut error_count = blocked.len();
798 let mut all_results: Vec<PruneResult> = blocked;
799 all_results.extend(left_alone);
800 all_results.extend(missing);
801 let mut total_freed: u64 = 0;
802 let mut pruned_count = 0;
803 let mut pruned_dirs: Vec<crate::config::PrunedDir> = Vec::new();
804 let pass_at = chrono::Utc::now();
807
808 for (repo_path, dirs) in &selection {
809 let recorded_before = pruned_dirs.len();
810 let idle_days = registry
815 .repositories
816 .get(repo_path)
817 .and_then(|e| e.override_idle_days)
818 .unwrap_or(registry.settings.idle_days);
819 let single_results = engine::prune_repo_with(
820 repo_path,
821 &PruneOptions {
822 idle_days,
823 dry_run: false,
824 force: args.force,
825 only_dirs: Some(dirs.clone()),
826 adapters: filter.clone(),
827 min_size_bytes: 0,
828 scan_depth: analysis.scan_depth,
829 allow_manifest_rewrite: analysis.allow_manifest_rewrite,
830 command_timeout_secs: analysis.command_timeout_secs,
831 build_idle_days: analysis.build_idle_days,
832 adapter_idle_days: analysis.adapter_idle_days.clone(),
833 },
834 );
835 for result in single_results {
836 match &result.status {
837 PruneStatus::Pruned => {
838 total_freed += result.size_freed;
839 pruned_count += 1;
840 registry.mark_pruned(&result.repo_path, result.size_freed);
841 pruned_dirs.push(crate::config::PrunedDir {
842 repo_path: result.repo_path.clone(),
843 bloat_dir: result.bloat_dir.clone(),
844 adapter: result.adapter_name.clone(),
845 size_freed: result.size_freed,
846 runtime: result.runtime.clone(),
847 });
848 if !args.json {
849 output::print_success(&format!(
850 "{} → {} ({}) — {}{}",
851 output::clean_path(&result.repo_path),
852 result.bloat_dir,
853 output::format_bytes(result.size_freed),
854 result.adapter_name,
855 output::shared_note(result.shared_bytes, &result.adapter_name)
856 ));
857 }
858 }
859 PruneStatus::LockfileError(e) => {
860 error_count += 1;
861 if !args.json {
862 report_lockfile_failure(&result, e);
863 }
864 }
865 PruneStatus::ActivityCheckError(e) => {
866 error_count += 1;
867 if !args.json {
868 output::print_error(&format!(
869 "{} skipped — its activity could not be determined:\n {}",
870 output::clean_path(&result.repo_path),
871 e.trim()
872 ));
873 }
874 }
875 PruneStatus::DeleteError(e) => {
876 error_count += 1;
877 if result.size_freed > 0 {
882 pruned_dirs.push(crate::config::PrunedDir {
883 repo_path: result.repo_path.clone(),
884 bloat_dir: result.bloat_dir.clone(),
885 adapter: result.adapter_name.clone(),
886 size_freed: result.size_freed,
887 runtime: result.runtime.clone(),
888 });
889 }
890 if !args.json {
891 output::print_error(&format!(
892 "{} → delete failed: {}",
893 output::clean_path(&result.repo_path),
894 e,
895 ));
896 }
897 }
898 PruneStatus::ConfigError(e) => {
899 error_count += 1;
900 if !args.json {
901 let clean_p = output::clean_path(&result.repo_path);
902 output::print_error(&format!(
903 "{clean_p} skipped — its .devprune.json could not be read:\n {}",
904 e.trim()
905 ));
906 output::print_info(&format!(
907 " Fix command: devp config {clean_p} --update"
908 ));
909 }
910 }
911 PruneStatus::SkippedActive if !args.json => {
914 output::print_info(&format!(
915 "{} became active since the analysis — left alone. \
916 Use `--ignore-idle` to prune it anyway.",
917 output::clean_path(&result.repo_path)
918 ));
919 }
920 _ => {}
921 }
922 all_results.push(result);
923 }
924
925 if pruned_dirs.len() > recorded_before {
931 registry.record_prune_progress(pass_at, pruned_dirs.clone());
932 let _ = registry.save();
933 }
934 }
935
936 registry.record_prune_progress(pass_at, pruned_dirs);
937 registry.save()?;
938
939 if args.json {
940 json::emit(&json::run_document(&all_results, false))?;
941 if error_count > 0 {
944 anyhow::bail!("{error_count} repositories could not be pruned.");
945 }
946 return Ok(());
947 }
948
949 output::print_header(i18n::t("run.summary"));
950 output::print_success(&i18n::tf(
951 "run.freed",
952 &[
953 ("size", &output::format_bytes_styled(total_freed)),
954 ("count", &pruned_count.to_string()),
955 ],
956 ));
957
958 if error_count > 0 {
959 output::print_warning(&i18n::tf(
960 "run.not_pruned",
961 &[("count", &error_count.to_string())],
962 ));
963
964 if all_results
968 .iter()
969 .any(|r| matches!(r.status, PruneStatus::LockfileError(_)))
970 {
971 output::print_info(
975 "Lockfile verification cannot be bypassed: without a lockfile the deleted \
976 dependencies could not be reinstalled. Run the fix command shown above for \
977 each repo, then re-run `devp run`.",
978 );
979 }
980 anyhow::bail!("{error_count} repositories could not be pruned.");
982 }
983
984 crate::commands::update::maybe_auto_update(®istry);
987
988 Ok(())
989}
990
991#[derive(Debug, PartialEq, Eq, Clone, Copy)]
1002enum ActivityFailure {
1003 UntrustedOwner,
1005 NotARepository,
1007 Individual,
1009}
1010
1011impl ActivityFailure {
1012 fn classify(message: &str) -> Self {
1019 let lower = message.to_lowercase();
1020 if lower.contains(constants::GIT_DUBIOUS_OWNERSHIP) {
1021 Self::UntrustedOwner
1022 } else if lower.contains(constants::GIT_NOT_A_REPOSITORY) {
1023 Self::NotARepository
1024 } else {
1025 Self::Individual
1026 }
1027 }
1028}
1029
1030const GROUPED_PATHS_SHOWN: usize = 8;
1035
1036fn report_blocked(blocked: &[PruneResult]) {
1040 if blocked.is_empty() {
1041 return;
1042 }
1043 output::print_header(&i18n::tf(
1044 "run.header.blocked",
1045 &[("count", &blocked.len().to_string())],
1046 ));
1047
1048 let grouped = |failure: ActivityFailure| -> Vec<&PruneResult> {
1049 blocked
1050 .iter()
1051 .filter(|r| match &r.status {
1052 PruneStatus::ActivityCheckError(e) => ActivityFailure::classify(e) == failure,
1053 _ => false,
1054 })
1055 .collect()
1056 };
1057
1058 let untrusted = grouped(ActivityFailure::UntrustedOwner);
1059 if !untrusted.is_empty() {
1060 let n = untrusted.len();
1061 output::print_error(&format!(
1062 "{n} {} owned by a different account — Git will not read {}.",
1063 output::plural(n, "repository is", "repositories are"),
1064 output::plural(n, "it", "them")
1065 ));
1066 list_paths(&untrusted);
1067 output::print_wrapped(
1068 " ",
1069 "Nothing is wrong with the repositories themselves. The owner recorded on \
1070 disk is usually one a Windows reinstall, a restored backup or a drive moved \
1071 between machines left behind.",
1072 );
1073 output::print_wrapped(
1074 " ",
1075 "dev-prune dates a repository by its last commit, so one Git will not open \
1076 has no known age — and nothing is ever deleted from a repository whose age is \
1077 unknown.",
1078 );
1079 output::print_info(&format!(
1080 " Fix all {n} at once: devp trust --fix-ownership"
1081 ));
1082 }
1083
1084 let orphaned = grouped(ActivityFailure::NotARepository);
1085 if !orphaned.is_empty() {
1086 if !untrusted.is_empty() {
1087 println!();
1088 }
1089 let n = orphaned.len();
1090 output::print_error(&format!(
1091 "{n} registered {} not {} git {} any more.",
1092 output::plural(n, "path is", "paths are"),
1093 output::plural(n, "a", ""),
1094 output::plural(n, "repository", "repositories")
1095 ));
1096 list_paths(&orphaned);
1097 output::print_wrapped(
1098 " ",
1099 "The directory is still there; its `.git` is not — a clone deleted and \
1100 recreated by hand, or a worktree `git worktree prune` has since removed. The \
1101 registry entry outlived what it pointed at.",
1102 );
1103 output::print_info(&format!(
1107 " Drop {} from the registry: devp unlink <path>",
1108 output::plural(n, "it", "them")
1109 ));
1110 }
1111
1112 for result in blocked {
1113 let clean_p = output::clean_path(&result.repo_path);
1114 match &result.status {
1115 PruneStatus::ConfigError(e) => {
1116 output::print_error(&format!(
1117 "{clean_p} skipped — its .devprune.json could not be read:
1118 {}",
1119 e.trim()
1120 ));
1121 output::print_info(&format!(
1122 " Fix command: devp config {clean_p} --update"
1123 ));
1124 }
1125 PruneStatus::LockfileError(e) => report_lockfile_failure(result, e),
1126 PruneStatus::ActivityCheckError(e)
1127 if ActivityFailure::classify(e) == ActivityFailure::Individual =>
1128 {
1129 output::print_error(&format!(
1130 "{clean_p} skipped — its activity could not be determined:
1131 {}",
1132 output::condense_tool_output(e, 4)
1133 ));
1134 }
1135 PruneStatus::DeleteError(e) => {
1136 output::print_error(&format!("{clean_p} → delete failed: {e}"));
1137 }
1138 _ => {}
1140 }
1141 }
1142}
1143
1144fn list_paths(results: &[&PruneResult]) {
1149 for result in results.iter().take(GROUPED_PATHS_SHOWN) {
1150 println!(" {}", output::styled_path(&result.repo_path));
1151 }
1152 if let Some(rest) = results
1153 .len()
1154 .checked_sub(GROUPED_PATHS_SHOWN)
1155 .filter(|n| *n > 0)
1156 {
1157 output::print_dimmed(&format!(
1158 " … and {rest} more — `devp run --dry-run --json` lists every one."
1159 ));
1160 }
1161}
1162
1163fn report_left_alone(left_alone: &[PruneResult]) {
1171 for result in left_alone {
1172 if let PruneStatus::SkippedSymlink(e) | PruneStatus::SkippedDeclaration(e) = &result.status
1173 {
1174 output::print_warning(&format!(
1175 "{} → {}",
1176 output::clean_path(&result.repo_path),
1177 e.trim()
1178 ));
1179 }
1180 }
1181}
1182
1183fn report_missing(missing: &[PruneResult]) {
1189 if missing.is_empty() {
1190 return;
1191 }
1192 println!();
1193 let n = missing.len();
1194 output::print_warning(&format!(
1195 "{n} registered {} no longer {} on disk.",
1196 output::plural(n, "path", "paths"),
1197 output::plural(n, "exists", "exist")
1198 ));
1199 for result in missing.iter().take(GROUPED_PATHS_SHOWN) {
1204 println!(" {}", output::styled_path(&result.repo_path));
1205 }
1206 if let Some(rest) = missing
1207 .len()
1208 .checked_sub(GROUPED_PATHS_SHOWN)
1209 .filter(|n| *n > 0)
1210 {
1211 output::print_dimmed(&format!(
1212 " … and {rest} more — `devp run --dry-run --json` lists every one."
1213 ));
1214 }
1215 output::print_info(&format!(
1216 " Clear {} from the registry: devp unlink --missing",
1217 output::plural(n, "it", "them all")
1218 ));
1219}
1220
1221fn fail_if_blocked(blocked: &[PruneResult]) -> Result<()> {
1226 if blocked.is_empty() {
1227 return Ok(());
1228 }
1229 anyhow::bail!("{} repositories could not be examined.", blocked.len());
1230}
1231
1232fn report_binaries(candidates: &[PruneResult]) {
1234 let adapter_names: Vec<String> = candidates.iter().map(|c| c.adapter_name.clone()).collect();
1235 let binary_statuses = adapters::scan_required_binaries(&adapter_names);
1236 if binary_statuses.is_empty() {
1237 return;
1238 }
1239 output::print_header(i18n::t("run.header.binaries"));
1240 for b in &binary_statuses {
1241 if b.available {
1242 output::print_success(&format!(
1243 " {} — available ({})",
1244 b.name,
1245 b.version.as_deref().unwrap_or("detected")
1246 ));
1247 } else {
1248 output::print_warning(&format!(
1249 " {} — missing (lockfile fallback active)",
1250 b.name
1251 ));
1252 }
1253 }
1254}
1255
1256fn report_candidates(candidates: &[PruneResult]) {
1257 output::print_header(i18n::t("run.header.candidates"));
1258 for candidate in candidates {
1259 output::print_info(&format!(
1260 " • {} → {} ({}) [{}]{}",
1261 output::styled_path(&candidate.repo_path),
1262 candidate.bloat_dir,
1263 output::format_bytes_styled(candidate.size_freed),
1264 output::styled_adapter(&candidate.adapter_name),
1265 output::shared_note(candidate.shared_bytes, &candidate.adapter_name)
1266 ));
1267 }
1268}
1269
1270pub(crate) fn report_lockfile_failure(result: &PruneResult, error: &str) {
1271 let project = output::clean_path(result.project_dir());
1274
1275 output::print_error(&format!(
1276 "{} → {} lockfile sync failed:\n {}",
1277 project,
1278 result.adapter_name,
1279 error.trim(),
1280 ));
1281 match json::lockfile_fix_command(&result.adapter_name) {
1282 Some(sync_cmd) => {
1283 #[cfg(windows)]
1286 let manual_cmd = format!("cd \"{project}\"; {sync_cmd}");
1287 #[cfg(not(windows))]
1288 let manual_cmd = format!("cd \"{project}\" && {sync_cmd}");
1289 output::print_info(&format!(" Fix command: {manual_cmd}"));
1290 }
1291 None => output::print_info(&format!(" Fix it in: {project}")),
1294 }
1295 output::print_info(&format!(
1296 " Troubleshooting: {}",
1297 constants::TROUBLESHOOTING_URL
1298 ));
1299}
1300
1301fn run_explain(args: &RunArgs<'_>, filter: &AdapterFilter) -> Result<()> {
1312 output::print_header(i18n::t("run.header.reasons"));
1313 if let Some(desc) = filter.describe() {
1314 output::print_info(&format!("Adapter filter: {desc}"));
1315 }
1316
1317 if let Some(target_str) = args.target_path {
1318 let raw = Path::new(target_str);
1319 let path = if raw.exists() {
1320 raw.canonicalize().unwrap_or_else(|_| raw.to_path_buf())
1321 } else {
1322 raw.to_path_buf()
1323 };
1324 if !crate::scanner::is_git_repo(&path) {
1325 anyhow::bail!(
1326 "{} is not a Git repository — dev-prune only prunes Git repos.",
1327 output::clean_path(&path)
1328 );
1329 }
1330 let registry = Registry::load().ok();
1331 let idle_days = registry
1332 .as_ref()
1333 .map(|r| {
1334 r.repositories
1335 .get(&path)
1336 .and_then(|e| e.override_idle_days)
1337 .unwrap_or(r.settings.idle_days)
1338 })
1339 .unwrap_or(constants::DEFAULT_IDLE_DAYS);
1340 let floor = resolve_min_size(args, registry.as_ref());
1341 let results = engine::prune_repo_with(
1342 &path,
1343 &PruneOptions {
1344 idle_days,
1345 dry_run: true,
1346 force: args.force,
1347 only_dirs: None,
1348 adapters: filter.clone(),
1349 min_size_bytes: 0,
1350 scan_depth: resolve_scan_depth(registry.as_ref()),
1351 allow_manifest_rewrite: resolve_manifest_rewrite(registry.as_ref()),
1352 command_timeout_secs: resolve_command_timeout(registry.as_ref()),
1353 build_idle_days: resolve_build_idle_days(registry.as_ref()),
1354 adapter_idle_days: resolve_adapter_idle_days(registry.as_ref()),
1355 },
1356 );
1357 let refs: Vec<&PruneResult> = results.iter().collect();
1358 explain_repo(&path, &refs, floor, idle_days);
1359 print_explain_footer();
1360 return Ok(());
1361 }
1362
1363 let mut registry = Registry::load()?;
1364 if registry.repo_count() == 0 {
1365 output::print_warning("No repositories registered. Run `dev-prune init` first.");
1366 return Ok(());
1367 }
1368
1369 let except = parse_except(args.except);
1370 let global_floor = resolve_min_size(args, Some(®istry));
1371 let analysis = PruneOptions {
1372 idle_days: 0, dry_run: true,
1374 force: args.force,
1375 only_dirs: None,
1376 adapters: filter.clone(),
1377 min_size_bytes: 0,
1378 scan_depth: resolve_scan_depth(Some(®istry)),
1379 allow_manifest_rewrite: resolve_manifest_rewrite(Some(®istry)),
1380 command_timeout_secs: resolve_command_timeout(Some(®istry)),
1381 build_idle_days: resolve_build_idle_days(Some(®istry)),
1382 adapter_idle_days: resolve_adapter_idle_days(Some(®istry)),
1383 };
1384 let results = engine::prune_all_with(&mut registry, &analysis);
1385
1386 let mut by_repo: std::collections::HashMap<&Path, Vec<&PruneResult>> =
1387 std::collections::HashMap::new();
1388 for r in &results {
1389 by_repo.entry(r.repo_path.as_path()).or_default().push(r);
1390 }
1391
1392 let mut repos: Vec<&std::path::PathBuf> = registry.repositories.keys().collect();
1393 repos.sort();
1394 for path in repos {
1395 if is_excepted(path, &except) {
1396 println!();
1397 output::print_info(&output::clean_path(path));
1398 println!(" • left completely alone this pass (`--except`)");
1399 continue;
1400 }
1401 let idle_days = registry
1402 .repositories
1403 .get(path)
1404 .and_then(|e| e.override_idle_days)
1405 .unwrap_or(registry.settings.idle_days);
1406 let empty = Vec::new();
1407 let repo_results = by_repo.get(path.as_path()).unwrap_or(&empty);
1408 explain_repo(path, repo_results, global_floor, idle_days);
1409 }
1410 print_explain_footer();
1411 Ok(())
1412}
1413
1414fn explain_repo(path: &Path, results: &[&PruneResult], floor: u64, idle_days: u64) {
1416 println!();
1417 output::print_info(&output::clean_path(path));
1418
1419 if results.is_empty() {
1420 println!(
1421 " • idle, but no known bloat directories were found. A project deeper than \
1422 `scan_depth` is not examined — `devp status` shows what dev-prune can see."
1423 );
1424 return;
1425 }
1426
1427 for r in results {
1428 match &r.status {
1429 PruneStatus::SkippedDryRun => {
1430 if r.size_freed >= floor {
1431 output::print_success(&format!(
1432 "would prune {} ({}) [{}]{}",
1433 r.bloat_dir,
1434 output::format_bytes(r.size_freed),
1435 r.adapter_name,
1436 output::shared_note(r.shared_bytes, &r.adapter_name)
1437 ));
1438 } else {
1439 println!(
1440 " • {} ({}) is under the size floor of {} — the reinstall would \
1441 cost more than the space is worth. `--min-size 0` includes it.",
1442 r.bloat_dir,
1443 output::format_bytes(r.size_freed),
1444 output::format_bytes(floor)
1445 );
1446 }
1447 }
1448 PruneStatus::SkippedActive => {
1449 let age = crate::scanner::git::get_last_activity(path)
1450 .ok()
1451 .flatten()
1452 .and_then(|t| std::time::SystemTime::now().duration_since(t).ok())
1453 .map(|d| d.as_secs() / 86_400);
1454 match age {
1455 Some(0) => println!(
1456 " • active — there was activity today, and the idle \
1457 threshold is {idle_days} days. `--ignore-idle` overrides."
1458 ),
1459 Some(days) => println!(
1460 " • active — last activity {days} day{} ago, and the idle \
1461 threshold is {idle_days} days. `--ignore-idle` overrides.",
1462 if days == 1 { "" } else { "s" }
1463 ),
1464 None => println!(
1465 " • active (not idle for {idle_days} days yet). \
1466 `--ignore-idle` overrides."
1467 ),
1468 }
1469 }
1470 other => println!(" • {other}"),
1471 }
1472 }
1473}
1474
1475fn print_explain_footer() {
1477 println!();
1478 output::print_info(
1479 "Nothing was verified or deleted. `devp run --dry-run` verifies candidates; \
1480 `devp run` prunes.",
1481 );
1482}
1483
1484#[cfg(test)]
1485mod tests {
1486 use super::*;
1487
1488 #[test]
1489 fn gits_ownership_refusal_is_recognised_whatever_the_path() {
1490 let message = "git could not read `V:/x`: fatal: detected dubious ownership in repository \
1494 at 'V:/x'";
1495 assert_eq!(
1496 ActivityFailure::classify(message),
1497 ActivityFailure::UntrustedOwner
1498 );
1499 }
1500
1501 #[test]
1502 fn a_path_that_lost_its_git_directory_is_its_own_cause() {
1503 let message = "fatal: not a git repository (or any of the parent directories): .git";
1507 assert_eq!(
1508 ActivityFailure::classify(message),
1509 ActivityFailure::NotARepository
1510 );
1511 }
1512
1513 #[test]
1514 fn an_unfamiliar_failure_is_still_printed_in_full() {
1515 assert_eq!(
1516 ActivityFailure::classify("fatal: unable to read tree"),
1517 ActivityFailure::Individual
1518 );
1519 }
1520}