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