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)
291 | PruneStatus::SkippedDeclaration(e)
292 | PruneStatus::SkippedNestedRepo(e) => {
293 output::print_warning(&format!(
294 "{} → {}",
295 output::clean_path(&result.repo_path),
296 e.trim(),
297 ));
298 }
299 _ => {}
300 }
301 }
302
303 if pruned_dirs.len() > recorded_before {
308 registry.record_prune_progress(pass_at, pruned_dirs.clone());
309 let _ = registry.save();
310 }
311 }
312
313 registry.record_prune_progress(pass_at, pruned_dirs);
314 registry.save()?;
315
316 output::print_header(i18n::t("run.summary"));
317 output::print_success(&i18n::tf(
318 "run.freed",
319 &[
320 ("size", &output::format_bytes(total_freed)),
321 ("count", &pruned_count.to_string()),
322 ],
323 ));
324 if error_count > 0 {
327 anyhow::bail!("{error_count} directories could not be pruned.");
328 }
329 }
330 Ok(_) => {
331 }
333 Err(e) => {
334 output::print_warning(&format!("Interactive view ended: {e:#}"));
338 status_view::render_status_plain(&repos);
339 }
340 }
341 } else {
342 status_view::render_status_plain(&repos);
344 }
345
346 Ok(())
347}
348
349fn run_drift(json_output: bool) -> Result<()> {
357 let registry = Registry::load()?;
358
359 let pb = (!json_output)
360 .then(|| output::create_spinner("Comparing environments against lockfiles..."));
361
362 let mut findings: Vec<ProjectDrift> = Vec::new();
363 for path in registry.repositories.keys() {
364 if !path.exists() {
365 continue;
366 }
367 let depth = workspace::resolve_depth(path, registry.settings.scan_depth);
368 for project in workspace::discover_to_depth(path, depth) {
369 for adapter in &project.adapters {
370 for report in adapter.drift(&project.path) {
371 findings.push(ProjectDrift {
372 repository: path.clone(),
373 project: project.relative.clone(),
374 adapter: adapter.name(),
375 report,
376 });
377 }
378 }
379 }
380 }
381 findings.sort_by(|a, b| {
384 (&a.repository, &a.project, a.adapter, &a.report.directory).cmp(&(
385 &b.repository,
386 &b.project,
387 b.adapter,
388 &b.report.directory,
389 ))
390 });
391
392 if let Some(pb) = pb {
393 pb.finish_and_clear();
394 }
395
396 if json_output {
397 return crate::json::emit(&crate::json::drift_document(&findings));
398 }
399
400 output::print_header(i18n::t("status.header.drift"));
401 println!();
402
403 if findings.is_empty() {
404 output::print_success(
405 "No drift found: nothing is installed that the lockfiles do not record.",
406 );
407 output::print_info(
408 "Checked where a cheap file-level comparison exists: node_modules against \
409 package-lock.json (npm), .venv against uv.lock (uv), and every virtual \
410 environment against requirements.txt (venv).",
411 );
412 return Ok(());
413 }
414
415 let mut last_repo: Option<&std::path::Path> = None;
416 for f in &findings {
417 if last_repo != Some(f.repository.as_path()) {
418 println!(" {}", output::clean_path(&f.repository));
419 last_repo = Some(f.repository.as_path());
420 }
421 let location = if f.project == "." {
422 f.report.directory.clone()
423 } else {
424 format!("{}/{}", f.project, f.report.directory)
425 };
426 let shown = f
427 .report
428 .unrecorded
429 .iter()
430 .take(10)
431 .map(String::as_str)
432 .collect::<Vec<_>>()
433 .join(", ");
434 let suffix = if f.report.unrecorded.len() > 10 {
435 format!(", … and {} more", f.report.unrecorded.len() - 10)
436 } else {
437 String::new()
438 };
439 println!(
440 " {} ({}): {} unrecorded {} — {shown}{suffix}",
441 location,
442 f.adapter,
443 f.report.unrecorded.len(),
444 output::plural(f.report.unrecorded.len(), "package", "packages"),
445 );
446 println!(" record them: {}", f.report.record_command);
447 println!();
448 }
449
450 output::print_info(
451 "A prune refuses to delete these environments as they are — the unrecorded \
452 packages would be lost with no way back. Record them with the command shown, \
453 or uninstall them, and the refusal goes away.",
454 );
455 Ok(())
456}
457
458fn restore_estimate_line(registry: &Registry, repos: &[engine::RepoStatusEntry]) -> Option<String> {
468 let mut by_adapter: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();
469 for repo in repos {
470 for (adapter, bytes) in &repo.reclaimable_by_adapter {
471 *by_adapter.entry(adapter.clone()).or_default() += bytes;
472 }
473 }
474 let total: u64 = by_adapter.values().sum();
475 let tallied: Vec<(String, u64)> = by_adapter.into_iter().collect();
476 let (secs, covered) = registry.estimate_restore(&tallied)?;
477
478 let samples: usize = registry
479 .restore_rates
480 .values()
481 .map(|r| r.samples as usize)
482 .sum();
483 let mut line = format!(
484 "Estimated Restore Cost: ~{} to put it all back, from {} timed {} on this machine",
485 output::format_seconds(secs.round() as u64),
486 samples,
487 output::plural(samples, "restore", "restores"),
488 );
489 if covered < total {
490 line.push_str(&format!(
491 " (covers {} of {} — the rest has never been restored here)",
492 output::format_bytes(covered),
493 output::format_bytes(total),
494 ));
495 }
496 Some(line)
497}
498
499fn format_duration(secs: u64) -> String {
501 match secs {
502 s if s > 0 && s % 3600 == 0 => format!("{}h", s / 3600),
503 s if s > 0 && s % 60 == 0 => format!("{}m", s / 60),
504 s => format!("{s}s"),
505 }
506}
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511
512 #[test]
513 fn the_timeout_is_described_in_whatever_unit_fits_it() {
514 assert_eq!(format_duration(600), "10m");
516 assert_eq!(format_duration(3600), "1h");
517 assert_eq!(format_duration(90), "90s");
518 assert_eq!(format_duration(0), "0s");
519 }
520}