use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use anyhow::Result;
use chrono::{DateTime, Utc};
use crate::adapters::BloatDir;
use crate::config::{Registry, RepoEntry};
use crate::constants;
use crate::scanner;
use crate::scanner::git;
use crate::workspace;
#[derive(Debug, Clone)]
pub enum PruneStatus {
Pruned,
SkippedActive,
SkippedDryRun,
LockfileError(String),
ActivityCheckError(String),
PathMissing,
NoBloat,
Disabled,
SkippedIgnored,
DeleteError(String),
SkippedSymlink(String),
ConfigError(String),
}
impl std::fmt::Display for PruneStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PruneStatus::Pruned => write!(f, "Pruned"),
PruneStatus::SkippedActive => write!(f, "Skipped (active)"),
PruneStatus::SkippedDryRun => write!(f, "Skipped (dry run)"),
PruneStatus::LockfileError(e) => write!(f, "Lockfile error: {e}"),
PruneStatus::ActivityCheckError(e) => write!(f, "Activity check failed: {e}"),
PruneStatus::PathMissing => {
write!(
f,
"Path no longer exists (`devp unlink --missing` clears it)"
)
}
PruneStatus::NoBloat => write!(f, "No bloat found"),
PruneStatus::Disabled => write!(f, "Disabled"),
PruneStatus::SkippedIgnored => write!(
f,
"Ignored (ignore.devprune.json or ignore config in .devprune.json)"
),
PruneStatus::DeleteError(e) => write!(f, "Delete error: {e}"),
PruneStatus::SkippedSymlink(e) => write!(f, "Skipped (symlink): {e}"),
PruneStatus::ConfigError(e) => write!(f, "Unreadable .devprune.json: {e}"),
}
}
}
pub const BYTES_PER_MIB: u64 = 1024 * 1024;
#[derive(Debug, Clone, Default, PartialEq)]
pub struct AdapterFilter {
only: Option<Vec<String>>,
skip: Vec<String>,
}
impl AdapterFilter {
pub fn new(only: Option<&str>, skip: Option<&str>) -> Result<Self> {
let known: Vec<&'static str> = crate::adapters::get_all_adapters()
.iter()
.map(|a| a.name())
.collect();
let parse = |raw: &str, flag: &str| -> Result<Vec<String>> {
let mut out = Vec::new();
for token in raw.split(',') {
let name = token.trim().to_lowercase();
if name.is_empty() {
continue;
}
if !known.contains(&name.as_str()) {
anyhow::bail!(
"`--{flag} {name}` names no known package manager. Available: {}.",
known.join(", ")
);
}
if !out.contains(&name) {
out.push(name);
}
}
if out.is_empty() {
anyhow::bail!("`--{flag}` was given no adapter names.");
}
Ok(out)
};
let only = only.map(|raw| parse(raw, "only")).transpose()?;
let skip = skip
.map(|raw| parse(raw, "skip"))
.transpose()?
.unwrap_or_default();
if let Some(only) = &only
&& let Some(clash) = only.iter().find(|n| skip.contains(n))
{
anyhow::bail!("`{clash}` is in both --only and --skip; pick one.");
}
Ok(Self { only, skip })
}
pub fn allows(&self, name: &str) -> bool {
if self.skip.iter().any(|s| s == name) {
return false;
}
match &self.only {
Some(only) => only.iter().any(|o| o == name),
None => true,
}
}
pub fn is_unrestricted(&self) -> bool {
self.only.is_none() && self.skip.is_empty()
}
pub fn describe(&self) -> Option<String> {
if self.is_unrestricted() {
return None;
}
let mut parts = Vec::new();
if let Some(only) = &self.only {
parts.push(format!("only {}", only.join(", ")));
}
if !self.skip.is_empty() {
parts.push(format!("skipping {}", self.skip.join(", ")));
}
Some(parts.join("; "))
}
}
#[derive(Debug, Clone)]
pub struct PruneOptions {
pub idle_days: u64,
pub dry_run: bool,
pub force: bool,
pub only_dirs: Option<Vec<String>>,
pub adapters: AdapterFilter,
pub min_size_bytes: u64,
pub scan_depth: usize,
pub allow_manifest_rewrite: bool,
pub command_timeout_secs: u64,
pub build_idle_days: u64,
pub adapter_idle_days: BTreeMap<String, u64>,
}
impl Default for PruneOptions {
fn default() -> Self {
Self {
idle_days: 0,
dry_run: false,
force: false,
only_dirs: None,
adapters: AdapterFilter::default(),
min_size_bytes: 0,
scan_depth: crate::constants::DEFAULT_SCAN_DEPTH,
allow_manifest_rewrite: crate::constants::DEFAULT_ALLOW_MANIFEST_REWRITE,
command_timeout_secs: crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS,
build_idle_days: crate::constants::DEFAULT_BUILD_IDLE_DAYS,
adapter_idle_days: BTreeMap::new(),
}
}
}
impl PruneOptions {
fn idle_threshold_for(&self, name: &str, opt_in: bool, base: u64) -> u64 {
let mut days = base;
if opt_in {
days = days.max(self.build_idle_days);
}
if let Some(&explicit) = self.adapter_idle_days.get(name) {
days = days.max(explicit);
}
days
}
pub fn new(idle_days: u64, dry_run: bool, force: bool) -> Self {
Self {
idle_days,
dry_run,
force,
..Self::default()
}
}
}
#[derive(Debug, Clone)]
pub struct PruneResult {
pub repo_path: PathBuf,
pub adapter_name: String,
pub bloat_dir: String,
pub size_freed: u64,
pub shared_bytes: u64,
pub runtime: Option<String>,
pub status: PruneStatus,
}
impl PruneResult {
pub fn project_dir(&self) -> PathBuf {
self.repo_path
.join(&self.bloat_dir)
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| self.repo_path.clone())
}
}
pub fn prune_repo(
repo_path: &Path,
idle_days: u64,
dry_run: bool,
force: bool,
) -> Vec<PruneResult> {
prune_repo_with(repo_path, &PruneOptions::new(idle_days, dry_run, force))
}
pub fn prune_repo_selected(
repo_path: &Path,
idle_days: u64,
dry_run: bool,
force: bool,
only: Option<&[String]>,
) -> Vec<PruneResult> {
prune_repo_with(
repo_path,
&PruneOptions {
only_dirs: only.map(<[String]>::to_vec),
..PruneOptions::new(idle_days, dry_run, force)
},
)
}
pub fn prune_repo_with(repo_path: &Path, opts: &PruneOptions) -> Vec<PruneResult> {
let idle_days = opts.idle_days;
let dry_run = opts.dry_run;
let force = opts.force;
let only = opts.only_dirs.as_deref();
let mut results = Vec::new();
if !repo_path.exists() {
results.push(PruneResult {
repo_path: repo_path.to_path_buf(),
adapter_name: "-".to_string(),
bloat_dir: "-".to_string(),
size_freed: 0,
shared_bytes: 0,
runtime: None,
status: PruneStatus::PathMissing,
});
return results;
}
if !scanner::is_git_repo(repo_path) {
results.push(PruneResult {
repo_path: repo_path.to_path_buf(),
adapter_name: "-".to_string(),
bloat_dir: "-".to_string(),
size_freed: 0,
shared_bytes: 0,
runtime: None,
status: PruneStatus::ActivityCheckError(format!(
"`{}` is no longer a git repository — nothing was touched. \
`devp unlink` removes it from the registry.",
repo_path.display()
)),
});
return results;
}
if repo_path.join(constants::DEVPRUNE_IGNORE_FILE).exists() {
results.push(PruneResult {
repo_path: repo_path.to_path_buf(),
adapter_name: "-".to_string(),
bloat_dir: "-".to_string(),
size_freed: 0,
shared_bytes: 0,
runtime: None,
status: PruneStatus::SkippedIgnored,
});
return results;
}
let per_repo_config = match crate::config::PerRepoConfig::load_with_diagnostics(repo_path) {
Ok(cfg) => cfg,
Err(e) => {
results.push(PruneResult {
repo_path: repo_path.to_path_buf(),
adapter_name: "-".to_string(),
bloat_dir: "-".to_string(),
size_freed: 0,
shared_bytes: 0,
runtime: None,
status: PruneStatus::ConfigError(e),
});
return results;
}
};
if per_repo_config.as_ref().map(|c| c.ignore).unwrap_or(false) {
results.push(PruneResult {
repo_path: repo_path.to_path_buf(),
adapter_name: "-".to_string(),
bloat_dir: "-".to_string(),
size_freed: 0,
shared_bytes: 0,
runtime: None,
status: PruneStatus::SkippedIgnored,
});
return results;
}
let effective_idle_days = per_repo_config
.as_ref()
.and_then(|c| c.override_idle_days)
.unwrap_or(idle_days);
let min_size_bytes = if only.is_some() {
0
} else {
per_repo_config
.as_ref()
.and_then(|c| c.min_size_mb)
.map(|mb| mb.saturating_mul(BYTES_PER_MIB))
.unwrap_or(opts.min_size_bytes)
};
if !force {
match git::is_repo_idle(repo_path, effective_idle_days) {
Ok(false) => {
results.push(PruneResult {
repo_path: repo_path.to_path_buf(),
adapter_name: "-".to_string(),
bloat_dir: "-".to_string(),
size_freed: 0,
shared_bytes: 0,
runtime: None,
status: PruneStatus::SkippedActive,
});
return results;
}
Ok(true) => {} Err(e) => {
results.push(PruneResult {
repo_path: repo_path.to_path_buf(),
adapter_name: "-".to_string(),
bloat_dir: "-".to_string(),
size_freed: 0,
shared_bytes: 0,
runtime: None,
status: PruneStatus::ActivityCheckError(e.to_string()),
});
return results;
}
}
}
let projects = workspace::discover_to_depth(
repo_path,
workspace::resolve_depth(repo_path, opts.scan_depth),
);
let mut claimed: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
let mut idle_at: BTreeMap<u64, bool> = BTreeMap::new();
for project in &projects {
for adapter in &project.adapters {
if !opts.adapters.allows(adapter.name()) {
continue;
}
let threshold =
opts.idle_threshold_for(adapter.name(), adapter.opt_in(), effective_idle_days);
if threshold > effective_idle_days && !force {
let idle_enough = *idle_at
.entry(threshold)
.or_insert_with(|| git::is_repo_idle(repo_path, threshold).unwrap_or(false));
if !idle_enough {
continue;
}
}
let bloat_dirs: Vec<(String, BloatDir)> = adapter
.bloat_dirs(&project.path)
.into_iter()
.map(|bd| (workspace::relative_label(repo_path, &bd.path), bd))
.filter(|(label, _)| only.is_none_or(|names| names.contains(label)))
.filter(|(_, bd)| bd.size_bytes >= min_size_bytes)
.filter(|(_, bd)| claimed.insert(bd.path.clone()))
.collect();
if bloat_dirs.is_empty() {
continue;
}
let mut deletable: Vec<(String, BloatDir)> = Vec::new();
for (label, bd) in bloat_dirs {
if fs::symlink_metadata(&bd.path)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
{
results.push(PruneResult {
repo_path: repo_path.to_path_buf(),
adapter_name: adapter.name().to_string(),
bloat_dir: label,
size_freed: 0,
shared_bytes: 0,
runtime: None,
status: PruneStatus::SkippedSymlink(format!(
"`{}` is a symlink to storage dev-prune does not own — \
left alone. Remove the link yourself if you really want \
it gone.",
bd.path.display()
)),
});
continue;
}
if is_mount_point(&bd.path) {
results.push(PruneResult {
repo_path: repo_path.to_path_buf(),
adapter_name: adapter.name().to_string(),
bloat_dir: label,
size_freed: 0,
shared_bytes: 0,
runtime: None,
status: PruneStatus::SkippedSymlink(format!(
"`{}` is a mount point — it is on a different filesystem \
than the repository around it, so its contents are shared \
with whatever mounted it. Left alone.",
bd.path.display()
)),
});
continue;
}
if let Some(nested) = find_nested_git(&bd.path) {
results.push(PruneResult {
repo_path: repo_path.to_path_buf(),
adapter_name: adapter.name().to_string(),
bloat_dir: label,
size_freed: 0,
shared_bytes: 0,
runtime: None,
status: PruneStatus::DeleteError(format!(
"`{}` contains a git repository at `{}` — refusing to \
delete it. Move or remove that checkout yourself if it \
holds nothing you need.",
bd.path.display(),
nested.display()
)),
});
continue;
}
deletable.push((label, bd));
}
if deletable.is_empty() {
continue;
}
if !dry_run {
let policy = crate::adapters::EnforcePolicy {
allow_rewrite: opts.allow_manifest_rewrite,
timeout: std::time::Duration::from_secs(opts.command_timeout_secs),
};
if let Err(e) = adapter.enforce_lockfile(&project.path, policy) {
for (label, _) in &deletable {
results.push(PruneResult {
repo_path: repo_path.to_path_buf(),
adapter_name: adapter.name().to_string(),
bloat_dir: label.clone(),
size_freed: 0,
shared_bytes: 0,
runtime: None,
status: PruneStatus::LockfileError(e.to_string()),
});
}
continue;
}
}
for (label, bd) in deletable {
if dry_run {
results.push(PruneResult {
repo_path: repo_path.to_path_buf(),
adapter_name: adapter.name().to_string(),
bloat_dir: label,
size_freed: bd.size_bytes,
shared_bytes: bd.shared_bytes,
runtime: None,
status: PruneStatus::SkippedDryRun,
});
continue;
}
let size = bd.size_bytes;
let runtime = adapter.runtime_tag(&project.path, &bd.name);
let delete = fs::remove_dir_all(&bd.path).or_else(|_| {
std::thread::sleep(std::time::Duration::from_millis(250));
fs::remove_dir_all(&bd.path)
});
match delete {
Ok(()) => {
results.push(PruneResult {
repo_path: repo_path.to_path_buf(),
adapter_name: adapter.name().to_string(),
bloat_dir: label,
size_freed: size,
shared_bytes: bd.shared_bytes,
runtime: runtime.clone(),
status: PruneStatus::Pruned,
});
}
Err(_) if !bd.path.exists() => {
results.push(PruneResult {
repo_path: repo_path.to_path_buf(),
adapter_name: adapter.name().to_string(),
bloat_dir: label,
size_freed: size,
shared_bytes: bd.shared_bytes,
runtime: runtime.clone(),
status: PruneStatus::Pruned,
});
}
Err(e) => {
let remaining = crate::adapters::dir_size(&bd.path);
let freed = size.saturating_sub(remaining);
let message = if freed > 0 {
format!(
"{e} — `{}` was partially deleted ({} of {} remains) \
and is no longer usable. Close whatever holds it open, \
then run `devp restore` to rebuild it.",
bd.path.display(),
crate::output::format_bytes(remaining),
crate::output::format_bytes(size)
)
} else {
e.to_string()
};
results.push(PruneResult {
repo_path: repo_path.to_path_buf(),
adapter_name: adapter.name().to_string(),
bloat_dir: label,
size_freed: freed,
shared_bytes: 0,
runtime,
status: PruneStatus::DeleteError(message),
});
}
}
}
}
}
if results.is_empty() {
results.push(PruneResult {
repo_path: repo_path.to_path_buf(),
adapter_name: "-".to_string(),
bloat_dir: "-".to_string(),
size_freed: 0,
shared_bytes: 0,
runtime: None,
status: PruneStatus::NoBloat,
});
}
results
}
#[cfg(unix)]
fn is_mount_point(path: &Path) -> bool {
use std::os::unix::fs::MetadataExt;
let Some(parent) = path.parent() else {
return false;
};
match (fs::symlink_metadata(path), fs::symlink_metadata(parent)) {
(Ok(here), Ok(above)) => here.dev() != above.dev(),
_ => false,
}
}
#[cfg(not(unix))]
fn is_mount_point(_path: &Path) -> bool {
false
}
fn find_nested_git(dir: &Path) -> Option<PathBuf> {
walkdir::WalkDir::new(dir)
.follow_links(false)
.into_iter()
.flatten()
.find(|e| e.file_name() == ".git")
.map(|e| e.into_path())
}
fn collect_bloat(
repo_path: &Path,
min_size_bytes: u64,
depth: usize,
) -> (Vec<String>, Vec<BloatDir>, Vec<(String, u64)>) {
let mut adapter_names: Vec<String> = Vec::new();
let mut bloat: Vec<BloatDir> = Vec::new();
let mut by_adapter: BTreeMap<String, u64> = BTreeMap::new();
let mut claimed: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
for project in workspace::discover_to_depth(repo_path, depth) {
for adapter in &project.adapters {
let name = adapter.name();
if !adapter_names.iter().any(|existing| existing == name) {
adapter_names.push(name.to_string());
}
for bd in adapter.bloat_dirs(&project.path) {
if bd.size_bytes < min_size_bytes {
continue;
}
if fs::symlink_metadata(&bd.path)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
|| is_mount_point(&bd.path)
|| find_nested_git(&bd.path).is_some()
{
continue;
}
if claimed.insert(bd.path.clone()) {
*by_adapter.entry(name.to_string()).or_default() += bd.size_bytes;
bloat.push(BloatDir {
name: workspace::relative_label(repo_path, &bd.path),
..bd
});
}
}
}
}
(adapter_names, bloat, by_adapter.into_iter().collect())
}
pub fn prune_all_with(registry: &mut Registry, opts: &PruneOptions) -> Vec<PruneResult> {
let mut all_results = Vec::new();
let mut repos: Vec<(PathBuf, u64, bool)> = registry
.repositories
.iter()
.map(|(path, entry)| {
let idle_days = entry
.override_idle_days
.unwrap_or(registry.settings.idle_days);
(path.clone(), idle_days, entry.enabled)
})
.collect();
repos.sort_by(|a, b| a.0.cmp(&b.0));
for (path, idle_days, enabled) in repos {
if !enabled {
all_results.push(PruneResult {
repo_path: path.clone(),
adapter_name: "-".to_string(),
bloat_dir: "-".to_string(),
size_freed: 0,
shared_bytes: 0,
runtime: None,
status: PruneStatus::Disabled,
});
continue;
}
let results = prune_repo_with(
&path,
&PruneOptions {
idle_days,
..opts.clone()
},
);
let path_freed: u64 = results
.iter()
.filter(|r| matches!(r.status, PruneStatus::Pruned))
.map(|r| r.size_freed)
.sum();
if path_freed > 0 {
registry.mark_pruned(&path, path_freed);
}
all_results.extend(results);
}
all_results
}
pub fn prune_all(registry: &mut Registry, dry_run: bool, force: bool) -> Vec<PruneResult> {
prune_all_with(registry, &PruneOptions::new(0, dry_run, force))
}
pub fn restore_project_to_depth(
project_path: &Path,
global_depth: usize,
timeout: std::time::Duration,
) -> Result<Vec<(String, Result<()>)>> {
let depth = workspace::resolve_depth(project_path, global_depth);
let projects = workspace::discover_to_depth(project_path, depth);
if projects.is_empty() {
anyhow::bail!(
"No recognized package manager found in {}",
project_path.display()
);
}
let mut results = Vec::new();
for project in &projects {
for adapter in &project.adapters {
let label = if project.relative == "." {
adapter.name().to_string()
} else {
format!("{} ({})", adapter.name(), project.relative)
};
results.push((label, adapter.restore(&project.path, timeout)));
}
}
Ok(results)
}
fn owning_project(bloat_label: &str) -> &str {
match bloat_label.rsplit_once('/') {
Some((parent, _)) => parent,
None => ".",
}
}
pub struct RestoreOutcome {
pub label: String,
pub adapter: String,
pub bytes: u64,
pub elapsed: std::time::Duration,
pub result: Result<()>,
}
pub fn restore_deleted(
repo_path: &Path,
deleted: &[crate::config::PrunedDir],
global_depth: usize,
timeout: std::time::Duration,
) -> Vec<RestoreOutcome> {
let depth = workspace::resolve_depth(repo_path, global_depth);
let projects = workspace::discover_to_depth(repo_path, depth);
let mut results = Vec::new();
for dir in deleted {
let (bloat_label, adapter_name) = (&dir.bloat_dir, &dir.adapter);
let timed = |result: Result<()>, started: std::time::Instant| RestoreOutcome {
label: format!("{adapter_name} ({bloat_label})"),
adapter: adapter_name.clone(),
bytes: dir.size_freed,
elapsed: started.elapsed(),
result,
};
let runtime = dir.runtime.as_deref();
let wanted = owning_project(bloat_label);
let dir_name = bloat_label
.rsplit_once('/')
.map_or(bloat_label.as_str(), |(_, name)| name);
let found = projects
.iter()
.filter(|p| p.relative == wanted)
.flat_map(|p| p.adapters.iter().map(move |a| (p, a)))
.find(|(_, a)| a.name() == adapter_name);
if let Some((project, adapter)) = found {
let started = std::time::Instant::now();
let result = adapter.restore_named(&project.path, dir_name, runtime, timeout);
results.push(timed(result, started));
continue;
}
let project_dir = if wanted == "." {
repo_path.to_path_buf()
} else {
repo_path.join(wanted)
};
let recorded = crate::adapters::get_all_adapters()
.into_iter()
.find(|a| a.name() == adapter_name);
match recorded {
Some(adapter) if project_dir.is_dir() => {
let started = std::time::Instant::now();
let result = adapter.restore_named(&project_dir, dir_name, runtime, timeout);
results.push(timed(result, started));
}
_ => results.push(timed(
Err(anyhow::anyhow!(
"`{wanted}` in {} is no longer a {adapter_name} project — it may have been \
moved or removed since the prune. Restore it by hand if it still exists.",
repo_path.display()
)),
std::time::Instant::now(),
)),
}
}
results
}
#[derive(Debug, Clone, PartialEq)]
pub enum SkipReason {
Candidate,
Active,
Ignored,
NoBloat,
PathMissing,
ConfigError(String),
}
impl std::fmt::Display for SkipReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SkipReason::Candidate => write!(f, "Candidate"),
SkipReason::Active => write!(f, "Active (not idle)"),
SkipReason::Ignored => write!(f, "Ignored"),
SkipReason::NoBloat => write!(f, "No bloat found"),
SkipReason::PathMissing => write!(f, "Path missing"),
SkipReason::ConfigError(_) => write!(f, "Unreadable .devprune.json"),
}
}
}
#[derive(Debug, Clone)]
pub struct RepoStatusEntry {
pub path: PathBuf,
pub entry: RepoEntry,
pub reason: SkipReason,
pub adapters: Vec<String>,
pub bloat_dirs: Vec<BloatDir>,
pub reclaimable_bytes: u64,
pub reclaimable_by_adapter: Vec<(String, u64)>,
pub last_activity: Option<DateTime<Utc>>,
pub idle_days: u64,
}
fn status_for_repo(registry: &Registry, path: &Path, reg_entry: &RepoEntry) -> RepoStatusEntry {
let registry_idle_days = reg_entry
.override_idle_days
.unwrap_or(registry.settings.idle_days);
if !path.exists() {
return RepoStatusEntry {
path: path.to_path_buf(),
entry: reg_entry.clone(),
reason: SkipReason::PathMissing,
adapters: Vec::new(),
bloat_dirs: Vec::new(),
reclaimable_by_adapter: Vec::new(),
reclaimable_bytes: 0,
last_activity: None,
idle_days: registry_idle_days,
};
}
let per_repo_config = match crate::config::PerRepoConfig::load_with_diagnostics(path) {
Ok(cfg) => cfg,
Err(e) => {
return RepoStatusEntry {
path: path.to_path_buf(),
entry: reg_entry.clone(),
reason: SkipReason::ConfigError(e),
adapters: Vec::new(),
bloat_dirs: Vec::new(),
reclaimable_by_adapter: Vec::new(),
reclaimable_bytes: 0,
last_activity: last_activity_time(path),
idle_days: registry_idle_days,
};
}
};
let idle_days = per_repo_config
.as_ref()
.and_then(|c| c.override_idle_days)
.unwrap_or(registry_idle_days);
let is_ignored = !reg_entry.enabled
|| path.join(constants::DEVPRUNE_IGNORE_FILE).exists()
|| per_repo_config.as_ref().map(|c| c.ignore).unwrap_or(false);
if is_ignored {
return RepoStatusEntry {
path: path.to_path_buf(),
entry: reg_entry.clone(),
reason: SkipReason::Ignored,
adapters: Vec::new(),
bloat_dirs: Vec::new(),
reclaimable_by_adapter: Vec::new(),
reclaimable_bytes: 0,
last_activity: last_activity_time(path),
idle_days,
};
}
let activity = git::get_last_activity(path).ok().flatten();
let activity_time = to_utc(activity);
let is_idle = git::is_idle_at(activity, idle_days);
let min_size_bytes = per_repo_config
.as_ref()
.and_then(|c| c.min_size_mb)
.unwrap_or(registry.settings.min_size_mb)
.saturating_mul(BYTES_PER_MIB);
let depth = workspace::clamp_depth(
per_repo_config
.as_ref()
.and_then(|c| c.scan_depth)
.unwrap_or(registry.settings.scan_depth),
);
let (adapter_names, all_bloat, by_adapter) = collect_bloat(path, min_size_bytes, depth);
let reclaimable: u64 = all_bloat.iter().map(|b| b.size_bytes).sum();
let reason = if !is_idle {
SkipReason::Active
} else if all_bloat.is_empty() {
SkipReason::NoBloat
} else {
SkipReason::Candidate
};
RepoStatusEntry {
path: path.to_path_buf(),
entry: reg_entry.clone(),
reason,
adapters: adapter_names,
bloat_dirs: all_bloat,
reclaimable_bytes: reclaimable,
reclaimable_by_adapter: by_adapter,
last_activity: activity_time,
idle_days,
}
}
fn scan_thread_count(total: usize) -> usize {
let requested = std::env::var(constants::STATUS_SCAN_THREADS_ENV)
.ok()
.and_then(|v| v.trim().parse::<usize>().ok())
.filter(|n| *n > 0)
.unwrap_or_else(|| {
std::thread::available_parallelism()
.map(std::num::NonZeroUsize::get)
.unwrap_or(4)
.saturating_mul(constants::STATUS_SCAN_THREADS_PER_CORE)
});
clamp_scan_threads(requested, total)
}
fn clamp_scan_threads(requested: usize, total: usize) -> usize {
requested
.clamp(1, constants::STATUS_SCAN_MAX_THREADS)
.min(total.max(1))
}
pub fn get_full_status(registry: &Registry) -> Vec<RepoStatusEntry> {
get_full_status_reporting(registry, &|_done, _total| {})
}
pub fn get_full_status_reporting(
registry: &Registry,
progress: &(dyn Fn(usize, usize) + Sync),
) -> Vec<RepoStatusEntry> {
use std::sync::atomic::{AtomicUsize, Ordering};
let repos: Vec<(&PathBuf, &RepoEntry)> = registry.repositories.iter().collect();
let total = repos.len();
let workers = scan_thread_count(total);
let next = AtomicUsize::new(0);
let done = AtomicUsize::new(0);
let take_work = || {
let mut mine = Vec::new();
loop {
let i = next.fetch_add(1, Ordering::Relaxed);
if i >= total {
break;
}
let (path, reg_entry) = repos[i];
mine.push(status_for_repo(registry, path, reg_entry));
progress(done.fetch_add(1, Ordering::Relaxed) + 1, total);
}
mine
};
let chunks: Vec<Vec<RepoStatusEntry>> = std::thread::scope(|scope| {
let mut handles = Vec::with_capacity(workers.saturating_sub(1));
for n in 1..workers {
match std::thread::Builder::new()
.name(format!("devp-scan-{n}"))
.spawn_scoped(scope, take_work)
{
Ok(handle) => handles.push(handle),
Err(_) => break,
}
}
let mut chunks = vec![take_work()];
chunks.extend(
handles
.into_iter()
.map(|h| h.join().unwrap_or_else(|e| std::panic::resume_unwind(e))),
);
chunks
});
let mut entries: Vec<RepoStatusEntry> = chunks.into_iter().flatten().collect();
fn rank(reason: &SkipReason) -> u8 {
match reason {
SkipReason::Candidate => 0,
SkipReason::PathMissing => 2,
_ => 1,
}
}
entries.sort_by(|a, b| {
rank(&a.reason)
.cmp(&rank(&b.reason))
.then_with(|| a.path.cmp(&b.path))
});
entries
}
pub fn take_top(repos: &[RepoStatusEntry], top: Option<usize>) -> Vec<RepoStatusEntry> {
let Some(n) = top else {
return repos.to_vec();
};
let mut ranked: Vec<usize> = (0..repos.len()).collect();
ranked.sort_by_key(|&i| std::cmp::Reverse(repos[i].reclaimable_bytes));
ranked.truncate(n);
ranked.sort_unstable();
ranked.into_iter().map(|i| repos[i].clone()).collect()
}
pub fn compute_display_name(repo_path: &Path, all_paths: &[PathBuf]) -> String {
if let Some(cfg) = crate::config::PerRepoConfig::load_with_diagnostics(repo_path)
.ok()
.flatten()
&& let Some(custom) = cfg.project_name
&& !custom.trim().is_empty()
{
return custom;
}
let folder_name = repo_path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| crate::output::clean_path(repo_path));
let duplicate_count = all_paths
.iter()
.filter(|p| {
p.file_name()
.map(|n| n.to_string_lossy().to_string())
.as_deref()
== Some(&folder_name)
})
.count();
if duplicate_count > 1
&& let Some(parent) = repo_path.parent()
&& let Some(parent_name) = parent.file_name()
{
return format!("{}/{}", parent_name.to_string_lossy(), folder_name);
}
folder_name
}
fn last_activity_time(path: &Path) -> Option<DateTime<Utc>> {
to_utc(git::get_last_activity(path).ok().flatten())
}
fn to_utc(system_time: Option<SystemTime>) -> Option<DateTime<Utc>> {
system_time.map(|st| {
let duration = st
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default();
DateTime::from_timestamp(duration.as_secs() as i64, 0).unwrap_or_default()
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::process::Command;
use tempfile::TempDir;
const TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
#[test]
fn an_ordinary_directory_is_not_a_mount_point() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("node_modules");
fs::create_dir_all(&dir).unwrap();
assert!(!is_mount_point(&dir));
}
#[test]
fn a_filesystem_root_is_not_reported_as_a_mount_point() {
let root = Path::new(std::path::MAIN_SEPARATOR_STR);
assert!(!is_mount_point(root));
}
fn create_git_repo_with_commit(path: &Path) {
fs::create_dir_all(path).unwrap();
Command::new("git")
.args(["init"])
.current_dir(path)
.output()
.unwrap();
fs::write(path.join("README.md"), "# Test").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(path)
.output()
.unwrap();
Command::new("git")
.args([
"-c",
"user.name=Test",
"-c",
"user.email=test@test.com",
"commit",
"-m",
"initial",
])
.current_dir(path)
.output()
.unwrap();
}
#[test]
fn a_bloat_label_names_the_project_that_owns_it() {
assert_eq!(owning_project("node_modules"), ".");
assert_eq!(owning_project("frontend/node_modules"), "frontend");
assert_eq!(
owning_project("packages/@scope/app/.venv"),
"packages/@scope/app"
);
}
#[test]
fn restore_deleted_touches_only_the_projects_that_were_pruned() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
for name in ["frontend", "docs"] {
let dir = root.join(name);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("package.json"), "{}").unwrap();
fs::write(dir.join("package-lock.json"), "{}").unwrap();
}
let deleted = vec![crate::config::PrunedDir {
repo_path: root.to_path_buf(),
bloat_dir: "frontend/node_modules".to_string(),
adapter: "npm".to_string(),
size_freed: 0,
runtime: None,
}];
let results = restore_deleted(root, &deleted, 4, TEST_TIMEOUT);
assert_eq!(results.len(), 1, "one recorded directory, one attempt");
assert_eq!(results[0].label, "npm (frontend/node_modules)");
}
#[test]
fn restore_deleted_reports_a_project_that_is_no_longer_there() {
let tmp = TempDir::new().unwrap();
let deleted = vec![crate::config::PrunedDir {
repo_path: tmp.path().to_path_buf(),
bloat_dir: "services/api/.venv".to_string(),
adapter: "uv".to_string(),
size_freed: 0,
runtime: None,
}];
let results = restore_deleted(tmp.path(), &deleted, 4, TEST_TIMEOUT);
assert_eq!(results.len(), 1);
assert_eq!(results[0].label, "uv (services/api/.venv)");
let err = results[0].result.as_ref().unwrap_err().to_string();
assert!(err.contains("services/api"), "names the missing project");
assert!(err.contains("uv"), "names the adapter that owned it");
}
#[test]
fn test_prune_status_display() {
assert_eq!(PruneStatus::Pruned.to_string(), "Pruned");
assert_eq!(PruneStatus::SkippedActive.to_string(), "Skipped (active)");
assert_eq!(PruneStatus::SkippedDryRun.to_string(), "Skipped (dry run)");
}
#[test]
fn test_prune_repo_non_git() {
let tmp = TempDir::new().unwrap();
let results = prune_repo(tmp.path(), 15, false, false);
assert_eq!(results.len(), 1);
assert!(matches!(
results[0].status,
PruneStatus::ActivityCheckError(_)
));
}
#[test]
fn test_prune_repo_active_skipped() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("repo");
create_git_repo_with_commit(&repo);
let results = prune_repo(&repo, 15, false, false);
assert_eq!(results.len(), 1);
assert!(matches!(results[0].status, PruneStatus::SkippedActive));
}
#[test]
fn test_unparseable_per_repo_config_skips_the_repo() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("repo");
create_git_repo_with_commit(&repo);
fs::create_dir(repo.join("target")).unwrap();
fs::write(repo.join("target").join("dummy"), "data").unwrap();
fs::write(
repo.join("Cargo.toml"),
"[package]\nname = \"t\"\nversion = \"0.1.0\"",
)
.unwrap();
fs::write(repo.join("Cargo.lock"), "# lockfile").unwrap();
fs::write(repo.join(".devprune.json"), "{ \"ignore\": true, }").unwrap();
let results = prune_repo(&repo, 15, false, true);
assert_eq!(results.len(), 1);
assert!(
matches!(results[0].status, PruneStatus::ConfigError(_)),
"expected ConfigError, got {:?}",
results[0].status
);
assert!(repo.join("target").exists(), "target must survive");
}
#[test]
fn a_broken_config_is_reported_by_status_and_not_as_a_candidate() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("repo");
create_git_repo_with_commit(&repo);
create_python_project(&repo);
fs::write(repo.join(".devprune.json"), "{ \"ignore\": true, }").unwrap();
let mut registry = Registry::default();
registry.add_repo(repo.clone());
let entries = get_full_status(®istry);
assert_eq!(entries.len(), 1);
assert!(
matches!(entries[0].reason, SkipReason::ConfigError(_)),
"expected ConfigError, got {:?}",
entries[0].reason
);
assert_eq!(entries[0].reclaimable_bytes, 0);
}
#[test]
fn test_prune_repo_dry_run() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("repo");
create_git_repo_with_commit(&repo);
create_go_project(&repo);
let results = prune_repo(&repo, 15, true, true);
let dry_run_results: Vec<_> = results
.iter()
.filter(|r| matches!(r.status, PruneStatus::SkippedDryRun))
.collect();
assert!(!dry_run_results.is_empty());
assert!(repo.join("vendor").exists());
}
fn create_python_project(dir: &Path) {
fs::create_dir_all(dir).unwrap();
fs::write(dir.join("requirements.txt"), "requests==2.32.3\n").unwrap();
let venv = dir.join(".venv");
fs::create_dir_all(&venv).unwrap();
fs::write(venv.join("pyvenv.cfg"), "home = /usr\n").unwrap();
fs::write(venv.join("payload.bin"), vec![0u8; 4096]).unwrap();
}
fn create_go_project(dir: &Path) {
fs::create_dir_all(dir).unwrap();
fs::write(dir.join("go.mod"), "module example.com/x\n\ngo 1.22\n").unwrap();
fs::write(dir.join("go.sum"), "").unwrap();
let vendor = dir.join("vendor");
fs::create_dir_all(&vendor).unwrap();
fs::write(vendor.join("modules.txt"), "# example.com/dep v1.0.0\n").unwrap();
fs::write(vendor.join("payload.bin"), vec![0u8; 4096]).unwrap();
}
fn labels(results: &[PruneResult]) -> Vec<String> {
let mut out: Vec<String> = results.iter().map(|r| r.bloat_dir.clone()).collect();
out.sort();
out
}
#[test]
fn test_prune_finds_several_ecosystems_at_the_repo_root() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("repo");
create_git_repo_with_commit(&repo);
create_go_project(&repo);
fs::write(repo.join("package.json"), "{}").unwrap();
fs::write(repo.join("package-lock.json"), "{}").unwrap();
fs::create_dir(repo.join("node_modules")).unwrap();
create_python_project(&repo);
let results = prune_repo(&repo, 15, true, true);
assert_eq!(labels(&results), vec![".venv", "node_modules", "vendor"]);
}
#[test]
fn test_prune_finds_ecosystems_at_different_depths() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("repo");
create_git_repo_with_commit(&repo);
fs::create_dir_all(repo.join("frontend")).unwrap();
fs::write(repo.join("frontend/package.json"), "{}").unwrap();
fs::write(repo.join("frontend/pnpm-lock.yaml"), "").unwrap();
fs::create_dir(repo.join("frontend/node_modules")).unwrap();
create_go_project(&repo.join("tools/cli"));
create_python_project(&repo.join("services/api"));
let results = prune_repo(&repo, 15, true, true);
assert_eq!(
labels(&results),
vec![
"frontend/node_modules",
"services/api/.venv",
"tools/cli/vendor",
]
);
}
#[test]
fn test_prune_deletes_only_the_selected_nested_directory() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("repo");
create_git_repo_with_commit(&repo);
create_python_project(&repo.join("a"));
create_python_project(&repo.join("b"));
let results = prune_repo_selected(&repo, 0, false, true, Some(&["a/.venv".to_string()]));
assert_eq!(labels(&results), vec!["a/.venv"]);
assert!(matches!(results[0].status, PruneStatus::Pruned));
assert!(!repo.join("a/.venv").exists());
assert!(repo.join("b/.venv").exists());
}
#[test]
fn test_prune_ignores_bloat_inside_a_nested_repository() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("repo");
create_git_repo_with_commit(&repo);
create_python_project(&repo.join("outer"));
let nested = repo.join("nested");
create_git_repo_with_commit(&nested);
create_python_project(&nested);
let results = prune_repo(&repo, 15, true, true);
assert_eq!(labels(&results), vec!["outer/.venv"]);
}
#[test]
fn test_prune_repo_no_adapters() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("repo");
create_git_repo_with_commit(&repo);
let results = prune_repo(&repo, 15, false, true);
assert!(
results
.iter()
.any(|r| matches!(r.status, PruneStatus::NoBloat))
);
}
#[test]
fn test_prune_all_disabled() {
let tmp = TempDir::new().unwrap();
let _registry_path = tmp.path().join("registry.json");
let mut registry = Registry::default();
let repo_path = PathBuf::from("/nonexistent/repo");
registry.add_repo(repo_path.clone());
registry.repositories.get_mut(&repo_path).unwrap().enabled = false;
let results = prune_all(&mut registry, false, false);
assert!(
results
.iter()
.any(|r| matches!(r.status, PruneStatus::Disabled))
);
}
#[test]
fn test_restore_project_no_adapters() {
let tmp = TempDir::new().unwrap();
let result = restore_project_to_depth(
tmp.path(),
crate::constants::DEFAULT_SCAN_DEPTH,
TEST_TIMEOUT,
);
assert!(result.is_err());
}
#[test]
fn restore_deleted_trusts_the_record_when_the_prune_erased_detection() {
let tmp = TempDir::new().unwrap();
let api = tmp.path().join("api");
fs::create_dir_all(&api).unwrap();
fs::write(api.join("requirements.txt"), "requests==2.32.3\n").unwrap();
let deleted = vec![crate::config::PrunedDir {
repo_path: tmp.path().to_path_buf(),
bloat_dir: "api/.venv".to_string(),
adapter: "venv".to_string(),
size_freed: 0,
runtime: None,
}];
let results = restore_deleted(tmp.path(), &deleted, 4, std::time::Duration::ZERO);
assert_eq!(results.len(), 1);
assert_eq!(results[0].label, "venv (api/.venv)");
if let Err(e) = &results[0].result {
assert!(
!e.to_string().contains("no longer a"),
"the recorded adapter must be attempted, got: {e}"
);
}
}
#[test]
fn a_git_repository_inside_a_bloat_directory_refuses_the_delete() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("repo");
create_git_repo_with_commit(&repo);
create_python_project(&repo);
fs::create_dir_all(repo.join(".venv/src/vendored/.git")).unwrap();
let results = prune_repo_selected(&repo, 0, false, true, Some(&[".venv".to_string()]));
assert_eq!(results.len(), 1);
let PruneStatus::DeleteError(msg) = &results[0].status else {
panic!("expected a refusal, got {:?}", results[0].status);
};
assert!(msg.contains("git repository"), "says why: {msg}");
assert!(repo.join(".venv").exists(), "nothing may be deleted");
assert_eq!(results[0].size_freed, 0);
}
fn status_entry(name: &str, reclaimable: u64) -> RepoStatusEntry {
RepoStatusEntry {
path: PathBuf::from(name),
entry: RepoEntry::new(),
reason: SkipReason::Candidate,
adapters: Vec::new(),
bloat_dirs: Vec::new(),
reclaimable_by_adapter: Vec::new(),
reclaimable_bytes: reclaimable,
last_activity: None,
idle_days: 15,
}
}
#[test]
fn take_top_selects_by_size_but_keeps_the_dashboard_order() {
let repos = [
status_entry("small", 10),
status_entry("big", 300),
status_entry("mid", 200),
];
let names: Vec<String> = take_top(&repos, Some(2))
.iter()
.map(|e| e.path.display().to_string())
.collect();
assert_eq!(names, vec!["big", "mid"]);
}
#[test]
fn take_top_without_a_limit_or_with_an_oversized_one_returns_everything() {
let repos = [status_entry("a", 1), status_entry("b", 2)];
assert_eq!(take_top(&repos, None).len(), 2);
assert_eq!(take_top(&repos, Some(10)).len(), 2);
assert_eq!(take_top(&repos, Some(0)).len(), 0);
}
#[test]
fn test_restore_project_with_npm() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("package.json"), "{}").unwrap();
fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
let results = restore_project_to_depth(
tmp.path(),
crate::constants::DEFAULT_SCAN_DEPTH,
TEST_TIMEOUT,
);
assert!(results.is_ok());
let results = results.unwrap();
assert!(!results.is_empty());
assert_eq!(results[0].0, "npm");
}
#[test]
fn the_scan_never_starts_more_threads_than_there_is_work() {
assert_eq!(clamp_scan_threads(32, 3), 3);
assert_eq!(clamp_scan_threads(0, 0), 1);
assert_eq!(clamp_scan_threads(0, 50), 1);
}
#[test]
fn an_absurd_thread_request_is_clamped_rather_than_honoured() {
assert_eq!(
clamp_scan_threads(9_999, 500),
constants::STATUS_SCAN_MAX_THREADS
);
}
#[test]
fn a_registry_of_one_repository_is_scanned_on_the_calling_thread_alone() {
assert_eq!(clamp_scan_threads(16, 1), 1);
}
}