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::i18n;
20use crate::json;
21use crate::output;
22use crate::tui;
23
24/// Everything `devp run` was asked to do.
25///
26/// A struct rather than nine positional parameters: the call site in `run_cli` reads as
27/// a list of names, and adding a flag does not silently shift an argument.
28pub struct RunArgs<'a> {
29    /// Optional single workspace to act on instead of the whole registry.
30    pub target_path: Option<&'a str>,
31    /// Report sizes and stop.
32    pub dry_run: bool,
33    /// Bypass the idle threshold. Lockfile verification still applies.
34    pub force: bool,
35    /// Skip the confirmation prompt.
36    pub yes: bool,
37    /// This is the scheduled background pass.
38    pub daemon: bool,
39    /// Comma-separated adapters to act on exclusively.
40    pub only: Option<&'a str>,
41    /// Comma-separated adapters to leave alone.
42    pub skip: Option<&'a str>,
43    /// Size floor in MiB, overriding the configured `min_size_mb`.
44    pub min_size_mb: Option<u64>,
45    /// Comma-separated repositories to leave completely alone this pass.
46    pub except: Option<&'a str>,
47    /// Emit one JSON document instead of the human report.
48    pub json: bool,
49    /// Explain every decision and touch nothing.
50    pub explain: bool,
51}
52
53/// Run the `run` command — prune all registered repos or a specific target directory (`devp run .`).
54///
55/// `daemon` marks the scheduled background pass; repositories that set `disable_daemon`
56/// in `.devprune.json` are excluded from it but remain pruneable by hand.
57pub fn run(args: RunArgs<'_>) -> Result<()> {
58    let filter = AdapterFilter::new(args.only, args.skip)?;
59
60    // In JSON mode there is no one to answer a prompt and no terminal to draw a selector
61    // in, so deletion has to have been authorised on the command line. Failing loudly
62    // beats either silently deleting or silently doing nothing.
63    if args.json && !args.dry_run && !args.yes {
64        return Err(anyhow::Error::new(crate::UsageError(
65            "`--json` cannot ask for confirmation. Pass `--dry-run` to analyse, or `--yes` to delete."
66                .to_string(),
67        )));
68    }
69
70    if !args.json {
71        output::print_banner();
72        if args.force {
73            print_ignore_idle_notice();
74        }
75    }
76
77    if args.explain {
78        return run_explain(&args, &filter);
79    }
80
81    if let Some(target_str) = args.target_path {
82        return run_targeted(&args, &filter, target_str);
83    }
84    run_registry(&args, &filter)
85}
86
87/// What `--ignore-idle` does and, more usefully, what it does not.
88///
89/// Printed whenever the idle check is bypassed, because that is the moment someone is
90/// most likely to be working around a problem rather than solving it — and the problem
91/// they hit is almost always one of the three below. Suppressed in JSON mode, where the
92/// document is the contract and prose on stdout would corrupt it.
93fn print_ignore_idle_notice() {
94    output::print_warning(
95        "Idle check bypassed — repositories you are working in right now are fair game.",
96    );
97    println!(
98        "  Still enforced: lockfile verification, `ignore.devprune.json`, `\"ignore\": true`,"
99    );
100    println!(
101        "  symlinked directories, and nested repositories. This flag does not turn those off."
102    );
103    println!();
104    println!("  If you reached for this because something would not prune, it is usually:");
105    println!("    • \"lockfile verification failed\"  → run the fix command printed next to it;");
106    println!("      it regenerates the lockfile so the reinstall is guaranteed to work.");
107    println!("    • nothing listed at all            → the project is deeper than `scan_depth`,");
108    println!("      or under `min_size_mb`. Try `devp status` to see what dev-prune can see.");
109    println!("    • \"could not be examined\"          → `.devprune.json` has a syntax error.");
110    println!();
111    println!("  Still stuck? Point your AI assistant at the bundled skill — `devp skill`");
112    println!("  exports a SKILL.md that teaches it this tool, exit codes and all. It has");
113    println!("  read the manual more recently than either of us.");
114    println!();
115}
116
117/// `devp run <PATH>` — one workspace, no registry, no selector.
118fn run_targeted(args: &RunArgs<'_>, filter: &AdapterFilter, target_str: &str) -> Result<()> {
119    let raw = std::path::Path::new(target_str);
120    let path = if raw.exists() {
121        raw.canonicalize().unwrap_or_else(|_| raw.to_path_buf())
122    } else {
123        raw.to_path_buf()
124    };
125
126    let clean = output::clean_path(&path);
127    if !crate::scanner::is_git_repo(&path) {
128        // Returning Ok here made `devp run <path>` exit 0 on a path it refused to
129        // touch, which is invisible to any script or CI step checking the status.
130        anyhow::bail!("{clean} is not a Git repository — dev-prune only prunes Git repos.");
131    }
132
133    // A targeted run still respects the configured idle threshold. Passing 0 here
134    // would make every repo look idle and silently defeat the guard — `--ignore-idle` is
135    // the documented way to prune a repo you are actively working in.
136    let registry = Registry::load().ok();
137    let idle_days = registry
138        .as_ref()
139        .map(|r| {
140            r.repositories
141                .get(&path)
142                .and_then(|e| e.override_idle_days)
143                .unwrap_or(r.settings.idle_days)
144        })
145        .unwrap_or(constants::DEFAULT_IDLE_DAYS);
146
147    let opts = PruneOptions {
148        idle_days,
149        dry_run: args.dry_run,
150        force: args.force,
151        only_dirs: None,
152        adapters: filter.clone(),
153        min_size_bytes: resolve_min_size(args, registry.as_ref()),
154        scan_depth: resolve_scan_depth(registry.as_ref()),
155        allow_manifest_rewrite: resolve_manifest_rewrite(registry.as_ref()),
156        command_timeout_secs: resolve_command_timeout(registry.as_ref()),
157        build_idle_days: resolve_build_idle_days(registry.as_ref()),
158        adapter_idle_days: resolve_adapter_idle_days(registry.as_ref()),
159    };
160
161    let results = engine::prune_repo_with(&path, &opts);
162
163    // A directory that could not be verified or deleted is a failure of the command,
164    // whichever output mode asked for it. `devp run <path>` used to exit 0 after a
165    // lockfile or delete error, which a script or CI step has no way to notice.
166    let error_count = results
167        .iter()
168        .filter(|r| {
169            matches!(
170                r.status,
171                PruneStatus::LockfileError(_)
172                    | PruneStatus::ActivityCheckError(_)
173                    | PruneStatus::DeleteError(_)
174                    | PruneStatus::ConfigError(_)
175            )
176        })
177        .count();
178
179    // Recorded before the output branches, so `--json` and the human report leave the
180    // same registry behind. A targeted run used to update neither the lifetime totals nor
181    // anything `restore` could read: `devp run .` freed two gigabytes and `devp status`
182    // still said nothing had ever been pruned.
183    record_targeted_prune(&path, &results, args.dry_run);
184
185    if args.json {
186        json::emit(&json::run_document(&results, args.dry_run))?;
187        if error_count > 0 {
188            anyhow::bail!("{error_count} directories in {clean} could not be pruned.");
189        }
190        return Ok(());
191    }
192
193    output::print_header(&i18n::tf(
194        "run.header.targeted",
195        &[("path", clean.as_str())],
196    ));
197    if let Some(desc) = filter.describe() {
198        output::print_info(&format!("Adapter filter: {desc}"));
199    }
200
201    if results.is_empty() {
202        output::print_info(&i18n::tf(
203            "run.nothing.bloat.targeted",
204            &[("path", clean.as_str())],
205        ));
206        return Ok(());
207    }
208
209    let mut total_freed = 0;
210    for result in results {
211        match &result.status {
212            PruneStatus::Pruned => {
213                total_freed += result.size_freed;
214                output::print_success(&format!(
215                    "{} → {} ({}) — {}{}",
216                    output::clean_path(&result.repo_path),
217                    result.bloat_dir,
218                    output::format_bytes(result.size_freed),
219                    result.adapter_name,
220                    output::shared_note(result.shared_bytes, &result.adapter_name)
221                ));
222            }
223            PruneStatus::SkippedDryRun => {
224                output::print_info(&format!(
225                    "  • {} → {} ({}) [{}] (Dry Run){}",
226                    output::clean_path(&result.repo_path),
227                    result.bloat_dir,
228                    output::format_bytes(result.size_freed),
229                    result.adapter_name,
230                    output::shared_note(result.shared_bytes, &result.adapter_name)
231                ));
232            }
233            PruneStatus::SkippedActive => {
234                output::print_info(&format!(
235                    "{clean} is currently active (not idle). Use `devp --ignore-idle run` to override."
236                ));
237            }
238            PruneStatus::LockfileError(e) => report_lockfile_failure(&result, e),
239            PruneStatus::ActivityCheckError(e) => {
240                output::print_error(&format!(
241                    "{clean} skipped — its activity could not be determined:\n    {}",
242                    e.trim()
243                ));
244            }
245            PruneStatus::DeleteError(e) => {
246                output::print_error(&format!("{clean} delete error: {e}"));
247            }
248            PruneStatus::ConfigError(e) => {
249                output::print_error(&format!(
250                    "{clean} skipped — its .devprune.json could not be read:\n    {}\n    \
251                     Fix it, or run `devp config {clean} --update` to reset it.",
252                    e.trim()
253                ));
254            }
255            PruneStatus::SkippedSymlink(e) => {
256                output::print_warning(&format!("{clean} → {}", e.trim()));
257            }
258            PruneStatus::SkippedDeclaration(e) => {
259                output::print_warning(&format!("{clean} → {}", e.trim()));
260            }
261            _ => {}
262        }
263    }
264
265    if !args.dry_run && total_freed > 0 {
266        output::print_success(&i18n::tf(
267            "run.freed.targeted",
268            &[
269                ("size", &output::format_bytes(total_freed)),
270                ("path", clean.as_str()),
271            ],
272        ));
273    }
274
275    if error_count > 0 {
276        anyhow::bail!("{error_count} directories in {clean} could not be pruned.");
277    }
278
279    Ok(())
280}
281
282/// Persist what a targeted run deleted: the lifetime totals and the `--last-run` record.
283///
284/// Silent on every failure. The directories are already gone by the time this is called,
285/// and a registry that could not be written is not a reason to report the prune itself as
286/// failed — it only costs the user `devp restore --last-run` for this one pass.
287fn record_targeted_prune(path: &std::path::Path, results: &[PruneResult], dry_run: bool) {
288    if dry_run {
289        return;
290    }
291
292    // A DeleteError with a non-zero size_freed is a delete that got half-way: the
293    // directory is corrupt, not intact, so `restore --last-run` must know to rebuild it.
294    let pruned: Vec<crate::config::PrunedDir> = results
295        .iter()
296        .filter(|r| {
297            matches!(r.status, PruneStatus::Pruned)
298                || (matches!(r.status, PruneStatus::DeleteError(_)) && r.size_freed > 0)
299        })
300        .map(|r| crate::config::PrunedDir {
301            repo_path: r.repo_path.clone(),
302            bloat_dir: r.bloat_dir.clone(),
303            adapter: r.adapter_name.clone(),
304            size_freed: r.size_freed,
305            runtime: r.runtime.clone(),
306        })
307        .collect();
308
309    if pruned.is_empty() {
310        return;
311    }
312
313    let freed: u64 = pruned.iter().map(|d| d.size_freed).sum();
314    if let Ok(mut registry) = Registry::load() {
315        registry.mark_pruned(path, freed);
316        registry.record_prune(pruned);
317        let _ = registry.save();
318    }
319}
320
321/// The size floor for this pass: `--min-size` if given, otherwise the global setting.
322///
323/// A per-repository `min_size_mb` still wins over both — that decision belongs to the
324/// repository and is applied inside the engine.
325fn resolve_min_size(args: &RunArgs<'_>, registry: Option<&Registry>) -> u64 {
326    let mb = args
327        .min_size_mb
328        .or_else(|| registry.map(|r| r.settings.min_size_mb))
329        .unwrap_or(constants::DEFAULT_MIN_SIZE_MB);
330    mb.saturating_mul(engine::BYTES_PER_MIB)
331}
332
333/// Repositories named by `--except`, as a set of lowercased names and path fragments.
334///
335/// Empty when the flag was not passed.
336///
337/// Each entry is tilde-expanded first, because a comma-separated list arrives as one
338/// argument and no shell expands a `~` sitting in the middle of it — not even bash.
339fn parse_except(spec: Option<&str>) -> Vec<String> {
340    spec.map(|s| {
341        s.split(',')
342            .map(|part| {
343                crate::config::expand_tilde(part.trim())
344                    .trim_end_matches(['/', '\\'])
345                    .to_lowercase()
346            })
347            .filter(|part| !part.is_empty())
348            .collect()
349    })
350    .unwrap_or_default()
351}
352
353/// Whether `--except` names this repository.
354///
355/// Matched three ways because there are three things a user reasonably types: the folder
356/// name (`api`), a path fragment (`work/api`), or the full path they see in `devp status`.
357/// Case-insensitive, and `/` and `\` are treated as the same separator, so the flag
358/// behaves the same in PowerShell and in bash.
359fn is_excepted(repo_path: &Path, except: &[String]) -> bool {
360    if except.is_empty() {
361        return false;
362    }
363    let full = output::clean_path(repo_path)
364        .to_lowercase()
365        .replace('\\', "/");
366    let name = repo_path
367        .file_name()
368        .map(|n| n.to_string_lossy().to_lowercase())
369        .unwrap_or_default();
370
371    except.iter().any(|want| {
372        let want = want.replace('\\', "/");
373        name == want || full == want || full.ends_with(&format!("/{want}"))
374    })
375}
376
377/// The global scan depth, falling back to the default when there is no registry yet.
378fn resolve_scan_depth(registry: Option<&Registry>) -> usize {
379    registry
380        .map(|r| r.settings.scan_depth)
381        .unwrap_or(constants::DEFAULT_SCAN_DEPTH)
382}
383
384/// The idle window for adapters holding compiler output, in days.
385fn resolve_build_idle_days(registry: Option<&Registry>) -> u64 {
386    registry
387        .map(|r| r.settings.build_idle_days)
388        .unwrap_or(constants::DEFAULT_BUILD_IDLE_DAYS)
389}
390
391/// The user's per-adapter idle windows, empty when there is no registry yet.
392fn resolve_adapter_idle_days(
393    registry: Option<&Registry>,
394) -> std::collections::BTreeMap<String, u64> {
395    registry
396        .map(|r| r.settings.adapter_idle_days.clone())
397        .unwrap_or_default()
398}
399
400fn resolve_command_timeout(registry: Option<&Registry>) -> u64 {
401    registry
402        .map(|r| r.settings.command_timeout_secs)
403        .unwrap_or(constants::DEFAULT_COMMAND_TIMEOUT_SECS)
404}
405
406/// Whether an adapter may run its lockfile-rewriting sync command.
407fn resolve_manifest_rewrite(registry: Option<&Registry>) -> bool {
408    registry
409        .map(|r| r.settings.allow_manifest_rewrite)
410        .unwrap_or(constants::DEFAULT_ALLOW_MANIFEST_REWRITE)
411}
412
413/// `devp run` — the full pass over every registered repository.
414fn run_registry(args: &RunArgs<'_>, filter: &AdapterFilter) -> Result<()> {
415    if !args.json {
416        if args.dry_run {
417            output::print_header(i18n::t("run.header.dry"));
418        } else {
419            output::print_header(i18n::t("run.header"));
420        }
421    }
422
423    let mut registry = Registry::load()?;
424
425    // A repository `git init` created fires no Git hook and so never registered itself.
426    // Picking it up here is what keeps `devp run` from reporting "No repositories
427    // registered" while standing inside one. See `link::adopt_enclosing_repo`.
428    let adopted = crate::commands::link::adopt_enclosing_repo(&mut registry);
429    if adopted.is_some() {
430        registry.save()?;
431    }
432    if let Some(path) = &adopted
433        && !args.json
434    {
435        crate::commands::link::report_cwd_adoption(path);
436        println!();
437    }
438
439    // Suppressed in JSON mode: the document is a contract, and a version notice printed
440    // into it would corrupt the output.
441    if !args.json && crate::commands::update::notify_if_outdated(&mut registry) {
442        let _ = registry.save();
443    }
444
445    if registry.repo_count() == 0 {
446        if args.json {
447            return json::emit(&json::run_document(&[], args.dry_run));
448        }
449        output::print_warning("No repositories registered. Run `dev-prune init` first.");
450        return Ok(());
451    }
452
453    // Validated against the registry *before* anything is analysed. A name that matches
454    // nothing is a typo, and the cost of a silent typo here is the one repository the
455    // user was trying to protect getting pruned — so it is an error, not a no-op.
456    let except = parse_except(args.except);
457    if !except.is_empty() {
458        let unmatched: Vec<&String> = except
459            .iter()
460            .filter(|want| {
461                !registry
462                    .repositories
463                    .keys()
464                    .any(|p| is_excepted(p, std::slice::from_ref(*want)))
465            })
466            .collect();
467        if !unmatched.is_empty() {
468            anyhow::bail!(
469                "`--except` names no registered repository: {}\n  \
470                 Run `devp status` to see the registered names.",
471                unmatched
472                    .iter()
473                    .map(|s| s.as_str())
474                    .collect::<Vec<_>>()
475                    .join(", ")
476            );
477        }
478    }
479
480    let min_size_bytes = resolve_min_size(args, Some(&registry));
481    let analysis = PruneOptions {
482        idle_days: 0, // replaced per repository from the registry
483        dry_run: true,
484        force: args.force,
485        only_dirs: None,
486        adapters: filter.clone(),
487        min_size_bytes,
488        scan_depth: resolve_scan_depth(Some(&registry)),
489        allow_manifest_rewrite: resolve_manifest_rewrite(Some(&registry)),
490        command_timeout_secs: resolve_command_timeout(Some(&registry)),
491        build_idle_days: resolve_build_idle_days(Some(&registry)),
492        adapter_idle_days: resolve_adapter_idle_days(Some(&registry)),
493    };
494
495    if !args.json {
496        output::print_info(&format!(
497            "Scanning {} registered repositories for prune candidates...",
498            registry.repo_count()
499        ));
500        if let Some(desc) = filter.describe() {
501            output::print_info(&format!("Adapter filter: {desc}"));
502        }
503        if min_size_bytes > 0 {
504            output::print_info(&format!(
505                "Size floor: ignoring directories under {}",
506                output::format_bytes(min_size_bytes)
507            ));
508        }
509    }
510
511    // Pre-run analysis (dry-run mode first to compute exact savings)
512    //
513    // Two lists come out of it, and both are reported. A repository the analysis refused
514    // to examine — an unreadable `.devprune.json`, most often — used to be dropped here
515    // along with every other non-candidate state, so a pass that had quietly skipped it
516    // still ended on "No idle repositories or pruneable bloat directories found." and
517    // exit 0. The execution loop further down knows how to report these states, but it
518    // only ever sees selected candidates, so it never got the chance.
519    let mut candidates: Vec<PruneResult> = Vec::new();
520    let mut blocked: Vec<PruneResult> = Vec::new();
521    let mut left_alone: Vec<PruneResult> = Vec::new();
522    let mut missing: Vec<PruneResult> = Vec::new();
523    for result in engine::prune_all_with(&mut registry, &analysis) {
524        // An excepted repository leaves the pass entirely — including its failures. The
525        // user said not to touch it, so a broken config in there is not this run's
526        // problem and must not fail an otherwise clean exit code.
527        if is_excepted(&result.repo_path, &except) {
528            continue;
529        }
530        match result.status {
531            PruneStatus::SkippedDryRun => candidates.push(result),
532            PruneStatus::ConfigError(_)
533            | PruneStatus::LockfileError(_)
534            | PruneStatus::ActivityCheckError(_)
535            | PruneStatus::DeleteError(_) => blocked.push(result),
536            // Reported, never failed on: the link is permanent and deliberate, and a
537            // "failure" here made every scheduled pass over the repo exit 1 forever.
538            // A refused declaration joins it for the same reason — it is a standing
539            // state of the repository's own config, not something this pass did wrong.
540            PruneStatus::SkippedSymlink(_) | PruneStatus::SkippedDeclaration(_) => {
541                left_alone.push(result)
542            }
543            // Same reasoning: a deleted clone stays deleted, and failing on it would
544            // keep every scheduled pass red until the entry is unlinked.
545            PruneStatus::PathMissing => missing.push(result),
546            _ => {}
547        }
548    }
549
550    if !args.json && !except.is_empty() {
551        output::print_info(&format!("Leaving alone: {}", except.join(", ")));
552    }
553
554    if args.daemon {
555        let before = candidates.len();
556        candidates.retain(|c| {
557            // An unreadable config drops the candidate. The engine already refuses such a
558            // repository outright, so this cannot fire today; if that ever changes, the
559            // unattended pass must not be the code path that guesses.
560            match crate::config::PerRepoConfig::load_with_diagnostics(&c.repo_path) {
561                Ok(Some(cfg)) => !cfg.disable_daemon,
562                Ok(None) => true,
563                Err(_) => false,
564            }
565        });
566        let skipped = before - candidates.len();
567        if skipped > 0 && !args.json {
568            output::print_info(&format!(
569                "Skipped {skipped} bloat directories in repositories that set `disable_daemon`."
570            ));
571        }
572    }
573
574    // A dry run stops here in both output modes: sizes are known, nothing was verified.
575    if args.dry_run {
576        if args.json {
577            json::emit(&json::run_document(
578                &[candidates, blocked, left_alone, missing].concat(),
579                true,
580            ))?;
581            return Ok(());
582        }
583        if candidates.is_empty()
584            && blocked.is_empty()
585            && left_alone.is_empty()
586            && missing.is_empty()
587        {
588            output::print_info(i18n::t("run.nothing"));
589            return Ok(());
590        }
591        if !candidates.is_empty() {
592            report_candidates(&candidates);
593        }
594        let total: u64 = candidates.iter().map(|c| c.size_freed).sum();
595        output::print_header(i18n::t("run.summary.dry"));
596        output::print_info(&i18n::tf(
597            "run.would_free",
598            &[
599                ("size", &output::format_bytes(total)),
600                ("count", &candidates.len().to_string()),
601            ],
602        ));
603        // Reported, but not an error: a dry run's job is to say what it found, and it
604        // found this too.
605        report_blocked(&blocked);
606        report_left_alone(&left_alone);
607        report_missing(&missing);
608        return Ok(());
609    }
610
611    if candidates.is_empty() {
612        if args.json {
613            json::emit(&json::run_document(
614                &[blocked.clone(), left_alone, missing].concat(),
615                false,
616            ))?;
617            return fail_if_blocked(&blocked);
618        }
619        if blocked.is_empty() && left_alone.is_empty() && missing.is_empty() {
620            output::print_info(i18n::t("run.nothing"));
621            return Ok(());
622        }
623        output::print_info(i18n::t("run.nothing.bloat"));
624        report_blocked(&blocked);
625        report_left_alone(&left_alone);
626        report_missing(&missing);
627        return fail_if_blocked(&blocked);
628    }
629
630    let total_reclaimable: u64 = candidates.iter().map(|c| c.size_freed).sum();
631
632    if !args.json {
633        report_binaries(&candidates);
634        report_candidates(&candidates);
635        output::print_info(&i18n::tf(
636            "run.reclaimable",
637            &[("size", &output::format_bytes_styled(total_reclaimable))],
638        ));
639        report_blocked(&blocked);
640        report_left_alone(&left_alone);
641        report_missing(&missing);
642    }
643
644    // Determine target candidates to prune (either interactive TUI selection or all).
645    // `--json` short-circuits both: it was already required to carry `--yes`.
646    let target_candidates: Vec<PruneResult> = if args.json
647        || args.yes
648        || !registry.settings.require_confirmation
649    {
650        candidates
651    } else if io::stdout().is_terminal() && io::stdin().is_terminal() {
652        eprintln!();
653        eprintln!(
654            "  Loading interactive selector... (↑↓ navigate, Space toggle, Enter confirm, q cancel)"
655        );
656        eprintln!();
657        let selected = tui::selection_view::select_candidates_tui(&candidates)?;
658        if selected.is_empty() {
659            output::print_info("Prune pass cancelled by user (0 candidates selected).");
660            return Ok(());
661        }
662        selected
663    } else {
664        // Reaching here means stdout is piped. If stdin is too, there is nobody to
665        // answer: the read hits EOF at once, and the old code then reported "aborted by
666        // user" about a user who was never asked. Failing with the fix beats that.
667        if !io::stdin().is_terminal() {
668            anyhow::bail!(
669                "Deleting {} directories ({}) needs confirmation, and there is no \
670                 terminal to ask on. Re-run with `--yes` to confirm, or `--dry-run` \
671                 to only analyse.",
672                candidates.len(),
673                output::format_bytes(total_reclaimable)
674            );
675        }
676        println!();
677        output::print_warning("CAUTION: Deleting bloat directories cannot be undone directly.");
678        output::print_info(
679            "Note: You can re-install missing dependencies anytime using `dev-prune restore`.",
680        );
681        // The question goes to stderr: stdout is a pipe here, and a prompt written into
682        // it is invisible on the terminal — the command just appears to hang.
683        eprint!(
684            "Proceed with deletion of {} directories ({})? [y/N]: ",
685            candidates.len(),
686            output::format_bytes(total_reclaimable)
687        );
688        io::stderr().flush()?;
689
690        let mut input = String::new();
691        io::stdin().read_line(&mut input)?;
692        let trimmed = input.trim().to_lowercase();
693        if trimmed != "y" && trimmed != "yes" {
694            output::print_info("Prune pass aborted by user.");
695            return Ok(());
696        }
697        candidates
698    };
699
700    if !args.json {
701        let selected_total_bytes: u64 = target_candidates.iter().map(|c| c.size_freed).sum();
702        output::print_header(&i18n::tf(
703            "run.header.deleting",
704            &[
705                ("repos", &target_candidates.len().to_string()),
706                ("size", &output::format_bytes(selected_total_bytes)),
707            ],
708        ));
709    }
710
711    // Execute deletion ONLY on the selected bloat directories.
712    //
713    // The selector works per bloat directory, so group the selection by repo and pass
714    // the chosen directory names down — pruning the whole repo would delete dirs the
715    // user explicitly unticked.
716    let mut selection: Vec<(std::path::PathBuf, Vec<String>)> = Vec::new();
717    for candidate in &target_candidates {
718        match selection
719            .iter_mut()
720            .find(|(p, _)| *p == candidate.repo_path)
721        {
722            Some((_, dirs)) => dirs.push(candidate.bloat_dir.clone()),
723            None => selection.push((
724                candidate.repo_path.clone(),
725                vec![candidate.bloat_dir.clone()],
726            )),
727        }
728    }
729
730    // Seeded with what the analysis pass could not get past. Those repositories belong in
731    // the document and in the exit code exactly as much as a failure from the loop below.
732    // Left-alone directories ride along for the document only — they are not errors.
733    let mut error_count = blocked.len();
734    let mut all_results: Vec<PruneResult> = blocked;
735    all_results.extend(left_alone);
736    all_results.extend(missing);
737    let mut total_freed: u64 = 0;
738    let mut pruned_count = 0;
739    let mut pruned_dirs: Vec<crate::config::PrunedDir> = Vec::new();
740    // One timestamp identifies the whole pass, so every incremental save below
741    // supersedes the previous one instead of counting as its own pass.
742    let pass_at = chrono::Utc::now();
743
744    for (repo_path, dirs) in &selection {
745        let recorded_before = pruned_dirs.len();
746        // The idle check runs again here, not just at analysis: the selector can sit
747        // open for hours, and a repository someone started working in between analysis
748        // and Enter must not be pruned on the strength of a stale answer. Only
749        // `--ignore-idle` skips it, exactly as it skipped the first check.
750        let idle_days = registry
751            .repositories
752            .get(repo_path)
753            .and_then(|e| e.override_idle_days)
754            .unwrap_or(registry.settings.idle_days);
755        let single_results = engine::prune_repo_with(
756            repo_path,
757            &PruneOptions {
758                idle_days,
759                dry_run: false,
760                force: args.force,
761                only_dirs: Some(dirs.clone()),
762                adapters: filter.clone(),
763                min_size_bytes: 0,
764                scan_depth: analysis.scan_depth,
765                allow_manifest_rewrite: analysis.allow_manifest_rewrite,
766                command_timeout_secs: analysis.command_timeout_secs,
767                build_idle_days: analysis.build_idle_days,
768                adapter_idle_days: analysis.adapter_idle_days.clone(),
769            },
770        );
771        for result in single_results {
772            match &result.status {
773                PruneStatus::Pruned => {
774                    total_freed += result.size_freed;
775                    pruned_count += 1;
776                    registry.mark_pruned(&result.repo_path, result.size_freed);
777                    pruned_dirs.push(crate::config::PrunedDir {
778                        repo_path: result.repo_path.clone(),
779                        bloat_dir: result.bloat_dir.clone(),
780                        adapter: result.adapter_name.clone(),
781                        size_freed: result.size_freed,
782                        runtime: result.runtime.clone(),
783                    });
784                    if !args.json {
785                        output::print_success(&format!(
786                            "{} → {} ({}) — {}{}",
787                            output::clean_path(&result.repo_path),
788                            result.bloat_dir,
789                            output::format_bytes(result.size_freed),
790                            result.adapter_name,
791                            output::shared_note(result.shared_bytes, &result.adapter_name)
792                        ));
793                    }
794                }
795                PruneStatus::LockfileError(e) => {
796                    error_count += 1;
797                    if !args.json {
798                        report_lockfile_failure(&result, e);
799                    }
800                }
801                PruneStatus::ActivityCheckError(e) => {
802                    error_count += 1;
803                    if !args.json {
804                        output::print_error(&format!(
805                            "{} skipped — its activity could not be determined:\n    {}",
806                            output::clean_path(&result.repo_path),
807                            e.trim()
808                        ));
809                    }
810                }
811                PruneStatus::DeleteError(e) => {
812                    error_count += 1;
813                    // A non-zero size_freed on a delete error means the delete got
814                    // half-way: the directory is corrupt, not intact. Record it so
815                    // `devp restore --last-run` knows to rebuild it — while the error
816                    // above still fails the pass.
817                    if result.size_freed > 0 {
818                        pruned_dirs.push(crate::config::PrunedDir {
819                            repo_path: result.repo_path.clone(),
820                            bloat_dir: result.bloat_dir.clone(),
821                            adapter: result.adapter_name.clone(),
822                            size_freed: result.size_freed,
823                            runtime: result.runtime.clone(),
824                        });
825                    }
826                    if !args.json {
827                        output::print_error(&format!(
828                            "{} → delete failed: {}",
829                            output::clean_path(&result.repo_path),
830                            e,
831                        ));
832                    }
833                }
834                PruneStatus::ConfigError(e) => {
835                    error_count += 1;
836                    if !args.json {
837                        let clean_p = output::clean_path(&result.repo_path);
838                        output::print_error(&format!(
839                            "{clean_p} skipped — its .devprune.json could not be read:\n    {}",
840                            e.trim()
841                        ));
842                        output::print_info(&format!(
843                            "  Fix command:       devp config {clean_p} --update"
844                        ));
845                    }
846                }
847                // The repo saw activity between analysis and execution — the re-check
848                // above caught it. A protective skip, not a failure.
849                PruneStatus::SkippedActive if !args.json => {
850                    output::print_info(&format!(
851                        "{} became active since the analysis — left alone. \
852                         Use `--ignore-idle` to prune it anyway.",
853                        output::clean_path(&result.repo_path)
854                    ));
855                }
856                _ => {}
857            }
858            all_results.push(result);
859        }
860
861        // Persisted after every repository, not once at the end. A pass killed
862        // half-way through used to leave the registry describing the *previous*
863        // pass, so `devp restore --last-run` offered to reinstall directories that
864        // were never deleted and said nothing about the ones that were. A save
865        // failure here is silent — the final save below reports it.
866        if pruned_dirs.len() > recorded_before {
867            registry.record_prune_progress(pass_at, pruned_dirs.clone());
868            let _ = registry.save();
869        }
870    }
871
872    registry.record_prune_progress(pass_at, pruned_dirs);
873    registry.save()?;
874
875    if args.json {
876        json::emit(&json::run_document(&all_results, false))?;
877        // The document already carries `summary.errors`; a non-zero exit keeps the
878        // shell contract identical in both output modes.
879        if error_count > 0 {
880            anyhow::bail!("{error_count} repositories could not be pruned.");
881        }
882        return Ok(());
883    }
884
885    output::print_header(i18n::t("run.summary"));
886    output::print_success(&i18n::tf(
887        "run.freed",
888        &[
889            ("size", &output::format_bytes_styled(total_freed)),
890            ("count", &pruned_count.to_string()),
891        ],
892    ));
893
894    if error_count > 0 {
895        output::print_warning(&i18n::tf(
896            "run.not_pruned",
897            &[("count", &error_count.to_string())],
898        ));
899
900        // Only when a lockfile was actually the problem. `error_count` also counts
901        // unreadable configs and failed deletions, and a lecture about lockfiles in front
902        // of a JSON syntax error sends the user to the wrong file.
903        if all_results
904            .iter()
905            .any(|r| matches!(r.status, PruneStatus::LockfileError(_)))
906        {
907            // Lockfile enforcement is not overridable — `--ignore-idle` only bypasses the idle
908            // check. Without a lockfile a deleted dependency tree cannot be rebuilt, so
909            // point at the fix instead of offering an override that does not exist.
910            output::print_info(
911                "Lockfile verification cannot be bypassed: without a lockfile the deleted \
912                 dependencies could not be reinstalled. Run the fix command shown above for \
913                 each repo, then re-run `devp run`.",
914            );
915        }
916        // Exit non-zero so a scheduled or scripted run surfaces the failure.
917        anyhow::bail!("{error_count} repositories could not be pruned.");
918    }
919
920    // After the pass, never before it: an upgrade mid-run would swap the binary out
921    // from under the work the user actually asked for.
922    crate::commands::update::maybe_auto_update(&registry);
923
924    Ok(())
925}
926
927/// Why a repository's activity could not be read, when the reason is one that every
928/// affected repository shares.
929///
930/// Git prints its "dubious ownership" refusal as twelve lines, ten of which are word for
931/// word identical for every repository it refuses — the same explanation, the same two
932/// account identifiers, the same `git config` invitation. On a machine where one Windows
933/// reinstall left twenty-one repositories with a stale owner, printing that per
934/// repository buries the only line that differs (the path) in two hundred that do not.
935/// One cause with one fix should read as one paragraph, however many repositories it
936/// covers.
937#[derive(Debug, PartialEq, Eq, Clone, Copy)]
938enum ActivityFailure {
939    /// Git refuses the working tree because it is owned by another account.
940    UntrustedOwner,
941    /// The registered path is no longer a working tree.
942    NotARepository,
943    /// Anything else: reported individually, with git's own words.
944    Individual,
945}
946
947impl ActivityFailure {
948    /// Classify one activity-check failure from Git's own stderr.
949    ///
950    /// Deliberately a substring match on Git's wording rather than a parse. The
951    /// alternative is asking Git a second question per repository, and the cost of a
952    /// wrong guess here is a message that reads slightly less well — never a wrong
953    /// deletion, because a repository in this list is one nothing was done to.
954    fn classify(message: &str) -> Self {
955        let lower = message.to_lowercase();
956        if lower.contains(constants::GIT_DUBIOUS_OWNERSHIP) {
957            Self::UntrustedOwner
958        } else if lower.contains(constants::GIT_NOT_A_REPOSITORY) {
959            Self::NotARepository
960        } else {
961            Self::Individual
962        }
963    }
964}
965
966/// How many paths a grouped cause lists before it stops and says how many are left.
967///
968/// Eight is enough to recognise a pattern — one directory tree, one old drive — without
969/// the list becoming the thing that has to be scrolled past.
970const GROUPED_PATHS_SHOWN: usize = 8;
971
972/// Report the repositories the analysis pass could not get past, with the fix for each.
973///
974/// Silent for an empty list, so callers do not have to guard it.
975fn report_blocked(blocked: &[PruneResult]) {
976    if blocked.is_empty() {
977        return;
978    }
979    output::print_header(&i18n::tf(
980        "run.header.blocked",
981        &[("count", &blocked.len().to_string())],
982    ));
983
984    let grouped = |failure: ActivityFailure| -> Vec<&PruneResult> {
985        blocked
986            .iter()
987            .filter(|r| match &r.status {
988                PruneStatus::ActivityCheckError(e) => ActivityFailure::classify(e) == failure,
989                _ => false,
990            })
991            .collect()
992    };
993
994    let untrusted = grouped(ActivityFailure::UntrustedOwner);
995    if !untrusted.is_empty() {
996        let n = untrusted.len();
997        output::print_error(&format!(
998            "{n} {} owned by a different account — Git will not read {}.",
999            output::plural(n, "repository is", "repositories are"),
1000            output::plural(n, "it", "them")
1001        ));
1002        list_paths(&untrusted);
1003        output::print_wrapped(
1004            "    ",
1005            "Nothing is wrong with the repositories themselves. The owner recorded on \
1006             disk is usually one a Windows reinstall, a restored backup or a drive moved \
1007             between machines left behind.",
1008        );
1009        output::print_wrapped(
1010            "    ",
1011            "dev-prune dates a repository by its last commit, so one Git will not open \
1012             has no known age — and nothing is ever deleted from a repository whose age is \
1013             unknown.",
1014        );
1015        output::print_info(&format!(
1016            "  Fix all {n} at once:  devp trust --fix-ownership"
1017        ));
1018    }
1019
1020    let orphaned = grouped(ActivityFailure::NotARepository);
1021    if !orphaned.is_empty() {
1022        if !untrusted.is_empty() {
1023            println!();
1024        }
1025        let n = orphaned.len();
1026        output::print_error(&format!(
1027            "{n} registered {} not {} git {} any more.",
1028            output::plural(n, "path is", "paths are"),
1029            output::plural(n, "a", ""),
1030            output::plural(n, "repository", "repositories")
1031        ));
1032        list_paths(&orphaned);
1033        output::print_wrapped(
1034            "    ",
1035            "The directory is still there; its `.git` is not — a clone deleted and \
1036             recreated by hand, or a worktree `git worktree prune` has since removed. The \
1037             registry entry outlived what it pointed at.",
1038        );
1039        // Not `--missing`: that clears entries whose *directory* has gone, and these
1040        // directories are still on disk. Naming the wrong repair here would have the
1041        // user run a command that reports it removed nothing.
1042        output::print_info(&format!(
1043            "  Drop {} from the registry:  devp unlink <path>",
1044            output::plural(n, "it", "them")
1045        ));
1046    }
1047
1048    for result in blocked {
1049        let clean_p = output::clean_path(&result.repo_path);
1050        match &result.status {
1051            PruneStatus::ConfigError(e) => {
1052                output::print_error(&format!(
1053                    "{clean_p} skipped — its .devprune.json could not be read:
1054    {}",
1055                    e.trim()
1056                ));
1057                output::print_info(&format!(
1058                    "  Fix command:       devp config {clean_p} --update"
1059                ));
1060            }
1061            PruneStatus::LockfileError(e) => report_lockfile_failure(result, e),
1062            PruneStatus::ActivityCheckError(e)
1063                if ActivityFailure::classify(e) == ActivityFailure::Individual =>
1064            {
1065                output::print_error(&format!(
1066                    "{clean_p} skipped — its activity could not be determined:
1067    {}",
1068                    output::condense_tool_output(e, 4)
1069                ));
1070            }
1071            PruneStatus::DeleteError(e) => {
1072                output::print_error(&format!("{clean_p} → delete failed: {e}"));
1073            }
1074            // Everything else was covered by one of the grouped causes above.
1075            _ => {}
1076        }
1077    }
1078}
1079
1080/// Print the paths of one grouped cause, indented, stopping at [`GROUPED_PATHS_SHOWN`].
1081///
1082/// `--json` is named as the way to see the rest rather than a `--verbose` flag, because
1083/// it already lists every result and is the output a script would be reading anyway.
1084fn list_paths(results: &[&PruneResult]) {
1085    for result in results.iter().take(GROUPED_PATHS_SHOWN) {
1086        println!("    {}", output::styled_path(&result.repo_path));
1087    }
1088    if let Some(rest) = results
1089        .len()
1090        .checked_sub(GROUPED_PATHS_SHOWN)
1091        .filter(|n| *n > 0)
1092    {
1093        output::print_dimmed(&format!(
1094            "    … and {rest} more — `devp run --dry-run --json` lists every one."
1095        ));
1096    }
1097}
1098
1099/// Report directories that were deliberately left alone: symlinks, and declarations
1100/// that did not pass their checks.
1101///
1102/// Informational only, never part of the exit code. The storage a link points at is not
1103/// this repository's to delete; a declaration dev-prune refuses is a standing fact about
1104/// the repository's own config. Both are permanent until somebody changes something, and
1105/// failing on either would turn every scheduled pass over such a repo red forever.
1106fn report_left_alone(left_alone: &[PruneResult]) {
1107    for result in left_alone {
1108        if let PruneStatus::SkippedSymlink(e) | PruneStatus::SkippedDeclaration(e) = &result.status
1109        {
1110            output::print_warning(&format!(
1111                "{} → {}",
1112                output::clean_path(&result.repo_path),
1113                e.trim()
1114            ));
1115        }
1116    }
1117}
1118
1119/// Report registered paths that no longer exist on disk.
1120///
1121/// Informational only, never part of the exit code: the clone is already gone, the state
1122/// does not fix itself, and failing on it would keep every scheduled pass red until the
1123/// user notices. The fix is one command, so name it.
1124fn report_missing(missing: &[PruneResult]) {
1125    if missing.is_empty() {
1126        return;
1127    }
1128    println!();
1129    let n = missing.len();
1130    output::print_warning(&format!(
1131        "{n} registered {} no longer {} on disk.",
1132        output::plural(n, "path", "paths"),
1133        output::plural(n, "exists", "exist")
1134    ));
1135    // One line per path was fine for the one or two a person deletes by hand. It stopped
1136    // being fine the first time a tool that clones into a temporary directory registered
1137    // thirty of them: the report ended in thirty near-identical lines carrying one
1138    // instruction, repeated thirty times.
1139    for result in missing.iter().take(GROUPED_PATHS_SHOWN) {
1140        println!("    {}", output::styled_path(&result.repo_path));
1141    }
1142    if let Some(rest) = missing
1143        .len()
1144        .checked_sub(GROUPED_PATHS_SHOWN)
1145        .filter(|n| *n > 0)
1146    {
1147        output::print_dimmed(&format!(
1148            "    … and {rest} more — `devp run --dry-run --json` lists every one."
1149        ));
1150    }
1151    output::print_info(&format!(
1152        "  Clear {} from the registry:  devp unlink --missing",
1153        output::plural(n, "it", "them all")
1154    ));
1155}
1156
1157/// Turn a non-empty blocked list into the process's failure exit.
1158///
1159/// A pass that skipped a repository the user asked it to handle has not succeeded, and a
1160/// scheduled or scripted run has to be able to see that.
1161fn fail_if_blocked(blocked: &[PruneResult]) -> Result<()> {
1162    if blocked.is_empty() {
1163        return Ok(());
1164    }
1165    anyhow::bail!("{} repositories could not be examined.", blocked.len());
1166}
1167
1168/// Report which ecosystem binaries the pass will need and whether they are present.
1169fn report_binaries(candidates: &[PruneResult]) {
1170    let adapter_names: Vec<String> = candidates.iter().map(|c| c.adapter_name.clone()).collect();
1171    let binary_statuses = adapters::scan_required_binaries(&adapter_names);
1172    if binary_statuses.is_empty() {
1173        return;
1174    }
1175    output::print_header(i18n::t("run.header.binaries"));
1176    for b in &binary_statuses {
1177        if b.available {
1178            output::print_success(&format!(
1179                "  {} — available ({})",
1180                b.name,
1181                b.version.as_deref().unwrap_or("detected")
1182            ));
1183        } else {
1184            output::print_warning(&format!(
1185                "  {} — missing (lockfile fallback active)",
1186                b.name
1187            ));
1188        }
1189    }
1190}
1191
1192fn report_candidates(candidates: &[PruneResult]) {
1193    output::print_header(i18n::t("run.header.candidates"));
1194    for candidate in candidates {
1195        output::print_info(&format!(
1196            "  • {} → {} ({}) [{}]{}",
1197            output::styled_path(&candidate.repo_path),
1198            candidate.bloat_dir,
1199            output::format_bytes_styled(candidate.size_freed),
1200            output::styled_adapter(&candidate.adapter_name),
1201            output::shared_note(candidate.shared_bytes, &candidate.adapter_name)
1202        ));
1203    }
1204}
1205
1206pub(crate) fn report_lockfile_failure(result: &PruneResult, error: &str) {
1207    // The project directory, not the repository root: a monorepo reports
1208    // `backend/.venv`, and `uv lock` at the root would not fix it.
1209    let project = output::clean_path(result.project_dir());
1210
1211    output::print_error(&format!(
1212        "{} → {} lockfile sync failed:\n    {}",
1213        project,
1214        result.adapter_name,
1215        error.trim(),
1216    ));
1217    match json::lockfile_fix_command(&result.adapter_name) {
1218        Some(sync_cmd) => {
1219            // `;` on PowerShell, `&&` on a POSIX shell — pasted, either has to work as
1220            // typed or the `cd` is decoration.
1221            #[cfg(windows)]
1222            let manual_cmd = format!("cd \"{project}\"; {sync_cmd}");
1223            #[cfg(not(windows))]
1224            let manual_cmd = format!("cd \"{project}\" && {sync_cmd}");
1225            output::print_info(&format!("  Fix command:       {manual_cmd}"));
1226        }
1227        // venv, gradle, maven and swift have no mechanical fix — saying where still
1228        // beats sending someone to the repository root to go looking.
1229        None => output::print_info(&format!("  Fix it in:         {project}")),
1230    }
1231    output::print_info(&format!(
1232        "  Troubleshooting:   {}",
1233        constants::TROUBLESHOOTING_URL
1234    ));
1235}
1236
1237/// `devp run --explain` — the decision for every repository and directory, with
1238/// nothing done.
1239///
1240/// The prune pass keeps quiet about the states that are not its job to fix — a
1241/// repository still active, one opted out, a directory under the size floor — which is
1242/// exactly what someone staring at "no candidates found" needs to hear about. This mode
1243/// runs the same analysis and reports every verdict instead of only the actionable
1244/// ones. Read-only by construction: the engine runs in dry-run mode, and the size floor
1245/// is applied here in the report rather than in the engine, so a too-small directory is
1246/// named as too small instead of silently missing.
1247fn run_explain(args: &RunArgs<'_>, filter: &AdapterFilter) -> Result<()> {
1248    output::print_header(i18n::t("run.header.reasons"));
1249    if let Some(desc) = filter.describe() {
1250        output::print_info(&format!("Adapter filter: {desc}"));
1251    }
1252
1253    if let Some(target_str) = args.target_path {
1254        let raw = Path::new(target_str);
1255        let path = if raw.exists() {
1256            raw.canonicalize().unwrap_or_else(|_| raw.to_path_buf())
1257        } else {
1258            raw.to_path_buf()
1259        };
1260        if !crate::scanner::is_git_repo(&path) {
1261            anyhow::bail!(
1262                "{} is not a Git repository — dev-prune only prunes Git repos.",
1263                output::clean_path(&path)
1264            );
1265        }
1266        let registry = Registry::load().ok();
1267        let idle_days = registry
1268            .as_ref()
1269            .map(|r| {
1270                r.repositories
1271                    .get(&path)
1272                    .and_then(|e| e.override_idle_days)
1273                    .unwrap_or(r.settings.idle_days)
1274            })
1275            .unwrap_or(constants::DEFAULT_IDLE_DAYS);
1276        let floor = resolve_min_size(args, registry.as_ref());
1277        let results = engine::prune_repo_with(
1278            &path,
1279            &PruneOptions {
1280                idle_days,
1281                dry_run: true,
1282                force: args.force,
1283                only_dirs: None,
1284                adapters: filter.clone(),
1285                min_size_bytes: 0,
1286                scan_depth: resolve_scan_depth(registry.as_ref()),
1287                allow_manifest_rewrite: resolve_manifest_rewrite(registry.as_ref()),
1288                command_timeout_secs: resolve_command_timeout(registry.as_ref()),
1289                build_idle_days: resolve_build_idle_days(registry.as_ref()),
1290                adapter_idle_days: resolve_adapter_idle_days(registry.as_ref()),
1291            },
1292        );
1293        let refs: Vec<&PruneResult> = results.iter().collect();
1294        explain_repo(&path, &refs, floor, idle_days);
1295        print_explain_footer();
1296        return Ok(());
1297    }
1298
1299    let mut registry = Registry::load()?;
1300    if registry.repo_count() == 0 {
1301        output::print_warning("No repositories registered. Run `dev-prune init` first.");
1302        return Ok(());
1303    }
1304
1305    let except = parse_except(args.except);
1306    let global_floor = resolve_min_size(args, Some(&registry));
1307    let analysis = PruneOptions {
1308        idle_days: 0, // replaced per repository from the registry
1309        dry_run: true,
1310        force: args.force,
1311        only_dirs: None,
1312        adapters: filter.clone(),
1313        min_size_bytes: 0,
1314        scan_depth: resolve_scan_depth(Some(&registry)),
1315        allow_manifest_rewrite: resolve_manifest_rewrite(Some(&registry)),
1316        command_timeout_secs: resolve_command_timeout(Some(&registry)),
1317        build_idle_days: resolve_build_idle_days(Some(&registry)),
1318        adapter_idle_days: resolve_adapter_idle_days(Some(&registry)),
1319    };
1320    let results = engine::prune_all_with(&mut registry, &analysis);
1321
1322    let mut by_repo: std::collections::HashMap<&Path, Vec<&PruneResult>> =
1323        std::collections::HashMap::new();
1324    for r in &results {
1325        by_repo.entry(r.repo_path.as_path()).or_default().push(r);
1326    }
1327
1328    let mut repos: Vec<&std::path::PathBuf> = registry.repositories.keys().collect();
1329    repos.sort();
1330    for path in repos {
1331        if is_excepted(path, &except) {
1332            println!();
1333            output::print_info(&output::clean_path(path));
1334            println!("  • left completely alone this pass (`--except`)");
1335            continue;
1336        }
1337        let idle_days = registry
1338            .repositories
1339            .get(path)
1340            .and_then(|e| e.override_idle_days)
1341            .unwrap_or(registry.settings.idle_days);
1342        let empty = Vec::new();
1343        let repo_results = by_repo.get(path.as_path()).unwrap_or(&empty);
1344        explain_repo(path, repo_results, global_floor, idle_days);
1345    }
1346    print_explain_footer();
1347    Ok(())
1348}
1349
1350/// One repository's verdicts, one line per decision.
1351fn explain_repo(path: &Path, results: &[&PruneResult], floor: u64, idle_days: u64) {
1352    println!();
1353    output::print_info(&output::clean_path(path));
1354
1355    if results.is_empty() {
1356        println!(
1357            "  • idle, but no known bloat directories were found. A project deeper than \
1358             `scan_depth` is not examined — `devp status` shows what dev-prune can see."
1359        );
1360        return;
1361    }
1362
1363    for r in results {
1364        match &r.status {
1365            PruneStatus::SkippedDryRun => {
1366                if r.size_freed >= floor {
1367                    output::print_success(&format!(
1368                        "would prune {} ({}) [{}]{}",
1369                        r.bloat_dir,
1370                        output::format_bytes(r.size_freed),
1371                        r.adapter_name,
1372                        output::shared_note(r.shared_bytes, &r.adapter_name)
1373                    ));
1374                } else {
1375                    println!(
1376                        "  • {} ({}) is under the size floor of {} — the reinstall would \
1377                         cost more than the space is worth. `--min-size 0` includes it.",
1378                        r.bloat_dir,
1379                        output::format_bytes(r.size_freed),
1380                        output::format_bytes(floor)
1381                    );
1382                }
1383            }
1384            PruneStatus::SkippedActive => {
1385                let age = crate::scanner::git::get_last_activity(path)
1386                    .ok()
1387                    .flatten()
1388                    .and_then(|t| std::time::SystemTime::now().duration_since(t).ok())
1389                    .map(|d| d.as_secs() / 86_400);
1390                match age {
1391                    Some(0) => println!(
1392                        "  • active — there was activity today, and the idle \
1393                         threshold is {idle_days} days. `--ignore-idle` overrides."
1394                    ),
1395                    Some(days) => println!(
1396                        "  • active — last activity {days} day{} ago, and the idle \
1397                         threshold is {idle_days} days. `--ignore-idle` overrides.",
1398                        if days == 1 { "" } else { "s" }
1399                    ),
1400                    None => println!(
1401                        "  • active (not idle for {idle_days} days yet). \
1402                         `--ignore-idle` overrides."
1403                    ),
1404                }
1405            }
1406            other => println!("  • {other}"),
1407        }
1408    }
1409}
1410
1411/// The one-line contract of `--explain`, printed after the verdicts.
1412fn print_explain_footer() {
1413    println!();
1414    output::print_info(
1415        "Nothing was verified or deleted. `devp run --dry-run` verifies candidates; \
1416         `devp run` prunes.",
1417    );
1418}
1419
1420#[cfg(test)]
1421mod tests {
1422    use super::*;
1423
1424    #[test]
1425    fn gits_ownership_refusal_is_recognised_whatever_the_path() {
1426        // The whole grouped report hangs off this substring. If Git ever reworded the
1427        // message, twenty-one repositories would silently go back to printing twelve
1428        // lines each, and nothing else in the suite would notice.
1429        let message = "git could not read `V:/x`: fatal: detected dubious ownership in repository \
1430                       at 'V:/x'";
1431        assert_eq!(
1432            ActivityFailure::classify(message),
1433            ActivityFailure::UntrustedOwner
1434        );
1435    }
1436
1437    #[test]
1438    fn a_path_that_lost_its_git_directory_is_its_own_cause() {
1439        // Deliberately distinct from UntrustedOwner: the two have different fixes, and
1440        // pointing the user at `devp unlink --missing` for a directory that still exists
1441        // is a command that reports it removed nothing.
1442        let message = "fatal: not a git repository (or any of the parent directories): .git";
1443        assert_eq!(
1444            ActivityFailure::classify(message),
1445            ActivityFailure::NotARepository
1446        );
1447    }
1448
1449    #[test]
1450    fn an_unfamiliar_failure_is_still_printed_in_full() {
1451        assert_eq!(
1452            ActivityFailure::classify("fatal: unable to read tree"),
1453            ActivityFailure::Individual
1454        );
1455    }
1456}