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