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