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::output;
18use crate::tui::status_view;
19use crate::workspace;
20
21pub struct ProjectDrift {
24 pub repository: std::path::PathBuf,
26 pub project: String,
28 pub adapter: &'static str,
30 pub report: DriftReport,
32}
33
34pub fn run(top: Option<usize>, drift: bool, json_output: bool) -> Result<()> {
48 if drift {
49 return run_drift(json_output);
50 }
51 let mut registry = Registry::load()?;
52
53 let daemon_st = crate::daemon::daemon_status()
54 .map(|s| s.to_string())
55 .unwrap_or_else(|_| "Unknown".to_string());
56 let hook_st = match crate::commands::hook::state() {
60 Ok(HookState::Active) => "Active (post-commit, post-checkout, post-merge)".to_string(),
61 Ok(HookState::Chained { previous, drifted }) if drifted.is_empty() => {
62 format!("Active, chained to {previous}")
63 }
64 Ok(HookState::Chained { previous, drifted }) => format!(
65 "Active, chained to {previous} ({} hook(s) not forwarded)",
66 drifted.len()
67 ),
68 Ok(HookState::Foreign(path)) => format!("Inactive (core.hooksPath belongs to {path})"),
69 Ok(HookState::Absent) | Err(_) => "Inactive".to_string(),
70 };
71
72 if json_output {
73 let repos = engine::get_full_status(®istry);
74 return crate::json::emit(&crate::json::status_document(
75 ®istry, &repos, &daemon_st, &hook_st, top,
76 ));
77 }
78
79 output::print_banner();
80
81 if crate::commands::update::notify_if_outdated(&mut registry) {
84 let _ = registry.save();
85 }
86
87 let reg_path = Registry::registry_path()
88 .map(|p| output::clean_path(&p))
89 .unwrap_or_else(|_| "unknown".to_string());
90
91 output::print_info(&format!("Global Config Location: {}", reg_path));
92 output::print_info(&format!("Background OS Daemon: {}", daemon_st));
93 output::print_info(&format!("Background Git Hooks: {}", hook_st));
94 let timeout = registry.settings.command_timeout_secs;
97 output::print_info(&format!(
98 "Global Command Timeout: {timeout}s ({})",
99 format_duration(timeout)
100 ));
101 if registry.settings.min_size_mb > 0 {
102 output::print_info(&format!(
103 "Minimum Directory Size: {} MiB (smaller ones are left alone)",
104 registry.settings.min_size_mb
105 ));
106 }
107 output::print_info(&format!(
108 "Tracked Repositories: {}",
109 registry.repo_count()
110 ));
111 output::print_info(&format!(
112 "Historical Space Saved: {} across {} prune {}",
113 output::format_bytes_styled(registry.total_freed_bytes),
114 registry.total_pruned_count,
115 output::plural(registry.total_pruned_count as usize, "pass", "passes")
116 ));
117 if let Some(n) = top {
118 output::print_info(&format!(
119 "Showing: the {n} {} with the most reclaimable space",
120 output::plural(n, "repository", "repositories")
121 ));
122 }
123 println!();
124
125 if registry.repositories.is_empty() {
128 output::print_info(
129 "No repositories are registered yet. `devp init <folder>` scans a folder and \
130 registers every Git repository in it; `devp link .` registers just one.",
131 );
132 return Ok(());
133 }
134
135 let scan_bar = (!json_output).then(|| {
142 output::create_progress_bar("Scanning repositories", registry.repositories.len() as u64)
143 });
144 let scanned = engine::get_full_status_reporting(®istry, &|done, _total| {
145 if let Some(pb) = &scan_bar {
146 pb.set_position(done as u64);
147 }
148 });
149 let estimate = restore_estimate_line(®istry, &scanned);
153 let repos = engine::take_top(&scanned, top);
154 if let Some(pb) = scan_bar {
155 pb.finish_and_clear();
158 }
159
160 if let Some(line) = estimate {
161 output::print_info(&line);
162 println!();
163 }
164
165 if io::stdout().is_terminal() && io::stdin().is_terminal() {
168 let registry_ref = ®istry;
172 match status_view::render_status_tui(&|| {
173 engine::take_top(&engine::get_full_status(registry_ref), top)
174 }) {
175 Ok(Some(candidates)) if !candidates.is_empty() => {
176 output::print_header("Pruning Selected Repositories");
180
181 let mut total_freed: u64 = 0;
182 let mut pruned_count = 0;
183 let mut error_count = 0;
184 let mut pruned_dirs: Vec<crate::config::PrunedDir> = Vec::new();
188 let pass_at = chrono::Utc::now();
189
190 let opts = engine::PruneOptions {
196 idle_days: 0,
197 dry_run: false,
198 force: true,
199 only_dirs: None,
200 adapters: engine::AdapterFilter::default(),
201 min_size_bytes: registry
202 .settings
203 .min_size_mb
204 .saturating_mul(engine::BYTES_PER_MIB),
205 scan_depth: registry.settings.scan_depth,
206 allow_manifest_rewrite: registry.settings.allow_manifest_rewrite,
207 command_timeout_secs: registry.settings.command_timeout_secs,
208 build_idle_days: registry.settings.build_idle_days,
209 adapter_idle_days: registry.settings.adapter_idle_days.clone(),
210 };
211
212 for path in &candidates {
213 let recorded_before = pruned_dirs.len();
214 let results = engine::prune_repo_with(path, &opts);
215 for result in results {
216 match &result.status {
217 PruneStatus::Pruned => {
218 total_freed += result.size_freed;
219 pruned_count += 1;
220 registry.mark_pruned(&result.repo_path, result.size_freed);
221 pruned_dirs.push(crate::config::PrunedDir {
222 repo_path: result.repo_path.clone(),
223 bloat_dir: result.bloat_dir.clone(),
224 adapter: result.adapter_name.clone(),
225 size_freed: result.size_freed,
226 runtime: result.runtime.clone(),
227 });
228 output::print_success(&format!(
229 "{} → {} ({}) — {}",
230 output::clean_path(&result.repo_path),
231 result.bloat_dir,
232 output::format_bytes(result.size_freed),
233 result.adapter_name,
234 ));
235 }
236 PruneStatus::LockfileError(e) => {
237 error_count += 1;
238 crate::commands::run::report_lockfile_failure(&result, e);
239 }
240 PruneStatus::DeleteError(e) => {
241 error_count += 1;
242 if result.size_freed > 0 {
247 pruned_dirs.push(crate::config::PrunedDir {
248 repo_path: result.repo_path.clone(),
249 bloat_dir: result.bloat_dir.clone(),
250 adapter: result.adapter_name.clone(),
251 size_freed: result.size_freed,
252 runtime: result.runtime.clone(),
253 });
254 }
255 output::print_error(&format!(
256 "{} delete failed: {}",
257 output::clean_path(&result.repo_path),
258 e,
259 ));
260 }
261 PruneStatus::ConfigError(e) => {
262 error_count += 1;
263 output::print_error(&format!(
264 "{} skipped — unreadable .devprune.json: {}",
265 output::clean_path(&result.repo_path),
266 e,
267 ));
268 }
269 PruneStatus::SkippedSymlink(e) => {
272 output::print_warning(&format!(
273 "{} → {}",
274 output::clean_path(&result.repo_path),
275 e.trim(),
276 ));
277 }
278 _ => {}
279 }
280 }
281
282 if pruned_dirs.len() > recorded_before {
287 registry.record_prune_progress(pass_at, pruned_dirs.clone());
288 let _ = registry.save();
289 }
290 }
291
292 registry.record_prune_progress(pass_at, pruned_dirs);
293 registry.save()?;
294
295 output::print_header("Summary");
296 output::print_success(&format!(
297 "Freed: {} across {pruned_count} directories",
298 output::format_bytes(total_freed)
299 ));
300 if error_count > 0 {
303 anyhow::bail!("{error_count} directories could not be pruned.");
304 }
305 }
306 Ok(_) => {
307 }
309 Err(e) => {
310 output::print_warning(&format!("Interactive view ended: {e:#}"));
314 status_view::render_status_plain(&repos);
315 }
316 }
317 } else {
318 status_view::render_status_plain(&repos);
320 }
321
322 Ok(())
323}
324
325fn run_drift(json_output: bool) -> Result<()> {
333 let registry = Registry::load()?;
334
335 let pb = (!json_output)
336 .then(|| output::create_spinner("Comparing environments against lockfiles..."));
337
338 let mut findings: Vec<ProjectDrift> = Vec::new();
339 for path in registry.repositories.keys() {
340 if !path.exists() {
341 continue;
342 }
343 let depth = workspace::resolve_depth(path, registry.settings.scan_depth);
344 for project in workspace::discover_to_depth(path, depth) {
345 for adapter in &project.adapters {
346 for report in adapter.drift(&project.path) {
347 findings.push(ProjectDrift {
348 repository: path.clone(),
349 project: project.relative.clone(),
350 adapter: adapter.name(),
351 report,
352 });
353 }
354 }
355 }
356 }
357 findings.sort_by(|a, b| {
360 (&a.repository, &a.project, a.adapter, &a.report.directory).cmp(&(
361 &b.repository,
362 &b.project,
363 b.adapter,
364 &b.report.directory,
365 ))
366 });
367
368 if let Some(pb) = pb {
369 pb.finish_and_clear();
370 }
371
372 if json_output {
373 return crate::json::emit(&crate::json::drift_document(&findings));
374 }
375
376 output::print_header("Lockfile drift");
377 println!();
378
379 if findings.is_empty() {
380 output::print_success(
381 "No drift found: nothing is installed that the lockfiles do not record.",
382 );
383 output::print_info(
384 "Checked where a cheap file-level comparison exists: node_modules against \
385 package-lock.json (npm), .venv against uv.lock (uv), and every virtual \
386 environment against requirements.txt (venv).",
387 );
388 return Ok(());
389 }
390
391 let mut last_repo: Option<&std::path::Path> = None;
392 for f in &findings {
393 if last_repo != Some(f.repository.as_path()) {
394 println!(" {}", output::clean_path(&f.repository));
395 last_repo = Some(f.repository.as_path());
396 }
397 let location = if f.project == "." {
398 f.report.directory.clone()
399 } else {
400 format!("{}/{}", f.project, f.report.directory)
401 };
402 let shown = f
403 .report
404 .unrecorded
405 .iter()
406 .take(10)
407 .map(String::as_str)
408 .collect::<Vec<_>>()
409 .join(", ");
410 let suffix = if f.report.unrecorded.len() > 10 {
411 format!(", … and {} more", f.report.unrecorded.len() - 10)
412 } else {
413 String::new()
414 };
415 println!(
416 " {} ({}): {} unrecorded {} — {shown}{suffix}",
417 location,
418 f.adapter,
419 f.report.unrecorded.len(),
420 output::plural(f.report.unrecorded.len(), "package", "packages"),
421 );
422 println!(" record them: {}", f.report.record_command);
423 println!();
424 }
425
426 output::print_info(
427 "A prune refuses to delete these environments as they are — the unrecorded \
428 packages would be lost with no way back. Record them with the command shown, \
429 or uninstall them, and the refusal goes away.",
430 );
431 Ok(())
432}
433
434fn restore_estimate_line(registry: &Registry, repos: &[engine::RepoStatusEntry]) -> Option<String> {
444 let mut by_adapter: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();
445 for repo in repos {
446 for (adapter, bytes) in &repo.reclaimable_by_adapter {
447 *by_adapter.entry(adapter.clone()).or_default() += bytes;
448 }
449 }
450 let total: u64 = by_adapter.values().sum();
451 let tallied: Vec<(String, u64)> = by_adapter.into_iter().collect();
452 let (secs, covered) = registry.estimate_restore(&tallied)?;
453
454 let samples: usize = registry
455 .restore_rates
456 .values()
457 .map(|r| r.samples as usize)
458 .sum();
459 let mut line = format!(
460 "Estimated Restore Cost: ~{} to put it all back, from {} timed {} on this machine",
461 output::format_seconds(secs.round() as u64),
462 samples,
463 output::plural(samples, "restore", "restores"),
464 );
465 if covered < total {
466 line.push_str(&format!(
467 " (covers {} of {} — the rest has never been restored here)",
468 output::format_bytes(covered),
469 output::format_bytes(total),
470 ));
471 }
472 Some(line)
473}
474
475fn format_duration(secs: u64) -> String {
477 match secs {
478 s if s > 0 && s % 3600 == 0 => format!("{}h", s / 3600),
479 s if s > 0 && s % 60 == 0 => format!("{}m", s / 60),
480 s => format!("{s}s"),
481 }
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487
488 #[test]
489 fn the_timeout_is_described_in_whatever_unit_fits_it() {
490 assert_eq!(format_duration(600), "10m");
492 assert_eq!(format_duration(3600), "1h");
493 assert_eq!(format_duration(90), "90s");
494 assert_eq!(format_duration(0), "0s");
495 }
496}