Skip to main content

dev_prune/commands/
run.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Handler for the `dev-prune run` command.
5//
6// Executes a full prune pass across all registered repositories.
7// Supports pre-deletion analysis, optimized ecosystem binary pre-checks,
8// interactive TUI multi-selection, progressive deletion, shell-specific
9// troubleshooting, and interactive error fallback.
10
11use 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
23/// Everything `devp run` was asked to do.
24///
25/// A struct rather than nine positional parameters: the call site in `run_cli` reads as
26/// a list of names, and adding a flag does not silently shift an argument.
27pub struct RunArgs<'a> {
28    /// Optional single workspace to act on instead of the whole registry.
29    pub target_path: Option<&'a str>,
30    /// Report sizes and stop.
31    pub dry_run: bool,
32    /// Bypass the idle threshold. Lockfile verification still applies.
33    pub force: bool,
34    /// Skip the confirmation prompt.
35    pub yes: bool,
36    /// This is the scheduled background pass.
37    pub daemon: bool,
38    /// Comma-separated adapters to act on exclusively.
39    pub only: Option<&'a str>,
40    /// Comma-separated adapters to leave alone.
41    pub skip: Option<&'a str>,
42    /// Size floor in MiB, overriding the configured `min_size_mb`.
43    pub min_size_mb: Option<u64>,
44    /// Comma-separated repositories to leave completely alone this pass.
45    pub except: Option<&'a str>,
46    /// Emit one JSON document instead of the human report.
47    pub json: bool,
48    /// Explain every decision and touch nothing.
49    pub explain: bool,
50}
51
52/// Run the `run` command — prune all registered repos or a specific target directory (`devp run .`).
53///
54/// `daemon` marks the scheduled background pass; repositories that set `disable_daemon`
55/// in `.devprune.json` are excluded from it but remain pruneable by hand.
56pub fn run(args: RunArgs<'_>) -> Result<()> {
57    let filter = AdapterFilter::new(args.only, args.skip)?;
58
59    // In JSON mode there is no one to answer a prompt and no terminal to draw a selector
60    // in, so deletion has to have been authorised on the command line. Failing loudly
61    // beats either silently deleting or silently doing nothing.
62    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
86/// What `--ignore-idle` does and, more usefully, what it does not.
87///
88/// Printed whenever the idle check is bypassed, because that is the moment someone is
89/// most likely to be working around a problem rather than solving it — and the problem
90/// they hit is almost always one of the three below. Suppressed in JSON mode, where the
91/// document is the contract and prose on stdout would corrupt it.
92fn 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
116/// `devp run <PATH>` — one workspace, no registry, no selector.
117fn 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        // Returning Ok here made `devp run <path>` exit 0 on a path it refused to
128        // touch, which is invisible to any script or CI step checking the status.
129        anyhow::bail!("{clean} is not a Git repository — dev-prune only prunes Git repos.");
130    }
131
132    // A targeted run still respects the configured idle threshold. Passing 0 here
133    // would make every repo look idle and silently defeat the guard — `--ignore-idle` is
134    // the documented way to prune a repo you are actively working in.
135    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    // A directory that could not be verified or deleted is a failure of the command,
163    // whichever output mode asked for it. `devp run <path>` used to exit 0 after a
164    // lockfile or delete error, which a script or CI step has no way to notice.
165    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    // Recorded before the output branches, so `--json` and the human report leave the
179    // same registry behind. A targeted run used to update neither the lifetime totals nor
180    // anything `restore` could read: `devp run .` freed two gigabytes and `devp status`
181    // still said nothing had ever been pruned.
182    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
269/// Persist what a targeted run deleted: the lifetime totals and the `--last-run` record.
270///
271/// Silent on every failure. The directories are already gone by the time this is called,
272/// and a registry that could not be written is not a reason to report the prune itself as
273/// failed — it only costs the user `devp restore --last-run` for this one pass.
274fn record_targeted_prune(path: &std::path::Path, results: &[PruneResult], dry_run: bool) {
275    if dry_run {
276        return;
277    }
278
279    // A DeleteError with a non-zero size_freed is a delete that got half-way: the
280    // directory is corrupt, not intact, so `restore --last-run` must know to rebuild it.
281    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
308/// The size floor for this pass: `--min-size` if given, otherwise the global setting.
309///
310/// A per-repository `min_size_mb` still wins over both — that decision belongs to the
311/// repository and is applied inside the engine.
312fn 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
320/// Repositories named by `--except`, as a set of lowercased names and path fragments.
321///
322/// Empty when the flag was not passed.
323///
324/// Each entry is tilde-expanded first, because a comma-separated list arrives as one
325/// argument and no shell expands a `~` sitting in the middle of it — not even bash.
326fn 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
340/// Whether `--except` names this repository.
341///
342/// Matched three ways because there are three things a user reasonably types: the folder
343/// name (`api`), a path fragment (`work/api`), or the full path they see in `devp status`.
344/// Case-insensitive, and `/` and `\` are treated as the same separator, so the flag
345/// behaves the same in PowerShell and in bash.
346fn 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
364/// The global scan depth, falling back to the default when there is no registry yet.
365fn 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
371/// The idle window for adapters holding compiler output, in days.
372fn 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
378/// The user's per-adapter idle windows, empty when there is no registry yet.
379fn 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
393/// Whether an adapter may run its lockfile-rewriting sync command.
394fn 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
400/// `devp run` — the full pass over every registered repository.
401fn 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    // Suppressed in JSON mode: the document is a contract, and a version notice printed
413    // into it would corrupt the output.
414    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    // Validated against the registry *before* anything is analysed. A name that matches
427    // nothing is a typo, and the cost of a silent typo here is the one repository the
428    // user was trying to protect getting pruned — so it is an error, not a no-op.
429    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(&registry));
454    let analysis = PruneOptions {
455        idle_days: 0, // replaced per repository from the registry
456        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(&registry)),
462        allow_manifest_rewrite: resolve_manifest_rewrite(Some(&registry)),
463        command_timeout_secs: resolve_command_timeout(Some(&registry)),
464        build_idle_days: resolve_build_idle_days(Some(&registry)),
465        adapter_idle_days: resolve_adapter_idle_days(Some(&registry)),
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    // Pre-run analysis (dry-run mode first to compute exact savings)
485    //
486    // Two lists come out of it, and both are reported. A repository the analysis refused
487    // to examine — an unreadable `.devprune.json`, most often — used to be dropped here
488    // along with every other non-candidate state, so a pass that had quietly skipped it
489    // still ended on "No idle repositories or pruneable bloat directories found." and
490    // exit 0. The execution loop further down knows how to report these states, but it
491    // only ever sees selected candidates, so it never got the chance.
492    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        // An excepted repository leaves the pass entirely — including its failures. The
498        // user said not to touch it, so a broken config in there is not this run's
499        // problem and must not fail an otherwise clean exit code.
500        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            // Reported, never failed on: the link is permanent and deliberate, and a
510            // "failure" here made every scheduled pass over the repo exit 1 forever.
511            PruneStatus::SkippedSymlink(_) => linked.push(result),
512            // Same reasoning: a deleted clone stays deleted, and failing on it would
513            // keep every scheduled pass red until the entry is unlinked.
514            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            // An unreadable config drops the candidate. The engine already refuses such a
527            // repository outright, so this cannot fire today; if that ever changes, the
528            // unattended pass must not be the code path that guesses.
529            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    // A dry run stops here in both output modes: sizes are known, nothing was verified.
544    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        // Reported, but not an error: a dry run's job is to say what it found, and it
567        // found this too.
568        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    // Determine target candidates to prune (either interactive TUI selection or all).
608    // `--json` short-circuits both: it was already required to carry `--yes`.
609    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        // Reaching here means stdout is piped. If stdin is too, there is nobody to
628        // answer: the read hits EOF at once, and the old code then reported "aborted by
629        // user" about a user who was never asked. Failing with the fix beats that.
630        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        // The question goes to stderr: stdout is a pipe here, and a prompt written into
645        // it is invisible on the terminal — the command just appears to hang.
646        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    // Execute deletion ONLY on the selected bloat directories.
673    //
674    // The selector works per bloat directory, so group the selection by repo and pass
675    // the chosen directory names down — pruning the whole repo would delete dirs the
676    // user explicitly unticked.
677    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    // Seeded with what the analysis pass could not get past. Those repositories belong in
692    // the document and in the exit code exactly as much as a failure from the loop below.
693    // Symlinked directories ride along for the document only — they are not errors.
694    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    // One timestamp identifies the whole pass, so every incremental save below
702    // supersedes the previous one instead of counting as its own pass.
703    let pass_at = chrono::Utc::now();
704
705    for (repo_path, dirs) in &selection {
706        let recorded_before = pruned_dirs.len();
707        // The idle check runs again here, not just at analysis: the selector can sit
708        // open for hours, and a repository someone started working in between analysis
709        // and Enter must not be pruned on the strength of a stale answer. Only
710        // `--ignore-idle` skips it, exactly as it skipped the first check.
711        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                    // A non-zero size_freed on a delete error means the delete got
775                    // half-way: the directory is corrupt, not intact. Record it so
776                    // `devp restore --last-run` knows to rebuild it — while the error
777                    // above still fails the pass.
778                    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                // The repo saw activity between analysis and execution — the re-check
809                // above caught it. A protective skip, not a failure.
810                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        // Persisted after every repository, not once at the end. A pass killed
823        // half-way through used to leave the registry describing the *previous*
824        // pass, so `devp restore --last-run` offered to reinstall directories that
825        // were never deleted and said nothing about the ones that were. A save
826        // failure here is silent — the final save below reports it.
827        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        // The document already carries `summary.errors`; a non-zero exit keeps the
839        // shell contract identical in both output modes.
840        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        // Only when a lockfile was actually the problem. `error_count` also counts
856        // unreadable configs and failed deletions, and a lecture about lockfiles in front
857        // of a JSON syntax error sends the user to the wrong file.
858        if all_results
859            .iter()
860            .any(|r| matches!(r.status, PruneStatus::LockfileError(_)))
861        {
862            // Lockfile enforcement is not overridable — `--ignore-idle` only bypasses the idle
863            // check. Without a lockfile a deleted dependency tree cannot be rebuilt, so
864            // point at the fix instead of offering an override that does not exist.
865            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        // Exit non-zero so a scheduled or scripted run surfaces the failure.
872        anyhow::bail!("{error_count} repositories could not be pruned.");
873    }
874
875    // After the pass, never before it: an upgrade mid-run would swap the binary out
876    // from under the work the user actually asked for.
877    crate::commands::update::maybe_auto_update(&registry);
878
879    Ok(())
880}
881
882/// Why a repository's activity could not be read, when the reason is one that every
883/// affected repository shares.
884///
885/// Git prints its "dubious ownership" refusal as twelve lines, ten of which are word for
886/// word identical for every repository it refuses — the same explanation, the same two
887/// account identifiers, the same `git config` invitation. On a machine where one Windows
888/// reinstall left twenty-one repositories with a stale owner, printing that per
889/// repository buries the only line that differs (the path) in two hundred that do not.
890/// One cause with one fix should read as one paragraph, however many repositories it
891/// covers.
892#[derive(Debug, PartialEq, Eq, Clone, Copy)]
893enum ActivityFailure {
894    /// Git refuses the working tree because it is owned by another account.
895    UntrustedOwner,
896    /// The registered path is no longer a working tree.
897    NotARepository,
898    /// Anything else: reported individually, with git's own words.
899    Individual,
900}
901
902impl ActivityFailure {
903    /// Classify one activity-check failure from Git's own stderr.
904    ///
905    /// Deliberately a substring match on Git's wording rather than a parse. The
906    /// alternative is asking Git a second question per repository, and the cost of a
907    /// wrong guess here is a message that reads slightly less well — never a wrong
908    /// deletion, because a repository in this list is one nothing was done to.
909    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
921/// How many paths a grouped cause lists before it stops and says how many are left.
922///
923/// Eight is enough to recognise a pattern — one directory tree, one old drive — without
924/// the list becoming the thing that has to be scrolled past.
925const GROUPED_PATHS_SHOWN: usize = 8;
926
927/// Report the repositories the analysis pass could not get past, with the fix for each.
928///
929/// Silent for an empty list, so callers do not have to guard it.
930fn 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        // Not `--missing`: that clears entries whose *directory* has gone, and these
995        // directories are still on disk. Naming the wrong repair here would have the
996        // user run a command that reports it removed nothing.
997        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            // Everything else was covered by one of the grouped causes above.
1030            _ => {}
1031        }
1032    }
1033}
1034
1035/// Print the paths of one grouped cause, indented, stopping at [`GROUPED_PATHS_SHOWN`].
1036///
1037/// `--json` is named as the way to see the rest rather than a `--verbose` flag, because
1038/// it already lists every result and is the output a script would be reading anyway.
1039fn 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
1054/// Report bloat directories that are symlinks and were deliberately left alone.
1055///
1056/// Informational only, never part of the exit code: the storage a link points at is
1057/// not this repository's to delete, the state is permanent, and failing on it would
1058/// turn every scheduled pass over such a repo red forever.
1059fn 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
1071/// Report registered paths that no longer exist on disk.
1072///
1073/// Informational only, never part of the exit code: the clone is already gone, the state
1074/// does not fix itself, and failing on it would keep every scheduled pass red until the
1075/// user notices. The fix is one command, so name it.
1076fn 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    // One line per path was fine for the one or two a person deletes by hand. It stopped
1088    // being fine the first time a tool that clones into a temporary directory registered
1089    // thirty of them: the report ended in thirty near-identical lines carrying one
1090    // instruction, repeated thirty times.
1091    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
1109/// Turn a non-empty blocked list into the process's failure exit.
1110///
1111/// A pass that skipped a repository the user asked it to handle has not succeeded, and a
1112/// scheduled or scripted run has to be able to see that.
1113fn 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
1120/// Report which ecosystem binaries the pass will need and whether they are present.
1121fn 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    // The project directory, not the repository root: a monorepo reports
1160    // `backend/.venv`, and `uv lock` at the root would not fix it.
1161    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            // `;` on PowerShell, `&&` on a POSIX shell — pasted, either has to work as
1172            // typed or the `cd` is decoration.
1173            #[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        // venv, gradle, maven and swift have no mechanical fix — saying where still
1180        // beats sending someone to the repository root to go looking.
1181        None => output::print_info(&format!("  Fix it in:         {project}")),
1182    }
1183    output::print_info(&format!(
1184        "  Troubleshooting:   {}",
1185        constants::TROUBLESHOOTING_URL
1186    ));
1187}
1188
1189/// `devp run --explain` — the decision for every repository and directory, with
1190/// nothing done.
1191///
1192/// The prune pass keeps quiet about the states that are not its job to fix — a
1193/// repository still active, one opted out, a directory under the size floor — which is
1194/// exactly what someone staring at "no candidates found" needs to hear about. This mode
1195/// runs the same analysis and reports every verdict instead of only the actionable
1196/// ones. Read-only by construction: the engine runs in dry-run mode, and the size floor
1197/// is applied here in the report rather than in the engine, so a too-small directory is
1198/// named as too small instead of silently missing.
1199fn 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(&registry));
1259    let analysis = PruneOptions {
1260        idle_days: 0, // replaced per repository from the registry
1261        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(&registry)),
1267        allow_manifest_rewrite: resolve_manifest_rewrite(Some(&registry)),
1268        command_timeout_secs: resolve_command_timeout(Some(&registry)),
1269        build_idle_days: resolve_build_idle_days(Some(&registry)),
1270        adapter_idle_days: resolve_adapter_idle_days(Some(&registry)),
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
1302/// One repository's verdicts, one line per decision.
1303fn 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
1363/// The one-line contract of `--explain`, printed after the verdicts.
1364fn 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        // The whole grouped report hangs off this substring. If Git ever reworded the
1379        // message, twenty-one repositories would silently go back to printing twelve
1380        // lines each, and nothing else in the suite would notice.
1381        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        // Deliberately distinct from UntrustedOwner: the two have different fixes, and
1391        // pointing the user at `devp unlink --missing` for a directory that still exists
1392        // is a command that reports it removed nothing.
1393        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}