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