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::json;
20use crate::output;
21use crate::tui;
22
23pub struct RunArgs<'a> {
28 pub target_path: Option<&'a str>,
30 pub dry_run: bool,
32 pub force: bool,
34 pub yes: bool,
36 pub daemon: bool,
38 pub only: Option<&'a str>,
40 pub skip: Option<&'a str>,
42 pub min_size_mb: Option<u64>,
44 pub except: Option<&'a str>,
46 pub json: bool,
48 pub explain: bool,
50}
51
52pub fn run(args: RunArgs<'_>) -> Result<()> {
57 let filter = AdapterFilter::new(args.only, args.skip)?;
58
59 if args.json && !args.dry_run && !args.yes {
63 return Err(anyhow::Error::new(crate::UsageError(
64 "`--json` cannot ask for confirmation. Pass `--dry-run` to analyse, or `--yes` to delete."
65 .to_string(),
66 )));
67 }
68
69 if !args.json {
70 output::print_banner();
71 if args.force {
72 print_ignore_idle_notice();
73 }
74 }
75
76 if args.explain {
77 return run_explain(&args, &filter);
78 }
79
80 if let Some(target_str) = args.target_path {
81 return run_targeted(&args, &filter, target_str);
82 }
83 run_registry(&args, &filter)
84}
85
86fn print_ignore_idle_notice() {
93 output::print_warning(
94 "Idle check bypassed — repositories you are working in right now are fair game.",
95 );
96 println!(
97 " Still enforced: lockfile verification, `ignore.devprune.json`, `\"ignore\": true`,"
98 );
99 println!(
100 " symlinked directories, and nested repositories. This flag does not turn those off."
101 );
102 println!();
103 println!(" If you reached for this because something would not prune, it is usually:");
104 println!(" • \"lockfile verification failed\" → run the fix command printed next to it;");
105 println!(" it regenerates the lockfile so the reinstall is guaranteed to work.");
106 println!(" • nothing listed at all → the project is deeper than `scan_depth`,");
107 println!(" or under `min_size_mb`. Try `devp status` to see what dev-prune can see.");
108 println!(" • \"could not be examined\" → `.devprune.json` has a syntax error.");
109 println!();
110 println!(" Still stuck? Point your AI assistant at the bundled skill — `devp skill`");
111 println!(" exports a SKILL.md that teaches it this tool, exit codes and all. It has");
112 println!(" read the manual more recently than either of us.");
113 println!();
114}
115
116fn run_targeted(args: &RunArgs<'_>, filter: &AdapterFilter, target_str: &str) -> Result<()> {
118 let raw = std::path::Path::new(target_str);
119 let path = if raw.exists() {
120 raw.canonicalize().unwrap_or_else(|_| raw.to_path_buf())
121 } else {
122 raw.to_path_buf()
123 };
124
125 let clean = output::clean_path(&path);
126 if !crate::scanner::is_git_repo(&path) {
127 anyhow::bail!("{clean} is not a Git repository — dev-prune only prunes Git repos.");
130 }
131
132 let registry = Registry::load().ok();
136 let idle_days = registry
137 .as_ref()
138 .map(|r| {
139 r.repositories
140 .get(&path)
141 .and_then(|e| e.override_idle_days)
142 .unwrap_or(r.settings.idle_days)
143 })
144 .unwrap_or(constants::DEFAULT_IDLE_DAYS);
145
146 let opts = PruneOptions {
147 idle_days,
148 dry_run: args.dry_run,
149 force: args.force,
150 only_dirs: None,
151 adapters: filter.clone(),
152 min_size_bytes: resolve_min_size(args, registry.as_ref()),
153 scan_depth: resolve_scan_depth(registry.as_ref()),
154 allow_manifest_rewrite: resolve_manifest_rewrite(registry.as_ref()),
155 command_timeout_secs: resolve_command_timeout(registry.as_ref()),
156 build_idle_days: resolve_build_idle_days(registry.as_ref()),
157 adapter_idle_days: resolve_adapter_idle_days(registry.as_ref()),
158 };
159
160 let results = engine::prune_repo_with(&path, &opts);
161
162 let error_count = results
166 .iter()
167 .filter(|r| {
168 matches!(
169 r.status,
170 PruneStatus::LockfileError(_)
171 | PruneStatus::ActivityCheckError(_)
172 | PruneStatus::DeleteError(_)
173 | PruneStatus::ConfigError(_)
174 )
175 })
176 .count();
177
178 record_targeted_prune(&path, &results, args.dry_run);
183
184 if args.json {
185 json::emit(&json::run_document(&results, args.dry_run))?;
186 if error_count > 0 {
187 anyhow::bail!("{error_count} directories in {clean} could not be pruned.");
188 }
189 return Ok(());
190 }
191
192 output::print_header(&format!("dev-prune Targeted Run ({clean})"));
193 if let Some(desc) = filter.describe() {
194 output::print_info(&format!("Adapter filter: {desc}"));
195 }
196
197 if results.is_empty() {
198 output::print_info(&format!("No pruneable bloat directories found in {clean}."));
199 return Ok(());
200 }
201
202 let mut total_freed = 0;
203 for result in results {
204 match &result.status {
205 PruneStatus::Pruned => {
206 total_freed += result.size_freed;
207 output::print_success(&format!(
208 "{} → {} ({}) — {}{}",
209 output::clean_path(&result.repo_path),
210 result.bloat_dir,
211 output::format_bytes(result.size_freed),
212 result.adapter_name,
213 output::shared_note(result.shared_bytes, &result.adapter_name)
214 ));
215 }
216 PruneStatus::SkippedDryRun => {
217 output::print_info(&format!(
218 " • {} → {} ({}) [{}] (Dry Run){}",
219 output::clean_path(&result.repo_path),
220 result.bloat_dir,
221 output::format_bytes(result.size_freed),
222 result.adapter_name,
223 output::shared_note(result.shared_bytes, &result.adapter_name)
224 ));
225 }
226 PruneStatus::SkippedActive => {
227 output::print_info(&format!(
228 "{clean} is currently active (not idle). Use `devp --ignore-idle run` to override."
229 ));
230 }
231 PruneStatus::LockfileError(e) => report_lockfile_failure(&result, e),
232 PruneStatus::ActivityCheckError(e) => {
233 output::print_error(&format!(
234 "{clean} skipped — its activity could not be determined:\n {}",
235 e.trim()
236 ));
237 }
238 PruneStatus::DeleteError(e) => {
239 output::print_error(&format!("{clean} delete error: {e}"));
240 }
241 PruneStatus::ConfigError(e) => {
242 output::print_error(&format!(
243 "{clean} skipped — its .devprune.json could not be read:\n {}\n \
244 Fix it, or run `devp config {clean} --update` to reset it.",
245 e.trim()
246 ));
247 }
248 PruneStatus::SkippedSymlink(e) => {
249 output::print_warning(&format!("{clean} → {}", e.trim()));
250 }
251 _ => {}
252 }
253 }
254
255 if !args.dry_run && total_freed > 0 {
256 output::print_success(&format!(
257 "Freed: {} in {clean}",
258 output::format_bytes(total_freed)
259 ));
260 }
261
262 if error_count > 0 {
263 anyhow::bail!("{error_count} directories in {clean} could not be pruned.");
264 }
265
266 Ok(())
267}
268
269fn record_targeted_prune(path: &std::path::Path, results: &[PruneResult], dry_run: bool) {
275 if dry_run {
276 return;
277 }
278
279 let pruned: Vec<crate::config::PrunedDir> = results
282 .iter()
283 .filter(|r| {
284 matches!(r.status, PruneStatus::Pruned)
285 || (matches!(r.status, PruneStatus::DeleteError(_)) && r.size_freed > 0)
286 })
287 .map(|r| crate::config::PrunedDir {
288 repo_path: r.repo_path.clone(),
289 bloat_dir: r.bloat_dir.clone(),
290 adapter: r.adapter_name.clone(),
291 size_freed: r.size_freed,
292 runtime: r.runtime.clone(),
293 })
294 .collect();
295
296 if pruned.is_empty() {
297 return;
298 }
299
300 let freed: u64 = pruned.iter().map(|d| d.size_freed).sum();
301 if let Ok(mut registry) = Registry::load() {
302 registry.mark_pruned(path, freed);
303 registry.record_prune(pruned);
304 let _ = registry.save();
305 }
306}
307
308fn resolve_min_size(args: &RunArgs<'_>, registry: Option<&Registry>) -> u64 {
313 let mb = args
314 .min_size_mb
315 .or_else(|| registry.map(|r| r.settings.min_size_mb))
316 .unwrap_or(constants::DEFAULT_MIN_SIZE_MB);
317 mb.saturating_mul(engine::BYTES_PER_MIB)
318}
319
320fn parse_except(spec: Option<&str>) -> Vec<String> {
327 spec.map(|s| {
328 s.split(',')
329 .map(|part| {
330 crate::config::expand_tilde(part.trim())
331 .trim_end_matches(['/', '\\'])
332 .to_lowercase()
333 })
334 .filter(|part| !part.is_empty())
335 .collect()
336 })
337 .unwrap_or_default()
338}
339
340fn is_excepted(repo_path: &Path, except: &[String]) -> bool {
347 if except.is_empty() {
348 return false;
349 }
350 let full = output::clean_path(repo_path)
351 .to_lowercase()
352 .replace('\\', "/");
353 let name = repo_path
354 .file_name()
355 .map(|n| n.to_string_lossy().to_lowercase())
356 .unwrap_or_default();
357
358 except.iter().any(|want| {
359 let want = want.replace('\\', "/");
360 name == want || full == want || full.ends_with(&format!("/{want}"))
361 })
362}
363
364fn resolve_scan_depth(registry: Option<&Registry>) -> usize {
366 registry
367 .map(|r| r.settings.scan_depth)
368 .unwrap_or(constants::DEFAULT_SCAN_DEPTH)
369}
370
371fn resolve_build_idle_days(registry: Option<&Registry>) -> u64 {
373 registry
374 .map(|r| r.settings.build_idle_days)
375 .unwrap_or(constants::DEFAULT_BUILD_IDLE_DAYS)
376}
377
378fn resolve_adapter_idle_days(
380 registry: Option<&Registry>,
381) -> std::collections::BTreeMap<String, u64> {
382 registry
383 .map(|r| r.settings.adapter_idle_days.clone())
384 .unwrap_or_default()
385}
386
387fn resolve_command_timeout(registry: Option<&Registry>) -> u64 {
388 registry
389 .map(|r| r.settings.command_timeout_secs)
390 .unwrap_or(constants::DEFAULT_COMMAND_TIMEOUT_SECS)
391}
392
393fn resolve_manifest_rewrite(registry: Option<&Registry>) -> bool {
395 registry
396 .map(|r| r.settings.allow_manifest_rewrite)
397 .unwrap_or(constants::DEFAULT_ALLOW_MANIFEST_REWRITE)
398}
399
400fn run_registry(args: &RunArgs<'_>, filter: &AdapterFilter) -> Result<()> {
402 if !args.json {
403 if args.dry_run {
404 output::print_header("dev-prune run (DRY RUN)");
405 } else {
406 output::print_header("dev-prune run");
407 }
408 }
409
410 let mut registry = Registry::load()?;
411
412 if !args.json && crate::commands::update::notify_if_outdated(&mut registry) {
415 let _ = registry.save();
416 }
417
418 if registry.repo_count() == 0 {
419 if args.json {
420 return json::emit(&json::run_document(&[], args.dry_run));
421 }
422 output::print_warning("No repositories registered. Run `dev-prune init` first.");
423 return Ok(());
424 }
425
426 let except = parse_except(args.except);
430 if !except.is_empty() {
431 let unmatched: Vec<&String> = except
432 .iter()
433 .filter(|want| {
434 !registry
435 .repositories
436 .keys()
437 .any(|p| is_excepted(p, std::slice::from_ref(*want)))
438 })
439 .collect();
440 if !unmatched.is_empty() {
441 anyhow::bail!(
442 "`--except` names no registered repository: {}\n \
443 Run `devp status` to see the registered names.",
444 unmatched
445 .iter()
446 .map(|s| s.as_str())
447 .collect::<Vec<_>>()
448 .join(", ")
449 );
450 }
451 }
452
453 let min_size_bytes = resolve_min_size(args, Some(®istry));
454 let analysis = PruneOptions {
455 idle_days: 0, dry_run: true,
457 force: args.force,
458 only_dirs: None,
459 adapters: filter.clone(),
460 min_size_bytes,
461 scan_depth: resolve_scan_depth(Some(®istry)),
462 allow_manifest_rewrite: resolve_manifest_rewrite(Some(®istry)),
463 command_timeout_secs: resolve_command_timeout(Some(®istry)),
464 build_idle_days: resolve_build_idle_days(Some(®istry)),
465 adapter_idle_days: resolve_adapter_idle_days(Some(®istry)),
466 };
467
468 if !args.json {
469 output::print_info(&format!(
470 "Scanning {} registered repositories for prune candidates...",
471 registry.repo_count()
472 ));
473 if let Some(desc) = filter.describe() {
474 output::print_info(&format!("Adapter filter: {desc}"));
475 }
476 if min_size_bytes > 0 {
477 output::print_info(&format!(
478 "Size floor: ignoring directories under {}",
479 output::format_bytes(min_size_bytes)
480 ));
481 }
482 }
483
484 let mut candidates: Vec<PruneResult> = Vec::new();
493 let mut blocked: Vec<PruneResult> = Vec::new();
494 let mut linked: Vec<PruneResult> = Vec::new();
495 let mut missing: Vec<PruneResult> = Vec::new();
496 for result in engine::prune_all_with(&mut registry, &analysis) {
497 if is_excepted(&result.repo_path, &except) {
501 continue;
502 }
503 match result.status {
504 PruneStatus::SkippedDryRun => candidates.push(result),
505 PruneStatus::ConfigError(_)
506 | PruneStatus::LockfileError(_)
507 | PruneStatus::ActivityCheckError(_)
508 | PruneStatus::DeleteError(_) => blocked.push(result),
509 PruneStatus::SkippedSymlink(_) => linked.push(result),
512 PruneStatus::PathMissing => missing.push(result),
515 _ => {}
516 }
517 }
518
519 if !args.json && !except.is_empty() {
520 output::print_info(&format!("Leaving alone: {}", except.join(", ")));
521 }
522
523 if args.daemon {
524 let before = candidates.len();
525 candidates.retain(|c| {
526 match crate::config::PerRepoConfig::load_with_diagnostics(&c.repo_path) {
530 Ok(Some(cfg)) => !cfg.disable_daemon,
531 Ok(None) => true,
532 Err(_) => false,
533 }
534 });
535 let skipped = before - candidates.len();
536 if skipped > 0 && !args.json {
537 output::print_info(&format!(
538 "Skipped {skipped} bloat directories in repositories that set `disable_daemon`."
539 ));
540 }
541 }
542
543 if args.dry_run {
545 if args.json {
546 json::emit(&json::run_document(
547 &[candidates, blocked, linked, missing].concat(),
548 true,
549 ))?;
550 return Ok(());
551 }
552 if candidates.is_empty() && blocked.is_empty() && linked.is_empty() && missing.is_empty() {
553 output::print_info("No idle repositories or pruneable bloat directories found.");
554 return Ok(());
555 }
556 if !candidates.is_empty() {
557 report_candidates(&candidates);
558 }
559 let total: u64 = candidates.iter().map(|c| c.size_freed).sum();
560 output::print_header("Summary (Dry Run)");
561 output::print_info(&format!(
562 "Would free {} across {} bloat directories.",
563 output::format_bytes(total),
564 candidates.len()
565 ));
566 report_blocked(&blocked);
569 report_linked(&linked);
570 report_missing(&missing);
571 return Ok(());
572 }
573
574 if candidates.is_empty() {
575 if args.json {
576 json::emit(&json::run_document(
577 &[blocked.clone(), linked, missing].concat(),
578 false,
579 ))?;
580 return fail_if_blocked(&blocked);
581 }
582 if blocked.is_empty() && linked.is_empty() && missing.is_empty() {
583 output::print_info("No idle repositories or pruneable bloat directories found.");
584 return Ok(());
585 }
586 output::print_info("No pruneable bloat directories found.");
587 report_blocked(&blocked);
588 report_linked(&linked);
589 report_missing(&missing);
590 return fail_if_blocked(&blocked);
591 }
592
593 let total_reclaimable: u64 = candidates.iter().map(|c| c.size_freed).sum();
594
595 if !args.json {
596 report_binaries(&candidates);
597 report_candidates(&candidates);
598 output::print_info(&format!(
599 "Total Reclaimable Space: {}",
600 output::format_bytes_styled(total_reclaimable)
601 ));
602 report_blocked(&blocked);
603 report_linked(&linked);
604 report_missing(&missing);
605 }
606
607 let target_candidates: Vec<PruneResult> = if args.json
610 || args.yes
611 || !registry.settings.require_confirmation
612 {
613 candidates
614 } else if io::stdout().is_terminal() && io::stdin().is_terminal() {
615 eprintln!();
616 eprintln!(
617 " Loading interactive selector... (↑↓ navigate, Space toggle, Enter confirm, q cancel)"
618 );
619 eprintln!();
620 let selected = tui::selection_view::select_candidates_tui(&candidates)?;
621 if selected.is_empty() {
622 output::print_info("Prune pass cancelled by user (0 candidates selected).");
623 return Ok(());
624 }
625 selected
626 } else {
627 if !io::stdin().is_terminal() {
631 anyhow::bail!(
632 "Deleting {} directories ({}) needs confirmation, and there is no \
633 terminal to ask on. Re-run with `--yes` to confirm, or `--dry-run` \
634 to only analyse.",
635 candidates.len(),
636 output::format_bytes(total_reclaimable)
637 );
638 }
639 println!();
640 output::print_warning("CAUTION: Deleting bloat directories cannot be undone directly.");
641 output::print_info(
642 "Note: You can re-install missing dependencies anytime using `dev-prune restore`.",
643 );
644 eprint!(
647 "Proceed with deletion of {} directories ({})? [y/N]: ",
648 candidates.len(),
649 output::format_bytes(total_reclaimable)
650 );
651 io::stderr().flush()?;
652
653 let mut input = String::new();
654 io::stdin().read_line(&mut input)?;
655 let trimmed = input.trim().to_lowercase();
656 if trimmed != "y" && trimmed != "yes" {
657 output::print_info("Prune pass aborted by user.");
658 return Ok(());
659 }
660 candidates
661 };
662
663 if !args.json {
664 let selected_total_bytes: u64 = target_candidates.iter().map(|c| c.size_freed).sum();
665 output::print_header(&format!(
666 "Executing Progressive Deletion ({} repos, {})",
667 target_candidates.len(),
668 output::format_bytes(selected_total_bytes)
669 ));
670 }
671
672 let mut selection: Vec<(std::path::PathBuf, Vec<String>)> = Vec::new();
678 for candidate in &target_candidates {
679 match selection
680 .iter_mut()
681 .find(|(p, _)| *p == candidate.repo_path)
682 {
683 Some((_, dirs)) => dirs.push(candidate.bloat_dir.clone()),
684 None => selection.push((
685 candidate.repo_path.clone(),
686 vec![candidate.bloat_dir.clone()],
687 )),
688 }
689 }
690
691 let mut error_count = blocked.len();
695 let mut all_results: Vec<PruneResult> = blocked;
696 all_results.extend(linked);
697 all_results.extend(missing);
698 let mut total_freed: u64 = 0;
699 let mut pruned_count = 0;
700 let mut pruned_dirs: Vec<crate::config::PrunedDir> = Vec::new();
701 let pass_at = chrono::Utc::now();
704
705 for (repo_path, dirs) in &selection {
706 let recorded_before = pruned_dirs.len();
707 let idle_days = registry
712 .repositories
713 .get(repo_path)
714 .and_then(|e| e.override_idle_days)
715 .unwrap_or(registry.settings.idle_days);
716 let single_results = engine::prune_repo_with(
717 repo_path,
718 &PruneOptions {
719 idle_days,
720 dry_run: false,
721 force: args.force,
722 only_dirs: Some(dirs.clone()),
723 adapters: filter.clone(),
724 min_size_bytes: 0,
725 scan_depth: analysis.scan_depth,
726 allow_manifest_rewrite: analysis.allow_manifest_rewrite,
727 command_timeout_secs: analysis.command_timeout_secs,
728 build_idle_days: analysis.build_idle_days,
729 adapter_idle_days: analysis.adapter_idle_days.clone(),
730 },
731 );
732 for result in single_results {
733 match &result.status {
734 PruneStatus::Pruned => {
735 total_freed += result.size_freed;
736 pruned_count += 1;
737 registry.mark_pruned(&result.repo_path, result.size_freed);
738 pruned_dirs.push(crate::config::PrunedDir {
739 repo_path: result.repo_path.clone(),
740 bloat_dir: result.bloat_dir.clone(),
741 adapter: result.adapter_name.clone(),
742 size_freed: result.size_freed,
743 runtime: result.runtime.clone(),
744 });
745 if !args.json {
746 output::print_success(&format!(
747 "{} → {} ({}) — {}{}",
748 output::clean_path(&result.repo_path),
749 result.bloat_dir,
750 output::format_bytes(result.size_freed),
751 result.adapter_name,
752 output::shared_note(result.shared_bytes, &result.adapter_name)
753 ));
754 }
755 }
756 PruneStatus::LockfileError(e) => {
757 error_count += 1;
758 if !args.json {
759 report_lockfile_failure(&result, e);
760 }
761 }
762 PruneStatus::ActivityCheckError(e) => {
763 error_count += 1;
764 if !args.json {
765 output::print_error(&format!(
766 "{} skipped — its activity could not be determined:\n {}",
767 output::clean_path(&result.repo_path),
768 e.trim()
769 ));
770 }
771 }
772 PruneStatus::DeleteError(e) => {
773 error_count += 1;
774 if result.size_freed > 0 {
779 pruned_dirs.push(crate::config::PrunedDir {
780 repo_path: result.repo_path.clone(),
781 bloat_dir: result.bloat_dir.clone(),
782 adapter: result.adapter_name.clone(),
783 size_freed: result.size_freed,
784 runtime: result.runtime.clone(),
785 });
786 }
787 if !args.json {
788 output::print_error(&format!(
789 "{} → delete failed: {}",
790 output::clean_path(&result.repo_path),
791 e,
792 ));
793 }
794 }
795 PruneStatus::ConfigError(e) => {
796 error_count += 1;
797 if !args.json {
798 let clean_p = output::clean_path(&result.repo_path);
799 output::print_error(&format!(
800 "{clean_p} skipped — its .devprune.json could not be read:\n {}",
801 e.trim()
802 ));
803 output::print_info(&format!(
804 " Fix command: devp config {clean_p} --update"
805 ));
806 }
807 }
808 PruneStatus::SkippedActive if !args.json => {
811 output::print_info(&format!(
812 "{} became active since the analysis — left alone. \
813 Use `--ignore-idle` to prune it anyway.",
814 output::clean_path(&result.repo_path)
815 ));
816 }
817 _ => {}
818 }
819 all_results.push(result);
820 }
821
822 if pruned_dirs.len() > recorded_before {
828 registry.record_prune_progress(pass_at, pruned_dirs.clone());
829 let _ = registry.save();
830 }
831 }
832
833 registry.record_prune_progress(pass_at, pruned_dirs);
834 registry.save()?;
835
836 if args.json {
837 json::emit(&json::run_document(&all_results, false))?;
838 if error_count > 0 {
841 anyhow::bail!("{error_count} repositories could not be pruned.");
842 }
843 return Ok(());
844 }
845
846 output::print_header("Summary");
847 output::print_success(&format!(
848 "Freed: {} across {pruned_count} directories",
849 output::format_bytes_styled(total_freed)
850 ));
851
852 if error_count > 0 {
853 output::print_warning(&format!("{error_count} repos were not pruned."));
854
855 if all_results
859 .iter()
860 .any(|r| matches!(r.status, PruneStatus::LockfileError(_)))
861 {
862 output::print_info(
866 "Lockfile verification cannot be bypassed: without a lockfile the deleted \
867 dependencies could not be reinstalled. Run the fix command shown above for \
868 each repo, then re-run `devp run`.",
869 );
870 }
871 anyhow::bail!("{error_count} repositories could not be pruned.");
873 }
874
875 crate::commands::update::maybe_auto_update(®istry);
878
879 Ok(())
880}
881
882#[derive(Debug, PartialEq, Eq, Clone, Copy)]
893enum ActivityFailure {
894 UntrustedOwner,
896 NotARepository,
898 Individual,
900}
901
902impl ActivityFailure {
903 fn classify(message: &str) -> Self {
910 let lower = message.to_lowercase();
911 if lower.contains(constants::GIT_DUBIOUS_OWNERSHIP) {
912 Self::UntrustedOwner
913 } else if lower.contains(constants::GIT_NOT_A_REPOSITORY) {
914 Self::NotARepository
915 } else {
916 Self::Individual
917 }
918 }
919}
920
921const GROUPED_PATHS_SHOWN: usize = 8;
926
927fn report_blocked(blocked: &[PruneResult]) {
931 if blocked.is_empty() {
932 return;
933 }
934 output::print_header(&format!(
935 "Repositories That Could Not Be Examined ({})",
936 blocked.len()
937 ));
938
939 let grouped = |failure: ActivityFailure| -> Vec<&PruneResult> {
940 blocked
941 .iter()
942 .filter(|r| match &r.status {
943 PruneStatus::ActivityCheckError(e) => ActivityFailure::classify(e) == failure,
944 _ => false,
945 })
946 .collect()
947 };
948
949 let untrusted = grouped(ActivityFailure::UntrustedOwner);
950 if !untrusted.is_empty() {
951 let n = untrusted.len();
952 output::print_error(&format!(
953 "{n} {} owned by a different account — Git will not read {}.",
954 output::plural(n, "repository is", "repositories are"),
955 output::plural(n, "it", "them")
956 ));
957 list_paths(&untrusted);
958 output::print_wrapped(
959 " ",
960 "Nothing is wrong with the repositories themselves. The owner recorded on \
961 disk is usually one a Windows reinstall, a restored backup or a drive moved \
962 between machines left behind.",
963 );
964 output::print_wrapped(
965 " ",
966 "dev-prune dates a repository by its last commit, so one Git will not open \
967 has no known age — and nothing is ever deleted from a repository whose age is \
968 unknown.",
969 );
970 output::print_info(&format!(
971 " Fix all {n} at once: devp trust --fix-ownership"
972 ));
973 }
974
975 let orphaned = grouped(ActivityFailure::NotARepository);
976 if !orphaned.is_empty() {
977 if !untrusted.is_empty() {
978 println!();
979 }
980 let n = orphaned.len();
981 output::print_error(&format!(
982 "{n} registered {} not {} git {} any more.",
983 output::plural(n, "path is", "paths are"),
984 output::plural(n, "a", ""),
985 output::plural(n, "repository", "repositories")
986 ));
987 list_paths(&orphaned);
988 output::print_wrapped(
989 " ",
990 "The directory is still there; its `.git` is not — a clone deleted and \
991 recreated by hand, or a worktree `git worktree prune` has since removed. The \
992 registry entry outlived what it pointed at.",
993 );
994 output::print_info(&format!(
998 " Drop {} from the registry: devp unlink <path>",
999 output::plural(n, "it", "them")
1000 ));
1001 }
1002
1003 for result in blocked {
1004 let clean_p = output::clean_path(&result.repo_path);
1005 match &result.status {
1006 PruneStatus::ConfigError(e) => {
1007 output::print_error(&format!(
1008 "{clean_p} skipped — its .devprune.json could not be read:
1009 {}",
1010 e.trim()
1011 ));
1012 output::print_info(&format!(
1013 " Fix command: devp config {clean_p} --update"
1014 ));
1015 }
1016 PruneStatus::LockfileError(e) => report_lockfile_failure(result, e),
1017 PruneStatus::ActivityCheckError(e)
1018 if ActivityFailure::classify(e) == ActivityFailure::Individual =>
1019 {
1020 output::print_error(&format!(
1021 "{clean_p} skipped — its activity could not be determined:
1022 {}",
1023 output::condense_tool_output(e, 4)
1024 ));
1025 }
1026 PruneStatus::DeleteError(e) => {
1027 output::print_error(&format!("{clean_p} → delete failed: {e}"));
1028 }
1029 _ => {}
1031 }
1032 }
1033}
1034
1035fn list_paths(results: &[&PruneResult]) {
1040 for result in results.iter().take(GROUPED_PATHS_SHOWN) {
1041 println!(" {}", output::styled_path(&result.repo_path));
1042 }
1043 if let Some(rest) = results
1044 .len()
1045 .checked_sub(GROUPED_PATHS_SHOWN)
1046 .filter(|n| *n > 0)
1047 {
1048 output::print_dimmed(&format!(
1049 " … and {rest} more — `devp run --dry-run --json` lists every one."
1050 ));
1051 }
1052}
1053
1054fn report_linked(linked: &[PruneResult]) {
1060 for result in linked {
1061 if let PruneStatus::SkippedSymlink(e) = &result.status {
1062 output::print_warning(&format!(
1063 "{} → {}",
1064 output::clean_path(&result.repo_path),
1065 e.trim()
1066 ));
1067 }
1068 }
1069}
1070
1071fn report_missing(missing: &[PruneResult]) {
1077 if missing.is_empty() {
1078 return;
1079 }
1080 println!();
1081 let n = missing.len();
1082 output::print_warning(&format!(
1083 "{n} registered {} no longer {} on disk.",
1084 output::plural(n, "path", "paths"),
1085 output::plural(n, "exists", "exist")
1086 ));
1087 for result in missing.iter().take(GROUPED_PATHS_SHOWN) {
1092 println!(" {}", output::styled_path(&result.repo_path));
1093 }
1094 if let Some(rest) = missing
1095 .len()
1096 .checked_sub(GROUPED_PATHS_SHOWN)
1097 .filter(|n| *n > 0)
1098 {
1099 output::print_dimmed(&format!(
1100 " … and {rest} more — `devp run --dry-run --json` lists every one."
1101 ));
1102 }
1103 output::print_info(&format!(
1104 " Clear {} from the registry: devp unlink --missing",
1105 output::plural(n, "it", "them all")
1106 ));
1107}
1108
1109fn fail_if_blocked(blocked: &[PruneResult]) -> Result<()> {
1114 if blocked.is_empty() {
1115 return Ok(());
1116 }
1117 anyhow::bail!("{} repositories could not be examined.", blocked.len());
1118}
1119
1120fn report_binaries(candidates: &[PruneResult]) {
1122 let adapter_names: Vec<String> = candidates.iter().map(|c| c.adapter_name.clone()).collect();
1123 let binary_statuses = adapters::scan_required_binaries(&adapter_names);
1124 if binary_statuses.is_empty() {
1125 return;
1126 }
1127 output::print_header("Required Ecosystem Binaries Pre-Check");
1128 for b in &binary_statuses {
1129 if b.available {
1130 output::print_success(&format!(
1131 " {} — available ({})",
1132 b.name,
1133 b.version.as_deref().unwrap_or("detected")
1134 ));
1135 } else {
1136 output::print_warning(&format!(
1137 " {} — missing (lockfile fallback active)",
1138 b.name
1139 ));
1140 }
1141 }
1142}
1143
1144fn report_candidates(candidates: &[PruneResult]) {
1145 output::print_header("Prune Candidates & Space Savings Calculation");
1146 for candidate in candidates {
1147 output::print_info(&format!(
1148 " • {} → {} ({}) [{}]{}",
1149 output::styled_path(&candidate.repo_path),
1150 candidate.bloat_dir,
1151 output::format_bytes_styled(candidate.size_freed),
1152 output::styled_adapter(&candidate.adapter_name),
1153 output::shared_note(candidate.shared_bytes, &candidate.adapter_name)
1154 ));
1155 }
1156}
1157
1158pub(crate) fn report_lockfile_failure(result: &PruneResult, error: &str) {
1159 let project = output::clean_path(result.project_dir());
1162
1163 output::print_error(&format!(
1164 "{} → {} lockfile sync failed:\n {}",
1165 project,
1166 result.adapter_name,
1167 error.trim(),
1168 ));
1169 match json::lockfile_fix_command(&result.adapter_name) {
1170 Some(sync_cmd) => {
1171 #[cfg(windows)]
1174 let manual_cmd = format!("cd \"{project}\"; {sync_cmd}");
1175 #[cfg(not(windows))]
1176 let manual_cmd = format!("cd \"{project}\" && {sync_cmd}");
1177 output::print_info(&format!(" Fix command: {manual_cmd}"));
1178 }
1179 None => output::print_info(&format!(" Fix it in: {project}")),
1182 }
1183 output::print_info(&format!(
1184 " Troubleshooting: {}",
1185 constants::TROUBLESHOOTING_URL
1186 ));
1187}
1188
1189fn run_explain(args: &RunArgs<'_>, filter: &AdapterFilter) -> Result<()> {
1200 output::print_header("Why each repository would or would not be pruned");
1201 if let Some(desc) = filter.describe() {
1202 output::print_info(&format!("Adapter filter: {desc}"));
1203 }
1204
1205 if let Some(target_str) = args.target_path {
1206 let raw = Path::new(target_str);
1207 let path = if raw.exists() {
1208 raw.canonicalize().unwrap_or_else(|_| raw.to_path_buf())
1209 } else {
1210 raw.to_path_buf()
1211 };
1212 if !crate::scanner::is_git_repo(&path) {
1213 anyhow::bail!(
1214 "{} is not a Git repository — dev-prune only prunes Git repos.",
1215 output::clean_path(&path)
1216 );
1217 }
1218 let registry = Registry::load().ok();
1219 let idle_days = registry
1220 .as_ref()
1221 .map(|r| {
1222 r.repositories
1223 .get(&path)
1224 .and_then(|e| e.override_idle_days)
1225 .unwrap_or(r.settings.idle_days)
1226 })
1227 .unwrap_or(constants::DEFAULT_IDLE_DAYS);
1228 let floor = resolve_min_size(args, registry.as_ref());
1229 let results = engine::prune_repo_with(
1230 &path,
1231 &PruneOptions {
1232 idle_days,
1233 dry_run: true,
1234 force: args.force,
1235 only_dirs: None,
1236 adapters: filter.clone(),
1237 min_size_bytes: 0,
1238 scan_depth: resolve_scan_depth(registry.as_ref()),
1239 allow_manifest_rewrite: resolve_manifest_rewrite(registry.as_ref()),
1240 command_timeout_secs: resolve_command_timeout(registry.as_ref()),
1241 build_idle_days: resolve_build_idle_days(registry.as_ref()),
1242 adapter_idle_days: resolve_adapter_idle_days(registry.as_ref()),
1243 },
1244 );
1245 let refs: Vec<&PruneResult> = results.iter().collect();
1246 explain_repo(&path, &refs, floor, idle_days);
1247 print_explain_footer();
1248 return Ok(());
1249 }
1250
1251 let mut registry = Registry::load()?;
1252 if registry.repo_count() == 0 {
1253 output::print_warning("No repositories registered. Run `dev-prune init` first.");
1254 return Ok(());
1255 }
1256
1257 let except = parse_except(args.except);
1258 let global_floor = resolve_min_size(args, Some(®istry));
1259 let analysis = PruneOptions {
1260 idle_days: 0, dry_run: true,
1262 force: args.force,
1263 only_dirs: None,
1264 adapters: filter.clone(),
1265 min_size_bytes: 0,
1266 scan_depth: resolve_scan_depth(Some(®istry)),
1267 allow_manifest_rewrite: resolve_manifest_rewrite(Some(®istry)),
1268 command_timeout_secs: resolve_command_timeout(Some(®istry)),
1269 build_idle_days: resolve_build_idle_days(Some(®istry)),
1270 adapter_idle_days: resolve_adapter_idle_days(Some(®istry)),
1271 };
1272 let results = engine::prune_all_with(&mut registry, &analysis);
1273
1274 let mut by_repo: std::collections::HashMap<&Path, Vec<&PruneResult>> =
1275 std::collections::HashMap::new();
1276 for r in &results {
1277 by_repo.entry(r.repo_path.as_path()).or_default().push(r);
1278 }
1279
1280 let mut repos: Vec<&std::path::PathBuf> = registry.repositories.keys().collect();
1281 repos.sort();
1282 for path in repos {
1283 if is_excepted(path, &except) {
1284 println!();
1285 output::print_info(&output::clean_path(path));
1286 println!(" • left completely alone this pass (`--except`)");
1287 continue;
1288 }
1289 let idle_days = registry
1290 .repositories
1291 .get(path)
1292 .and_then(|e| e.override_idle_days)
1293 .unwrap_or(registry.settings.idle_days);
1294 let empty = Vec::new();
1295 let repo_results = by_repo.get(path.as_path()).unwrap_or(&empty);
1296 explain_repo(path, repo_results, global_floor, idle_days);
1297 }
1298 print_explain_footer();
1299 Ok(())
1300}
1301
1302fn explain_repo(path: &Path, results: &[&PruneResult], floor: u64, idle_days: u64) {
1304 println!();
1305 output::print_info(&output::clean_path(path));
1306
1307 if results.is_empty() {
1308 println!(
1309 " • idle, but no known bloat directories were found. A project deeper than \
1310 `scan_depth` is not examined — `devp status` shows what dev-prune can see."
1311 );
1312 return;
1313 }
1314
1315 for r in results {
1316 match &r.status {
1317 PruneStatus::SkippedDryRun => {
1318 if r.size_freed >= floor {
1319 output::print_success(&format!(
1320 "would prune {} ({}) [{}]{}",
1321 r.bloat_dir,
1322 output::format_bytes(r.size_freed),
1323 r.adapter_name,
1324 output::shared_note(r.shared_bytes, &r.adapter_name)
1325 ));
1326 } else {
1327 println!(
1328 " • {} ({}) is under the size floor of {} — the reinstall would \
1329 cost more than the space is worth. `--min-size 0` includes it.",
1330 r.bloat_dir,
1331 output::format_bytes(r.size_freed),
1332 output::format_bytes(floor)
1333 );
1334 }
1335 }
1336 PruneStatus::SkippedActive => {
1337 let age = crate::scanner::git::get_last_activity(path)
1338 .ok()
1339 .flatten()
1340 .and_then(|t| std::time::SystemTime::now().duration_since(t).ok())
1341 .map(|d| d.as_secs() / 86_400);
1342 match age {
1343 Some(0) => println!(
1344 " • active — there was activity today, and the idle \
1345 threshold is {idle_days} days. `--ignore-idle` overrides."
1346 ),
1347 Some(days) => println!(
1348 " • active — last activity {days} day{} ago, and the idle \
1349 threshold is {idle_days} days. `--ignore-idle` overrides.",
1350 if days == 1 { "" } else { "s" }
1351 ),
1352 None => println!(
1353 " • active (not idle for {idle_days} days yet). \
1354 `--ignore-idle` overrides."
1355 ),
1356 }
1357 }
1358 other => println!(" • {other}"),
1359 }
1360 }
1361}
1362
1363fn print_explain_footer() {
1365 println!();
1366 output::print_info(
1367 "Nothing was verified or deleted. `devp run --dry-run` verifies candidates; \
1368 `devp run` prunes.",
1369 );
1370}
1371
1372#[cfg(test)]
1373mod tests {
1374 use super::*;
1375
1376 #[test]
1377 fn gits_ownership_refusal_is_recognised_whatever_the_path() {
1378 let message = "git could not read `V:/x`: fatal: detected dubious ownership in repository at 'V:/x'";
1382 assert_eq!(
1383 ActivityFailure::classify(message),
1384 ActivityFailure::UntrustedOwner
1385 );
1386 }
1387
1388 #[test]
1389 fn a_path_that_lost_its_git_directory_is_its_own_cause() {
1390 let message = "fatal: not a git repository (or any of the parent directories): .git";
1394 assert_eq!(
1395 ActivityFailure::classify(message),
1396 ActivityFailure::NotARepository
1397 );
1398 }
1399
1400 #[test]
1401 fn an_unfamiliar_failure_is_still_printed_in_full() {
1402 assert_eq!(
1403 ActivityFailure::classify("fatal: unable to read tree"),
1404 ActivityFailure::Individual
1405 );
1406 }
1407}