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 ®istry.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(®istry);
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}