Skip to main content

dev_prune/commands/
run.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Handler for the `dev-prune run` command.
5//
6// Executes a full prune pass across all registered repositories.
7// Supports pre-deletion analysis, optimized ecosystem binary pre-checks,
8// interactive TUI multi-selection, progressive deletion, shell-specific
9// troubleshooting, and interactive error fallback.
10
11use anyhow::Result;
12use std::io::{self, IsTerminal, Write};
13use std::path::Path;
14
15use crate::adapters;
16use crate::config::Registry;
17use crate::constants;
18use crate::engine::{self, AdapterFilter, PruneOptions, PruneResult, PruneStatus};
19use crate::json;
20use crate::output;
21use crate::tui;
22
23/// Everything `devp run` was asked to do.
24///
25/// A struct rather than nine positional parameters: the call site in `run_cli` reads as
26/// a list of names, and adding a flag does not silently shift an argument.
27pub struct RunArgs<'a> {
28    /// Optional single workspace to act on instead of the whole registry.
29    pub target_path: Option<&'a str>,
30    /// Report sizes and stop.
31    pub dry_run: bool,
32    /// Bypass the idle threshold. Lockfile verification still applies.
33    pub force: bool,
34    /// Skip the confirmation prompt.
35    pub yes: bool,
36    /// This is the scheduled background pass.
37    pub daemon: bool,
38    /// Comma-separated adapters to act on exclusively.
39    pub only: Option<&'a str>,
40    /// Comma-separated adapters to leave alone.
41    pub skip: Option<&'a str>,
42    /// Size floor in MiB, overriding the configured `min_size_mb`.
43    pub min_size_mb: Option<u64>,
44    /// Comma-separated repositories to leave completely alone this pass.
45    pub except: Option<&'a str>,
46    /// Emit one JSON document instead of the human report.
47    pub json: bool,
48}
49
50/// Run the `run` command — prune all registered repos or a specific target directory (`devp run .`).
51///
52/// `daemon` marks the scheduled background pass; repositories that set `disable_daemon`
53/// in `.devprune.json` are excluded from it but remain pruneable by hand.
54pub fn run(args: RunArgs<'_>) -> Result<()> {
55    let filter = AdapterFilter::new(args.only, args.skip)?;
56
57    // In JSON mode there is no one to answer a prompt and no terminal to draw a selector
58    // in, so deletion has to have been authorised on the command line. Failing loudly
59    // beats either silently deleting or silently doing nothing.
60    if args.json && !args.dry_run && !args.yes {
61        anyhow::bail!(
62            "`--json` cannot ask for confirmation. Pass `--dry-run` to analyse, or `--yes` to delete."
63        );
64    }
65
66    if !args.json {
67        output::print_banner();
68        if args.force {
69            print_ignore_idle_notice();
70        }
71    }
72
73    if let Some(target_str) = args.target_path {
74        return run_targeted(&args, &filter, target_str);
75    }
76    run_registry(&args, &filter)
77}
78
79/// What `--ignore-idle` does and, more usefully, what it does not.
80///
81/// Printed whenever the idle check is bypassed, because that is the moment someone is
82/// most likely to be working around a problem rather than solving it — and the problem
83/// they hit is almost always one of the three below. Suppressed in JSON mode, where the
84/// document is the contract and prose on stdout would corrupt it.
85fn print_ignore_idle_notice() {
86    output::print_warning(
87        "Idle check bypassed — repositories you are working in right now are fair game.",
88    );
89    println!(
90        "  Still enforced: lockfile verification, `ignore.devprune.json`, `\"ignore\": true`,"
91    );
92    println!(
93        "  symlinked directories, and nested repositories. This flag does not turn those off."
94    );
95    println!();
96    println!("  If you reached for this because something would not prune, it is usually:");
97    println!("    • \"lockfile verification failed\"  → run the fix command printed next to it;");
98    println!("      it regenerates the lockfile so the reinstall is guaranteed to work.");
99    println!("    • nothing listed at all            → the project is deeper than `scan_depth`,");
100    println!("      or under `min_size_mb`. Try `devp status` to see what dev-prune can see.");
101    println!("    • \"could not be examined\"          → `.devprune.json` has a syntax error.");
102    println!();
103    println!("  Still stuck? Point your AI assistant at the bundled skill — `devp skill`");
104    println!("  exports a SKILL.md that teaches it this tool, exit codes and all. It has");
105    println!("  read the manual more recently than either of us.");
106    println!();
107}
108
109/// `devp run <PATH>` — one workspace, no registry, no selector.
110fn run_targeted(args: &RunArgs<'_>, filter: &AdapterFilter, target_str: &str) -> Result<()> {
111    let raw = std::path::Path::new(target_str);
112    let path = if raw.exists() {
113        raw.canonicalize().unwrap_or_else(|_| raw.to_path_buf())
114    } else {
115        raw.to_path_buf()
116    };
117
118    let clean = output::clean_path(&path);
119    if !crate::scanner::is_git_repo(&path) {
120        // Returning Ok here made `devp run <path>` exit 0 on a path it refused to
121        // touch, which is invisible to any script or CI step checking the status.
122        anyhow::bail!("{clean} is not a Git repository — dev-prune only prunes Git repos.");
123    }
124
125    // A targeted run still respects the configured idle threshold. Passing 0 here
126    // would make every repo look idle and silently defeat the guard — `--ignore-idle` is
127    // the documented way to prune a repo you are actively working in.
128    let registry = Registry::load().ok();
129    let idle_days = registry
130        .as_ref()
131        .map(|r| {
132            r.repositories
133                .get(&path)
134                .and_then(|e| e.override_idle_days)
135                .unwrap_or(r.settings.idle_days)
136        })
137        .unwrap_or(constants::DEFAULT_IDLE_DAYS);
138
139    let opts = PruneOptions {
140        idle_days,
141        dry_run: args.dry_run,
142        force: args.force,
143        only_dirs: None,
144        adapters: filter.clone(),
145        min_size_bytes: resolve_min_size(args, registry.as_ref()),
146        scan_depth: resolve_scan_depth(registry.as_ref()),
147        allow_manifest_rewrite: resolve_manifest_rewrite(registry.as_ref()),
148        command_timeout_secs: resolve_command_timeout(registry.as_ref()),
149    };
150
151    let results = engine::prune_repo_with(&path, &opts);
152
153    // A directory that could not be verified or deleted is a failure of the command,
154    // whichever output mode asked for it. `devp run <path>` used to exit 0 after a
155    // lockfile or delete error, which a script or CI step has no way to notice.
156    let error_count = results
157        .iter()
158        .filter(|r| {
159            matches!(
160                r.status,
161                PruneStatus::LockfileError(_)
162                    | PruneStatus::ActivityCheckError(_)
163                    | PruneStatus::DeleteError(_)
164                    | PruneStatus::ConfigError(_)
165            )
166        })
167        .count();
168
169    // Recorded before the output branches, so `--json` and the human report leave the
170    // same registry behind. A targeted run used to update neither the lifetime totals nor
171    // anything `restore` could read: `devp run .` freed two gigabytes and `devp status`
172    // still said nothing had ever been pruned.
173    record_targeted_prune(&path, &results, args.dry_run);
174
175    if args.json {
176        json::emit(&json::run_document(&results, args.dry_run))?;
177        if error_count > 0 {
178            anyhow::bail!("{error_count} directories in {clean} could not be pruned.");
179        }
180        return Ok(());
181    }
182
183    output::print_header(&format!("dev-prune Targeted Run ({clean})"));
184    if let Some(desc) = filter.describe() {
185        output::print_info(&format!("Adapter filter: {desc}"));
186    }
187
188    if results.is_empty() {
189        output::print_info(&format!("No pruneable bloat directories found in {clean}."));
190        return Ok(());
191    }
192
193    let mut total_freed = 0;
194    for result in results {
195        match &result.status {
196            PruneStatus::Pruned => {
197                total_freed += result.size_freed;
198                output::print_success(&format!(
199                    "{} → {} ({}) — {}{}",
200                    output::clean_path(&result.repo_path),
201                    result.bloat_dir,
202                    output::format_bytes(result.size_freed),
203                    result.adapter_name,
204                    output::shared_note(result.shared_bytes, &result.adapter_name)
205                ));
206            }
207            PruneStatus::SkippedDryRun => {
208                output::print_info(&format!(
209                    "  • {} → {} ({}) [{}] (Dry Run){}",
210                    output::clean_path(&result.repo_path),
211                    result.bloat_dir,
212                    output::format_bytes(result.size_freed),
213                    result.adapter_name,
214                    output::shared_note(result.shared_bytes, &result.adapter_name)
215                ));
216            }
217            PruneStatus::SkippedActive => {
218                output::print_info(&format!(
219                    "{clean} is currently active (not idle). Use `devp --ignore-idle run` to override."
220                ));
221            }
222            PruneStatus::LockfileError(e) => {
223                output::print_error(&format!("{clean} lockfile sync failed:\n    {}", e.trim()));
224            }
225            PruneStatus::ActivityCheckError(e) => {
226                output::print_error(&format!(
227                    "{clean} skipped — its activity could not be determined:\n    {}",
228                    e.trim()
229                ));
230            }
231            PruneStatus::DeleteError(e) => {
232                output::print_error(&format!("{clean} delete error: {e}"));
233            }
234            PruneStatus::ConfigError(e) => {
235                output::print_error(&format!(
236                    "{clean} skipped — its .devprune.json could not be read:\n    {}\n    \
237                     Fix it, or run `devp config {clean} --update` to reset it.",
238                    e.trim()
239                ));
240            }
241            PruneStatus::SkippedSymlink(e) => {
242                output::print_warning(&format!("{clean} → {}", e.trim()));
243            }
244            _ => {}
245        }
246    }
247
248    if !args.dry_run && total_freed > 0 {
249        output::print_success(&format!(
250            "Freed: {} in {clean}",
251            output::format_bytes(total_freed)
252        ));
253    }
254
255    if error_count > 0 {
256        anyhow::bail!("{error_count} directories in {clean} could not be pruned.");
257    }
258
259    Ok(())
260}
261
262/// Persist what a targeted run deleted: the lifetime totals and the `--last-run` record.
263///
264/// Silent on every failure. The directories are already gone by the time this is called,
265/// and a registry that could not be written is not a reason to report the prune itself as
266/// failed — it only costs the user `devp restore --last-run` for this one pass.
267fn record_targeted_prune(path: &std::path::Path, results: &[PruneResult], dry_run: bool) {
268    if dry_run {
269        return;
270    }
271
272    // A DeleteError with a non-zero size_freed is a delete that got half-way: the
273    // directory is corrupt, not intact, so `restore --last-run` must know to rebuild it.
274    let pruned: Vec<crate::config::PrunedDir> = results
275        .iter()
276        .filter(|r| {
277            matches!(r.status, PruneStatus::Pruned)
278                || (matches!(r.status, PruneStatus::DeleteError(_)) && r.size_freed > 0)
279        })
280        .map(|r| crate::config::PrunedDir {
281            repo_path: r.repo_path.clone(),
282            bloat_dir: r.bloat_dir.clone(),
283            adapter: r.adapter_name.clone(),
284            size_freed: r.size_freed,
285        })
286        .collect();
287
288    if pruned.is_empty() {
289        return;
290    }
291
292    let freed: u64 = pruned.iter().map(|d| d.size_freed).sum();
293    if let Ok(mut registry) = Registry::load() {
294        registry.mark_pruned(path, freed);
295        registry.record_prune(pruned);
296        let _ = registry.save();
297    }
298}
299
300/// The size floor for this pass: `--min-size` if given, otherwise the global setting.
301///
302/// A per-repository `min_size_mb` still wins over both — that decision belongs to the
303/// repository and is applied inside the engine.
304fn resolve_min_size(args: &RunArgs<'_>, registry: Option<&Registry>) -> u64 {
305    let mb = args
306        .min_size_mb
307        .or_else(|| registry.map(|r| r.settings.min_size_mb))
308        .unwrap_or(constants::DEFAULT_MIN_SIZE_MB);
309    mb.saturating_mul(engine::BYTES_PER_MIB)
310}
311
312/// Repositories named by `--except`, as a set of lowercased names and path fragments.
313///
314/// Empty when the flag was not passed.
315///
316/// Each entry is tilde-expanded first, because a comma-separated list arrives as one
317/// argument and no shell expands a `~` sitting in the middle of it — not even bash.
318fn parse_except(spec: Option<&str>) -> Vec<String> {
319    spec.map(|s| {
320        s.split(',')
321            .map(|part| {
322                crate::config::expand_tilde(part.trim())
323                    .trim_end_matches(['/', '\\'])
324                    .to_lowercase()
325            })
326            .filter(|part| !part.is_empty())
327            .collect()
328    })
329    .unwrap_or_default()
330}
331
332/// Whether `--except` names this repository.
333///
334/// Matched three ways because there are three things a user reasonably types: the folder
335/// name (`api`), a path fragment (`work/api`), or the full path they see in `devp status`.
336/// Case-insensitive, and `/` and `\` are treated as the same separator, so the flag
337/// behaves the same in PowerShell and in bash.
338fn is_excepted(repo_path: &Path, except: &[String]) -> bool {
339    if except.is_empty() {
340        return false;
341    }
342    let full = output::clean_path(repo_path)
343        .to_lowercase()
344        .replace('\\', "/");
345    let name = repo_path
346        .file_name()
347        .map(|n| n.to_string_lossy().to_lowercase())
348        .unwrap_or_default();
349
350    except.iter().any(|want| {
351        let want = want.replace('\\', "/");
352        name == want || full == want || full.ends_with(&format!("/{want}"))
353    })
354}
355
356/// The global scan depth, falling back to the default when there is no registry yet.
357fn resolve_scan_depth(registry: Option<&Registry>) -> usize {
358    registry
359        .map(|r| r.settings.scan_depth)
360        .unwrap_or(constants::DEFAULT_SCAN_DEPTH)
361}
362
363/// The ceiling on any one package-manager command, in seconds.
364fn resolve_command_timeout(registry: Option<&Registry>) -> u64 {
365    registry
366        .map(|r| r.settings.command_timeout_secs)
367        .unwrap_or(constants::DEFAULT_COMMAND_TIMEOUT_SECS)
368}
369
370/// Whether an adapter may run its lockfile-rewriting sync command.
371fn resolve_manifest_rewrite(registry: Option<&Registry>) -> bool {
372    registry
373        .map(|r| r.settings.allow_manifest_rewrite)
374        .unwrap_or(constants::DEFAULT_ALLOW_MANIFEST_REWRITE)
375}
376
377/// `devp run` — the full pass over every registered repository.
378fn run_registry(args: &RunArgs<'_>, filter: &AdapterFilter) -> Result<()> {
379    if !args.json {
380        if args.dry_run {
381            output::print_header("dev-prune run (DRY RUN)");
382        } else {
383            output::print_header("dev-prune run");
384        }
385    }
386
387    let mut registry = Registry::load()?;
388
389    // Suppressed in JSON mode: the document is a contract, and a version notice printed
390    // into it would corrupt the output.
391    if !args.json && crate::commands::update::notify_if_outdated(&mut registry) {
392        let _ = registry.save();
393    }
394
395    if registry.repo_count() == 0 {
396        if args.json {
397            return json::emit(&json::run_document(&[], args.dry_run));
398        }
399        output::print_warning("No repositories registered. Run `dev-prune init` first.");
400        return Ok(());
401    }
402
403    // Validated against the registry *before* anything is analysed. A name that matches
404    // nothing is a typo, and the cost of a silent typo here is the one repository the
405    // user was trying to protect getting pruned — so it is an error, not a no-op.
406    let except = parse_except(args.except);
407    if !except.is_empty() {
408        let unmatched: Vec<&String> = except
409            .iter()
410            .filter(|want| {
411                !registry
412                    .repositories
413                    .keys()
414                    .any(|p| is_excepted(p, std::slice::from_ref(*want)))
415            })
416            .collect();
417        if !unmatched.is_empty() {
418            anyhow::bail!(
419                "`--except` names no registered repository: {}\n  \
420                 Run `devp status` to see the registered names.",
421                unmatched
422                    .iter()
423                    .map(|s| s.as_str())
424                    .collect::<Vec<_>>()
425                    .join(", ")
426            );
427        }
428    }
429
430    let min_size_bytes = resolve_min_size(args, Some(&registry));
431    let analysis = PruneOptions {
432        idle_days: 0, // replaced per repository from the registry
433        dry_run: true,
434        force: args.force,
435        only_dirs: None,
436        adapters: filter.clone(),
437        min_size_bytes,
438        scan_depth: resolve_scan_depth(Some(&registry)),
439        allow_manifest_rewrite: resolve_manifest_rewrite(Some(&registry)),
440        command_timeout_secs: resolve_command_timeout(Some(&registry)),
441    };
442
443    if !args.json {
444        output::print_info(&format!(
445            "Scanning {} registered repositories for prune candidates...",
446            registry.repo_count()
447        ));
448        if let Some(desc) = filter.describe() {
449            output::print_info(&format!("Adapter filter: {desc}"));
450        }
451        if min_size_bytes > 0 {
452            output::print_info(&format!(
453                "Size floor: ignoring directories under {}",
454                output::format_bytes(min_size_bytes)
455            ));
456        }
457    }
458
459    // Pre-run analysis (dry-run mode first to compute exact savings)
460    //
461    // Two lists come out of it, and both are reported. A repository the analysis refused
462    // to examine — an unreadable `.devprune.json`, most often — used to be dropped here
463    // along with every other non-candidate state, so a pass that had quietly skipped it
464    // still ended on "No idle repositories or pruneable bloat directories found." and
465    // exit 0. The execution loop further down knows how to report these states, but it
466    // only ever sees selected candidates, so it never got the chance.
467    let mut candidates: Vec<PruneResult> = Vec::new();
468    let mut blocked: Vec<PruneResult> = Vec::new();
469    let mut linked: Vec<PruneResult> = Vec::new();
470    let mut missing: Vec<PruneResult> = Vec::new();
471    for result in engine::prune_all_with(&mut registry, &analysis) {
472        // An excepted repository leaves the pass entirely — including its failures. The
473        // user said not to touch it, so a broken config in there is not this run's
474        // problem and must not fail an otherwise clean exit code.
475        if is_excepted(&result.repo_path, &except) {
476            continue;
477        }
478        match result.status {
479            PruneStatus::SkippedDryRun => candidates.push(result),
480            PruneStatus::ConfigError(_)
481            | PruneStatus::LockfileError(_)
482            | PruneStatus::ActivityCheckError(_)
483            | PruneStatus::DeleteError(_) => blocked.push(result),
484            // Reported, never failed on: the link is permanent and deliberate, and a
485            // "failure" here made every scheduled pass over the repo exit 1 forever.
486            PruneStatus::SkippedSymlink(_) => linked.push(result),
487            // Same reasoning: a deleted clone stays deleted, and failing on it would
488            // keep every scheduled pass red until the entry is unlinked.
489            PruneStatus::PathMissing => missing.push(result),
490            _ => {}
491        }
492    }
493
494    if !args.json && !except.is_empty() {
495        output::print_info(&format!("Leaving alone: {}", except.join(", ")));
496    }
497
498    if args.daemon {
499        let before = candidates.len();
500        candidates.retain(|c| {
501            // An unreadable config drops the candidate. The engine already refuses such a
502            // repository outright, so this cannot fire today; if that ever changes, the
503            // unattended pass must not be the code path that guesses.
504            match crate::config::PerRepoConfig::load_with_diagnostics(&c.repo_path) {
505                Ok(Some(cfg)) => !cfg.disable_daemon,
506                Ok(None) => true,
507                Err(_) => false,
508            }
509        });
510        let skipped = before - candidates.len();
511        if skipped > 0 && !args.json {
512            output::print_info(&format!(
513                "Skipped {skipped} bloat directories in repositories that set `disable_daemon`."
514            ));
515        }
516    }
517
518    // A dry run stops here in both output modes: sizes are known, nothing was verified.
519    if args.dry_run {
520        if args.json {
521            json::emit(&json::run_document(
522                &[candidates, blocked, linked, missing].concat(),
523                true,
524            ))?;
525            return Ok(());
526        }
527        if candidates.is_empty() && blocked.is_empty() && linked.is_empty() && missing.is_empty() {
528            output::print_info("No idle repositories or pruneable bloat directories found.");
529            return Ok(());
530        }
531        if !candidates.is_empty() {
532            report_candidates(&candidates);
533        }
534        let total: u64 = candidates.iter().map(|c| c.size_freed).sum();
535        output::print_header("Summary (Dry Run)");
536        output::print_info(&format!(
537            "Would free {} across {} bloat directories.",
538            output::format_bytes(total),
539            candidates.len()
540        ));
541        // Reported, but not an error: a dry run's job is to say what it found, and it
542        // found this too.
543        report_blocked(&blocked);
544        report_linked(&linked);
545        report_missing(&missing);
546        return Ok(());
547    }
548
549    if candidates.is_empty() {
550        if args.json {
551            json::emit(&json::run_document(
552                &[blocked.clone(), linked, missing].concat(),
553                false,
554            ))?;
555            return fail_if_blocked(&blocked);
556        }
557        if blocked.is_empty() && linked.is_empty() && missing.is_empty() {
558            output::print_info("No idle repositories or pruneable bloat directories found.");
559            return Ok(());
560        }
561        output::print_info("No pruneable bloat directories found.");
562        report_blocked(&blocked);
563        report_linked(&linked);
564        report_missing(&missing);
565        return fail_if_blocked(&blocked);
566    }
567
568    let total_reclaimable: u64 = candidates.iter().map(|c| c.size_freed).sum();
569
570    if !args.json {
571        report_binaries(&candidates);
572        report_candidates(&candidates);
573        output::print_info(&format!(
574            "Total Reclaimable Space: {}",
575            output::format_bytes(total_reclaimable)
576        ));
577        report_blocked(&blocked);
578        report_linked(&linked);
579        report_missing(&missing);
580    }
581
582    // Determine target candidates to prune (either interactive TUI selection or all).
583    // `--json` short-circuits both: it was already required to carry `--yes`.
584    let target_candidates: Vec<PruneResult> = if args.json
585        || args.yes
586        || !registry.settings.require_confirmation
587    {
588        candidates
589    } else if io::stdout().is_terminal() {
590        eprintln!();
591        eprintln!(
592            "  Loading interactive selector... (↑↓ navigate, Space toggle, Enter confirm, q cancel)"
593        );
594        eprintln!();
595        let selected = tui::selection_view::select_candidates_tui(&candidates)?;
596        if selected.is_empty() {
597            output::print_info("Prune pass cancelled by user (0 candidates selected).");
598            return Ok(());
599        }
600        selected
601    } else {
602        // Reaching here means stdout is piped. If stdin is too, there is nobody to
603        // answer: the read hits EOF at once, and the old code then reported "aborted by
604        // user" about a user who was never asked. Failing with the fix beats that.
605        if !io::stdin().is_terminal() {
606            anyhow::bail!(
607                "Deleting {} directories ({}) needs confirmation, and there is no \
608                 terminal to ask on. Re-run with `--yes` to confirm, or `--dry-run` \
609                 to only analyse.",
610                candidates.len(),
611                output::format_bytes(total_reclaimable)
612            );
613        }
614        println!();
615        output::print_warning("CAUTION: Deleting bloat directories cannot be undone directly.");
616        output::print_info(
617            "Note: You can re-install missing dependencies anytime using `dev-prune restore`.",
618        );
619        // The question goes to stderr: stdout is a pipe here, and a prompt written into
620        // it is invisible on the terminal — the command just appears to hang.
621        eprint!(
622            "Proceed with deletion of {} directories ({})? [y/N]: ",
623            candidates.len(),
624            output::format_bytes(total_reclaimable)
625        );
626        io::stderr().flush()?;
627
628        let mut input = String::new();
629        io::stdin().read_line(&mut input)?;
630        let trimmed = input.trim().to_lowercase();
631        if trimmed != "y" && trimmed != "yes" {
632            output::print_info("Prune pass aborted by user.");
633            return Ok(());
634        }
635        candidates
636    };
637
638    if !args.json {
639        let selected_total_bytes: u64 = target_candidates.iter().map(|c| c.size_freed).sum();
640        output::print_header(&format!(
641            "Executing Progressive Deletion ({} repos, {})",
642            target_candidates.len(),
643            output::format_bytes(selected_total_bytes)
644        ));
645    }
646
647    // Execute deletion ONLY on the selected bloat directories.
648    //
649    // The selector works per bloat directory, so group the selection by repo and pass
650    // the chosen directory names down — pruning the whole repo would delete dirs the
651    // user explicitly unticked.
652    let mut selection: Vec<(std::path::PathBuf, Vec<String>)> = Vec::new();
653    for candidate in &target_candidates {
654        match selection
655            .iter_mut()
656            .find(|(p, _)| *p == candidate.repo_path)
657        {
658            Some((_, dirs)) => dirs.push(candidate.bloat_dir.clone()),
659            None => selection.push((
660                candidate.repo_path.clone(),
661                vec![candidate.bloat_dir.clone()],
662            )),
663        }
664    }
665
666    // Seeded with what the analysis pass could not get past. Those repositories belong in
667    // the document and in the exit code exactly as much as a failure from the loop below.
668    // Symlinked directories ride along for the document only — they are not errors.
669    let mut error_count = blocked.len();
670    let mut all_results: Vec<PruneResult> = blocked;
671    all_results.extend(linked);
672    all_results.extend(missing);
673    let mut total_freed: u64 = 0;
674    let mut pruned_count = 0;
675    let mut pruned_dirs: Vec<crate::config::PrunedDir> = Vec::new();
676    // One timestamp identifies the whole pass, so every incremental save below
677    // supersedes the previous one instead of counting as its own pass.
678    let pass_at = chrono::Utc::now();
679
680    for (repo_path, dirs) in &selection {
681        let recorded_before = pruned_dirs.len();
682        // The idle check runs again here, not just at analysis: the selector can sit
683        // open for hours, and a repository someone started working in between analysis
684        // and Enter must not be pruned on the strength of a stale answer. Only
685        // `--ignore-idle` skips it, exactly as it skipped the first check.
686        let idle_days = registry
687            .repositories
688            .get(repo_path)
689            .and_then(|e| e.override_idle_days)
690            .unwrap_or(registry.settings.idle_days);
691        let single_results = engine::prune_repo_with(
692            repo_path,
693            &PruneOptions {
694                idle_days,
695                dry_run: false,
696                force: args.force,
697                only_dirs: Some(dirs.clone()),
698                adapters: filter.clone(),
699                min_size_bytes: 0,
700                scan_depth: analysis.scan_depth,
701                allow_manifest_rewrite: analysis.allow_manifest_rewrite,
702                command_timeout_secs: analysis.command_timeout_secs,
703            },
704        );
705        for result in single_results {
706            match &result.status {
707                PruneStatus::Pruned => {
708                    total_freed += result.size_freed;
709                    pruned_count += 1;
710                    registry.mark_pruned(&result.repo_path, result.size_freed);
711                    pruned_dirs.push(crate::config::PrunedDir {
712                        repo_path: result.repo_path.clone(),
713                        bloat_dir: result.bloat_dir.clone(),
714                        adapter: result.adapter_name.clone(),
715                        size_freed: result.size_freed,
716                    });
717                    if !args.json {
718                        output::print_success(&format!(
719                            "{} → {} ({}) — {}{}",
720                            output::clean_path(&result.repo_path),
721                            result.bloat_dir,
722                            output::format_bytes(result.size_freed),
723                            result.adapter_name,
724                            output::shared_note(result.shared_bytes, &result.adapter_name)
725                        ));
726                    }
727                }
728                PruneStatus::LockfileError(e) => {
729                    error_count += 1;
730                    if !args.json {
731                        report_lockfile_failure(&result, e);
732                    }
733                }
734                PruneStatus::ActivityCheckError(e) => {
735                    error_count += 1;
736                    if !args.json {
737                        output::print_error(&format!(
738                            "{} skipped — its activity could not be determined:\n    {}",
739                            output::clean_path(&result.repo_path),
740                            e.trim()
741                        ));
742                    }
743                }
744                PruneStatus::DeleteError(e) => {
745                    error_count += 1;
746                    // A non-zero size_freed on a delete error means the delete got
747                    // half-way: the directory is corrupt, not intact. Record it so
748                    // `devp restore --last-run` knows to rebuild it — while the error
749                    // above still fails the pass.
750                    if result.size_freed > 0 {
751                        pruned_dirs.push(crate::config::PrunedDir {
752                            repo_path: result.repo_path.clone(),
753                            bloat_dir: result.bloat_dir.clone(),
754                            adapter: result.adapter_name.clone(),
755                            size_freed: result.size_freed,
756                        });
757                    }
758                    if !args.json {
759                        output::print_error(&format!(
760                            "{} → delete failed: {}",
761                            output::clean_path(&result.repo_path),
762                            e,
763                        ));
764                    }
765                }
766                PruneStatus::ConfigError(e) => {
767                    error_count += 1;
768                    if !args.json {
769                        let clean_p = output::clean_path(&result.repo_path);
770                        output::print_error(&format!(
771                            "{clean_p} skipped — its .devprune.json could not be read:\n    {}",
772                            e.trim()
773                        ));
774                        output::print_info(&format!(
775                            "  Fix command:       devp config {clean_p} --update"
776                        ));
777                    }
778                }
779                // The repo saw activity between analysis and execution — the re-check
780                // above caught it. A protective skip, not a failure.
781                PruneStatus::SkippedActive if !args.json => {
782                    output::print_info(&format!(
783                        "{} became active since the analysis — left alone. \
784                         Use `--ignore-idle` to prune it anyway.",
785                        output::clean_path(&result.repo_path)
786                    ));
787                }
788                _ => {}
789            }
790            all_results.push(result);
791        }
792
793        // Persisted after every repository, not once at the end. A pass killed
794        // half-way through used to leave the registry describing the *previous*
795        // pass, so `devp restore --last-run` offered to reinstall directories that
796        // were never deleted and said nothing about the ones that were. A save
797        // failure here is silent — the final save below reports it.
798        if pruned_dirs.len() > recorded_before {
799            registry.record_prune_progress(pass_at, pruned_dirs.clone());
800            let _ = registry.save();
801        }
802    }
803
804    registry.record_prune_progress(pass_at, pruned_dirs);
805    registry.save()?;
806
807    if args.json {
808        json::emit(&json::run_document(&all_results, false))?;
809        // The document already carries `summary.errors`; a non-zero exit keeps the
810        // shell contract identical in both output modes.
811        if error_count > 0 {
812            anyhow::bail!("{error_count} repositories could not be pruned.");
813        }
814        return Ok(());
815    }
816
817    output::print_header("Summary");
818    output::print_success(&format!(
819        "Freed: {} across {pruned_count} directories",
820        output::format_bytes(total_freed)
821    ));
822
823    if error_count > 0 {
824        output::print_warning(&format!("{error_count} repos were not pruned."));
825
826        // Only when a lockfile was actually the problem. `error_count` also counts
827        // unreadable configs and failed deletions, and a lecture about lockfiles in front
828        // of a JSON syntax error sends the user to the wrong file.
829        if all_results
830            .iter()
831            .any(|r| matches!(r.status, PruneStatus::LockfileError(_)))
832        {
833            // Lockfile enforcement is not overridable — `--ignore-idle` only bypasses the idle
834            // check. Without a lockfile a deleted dependency tree cannot be rebuilt, so
835            // point at the fix instead of offering an override that does not exist.
836            output::print_info(
837                "Lockfile verification cannot be bypassed: without a lockfile the deleted \
838                 dependencies could not be reinstalled. Run the fix command shown above for \
839                 each repo, then re-run `devp run`.",
840            );
841        }
842        // Exit non-zero so a scheduled or scripted run surfaces the failure.
843        anyhow::bail!("{error_count} repositories could not be pruned.");
844    }
845
846    Ok(())
847}
848
849/// Report the repositories the analysis pass could not get past, with the fix for each.
850///
851/// Silent for an empty list, so callers do not have to guard it.
852fn report_blocked(blocked: &[PruneResult]) {
853    if blocked.is_empty() {
854        return;
855    }
856    output::print_header("Repositories That Could Not Be Examined");
857    for result in blocked {
858        let clean_p = output::clean_path(&result.repo_path);
859        match &result.status {
860            PruneStatus::ConfigError(e) => {
861                output::print_error(&format!(
862                    "{clean_p} skipped — its .devprune.json could not be read:\n    {}",
863                    e.trim()
864                ));
865                output::print_info(&format!(
866                    "  Fix command:       devp config {clean_p} --update"
867                ));
868            }
869            PruneStatus::LockfileError(e) => report_lockfile_failure(result, e),
870            PruneStatus::ActivityCheckError(e) => {
871                output::print_error(&format!(
872                    "{clean_p} skipped — its activity could not be determined:\n    {}",
873                    e.trim()
874                ));
875            }
876            PruneStatus::DeleteError(e) => {
877                output::print_error(&format!("{clean_p} → delete failed: {e}"));
878            }
879            // `blocked` is built from exactly the four arms above.
880            _ => {}
881        }
882    }
883}
884
885/// Report bloat directories that are symlinks and were deliberately left alone.
886///
887/// Informational only, never part of the exit code: the storage a link points at is
888/// not this repository's to delete, the state is permanent, and failing on it would
889/// turn every scheduled pass over such a repo red forever.
890fn report_linked(linked: &[PruneResult]) {
891    for result in linked {
892        if let PruneStatus::SkippedSymlink(e) = &result.status {
893            output::print_warning(&format!(
894                "{} → {}",
895                output::clean_path(&result.repo_path),
896                e.trim()
897            ));
898        }
899    }
900}
901
902/// Report registered paths that no longer exist on disk.
903///
904/// Informational only, never part of the exit code: the clone is already gone, the state
905/// does not fix itself, and failing on it would keep every scheduled pass red until the
906/// user notices. The fix is one command, so name it.
907fn report_missing(missing: &[PruneResult]) {
908    for result in missing {
909        output::print_warning(&format!(
910            "{} no longer exists — `devp unlink --missing` clears such entries.",
911            output::clean_path(&result.repo_path)
912        ));
913    }
914}
915
916/// Turn a non-empty blocked list into the process's failure exit.
917///
918/// A pass that skipped a repository the user asked it to handle has not succeeded, and a
919/// scheduled or scripted run has to be able to see that.
920fn fail_if_blocked(blocked: &[PruneResult]) -> Result<()> {
921    if blocked.is_empty() {
922        return Ok(());
923    }
924    anyhow::bail!("{} repositories could not be examined.", blocked.len());
925}
926
927/// Report which ecosystem binaries the pass will need and whether they are present.
928fn report_binaries(candidates: &[PruneResult]) {
929    let adapter_names: Vec<String> = candidates.iter().map(|c| c.adapter_name.clone()).collect();
930    let binary_statuses = adapters::scan_required_binaries(&adapter_names);
931    if binary_statuses.is_empty() {
932        return;
933    }
934    output::print_header("Required Ecosystem Binaries Pre-Check");
935    for b in &binary_statuses {
936        if b.available {
937            output::print_success(&format!(
938                "  {} — available ({})",
939                b.name,
940                b.version.as_deref().unwrap_or("detected")
941            ));
942        } else {
943            output::print_warning(&format!(
944                "  {} — missing (lockfile fallback active)",
945                b.name
946            ));
947        }
948    }
949}
950
951fn report_candidates(candidates: &[PruneResult]) {
952    output::print_header("Prune Candidates & Space Savings Calculation");
953    for candidate in candidates {
954        output::print_info(&format!(
955            "  • {} → {} ({}) [{}]{}",
956            output::clean_path(&candidate.repo_path),
957            candidate.bloat_dir,
958            output::format_bytes(candidate.size_freed),
959            candidate.adapter_name,
960            output::shared_note(candidate.shared_bytes, &candidate.adapter_name)
961        ));
962    }
963}
964
965fn report_lockfile_failure(result: &PruneResult, error: &str) {
966    let clean_p = output::clean_path(&result.repo_path);
967    let sync_cmd_help =
968        json::lockfile_fix_command(&result.adapter_name).unwrap_or("check the adapter's docs");
969
970    #[cfg(windows)]
971    let manual_cmd = format!("cd \"{}\"; {}", clean_p, sync_cmd_help);
972    #[cfg(not(windows))]
973    let manual_cmd = format!("cd \"{}\" && {}", clean_p, sync_cmd_help);
974
975    output::print_error(&format!(
976        "{} → {} lockfile sync failed:\n    {}",
977        clean_p,
978        result.adapter_name,
979        error.trim(),
980    ));
981    output::print_info(&format!("  Fix command:       {}", manual_cmd));
982    output::print_info(&format!(
983        "  Troubleshooting:   {}",
984        constants::TROUBLESHOOTING_URL
985    ));
986}