Skip to main content

dev_prune/
engine.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Pruning engine — orchestrates the full prune pass.
5//
6// This module is the "brain" of dev-prune. It coordinates:
7// 1. Git repo validation
8// 2. Activity/idle checking
9// 3. Adapter detection
10// 4. Lockfile enforcement
11// 5. Safe bloat directory deletion
12//
13// The engine enforces all safety invariants described in the project spec.
14
15use std::collections::BTreeMap;
16use std::fs;
17use std::path::{Path, PathBuf};
18use std::time::SystemTime;
19
20use anyhow::Result;
21use chrono::{DateTime, Utc};
22
23use crate::adapters::BloatDir;
24use crate::config::{Registry, RepoEntry};
25use crate::constants;
26use crate::scanner;
27use crate::scanner::git;
28use crate::workspace;
29
30/// The outcome of pruning a single bloat directory.
31#[derive(Debug, Clone)]
32pub enum PruneStatus {
33    /// Successfully deleted the bloat directory.
34    Pruned,
35    /// Skipped because the repo is still active (not idle).
36    SkippedActive,
37    /// Skipped because it's a dry run.
38    SkippedDryRun,
39    /// Skipped because lockfile enforcement failed.
40    LockfileError(String),
41    /// Skipped because the repository's last activity could not be determined.
42    ///
43    /// Not a `LockfileError`: that tag carries a `fix_command` an agent is told it can
44    /// run, and "git failed to answer" has no such mechanical fix.
45    ActivityCheckError(String),
46    /// The registered path no longer exists on disk.
47    PathMissing,
48    /// The registered path still exists on disk but no longer holds a `.git`.
49    ///
50    /// A standing state, not an error: the clone was deleted and recreated by hand, or
51    /// it was a worktree `git worktree prune` has since removed — and reporting it as
52    /// a failure made every scheduled pass exit non-zero forever, for a directory
53    /// nothing could ever have been touched in. Same reasoning as
54    /// [`Self::PathMissing`], but its own variant because the fix is different:
55    /// `devp unlink --missing` only clears entries whose directory has gone, and this
56    /// one is still there.
57    NotARepo,
58    /// An adapter that waits for a longer idle window than the repository's own
59    /// threshold was not examined, because the repository has not been idle that long
60    /// yet. Carries the window, in days.
61    ///
62    /// This was a bare `continue` with no row behind it, and every surface downstream
63    /// then disagreed about the repository: `--explain` said "idle, but no known bloat
64    /// directories were found", `status` said Candidate, `doctor` said Yes — and a run
65    /// touched nothing. Worded as "not examined" rather than "skipped bloat"
66    /// deliberately: the gate fires before the adapter is asked for its directories,
67    /// so there may be nothing behind it at all.
68    SkippedAdapterWindow(u64),
69    /// Skipped because the bloat directory doesn't exist.
70    NoBloat,
71    /// Repo is disabled in the registry.
72    Disabled,
73    /// Repo has a `ignore.devprune.json` file — opted out.
74    SkippedIgnored,
75    /// Error during deletion.
76    DeleteError(String),
77    /// The bloat directory is a symlink or junction, so it was deliberately left alone.
78    ///
79    /// A skip, not an error: the storage it points at is not this repository's to
80    /// delete, and the situation is permanent — reporting it as a failure made every
81    /// scheduled pass over such a repo exit non-zero forever.
82    SkippedSymlink(String),
83    /// `.devprune.json` exists but could not be parsed, so the repo was left alone.
84    ConfigError(String),
85    /// A directory the project declared prunable did not survive its checks.
86    ///
87    /// Not a `LockfileError` even though it is the same kind of refusal: that tag
88    /// carries a `fix_command` in `--json`, and there is no command that fixes "your
89    /// repository declares its own source directory".
90    SkippedDeclaration(String),
91    /// The directory holds a git repository of its own, so it was deliberately left
92    /// alone.
93    ///
94    /// A skip, not a `DeleteError`: a vendored checkout — a pip `-e git+…` install
95    /// under `.venv/src/`, a `file:` dependency — is a permanent fact of the
96    /// repository, and reporting it as a failure made every scheduled pass over such
97    /// a repo exit non-zero forever while `--dry-run` over the same repo exited 0.
98    SkippedNestedRepo(String),
99}
100
101impl std::fmt::Display for PruneStatus {
102    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103        match self {
104            PruneStatus::Pruned => write!(f, "Pruned"),
105            PruneStatus::SkippedActive => write!(f, "Skipped (active)"),
106            PruneStatus::SkippedDryRun => write!(f, "Skipped (dry run)"),
107            PruneStatus::LockfileError(e) => write!(f, "Lockfile error: {e}"),
108            PruneStatus::ActivityCheckError(e) => write!(f, "Activity check failed: {e}"),
109            PruneStatus::PathMissing => {
110                write!(
111                    f,
112                    "Path no longer exists (`devp unlink --missing` clears it)"
113                )
114            }
115            PruneStatus::NotARepo => {
116                write!(
117                    f,
118                    "No longer a git repository (`devp unlink` removes the entry; \
119                     `git init` there makes it a repository again)"
120                )
121            }
122            PruneStatus::SkippedAdapterWindow(days) => {
123                write!(f, "Not examined (adapter idle window: {days} days)")
124            }
125            PruneStatus::NoBloat => write!(f, "No bloat found"),
126            PruneStatus::Disabled => write!(f, "Disabled"),
127            PruneStatus::SkippedIgnored => write!(
128                f,
129                "Ignored (ignore.devprune.json or ignore config in .devprune.json)"
130            ),
131            PruneStatus::DeleteError(e) => write!(f, "Delete error: {e}"),
132            PruneStatus::SkippedSymlink(e) => write!(f, "Skipped (symlink): {e}"),
133            PruneStatus::ConfigError(e) => write!(f, "Unreadable .devprune.json: {e}"),
134            PruneStatus::SkippedDeclaration(e) => write!(f, "Skipped (declaration): {e}"),
135            PruneStatus::SkippedNestedRepo(e) => write!(f, "Skipped (nested repository): {e}"),
136        }
137    }
138}
139
140/// Bytes in one mebibyte. The size floor is configured in MiB because that is the unit
141/// `format_bytes` prints, so a user who sets `10` sees the same number they typed.
142pub const BYTES_PER_MIB: u64 = 1024 * 1024;
143
144/// Which package managers a pass is allowed to act on.
145///
146/// The default allows everything. Built through [`AdapterFilter::new`], which rejects
147/// names no adapter answers to — a typo like `--only pmpm` silently matching nothing
148/// looks exactly like "there was no bloat", and that is the wrong thing to believe about
149/// a tool that deletes directories.
150#[derive(Debug, Clone, Default, PartialEq)]
151pub struct AdapterFilter {
152    only: Option<Vec<String>>,
153    skip: Vec<String>,
154}
155
156impl AdapterFilter {
157    /// Build a filter from comma-separated `--only` / `--skip` values.
158    ///
159    /// Names are matched case-insensitively. Listing the same adapter in both lists is
160    /// a contradiction rather than a precedence puzzle, so it is rejected outright.
161    pub fn new(only: Option<&str>, skip: Option<&str>) -> Result<Self> {
162        // `declared` is not a package manager and has no adapter, but it fills the
163        // same column of the same report, and a user who can see `declared` in
164        // `devp status` will reasonably try to `--only` it.
165        let known: Vec<&'static str> = crate::adapters::get_all_adapters()
166            .iter()
167            .map(|a| a.name())
168            .chain(std::iter::once(constants::DECLARED_ADAPTER_NAME))
169            .collect();
170
171        let parse = |raw: &str, flag: &str| -> Result<Vec<String>> {
172            let mut out = Vec::new();
173            for token in raw.split(',') {
174                let name = token.trim().to_lowercase();
175                if name.is_empty() {
176                    continue;
177                }
178                if !known.contains(&name.as_str()) {
179                    anyhow::bail!(
180                        "`--{flag} {name}` names no known package manager. Available: {}.",
181                        known.join(", ")
182                    );
183                }
184                if !out.contains(&name) {
185                    out.push(name);
186                }
187            }
188            if out.is_empty() {
189                anyhow::bail!("`--{flag}` was given no adapter names.");
190            }
191            Ok(out)
192        };
193
194        let only = only.map(|raw| parse(raw, "only")).transpose()?;
195        let skip = skip
196            .map(|raw| parse(raw, "skip"))
197            .transpose()?
198            .unwrap_or_default();
199
200        if let Some(only) = &only
201            && let Some(clash) = only.iter().find(|n| skip.contains(n))
202        {
203            anyhow::bail!("`{clash}` is in both --only and --skip; pick one.");
204        }
205
206        Ok(Self { only, skip })
207    }
208
209    /// Whether this filter would let `name` through.
210    pub fn allows(&self, name: &str) -> bool {
211        if self.skip.iter().any(|s| s == name) {
212            return false;
213        }
214        match &self.only {
215            Some(only) => only.iter().any(|o| o == name),
216            None => true,
217        }
218    }
219
220    /// Whether the filter restricts anything at all.
221    pub fn is_unrestricted(&self) -> bool {
222        self.only.is_none() && self.skip.is_empty()
223    }
224
225    /// Human-readable summary for the run header, or `None` when nothing is filtered.
226    pub fn describe(&self) -> Option<String> {
227        if self.is_unrestricted() {
228            return None;
229        }
230        let mut parts = Vec::new();
231        if let Some(only) = &self.only {
232            parts.push(format!("only {}", only.join(", ")));
233        }
234        if !self.skip.is_empty() {
235            parts.push(format!("skipping {}", self.skip.join(", ")));
236        }
237        Some(parts.join("; "))
238    }
239}
240
241/// Everything that shapes a prune pass beyond the repository itself.
242///
243/// `Default` is written out rather than derived: `scan_depth` has a real default that is
244/// not zero, and a derived one would have made every `..Default::default()` call site
245/// quietly walk a single level and report a monorepo as empty.
246#[derive(Debug, Clone)]
247pub struct PruneOptions {
248    /// Days of inactivity required before the repository is eligible.
249    pub idle_days: u64,
250    /// Report sizes and stop. Nothing is verified and nothing is deleted.
251    pub dry_run: bool,
252    /// Bypass the idle check. Lockfile verification still applies.
253    pub force: bool,
254    /// Restrict the pass to these repository-relative bloat directory labels.
255    ///
256    /// `Some` means a caller has already chosen — the interactive selector, or the
257    /// second phase of `devp run`. The size floor is not applied on top of an explicit
258    /// choice, because the caller has already decided these directories are wanted.
259    pub only_dirs: Option<Vec<String>>,
260    /// Which package managers may act.
261    pub adapters: AdapterFilter,
262    /// Smallest directory worth deleting. `0` disables the floor.
263    pub min_size_bytes: u64,
264    /// How deep to walk each repository looking for projects.
265    ///
266    /// The global setting. A repository's own `.devprune.json` may raise or lower it —
267    /// see [`workspace::resolve_depth`], which this is fed into.
268    pub scan_depth: usize,
269    /// Whether an adapter may run the sync command that rewrites its tracked lockfile.
270    pub allow_manifest_rewrite: bool,
271    /// Ceiling on any one package-manager command, in seconds.
272    ///
273    /// The user's `command_timeout_secs`. It was settable, displayed by `devp status`
274    /// and named in the timeout message long before anything actually read it here.
275    pub command_timeout_secs: u64,
276    /// Idle days required before *build-tree* directories are touched.
277    ///
278    /// Applied as `max(build_idle_days, idle_days)`, only to adapters that answer
279    /// [`crate::adapters::PackageManager::opt_in`]. A recompile costs more than a
280    /// reinstall, so those directories wait longer.
281    pub build_idle_days: u64,
282    /// Per-adapter idle windows, keyed by adapter name; the user's `adapter_idle_days`.
283    ///
284    /// A floor on top of everything else, never a bypass — see
285    /// [`PruneOptions::idle_threshold_for`].
286    pub adapter_idle_days: BTreeMap<String, u64>,
287}
288
289impl Default for PruneOptions {
290    fn default() -> Self {
291        Self {
292            idle_days: 0,
293            dry_run: false,
294            force: false,
295            only_dirs: None,
296            adapters: AdapterFilter::default(),
297            min_size_bytes: 0,
298            scan_depth: crate::constants::DEFAULT_SCAN_DEPTH,
299            allow_manifest_rewrite: crate::constants::DEFAULT_ALLOW_MANIFEST_REWRITE,
300            command_timeout_secs: crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS,
301            build_idle_days: crate::constants::DEFAULT_BUILD_IDLE_DAYS,
302            adapter_idle_days: BTreeMap::new(),
303        }
304    }
305}
306
307impl PruneOptions {
308    /// How many idle days this adapter needs before its directories may be deleted.
309    ///
310    /// Three rules, applied in order and each one only ever able to raise the bar:
311    ///
312    /// 1. the repository-level window (`idle_days`, or the repo's own override), which
313    ///    every adapter has already had to clear before this is asked;
314    /// 2. `build_idle_days` for an adapter holding compiler output;
315    /// 3. this adapter's own entry in `adapter_idle_days`, if there is one.
316    ///
317    /// Only ever raise, because the repository-level check runs once for the whole
318    /// repository and is the gate that makes "active work is never touched" true. An
319    /// adapter that could lower it would be a bypass of that gate wearing a
320    /// preference's clothes, so a smaller number is accepted and does nothing.
321    fn idle_threshold_for(&self, name: &str, opt_in: bool, base: u64) -> u64 {
322        adapter_idle_threshold(
323            name,
324            opt_in,
325            base,
326            self.build_idle_days,
327            &self.adapter_idle_days,
328        )
329    }
330
331    /// The common case: prune everything eligible in this repository.
332    pub fn new(idle_days: u64, dry_run: bool, force: bool) -> Self {
333        Self {
334            idle_days,
335            dry_run,
336            force,
337            ..Self::default()
338        }
339    }
340}
341
342/// The idle threshold one adapter is held to, in days.
343///
344/// The one place the merge lives: the prune gate, `status_for_repo` and `devp doctor`
345/// all answer "how long must this repository be idle before this adapter is touched?"
346/// through this function. Each used to compute its own answer — the gate correctly,
347/// the other two not at all — which is how a window-held repository was a Candidate
348/// on the dashboard and a "Yes" in doctor while every pass left it alone.
349pub(crate) fn adapter_idle_threshold(
350    name: &str,
351    opt_in: bool,
352    base: u64,
353    build_idle_days: u64,
354    adapter_idle_days: &BTreeMap<String, u64>,
355) -> u64 {
356    let mut days = base;
357    if opt_in {
358        days = days.max(build_idle_days);
359    }
360    if let Some(&explicit) = adapter_idle_days.get(name) {
361        days = days.max(explicit);
362    }
363    days
364}
365
366/// Result of pruning a single bloat directory in a single repo.
367#[derive(Debug, Clone)]
368pub struct PruneResult {
369    /// Path to the repository.
370    pub repo_path: PathBuf,
371    /// Name of the adapter that handled this.
372    pub adapter_name: String,
373    /// The bloat directory that was (or would be) pruned.
374    pub bloat_dir: String,
375    /// Bytes freed (0 if not pruned).
376    pub size_freed: u64,
377    /// Bytes hardlinked into a package-manager store outside the pruned directory.
378    /// The store keeps them, so they are excluded from `size_freed` — this carries
379    /// them separately so reports can say why the number is smaller than `du` says.
380    pub shared_bytes: u64,
381    /// The language runtime the directory was built against, captured before the delete.
382    /// Only the Python managers set it; see [`crate::config::PrunedDir::runtime`].
383    pub runtime: Option<String>,
384    /// What happened.
385    pub status: PruneStatus,
386}
387
388impl PruneResult {
389    /// The directory a fix command has to be run from.
390    ///
391    /// `bloat_dir` is the label relative to the repository root, so in a monorepo it is
392    /// `backend/.venv`, not `.venv` — and `uv lock` run at the repository root would
393    /// rebuild a different project, or find nothing at all. Its parent is the project
394    /// directory the adapter actually detected. A fix command you can only run after
395    /// working out which directory it meant is not a fix command.
396    pub fn project_dir(&self) -> PathBuf {
397        self.repo_path
398            .join(&self.bloat_dir)
399            .parent()
400            .map(Path::to_path_buf)
401            .unwrap_or_else(|| self.repo_path.clone())
402    }
403}
404
405/// Prune a single repository. Returns results for each bloat directory found.
406///
407/// # Safety Invariants
408/// 1. The path MUST contain a valid `.git` directory
409/// 2. The repo must be idle (unless `force` is true)
410/// 3. Lockfile enforcement MUST succeed before any deletion
411pub fn prune_repo(
412    repo_path: &Path,
413    idle_days: u64,
414    dry_run: bool,
415    force: bool,
416) -> Vec<PruneResult> {
417    prune_repo_with(repo_path, &PruneOptions::new(idle_days, dry_run, force))
418}
419
420/// Prune a repository, optionally restricted to a specific set of bloat directories.
421///
422/// `only` is a list of `BloatDir::name` values. When `Some`, any bloat directory whose
423/// name is not in the list is left untouched and produces no result — this is what makes
424/// the interactive selector's per-directory choices meaningful. When `None`, every
425/// detected bloat directory is pruned.
426///
427/// Same safety invariants as [`prune_repo`].
428pub fn prune_repo_selected(
429    repo_path: &Path,
430    idle_days: u64,
431    dry_run: bool,
432    force: bool,
433    only: Option<&[String]>,
434) -> Vec<PruneResult> {
435    prune_repo_with(
436        repo_path,
437        &PruneOptions {
438            only_dirs: only.map(<[String]>::to_vec),
439            ..PruneOptions::new(idle_days, dry_run, force)
440        },
441    )
442}
443
444/// Prune a repository under a full set of [`PruneOptions`].
445///
446/// This is the single implementation; [`prune_repo`] and [`prune_repo_selected`] are
447/// thin wrappers for the two common shapes.
448pub fn prune_repo_with(repo_path: &Path, opts: &PruneOptions) -> Vec<PruneResult> {
449    let idle_days = opts.idle_days;
450    let dry_run = opts.dry_run;
451    let force = opts.force;
452    let only = opts.only_dirs.as_deref();
453    let mut results = Vec::new();
454
455    // A registered path that is gone gets a visible line, not silence. Returning empty
456    // results made the repository vanish from the run report entirely, which reads as
457    // "handled" when the truth is "not found".
458    if !repo_path.exists() {
459        results.push(PruneResult {
460            repo_path: repo_path.to_path_buf(),
461            adapter_name: "-".to_string(),
462            bloat_dir: "-".to_string(),
463            size_freed: 0,
464            shared_bytes: 0,
465            runtime: None,
466            status: PruneStatus::PathMissing,
467        });
468        return results;
469    }
470
471    // A registered path whose `.git` has gone (deleted by hand, or a worktree pruned
472    // by `git worktree prune`) must not vanish from the report the way it once did —
473    // same reasoning as the PathMissing line above: silence reads as "handled". It was
474    // an `ActivityCheckError` for a while, which read fine but counted as a blocking
475    // failure — so one dead registry entry turned every scheduled pass red until the
476    // user happened to look at why.
477    if !scanner::is_git_repo(repo_path) {
478        results.push(PruneResult {
479            repo_path: repo_path.to_path_buf(),
480            adapter_name: "-".to_string(),
481            bloat_dir: "-".to_string(),
482            size_freed: 0,
483            shared_bytes: 0,
484            runtime: None,
485            status: PruneStatus::NotARepo,
486        });
487        return results;
488    }
489
490    // Instant 0ms Check: if `ignore.devprune.json` exists in repo root, skip immediately without parsing any JSON files!
491    if repo_path.join(constants::DEVPRUNE_IGNORE_FILE).exists() {
492        results.push(PruneResult {
493            repo_path: repo_path.to_path_buf(),
494            adapter_name: "-".to_string(),
495            bloat_dir: "-".to_string(),
496            size_freed: 0,
497            shared_bytes: 0,
498            runtime: None,
499            status: PruneStatus::SkippedIgnored,
500        });
501        return results;
502    }
503
504    // A `.devprune.json` that does not parse is a refusal to guess, not a missing file.
505    // Falling back to defaults would drop `"ignore": true` and prune a repository the
506    // user explicitly opted out of, so an unreadable config skips the repo entirely.
507    let per_repo_config = match crate::config::PerRepoConfig::load_with_diagnostics(repo_path) {
508        Ok(cfg) => cfg,
509        Err(e) => {
510            results.push(PruneResult {
511                repo_path: repo_path.to_path_buf(),
512                adapter_name: "-".to_string(),
513                bloat_dir: "-".to_string(),
514                size_freed: 0,
515                shared_bytes: 0,
516                runtime: None,
517                status: PruneStatus::ConfigError(e),
518            });
519            return results;
520        }
521    };
522    if per_repo_config.as_ref().map(|c| c.ignore).unwrap_or(false) {
523        results.push(PruneResult {
524            repo_path: repo_path.to_path_buf(),
525            adapter_name: "-".to_string(),
526            bloat_dir: "-".to_string(),
527            size_freed: 0,
528            shared_bytes: 0,
529            runtime: None,
530            status: PruneStatus::SkippedIgnored,
531        });
532        return results;
533    }
534
535    // Effective idle days from per-repo config or parameter
536    let effective_idle_days = per_repo_config
537        .as_ref()
538        .and_then(|c| c.override_idle_days)
539        .unwrap_or(idle_days);
540
541    // A repository may set its own floor, including `0` to opt out of a global one.
542    // An explicit directory selection overrides both: the caller already chose.
543    let min_size_bytes = if only.is_some() {
544        0
545    } else {
546        per_repo_config
547            .as_ref()
548            .and_then(|c| c.min_size_mb)
549            .map(|mb| mb.saturating_mul(BYTES_PER_MIB))
550            .unwrap_or(opts.min_size_bytes)
551    };
552
553    // Check if repo is idle (skip if active, unless forced)
554    if !force {
555        match git::is_repo_idle(repo_path, effective_idle_days) {
556            Ok(false) => {
557                results.push(PruneResult {
558                    repo_path: repo_path.to_path_buf(),
559                    adapter_name: "-".to_string(),
560                    bloat_dir: "-".to_string(),
561                    size_freed: 0,
562                    shared_bytes: 0,
563                    runtime: None,
564                    status: PruneStatus::SkippedActive,
565                });
566                return results;
567            }
568            Ok(true) => {} // Continue — repo is idle
569            Err(e) => {
570                results.push(PruneResult {
571                    repo_path: repo_path.to_path_buf(),
572                    adapter_name: "-".to_string(),
573                    bloat_dir: "-".to_string(),
574                    size_freed: 0,
575                    shared_bytes: 0,
576                    runtime: None,
577                    status: PruneStatus::ActivityCheckError(e.to_string()),
578                });
579                return results;
580            }
581        }
582    }
583
584    // A repository can hold several projects at several depths — `frontend/` on pnpm,
585    // `services/api/` on uv, `cli/` on cargo — and each is verified and pruned on its
586    // own terms.
587    let projects = workspace::discover_to_depth(
588        repo_path,
589        workspace::resolve_depth(repo_path, opts.scan_depth),
590    );
591
592    // Two adapters can legitimately claim the same directory (e.g. a cargo workspace
593    // member and its workspace root both resolving to the same `target`). Without this
594    // guard the size is counted twice and the second delete fails with "not found".
595    let mut claimed: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
596
597    // One `git log` walk per distinct threshold, computed lazily. Before per-adapter
598    // windows there was only ever one extra threshold to check; now a repository with
599    // cargo at 45 days and npm pinned to 30 needs two, and each is worth caching because
600    // the walk is the expensive part of a pass that finds nothing.
601    let mut idle_at: BTreeMap<u64, bool> = BTreeMap::new();
602
603    // One row per held adapter, not one per project: a monorepo with thirty cargo
604    // members would otherwise report the same window thirty times.
605    let mut window_reported: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
606
607    for project in &projects {
608        for adapter in &project.adapters {
609            if !opts.adapters.allows(adapter.name()) {
610                continue;
611            }
612
613            // Build trees come back by recompiling, and any adapter may carry a window
614            // of its own, so each one is asked what it needs. `force` bypasses this
615            // exactly as it bypasses the normal idle check; verification still applies.
616            let threshold =
617                opts.idle_threshold_for(adapter.name(), adapter.opt_in(), effective_idle_days);
618            if threshold > effective_idle_days && !force {
619                let idle_enough = *idle_at
620                    .entry(threshold)
621                    .or_insert_with(|| git::is_repo_idle(repo_path, threshold).unwrap_or(false));
622                if !idle_enough {
623                    // Held, not silent. This `continue` used to be the whole story,
624                    // and nothing downstream could say why the pass left the
625                    // repository alone.
626                    if window_reported.insert(adapter.name().to_string()) {
627                        results.push(PruneResult {
628                            repo_path: repo_path.to_path_buf(),
629                            adapter_name: adapter.name().to_string(),
630                            bloat_dir: "-".to_string(),
631                            size_freed: 0,
632                            shared_bytes: 0,
633                            runtime: None,
634                            status: PruneStatus::SkippedAdapterWindow(threshold),
635                        });
636                    }
637                    continue;
638                }
639            }
640
641            // Labels are repo-relative (`node_modules`, `frontend/node_modules`) so that
642            // two directories with the same basename in different projects stay
643            // distinguishable — both on screen and in the `only` selection.
644            //
645            // The size floor is applied before `claimed`, so a directory rejected for
646            // being too small does not also block a second adapter from considering it.
647            let bloat_dirs: Vec<(String, BloatDir)> = adapter
648                .bloat_dirs(&project.path)
649                .into_iter()
650                .map(|bd| (workspace::relative_label(repo_path, &bd.path), bd))
651                .filter(|(label, _)| only.is_none_or(|names| names.contains(label)))
652                .filter(|(_, bd)| bd.size_bytes >= min_size_bytes)
653                .filter(|(_, bd)| claimed.insert(bd.path.clone()))
654                .collect();
655
656            if bloat_dirs.is_empty() {
657                continue;
658            }
659
660            // Both refusals run before the dry-run branch AND before lockfile
661            // enforcement. Before dry-run, because an analysis that counted a
662            // symlinked or nested-git directory as reclaimable promised space the
663            // real pass then refused to touch. Before enforcement, because with
664            // `allow_manifest_rewrite` the enforcement step may rewrite a tracked
665            // lockfile — and rewriting one in service of directories that are then
666            // every one refused leaves a modified tracked file behind with nothing
667            // deleted, the exact background-pass surprise the config forbids.
668            let mut deletable: Vec<(String, BloatDir)> = Vec::new();
669            for (label, bd) in bloat_dirs {
670                if let Some(status) = shared_storage_refusal(&bd.path) {
671                    results.push(PruneResult {
672                        repo_path: repo_path.to_path_buf(),
673                        adapter_name: adapter.name().to_string(),
674                        bloat_dir: label,
675                        size_freed: 0,
676                        shared_bytes: 0,
677                        runtime: None,
678                        status,
679                    });
680                    continue;
681                }
682
683                deletable.push((label, bd));
684            }
685
686            if deletable.is_empty() {
687                continue;
688            }
689
690            // Enforce lockfile BEFORE any deletion (skipped in dry-run — analysis only)
691            if !dry_run {
692                let policy = crate::adapters::EnforcePolicy {
693                    allow_rewrite: opts.allow_manifest_rewrite,
694                    timeout: crate::adapters::command_timeout(opts.command_timeout_secs),
695                };
696                if let Err(e) = adapter.enforce_lockfile(&project.path, policy) {
697                    for (label, _) in &deletable {
698                        results.push(PruneResult {
699                            repo_path: repo_path.to_path_buf(),
700                            adapter_name: adapter.name().to_string(),
701                            bloat_dir: label.clone(),
702                            size_freed: 0,
703                            shared_bytes: 0,
704                            runtime: None,
705                            status: PruneStatus::LockfileError(e.to_string()),
706                        });
707                    }
708                    continue;
709                }
710            }
711
712            for (label, bd) in deletable {
713                if dry_run {
714                    results.push(PruneResult {
715                        repo_path: repo_path.to_path_buf(),
716                        adapter_name: adapter.name().to_string(),
717                        bloat_dir: label,
718                        size_freed: bd.size_bytes,
719                        shared_bytes: bd.shared_bytes,
720                        runtime: None,
721                        status: PruneStatus::SkippedDryRun,
722                    });
723                    continue;
724                }
725
726                // Asked *before* the delete: the record of which interpreter built a
727                // virtual environment lives inside the environment, so a moment later
728                // there is nothing left to ask.
729                let runtime = adapter.runtime_tag(&project.path, &bd.name);
730                results.push(delete_bloat(repo_path, adapter.name(), label, &bd, runtime));
731            }
732        }
733    }
734
735    results.extend(prune_declarations(
736        repo_path,
737        per_repo_config.as_ref(),
738        opts,
739        min_size_bytes,
740        only,
741        &mut claimed,
742    ));
743
744    // Nothing recognised, or recognised but nothing on disk to reclaim.
745    if results.is_empty() {
746        results.push(PruneResult {
747            repo_path: repo_path.to_path_buf(),
748            adapter_name: "-".to_string(),
749            bloat_dir: "-".to_string(),
750            size_freed: 0,
751            shared_bytes: 0,
752            runtime: None,
753            status: PruneStatus::NoBloat,
754        });
755    }
756
757    results
758}
759
760/// The refusals that apply to any directory, whoever nominated it.
761///
762/// Shared by the adapter loop, the declared-directory pass and `collect_bloat`, because
763/// the three of them disagreeing is exactly the bug this prevents: what `devp status`
764/// counts as reclaimable has to be what `devp run` actually deletes, and a directory
765/// declared by hand is no more deletable than one an adapter found.
766///
767/// `None` means nothing here stands in the way.
768fn shared_storage_refusal(path: &Path) -> Option<PruneStatus> {
769    // A symlinked/junctioned directory points at storage we do not own — in a monorepo
770    // it is usually the workspace root's real `node_modules`. Refuse rather than risk a
771    // recursive delete outside the repo.
772    if fs::symlink_metadata(path)
773        .map(|m| m.file_type().is_symlink())
774        .unwrap_or(false)
775    {
776        return Some(PruneStatus::SkippedSymlink(format!(
777            "`{}` is a symlink to storage dev-prune does not own — left alone. Remove \
778             the link yourself if you really want it gone.",
779            crate::output::clean_path(path)
780        )));
781    }
782
783    // A mount point is the same problem wearing different clothes: the name is inside
784    // the repository but the storage is somebody else's, and here there is no link to
785    // remove — unmounting is the only way out, which is a decision for whoever mounted
786    // it.
787    if is_mount_point(path) {
788        return Some(PruneStatus::SkippedSymlink(format!(
789            "`{}` is a mount point — it is on a different filesystem than the repository \
790             around it, so its contents are shared with whatever mounted it. Left alone.",
791            crate::output::clean_path(path)
792        )));
793    }
794
795    // Invariant 7 keeps the *walk* out of nested repositories, but the directory about
796    // to be deleted can hold one inside it — a `file:` dependency, a vendored checkout —
797    // with its own unpushed history. No lockfile rebuilds somebody else's git history,
798    // so refuse.
799    if let Some(nested) = find_nested_git(path) {
800        let mut msg = format!(
801            "`{}` contains a git repository at `{}` — refusing to delete it. Move or \
802             remove that checkout yourself if it holds nothing you need.",
803            crate::output::clean_path(path),
804            crate::output::clean_path(&nested)
805        );
806        // A package-manager cache kept inside the project (uv with `cache-dir =
807        // ".uv-cache"` is the case that surfaced this) accumulates sdist checkouts,
808        // so the refusal fires on every pass until the cache moves. The name is the
809        // only signal this code has for "this is a cache", but it is enough to point
810        // at the lasting fix instead of leaving the user to re-hit the warning.
811        if path
812            .file_name()
813            .is_some_and(|n| n.to_string_lossy().to_ascii_lowercase().contains("cache"))
814        {
815            msg.push_str(
816                " If this directory is a package manager's cache configured to live \
817                 inside the project (uv's `cache-dir`, for example), relocating the \
818                 cache outside the repository clears this warning for good.",
819            );
820        }
821        return Some(PruneStatus::SkippedNestedRepo(msg));
822    }
823
824    None
825}
826
827/// Walk a tree clearing the Windows read-only attribute so `remove_dir_all` can finish.
828///
829/// Never steps through a symlink or junction: the storage behind one belongs to
830/// something else, and clearing attributes across it would touch files this prune has
831/// no claim on. Errors are ignored on purpose — the retry that follows reports honestly
832/// whatever still cannot be deleted.
833#[cfg(windows)]
834fn clear_readonly_attributes(path: &Path) {
835    let Ok(meta) = fs::symlink_metadata(path) else {
836        return;
837    };
838    if meta.file_type().is_symlink() {
839        return;
840    }
841    if meta.permissions().readonly() {
842        let mut perms = meta.permissions();
843        // This flips FILE_ATTRIBUTE_READONLY and nothing else; the world-writable
844        // hazard the lint warns about is Unix behaviour, and this function does not
845        // compile there.
846        #[allow(clippy::permissions_set_readonly_false)]
847        perms.set_readonly(false);
848        let _ = fs::set_permissions(path, perms);
849    }
850    if meta.is_dir()
851        && let Ok(entries) = fs::read_dir(path)
852    {
853        for entry in entries.flatten() {
854            clear_readonly_attributes(&entry.path());
855        }
856    }
857}
858
859/// Delete one directory and report what happened, with one retry.
860fn delete_bloat(
861    repo_path: &Path,
862    adapter_name: &str,
863    label: String,
864    bd: &BloatDir,
865    runtime: Option<String>,
866) -> PruneResult {
867    let size = bd.size_bytes;
868    // `remove_dir_all` is not atomic: one locked file — an antivirus scan, an editor's
869    // file watcher — aborts it half-way, leaving a directory that is neither usable nor
870    // gone. Retry once after a beat, because such locks are usually released within
871    // moments of being hit — and a back-to-back retry lost the race to the very
872    // scanners it was meant to outwait.
873    let delete = fs::remove_dir_all(&bd.path).or_else(|_| {
874        std::thread::sleep(std::time::Duration::from_millis(250));
875        fs::remove_dir_all(&bd.path)
876    });
877    // On Windows a single read-only file vetoes the whole delete — `go mod` writes
878    // module directories read-only on purpose, and the odd npm package ships one.
879    // Strip the attribute the way rimraf does and try once more; anything still
880    // failing after that is a real error and falls through to the honest report.
881    #[cfg(windows)]
882    let delete = delete.or_else(|e| {
883        if e.kind() == std::io::ErrorKind::PermissionDenied {
884            clear_readonly_attributes(&bd.path);
885            fs::remove_dir_all(&bd.path)
886        } else {
887            Err(e)
888        }
889    });
890    // "Not found" after a failed first attempt means the delete *did* complete — treat
891    // both the same.
892    let (size_freed, shared_bytes, status) = match delete {
893        Ok(()) => (size, bd.shared_bytes, PruneStatus::Pruned),
894        Err(_) if !bd.path.exists() => (size, bd.shared_bytes, PruneStatus::Pruned),
895        Err(e) => {
896            // Say what state the failure left behind. A half-deleted `node_modules` is
897            // corrupt whatever caused the abort, so the honest report is "no longer
898            // usable, rebuild it" — and the bytes already freed, so callers can record
899            // the partial pass and `devp restore` knows what to rebuild.
900            let remaining = crate::adapters::dir_size(&bd.path);
901            let freed = size.saturating_sub(remaining);
902            let message = if freed > 0 {
903                format!(
904                    "{e} — `{}` was partially deleted ({} of {} remains) and is no \
905                     longer usable. Close whatever holds it open, then run `devp \
906                     restore` to rebuild it.",
907                    crate::output::clean_path(&bd.path),
908                    crate::output::format_bytes(remaining),
909                    crate::output::format_bytes(size)
910                )
911            } else {
912                e.to_string()
913            };
914            (freed, 0, PruneStatus::DeleteError(message))
915        }
916    };
917    PruneResult {
918        repo_path: repo_path.to_path_buf(),
919        adapter_name: adapter_name.to_string(),
920        bloat_dir: label,
921        size_freed,
922        shared_bytes,
923        runtime,
924        status,
925    }
926}
927
928/// The directories this repository declared prunable, checked and then treated as bloat.
929///
930/// Runs after the adapters and shares their `claimed` set, so a project that declares
931/// something an adapter already found is not charged for it twice — and so the adapter's
932/// version, which has a lockfile behind it, is the one that wins.
933fn prune_declarations(
934    repo_path: &Path,
935    config: Option<&crate::config::PerRepoConfig>,
936    opts: &PruneOptions,
937    min_size_bytes: u64,
938    only: Option<&[String]>,
939    claimed: &mut std::collections::HashSet<PathBuf>,
940) -> Vec<PruneResult> {
941    let name = constants::DECLARED_ADAPTER_NAME;
942    if !opts.adapters.allows(name) {
943        return Vec::new();
944    }
945    let Some(declared) = config.and_then(|c| c.prunable.as_ref()) else {
946        return Vec::new();
947    };
948
949    let mut results = Vec::new();
950    for outcome in crate::declared::resolve(repo_path, declared) {
951        let target = match outcome {
952            crate::declared::Declaration::Prunable(target) => target,
953            // Printed even under `--only`, and even when the directory is below the
954            // size floor: a refusal means the repository asked for something dev-prune
955            // will not do, and silently doing nothing is how that stays unnoticed.
956            crate::declared::Declaration::Refused { label, reason } => {
957                results.push(PruneResult {
958                    repo_path: repo_path.to_path_buf(),
959                    adapter_name: name.to_string(),
960                    bloat_dir: label,
961                    size_freed: 0,
962                    shared_bytes: 0,
963                    runtime: None,
964                    status: PruneStatus::SkippedDeclaration(reason),
965                });
966                continue;
967            }
968        };
969
970        if only.is_some_and(|names| !names.contains(&target.label)) {
971            continue;
972        }
973        if target.size_bytes < min_size_bytes {
974            continue;
975        }
976        if !claimed.insert(target.path.clone()) {
977            continue;
978        }
979
980        let bd = BloatDir {
981            name: target.label.clone(),
982            path: target.path.clone(),
983            size_bytes: target.size_bytes,
984            shared_bytes: 0,
985        };
986        if let Some(status) = shared_storage_refusal(&bd.path) {
987            results.push(PruneResult {
988                repo_path: repo_path.to_path_buf(),
989                adapter_name: name.to_string(),
990                bloat_dir: target.label,
991                size_freed: 0,
992                shared_bytes: 0,
993                runtime: None,
994                status,
995            });
996            continue;
997        }
998        if opts.dry_run {
999            results.push(PruneResult {
1000                repo_path: repo_path.to_path_buf(),
1001                adapter_name: name.to_string(),
1002                bloat_dir: target.label,
1003                size_freed: target.size_bytes,
1004                shared_bytes: 0,
1005                runtime: None,
1006                status: PruneStatus::SkippedDryRun,
1007            });
1008            continue;
1009        }
1010
1011        // The rebuild command travels with the result the way an adapter's runtime tag
1012        // does. It is the only record of how to put this directory back, and once the
1013        // directory is gone the config file is the only place left holding it — which
1014        // is no help at all to somebody reading a finished run report.
1015        results.push(delete_bloat(
1016            repo_path,
1017            name,
1018            target.label.clone(),
1019            &bd,
1020            Some(target.rebuild.clone()),
1021        ));
1022    }
1023    results
1024}
1025
1026/// Does `path` sit on a different filesystem from the directory that holds it?
1027///
1028/// Nothing inside a repository should: `node_modules` is an ordinary directory on the
1029/// same volume as its parent. A mismatch means something was *mounted* there — a
1030/// container's `-v shared_modules:/app/node_modules`, an NFS export, a bind mount
1031/// pointing two checkouts at one cache — and what lives under it belongs to whoever
1032/// set that up, not to this repository. A lockfile can rebuild this checkout's copy;
1033/// it cannot rebuild the other consumers' copy, because there is only one copy.
1034///
1035/// Windows expresses the same idea as a reparse point, which the symlink refusal
1036/// already catches, so this is a Unix-only check.
1037#[cfg(unix)]
1038fn is_mount_point(path: &Path) -> bool {
1039    use std::os::unix::fs::MetadataExt;
1040    let Some(parent) = path.parent() else {
1041        return false;
1042    };
1043    match (fs::symlink_metadata(path), fs::symlink_metadata(parent)) {
1044        (Ok(here), Ok(above)) => here.dev() != above.dev(),
1045        // Unreadable is not evidence of a mount; the delete will fail on its own terms.
1046        _ => false,
1047    }
1048}
1049
1050#[cfg(not(unix))]
1051fn is_mount_point(_path: &Path) -> bool {
1052    false
1053}
1054
1055/// The first git repository found anywhere inside `dir`, if there is one.
1056///
1057/// `.git` as a directory is a full repository; as a file it is a submodule or worktree
1058/// gitlink. Either way the history it anchors lives (at least partly) in the tree that
1059/// is about to be deleted, and no lockfile can rebuild that.
1060fn find_nested_git(dir: &Path) -> Option<PathBuf> {
1061    walkdir::WalkDir::new(dir)
1062        .follow_links(false)
1063        .into_iter()
1064        .flatten()
1065        .find(|e| e.file_name() == ".git")
1066        .map(|e| e.into_path())
1067}
1068
1069/// Every bloat directory in a repository, across every nested project.
1070///
1071/// Returns the distinct adapter names in play alongside the deduplicated directories,
1072/// each labelled with its repository-relative path, and how many bytes each adapter
1073/// accounts for. Directories under `min_size_bytes` are omitted so that what `devp
1074/// status` reports as reclaimable is what `devp run` would actually offer to delete.
1075///
1076/// The per-adapter tally exists for the restore estimate: `node_modules` and `target`
1077/// come back at wildly different speeds, so a repository's total tells you nothing about
1078/// how long it takes to put back until it is split by who puts it back.
1079fn collect_bloat(
1080    repo_path: &Path,
1081    min_size_bytes: u64,
1082    depth: usize,
1083) -> (Vec<String>, Vec<BloatDir>, Vec<(String, u64)>) {
1084    let mut adapter_names: Vec<String> = Vec::new();
1085    let mut bloat: Vec<BloatDir> = Vec::new();
1086    let mut by_adapter: BTreeMap<String, u64> = BTreeMap::new();
1087    let mut claimed: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
1088
1089    for project in workspace::discover_to_depth(repo_path, depth) {
1090        for adapter in &project.adapters {
1091            let name = adapter.name();
1092            if !adapter_names.iter().any(|existing| existing == name) {
1093                adapter_names.push(name.to_string());
1094            }
1095            for bd in adapter.bloat_dirs(&project.path) {
1096                if bd.size_bytes < min_size_bytes {
1097                    continue;
1098                }
1099                // The same refusals the prune pass applies, for the same reason the
1100                // size floor is applied here: what `devp status` reports as reclaimable
1101                // must be what `devp run` would actually delete. A junctioned
1102                // `node_modules` even sizes somebody else's storage, so counting it
1103                // overstates the dashboard twice over.
1104                if shared_storage_refusal(&bd.path).is_some() {
1105                    continue;
1106                }
1107                if claimed.insert(bd.path.clone()) {
1108                    *by_adapter.entry(name.to_string()).or_default() += bd.size_bytes;
1109                    bloat.push(BloatDir {
1110                        name: workspace::relative_label(repo_path, &bd.path),
1111                        ..bd
1112                    });
1113                }
1114            }
1115        }
1116    }
1117
1118    // Declared directories count towards the dashboard exactly as adapter-found ones
1119    // do. A repository whose largest reclaimable tree is a declared one would otherwise
1120    // read as empty in `devp status` and then delete gigabytes in `devp run`.
1121    let declared = crate::config::PerRepoConfig::load_with_diagnostics(repo_path)
1122        .ok()
1123        .flatten()
1124        .and_then(|c| c.prunable)
1125        .unwrap_or_default();
1126    for outcome in crate::declared::resolve(repo_path, &declared) {
1127        let crate::declared::Declaration::Prunable(target) = outcome else {
1128            continue;
1129        };
1130        if target.size_bytes < min_size_bytes
1131            || shared_storage_refusal(&target.path).is_some()
1132            || !claimed.insert(target.path.clone())
1133        {
1134            continue;
1135        }
1136        let name = constants::DECLARED_ADAPTER_NAME;
1137        if !adapter_names.iter().any(|existing| existing == name) {
1138            adapter_names.push(name.to_string());
1139        }
1140        *by_adapter.entry(name.to_string()).or_default() += target.size_bytes;
1141        bloat.push(BloatDir {
1142            name: target.label,
1143            path: target.path,
1144            size_bytes: target.size_bytes,
1145            shared_bytes: 0,
1146        });
1147    }
1148
1149    (adapter_names, bloat, by_adapter.into_iter().collect())
1150}
1151
1152/// Run a prune pass across all registered repositories.
1153///
1154/// Each repository's own idle threshold applies; everything else in `opts` — the
1155/// adapter filter, the size floor, dry-run and force — is shared by the whole pass.
1156pub fn prune_all_with(registry: &mut Registry, opts: &PruneOptions) -> Vec<PruneResult> {
1157    let mut all_results = Vec::new();
1158
1159    // Collect paths first to avoid borrow issues.
1160    //
1161    // Sorted, because `repositories` is a HashMap: without this the output of two
1162    // identical runs lists the same repositories in a different order, which makes the
1163    // summary hard to read and the JSON document impossible to diff.
1164    let mut repos: Vec<(PathBuf, u64, bool)> = registry
1165        .repositories
1166        .iter()
1167        .map(|(path, entry)| {
1168            let idle_days = entry
1169                .override_idle_days
1170                .unwrap_or(registry.settings.idle_days);
1171            (path.clone(), idle_days, entry.enabled)
1172        })
1173        .collect();
1174    repos.sort_by(|a, b| a.0.cmp(&b.0));
1175
1176    for (path, idle_days, enabled) in repos {
1177        if !enabled {
1178            all_results.push(PruneResult {
1179                repo_path: path.clone(),
1180                adapter_name: "-".to_string(),
1181                bloat_dir: "-".to_string(),
1182                size_freed: 0,
1183                shared_bytes: 0,
1184                runtime: None,
1185                status: PruneStatus::Disabled,
1186            });
1187            continue;
1188        }
1189
1190        let results = prune_repo_with(
1191            &path,
1192            &PruneOptions {
1193                idle_days,
1194                ..opts.clone()
1195            },
1196        );
1197
1198        let path_freed: u64 = results
1199            .iter()
1200            .filter(|r| matches!(r.status, PruneStatus::Pruned))
1201            .map(|r| r.size_freed)
1202            .sum();
1203
1204        if path_freed > 0 {
1205            registry.mark_pruned(&path, path_freed);
1206        }
1207
1208        all_results.extend(results);
1209    }
1210
1211    all_results
1212}
1213
1214/// Run a prune pass across all registered repositories with default options.
1215pub fn prune_all(registry: &mut Registry, dry_run: bool, force: bool) -> Vec<PruneResult> {
1216    prune_all_with(registry, &PruneOptions::new(0, dry_run, force))
1217}
1218
1219/// Restore dependencies across every project in a tree.
1220///
1221/// Mirrors pruning: if `frontend/`, `services/api/` and `cli/` were each pruned, each is
1222/// restored by its own manager. The returned label is the adapter name for a project at
1223/// the root and `adapter (relative/path)` for a nested one.
1224///
1225/// Restore must reach at least as deep as the prune did. A repository configured to a
1226/// depth of 10 and pruned at 10, then restored at the default 6, comes back with its
1227/// deepest projects still empty — and nothing would have said so. `timeout` is the
1228/// user's `command_timeout_secs` for the same reason: a full reinstall is the longest
1229/// command this tool ever runs, and it used to be the only one that ignored the setting.
1230pub fn restore_project_to_depth(
1231    project_path: &Path,
1232    global_depth: usize,
1233    timeout: std::time::Duration,
1234) -> Result<Vec<(String, Result<()>)>> {
1235    let depth = workspace::resolve_depth(project_path, global_depth);
1236    let projects = workspace::discover_to_depth(project_path, depth);
1237
1238    if projects.is_empty() {
1239        anyhow::bail!(
1240            "No recognized package manager found in {}",
1241            project_path.display()
1242        );
1243    }
1244
1245    let mut results = Vec::new();
1246    for project in &projects {
1247        for adapter in &project.adapters {
1248            let label = if project.relative == "." {
1249                adapter.name().to_string()
1250            } else {
1251                format!("{} ({})", adapter.name(), project.relative)
1252            };
1253            results.push((label, adapter.restore(&project.path, timeout)));
1254        }
1255    }
1256
1257    Ok(results)
1258}
1259
1260/// The project directory that owns a bloat directory, as a repository-relative label.
1261///
1262/// Every adapter puts its bloat directory immediately inside the project it belongs to —
1263/// `node_modules`, `target`, `.venv`, `vendor` — so the owner is the label's parent.
1264/// `"node_modules"` belongs to the repository root, `"frontend/node_modules"` to
1265/// `frontend/`. Labels are `/`-separated on every platform; see
1266/// [`workspace::relative_label`].
1267fn owning_project(bloat_label: &str) -> &str {
1268    match bloat_label.rsplit_once('/') {
1269        Some((parent, _)) => parent,
1270        None => ".",
1271    }
1272}
1273
1274/// Restore exactly the projects a previous pass emptied, and nothing else.
1275///
1276/// `deleted` is the `(bloat directory label, adapter name)` pairs recorded at prune time.
1277/// [`restore_project_to_depth`] would reinstall every project in the tree; a repository
1278/// where one of five projects was pruned does not want the other four rebuilt, which on a
1279/// monorepo is the difference between one `npm ci` and five.
1280///
1281/// A recorded pair that no longer matches anything in the tree — the project was deleted,
1282/// renamed, or its manifest removed since the prune — comes back as an `Err` under its
1283/// own label rather than being dropped, because a restore that silently skips half its
1284/// work is the failure mode this whole command exists to avoid.
1285/// One recorded directory's restore: what was attempted, whether it worked, and what it
1286/// cost.
1287///
1288/// The cost is carried out of the engine rather than measured by the caller because this
1289/// is the only place that knows where one directory's rebuild starts and ends. A caller
1290/// timing the whole pass would learn the average of a `node_modules` and a `target`,
1291/// which is a number about nothing.
1292pub struct RestoreOutcome {
1293    /// `adapter (repo/relative/dir)`, as the command prints it.
1294    pub label: String,
1295    /// The adapter that owned the directory.
1296    pub adapter: String,
1297    /// Bytes the prune recorded for it — what this restore is putting back.
1298    pub bytes: u64,
1299    /// How long the rebuild took, successful or not.
1300    pub elapsed: std::time::Duration,
1301    /// Whether it worked.
1302    pub result: Result<()>,
1303}
1304
1305pub fn restore_deleted(
1306    repo_path: &Path,
1307    deleted: &[crate::config::PrunedDir],
1308    global_depth: usize,
1309    timeout: std::time::Duration,
1310) -> Vec<RestoreOutcome> {
1311    let depth = workspace::resolve_depth(repo_path, global_depth);
1312    let projects = workspace::discover_to_depth(repo_path, depth);
1313
1314    let mut results = Vec::new();
1315    for dir in deleted {
1316        let (bloat_label, adapter_name) = (&dir.bloat_dir, &dir.adapter);
1317        // Closure rather than a helper: it captures the record being restored, and the
1318        // three call sites below differ only in what they pass for `result`.
1319        let timed = |result: Result<()>, started: std::time::Instant| RestoreOutcome {
1320            label: format!("{adapter_name} ({bloat_label})"),
1321            adapter: adapter_name.clone(),
1322            bytes: dir.size_freed,
1323            elapsed: started.elapsed(),
1324            result,
1325        };
1326        let runtime = dir.runtime.as_deref();
1327        let wanted = owning_project(bloat_label);
1328        // The deleted directory's own name, so an adapter that supports several
1329        // (venv's `.venv`/`venv`/`my_env`) rebuilds the one that was actually there.
1330        let dir_name = bloat_label
1331            .rsplit_once('/')
1332            .map_or(bloat_label.as_str(), |(_, name)| name);
1333
1334        let found = projects
1335            .iter()
1336            .filter(|p| p.relative == wanted)
1337            .flat_map(|p| p.adapters.iter().map(move |a| (p, a)))
1338            .find(|(_, a)| a.name() == adapter_name);
1339
1340        if let Some((project, adapter)) = found {
1341            let started = std::time::Instant::now();
1342            let result = adapter.restore_named(&project.path, dir_name, runtime, timeout);
1343            results.push(timed(result, started));
1344            continue;
1345        }
1346
1347        // Re-detection can fail *because* the prune succeeded: deleting a virtual
1348        // environment removes the very `pyvenv.cfg` that venv detection looks for. The
1349        // recorded adapter passed detection and lockfile verification at prune time, so
1350        // when the project directory still exists, trust the record over a re-detect
1351        // that is looking at the hole the prune left.
1352        let project_dir = if wanted == "." {
1353            repo_path.to_path_buf()
1354        } else {
1355            repo_path.join(wanted)
1356        };
1357        let recorded = crate::adapters::get_all_adapters()
1358            .into_iter()
1359            .find(|a| a.name() == adapter_name);
1360        match recorded {
1361            Some(adapter) if project_dir.is_dir() => {
1362                let started = std::time::Instant::now();
1363                let result = adapter.restore_named(&project_dir, dir_name, runtime, timeout);
1364                results.push(timed(result, started));
1365            }
1366            _ => results.push(timed(
1367                Err(anyhow::anyhow!(
1368                    "`{wanted}` in {} is no longer a {adapter_name} project — it may have been \
1369                     moved or removed since the prune. Restore it by hand if it still exists.",
1370                    repo_path.display()
1371                )),
1372                std::time::Instant::now(),
1373            )),
1374        }
1375    }
1376
1377    results
1378}
1379
1380/// Reason why a repo was not selected as a prune candidate.
1381#[derive(Debug, Clone, PartialEq)]
1382pub enum SkipReason {
1383    /// Has pruneable bloat — this IS a candidate.
1384    Candidate,
1385    /// Repo has been active recently.
1386    Active,
1387    /// Opted out: either registry-disabled OR `ignore.devprune.json` file present OR `.devprune.json` ignore config.
1388    /// Both are treated identically.
1389    Ignored,
1390    /// No recognised package manager / bloat dirs found.
1391    NoBloat,
1392    /// Path no longer exists on disk.
1393    PathMissing,
1394    /// `.devprune.json` exists but does not parse, so nothing about this repo is known.
1395    ConfigError(String),
1396}
1397
1398impl std::fmt::Display for SkipReason {
1399    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1400        match self {
1401            SkipReason::Candidate => write!(f, "Candidate"),
1402            SkipReason::Active => write!(f, "Active (not idle)"),
1403            SkipReason::Ignored => write!(f, "Ignored"),
1404            SkipReason::NoBloat => write!(f, "No bloat found"),
1405            SkipReason::PathMissing => write!(f, "Path missing"),
1406            SkipReason::ConfigError(_) => write!(f, "Unreadable .devprune.json"),
1407        }
1408    }
1409}
1410
1411/// Full status entry for a single registered repository.
1412#[derive(Debug, Clone)]
1413pub struct RepoStatusEntry {
1414    /// Repository path.
1415    pub path: PathBuf,
1416    /// Registry metadata.
1417    pub entry: RepoEntry,
1418    /// Why this repo was/wasn't selected as a prune candidate.
1419    pub reason: SkipReason,
1420    /// Adapter names detected (e.g. ["npm", "uv"]).
1421    pub adapters: Vec<String>,
1422    /// Bloat directories and sizes (empty if not a candidate).
1423    pub bloat_dirs: Vec<BloatDir>,
1424    /// Total reclaimable bytes.
1425    pub reclaimable_bytes: u64,
1426    /// Reclaimable bytes split by the adapter that would have to put them back, sorted
1427    /// by adapter name. Empty whenever `bloat_dirs` is.
1428    pub reclaimable_by_adapter: Vec<(String, u64)>,
1429    /// Last git/file-system activity time.
1430    pub last_activity: Option<DateTime<Utc>>,
1431    /// Idle threshold that applies to this repo (days).
1432    pub idle_days: u64,
1433    /// AI-assistant tool output found at the repo root (graphify, understand-anything).
1434    /// Report-only: never counted in `reclaimable_bytes` or `bloat_dirs`, and never a
1435    /// prune candidate. Empty whenever `path` does not exist.
1436    pub tools: Vec<crate::tools::ToolFootprint>,
1437}
1438
1439/// Compute full status for ALL registered repositories.
1440///
1441/// Unlike `get_space_summary`, this includes every repo — active, disabled,
1442/// ignored, or missing — with a human-readable reason for each.
1443/// Everything `status` needs to say about one registered repository.
1444///
1445/// Split out of [`get_full_status`] so the scan can run several at once; it reads the
1446/// registry and the file system and writes nothing, which is what makes that safe.
1447fn status_for_repo(registry: &Registry, path: &Path, reg_entry: &RepoEntry) -> RepoStatusEntry {
1448    let registry_idle_days = reg_entry
1449        .override_idle_days
1450        .unwrap_or(registry.settings.idle_days);
1451
1452    // Path missing? Checked before the config is read, because a directory that is
1453    // gone has no config to read.
1454    if !path.exists() {
1455        return RepoStatusEntry {
1456            path: path.to_path_buf(),
1457            entry: reg_entry.clone(),
1458            reason: SkipReason::PathMissing,
1459            adapters: Vec::new(),
1460            bloat_dirs: Vec::new(),
1461            reclaimable_by_adapter: Vec::new(),
1462            reclaimable_bytes: 0,
1463            last_activity: None,
1464            idle_days: registry_idle_days,
1465            tools: Vec::new(),
1466        };
1467    }
1468
1469    // The same refusal-to-guess the prune pass makes. Reading this with
1470    // `load_from_repo` treated a broken file as "no config", so a repo that
1471    // `devp run` would refuse to touch showed up in the dashboard as a healthy
1472    // candidate with a reclaimable size next to it.
1473    let per_repo_config = match crate::config::PerRepoConfig::load_with_diagnostics(path) {
1474        Ok(cfg) => cfg,
1475        Err(e) => {
1476            return RepoStatusEntry {
1477                path: path.to_path_buf(),
1478                entry: reg_entry.clone(),
1479                reason: SkipReason::ConfigError(e),
1480                adapters: Vec::new(),
1481                bloat_dirs: Vec::new(),
1482                reclaimable_by_adapter: Vec::new(),
1483                reclaimable_bytes: 0,
1484                last_activity: last_activity_time(path),
1485                idle_days: registry_idle_days,
1486                tools: crate::tools::scan(path),
1487            };
1488        }
1489    };
1490    let idle_days = per_repo_config
1491        .as_ref()
1492        .and_then(|c| c.override_idle_days)
1493        .unwrap_or(registry_idle_days);
1494
1495    // Disabled in registry, ignore.devprune.json present, OR .devprune.json ignore=true
1496    let is_ignored = !reg_entry.enabled
1497        || path.join(constants::DEVPRUNE_IGNORE_FILE).exists()
1498        || per_repo_config.as_ref().map(|c| c.ignore).unwrap_or(false);
1499    if is_ignored {
1500        return RepoStatusEntry {
1501            path: path.to_path_buf(),
1502            entry: reg_entry.clone(),
1503            reason: SkipReason::Ignored,
1504            adapters: Vec::new(),
1505            bloat_dirs: Vec::new(),
1506            reclaimable_by_adapter: Vec::new(),
1507            reclaimable_bytes: 0,
1508            last_activity: last_activity_time(path),
1509            idle_days,
1510            tools: crate::tools::scan(path),
1511        };
1512    }
1513
1514    // Activity check. One computation drives both the column and the decision —
1515    // they used to be computed separately, from different rules, so a repo with
1516    // uncommitted edits was correctly held back as "Active" while the column next
1517    // to it showed the last *commit*, months earlier.
1518    let activity = git::get_last_activity(path).ok().flatten();
1519    let activity_time = to_utc(activity);
1520    let is_idle = git::is_idle_at(activity, idle_days);
1521
1522    // Detect adapters & bloat across every project in the repository
1523    let min_size_bytes = per_repo_config
1524        .as_ref()
1525        .and_then(|c| c.min_size_mb)
1526        .unwrap_or(registry.settings.min_size_mb)
1527        .saturating_mul(BYTES_PER_MIB);
1528    // Same resolution order as the size floor just above: the repository's own
1529    // config first, the global setting otherwise. The dashboard and a run must walk
1530    // to the same depth or `status` will list projects `run` never sees.
1531    let depth = workspace::clamp_depth(
1532        per_repo_config
1533            .as_ref()
1534            .and_then(|c| c.scan_depth)
1535            .unwrap_or(registry.settings.scan_depth),
1536    );
1537    let (adapter_names, all_bloat, by_adapter) = collect_bloat(path, min_size_bytes, depth);
1538    let reclaimable: u64 = all_bloat.iter().map(|b| b.size_bytes).sum();
1539
1540    // A repository idle past its own threshold can still be entirely held back by
1541    // adapter windows — `build_idle_days` for the opt-in build adapters, or an
1542    // `adapter_idle_days` entry. The dashboard used to call that a Candidate while a
1543    // run touched nothing. No new SkipReason: being held by a longer window *is*
1544    // being active, just against a higher bar — so the entry reports Active with
1545    // `idle_days` raised to the lowest window still unmet, which is the threshold
1546    // that actually decides this repository. A partial hold stays Candidate, because
1547    // a run would still prune the adapters whose window is met.
1548    let opt_in_names = crate::adapters::opt_in_adapter_names();
1549    let lowest_unmet_window = (is_idle && !by_adapter.is_empty())
1550        .then(|| {
1551            by_adapter
1552                .iter()
1553                .map(|(name, _)| {
1554                    let threshold = adapter_idle_threshold(
1555                        name,
1556                        opt_in_names.contains(&name.as_str()),
1557                        idle_days,
1558                        registry.settings.build_idle_days,
1559                        &registry.settings.adapter_idle_days,
1560                    );
1561                    (!git::is_idle_at(activity, threshold)).then_some(threshold)
1562                })
1563                .collect::<Option<Vec<u64>>>()
1564                .and_then(|unmet| unmet.into_iter().min())
1565        })
1566        .flatten();
1567
1568    let reason = if !is_idle || lowest_unmet_window.is_some() {
1569        SkipReason::Active
1570    } else if all_bloat.is_empty() {
1571        SkipReason::NoBloat
1572    } else {
1573        SkipReason::Candidate
1574    };
1575
1576    RepoStatusEntry {
1577        path: path.to_path_buf(),
1578        entry: reg_entry.clone(),
1579        reason,
1580        adapters: adapter_names,
1581        bloat_dirs: all_bloat,
1582        reclaimable_bytes: reclaimable,
1583        reclaimable_by_adapter: by_adapter,
1584        last_activity: activity_time,
1585        idle_days: lowest_unmet_window.unwrap_or(idle_days),
1586        tools: crate::tools::scan(path),
1587    }
1588}
1589
1590/// How many threads the status scan should use for `total` repositories.
1591///
1592/// Each repository is an independent read of the file system and the pass is bound by
1593/// I/O, not by the CPU — so oversubscribing the cores still helps, up to the point where
1594/// the disk becomes the queue rather than the processor. The multiplier is the ramp:
1595/// a machine that reports more parallelism gets proportionally more, and the ceiling
1596/// stops a 64-core box from starting more threads than any disk can usefully serve.
1597///
1598/// Never more threads than there are repositories: a registry of three should not start
1599/// thirty-two of them to do nothing. Never fewer than one, because the calling thread is
1600/// itself the first worker.
1601///
1602/// [`constants::STATUS_SCAN_THREADS_ENV`] overrides the whole calculation, clamped the
1603/// same way — the escape hatch for a machine where the guess is wrong in either
1604/// direction: a network filesystem that wants far more requests in flight, or a spinning
1605/// disk that is fastest with one.
1606fn scan_thread_count(total: usize) -> usize {
1607    let requested = std::env::var(constants::STATUS_SCAN_THREADS_ENV)
1608        .ok()
1609        .and_then(|v| v.trim().parse::<usize>().ok())
1610        .filter(|n| *n > 0)
1611        .unwrap_or_else(|| {
1612            std::thread::available_parallelism()
1613                .map(std::num::NonZeroUsize::get)
1614                .unwrap_or(4)
1615                .saturating_mul(constants::STATUS_SCAN_THREADS_PER_CORE)
1616        });
1617    clamp_scan_threads(requested, total)
1618}
1619
1620/// The clamping half of [`scan_thread_count`], without the environment read, so the
1621/// bounds can be tested without mutating process-wide state.
1622fn clamp_scan_threads(requested: usize, total: usize) -> usize {
1623    requested
1624        .clamp(1, constants::STATUS_SCAN_MAX_THREADS)
1625        .min(total.max(1))
1626}
1627
1628pub fn get_full_status(registry: &Registry) -> Vec<RepoStatusEntry> {
1629    get_full_status_reporting(registry, &|_done, _total| {})
1630}
1631
1632/// [`get_full_status`], reporting each repository as it finishes.
1633///
1634/// The scan is dominated by `collect_bloat`, which walks and sizes every dependency tree
1635/// it finds; on a registry of eighty repositories that was half a minute of silence
1636/// before anything appeared. The callback is what lets `devp status` draw a progress bar
1637/// over it instead — a dashboard that looks hung is one people kill before it renders.
1638///
1639/// The callback is invoked from several threads at once, and its first argument is the
1640/// number of repositories finished, not the index of this one: workers finish out of
1641/// order.
1642pub fn get_full_status_reporting(
1643    registry: &Registry,
1644    progress: &(dyn Fn(usize, usize) + Sync),
1645) -> Vec<RepoStatusEntry> {
1646    use std::sync::atomic::{AtomicUsize, Ordering};
1647
1648    let repos: Vec<(&PathBuf, &RepoEntry)> = registry.repositories.iter().collect();
1649    let total = repos.len();
1650    let workers = scan_thread_count(total);
1651
1652    // Work-stealing off a shared cursor rather than a fixed slice per thread, because the
1653    // cost per repository varies by orders of magnitude — one repository in a real
1654    // registry held a 2 GiB virtualenv while thirty others held nothing at all. A static
1655    // split leaves every other thread idle waiting for whichever one drew that repo.
1656    let next = AtomicUsize::new(0);
1657    let done = AtomicUsize::new(0);
1658
1659    let take_work = || {
1660        let mut mine = Vec::new();
1661        loop {
1662            let i = next.fetch_add(1, Ordering::Relaxed);
1663            if i >= total {
1664                break;
1665            }
1666            let (path, reg_entry) = repos[i];
1667            mine.push(status_for_repo(registry, path, reg_entry));
1668            progress(done.fetch_add(1, Ordering::Relaxed) + 1, total);
1669        }
1670        mine
1671    };
1672
1673    let chunks: Vec<Vec<RepoStatusEntry>> = std::thread::scope(|scope| {
1674        // `Builder::spawn_scoped` rather than `scope.spawn`, which panics when the OS
1675        // refuses a thread — under a low `ulimit -u`, in a constrained container, on a
1676        // machine already at its process limit. Refusing to draw a dashboard because the
1677        // system was busy is not an acceptable outcome, so a refusal here just means
1678        // fewer workers: whatever did start keeps pulling off the same cursor, and the
1679        // calling thread below is always one of them. In the worst case — nothing at all
1680        // would start — the scan runs single-threaded and still finishes.
1681        let mut handles = Vec::with_capacity(workers.saturating_sub(1));
1682        for n in 1..workers {
1683            match std::thread::Builder::new()
1684                .name(format!("devp-scan-{n}"))
1685                .spawn_scoped(scope, take_work)
1686            {
1687                Ok(handle) => handles.push(handle),
1688                Err(_) => break,
1689            }
1690        }
1691
1692        // The calling thread is a worker too, not a supervisor waiting on them. That is
1693        // what makes zero spawned threads a slow scan rather than a hung one.
1694        let mut chunks = vec![take_work()];
1695        chunks.extend(
1696            handles
1697                .into_iter()
1698                // A panicking worker takes the whole scan down with it. A dashboard for a
1699                // tool that deletes things must never quietly return a short list.
1700                .map(|h| h.join().unwrap_or_else(|e| std::panic::resume_unwind(e))),
1701        );
1702        chunks
1703    });
1704
1705    let mut entries: Vec<RepoStatusEntry> = chunks.into_iter().flatten().collect();
1706
1707    // Sort: what you can act on, then what is merely there, then what is gone — and by
1708    // path within each band. Path order alone put thirty-four dead entries at the top of
1709    // one dashboard, because `C:\Users\…\Temp` sorts before `V:\Code`, and the rows
1710    // that mattered started below the fold.
1711    fn rank(reason: &SkipReason) -> u8 {
1712        match reason {
1713            SkipReason::Candidate => 0,
1714            SkipReason::PathMissing => 2,
1715            _ => 1,
1716        }
1717    }
1718    entries.sort_by(|a, b| {
1719        rank(&a.reason)
1720            .cmp(&rank(&b.reason))
1721            .then_with(|| a.path.cmp(&b.path))
1722    });
1723
1724    entries
1725}
1726
1727/// The `n` repositories with the most reclaimable space, or all of them when `top` is
1728/// `None`.
1729///
1730/// `devp status` lists every registered repository, which on a machine tracking a hundred
1731/// of them pushes the handful actually worth pruning off the screen. Selection is by
1732/// reclaimable bytes, descending; the survivors are then put back into the order
1733/// [`get_full_status`] produced, so a truncated dashboard reads like a shorter version of
1734/// the full one rather than a differently-sorted one.
1735pub fn take_top(repos: &[RepoStatusEntry], top: Option<usize>) -> Vec<RepoStatusEntry> {
1736    let Some(n) = top else {
1737        return repos.to_vec();
1738    };
1739
1740    let mut ranked: Vec<usize> = (0..repos.len()).collect();
1741    ranked.sort_by_key(|&i| std::cmp::Reverse(repos[i].reclaimable_bytes));
1742    ranked.truncate(n);
1743    ranked.sort_unstable();
1744    ranked.into_iter().map(|i| repos[i].clone()).collect()
1745}
1746
1747/// Compute crisp, disambiguated project names for a repository path.
1748///
1749/// Uses `.devprune.json` custom `project_name` if present. Otherwise defaults to folder name.
1750/// If multiple repositories share the exact same folder name, disambiguates by including parent folder.
1751pub fn compute_display_name(repo_path: &Path, all_paths: &[PathBuf]) -> String {
1752    // A label, so a config that does not parse just falls through to the folder name —
1753    // the states that matter are reported by the caller.
1754    if let Some(cfg) = crate::config::PerRepoConfig::load_with_diagnostics(repo_path)
1755        .ok()
1756        .flatten()
1757        && let Some(custom) = cfg.project_name
1758        && !custom.trim().is_empty()
1759    {
1760        return custom;
1761    }
1762
1763    let folder_name = repo_path
1764        .file_name()
1765        .map(|n| n.to_string_lossy().to_string())
1766        .unwrap_or_else(|| crate::output::clean_path(repo_path));
1767
1768    // Check if duplicate folder names exist
1769    let duplicate_count = all_paths
1770        .iter()
1771        .filter(|p| {
1772            p.file_name()
1773                .map(|n| n.to_string_lossy().to_string())
1774                .as_deref()
1775                == Some(&folder_name)
1776        })
1777        .count();
1778
1779    if duplicate_count > 1
1780        && let Some(parent) = repo_path.parent()
1781        && let Some(parent_name) = parent.file_name()
1782    {
1783        return format!("{}/{}", parent_name.to_string_lossy(), folder_name);
1784    }
1785
1786    folder_name
1787}
1788
1789/// Best-effort last activity time for a repo: the later of its last commit and the
1790/// newest source file mtime, which is the same value the idle check uses.
1791fn last_activity_time(path: &Path) -> Option<DateTime<Utc>> {
1792    to_utc(git::get_last_activity(path).ok().flatten())
1793}
1794
1795/// A `SystemTime` as the UTC timestamp the status entries carry.
1796fn to_utc(system_time: Option<SystemTime>) -> Option<DateTime<Utc>> {
1797    system_time.map(|st| {
1798        let duration = st
1799            .duration_since(SystemTime::UNIX_EPOCH)
1800            .unwrap_or_default();
1801        DateTime::from_timestamp(duration.as_secs() as i64, 0).unwrap_or_default()
1802    })
1803}
1804
1805#[cfg(test)]
1806mod tests {
1807    use super::*;
1808    use std::fs;
1809    use tempfile::TempDir;
1810
1811    /// Restore in these tests either fails before running anything or runs against an
1812    /// empty project; none of them should ever sit anywhere near this long.
1813    const TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
1814
1815    #[test]
1816    fn an_ordinary_directory_is_not_a_mount_point() {
1817        // The check has to be silent on the only case that ever really happens; a real
1818        // mount cannot be created in a test without root, so this pins the negative.
1819        let tmp = TempDir::new().unwrap();
1820        let dir = tmp.path().join("node_modules");
1821        fs::create_dir_all(&dir).unwrap();
1822        assert!(!is_mount_point(&dir));
1823    }
1824
1825    #[test]
1826    fn a_filesystem_root_is_not_reported_as_a_mount_point() {
1827        // `/` has no parent, so the comparison has nothing to compare against. It must
1828        // answer "no" rather than panic on the `None`.
1829        let root = Path::new(std::path::MAIN_SEPARATOR_STR);
1830        assert!(!is_mount_point(root));
1831    }
1832
1833    #[cfg(windows)]
1834    #[test]
1835    fn a_read_only_file_does_not_stop_the_delete() {
1836        // `go mod` writes module directories read-only on purpose, and the odd npm
1837        // package ships a read-only file. `remove_dir_all` refuses those with Access
1838        // Denied, so before the attribute-clearing fallback one such file failed the
1839        // whole prune.
1840        let tmp = TempDir::new().unwrap();
1841        let bloat = tmp.path().join("node_modules");
1842        fs::create_dir_all(bloat.join("pkg")).unwrap();
1843        let file = bloat.join("pkg").join("locked.js");
1844        fs::write(&file, "x").unwrap();
1845        let mut perms = fs::metadata(&file).unwrap().permissions();
1846        perms.set_readonly(true);
1847        fs::set_permissions(&file, perms).unwrap();
1848
1849        let bd = BloatDir {
1850            name: "node_modules".into(),
1851            path: bloat.clone(),
1852            size_bytes: 1,
1853            shared_bytes: 0,
1854        };
1855        let result = delete_bloat(tmp.path(), "npm", "node_modules".into(), &bd, None);
1856        assert!(
1857            matches!(result.status, PruneStatus::Pruned),
1858            "expected Pruned, got {:?}",
1859            result.status
1860        );
1861        assert!(!bloat.exists(), "the directory must be gone");
1862    }
1863
1864    /// `git_in`, not a bare `git`: run from inside a git hook, an inherited absolute
1865    /// `GIT_DIR` would aim these fixture commands (the commit included) at the hook's
1866    /// own repository. The config isolation keeps the commit from firing the
1867    /// developer's real global `post-commit` hook.
1868    fn create_git_repo_with_commit(path: &Path) {
1869        fs::create_dir_all(path).unwrap();
1870        let git = |args: &[&str]| {
1871            crate::scanner::git::git_in(path)
1872                .env("GIT_CONFIG_GLOBAL", path.join("no-such-gitconfig"))
1873                .env("GIT_CONFIG_SYSTEM", path.join("no-such-gitconfig"))
1874                .args(args)
1875                .output()
1876                .unwrap();
1877        };
1878        git(&["init"]);
1879        fs::write(path.join("README.md"), "# Test").unwrap();
1880        git(&["add", "."]);
1881        git(&[
1882            "-c",
1883            "user.name=Test",
1884            "-c",
1885            "user.email=test@test.com",
1886            "commit",
1887            "-m",
1888            "initial",
1889        ]);
1890    }
1891
1892    #[test]
1893    fn a_bloat_label_names_the_project_that_owns_it() {
1894        assert_eq!(owning_project("node_modules"), ".");
1895        assert_eq!(owning_project("frontend/node_modules"), "frontend");
1896        assert_eq!(
1897            owning_project("packages/@scope/app/.venv"),
1898            "packages/@scope/app"
1899        );
1900    }
1901
1902    #[test]
1903    fn restore_deleted_touches_only_the_projects_that_were_pruned() {
1904        // Two npm projects, one of which was pruned. Restoring the whole tree would
1905        // reinstall both; only the recorded one may be attempted.
1906        let tmp = TempDir::new().unwrap();
1907        let root = tmp.path();
1908        for name in ["frontend", "docs"] {
1909            let dir = root.join(name);
1910            fs::create_dir_all(&dir).unwrap();
1911            fs::write(dir.join("package.json"), "{}").unwrap();
1912            fs::write(dir.join("package-lock.json"), "{}").unwrap();
1913        }
1914
1915        let deleted = vec![crate::config::PrunedDir {
1916            repo_path: root.to_path_buf(),
1917            bloat_dir: "frontend/node_modules".to_string(),
1918            adapter: "npm".to_string(),
1919            size_freed: 0,
1920            runtime: None,
1921        }];
1922        let results = restore_deleted(root, &deleted, 4, TEST_TIMEOUT);
1923
1924        assert_eq!(results.len(), 1, "one recorded directory, one attempt");
1925        assert_eq!(results[0].label, "npm (frontend/node_modules)");
1926    }
1927
1928    #[test]
1929    fn restore_deleted_reports_a_project_that_is_no_longer_there() {
1930        // Recorded at prune time, gone by restore time. Reported, never dropped: a
1931        // restore that quietly skips half its work is the failure this command prevents.
1932        let tmp = TempDir::new().unwrap();
1933        let deleted = vec![crate::config::PrunedDir {
1934            repo_path: tmp.path().to_path_buf(),
1935            bloat_dir: "services/api/.venv".to_string(),
1936            adapter: "uv".to_string(),
1937            size_freed: 0,
1938            runtime: None,
1939        }];
1940        let results = restore_deleted(tmp.path(), &deleted, 4, TEST_TIMEOUT);
1941
1942        assert_eq!(results.len(), 1);
1943        assert_eq!(results[0].label, "uv (services/api/.venv)");
1944        let err = results[0].result.as_ref().unwrap_err().to_string();
1945        assert!(err.contains("services/api"), "names the missing project");
1946        assert!(err.contains("uv"), "names the adapter that owned it");
1947    }
1948
1949    #[test]
1950    fn test_prune_status_display() {
1951        assert_eq!(PruneStatus::Pruned.to_string(), "Pruned");
1952        assert_eq!(PruneStatus::SkippedActive.to_string(), "Skipped (active)");
1953        assert_eq!(PruneStatus::SkippedDryRun.to_string(), "Skipped (dry run)");
1954        assert_eq!(
1955            PruneStatus::SkippedAdapterWindow(45).to_string(),
1956            "Not examined (adapter idle window: 45 days)"
1957        );
1958    }
1959
1960    #[test]
1961    fn test_prune_repo_non_git() {
1962        // A directory that is not a git repository produces a visible error line, not
1963        // silence — an empty result reads as "handled" in the run report.
1964        let tmp = TempDir::new().unwrap();
1965        let results = prune_repo(tmp.path(), 15, false, false);
1966        assert_eq!(results.len(), 1);
1967        assert!(matches!(results[0].status, PruneStatus::NotARepo));
1968    }
1969
1970    #[test]
1971    fn test_prune_repo_active_skipped() {
1972        let tmp = TempDir::new().unwrap();
1973        let repo = tmp.path().join("repo");
1974        create_git_repo_with_commit(&repo);
1975        // Just committed — active
1976        let results = prune_repo(&repo, 15, false, false);
1977        assert_eq!(results.len(), 1);
1978        assert!(matches!(results[0].status, PruneStatus::SkippedActive));
1979    }
1980
1981    /// An unreadable `.devprune.json` must never fall back to defaults: the file may have
1982    /// said `"ignore": true`, and guessing would delete from a repo that opted out.
1983    #[test]
1984    fn test_unparseable_per_repo_config_skips_the_repo() {
1985        let tmp = TempDir::new().unwrap();
1986        let repo = tmp.path().join("repo");
1987        create_git_repo_with_commit(&repo);
1988        fs::create_dir(repo.join("target")).unwrap();
1989        fs::write(repo.join("target").join("dummy"), "data").unwrap();
1990        fs::write(
1991            repo.join("Cargo.toml"),
1992            "[package]\nname = \"t\"\nversion = \"0.1.0\"",
1993        )
1994        .unwrap();
1995        fs::write(repo.join("Cargo.lock"), "# lockfile").unwrap();
1996        // Trailing comma — valid-looking, but not valid JSON.
1997        fs::write(repo.join(".devprune.json"), "{ \"ignore\": true, }").unwrap();
1998
1999        // Forced, non-dry-run: everything else would have this repo pruned.
2000        let results = prune_repo(&repo, 15, false, true);
2001
2002        assert_eq!(results.len(), 1);
2003        assert!(
2004            matches!(results[0].status, PruneStatus::ConfigError(_)),
2005            "expected ConfigError, got {:?}",
2006            results[0].status
2007        );
2008        assert!(repo.join("target").exists(), "target must survive");
2009    }
2010
2011    /// The dashboard and the prune pass have to agree about a broken config file.
2012    /// Reporting it as a healthy candidate with a size next to it invites the user to
2013    /// select a repository that `devp run` will then refuse to touch.
2014    #[test]
2015    fn a_broken_config_is_reported_by_status_and_not_as_a_candidate() {
2016        let tmp = TempDir::new().unwrap();
2017        let repo = tmp.path().join("repo");
2018        create_git_repo_with_commit(&repo);
2019        create_python_project(&repo);
2020        fs::write(repo.join(".devprune.json"), "{ \"ignore\": true, }").unwrap();
2021
2022        let mut registry = Registry::default();
2023        registry.add_repo(repo.clone());
2024
2025        let entries = get_full_status(&registry);
2026        assert_eq!(entries.len(), 1);
2027        assert!(
2028            matches!(entries[0].reason, SkipReason::ConfigError(_)),
2029            "expected ConfigError, got {:?}",
2030            entries[0].reason
2031        );
2032        assert_eq!(entries[0].reclaimable_bytes, 0);
2033    }
2034
2035    #[test]
2036    fn test_prune_repo_dry_run() {
2037        let tmp = TempDir::new().unwrap();
2038        let repo = tmp.path().join("repo");
2039        create_git_repo_with_commit(&repo);
2040        // Go rather than Cargo: `target/` belongs to an opt-in adapter now, and a
2041        // fixture nothing detects would make this test pass for the wrong reason.
2042        create_go_project(&repo);
2043        // Force + dry run → should report what WOULD be pruned
2044        let results = prune_repo(&repo, 15, true, true);
2045        let dry_run_results: Vec<_> = results
2046            .iter()
2047            .filter(|r| matches!(r.status, PruneStatus::SkippedDryRun))
2048            .collect();
2049        assert!(!dry_run_results.is_empty());
2050        // vendor should still exist
2051        assert!(repo.join("vendor").exists());
2052    }
2053
2054    #[test]
2055    fn an_unmet_adapter_window_reports_one_row_per_adapter_not_one_per_project() {
2056        let tmp = TempDir::new().unwrap();
2057        let repo = tmp.path().join("repo");
2058        create_git_repo_with_commit(&repo);
2059        // Two go projects, one window: the gate fires per project, the report must
2060        // not repeat itself.
2061        create_go_project(&repo);
2062        create_go_project(&repo.join("sub"));
2063
2064        // Base threshold 0: a just-committed repository already counts as idle, so
2065        // the pass reaches the per-adapter gate — where go's pinned 10 000-day window
2066        // is unmet and nothing behind it may even be looked at.
2067        let mut opts = PruneOptions::new(0, true, false);
2068        opts.adapter_idle_days.insert("go".to_string(), 10_000);
2069        let results = prune_repo_with(&repo, &opts);
2070
2071        let windows: Vec<_> = results
2072            .iter()
2073            .filter(|r| matches!(r.status, PruneStatus::SkippedAdapterWindow(_)))
2074            .collect();
2075        assert_eq!(windows.len(), 1, "{results:?}");
2076        assert_eq!(windows[0].adapter_name, "go");
2077        assert!(matches!(
2078            windows[0].status,
2079            PruneStatus::SkippedAdapterWindow(10_000)
2080        ));
2081        assert!(
2082            !results
2083                .iter()
2084                .any(|r| matches!(r.status, PruneStatus::SkippedDryRun)),
2085            "a held adapter must not also report candidates: {results:?}"
2086        );
2087    }
2088
2089    /// A Python project with a populated `requirements.txt` and a virtual environment.
2090    ///
2091    /// The venv adapter verifies its lockfile by reading files rather than by shelling
2092    /// out, so it is the one ecosystem that can be pruned for real inside a test.
2093    fn create_python_project(dir: &Path) {
2094        fs::create_dir_all(dir).unwrap();
2095        fs::write(dir.join("requirements.txt"), "requests==2.32.3\n").unwrap();
2096        let venv = dir.join(".venv");
2097        fs::create_dir_all(&venv).unwrap();
2098        fs::write(venv.join("pyvenv.cfg"), "home = /usr\n").unwrap();
2099        fs::write(venv.join("payload.bin"), vec![0u8; 4096]).unwrap();
2100    }
2101
2102    /// A Go module with a vendored dependency tree.
2103    ///
2104    /// `modules.txt` is not decoration: the adapter refuses a `vendor/` without it,
2105    /// because only a `go mod vendor` product carries one.
2106    fn create_go_project(dir: &Path) {
2107        fs::create_dir_all(dir).unwrap();
2108        fs::write(dir.join("go.mod"), "module example.com/x\n\ngo 1.22\n").unwrap();
2109        fs::write(dir.join("go.sum"), "").unwrap();
2110        let vendor = dir.join("vendor");
2111        fs::create_dir_all(&vendor).unwrap();
2112        fs::write(vendor.join("modules.txt"), "# example.com/dep v1.0.0\n").unwrap();
2113        fs::write(vendor.join("payload.bin"), vec![0u8; 4096]).unwrap();
2114    }
2115
2116    /// The `bloat_dir` labels of every result, sorted.
2117    fn labels(results: &[PruneResult]) -> Vec<String> {
2118        let mut out: Vec<String> = results.iter().map(|r| r.bloat_dir.clone()).collect();
2119        out.sort();
2120        out
2121    }
2122
2123    #[test]
2124    fn test_prune_finds_several_ecosystems_at_the_repo_root() {
2125        let tmp = TempDir::new().unwrap();
2126        let repo = tmp.path().join("repo");
2127        create_git_repo_with_commit(&repo);
2128
2129        create_go_project(&repo);
2130        fs::write(repo.join("package.json"), "{}").unwrap();
2131        fs::write(repo.join("package-lock.json"), "{}").unwrap();
2132        fs::create_dir(repo.join("node_modules")).unwrap();
2133        create_python_project(&repo);
2134
2135        let results = prune_repo(&repo, 15, true, true);
2136        assert_eq!(labels(&results), vec![".venv", "node_modules", "vendor"]);
2137    }
2138
2139    #[test]
2140    fn test_prune_finds_ecosystems_at_different_depths() {
2141        let tmp = TempDir::new().unwrap();
2142        let repo = tmp.path().join("repo");
2143        create_git_repo_with_commit(&repo);
2144
2145        fs::create_dir_all(repo.join("frontend")).unwrap();
2146        fs::write(repo.join("frontend/package.json"), "{}").unwrap();
2147        fs::write(repo.join("frontend/pnpm-lock.yaml"), "").unwrap();
2148        fs::create_dir(repo.join("frontend/node_modules")).unwrap();
2149
2150        create_go_project(&repo.join("tools/cli"));
2151
2152        create_python_project(&repo.join("services/api"));
2153
2154        let results = prune_repo(&repo, 15, true, true);
2155        assert_eq!(
2156            labels(&results),
2157            vec![
2158                "frontend/node_modules",
2159                "services/api/.venv",
2160                "tools/cli/vendor",
2161            ]
2162        );
2163    }
2164
2165    #[test]
2166    fn test_prune_deletes_only_the_selected_nested_directory() {
2167        let tmp = TempDir::new().unwrap();
2168        let repo = tmp.path().join("repo");
2169        create_git_repo_with_commit(&repo);
2170        create_python_project(&repo.join("a"));
2171        create_python_project(&repo.join("b"));
2172
2173        let results = prune_repo_selected(&repo, 0, false, true, Some(&["a/.venv".to_string()]));
2174
2175        assert_eq!(labels(&results), vec!["a/.venv"]);
2176        assert!(matches!(results[0].status, PruneStatus::Pruned));
2177        assert!(!repo.join("a/.venv").exists());
2178        assert!(repo.join("b/.venv").exists());
2179    }
2180
2181    #[test]
2182    fn test_prune_ignores_bloat_inside_a_nested_repository() {
2183        let tmp = TempDir::new().unwrap();
2184        let repo = tmp.path().join("repo");
2185        create_git_repo_with_commit(&repo);
2186        create_python_project(&repo.join("outer"));
2187
2188        // A submodule is its own repository with its own activity history — pruning it
2189        // as part of the parent would ignore that.
2190        let nested = repo.join("nested");
2191        create_git_repo_with_commit(&nested);
2192        create_python_project(&nested);
2193
2194        let results = prune_repo(&repo, 15, true, true);
2195        assert_eq!(labels(&results), vec!["outer/.venv"]);
2196    }
2197
2198    #[test]
2199    fn test_prune_repo_no_adapters() {
2200        let tmp = TempDir::new().unwrap();
2201        let repo = tmp.path().join("repo");
2202        create_git_repo_with_commit(&repo);
2203        // Force prune but no package manager files
2204        let results = prune_repo(&repo, 15, false, true);
2205        assert!(
2206            results
2207                .iter()
2208                .any(|r| matches!(r.status, PruneStatus::NoBloat))
2209        );
2210    }
2211
2212    #[test]
2213    fn test_prune_all_disabled() {
2214        let tmp = TempDir::new().unwrap();
2215        let _registry_path = tmp.path().join("registry.json");
2216
2217        let mut registry = Registry::default();
2218        let repo_path = PathBuf::from("/nonexistent/repo");
2219        registry.add_repo(repo_path.clone());
2220        registry.repositories.get_mut(&repo_path).unwrap().enabled = false;
2221
2222        let results = prune_all(&mut registry, false, false);
2223        assert!(
2224            results
2225                .iter()
2226                .any(|r| matches!(r.status, PruneStatus::Disabled))
2227        );
2228    }
2229
2230    #[test]
2231    fn test_restore_project_no_adapters() {
2232        let tmp = TempDir::new().unwrap();
2233        let result = restore_project_to_depth(
2234            tmp.path(),
2235            crate::constants::DEFAULT_SCAN_DEPTH,
2236            TEST_TIMEOUT,
2237        );
2238        assert!(result.is_err());
2239    }
2240
2241    #[test]
2242    fn restore_deleted_trusts_the_record_when_the_prune_erased_detection() {
2243        // Deleting a venv removes the very pyvenv.cfg that detection looks for, so
2244        // re-detection finds nothing. The recorded adapter must still be attempted —
2245        // not reported as "no longer a venv project".
2246        let tmp = TempDir::new().unwrap();
2247        let api = tmp.path().join("api");
2248        fs::create_dir_all(&api).unwrap();
2249        fs::write(api.join("requirements.txt"), "requests==2.32.3\n").unwrap();
2250        // No .venv on disk — the prune already removed it.
2251
2252        let deleted = vec![crate::config::PrunedDir {
2253            repo_path: tmp.path().to_path_buf(),
2254            bloat_dir: "api/.venv".to_string(),
2255            adapter: "venv".to_string(),
2256            size_freed: 0,
2257            runtime: None,
2258        }];
2259        // A zero timeout kills the rebuild the moment it starts; the test is about
2260        // which branch routes, not whether python can build an environment here.
2261        let results = restore_deleted(tmp.path(), &deleted, 4, std::time::Duration::ZERO);
2262
2263        assert_eq!(results.len(), 1);
2264        assert_eq!(results[0].label, "venv (api/.venv)");
2265        if let Err(e) = &results[0].result {
2266            assert!(
2267                !e.to_string().contains("no longer a"),
2268                "the recorded adapter must be attempted, got: {e}"
2269            );
2270        }
2271    }
2272
2273    #[test]
2274    fn a_git_repository_inside_a_bloat_directory_refuses_the_delete() {
2275        // A vendored checkout inside the directory about to be deleted carries its own
2276        // history, which no lockfile rebuilds. The whole delete must be refused.
2277        let tmp = TempDir::new().unwrap();
2278        let repo = tmp.path().join("repo");
2279        create_git_repo_with_commit(&repo);
2280        create_python_project(&repo);
2281        fs::create_dir_all(repo.join(".venv/src/vendored/.git")).unwrap();
2282
2283        let results = prune_repo_selected(&repo, 0, false, true, Some(&[".venv".to_string()]));
2284
2285        assert_eq!(results.len(), 1);
2286        let PruneStatus::SkippedNestedRepo(msg) = &results[0].status else {
2287            panic!("expected a refusal, got {:?}", results[0].status);
2288        };
2289        assert!(msg.contains("git repository"), "says why: {msg}");
2290        assert!(repo.join(".venv").exists(), "nothing may be deleted");
2291        assert_eq!(results[0].size_freed, 0);
2292    }
2293
2294    fn status_entry(name: &str, reclaimable: u64) -> RepoStatusEntry {
2295        RepoStatusEntry {
2296            path: PathBuf::from(name),
2297            entry: RepoEntry::new(),
2298            reason: SkipReason::Candidate,
2299            adapters: Vec::new(),
2300            bloat_dirs: Vec::new(),
2301            reclaimable_by_adapter: Vec::new(),
2302            reclaimable_bytes: reclaimable,
2303            last_activity: None,
2304            idle_days: 15,
2305            tools: Vec::new(),
2306        }
2307    }
2308
2309    #[test]
2310    fn take_top_selects_by_size_but_keeps_the_dashboard_order() {
2311        let repos = [
2312            status_entry("small", 10),
2313            status_entry("big", 300),
2314            status_entry("mid", 200),
2315        ];
2316        let names: Vec<String> = take_top(&repos, Some(2))
2317            .iter()
2318            .map(|e| e.path.display().to_string())
2319            .collect();
2320        // Selection is by reclaimable bytes; the survivors come back in the order the
2321        // full dashboard had them, so a truncated list reads like a shorter version of
2322        // the full one rather than a differently-sorted one.
2323        assert_eq!(names, vec!["big", "mid"]);
2324    }
2325
2326    #[test]
2327    fn take_top_without_a_limit_or_with_an_oversized_one_returns_everything() {
2328        let repos = [status_entry("a", 1), status_entry("b", 2)];
2329        assert_eq!(take_top(&repos, None).len(), 2);
2330        assert_eq!(take_top(&repos, Some(10)).len(), 2);
2331        assert_eq!(take_top(&repos, Some(0)).len(), 0);
2332    }
2333
2334    #[test]
2335    fn test_restore_project_with_npm() {
2336        let tmp = TempDir::new().unwrap();
2337        fs::write(tmp.path().join("package.json"), "{}").unwrap();
2338        fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
2339        let results = restore_project_to_depth(
2340            tmp.path(),
2341            crate::constants::DEFAULT_SCAN_DEPTH,
2342            TEST_TIMEOUT,
2343        );
2344        // Will fail because npm isn't available in test env, but shouldn't panic
2345        assert!(results.is_ok());
2346        let results = results.unwrap();
2347        assert!(!results.is_empty());
2348        assert_eq!(results[0].0, "npm");
2349    }
2350
2351    #[test]
2352    fn the_scan_never_starts_more_threads_than_there_is_work() {
2353        // A registry of three should not start thirty-two of them to do nothing.
2354        assert_eq!(clamp_scan_threads(32, 3), 3);
2355        // Nor fewer than one on an empty registry: the calling thread is worker zero, and
2356        // a count of zero would mean the work loop never ran at all.
2357        assert_eq!(clamp_scan_threads(0, 0), 1);
2358        assert_eq!(clamp_scan_threads(0, 50), 1);
2359    }
2360
2361    #[test]
2362    fn an_absurd_thread_request_is_clamped_rather_than_honoured() {
2363        // `DEV_PRUNE_SCAN_THREADS=9999` is a typo, not an instruction.
2364        assert_eq!(
2365            clamp_scan_threads(9_999, 500),
2366            constants::STATUS_SCAN_MAX_THREADS
2367        );
2368    }
2369
2370    #[test]
2371    fn a_registry_of_one_repository_is_scanned_on_the_calling_thread_alone() {
2372        assert_eq!(clamp_scan_threads(16, 1), 1);
2373    }
2374}