use anyhow::Result;
use std::io::{self, IsTerminal, Write};
use std::path::Path;
use crate::adapters;
use crate::config::Registry;
use crate::constants;
use crate::engine::{self, AdapterFilter, PruneOptions, PruneResult, PruneStatus};
use crate::json;
use crate::output;
use crate::tui;
pub struct RunArgs<'a> {
pub target_path: Option<&'a str>,
pub dry_run: bool,
pub force: bool,
pub yes: bool,
pub daemon: bool,
pub only: Option<&'a str>,
pub skip: Option<&'a str>,
pub min_size_mb: Option<u64>,
pub except: Option<&'a str>,
pub json: bool,
pub explain: bool,
}
pub fn run(args: RunArgs<'_>) -> Result<()> {
let filter = AdapterFilter::new(args.only, args.skip)?;
if args.json && !args.dry_run && !args.yes {
return Err(anyhow::Error::new(crate::UsageError(
"`--json` cannot ask for confirmation. Pass `--dry-run` to analyse, or `--yes` to delete."
.to_string(),
)));
}
if !args.json {
output::print_banner();
if args.force {
print_ignore_idle_notice();
}
}
if args.explain {
return run_explain(&args, &filter);
}
if let Some(target_str) = args.target_path {
return run_targeted(&args, &filter, target_str);
}
run_registry(&args, &filter)
}
fn print_ignore_idle_notice() {
output::print_warning(
"Idle check bypassed — repositories you are working in right now are fair game.",
);
println!(
" Still enforced: lockfile verification, `ignore.devprune.json`, `\"ignore\": true`,"
);
println!(
" symlinked directories, and nested repositories. This flag does not turn those off."
);
println!();
println!(" If you reached for this because something would not prune, it is usually:");
println!(" • \"lockfile verification failed\" → run the fix command printed next to it;");
println!(" it regenerates the lockfile so the reinstall is guaranteed to work.");
println!(" • nothing listed at all → the project is deeper than `scan_depth`,");
println!(" or under `min_size_mb`. Try `devp status` to see what dev-prune can see.");
println!(" • \"could not be examined\" → `.devprune.json` has a syntax error.");
println!();
println!(" Still stuck? Point your AI assistant at the bundled skill — `devp skill`");
println!(" exports a SKILL.md that teaches it this tool, exit codes and all. It has");
println!(" read the manual more recently than either of us.");
println!();
}
fn run_targeted(args: &RunArgs<'_>, filter: &AdapterFilter, target_str: &str) -> Result<()> {
let raw = std::path::Path::new(target_str);
let path = if raw.exists() {
raw.canonicalize().unwrap_or_else(|_| raw.to_path_buf())
} else {
raw.to_path_buf()
};
let clean = output::clean_path(&path);
if !crate::scanner::is_git_repo(&path) {
anyhow::bail!("{clean} is not a Git repository — dev-prune only prunes Git repos.");
}
let registry = Registry::load().ok();
let idle_days = registry
.as_ref()
.map(|r| {
r.repositories
.get(&path)
.and_then(|e| e.override_idle_days)
.unwrap_or(r.settings.idle_days)
})
.unwrap_or(constants::DEFAULT_IDLE_DAYS);
let opts = PruneOptions {
idle_days,
dry_run: args.dry_run,
force: args.force,
only_dirs: None,
adapters: filter.clone(),
min_size_bytes: resolve_min_size(args, registry.as_ref()),
scan_depth: resolve_scan_depth(registry.as_ref()),
allow_manifest_rewrite: resolve_manifest_rewrite(registry.as_ref()),
command_timeout_secs: resolve_command_timeout(registry.as_ref()),
build_idle_days: resolve_build_idle_days(registry.as_ref()),
adapter_idle_days: resolve_adapter_idle_days(registry.as_ref()),
};
let results = engine::prune_repo_with(&path, &opts);
let error_count = results
.iter()
.filter(|r| {
matches!(
r.status,
PruneStatus::LockfileError(_)
| PruneStatus::ActivityCheckError(_)
| PruneStatus::DeleteError(_)
| PruneStatus::ConfigError(_)
)
})
.count();
record_targeted_prune(&path, &results, args.dry_run);
if args.json {
json::emit(&json::run_document(&results, args.dry_run))?;
if error_count > 0 {
anyhow::bail!("{error_count} directories in {clean} could not be pruned.");
}
return Ok(());
}
output::print_header(&format!("dev-prune Targeted Run ({clean})"));
if let Some(desc) = filter.describe() {
output::print_info(&format!("Adapter filter: {desc}"));
}
if results.is_empty() {
output::print_info(&format!("No pruneable bloat directories found in {clean}."));
return Ok(());
}
let mut total_freed = 0;
for result in results {
match &result.status {
PruneStatus::Pruned => {
total_freed += result.size_freed;
output::print_success(&format!(
"{} → {} ({}) — {}{}",
output::clean_path(&result.repo_path),
result.bloat_dir,
output::format_bytes(result.size_freed),
result.adapter_name,
output::shared_note(result.shared_bytes, &result.adapter_name)
));
}
PruneStatus::SkippedDryRun => {
output::print_info(&format!(
" • {} → {} ({}) [{}] (Dry Run){}",
output::clean_path(&result.repo_path),
result.bloat_dir,
output::format_bytes(result.size_freed),
result.adapter_name,
output::shared_note(result.shared_bytes, &result.adapter_name)
));
}
PruneStatus::SkippedActive => {
output::print_info(&format!(
"{clean} is currently active (not idle). Use `devp --ignore-idle run` to override."
));
}
PruneStatus::LockfileError(e) => report_lockfile_failure(&result, e),
PruneStatus::ActivityCheckError(e) => {
output::print_error(&format!(
"{clean} skipped — its activity could not be determined:\n {}",
e.trim()
));
}
PruneStatus::DeleteError(e) => {
output::print_error(&format!("{clean} delete error: {e}"));
}
PruneStatus::ConfigError(e) => {
output::print_error(&format!(
"{clean} skipped — its .devprune.json could not be read:\n {}\n \
Fix it, or run `devp config {clean} --update` to reset it.",
e.trim()
));
}
PruneStatus::SkippedSymlink(e) => {
output::print_warning(&format!("{clean} → {}", e.trim()));
}
_ => {}
}
}
if !args.dry_run && total_freed > 0 {
output::print_success(&format!(
"Freed: {} in {clean}",
output::format_bytes(total_freed)
));
}
if error_count > 0 {
anyhow::bail!("{error_count} directories in {clean} could not be pruned.");
}
Ok(())
}
fn record_targeted_prune(path: &std::path::Path, results: &[PruneResult], dry_run: bool) {
if dry_run {
return;
}
let pruned: Vec<crate::config::PrunedDir> = results
.iter()
.filter(|r| {
matches!(r.status, PruneStatus::Pruned)
|| (matches!(r.status, PruneStatus::DeleteError(_)) && r.size_freed > 0)
})
.map(|r| crate::config::PrunedDir {
repo_path: r.repo_path.clone(),
bloat_dir: r.bloat_dir.clone(),
adapter: r.adapter_name.clone(),
size_freed: r.size_freed,
runtime: r.runtime.clone(),
})
.collect();
if pruned.is_empty() {
return;
}
let freed: u64 = pruned.iter().map(|d| d.size_freed).sum();
if let Ok(mut registry) = Registry::load() {
registry.mark_pruned(path, freed);
registry.record_prune(pruned);
let _ = registry.save();
}
}
fn resolve_min_size(args: &RunArgs<'_>, registry: Option<&Registry>) -> u64 {
let mb = args
.min_size_mb
.or_else(|| registry.map(|r| r.settings.min_size_mb))
.unwrap_or(constants::DEFAULT_MIN_SIZE_MB);
mb.saturating_mul(engine::BYTES_PER_MIB)
}
fn parse_except(spec: Option<&str>) -> Vec<String> {
spec.map(|s| {
s.split(',')
.map(|part| {
crate::config::expand_tilde(part.trim())
.trim_end_matches(['/', '\\'])
.to_lowercase()
})
.filter(|part| !part.is_empty())
.collect()
})
.unwrap_or_default()
}
fn is_excepted(repo_path: &Path, except: &[String]) -> bool {
if except.is_empty() {
return false;
}
let full = output::clean_path(repo_path)
.to_lowercase()
.replace('\\', "/");
let name = repo_path
.file_name()
.map(|n| n.to_string_lossy().to_lowercase())
.unwrap_or_default();
except.iter().any(|want| {
let want = want.replace('\\', "/");
name == want || full == want || full.ends_with(&format!("/{want}"))
})
}
fn resolve_scan_depth(registry: Option<&Registry>) -> usize {
registry
.map(|r| r.settings.scan_depth)
.unwrap_or(constants::DEFAULT_SCAN_DEPTH)
}
fn resolve_build_idle_days(registry: Option<&Registry>) -> u64 {
registry
.map(|r| r.settings.build_idle_days)
.unwrap_or(constants::DEFAULT_BUILD_IDLE_DAYS)
}
fn resolve_adapter_idle_days(
registry: Option<&Registry>,
) -> std::collections::BTreeMap<String, u64> {
registry
.map(|r| r.settings.adapter_idle_days.clone())
.unwrap_or_default()
}
fn resolve_command_timeout(registry: Option<&Registry>) -> u64 {
registry
.map(|r| r.settings.command_timeout_secs)
.unwrap_or(constants::DEFAULT_COMMAND_TIMEOUT_SECS)
}
fn resolve_manifest_rewrite(registry: Option<&Registry>) -> bool {
registry
.map(|r| r.settings.allow_manifest_rewrite)
.unwrap_or(constants::DEFAULT_ALLOW_MANIFEST_REWRITE)
}
fn run_registry(args: &RunArgs<'_>, filter: &AdapterFilter) -> Result<()> {
if !args.json {
if args.dry_run {
output::print_header("dev-prune run (DRY RUN)");
} else {
output::print_header("dev-prune run");
}
}
let mut registry = Registry::load()?;
if !args.json && crate::commands::update::notify_if_outdated(&mut registry) {
let _ = registry.save();
}
if registry.repo_count() == 0 {
if args.json {
return json::emit(&json::run_document(&[], args.dry_run));
}
output::print_warning("No repositories registered. Run `dev-prune init` first.");
return Ok(());
}
let except = parse_except(args.except);
if !except.is_empty() {
let unmatched: Vec<&String> = except
.iter()
.filter(|want| {
!registry
.repositories
.keys()
.any(|p| is_excepted(p, std::slice::from_ref(*want)))
})
.collect();
if !unmatched.is_empty() {
anyhow::bail!(
"`--except` names no registered repository: {}\n \
Run `devp status` to see the registered names.",
unmatched
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ")
);
}
}
let min_size_bytes = resolve_min_size(args, Some(®istry));
let analysis = PruneOptions {
idle_days: 0, dry_run: true,
force: args.force,
only_dirs: None,
adapters: filter.clone(),
min_size_bytes,
scan_depth: resolve_scan_depth(Some(®istry)),
allow_manifest_rewrite: resolve_manifest_rewrite(Some(®istry)),
command_timeout_secs: resolve_command_timeout(Some(®istry)),
build_idle_days: resolve_build_idle_days(Some(®istry)),
adapter_idle_days: resolve_adapter_idle_days(Some(®istry)),
};
if !args.json {
output::print_info(&format!(
"Scanning {} registered repositories for prune candidates...",
registry.repo_count()
));
if let Some(desc) = filter.describe() {
output::print_info(&format!("Adapter filter: {desc}"));
}
if min_size_bytes > 0 {
output::print_info(&format!(
"Size floor: ignoring directories under {}",
output::format_bytes(min_size_bytes)
));
}
}
let mut candidates: Vec<PruneResult> = Vec::new();
let mut blocked: Vec<PruneResult> = Vec::new();
let mut linked: Vec<PruneResult> = Vec::new();
let mut missing: Vec<PruneResult> = Vec::new();
for result in engine::prune_all_with(&mut registry, &analysis) {
if is_excepted(&result.repo_path, &except) {
continue;
}
match result.status {
PruneStatus::SkippedDryRun => candidates.push(result),
PruneStatus::ConfigError(_)
| PruneStatus::LockfileError(_)
| PruneStatus::ActivityCheckError(_)
| PruneStatus::DeleteError(_) => blocked.push(result),
PruneStatus::SkippedSymlink(_) => linked.push(result),
PruneStatus::PathMissing => missing.push(result),
_ => {}
}
}
if !args.json && !except.is_empty() {
output::print_info(&format!("Leaving alone: {}", except.join(", ")));
}
if args.daemon {
let before = candidates.len();
candidates.retain(|c| {
match crate::config::PerRepoConfig::load_with_diagnostics(&c.repo_path) {
Ok(Some(cfg)) => !cfg.disable_daemon,
Ok(None) => true,
Err(_) => false,
}
});
let skipped = before - candidates.len();
if skipped > 0 && !args.json {
output::print_info(&format!(
"Skipped {skipped} bloat directories in repositories that set `disable_daemon`."
));
}
}
if args.dry_run {
if args.json {
json::emit(&json::run_document(
&[candidates, blocked, linked, missing].concat(),
true,
))?;
return Ok(());
}
if candidates.is_empty() && blocked.is_empty() && linked.is_empty() && missing.is_empty() {
output::print_info("No idle repositories or pruneable bloat directories found.");
return Ok(());
}
if !candidates.is_empty() {
report_candidates(&candidates);
}
let total: u64 = candidates.iter().map(|c| c.size_freed).sum();
output::print_header("Summary (Dry Run)");
output::print_info(&format!(
"Would free {} across {} bloat directories.",
output::format_bytes(total),
candidates.len()
));
report_blocked(&blocked);
report_linked(&linked);
report_missing(&missing);
return Ok(());
}
if candidates.is_empty() {
if args.json {
json::emit(&json::run_document(
&[blocked.clone(), linked, missing].concat(),
false,
))?;
return fail_if_blocked(&blocked);
}
if blocked.is_empty() && linked.is_empty() && missing.is_empty() {
output::print_info("No idle repositories or pruneable bloat directories found.");
return Ok(());
}
output::print_info("No pruneable bloat directories found.");
report_blocked(&blocked);
report_linked(&linked);
report_missing(&missing);
return fail_if_blocked(&blocked);
}
let total_reclaimable: u64 = candidates.iter().map(|c| c.size_freed).sum();
if !args.json {
report_binaries(&candidates);
report_candidates(&candidates);
output::print_info(&format!(
"Total Reclaimable Space: {}",
output::format_bytes_styled(total_reclaimable)
));
report_blocked(&blocked);
report_linked(&linked);
report_missing(&missing);
}
let target_candidates: Vec<PruneResult> = if args.json
|| args.yes
|| !registry.settings.require_confirmation
{
candidates
} else if io::stdout().is_terminal() && io::stdin().is_terminal() {
eprintln!();
eprintln!(
" Loading interactive selector... (↑↓ navigate, Space toggle, Enter confirm, q cancel)"
);
eprintln!();
let selected = tui::selection_view::select_candidates_tui(&candidates)?;
if selected.is_empty() {
output::print_info("Prune pass cancelled by user (0 candidates selected).");
return Ok(());
}
selected
} else {
if !io::stdin().is_terminal() {
anyhow::bail!(
"Deleting {} directories ({}) needs confirmation, and there is no \
terminal to ask on. Re-run with `--yes` to confirm, or `--dry-run` \
to only analyse.",
candidates.len(),
output::format_bytes(total_reclaimable)
);
}
println!();
output::print_warning("CAUTION: Deleting bloat directories cannot be undone directly.");
output::print_info(
"Note: You can re-install missing dependencies anytime using `dev-prune restore`.",
);
eprint!(
"Proceed with deletion of {} directories ({})? [y/N]: ",
candidates.len(),
output::format_bytes(total_reclaimable)
);
io::stderr().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let trimmed = input.trim().to_lowercase();
if trimmed != "y" && trimmed != "yes" {
output::print_info("Prune pass aborted by user.");
return Ok(());
}
candidates
};
if !args.json {
let selected_total_bytes: u64 = target_candidates.iter().map(|c| c.size_freed).sum();
output::print_header(&format!(
"Executing Progressive Deletion ({} repos, {})",
target_candidates.len(),
output::format_bytes(selected_total_bytes)
));
}
let mut selection: Vec<(std::path::PathBuf, Vec<String>)> = Vec::new();
for candidate in &target_candidates {
match selection
.iter_mut()
.find(|(p, _)| *p == candidate.repo_path)
{
Some((_, dirs)) => dirs.push(candidate.bloat_dir.clone()),
None => selection.push((
candidate.repo_path.clone(),
vec![candidate.bloat_dir.clone()],
)),
}
}
let mut error_count = blocked.len();
let mut all_results: Vec<PruneResult> = blocked;
all_results.extend(linked);
all_results.extend(missing);
let mut total_freed: u64 = 0;
let mut pruned_count = 0;
let mut pruned_dirs: Vec<crate::config::PrunedDir> = Vec::new();
let pass_at = chrono::Utc::now();
for (repo_path, dirs) in &selection {
let recorded_before = pruned_dirs.len();
let idle_days = registry
.repositories
.get(repo_path)
.and_then(|e| e.override_idle_days)
.unwrap_or(registry.settings.idle_days);
let single_results = engine::prune_repo_with(
repo_path,
&PruneOptions {
idle_days,
dry_run: false,
force: args.force,
only_dirs: Some(dirs.clone()),
adapters: filter.clone(),
min_size_bytes: 0,
scan_depth: analysis.scan_depth,
allow_manifest_rewrite: analysis.allow_manifest_rewrite,
command_timeout_secs: analysis.command_timeout_secs,
build_idle_days: analysis.build_idle_days,
adapter_idle_days: analysis.adapter_idle_days.clone(),
},
);
for result in single_results {
match &result.status {
PruneStatus::Pruned => {
total_freed += result.size_freed;
pruned_count += 1;
registry.mark_pruned(&result.repo_path, result.size_freed);
pruned_dirs.push(crate::config::PrunedDir {
repo_path: result.repo_path.clone(),
bloat_dir: result.bloat_dir.clone(),
adapter: result.adapter_name.clone(),
size_freed: result.size_freed,
runtime: result.runtime.clone(),
});
if !args.json {
output::print_success(&format!(
"{} → {} ({}) — {}{}",
output::clean_path(&result.repo_path),
result.bloat_dir,
output::format_bytes(result.size_freed),
result.adapter_name,
output::shared_note(result.shared_bytes, &result.adapter_name)
));
}
}
PruneStatus::LockfileError(e) => {
error_count += 1;
if !args.json {
report_lockfile_failure(&result, e);
}
}
PruneStatus::ActivityCheckError(e) => {
error_count += 1;
if !args.json {
output::print_error(&format!(
"{} skipped — its activity could not be determined:\n {}",
output::clean_path(&result.repo_path),
e.trim()
));
}
}
PruneStatus::DeleteError(e) => {
error_count += 1;
if result.size_freed > 0 {
pruned_dirs.push(crate::config::PrunedDir {
repo_path: result.repo_path.clone(),
bloat_dir: result.bloat_dir.clone(),
adapter: result.adapter_name.clone(),
size_freed: result.size_freed,
runtime: result.runtime.clone(),
});
}
if !args.json {
output::print_error(&format!(
"{} → delete failed: {}",
output::clean_path(&result.repo_path),
e,
));
}
}
PruneStatus::ConfigError(e) => {
error_count += 1;
if !args.json {
let clean_p = output::clean_path(&result.repo_path);
output::print_error(&format!(
"{clean_p} skipped — its .devprune.json could not be read:\n {}",
e.trim()
));
output::print_info(&format!(
" Fix command: devp config {clean_p} --update"
));
}
}
PruneStatus::SkippedActive if !args.json => {
output::print_info(&format!(
"{} became active since the analysis — left alone. \
Use `--ignore-idle` to prune it anyway.",
output::clean_path(&result.repo_path)
));
}
_ => {}
}
all_results.push(result);
}
if pruned_dirs.len() > recorded_before {
registry.record_prune_progress(pass_at, pruned_dirs.clone());
let _ = registry.save();
}
}
registry.record_prune_progress(pass_at, pruned_dirs);
registry.save()?;
if args.json {
json::emit(&json::run_document(&all_results, false))?;
if error_count > 0 {
anyhow::bail!("{error_count} repositories could not be pruned.");
}
return Ok(());
}
output::print_header("Summary");
output::print_success(&format!(
"Freed: {} across {pruned_count} directories",
output::format_bytes_styled(total_freed)
));
if error_count > 0 {
output::print_warning(&format!("{error_count} repos were not pruned."));
if all_results
.iter()
.any(|r| matches!(r.status, PruneStatus::LockfileError(_)))
{
output::print_info(
"Lockfile verification cannot be bypassed: without a lockfile the deleted \
dependencies could not be reinstalled. Run the fix command shown above for \
each repo, then re-run `devp run`.",
);
}
anyhow::bail!("{error_count} repositories could not be pruned.");
}
crate::commands::update::maybe_auto_update(®istry);
Ok(())
}
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
enum ActivityFailure {
UntrustedOwner,
NotARepository,
Individual,
}
impl ActivityFailure {
fn classify(message: &str) -> Self {
let lower = message.to_lowercase();
if lower.contains(constants::GIT_DUBIOUS_OWNERSHIP) {
Self::UntrustedOwner
} else if lower.contains(constants::GIT_NOT_A_REPOSITORY) {
Self::NotARepository
} else {
Self::Individual
}
}
}
const GROUPED_PATHS_SHOWN: usize = 8;
fn report_blocked(blocked: &[PruneResult]) {
if blocked.is_empty() {
return;
}
output::print_header(&format!(
"Repositories That Could Not Be Examined ({})",
blocked.len()
));
let grouped = |failure: ActivityFailure| -> Vec<&PruneResult> {
blocked
.iter()
.filter(|r| match &r.status {
PruneStatus::ActivityCheckError(e) => ActivityFailure::classify(e) == failure,
_ => false,
})
.collect()
};
let untrusted = grouped(ActivityFailure::UntrustedOwner);
if !untrusted.is_empty() {
let n = untrusted.len();
output::print_error(&format!(
"{n} {} owned by a different account — Git will not read {}.",
output::plural(n, "repository is", "repositories are"),
output::plural(n, "it", "them")
));
list_paths(&untrusted);
output::print_wrapped(
" ",
"Nothing is wrong with the repositories themselves. The owner recorded on \
disk is usually one a Windows reinstall, a restored backup or a drive moved \
between machines left behind.",
);
output::print_wrapped(
" ",
"dev-prune dates a repository by its last commit, so one Git will not open \
has no known age — and nothing is ever deleted from a repository whose age is \
unknown.",
);
output::print_info(&format!(
" Fix all {n} at once: devp trust --fix-ownership"
));
}
let orphaned = grouped(ActivityFailure::NotARepository);
if !orphaned.is_empty() {
if !untrusted.is_empty() {
println!();
}
let n = orphaned.len();
output::print_error(&format!(
"{n} registered {} not {} git {} any more.",
output::plural(n, "path is", "paths are"),
output::plural(n, "a", ""),
output::plural(n, "repository", "repositories")
));
list_paths(&orphaned);
output::print_wrapped(
" ",
"The directory is still there; its `.git` is not — a clone deleted and \
recreated by hand, or a worktree `git worktree prune` has since removed. The \
registry entry outlived what it pointed at.",
);
output::print_info(&format!(
" Drop {} from the registry: devp unlink <path>",
output::plural(n, "it", "them")
));
}
for result in blocked {
let clean_p = output::clean_path(&result.repo_path);
match &result.status {
PruneStatus::ConfigError(e) => {
output::print_error(&format!(
"{clean_p} skipped — its .devprune.json could not be read:
{}",
e.trim()
));
output::print_info(&format!(
" Fix command: devp config {clean_p} --update"
));
}
PruneStatus::LockfileError(e) => report_lockfile_failure(result, e),
PruneStatus::ActivityCheckError(e)
if ActivityFailure::classify(e) == ActivityFailure::Individual =>
{
output::print_error(&format!(
"{clean_p} skipped — its activity could not be determined:
{}",
output::condense_tool_output(e, 4)
));
}
PruneStatus::DeleteError(e) => {
output::print_error(&format!("{clean_p} → delete failed: {e}"));
}
_ => {}
}
}
}
fn list_paths(results: &[&PruneResult]) {
for result in results.iter().take(GROUPED_PATHS_SHOWN) {
println!(" {}", output::styled_path(&result.repo_path));
}
if let Some(rest) = results
.len()
.checked_sub(GROUPED_PATHS_SHOWN)
.filter(|n| *n > 0)
{
output::print_dimmed(&format!(
" … and {rest} more — `devp run --dry-run --json` lists every one."
));
}
}
fn report_linked(linked: &[PruneResult]) {
for result in linked {
if let PruneStatus::SkippedSymlink(e) = &result.status {
output::print_warning(&format!(
"{} → {}",
output::clean_path(&result.repo_path),
e.trim()
));
}
}
}
fn report_missing(missing: &[PruneResult]) {
if missing.is_empty() {
return;
}
println!();
let n = missing.len();
output::print_warning(&format!(
"{n} registered {} no longer {} on disk.",
output::plural(n, "path", "paths"),
output::plural(n, "exists", "exist")
));
for result in missing.iter().take(GROUPED_PATHS_SHOWN) {
println!(" {}", output::styled_path(&result.repo_path));
}
if let Some(rest) = missing
.len()
.checked_sub(GROUPED_PATHS_SHOWN)
.filter(|n| *n > 0)
{
output::print_dimmed(&format!(
" … and {rest} more — `devp run --dry-run --json` lists every one."
));
}
output::print_info(&format!(
" Clear {} from the registry: devp unlink --missing",
output::plural(n, "it", "them all")
));
}
fn fail_if_blocked(blocked: &[PruneResult]) -> Result<()> {
if blocked.is_empty() {
return Ok(());
}
anyhow::bail!("{} repositories could not be examined.", blocked.len());
}
fn report_binaries(candidates: &[PruneResult]) {
let adapter_names: Vec<String> = candidates.iter().map(|c| c.adapter_name.clone()).collect();
let binary_statuses = adapters::scan_required_binaries(&adapter_names);
if binary_statuses.is_empty() {
return;
}
output::print_header("Required Ecosystem Binaries Pre-Check");
for b in &binary_statuses {
if b.available {
output::print_success(&format!(
" {} — available ({})",
b.name,
b.version.as_deref().unwrap_or("detected")
));
} else {
output::print_warning(&format!(
" {} — missing (lockfile fallback active)",
b.name
));
}
}
}
fn report_candidates(candidates: &[PruneResult]) {
output::print_header("Prune Candidates & Space Savings Calculation");
for candidate in candidates {
output::print_info(&format!(
" • {} → {} ({}) [{}]{}",
output::styled_path(&candidate.repo_path),
candidate.bloat_dir,
output::format_bytes_styled(candidate.size_freed),
output::styled_adapter(&candidate.adapter_name),
output::shared_note(candidate.shared_bytes, &candidate.adapter_name)
));
}
}
pub(crate) fn report_lockfile_failure(result: &PruneResult, error: &str) {
let project = output::clean_path(result.project_dir());
output::print_error(&format!(
"{} → {} lockfile sync failed:\n {}",
project,
result.adapter_name,
error.trim(),
));
match json::lockfile_fix_command(&result.adapter_name) {
Some(sync_cmd) => {
#[cfg(windows)]
let manual_cmd = format!("cd \"{project}\"; {sync_cmd}");
#[cfg(not(windows))]
let manual_cmd = format!("cd \"{project}\" && {sync_cmd}");
output::print_info(&format!(" Fix command: {manual_cmd}"));
}
None => output::print_info(&format!(" Fix it in: {project}")),
}
output::print_info(&format!(
" Troubleshooting: {}",
constants::TROUBLESHOOTING_URL
));
}
fn run_explain(args: &RunArgs<'_>, filter: &AdapterFilter) -> Result<()> {
output::print_header("Why each repository would or would not be pruned");
if let Some(desc) = filter.describe() {
output::print_info(&format!("Adapter filter: {desc}"));
}
if let Some(target_str) = args.target_path {
let raw = Path::new(target_str);
let path = if raw.exists() {
raw.canonicalize().unwrap_or_else(|_| raw.to_path_buf())
} else {
raw.to_path_buf()
};
if !crate::scanner::is_git_repo(&path) {
anyhow::bail!(
"{} is not a Git repository — dev-prune only prunes Git repos.",
output::clean_path(&path)
);
}
let registry = Registry::load().ok();
let idle_days = registry
.as_ref()
.map(|r| {
r.repositories
.get(&path)
.and_then(|e| e.override_idle_days)
.unwrap_or(r.settings.idle_days)
})
.unwrap_or(constants::DEFAULT_IDLE_DAYS);
let floor = resolve_min_size(args, registry.as_ref());
let results = engine::prune_repo_with(
&path,
&PruneOptions {
idle_days,
dry_run: true,
force: args.force,
only_dirs: None,
adapters: filter.clone(),
min_size_bytes: 0,
scan_depth: resolve_scan_depth(registry.as_ref()),
allow_manifest_rewrite: resolve_manifest_rewrite(registry.as_ref()),
command_timeout_secs: resolve_command_timeout(registry.as_ref()),
build_idle_days: resolve_build_idle_days(registry.as_ref()),
adapter_idle_days: resolve_adapter_idle_days(registry.as_ref()),
},
);
let refs: Vec<&PruneResult> = results.iter().collect();
explain_repo(&path, &refs, floor, idle_days);
print_explain_footer();
return Ok(());
}
let mut registry = Registry::load()?;
if registry.repo_count() == 0 {
output::print_warning("No repositories registered. Run `dev-prune init` first.");
return Ok(());
}
let except = parse_except(args.except);
let global_floor = resolve_min_size(args, Some(®istry));
let analysis = PruneOptions {
idle_days: 0, dry_run: true,
force: args.force,
only_dirs: None,
adapters: filter.clone(),
min_size_bytes: 0,
scan_depth: resolve_scan_depth(Some(®istry)),
allow_manifest_rewrite: resolve_manifest_rewrite(Some(®istry)),
command_timeout_secs: resolve_command_timeout(Some(®istry)),
build_idle_days: resolve_build_idle_days(Some(®istry)),
adapter_idle_days: resolve_adapter_idle_days(Some(®istry)),
};
let results = engine::prune_all_with(&mut registry, &analysis);
let mut by_repo: std::collections::HashMap<&Path, Vec<&PruneResult>> =
std::collections::HashMap::new();
for r in &results {
by_repo.entry(r.repo_path.as_path()).or_default().push(r);
}
let mut repos: Vec<&std::path::PathBuf> = registry.repositories.keys().collect();
repos.sort();
for path in repos {
if is_excepted(path, &except) {
println!();
output::print_info(&output::clean_path(path));
println!(" • left completely alone this pass (`--except`)");
continue;
}
let idle_days = registry
.repositories
.get(path)
.and_then(|e| e.override_idle_days)
.unwrap_or(registry.settings.idle_days);
let empty = Vec::new();
let repo_results = by_repo.get(path.as_path()).unwrap_or(&empty);
explain_repo(path, repo_results, global_floor, idle_days);
}
print_explain_footer();
Ok(())
}
fn explain_repo(path: &Path, results: &[&PruneResult], floor: u64, idle_days: u64) {
println!();
output::print_info(&output::clean_path(path));
if results.is_empty() {
println!(
" • idle, but no known bloat directories were found. A project deeper than \
`scan_depth` is not examined — `devp status` shows what dev-prune can see."
);
return;
}
for r in results {
match &r.status {
PruneStatus::SkippedDryRun => {
if r.size_freed >= floor {
output::print_success(&format!(
"would prune {} ({}) [{}]{}",
r.bloat_dir,
output::format_bytes(r.size_freed),
r.adapter_name,
output::shared_note(r.shared_bytes, &r.adapter_name)
));
} else {
println!(
" • {} ({}) is under the size floor of {} — the reinstall would \
cost more than the space is worth. `--min-size 0` includes it.",
r.bloat_dir,
output::format_bytes(r.size_freed),
output::format_bytes(floor)
);
}
}
PruneStatus::SkippedActive => {
let age = crate::scanner::git::get_last_activity(path)
.ok()
.flatten()
.and_then(|t| std::time::SystemTime::now().duration_since(t).ok())
.map(|d| d.as_secs() / 86_400);
match age {
Some(0) => println!(
" • active — there was activity today, and the idle \
threshold is {idle_days} days. `--ignore-idle` overrides."
),
Some(days) => println!(
" • active — last activity {days} day{} ago, and the idle \
threshold is {idle_days} days. `--ignore-idle` overrides.",
if days == 1 { "" } else { "s" }
),
None => println!(
" • active (not idle for {idle_days} days yet). \
`--ignore-idle` overrides."
),
}
}
other => println!(" • {other}"),
}
}
}
fn print_explain_footer() {
println!();
output::print_info(
"Nothing was verified or deleted. `devp run --dry-run` verifies candidates; \
`devp run` prunes.",
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gits_ownership_refusal_is_recognised_whatever_the_path() {
let message = "git could not read `V:/x`: fatal: detected dubious ownership in repository at 'V:/x'";
assert_eq!(
ActivityFailure::classify(message),
ActivityFailure::UntrustedOwner
);
}
#[test]
fn a_path_that_lost_its_git_directory_is_its_own_cause() {
let message = "fatal: not a git repository (or any of the parent directories): .git";
assert_eq!(
ActivityFailure::classify(message),
ActivityFailure::NotARepository
);
}
#[test]
fn an_unfamiliar_failure_is_still_printed_in_full() {
assert_eq!(
ActivityFailure::classify("fatal: unable to read tree"),
ActivityFailure::Individual
);
}
}