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 println!(" Or do it the old-school way — every step that skill would take, by hand:");
120 println!(" • `devp run --explain` the verdict for every repository and directory,");
121 println!(" and the one reason behind each of them.");
122 println!(" • `devp status` what is registered, how idle it is, what it holds.");
123 println!(" • `devp doctor` the install itself: hooks, scheduler, config, PATH.");
124 println!(" • `devp config show` every setting and the value it currently has.");
125 println!(" • `devp man` the full reference, offline.");
126 println!();
127}
128
129fn run_targeted(args: &RunArgs<'_>, filter: &AdapterFilter, target_str: &str) -> Result<()> {
131 let raw = std::path::Path::new(target_str);
132 let path = if raw.exists() {
133 raw.canonicalize().unwrap_or_else(|_| raw.to_path_buf())
134 } else {
135 raw.to_path_buf()
136 };
137
138 let clean = output::clean_path(&path);
139 if !crate::scanner::is_git_repo(&path) {
140 anyhow::bail!("{clean} is not a Git repository — dev-prune only prunes Git repos.");
143 }
144
145 let registry = Registry::load().ok();
149 let idle_days = registry
150 .as_ref()
151 .map(|r| {
152 r.repositories
153 .get(&path)
154 .and_then(|e| e.override_idle_days)
155 .unwrap_or(r.settings.idle_days)
156 })
157 .unwrap_or(constants::DEFAULT_IDLE_DAYS);
158
159 let mut opts = PruneOptions {
160 idle_days,
161 dry_run: args.dry_run,
162 force: args.force,
163 only_dirs: None,
164 adapters: filter.clone(),
165 min_size_bytes: resolve_min_size(args, registry.as_ref()),
166 scan_depth: resolve_scan_depth(registry.as_ref()),
167 allow_manifest_rewrite: resolve_manifest_rewrite(registry.as_ref()),
168 command_timeout_secs: resolve_command_timeout(registry.as_ref()),
169 build_idle_days: resolve_build_idle_days(registry.as_ref()),
170 adapter_idle_days: resolve_adapter_idle_days(registry.as_ref()),
171 };
172
173 if !args.json {
174 output::print_header(&i18n::tf(
175 "run.header.targeted",
176 &[("path", clean.as_str())],
177 ));
178 if let Some(desc) = filter.describe() {
179 output::print_info(&format!("Adapter filter: {desc}"));
180 }
181 }
182
183 if !args.dry_run
189 && !args.json
190 && !args.yes
191 && registry
192 .as_ref()
193 .is_none_or(|r| r.settings.require_confirmation)
194 {
195 let preview = engine::prune_repo_with(
196 &path,
197 &PruneOptions {
198 dry_run: true,
199 ..opts.clone()
200 },
201 );
202 let candidates: Vec<PruneResult> = preview
203 .into_iter()
204 .filter(|r| matches!(r.status, PruneStatus::SkippedDryRun))
205 .collect();
206 if !candidates.is_empty() {
209 let total: u64 = candidates.iter().map(|c| c.size_freed).sum();
210 report_candidates(&candidates);
211 output::print_info(&i18n::tf(
212 "run.reclaimable",
213 &[("size", &output::format_bytes_styled(total))],
214 ));
215 output::print_info(
216 "Everything above is rebuilt from a lockfile — `devp restore` brings it back.",
217 );
218 if !io::stdin().is_terminal() {
219 anyhow::bail!(
220 "Deleting {} directories ({}) needs confirmation, and there is no \
221 terminal to ask on. Re-run with `--yes` to confirm, or `--dry-run` \
222 to only analyse.",
223 candidates.len(),
224 output::format_bytes(total)
225 );
226 }
227 eprint!(
230 "Proceed with deletion of {} directories ({})? [y/N]: ",
231 candidates.len(),
232 output::format_bytes(total)
233 );
234 io::stderr().flush()?;
235 let mut input = String::new();
236 io::stdin().read_line(&mut input)?;
237 let trimmed = input.trim().to_lowercase();
238 if trimmed != "y" && trimmed != "yes" {
239 output::print_info("Prune pass aborted by user.");
240 return Ok(());
241 }
242 opts.only_dirs = Some(candidates.iter().map(|c| c.bloat_dir.clone()).collect());
246 }
247 }
248
249 let results = engine::prune_repo_with(&path, &opts);
250
251 let error_count = results
255 .iter()
256 .filter(|r| {
257 matches!(
258 r.status,
259 PruneStatus::LockfileError(_)
260 | PruneStatus::ActivityCheckError(_)
261 | PruneStatus::DeleteError(_)
262 | PruneStatus::ConfigError(_)
263 )
264 })
265 .count();
266
267 record_targeted_prune(&path, &results, args.dry_run, args.daemon);
272
273 if args.json {
274 json::emit(&json::run_document(&results, args.dry_run))?;
275 if error_count > 0 {
276 anyhow::bail!("{error_count} directories in {clean} could not be pruned.");
277 }
278 return Ok(());
279 }
280
281 if results.is_empty() {
282 output::print_info(&i18n::tf(
283 "run.nothing.bloat.targeted",
284 &[("path", clean.as_str())],
285 ));
286 return Ok(());
287 }
288
289 let mut total_freed = 0;
290 for result in results {
291 match &result.status {
292 PruneStatus::Pruned => {
293 total_freed += result.size_freed;
294 output::print_success(&format!(
295 "{} → {} ({}) — {}{}",
296 output::clean_path(&result.repo_path),
297 result.bloat_dir,
298 output::format_bytes(result.size_freed),
299 result.adapter_name,
300 output::shared_note(result.shared_bytes, &result.adapter_name)
301 ));
302 }
303 PruneStatus::SkippedDryRun => {
304 output::print_info(&format!(
305 " • {} → {} ({}) [{}] (Dry Run){}",
306 output::clean_path(&result.repo_path),
307 result.bloat_dir,
308 output::format_bytes(result.size_freed),
309 result.adapter_name,
310 output::shared_note(result.shared_bytes, &result.adapter_name)
311 ));
312 }
313 PruneStatus::SkippedActive => {
314 output::print_info(&format!(
315 "{clean} is currently active (not idle). Use `devp --ignore-idle run` to override."
316 ));
317 }
318 PruneStatus::LockfileError(e) => report_lockfile_failure(&result, e),
319 PruneStatus::ActivityCheckError(e) => {
320 output::print_error(&format!(
321 "{clean} skipped — its activity could not be determined:\n {}",
322 e.trim()
323 ));
324 }
325 PruneStatus::DeleteError(e) => {
326 output::print_error(&format!("{clean} delete error: {e}"));
327 }
328 PruneStatus::ConfigError(e) => {
329 output::print_error(&format!(
330 "{clean} skipped — its .devprune.json could not be read:\n {}\n \
331 Fix it, or run `devp config {clean} --update` to reset it.",
332 e.trim()
333 ));
334 }
335 PruneStatus::SkippedSymlink(e) => {
336 output::print_warning(&format!("{clean} → {}", e.trim()));
337 }
338 PruneStatus::SkippedDeclaration(e) => {
339 output::print_warning(&format!("{clean} → {}", e.trim()));
340 }
341 PruneStatus::SkippedNestedRepo(e) => {
342 output::print_warning(&format!("{clean} → {}", e.trim()));
343 }
344 _ => {}
345 }
346 }
347
348 if !args.dry_run && total_freed > 0 {
349 output::print_success(&i18n::tf(
350 "run.freed.targeted",
351 &[
352 ("size", &output::format_bytes(total_freed)),
353 ("path", clean.as_str()),
354 ],
355 ));
356 }
357
358 if error_count > 0 {
359 anyhow::bail!("{error_count} directories in {clean} could not be pruned.");
360 }
361
362 Ok(())
363}
364
365fn record_targeted_prune(
371 path: &std::path::Path,
372 results: &[PruneResult],
373 dry_run: bool,
374 daemon: bool,
375) {
376 if dry_run {
377 return;
378 }
379
380 let pruned: Vec<crate::config::PrunedDir> = results
383 .iter()
384 .filter(|r| {
385 matches!(r.status, PruneStatus::Pruned)
386 || (matches!(r.status, PruneStatus::DeleteError(_)) && r.size_freed > 0)
387 })
388 .map(|r| crate::config::PrunedDir {
389 repo_path: r.repo_path.clone(),
390 bloat_dir: r.bloat_dir.clone(),
391 adapter: r.adapter_name.clone(),
392 size_freed: r.size_freed,
393 runtime: r.runtime.clone(),
394 })
395 .collect();
396
397 if pruned.is_empty() {
398 return;
399 }
400
401 let freed: u64 = pruned.iter().map(|d| d.size_freed).sum();
402 let pass_at = chrono::Utc::now();
405 crate::history::record(pass_at, crate::history::Trigger::for_run(daemon), &pruned);
406 if let Ok(mut registry) = Registry::load() {
407 registry.mark_pruned(path, freed);
408 registry.record_prune_progress(pass_at, pruned);
409 let _ = registry.save();
410 }
411}
412
413fn resolve_min_size(args: &RunArgs<'_>, registry: Option<&Registry>) -> u64 {
418 let mb = args
419 .min_size_mb
420 .or_else(|| registry.map(|r| r.settings.min_size_mb))
421 .unwrap_or(constants::DEFAULT_MIN_SIZE_MB);
422 mb.saturating_mul(engine::BYTES_PER_MIB)
423}
424
425fn parse_except(spec: Option<&str>) -> Vec<String> {
432 spec.map(|s| {
433 s.split(',')
434 .map(|part| {
435 crate::config::expand_tilde(part.trim())
436 .trim_end_matches(['/', '\\'])
437 .to_lowercase()
438 })
439 .filter(|part| !part.is_empty())
440 .collect()
441 })
442 .unwrap_or_default()
443}
444
445fn is_excepted(repo_path: &Path, except: &[String]) -> bool {
452 if except.is_empty() {
453 return false;
454 }
455 let full = output::clean_path(repo_path)
456 .to_lowercase()
457 .replace('\\', "/");
458 let name = repo_path
459 .file_name()
460 .map(|n| n.to_string_lossy().to_lowercase())
461 .unwrap_or_default();
462
463 except.iter().any(|want| {
464 let want = want.replace('\\', "/");
465 name == want || full == want || full.ends_with(&format!("/{want}"))
466 })
467}
468
469fn resolve_scan_depth(registry: Option<&Registry>) -> usize {
471 registry
472 .map(|r| r.settings.scan_depth)
473 .unwrap_or(constants::DEFAULT_SCAN_DEPTH)
474}
475
476fn resolve_build_idle_days(registry: Option<&Registry>) -> u64 {
478 registry
479 .map(|r| r.settings.build_idle_days)
480 .unwrap_or(constants::DEFAULT_BUILD_IDLE_DAYS)
481}
482
483fn resolve_adapter_idle_days(
485 registry: Option<&Registry>,
486) -> std::collections::BTreeMap<String, u64> {
487 registry
488 .map(|r| r.settings.adapter_idle_days.clone())
489 .unwrap_or_default()
490}
491
492fn resolve_command_timeout(registry: Option<&Registry>) -> u64 {
493 registry
494 .map(|r| r.settings.command_timeout_secs)
495 .unwrap_or(constants::DEFAULT_COMMAND_TIMEOUT_SECS)
496}
497
498fn resolve_manifest_rewrite(registry: Option<&Registry>) -> bool {
500 registry
501 .map(|r| r.settings.allow_manifest_rewrite)
502 .unwrap_or(constants::DEFAULT_ALLOW_MANIFEST_REWRITE)
503}
504
505fn run_registry(args: &RunArgs<'_>, filter: &AdapterFilter) -> Result<()> {
507 if !args.json {
508 if args.dry_run {
509 output::print_header(i18n::t("run.header.dry"));
510 } else {
511 output::print_header(i18n::t("run.header"));
512 }
513 }
514
515 let mut registry = Registry::load()?;
516
517 let adopted = crate::commands::link::adopt_enclosing_repo(&mut registry);
521 if adopted.is_some() {
522 registry.save()?;
523 }
524 if let Some(path) = &adopted
525 && !args.json
526 {
527 crate::commands::link::report_cwd_adoption(path);
528 println!();
529 }
530
531 if !args.json && crate::commands::update::notify_if_outdated(&mut registry) {
534 let _ = registry.save();
535 }
536
537 if args.daemon && registry.settings.auto_discover && !setup::no_auto_setup_requested() {
549 let found = crate::discovery::discover(®istry)?;
550 let added = found
551 .found
552 .into_iter()
553 .filter(|repo| registry.add_repo(repo.clone()))
554 .count();
555 if added > 0 {
556 registry.save()?;
557 if !args.json {
558 output::print_info(&format!(
559 "Discovered and registered {added} new {}.",
560 output::plural(added, "repository", "repositories")
561 ));
562 }
563 }
564 }
565
566 if registry.repo_count() == 0 {
567 if args.json {
568 return json::emit(&json::run_document(&[], args.dry_run));
569 }
570 output::print_warning(
571 "No repositories registered. Run `devp init` here, or `devp init --auto` to find them.",
572 );
573 return Ok(());
574 }
575
576 let except = parse_except(args.except);
580 if !except.is_empty() {
581 let unmatched: Vec<&String> = except
582 .iter()
583 .filter(|want| {
584 !registry
585 .repositories
586 .keys()
587 .any(|p| is_excepted(p, std::slice::from_ref(*want)))
588 })
589 .collect();
590 if !unmatched.is_empty() {
591 anyhow::bail!(
592 "`--except` names no registered repository: {}\n \
593 Run `devp status` to see the registered names.",
594 unmatched
595 .iter()
596 .map(|s| s.as_str())
597 .collect::<Vec<_>>()
598 .join(", ")
599 );
600 }
601 }
602
603 let min_size_bytes = resolve_min_size(args, Some(®istry));
604 let analysis = PruneOptions {
605 idle_days: 0, dry_run: true,
607 force: args.force,
608 only_dirs: None,
609 adapters: filter.clone(),
610 min_size_bytes,
611 scan_depth: resolve_scan_depth(Some(®istry)),
612 allow_manifest_rewrite: resolve_manifest_rewrite(Some(®istry)),
613 command_timeout_secs: resolve_command_timeout(Some(®istry)),
614 build_idle_days: resolve_build_idle_days(Some(®istry)),
615 adapter_idle_days: resolve_adapter_idle_days(Some(®istry)),
616 };
617
618 if !args.json {
619 output::print_info(&format!(
620 "Scanning {} registered repositories for prune candidates...",
621 registry.repo_count()
622 ));
623 if let Some(desc) = filter.describe() {
624 output::print_info(&format!("Adapter filter: {desc}"));
625 }
626 if min_size_bytes > 0 {
627 output::print_info(&format!(
628 "Size floor: ignoring directories under {}",
629 output::format_bytes(min_size_bytes)
630 ));
631 }
632 }
633
634 let mut candidates: Vec<PruneResult> = Vec::new();
643 let mut blocked: Vec<PruneResult> = Vec::new();
644 let mut left_alone: Vec<PruneResult> = Vec::new();
645 let mut missing: Vec<PruneResult> = Vec::new();
646 for result in engine::prune_all_with(&mut registry, &analysis) {
647 if is_excepted(&result.repo_path, &except) {
651 continue;
652 }
653 match result.status {
654 PruneStatus::SkippedDryRun => candidates.push(result),
655 PruneStatus::ConfigError(_)
656 | PruneStatus::LockfileError(_)
657 | PruneStatus::ActivityCheckError(_)
658 | PruneStatus::DeleteError(_) => blocked.push(result),
659 PruneStatus::SkippedSymlink(_)
667 | PruneStatus::SkippedDeclaration(_)
668 | PruneStatus::SkippedNestedRepo(_) => left_alone.push(result),
669 PruneStatus::PathMissing => missing.push(result),
672 _ => {}
673 }
674 }
675
676 if !args.json && !except.is_empty() {
677 output::print_info(&format!("Leaving alone: {}", except.join(", ")));
678 }
679
680 if args.daemon {
681 let before = candidates.len();
682 candidates.retain(|c| {
683 match crate::config::PerRepoConfig::load_with_diagnostics(&c.repo_path) {
687 Ok(Some(cfg)) => !cfg.disable_daemon,
688 Ok(None) => true,
689 Err(_) => false,
690 }
691 });
692 let skipped = before - candidates.len();
693 if skipped > 0 && !args.json {
694 output::print_info(&format!(
695 "Skipped {skipped} bloat directories in repositories that set `disable_daemon`."
696 ));
697 }
698 }
699
700 if args.dry_run {
702 if args.json {
703 json::emit(&json::run_document(
704 &[candidates, blocked, left_alone, missing].concat(),
705 true,
706 ))?;
707 return Ok(());
708 }
709 if candidates.is_empty()
710 && blocked.is_empty()
711 && left_alone.is_empty()
712 && missing.is_empty()
713 {
714 output::print_info(i18n::t("run.nothing"));
715 return Ok(());
716 }
717 if !candidates.is_empty() {
718 report_candidates(&candidates);
719 }
720 let total: u64 = candidates.iter().map(|c| c.size_freed).sum();
721 output::print_header(i18n::t("run.summary.dry"));
722 output::print_info(&i18n::tf(
723 "run.would_free",
724 &[
725 ("size", &output::format_bytes(total)),
726 ("count", &candidates.len().to_string()),
727 ],
728 ));
729 report_blocked(&blocked);
732 report_left_alone(&left_alone);
733 report_missing(&missing);
734 return Ok(());
735 }
736
737 if candidates.is_empty() {
738 if args.json {
739 json::emit(&json::run_document(
740 &[blocked.clone(), left_alone, missing].concat(),
741 false,
742 ))?;
743 return fail_if_blocked(&blocked);
744 }
745 if blocked.is_empty() && left_alone.is_empty() && missing.is_empty() {
746 output::print_info(i18n::t("run.nothing"));
747 return Ok(());
748 }
749 output::print_info(i18n::t("run.nothing.bloat"));
750 report_blocked(&blocked);
751 report_left_alone(&left_alone);
752 report_missing(&missing);
753 return fail_if_blocked(&blocked);
754 }
755
756 let total_reclaimable: u64 = candidates.iter().map(|c| c.size_freed).sum();
757
758 if !args.json {
759 report_binaries(&candidates);
760 report_candidates(&candidates);
761 output::print_info(&i18n::tf(
762 "run.reclaimable",
763 &[("size", &output::format_bytes_styled(total_reclaimable))],
764 ));
765 report_blocked(&blocked);
766 report_left_alone(&left_alone);
767 report_missing(&missing);
768 }
769
770 let target_candidates: Vec<PruneResult> = if args.json
773 || args.yes
774 || !registry.settings.require_confirmation
775 {
776 candidates
777 } else if io::stdout().is_terminal() && io::stdin().is_terminal() {
778 eprintln!();
779 eprintln!(
780 " Loading interactive selector... (↑↓ navigate, Space toggle, Enter confirm, q cancel)"
781 );
782 eprintln!();
783 let selected = tui::selection_view::select_candidates_tui(&candidates)?;
784 if selected.is_empty() {
785 output::print_info("Prune pass cancelled by user (0 candidates selected).");
786 return Ok(());
787 }
788 selected
789 } else {
790 if !io::stdin().is_terminal() {
794 anyhow::bail!(
795 "Deleting {} directories ({}) needs confirmation, and there is no \
796 terminal to ask on. Re-run with `--yes` to confirm, or `--dry-run` \
797 to only analyse.",
798 candidates.len(),
799 output::format_bytes(total_reclaimable)
800 );
801 }
802 println!();
803 output::print_warning("CAUTION: Deleting bloat directories cannot be undone directly.");
804 output::print_info(
805 "Note: You can re-install missing dependencies anytime using `dev-prune restore`.",
806 );
807 eprint!(
810 "Proceed with deletion of {} directories ({})? [y/N]: ",
811 candidates.len(),
812 output::format_bytes(total_reclaimable)
813 );
814 io::stderr().flush()?;
815
816 let mut input = String::new();
817 io::stdin().read_line(&mut input)?;
818 let trimmed = input.trim().to_lowercase();
819 if trimmed != "y" && trimmed != "yes" {
820 output::print_info("Prune pass aborted by user.");
821 return Ok(());
822 }
823 candidates
824 };
825
826 if !args.json {
827 let selected_total_bytes: u64 = target_candidates.iter().map(|c| c.size_freed).sum();
828 output::print_header(&i18n::tf(
829 "run.header.deleting",
830 &[
831 ("repos", &target_candidates.len().to_string()),
832 ("size", &output::format_bytes(selected_total_bytes)),
833 ],
834 ));
835 }
836
837 let mut selection: Vec<(std::path::PathBuf, Vec<String>)> = Vec::new();
843 for candidate in &target_candidates {
844 match selection
845 .iter_mut()
846 .find(|(p, _)| *p == candidate.repo_path)
847 {
848 Some((_, dirs)) => dirs.push(candidate.bloat_dir.clone()),
849 None => selection.push((
850 candidate.repo_path.clone(),
851 vec![candidate.bloat_dir.clone()],
852 )),
853 }
854 }
855
856 let mut error_count = blocked.len();
860 let mut all_results: Vec<PruneResult> = blocked;
861 all_results.extend(left_alone);
862 all_results.extend(missing);
863 let mut total_freed: u64 = 0;
864 let mut pruned_count = 0;
865 let mut pruned_dirs: Vec<crate::config::PrunedDir> = Vec::new();
866 let pass_at = chrono::Utc::now();
869 let trigger = crate::history::Trigger::for_run(args.daemon);
870
871 for (repo_path, dirs) in &selection {
872 let recorded_before = pruned_dirs.len();
873 let idle_days = registry
878 .repositories
879 .get(repo_path)
880 .and_then(|e| e.override_idle_days)
881 .unwrap_or(registry.settings.idle_days);
882 let single_results = engine::prune_repo_with(
883 repo_path,
884 &PruneOptions {
885 idle_days,
886 dry_run: false,
887 force: args.force,
888 only_dirs: Some(dirs.clone()),
889 adapters: filter.clone(),
890 min_size_bytes: 0,
891 scan_depth: analysis.scan_depth,
892 allow_manifest_rewrite: analysis.allow_manifest_rewrite,
893 command_timeout_secs: analysis.command_timeout_secs,
894 build_idle_days: analysis.build_idle_days,
895 adapter_idle_days: analysis.adapter_idle_days.clone(),
896 },
897 );
898 for result in single_results {
899 match &result.status {
900 PruneStatus::Pruned => {
901 total_freed += result.size_freed;
902 pruned_count += 1;
903 registry.mark_pruned(&result.repo_path, result.size_freed);
904 pruned_dirs.push(crate::config::PrunedDir {
905 repo_path: result.repo_path.clone(),
906 bloat_dir: result.bloat_dir.clone(),
907 adapter: result.adapter_name.clone(),
908 size_freed: result.size_freed,
909 runtime: result.runtime.clone(),
910 });
911 if !args.json {
912 output::print_success(&format!(
913 "{} → {} ({}) — {}{}",
914 output::clean_path(&result.repo_path),
915 result.bloat_dir,
916 output::format_bytes(result.size_freed),
917 result.adapter_name,
918 output::shared_note(result.shared_bytes, &result.adapter_name)
919 ));
920 }
921 }
922 PruneStatus::LockfileError(e) => {
923 error_count += 1;
924 if !args.json {
925 report_lockfile_failure(&result, e);
926 }
927 }
928 PruneStatus::ActivityCheckError(e) => {
929 error_count += 1;
930 if !args.json {
931 output::print_error(&format!(
932 "{} skipped — its activity could not be determined:\n {}",
933 output::clean_path(&result.repo_path),
934 e.trim()
935 ));
936 }
937 }
938 PruneStatus::DeleteError(e) => {
939 error_count += 1;
940 if result.size_freed > 0 {
945 pruned_dirs.push(crate::config::PrunedDir {
946 repo_path: result.repo_path.clone(),
947 bloat_dir: result.bloat_dir.clone(),
948 adapter: result.adapter_name.clone(),
949 size_freed: result.size_freed,
950 runtime: result.runtime.clone(),
951 });
952 }
953 if !args.json {
954 output::print_error(&format!(
955 "{} → delete failed: {}",
956 output::clean_path(&result.repo_path),
957 e,
958 ));
959 }
960 }
961 PruneStatus::ConfigError(e) => {
962 error_count += 1;
963 if !args.json {
964 let clean_p = output::clean_path(&result.repo_path);
965 output::print_error(&format!(
966 "{clean_p} skipped — its .devprune.json could not be read:\n {}",
967 e.trim()
968 ));
969 output::print_info(&format!(
970 " Fix command: devp config {clean_p} --update"
971 ));
972 }
973 }
974 PruneStatus::SkippedActive if !args.json => {
977 output::print_info(&format!(
978 "{} became active since the analysis — left alone. \
979 Use `--ignore-idle` to prune it anyway.",
980 output::clean_path(&result.repo_path)
981 ));
982 }
983 PruneStatus::SkippedDeclaration(_) => continue,
988 _ => {}
989 }
990 all_results.push(result);
991 }
992
993 if pruned_dirs.len() > recorded_before {
999 registry.record_prune_progress(pass_at, pruned_dirs.clone());
1000 crate::history::record(pass_at, trigger, &pruned_dirs);
1001 let _ = registry.save();
1002 }
1003 }
1004
1005 crate::history::record(pass_at, trigger, &pruned_dirs);
1006 registry.record_prune_progress(pass_at, pruned_dirs);
1007 let saved = registry.save();
1011
1012 if args.json {
1013 json::emit(&json::run_document(&all_results, false))?;
1014 saved?;
1015 if error_count > 0 {
1018 anyhow::bail!("{error_count} repositories could not be pruned.");
1019 }
1020 return Ok(());
1021 }
1022 saved?;
1023
1024 output::print_header(i18n::t("run.summary"));
1025 output::print_success(&i18n::tf(
1026 "run.freed",
1027 &[
1028 ("size", &output::format_bytes_styled(total_freed)),
1029 ("count", &pruned_count.to_string()),
1030 ],
1031 ));
1032
1033 if error_count > 0 {
1034 output::print_warning(&i18n::tf(
1035 "run.not_pruned",
1036 &[("count", &error_count.to_string())],
1037 ));
1038
1039 if all_results
1043 .iter()
1044 .any(|r| matches!(r.status, PruneStatus::LockfileError(_)))
1045 {
1046 output::print_info(
1050 "Lockfile verification cannot be bypassed: without a lockfile the deleted \
1051 dependencies could not be reinstalled. Run the fix command shown above for \
1052 each repo, then re-run `devp run`.",
1053 );
1054 }
1055 anyhow::bail!("{error_count} repositories could not be pruned.");
1057 }
1058
1059 crate::commands::update::maybe_auto_update(®istry);
1062
1063 Ok(())
1064}
1065
1066#[derive(Debug, PartialEq, Eq, Clone, Copy)]
1077enum ActivityFailure {
1078 UntrustedOwner,
1080 NotARepository,
1082 Individual,
1084}
1085
1086impl ActivityFailure {
1087 fn classify(message: &str) -> Self {
1094 let lower = message.to_lowercase();
1095 if lower.contains(constants::GIT_DUBIOUS_OWNERSHIP) {
1096 Self::UntrustedOwner
1097 } else if lower.contains(constants::GIT_NOT_A_REPOSITORY) {
1098 Self::NotARepository
1099 } else {
1100 Self::Individual
1101 }
1102 }
1103}
1104
1105const GROUPED_PATHS_SHOWN: usize = 8;
1110
1111fn report_blocked(blocked: &[PruneResult]) {
1115 if blocked.is_empty() {
1116 return;
1117 }
1118 output::print_header(&i18n::tf(
1119 "run.header.blocked",
1120 &[("count", &blocked.len().to_string())],
1121 ));
1122
1123 let grouped = |failure: ActivityFailure| -> Vec<&PruneResult> {
1124 blocked
1125 .iter()
1126 .filter(|r| match &r.status {
1127 PruneStatus::ActivityCheckError(e) => ActivityFailure::classify(e) == failure,
1128 _ => false,
1129 })
1130 .collect()
1131 };
1132
1133 let untrusted = grouped(ActivityFailure::UntrustedOwner);
1134 if !untrusted.is_empty() {
1135 let n = untrusted.len();
1136 output::print_error(&format!(
1137 "{n} {} owned by a different account — Git will not read {}.",
1138 output::plural(n, "repository is", "repositories are"),
1139 output::plural(n, "it", "them")
1140 ));
1141 list_paths(&untrusted);
1142 output::print_wrapped(
1143 " ",
1144 "Nothing is wrong with the repositories themselves. The owner recorded on \
1145 disk is usually one a Windows reinstall, a restored backup or a drive moved \
1146 between machines left behind.",
1147 );
1148 output::print_wrapped(
1149 " ",
1150 "dev-prune dates a repository by its last commit, so one Git will not open \
1151 has no known age — and nothing is ever deleted from a repository whose age is \
1152 unknown.",
1153 );
1154 output::print_info(&format!(
1155 " Fix all {n} at once: devp trust --fix-ownership"
1156 ));
1157 }
1158
1159 let orphaned = grouped(ActivityFailure::NotARepository);
1160 if !orphaned.is_empty() {
1161 if !untrusted.is_empty() {
1162 println!();
1163 }
1164 let n = orphaned.len();
1165 output::print_error(&format!(
1166 "{n} registered {} not {} git {} any more.",
1167 output::plural(n, "path is", "paths are"),
1168 output::plural(n, "a", ""),
1169 output::plural(n, "repository", "repositories")
1170 ));
1171 list_paths(&orphaned);
1172 output::print_wrapped(
1173 " ",
1174 "The directory is still there; its `.git` is not — a clone deleted and \
1175 recreated by hand, or a worktree `git worktree prune` has since removed. The \
1176 registry entry outlived what it pointed at.",
1177 );
1178 output::print_info(&format!(
1182 " Drop {} from the registry: devp unlink <path>",
1183 output::plural(n, "it", "them")
1184 ));
1185 }
1186
1187 for result in blocked {
1188 let clean_p = output::clean_path(&result.repo_path);
1189 match &result.status {
1190 PruneStatus::ConfigError(e) => {
1191 output::print_error(&format!(
1192 "{clean_p} skipped — its .devprune.json could not be read:
1193 {}",
1194 e.trim()
1195 ));
1196 output::print_info(&format!(
1197 " Fix command: devp config {clean_p} --update"
1198 ));
1199 }
1200 PruneStatus::LockfileError(e) => report_lockfile_failure(result, e),
1201 PruneStatus::ActivityCheckError(e)
1202 if ActivityFailure::classify(e) == ActivityFailure::Individual =>
1203 {
1204 output::print_error(&format!(
1205 "{clean_p} skipped — its activity could not be determined:
1206 {}",
1207 output::condense_tool_output(e, 4)
1208 ));
1209 }
1210 PruneStatus::DeleteError(e) => {
1211 output::print_error(&format!("{clean_p} → delete failed: {e}"));
1212 }
1213 _ => {}
1215 }
1216 }
1217}
1218
1219fn list_paths(results: &[&PruneResult]) {
1224 for result in results.iter().take(GROUPED_PATHS_SHOWN) {
1225 println!(" {}", output::styled_path(&result.repo_path));
1226 }
1227 if let Some(rest) = results
1228 .len()
1229 .checked_sub(GROUPED_PATHS_SHOWN)
1230 .filter(|n| *n > 0)
1231 {
1232 output::print_dimmed(&format!(
1233 " … and {rest} more — `devp run --dry-run --json` lists every one."
1234 ));
1235 }
1236}
1237
1238fn report_left_alone(left_alone: &[PruneResult]) {
1246 for result in left_alone {
1247 if let PruneStatus::SkippedSymlink(e)
1248 | PruneStatus::SkippedDeclaration(e)
1249 | PruneStatus::SkippedNestedRepo(e) = &result.status
1250 {
1251 output::print_warning(&format!(
1252 "{} → {}",
1253 output::clean_path(&result.repo_path),
1254 e.trim()
1255 ));
1256 }
1257 }
1258}
1259
1260fn report_missing(missing: &[PruneResult]) {
1266 if missing.is_empty() {
1267 return;
1268 }
1269 println!();
1270 let n = missing.len();
1271 output::print_warning(&format!(
1272 "{n} registered {} no longer {} on disk.",
1273 output::plural(n, "path", "paths"),
1274 output::plural(n, "exists", "exist")
1275 ));
1276 for result in missing.iter().take(GROUPED_PATHS_SHOWN) {
1281 println!(" {}", output::styled_path(&result.repo_path));
1282 }
1283 if let Some(rest) = missing
1284 .len()
1285 .checked_sub(GROUPED_PATHS_SHOWN)
1286 .filter(|n| *n > 0)
1287 {
1288 output::print_dimmed(&format!(
1289 " … and {rest} more — `devp run --dry-run --json` lists every one."
1290 ));
1291 }
1292 output::print_info(&format!(
1293 " Clear {} from the registry: devp unlink --missing",
1294 output::plural(n, "it", "them all")
1295 ));
1296}
1297
1298fn fail_if_blocked(blocked: &[PruneResult]) -> Result<()> {
1303 if blocked.is_empty() {
1304 return Ok(());
1305 }
1306 anyhow::bail!("{} repositories could not be examined.", blocked.len());
1307}
1308
1309fn report_binaries(candidates: &[PruneResult]) {
1311 let adapter_names: Vec<String> = candidates.iter().map(|c| c.adapter_name.clone()).collect();
1312 let binary_statuses = adapters::scan_required_binaries(&adapter_names);
1313 if binary_statuses.is_empty() {
1314 return;
1315 }
1316 output::print_header(i18n::t("run.header.binaries"));
1317 for b in &binary_statuses {
1318 if b.available {
1319 output::print_success(&format!(
1320 " {} — available ({})",
1321 b.name,
1322 b.version.as_deref().unwrap_or("detected")
1323 ));
1324 } else {
1325 output::print_warning(&format!(
1326 " {} — missing (lockfile fallback active)",
1327 b.name
1328 ));
1329 }
1330 }
1331}
1332
1333fn report_candidates(candidates: &[PruneResult]) {
1334 output::print_header(i18n::t("run.header.candidates"));
1335 for candidate in candidates {
1336 output::print_info(&format!(
1337 " • {} → {} ({}) [{}]{}",
1338 output::styled_path(&candidate.repo_path),
1339 candidate.bloat_dir,
1340 output::format_bytes_styled(candidate.size_freed),
1341 output::styled_adapter(&candidate.adapter_name),
1342 output::shared_note(candidate.shared_bytes, &candidate.adapter_name)
1343 ));
1344 }
1345}
1346
1347pub(crate) fn report_lockfile_failure(result: &PruneResult, error: &str) {
1348 let project = output::clean_path(result.project_dir());
1351
1352 output::print_error(&format!(
1353 "{} → {} lockfile sync failed:\n {}",
1354 project,
1355 result.adapter_name,
1356 error.trim(),
1357 ));
1358 match json::lockfile_fix_command(&result.adapter_name) {
1359 Some(sync_cmd) => {
1360 #[cfg(windows)]
1363 let manual_cmd = format!("cd \"{project}\"; {sync_cmd}");
1364 #[cfg(not(windows))]
1365 let manual_cmd = format!("cd \"{project}\" && {sync_cmd}");
1366 output::print_info(&format!(" Fix command: {manual_cmd}"));
1367 }
1368 None => output::print_info(&format!(" Fix it in: {project}")),
1371 }
1372 output::print_info(&format!(
1373 " Troubleshooting: {}",
1374 constants::TROUBLESHOOTING_URL
1375 ));
1376}
1377
1378fn run_explain(args: &RunArgs<'_>, filter: &AdapterFilter) -> Result<()> {
1389 output::print_header(i18n::t("run.header.reasons"));
1390 if let Some(desc) = filter.describe() {
1391 output::print_info(&format!("Adapter filter: {desc}"));
1392 }
1393
1394 if let Some(target_str) = args.target_path {
1395 let raw = Path::new(target_str);
1396 let path = if raw.exists() {
1397 raw.canonicalize().unwrap_or_else(|_| raw.to_path_buf())
1398 } else {
1399 raw.to_path_buf()
1400 };
1401 if !crate::scanner::is_git_repo(&path) {
1402 anyhow::bail!(
1403 "{} is not a Git repository — dev-prune only prunes Git repos.",
1404 output::clean_path(&path)
1405 );
1406 }
1407 let registry = Registry::load().ok();
1408 let idle_days = registry
1409 .as_ref()
1410 .map(|r| {
1411 r.repositories
1412 .get(&path)
1413 .and_then(|e| e.override_idle_days)
1414 .unwrap_or(r.settings.idle_days)
1415 })
1416 .unwrap_or(constants::DEFAULT_IDLE_DAYS);
1417 let floor = resolve_min_size(args, registry.as_ref());
1418 let results = engine::prune_repo_with(
1419 &path,
1420 &PruneOptions {
1421 idle_days,
1422 dry_run: true,
1423 force: args.force,
1424 only_dirs: None,
1425 adapters: filter.clone(),
1426 min_size_bytes: 0,
1427 scan_depth: resolve_scan_depth(registry.as_ref()),
1428 allow_manifest_rewrite: resolve_manifest_rewrite(registry.as_ref()),
1429 command_timeout_secs: resolve_command_timeout(registry.as_ref()),
1430 build_idle_days: resolve_build_idle_days(registry.as_ref()),
1431 adapter_idle_days: resolve_adapter_idle_days(registry.as_ref()),
1432 },
1433 );
1434 let refs: Vec<&PruneResult> = results.iter().collect();
1435 explain_repo(&path, &refs, floor, idle_days);
1436 print_explain_footer();
1437 return Ok(());
1438 }
1439
1440 let mut registry = Registry::load()?;
1441 if registry.repo_count() == 0 {
1442 output::print_warning("No repositories registered. Run `dev-prune init` first.");
1443 return Ok(());
1444 }
1445
1446 let except = parse_except(args.except);
1447 let global_floor = resolve_min_size(args, Some(®istry));
1448 let analysis = PruneOptions {
1449 idle_days: 0, dry_run: true,
1451 force: args.force,
1452 only_dirs: None,
1453 adapters: filter.clone(),
1454 min_size_bytes: 0,
1455 scan_depth: resolve_scan_depth(Some(®istry)),
1456 allow_manifest_rewrite: resolve_manifest_rewrite(Some(®istry)),
1457 command_timeout_secs: resolve_command_timeout(Some(®istry)),
1458 build_idle_days: resolve_build_idle_days(Some(®istry)),
1459 adapter_idle_days: resolve_adapter_idle_days(Some(®istry)),
1460 };
1461 let results = engine::prune_all_with(&mut registry, &analysis);
1462
1463 let mut by_repo: std::collections::HashMap<&Path, Vec<&PruneResult>> =
1464 std::collections::HashMap::new();
1465 for r in &results {
1466 by_repo.entry(r.repo_path.as_path()).or_default().push(r);
1467 }
1468
1469 let mut repos: Vec<&std::path::PathBuf> = registry.repositories.keys().collect();
1470 repos.sort();
1471 for path in repos {
1472 if is_excepted(path, &except) {
1473 println!();
1474 output::print_info(&output::clean_path(path));
1475 println!(" • left completely alone this pass (`--except`)");
1476 continue;
1477 }
1478 let idle_days = registry
1479 .repositories
1480 .get(path)
1481 .and_then(|e| e.override_idle_days)
1482 .unwrap_or(registry.settings.idle_days);
1483 let empty = Vec::new();
1484 let repo_results = by_repo.get(path.as_path()).unwrap_or(&empty);
1485 explain_repo(path, repo_results, global_floor, idle_days);
1486 }
1487 print_explain_footer();
1488 Ok(())
1489}
1490
1491fn explain_repo(path: &Path, results: &[&PruneResult], floor: u64, idle_days: u64) {
1493 println!();
1494 output::print_info(&output::clean_path(path));
1495
1496 if results.is_empty() {
1497 println!(
1498 " • idle, but no known bloat directories were found. A project deeper than \
1499 `scan_depth` is not examined — `devp status` shows what dev-prune can see."
1500 );
1501 return;
1502 }
1503
1504 for r in results {
1505 match &r.status {
1506 PruneStatus::SkippedDryRun => {
1507 if r.size_freed >= floor {
1508 output::print_success(&format!(
1509 "would prune {} ({}) [{}]{}",
1510 r.bloat_dir,
1511 output::format_bytes(r.size_freed),
1512 r.adapter_name,
1513 output::shared_note(r.shared_bytes, &r.adapter_name)
1514 ));
1515 } else {
1516 println!(
1517 " • {} ({}) is under the size floor of {} — the reinstall would \
1518 cost more than the space is worth. `--min-size 0` includes it.",
1519 r.bloat_dir,
1520 output::format_bytes(r.size_freed),
1521 output::format_bytes(floor)
1522 );
1523 }
1524 }
1525 PruneStatus::SkippedActive => {
1526 let age = crate::scanner::git::get_last_activity(path)
1527 .ok()
1528 .flatten()
1529 .and_then(|t| std::time::SystemTime::now().duration_since(t).ok())
1530 .map(|d| d.as_secs() / 86_400);
1531 match age {
1532 Some(0) => println!(
1533 " • active — there was activity today, and the idle \
1534 threshold is {idle_days} days. `--ignore-idle` overrides."
1535 ),
1536 Some(days) => println!(
1537 " • active — last activity {days} day{} ago, and the idle \
1538 threshold is {idle_days} days. `--ignore-idle` overrides.",
1539 if days == 1 { "" } else { "s" }
1540 ),
1541 None => println!(
1542 " • active (not idle for {idle_days} days yet). \
1543 `--ignore-idle` overrides."
1544 ),
1545 }
1546 }
1547 other => println!(" • {other}"),
1548 }
1549 }
1550}
1551
1552fn print_explain_footer() {
1554 println!();
1555 output::print_info(
1556 "Nothing was verified or deleted. `devp run --dry-run` verifies candidates; \
1557 `devp run` prunes.",
1558 );
1559}
1560
1561#[cfg(test)]
1562mod tests {
1563 use super::*;
1564
1565 #[test]
1566 fn gits_ownership_refusal_is_recognised_whatever_the_path() {
1567 let message = "git could not read `V:/x`: fatal: detected dubious ownership in repository \
1571 at 'V:/x'";
1572 assert_eq!(
1573 ActivityFailure::classify(message),
1574 ActivityFailure::UntrustedOwner
1575 );
1576 }
1577
1578 #[test]
1579 fn a_path_that_lost_its_git_directory_is_its_own_cause() {
1580 let message = "fatal: not a git repository (or any of the parent directories): .git";
1584 assert_eq!(
1585 ActivityFailure::classify(message),
1586 ActivityFailure::NotARepository
1587 );
1588 }
1589
1590 #[test]
1591 fn an_unfamiliar_failure_is_still_printed_in_full() {
1592 assert_eq!(
1593 ActivityFailure::classify("fatal: unable to read tree"),
1594 ActivityFailure::Individual
1595 );
1596 }
1597}