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::SkippedAdapterWindow(days) => {
319 output::print_info(&format!(
320 " • {} was not examined — it waits for {days} idle days \
321 (`build_idle_days` / `adapter_idle_days`), longer than this \
322 repository has been idle. `--ignore-idle` overrides.",
323 result.adapter_name
324 ));
325 }
326 PruneStatus::LockfileError(e) => report_lockfile_failure(&result, e),
327 PruneStatus::ActivityCheckError(e) => {
328 output::print_error(&format!(
329 "{clean} skipped — its activity could not be determined:\n {}",
330 e.trim()
331 ));
332 }
333 PruneStatus::DeleteError(e) => {
334 output::print_error(&format!("{clean} delete error: {e}"));
335 }
336 PruneStatus::ConfigError(e) => {
337 output::print_error(&format!(
338 "{clean} skipped — its .devprune.json could not be read:\n {}\n \
339 Fix it, or run `devp config project {clean} --update` to reset it.",
340 e.trim()
341 ));
342 }
343 PruneStatus::SkippedSymlink(e) => {
344 output::print_warning(&format!("{clean} → {}", e.trim()));
345 }
346 PruneStatus::SkippedDeclaration(e) => {
347 output::print_warning(&format!("{clean} → {}", e.trim()));
348 }
349 PruneStatus::SkippedNestedRepo(e) => {
350 output::print_warning(&format!("{clean} → {}", e.trim()));
351 }
352 _ => {}
353 }
354 }
355
356 if !args.dry_run && total_freed > 0 {
357 output::print_success(&i18n::tf(
358 "run.freed.targeted",
359 &[
360 ("size", &output::format_bytes(total_freed)),
361 ("path", clean.as_str()),
362 ],
363 ));
364 }
365
366 if error_count > 0 {
367 anyhow::bail!("{error_count} directories in {clean} could not be pruned.");
368 }
369
370 Ok(())
371}
372
373fn record_targeted_prune(
379 path: &std::path::Path,
380 results: &[PruneResult],
381 dry_run: bool,
382 daemon: bool,
383) {
384 if dry_run {
385 return;
386 }
387
388 let pruned: Vec<crate::config::PrunedDir> = results
391 .iter()
392 .filter(|r| {
393 matches!(r.status, PruneStatus::Pruned)
394 || (matches!(r.status, PruneStatus::DeleteError(_)) && r.size_freed > 0)
395 })
396 .map(|r| crate::config::PrunedDir {
397 repo_path: r.repo_path.clone(),
398 bloat_dir: r.bloat_dir.clone(),
399 adapter: r.adapter_name.clone(),
400 size_freed: r.size_freed,
401 runtime: r.runtime.clone(),
402 })
403 .collect();
404
405 if pruned.is_empty() {
406 return;
407 }
408
409 let freed: u64 = pruned.iter().map(|d| d.size_freed).sum();
410 let pass_at = chrono::Utc::now();
413 crate::history::record(pass_at, crate::history::Trigger::for_run(daemon), &pruned);
414 if let Ok(mut registry) = Registry::load() {
415 registry.mark_pruned(path, freed);
416 registry.record_prune_progress(pass_at, pruned);
417 let _ = registry.save();
418 }
419}
420
421fn resolve_min_size(args: &RunArgs<'_>, registry: Option<&Registry>) -> u64 {
426 let mb = args
427 .min_size_mb
428 .or_else(|| registry.map(|r| r.settings.min_size_mb))
429 .unwrap_or(constants::DEFAULT_MIN_SIZE_MB);
430 mb.saturating_mul(engine::BYTES_PER_MIB)
431}
432
433fn parse_except(spec: Option<&str>) -> Vec<String> {
440 spec.map(|s| {
441 s.split(',')
442 .map(|part| {
443 crate::config::expand_tilde(part.trim())
444 .trim_end_matches(['/', '\\'])
445 .to_lowercase()
446 })
447 .filter(|part| !part.is_empty())
448 .collect()
449 })
450 .unwrap_or_default()
451}
452
453fn is_excepted(repo_path: &Path, except: &[String]) -> bool {
460 if except.is_empty() {
461 return false;
462 }
463 let full = output::clean_path(repo_path)
464 .to_lowercase()
465 .replace('\\', "/");
466 let name = repo_path
467 .file_name()
468 .map(|n| n.to_string_lossy().to_lowercase())
469 .unwrap_or_default();
470
471 except.iter().any(|want| {
472 let want = want.replace('\\', "/");
473 name == want || full == want || full.ends_with(&format!("/{want}"))
474 })
475}
476
477fn resolve_scan_depth(registry: Option<&Registry>) -> usize {
479 registry
480 .map(|r| r.settings.scan_depth)
481 .unwrap_or(constants::DEFAULT_SCAN_DEPTH)
482}
483
484fn resolve_build_idle_days(registry: Option<&Registry>) -> u64 {
486 registry
487 .map(|r| r.settings.build_idle_days)
488 .unwrap_or(constants::DEFAULT_BUILD_IDLE_DAYS)
489}
490
491fn resolve_adapter_idle_days(
493 registry: Option<&Registry>,
494) -> std::collections::BTreeMap<String, u64> {
495 registry
496 .map(|r| r.settings.adapter_idle_days.clone())
497 .unwrap_or_default()
498}
499
500fn resolve_command_timeout(registry: Option<&Registry>) -> u64 {
501 registry
502 .map(|r| r.settings.command_timeout_secs)
503 .unwrap_or(constants::DEFAULT_COMMAND_TIMEOUT_SECS)
504}
505
506fn resolve_manifest_rewrite(registry: Option<&Registry>) -> bool {
508 registry
509 .map(|r| r.settings.allow_manifest_rewrite)
510 .unwrap_or(constants::DEFAULT_ALLOW_MANIFEST_REWRITE)
511}
512
513fn run_registry(args: &RunArgs<'_>, filter: &AdapterFilter) -> Result<()> {
515 if !args.json {
516 if args.dry_run {
517 output::print_header(i18n::t("run.header.dry"));
518 } else {
519 output::print_header(i18n::t("run.header"));
520 }
521 }
522
523 let mut registry = Registry::load()?;
524
525 let healed = registry.heal_dead_and_moved_repositories();
527 if healed > 0 {
528 let _ = registry.save();
529 }
530
531 let adopted = crate::commands::link::adopt_enclosing_repo(&mut registry);
535 if adopted.is_some() {
536 registry.save()?;
537 }
538 if let Some(path) = &adopted
539 && !args.json
540 {
541 crate::commands::link::report_cwd_adoption(path);
542 println!();
543 }
544
545 if !args.json && crate::commands::update::notify_if_outdated(&mut registry) {
548 let _ = registry.save();
549 }
550
551 if args.daemon && registry.settings.auto_discover && !setup::no_auto_setup_requested() {
563 let found = crate::discovery::discover(®istry)?;
564 let added = found
565 .found
566 .into_iter()
567 .filter(|repo| registry.add_repo(repo.clone()))
568 .count();
569 if added > 0 {
570 registry.save()?;
571 if !args.json {
572 output::print_info(&format!(
573 "Discovered and registered {added} new {}.",
574 output::plural(added, "repository", "repositories")
575 ));
576 }
577 }
578 }
579
580 if registry.repo_count() == 0 {
581 if args.json {
582 return json::emit(&json::run_document(&[], args.dry_run));
583 }
584 output::print_warning(
585 "No repositories registered. Run `devp init` here, or `devp init --auto` to find them.",
586 );
587 return Ok(());
588 }
589
590 let except = parse_except(args.except);
594 if !except.is_empty() {
595 let unmatched: Vec<&String> = except
596 .iter()
597 .filter(|want| {
598 !registry
599 .repositories
600 .keys()
601 .any(|p| is_excepted(p, std::slice::from_ref(*want)))
602 })
603 .collect();
604 if !unmatched.is_empty() {
605 anyhow::bail!(
606 "`--except` names no registered repository: {}\n \
607 Run `devp status` to see the registered names.",
608 unmatched
609 .iter()
610 .map(|s| s.as_str())
611 .collect::<Vec<_>>()
612 .join(", ")
613 );
614 }
615 }
616
617 let min_size_bytes = resolve_min_size(args, Some(®istry));
618 let analysis = PruneOptions {
619 idle_days: 0, dry_run: true,
621 force: args.force,
622 only_dirs: None,
623 adapters: filter.clone(),
624 min_size_bytes,
625 scan_depth: resolve_scan_depth(Some(®istry)),
626 allow_manifest_rewrite: resolve_manifest_rewrite(Some(®istry)),
627 command_timeout_secs: resolve_command_timeout(Some(®istry)),
628 build_idle_days: resolve_build_idle_days(Some(®istry)),
629 adapter_idle_days: resolve_adapter_idle_days(Some(®istry)),
630 };
631
632 if !args.json {
633 output::print_info(&format!(
634 "Scanning {} registered repositories for prune candidates...",
635 registry.repo_count()
636 ));
637 if let Some(desc) = filter.describe() {
638 output::print_info(&format!("Adapter filter: {desc}"));
639 }
640 if min_size_bytes > 0 {
641 output::print_info(&format!(
642 "Size floor: ignoring directories under {}",
643 output::format_bytes(min_size_bytes)
644 ));
645 }
646 }
647
648 let mut candidates: Vec<PruneResult> = Vec::new();
657 let mut blocked: Vec<PruneResult> = Vec::new();
658 let mut left_alone: Vec<PruneResult> = Vec::new();
659 let mut missing: Vec<PruneResult> = Vec::new();
660 let mut orphaned: Vec<PruneResult> = Vec::new();
661 for result in engine::prune_all_with(&mut registry, &analysis) {
662 if is_excepted(&result.repo_path, &except) {
666 continue;
667 }
668 match result.status {
669 PruneStatus::SkippedDryRun => candidates.push(result),
670 PruneStatus::ConfigError(_)
671 | PruneStatus::LockfileError(_)
672 | PruneStatus::ActivityCheckError(_)
673 | PruneStatus::DeleteError(_) => blocked.push(result),
674 PruneStatus::SkippedSymlink(_)
682 | PruneStatus::SkippedDeclaration(_)
683 | PruneStatus::SkippedNestedRepo(_) => left_alone.push(result),
684 PruneStatus::PathMissing => missing.push(result),
687 PruneStatus::NotARepo => orphaned.push(result),
691 _ => {}
692 }
693 }
694
695 if !args.json && !except.is_empty() {
696 output::print_info(&format!("Leaving alone: {}", except.join(", ")));
697 }
698
699 if args.daemon {
700 let before = candidates.len();
701 candidates.retain(|c| {
702 match crate::config::PerRepoConfig::load_with_diagnostics(&c.repo_path) {
706 Ok(Some(cfg)) => !cfg.disable_daemon,
707 Ok(None) => true,
708 Err(_) => false,
709 }
710 });
711 let skipped = before - candidates.len();
712 if skipped > 0 && !args.json {
713 output::print_info(&format!(
714 "Skipped {skipped} bloat directories in repositories that set `disable_daemon`."
715 ));
716 }
717 }
718
719 if args.dry_run {
721 let recommendations = if registry.settings.recommendations {
724 opt_in_recommendations(®istry, &except)
725 } else {
726 Vec::new()
727 };
728 if args.json {
729 let mut doc = json::run_document(
730 &[candidates, blocked, left_alone, missing, orphaned].concat(),
731 true,
732 );
733 json::attach_recommendations(&mut doc, &recommendations);
734 json::emit(&doc)?;
735 return Ok(());
736 }
737 if candidates.is_empty()
738 && blocked.is_empty()
739 && left_alone.is_empty()
740 && missing.is_empty()
741 && orphaned.is_empty()
742 {
743 output::print_info(i18n::t("run.nothing"));
744 report_recommendations(&recommendations);
748 return Ok(());
749 }
750 if !candidates.is_empty() {
751 report_candidates(&candidates);
752 }
753 let total: u64 = candidates.iter().map(|c| c.size_freed).sum();
754 output::print_header(i18n::t("run.summary.dry"));
755 output::print_info(&i18n::tf(
756 "run.would_free",
757 &[
758 ("size", &output::format_bytes(total)),
759 ("count", &candidates.len().to_string()),
760 ],
761 ));
762 report_blocked(&blocked);
765 report_left_alone(&left_alone);
766 report_missing(&missing);
767 report_orphaned(&orphaned);
768 report_recommendations(&recommendations);
769 return Ok(());
770 }
771
772 if candidates.is_empty() {
773 if args.json {
774 json::emit(&json::run_document(
775 &[blocked.clone(), left_alone, missing, orphaned].concat(),
776 false,
777 ))?;
778 return fail_if_blocked(&blocked);
779 }
780 if blocked.is_empty() && left_alone.is_empty() && missing.is_empty() && orphaned.is_empty()
781 {
782 output::print_info(i18n::t("run.nothing"));
783 crate::commands::update::maybe_auto_update(®istry);
784 return Ok(());
785 }
786 output::print_info(i18n::t("run.nothing.bloat"));
787 report_blocked(&blocked);
788 report_left_alone(&left_alone);
789 report_missing(&missing);
790 report_orphaned(&orphaned);
791 crate::commands::update::maybe_auto_update(®istry);
792 return fail_if_blocked(&blocked);
793 }
794
795 let total_reclaimable: u64 = candidates.iter().map(|c| c.size_freed).sum();
796
797 if !args.json {
798 report_binaries(&candidates);
799 report_candidates(&candidates);
800 output::print_info(&i18n::tf(
801 "run.reclaimable",
802 &[("size", &output::format_bytes_styled(total_reclaimable))],
803 ));
804 report_blocked(&blocked);
805 report_left_alone(&left_alone);
806 report_missing(&missing);
807 report_orphaned(&orphaned);
808 }
809
810 let target_candidates: Vec<PruneResult> = if args.json
813 || args.yes
814 || !registry.settings.require_confirmation
815 {
816 candidates
817 } else if tui::full_screen_is_usable() {
818 eprintln!();
819 eprintln!(
820 " Loading interactive selector... (↑↓ navigate, Space toggle, Enter confirm, q cancel)"
821 );
822 eprintln!();
823 let selected = tui::selection_view::select_candidates_tui(&candidates)?;
824 if selected.is_empty() {
825 output::print_info("Prune pass cancelled by user (0 candidates selected).");
826 return Ok(());
827 }
828 selected
829 } else {
830 if !io::stdin().is_terminal() {
835 anyhow::bail!(
836 "Deleting {} directories ({}) needs confirmation, and there is no \
837 terminal to ask on. Re-run with `--yes` to confirm, or `--dry-run` \
838 to only analyse.",
839 candidates.len(),
840 output::format_bytes(total_reclaimable)
841 );
842 }
843 println!();
844 output::print_warning("CAUTION: Deleting bloat directories cannot be undone directly.");
845 output::print_info(
846 "Note: You can re-install missing dependencies anytime using `dev-prune restore`.",
847 );
848 eprint!(
851 "Proceed with deletion of {} directories ({})? [y/N]: ",
852 candidates.len(),
853 output::format_bytes(total_reclaimable)
854 );
855 io::stderr().flush()?;
856
857 let mut input = String::new();
858 io::stdin().read_line(&mut input)?;
859 let trimmed = input.trim().to_lowercase();
860 if trimmed != "y" && trimmed != "yes" {
861 output::print_info("Prune pass aborted by user.");
862 return Ok(());
863 }
864 candidates
865 };
866
867 if !args.json {
868 let selected_total_bytes: u64 = target_candidates.iter().map(|c| c.size_freed).sum();
869 output::print_header(&i18n::tf(
870 "run.header.deleting",
871 &[
872 ("repos", &target_candidates.len().to_string()),
873 ("size", &output::format_bytes(selected_total_bytes)),
874 ],
875 ));
876 }
877
878 let mut selection: Vec<(std::path::PathBuf, Vec<String>)> = Vec::new();
884 for candidate in &target_candidates {
885 match selection
886 .iter_mut()
887 .find(|(p, _)| *p == candidate.repo_path)
888 {
889 Some((_, dirs)) => dirs.push(candidate.bloat_dir.clone()),
890 None => selection.push((
891 candidate.repo_path.clone(),
892 vec![candidate.bloat_dir.clone()],
893 )),
894 }
895 }
896
897 let mut error_count = blocked.len();
901 let mut all_results: Vec<PruneResult> = blocked;
902 all_results.extend(left_alone);
903 all_results.extend(missing);
904 all_results.extend(orphaned);
905 let mut total_freed: u64 = 0;
906 let mut pruned_count = 0;
907 let mut pruned_dirs: Vec<crate::config::PrunedDir> = Vec::new();
908 let pass_at = chrono::Utc::now();
911 let trigger = crate::history::Trigger::for_run(args.daemon);
912
913 for (repo_path, dirs) in &selection {
914 let recorded_before = pruned_dirs.len();
915 let idle_days = registry
920 .repositories
921 .get(repo_path)
922 .and_then(|e| e.override_idle_days)
923 .unwrap_or(registry.settings.idle_days);
924 let single_results = engine::prune_repo_with(
925 repo_path,
926 &PruneOptions {
927 idle_days,
928 dry_run: false,
929 force: args.force,
930 only_dirs: Some(dirs.clone()),
931 adapters: filter.clone(),
932 min_size_bytes: 0,
933 scan_depth: analysis.scan_depth,
934 allow_manifest_rewrite: analysis.allow_manifest_rewrite,
935 command_timeout_secs: analysis.command_timeout_secs,
936 build_idle_days: analysis.build_idle_days,
937 adapter_idle_days: analysis.adapter_idle_days.clone(),
938 },
939 );
940 for result in single_results {
941 match &result.status {
942 PruneStatus::Pruned => {
943 total_freed += result.size_freed;
944 pruned_count += 1;
945 registry.mark_pruned(&result.repo_path, result.size_freed);
946 pruned_dirs.push(crate::config::PrunedDir {
947 repo_path: result.repo_path.clone(),
948 bloat_dir: result.bloat_dir.clone(),
949 adapter: result.adapter_name.clone(),
950 size_freed: result.size_freed,
951 runtime: result.runtime.clone(),
952 });
953 if !args.json {
954 output::print_success(&format!(
955 "{} → {} ({}) — {}{}",
956 output::clean_path(&result.repo_path),
957 result.bloat_dir,
958 output::format_bytes(result.size_freed),
959 result.adapter_name,
960 output::shared_note(result.shared_bytes, &result.adapter_name)
961 ));
962 }
963 }
964 PruneStatus::LockfileError(e) => {
965 error_count += 1;
966 if !args.json {
967 report_lockfile_failure(&result, e);
968 }
969 }
970 PruneStatus::ActivityCheckError(e) => {
971 error_count += 1;
972 if !args.json {
973 output::print_error(&format!(
974 "{} skipped — its activity could not be determined:\n {}",
975 output::clean_path(&result.repo_path),
976 e.trim()
977 ));
978 }
979 }
980 PruneStatus::DeleteError(e) => {
981 error_count += 1;
982 if result.size_freed > 0 {
987 pruned_dirs.push(crate::config::PrunedDir {
988 repo_path: result.repo_path.clone(),
989 bloat_dir: result.bloat_dir.clone(),
990 adapter: result.adapter_name.clone(),
991 size_freed: result.size_freed,
992 runtime: result.runtime.clone(),
993 });
994 }
995 if !args.json {
996 output::print_error(&format!(
997 "{} → delete failed: {}",
998 output::clean_path(&result.repo_path),
999 e,
1000 ));
1001 }
1002 }
1003 PruneStatus::ConfigError(e) => {
1004 error_count += 1;
1005 if !args.json {
1006 let clean_p = output::clean_path(&result.repo_path);
1007 output::print_error(&format!(
1008 "{clean_p} skipped — its .devprune.json could not be read:\n {}",
1009 e.trim()
1010 ));
1011 output::print_info(&format!(
1012 " Fix command: devp config project {clean_p} --update"
1013 ));
1014 }
1015 }
1016 PruneStatus::SkippedActive if !args.json => {
1019 output::print_info(&format!(
1020 "{} became active since the analysis — left alone. \
1021 Use `--ignore-idle` to prune it anyway.",
1022 output::clean_path(&result.repo_path)
1023 ));
1024 }
1025 PruneStatus::SkippedDeclaration(_) => continue,
1030 PruneStatus::SkippedAdapterWindow(_) => continue,
1036 _ => {}
1037 }
1038 all_results.push(result);
1039 }
1040
1041 if pruned_dirs.len() > recorded_before {
1047 registry.record_prune_progress(pass_at, pruned_dirs.clone());
1048 crate::history::record(pass_at, trigger, &pruned_dirs);
1049 let _ = registry.save();
1050 }
1051 }
1052
1053 crate::history::record(pass_at, trigger, &pruned_dirs);
1054 registry.record_prune_progress(pass_at, pruned_dirs);
1055 let saved = registry.save();
1059
1060 if args.json {
1061 json::emit(&json::run_document(&all_results, false))?;
1062 saved?;
1063 if error_count > 0 {
1066 anyhow::bail!("{error_count} repositories could not be pruned.");
1067 }
1068 return Ok(());
1069 }
1070 saved?;
1071
1072 output::print_header(i18n::t("run.summary"));
1073 output::print_success(&i18n::tf(
1074 "run.freed",
1075 &[
1076 ("size", &output::format_bytes_styled(total_freed)),
1077 ("count", &pruned_count.to_string()),
1078 ],
1079 ));
1080
1081 crate::commands::update::maybe_auto_update(®istry);
1085
1086 if error_count > 0 {
1087 output::print_warning(&i18n::tf(
1088 "run.not_pruned",
1089 &[("count", &error_count.to_string())],
1090 ));
1091
1092 if all_results
1096 .iter()
1097 .any(|r| matches!(r.status, PruneStatus::LockfileError(_)))
1098 {
1099 output::print_info(
1103 "Lockfile verification cannot be bypassed: without a lockfile the deleted \
1104 dependencies could not be reinstalled. Run the fix command shown above for \
1105 each repo, then re-run `devp run`.",
1106 );
1107 }
1108 anyhow::bail!("{error_count} repositories could not be pruned.");
1110 }
1111
1112 Ok(())
1113}
1114
1115#[derive(Debug, PartialEq, Eq, Clone, Copy)]
1126enum ActivityFailure {
1127 UntrustedOwner,
1129 NotARepository,
1131 Individual,
1133}
1134
1135impl ActivityFailure {
1136 fn classify(message: &str) -> Self {
1143 let lower = message.to_lowercase();
1144 if lower.contains(constants::GIT_DUBIOUS_OWNERSHIP) {
1145 Self::UntrustedOwner
1146 } else if lower.contains(constants::GIT_NOT_A_REPOSITORY) {
1147 Self::NotARepository
1148 } else {
1149 Self::Individual
1150 }
1151 }
1152}
1153
1154const GROUPED_PATHS_SHOWN: usize = 8;
1159
1160fn report_blocked(blocked: &[PruneResult]) {
1164 if blocked.is_empty() {
1165 return;
1166 }
1167 output::print_header(&i18n::tf(
1168 "run.header.blocked",
1169 &[("count", &blocked.len().to_string())],
1170 ));
1171
1172 let grouped = |failure: ActivityFailure| -> Vec<&PruneResult> {
1173 blocked
1174 .iter()
1175 .filter(|r| match &r.status {
1176 PruneStatus::ActivityCheckError(e) => ActivityFailure::classify(e) == failure,
1177 _ => false,
1178 })
1179 .collect()
1180 };
1181
1182 let untrusted = grouped(ActivityFailure::UntrustedOwner);
1183 if !untrusted.is_empty() {
1184 let n = untrusted.len();
1185 output::print_error(&format!(
1186 "{n} {} owned by a different account — Git will not read {}.",
1187 output::plural(n, "repository is", "repositories are"),
1188 output::plural(n, "it", "them")
1189 ));
1190 list_paths(&untrusted);
1191 output::print_wrapped(
1192 " ",
1193 "Nothing is wrong with the repositories themselves. The owner recorded on \
1194 disk is usually one a Windows reinstall, a restored backup or a drive moved \
1195 between machines left behind.",
1196 );
1197 output::print_wrapped(
1198 " ",
1199 "dev-prune dates a repository by its last commit, so one Git will not open \
1200 has no known age — and nothing is ever deleted from a repository whose age is \
1201 unknown.",
1202 );
1203 output::print_info(&format!(
1204 " Fix all {n} at once: devp trust --fix-ownership"
1205 ));
1206 }
1207
1208 let orphaned = grouped(ActivityFailure::NotARepository);
1209 if !orphaned.is_empty() {
1210 if !untrusted.is_empty() {
1211 println!();
1212 }
1213 let n = orphaned.len();
1214 output::print_error(&format!(
1215 "{n} registered {} not {} git {} any more.",
1216 output::plural(n, "path is", "paths are"),
1217 output::plural(n, "a", ""),
1218 output::plural(n, "repository", "repositories")
1219 ));
1220 list_paths(&orphaned);
1221 output::print_wrapped(
1222 " ",
1223 "The directory is still there; its `.git` is not — a clone deleted and \
1224 recreated by hand, or a worktree `git worktree prune` has since removed. The \
1225 registry entry outlived what it pointed at.",
1226 );
1227 output::print_info(&format!(
1231 " Drop {} from the registry: devp unlink <path>",
1232 output::plural(n, "it", "them")
1233 ));
1234 output::print_info(&format!(
1238 " Or keep {} tracked: git init <path> makes it a repository again",
1239 output::plural(n, "it", "them")
1240 ));
1241 }
1242
1243 for result in blocked {
1244 let clean_p = output::clean_path(&result.repo_path);
1245 match &result.status {
1246 PruneStatus::ConfigError(e) => {
1247 output::print_error(&format!(
1248 "{clean_p} skipped — its .devprune.json could not be read:
1249 {}",
1250 e.trim()
1251 ));
1252 output::print_info(&format!(
1253 " Fix command: devp config project {clean_p} --update"
1254 ));
1255 }
1256 PruneStatus::LockfileError(e) => report_lockfile_failure(result, e),
1257 PruneStatus::ActivityCheckError(e)
1258 if ActivityFailure::classify(e) == ActivityFailure::Individual =>
1259 {
1260 output::print_error(&format!(
1261 "{clean_p} skipped — its activity could not be determined:
1262 {}",
1263 output::condense_tool_output(e, 4)
1264 ));
1265 }
1266 PruneStatus::DeleteError(e) => {
1267 output::print_error(&format!("{clean_p} → delete failed: {e}"));
1268 }
1269 _ => {}
1271 }
1272 }
1273}
1274
1275fn list_paths(results: &[&PruneResult]) {
1280 for result in results.iter().take(GROUPED_PATHS_SHOWN) {
1281 println!(" {}", output::styled_path(&result.repo_path));
1282 }
1283 if let Some(rest) = results
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}
1293
1294fn report_left_alone(left_alone: &[PruneResult]) {
1302 for result in left_alone {
1303 if let PruneStatus::SkippedSymlink(e)
1304 | PruneStatus::SkippedDeclaration(e)
1305 | PruneStatus::SkippedNestedRepo(e) = &result.status
1306 {
1307 output::print_warning(&format!(
1308 "{} → {}",
1309 output::clean_path(&result.repo_path),
1310 e.trim()
1311 ));
1312 }
1313 }
1314}
1315
1316fn report_missing(missing: &[PruneResult]) {
1322 if missing.is_empty() {
1323 return;
1324 }
1325 println!();
1326 let n = missing.len();
1327 output::print_warning(&format!(
1328 "{n} registered {} no longer {} on disk.",
1329 output::plural(n, "path", "paths"),
1330 output::plural(n, "exists", "exist")
1331 ));
1332 for result in missing.iter().take(GROUPED_PATHS_SHOWN) {
1337 println!(" {}", output::styled_path(&result.repo_path));
1338 }
1339 if let Some(rest) = missing
1340 .len()
1341 .checked_sub(GROUPED_PATHS_SHOWN)
1342 .filter(|n| *n > 0)
1343 {
1344 output::print_dimmed(&format!(
1345 " … and {rest} more — `devp run --dry-run --json` lists every one."
1346 ));
1347 }
1348 output::print_info(&format!(
1349 " Clear {} from the registry: devp unlink --missing",
1350 output::plural(n, "it", "them all")
1351 ));
1352}
1353
1354fn report_orphaned(orphaned: &[PruneResult]) {
1363 if orphaned.is_empty() {
1364 return;
1365 }
1366 println!();
1367 let n = orphaned.len();
1368 output::print_warning(&format!(
1369 "{n} registered {} not {} git {} any more.",
1370 output::plural(n, "path is", "paths are"),
1371 output::plural(n, "a", ""),
1372 output::plural(n, "repository", "repositories")
1373 ));
1374 for result in orphaned.iter().take(GROUPED_PATHS_SHOWN) {
1375 println!(" {}", output::styled_path(&result.repo_path));
1376 }
1377 if let Some(rest) = orphaned
1378 .len()
1379 .checked_sub(GROUPED_PATHS_SHOWN)
1380 .filter(|n| *n > 0)
1381 {
1382 output::print_dimmed(&format!(
1383 " … and {rest} more — `devp run --dry-run --json` lists every one."
1384 ));
1385 }
1386 output::print_info(&format!(
1387 " Drop {} from the registry: devp unlink <path>",
1388 output::plural(n, "it", "them")
1389 ));
1390 output::print_info(&format!(
1393 " Or keep {} tracked: git init <path> makes it a repository again",
1394 output::plural(n, "it", "them")
1395 ));
1396}
1397
1398fn opt_in_recommendations(registry: &Registry, except: &[String]) -> Vec<(String, usize)> {
1406 let mut counts: std::collections::BTreeMap<&'static str, usize> =
1407 std::collections::BTreeMap::new();
1408 for (path, entry) in ®istry.repositories {
1409 if !entry.enabled
1410 || !path.exists()
1411 || is_excepted(path, except)
1412 || path.join(constants::DEVPRUNE_IGNORE_FILE).exists()
1413 {
1414 continue;
1415 }
1416 let per_repo = match crate::config::PerRepoConfig::load_with_diagnostics(path) {
1417 Ok(cfg) => cfg,
1418 Err(_) => continue,
1420 };
1421 if per_repo.as_ref().is_some_and(|c| c.ignore) {
1422 continue;
1423 }
1424 let depth = crate::workspace::clamp_depth(
1425 per_repo
1426 .as_ref()
1427 .and_then(|c| c.scan_depth)
1428 .unwrap_or(registry.settings.scan_depth),
1429 );
1430 let mut here: std::collections::BTreeSet<&'static str> = std::collections::BTreeSet::new();
1431 for project in crate::workspace::discover_dormant_opt_in_to_depth(path, depth) {
1432 for adapter in &project.adapters {
1433 here.insert(adapter.name());
1434 }
1435 }
1436 for name in here {
1437 *counts.entry(name).or_insert(0) += 1;
1438 }
1439 }
1440 counts
1441 .into_iter()
1442 .map(|(name, n)| (name.to_string(), n))
1443 .collect()
1444}
1445
1446fn report_recommendations(recommendations: &[(String, usize)]) {
1452 if recommendations.is_empty() {
1453 return;
1454 }
1455 println!();
1456 output::print_header("Detected, but switched off");
1457 for (adapter, repos) in recommendations {
1458 output::print_info(&format!(
1459 " {adapter}: {repos} {}. Enable with: devp config set enable_{adapter} true",
1460 output::plural(*repos, "repository", "repositories")
1461 ));
1462 }
1463 output::print_wrapped(
1464 " ",
1465 "These adapters claim build trees, which come back by recompiling rather than \
1466 re-downloading, so they ship disabled until you opt in. `devp config` shows \
1467 the same switches in a full-screen picker.",
1468 );
1469}
1470
1471fn fail_if_blocked(blocked: &[PruneResult]) -> Result<()> {
1476 if blocked.is_empty() {
1477 return Ok(());
1478 }
1479 anyhow::bail!("{} repositories could not be examined.", blocked.len());
1480}
1481
1482fn report_binaries(candidates: &[PruneResult]) {
1484 let adapter_names: Vec<String> = candidates.iter().map(|c| c.adapter_name.clone()).collect();
1485 let binary_statuses = adapters::scan_required_binaries(&adapter_names);
1486 if binary_statuses.is_empty() {
1487 return;
1488 }
1489 output::print_header(i18n::t("run.header.binaries"));
1490 for b in &binary_statuses {
1491 if b.available {
1492 output::print_success(&format!(
1493 " {} — available ({})",
1494 b.name,
1495 b.version.as_deref().unwrap_or("detected")
1496 ));
1497 } else {
1498 output::print_warning(&format!(
1499 " {} — missing (lockfile fallback active)",
1500 b.name
1501 ));
1502 }
1503 }
1504}
1505
1506fn report_candidates(candidates: &[PruneResult]) {
1507 output::print_header(i18n::t("run.header.candidates"));
1508 for candidate in candidates {
1509 output::print_info(&format!(
1510 " • {} → {} ({}) [{}]{}",
1511 output::styled_path(&candidate.repo_path),
1512 candidate.bloat_dir,
1513 output::format_bytes_styled(candidate.size_freed),
1514 output::styled_adapter(&candidate.adapter_name),
1515 output::shared_note(candidate.shared_bytes, &candidate.adapter_name)
1516 ));
1517 }
1518}
1519
1520pub(crate) fn report_lockfile_failure(result: &PruneResult, error: &str) {
1521 let project = output::clean_path(result.project_dir());
1524
1525 output::print_error(&format!(
1526 "{} → {} lockfile sync failed:\n {}",
1527 project,
1528 result.adapter_name,
1529 error.trim(),
1530 ));
1531 match json::lockfile_fix_command(&result.adapter_name) {
1532 Some(sync_cmd) => {
1533 #[cfg(windows)]
1536 let manual_cmd = format!("cd \"{project}\"; {sync_cmd}");
1537 #[cfg(not(windows))]
1538 let manual_cmd = format!("cd \"{project}\" && {sync_cmd}");
1539 output::print_info(&format!(" Fix command: {manual_cmd}"));
1540 }
1541 None => output::print_info(&format!(" Fix it in: {project}")),
1544 }
1545 output::print_info(&format!(
1546 " Troubleshooting: {}",
1547 constants::TROUBLESHOOTING_URL
1548 ));
1549}
1550
1551fn run_explain(args: &RunArgs<'_>, filter: &AdapterFilter) -> Result<()> {
1562 output::print_header(i18n::t("run.header.reasons"));
1563 if let Some(desc) = filter.describe() {
1564 output::print_info(&format!("Adapter filter: {desc}"));
1565 }
1566
1567 if let Some(target_str) = args.target_path {
1568 let raw = Path::new(target_str);
1569 let path = if raw.exists() {
1570 raw.canonicalize().unwrap_or_else(|_| raw.to_path_buf())
1571 } else {
1572 raw.to_path_buf()
1573 };
1574 if !crate::scanner::is_git_repo(&path) {
1575 anyhow::bail!(
1576 "{} is not a Git repository — dev-prune only prunes Git repos.",
1577 output::clean_path(&path)
1578 );
1579 }
1580 let registry = Registry::load().ok();
1581 let idle_days = registry
1582 .as_ref()
1583 .map(|r| {
1584 r.repositories
1585 .get(&path)
1586 .and_then(|e| e.override_idle_days)
1587 .unwrap_or(r.settings.idle_days)
1588 })
1589 .unwrap_or(constants::DEFAULT_IDLE_DAYS);
1590 let floor = resolve_min_size(args, registry.as_ref());
1591 let results = engine::prune_repo_with(
1592 &path,
1593 &PruneOptions {
1594 idle_days,
1595 dry_run: true,
1596 force: args.force,
1597 only_dirs: None,
1598 adapters: filter.clone(),
1599 min_size_bytes: 0,
1600 scan_depth: resolve_scan_depth(registry.as_ref()),
1601 allow_manifest_rewrite: resolve_manifest_rewrite(registry.as_ref()),
1602 command_timeout_secs: resolve_command_timeout(registry.as_ref()),
1603 build_idle_days: resolve_build_idle_days(registry.as_ref()),
1604 adapter_idle_days: resolve_adapter_idle_days(registry.as_ref()),
1605 },
1606 );
1607 let refs: Vec<&PruneResult> = results.iter().collect();
1608 explain_repo(&path, &refs, floor, idle_days);
1609 print_explain_footer();
1610 return Ok(());
1611 }
1612
1613 let mut registry = Registry::load()?;
1614 if registry.repo_count() == 0 {
1615 output::print_warning("No repositories registered. Run `dev-prune init` first.");
1616 return Ok(());
1617 }
1618
1619 let except = parse_except(args.except);
1620 let global_floor = resolve_min_size(args, Some(®istry));
1621 let analysis = PruneOptions {
1622 idle_days: 0, dry_run: true,
1624 force: args.force,
1625 only_dirs: None,
1626 adapters: filter.clone(),
1627 min_size_bytes: 0,
1628 scan_depth: resolve_scan_depth(Some(®istry)),
1629 allow_manifest_rewrite: resolve_manifest_rewrite(Some(®istry)),
1630 command_timeout_secs: resolve_command_timeout(Some(®istry)),
1631 build_idle_days: resolve_build_idle_days(Some(®istry)),
1632 adapter_idle_days: resolve_adapter_idle_days(Some(®istry)),
1633 };
1634 let results = engine::prune_all_with(&mut registry, &analysis);
1635
1636 let mut by_repo: std::collections::HashMap<&Path, Vec<&PruneResult>> =
1637 std::collections::HashMap::new();
1638 for r in &results {
1639 by_repo.entry(r.repo_path.as_path()).or_default().push(r);
1640 }
1641
1642 let mut repos: Vec<&std::path::PathBuf> = registry.repositories.keys().collect();
1643 repos.sort();
1644 for path in repos {
1645 if is_excepted(path, &except) {
1646 println!();
1647 output::print_info(&output::clean_path(path));
1648 println!(" • left completely alone this pass (`--except`)");
1649 continue;
1650 }
1651 let idle_days = registry
1652 .repositories
1653 .get(path)
1654 .and_then(|e| e.override_idle_days)
1655 .unwrap_or(registry.settings.idle_days);
1656 let empty = Vec::new();
1657 let repo_results = by_repo.get(path.as_path()).unwrap_or(&empty);
1658 explain_repo(path, repo_results, global_floor, idle_days);
1659 }
1660 print_explain_footer();
1661 Ok(())
1662}
1663
1664fn explain_repo(path: &Path, results: &[&PruneResult], floor: u64, idle_days: u64) {
1666 println!();
1667 output::print_info(&output::clean_path(path));
1668
1669 if results.is_empty() {
1670 println!(
1671 " • idle, but no known bloat directories were found. A project deeper than \
1672 `scan_depth` is not examined — `devp status` shows what dev-prune can see."
1673 );
1674 return;
1675 }
1676
1677 for r in results {
1678 match &r.status {
1679 PruneStatus::SkippedDryRun => {
1680 if r.size_freed >= floor {
1681 output::print_success(&format!(
1682 "would prune {} ({}) [{}]{}",
1683 r.bloat_dir,
1684 output::format_bytes(r.size_freed),
1685 r.adapter_name,
1686 output::shared_note(r.shared_bytes, &r.adapter_name)
1687 ));
1688 } else {
1689 println!(
1690 " • {} ({}) is under the size floor of {} — the reinstall would \
1691 cost more than the space is worth. `--min-size 0` includes it.",
1692 r.bloat_dir,
1693 output::format_bytes(r.size_freed),
1694 output::format_bytes(floor)
1695 );
1696 }
1697 }
1698 PruneStatus::SkippedActive => {
1699 let age = crate::scanner::git::get_last_activity(path)
1700 .ok()
1701 .flatten()
1702 .and_then(|t| std::time::SystemTime::now().duration_since(t).ok())
1703 .map(|d| d.as_secs() / 86_400);
1704 match age {
1705 Some(0) => println!(
1706 " • active — there was activity today, and the idle \
1707 threshold is {idle_days} days. `--ignore-idle` overrides."
1708 ),
1709 Some(days) => println!(
1710 " • active — last activity {days} day{} ago, and the idle \
1711 threshold is {idle_days} days. `--ignore-idle` overrides.",
1712 if days == 1 { "" } else { "s" }
1713 ),
1714 None => println!(
1715 " • active (not idle for {idle_days} days yet). \
1716 `--ignore-idle` overrides."
1717 ),
1718 }
1719 }
1720 PruneStatus::SkippedAdapterWindow(days) => println!(
1721 " • {}: not examined — this adapter waits for {days} idle days \
1722 (`build_idle_days` / `adapter_idle_days`), and the repository is not \
1723 idle that long yet. `--ignore-idle` overrides.",
1724 r.adapter_name
1725 ),
1726 other => println!(" • {other}"),
1727 }
1728 }
1729}
1730
1731fn print_explain_footer() {
1733 println!();
1734 output::print_info(
1735 "Nothing was verified or deleted. `devp run --dry-run` verifies candidates; \
1736 `devp run` prunes.",
1737 );
1738}
1739
1740#[cfg(test)]
1741mod tests {
1742 use super::*;
1743
1744 #[test]
1745 fn gits_ownership_refusal_is_recognised_whatever_the_path() {
1746 let message = "git could not read `V:/x`: fatal: detected dubious ownership in repository \
1750 at 'V:/x'";
1751 assert_eq!(
1752 ActivityFailure::classify(message),
1753 ActivityFailure::UntrustedOwner
1754 );
1755 }
1756
1757 #[test]
1758 fn a_path_that_lost_its_git_directory_is_its_own_cause() {
1759 let message = "fatal: not a git repository (or any of the parent directories): .git";
1763 assert_eq!(
1764 ActivityFailure::classify(message),
1765 ActivityFailure::NotARepository
1766 );
1767 }
1768
1769 #[test]
1770 fn an_unfamiliar_failure_is_still_printed_in_full() {
1771 assert_eq!(
1772 ActivityFailure::classify("fatal: unable to read tree"),
1773 ActivityFailure::Individual
1774 );
1775 }
1776}