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