1use anyhow::Result;
11use std::io::{self, IsTerminal};
12
13use crate::adapters::DriftReport;
14use crate::commands::hook::HookState;
15use crate::config::Registry;
16use crate::engine::{self, PruneStatus};
17use crate::i18n;
18use crate::output;
19use crate::tui::status_view;
20use crate::workspace;
21
22pub struct ProjectDrift {
25 pub repository: std::path::PathBuf,
27 pub project: String,
29 pub adapter: &'static str,
31 pub report: DriftReport,
33}
34
35pub fn run(top: Option<usize>, drift: bool, json_output: bool) -> Result<()> {
51 if drift {
52 return run_drift(json_output);
53 }
54 let mut registry = Registry::load()?;
55
56 let adopted = crate::commands::link::adopt_enclosing_repo(&mut registry);
62 if adopted.is_some() {
63 registry.save()?;
64 }
65
66 let daemon_st = crate::daemon::daemon_status()
67 .map(|s| s.to_string())
68 .unwrap_or_else(|_| "Unknown".to_string());
69 let hook_st = match crate::commands::hook::state() {
73 Ok(HookState::Active) => "Active (post-commit, post-checkout, post-merge)".to_string(),
74 Ok(HookState::Chained { previous, drifted }) if drifted.is_empty() => {
75 format!("Active, chained to {previous}")
76 }
77 Ok(HookState::Chained { previous, drifted }) => format!(
78 "Active, chained to {previous} ({} hook(s) not forwarded)",
79 drifted.len()
80 ),
81 Ok(HookState::Foreign(path)) => format!("Inactive (core.hooksPath belongs to {path})"),
82 Ok(HookState::Absent) | Err(_) => "Inactive".to_string(),
83 };
84
85 if json_output {
86 let repos = engine::get_full_status(®istry);
87 return crate::json::emit(&crate::json::status_document(
88 ®istry, &repos, &daemon_st, &hook_st, top,
89 ));
90 }
91
92 output::print_banner();
93
94 if let Some(path) = &adopted {
95 crate::commands::link::report_cwd_adoption(path);
96 println!();
97 }
98
99 if crate::commands::update::notify_if_outdated(&mut registry) {
102 let _ = registry.save();
103 }
104
105 let reg_path = Registry::registry_path()
106 .map(|p| output::clean_path(&p))
107 .unwrap_or_else(|_| "unknown".to_string());
108
109 output::print_info(&format!("Global Config Location: {}", reg_path));
110 output::print_info(&format!("Background OS Daemon: {}", daemon_st));
111 output::print_info(&format!("Background Git Hooks: {}", hook_st));
112 let timeout = registry.settings.command_timeout_secs;
115 output::print_info(&format!(
116 "Global Command Timeout: {timeout}s ({})",
117 format_duration(timeout)
118 ));
119 if registry.settings.min_size_mb > 0 {
120 output::print_info(&format!(
121 "Minimum Directory Size: {} MiB (smaller ones are left alone)",
122 registry.settings.min_size_mb
123 ));
124 }
125 output::print_info(&format!(
126 "Tracked Repositories: {}",
127 registry.repo_count()
128 ));
129 output::print_info(&format!(
130 "Historical Space Saved: {} across {} prune {}",
131 output::format_bytes_styled(registry.total_freed_bytes),
132 registry.total_pruned_count,
133 output::plural(registry.total_pruned_count as usize, "pass", "passes")
134 ));
135 if let Some(n) = top {
136 output::print_info(&format!(
137 "Showing: the {n} {} with the most reclaimable space",
138 output::plural(n, "repository", "repositories")
139 ));
140 }
141 println!();
142
143 if registry.repositories.is_empty() {
146 output::print_info(
147 "No repositories are registered yet. `devp init <folder>` scans a folder and \
148 registers every Git repository in it; `devp link .` registers just one.",
149 );
150 return Ok(());
151 }
152
153 let scan_bar = (!json_output).then(|| {
160 output::create_progress_bar("Scanning repositories", registry.repositories.len() as u64)
161 });
162 let scanned = engine::get_full_status_reporting(®istry, &|done, _total| {
163 if let Some(pb) = &scan_bar {
164 pb.set_position(done as u64);
165 }
166 });
167 let estimate = restore_estimate_line(®istry, &scanned);
171 let repos = engine::take_top(&scanned, top);
172 if let Some(pb) = scan_bar {
173 pb.finish_and_clear();
176 }
177
178 if let Some(line) = estimate {
179 output::print_info(&line);
180 println!();
181 }
182
183 if io::stdout().is_terminal() && io::stdin().is_terminal() {
186 let registry_ref = ®istry;
190 match status_view::render_status_tui(&|| {
191 engine::take_top(&engine::get_full_status(registry_ref), top)
192 }) {
193 Ok(Some(candidates)) if !candidates.is_empty() => {
194 output::print_header(i18n::t("status.header.pruning"));
198
199 let mut total_freed: u64 = 0;
200 let mut pruned_count = 0;
201 let mut error_count = 0;
202 let mut pruned_dirs: Vec<crate::config::PrunedDir> = Vec::new();
206 let pass_at = chrono::Utc::now();
207
208 let opts = engine::PruneOptions {
214 idle_days: 0,
215 dry_run: false,
216 force: true,
217 only_dirs: None,
218 adapters: engine::AdapterFilter::default(),
219 min_size_bytes: registry
220 .settings
221 .min_size_mb
222 .saturating_mul(engine::BYTES_PER_MIB),
223 scan_depth: registry.settings.scan_depth,
224 allow_manifest_rewrite: registry.settings.allow_manifest_rewrite,
225 command_timeout_secs: registry.settings.command_timeout_secs,
226 build_idle_days: registry.settings.build_idle_days,
227 adapter_idle_days: registry.settings.adapter_idle_days.clone(),
228 };
229
230 for path in &candidates {
231 let recorded_before = pruned_dirs.len();
232 let results = engine::prune_repo_with(path, &opts);
233 for result in results {
234 match &result.status {
235 PruneStatus::Pruned => {
236 total_freed += result.size_freed;
237 pruned_count += 1;
238 registry.mark_pruned(&result.repo_path, result.size_freed);
239 pruned_dirs.push(crate::config::PrunedDir {
240 repo_path: result.repo_path.clone(),
241 bloat_dir: result.bloat_dir.clone(),
242 adapter: result.adapter_name.clone(),
243 size_freed: result.size_freed,
244 runtime: result.runtime.clone(),
245 });
246 output::print_success(&format!(
247 "{} → {} ({}) — {}",
248 output::clean_path(&result.repo_path),
249 result.bloat_dir,
250 output::format_bytes(result.size_freed),
251 result.adapter_name,
252 ));
253 }
254 PruneStatus::LockfileError(e) => {
255 error_count += 1;
256 crate::commands::run::report_lockfile_failure(&result, e);
257 }
258 PruneStatus::DeleteError(e) => {
259 error_count += 1;
260 if result.size_freed > 0 {
265 pruned_dirs.push(crate::config::PrunedDir {
266 repo_path: result.repo_path.clone(),
267 bloat_dir: result.bloat_dir.clone(),
268 adapter: result.adapter_name.clone(),
269 size_freed: result.size_freed,
270 runtime: result.runtime.clone(),
271 });
272 }
273 output::print_error(&format!(
274 "{} delete failed: {}",
275 output::clean_path(&result.repo_path),
276 e,
277 ));
278 }
279 PruneStatus::ConfigError(e) => {
280 error_count += 1;
281 output::print_error(&format!(
282 "{} skipped — unreadable .devprune.json: {}",
283 output::clean_path(&result.repo_path),
284 e,
285 ));
286 }
287 PruneStatus::SkippedSymlink(e) | PruneStatus::SkippedDeclaration(e) => {
291 output::print_warning(&format!(
292 "{} → {}",
293 output::clean_path(&result.repo_path),
294 e.trim(),
295 ));
296 }
297 _ => {}
298 }
299 }
300
301 if pruned_dirs.len() > recorded_before {
306 registry.record_prune_progress(pass_at, pruned_dirs.clone());
307 let _ = registry.save();
308 }
309 }
310
311 registry.record_prune_progress(pass_at, pruned_dirs);
312 registry.save()?;
313
314 output::print_header(i18n::t("run.summary"));
315 output::print_success(&i18n::tf(
316 "run.freed",
317 &[
318 ("size", &output::format_bytes(total_freed)),
319 ("count", &pruned_count.to_string()),
320 ],
321 ));
322 if error_count > 0 {
325 anyhow::bail!("{error_count} directories could not be pruned.");
326 }
327 }
328 Ok(_) => {
329 }
331 Err(e) => {
332 output::print_warning(&format!("Interactive view ended: {e:#}"));
336 status_view::render_status_plain(&repos);
337 }
338 }
339 } else {
340 status_view::render_status_plain(&repos);
342 }
343
344 Ok(())
345}
346
347fn run_drift(json_output: bool) -> Result<()> {
355 let registry = Registry::load()?;
356
357 let pb = (!json_output)
358 .then(|| output::create_spinner("Comparing environments against lockfiles..."));
359
360 let mut findings: Vec<ProjectDrift> = Vec::new();
361 for path in registry.repositories.keys() {
362 if !path.exists() {
363 continue;
364 }
365 let depth = workspace::resolve_depth(path, registry.settings.scan_depth);
366 for project in workspace::discover_to_depth(path, depth) {
367 for adapter in &project.adapters {
368 for report in adapter.drift(&project.path) {
369 findings.push(ProjectDrift {
370 repository: path.clone(),
371 project: project.relative.clone(),
372 adapter: adapter.name(),
373 report,
374 });
375 }
376 }
377 }
378 }
379 findings.sort_by(|a, b| {
382 (&a.repository, &a.project, a.adapter, &a.report.directory).cmp(&(
383 &b.repository,
384 &b.project,
385 b.adapter,
386 &b.report.directory,
387 ))
388 });
389
390 if let Some(pb) = pb {
391 pb.finish_and_clear();
392 }
393
394 if json_output {
395 return crate::json::emit(&crate::json::drift_document(&findings));
396 }
397
398 output::print_header(i18n::t("status.header.drift"));
399 println!();
400
401 if findings.is_empty() {
402 output::print_success(
403 "No drift found: nothing is installed that the lockfiles do not record.",
404 );
405 output::print_info(
406 "Checked where a cheap file-level comparison exists: node_modules against \
407 package-lock.json (npm), .venv against uv.lock (uv), and every virtual \
408 environment against requirements.txt (venv).",
409 );
410 return Ok(());
411 }
412
413 let mut last_repo: Option<&std::path::Path> = None;
414 for f in &findings {
415 if last_repo != Some(f.repository.as_path()) {
416 println!(" {}", output::clean_path(&f.repository));
417 last_repo = Some(f.repository.as_path());
418 }
419 let location = if f.project == "." {
420 f.report.directory.clone()
421 } else {
422 format!("{}/{}", f.project, f.report.directory)
423 };
424 let shown = f
425 .report
426 .unrecorded
427 .iter()
428 .take(10)
429 .map(String::as_str)
430 .collect::<Vec<_>>()
431 .join(", ");
432 let suffix = if f.report.unrecorded.len() > 10 {
433 format!(", … and {} more", f.report.unrecorded.len() - 10)
434 } else {
435 String::new()
436 };
437 println!(
438 " {} ({}): {} unrecorded {} — {shown}{suffix}",
439 location,
440 f.adapter,
441 f.report.unrecorded.len(),
442 output::plural(f.report.unrecorded.len(), "package", "packages"),
443 );
444 println!(" record them: {}", f.report.record_command);
445 println!();
446 }
447
448 output::print_info(
449 "A prune refuses to delete these environments as they are — the unrecorded \
450 packages would be lost with no way back. Record them with the command shown, \
451 or uninstall them, and the refusal goes away.",
452 );
453 Ok(())
454}
455
456fn restore_estimate_line(registry: &Registry, repos: &[engine::RepoStatusEntry]) -> Option<String> {
466 let mut by_adapter: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();
467 for repo in repos {
468 for (adapter, bytes) in &repo.reclaimable_by_adapter {
469 *by_adapter.entry(adapter.clone()).or_default() += bytes;
470 }
471 }
472 let total: u64 = by_adapter.values().sum();
473 let tallied: Vec<(String, u64)> = by_adapter.into_iter().collect();
474 let (secs, covered) = registry.estimate_restore(&tallied)?;
475
476 let samples: usize = registry
477 .restore_rates
478 .values()
479 .map(|r| r.samples as usize)
480 .sum();
481 let mut line = format!(
482 "Estimated Restore Cost: ~{} to put it all back, from {} timed {} on this machine",
483 output::format_seconds(secs.round() as u64),
484 samples,
485 output::plural(samples, "restore", "restores"),
486 );
487 if covered < total {
488 line.push_str(&format!(
489 " (covers {} of {} — the rest has never been restored here)",
490 output::format_bytes(covered),
491 output::format_bytes(total),
492 ));
493 }
494 Some(line)
495}
496
497fn format_duration(secs: u64) -> String {
499 match secs {
500 s if s > 0 && s % 3600 == 0 => format!("{}h", s / 3600),
501 s if s > 0 && s % 60 == 0 => format!("{}m", s / 60),
502 s => format!("{s}s"),
503 }
504}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509
510 #[test]
511 fn the_timeout_is_described_in_whatever_unit_fits_it() {
512 assert_eq!(format_duration(600), "10m");
514 assert_eq!(format_duration(3600), "1h");
515 assert_eq!(format_duration(90), "90s");
516 assert_eq!(format_duration(0), "0s");
517 }
518}