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 repos = engine::take_top(
145 &engine::get_full_status_reporting(®istry, &|done, _total| {
146 if let Some(pb) = &scan_bar {
147 pb.set_position(done as u64);
148 }
149 }),
150 top,
151 );
152 if let Some(pb) = scan_bar {
153 pb.finish_and_clear();
156 }
157
158 if io::stdout().is_terminal() && io::stdin().is_terminal() {
161 let registry_ref = ®istry;
165 match status_view::render_status_tui(&|| {
166 engine::take_top(&engine::get_full_status(registry_ref), top)
167 }) {
168 Ok(Some(candidates)) if !candidates.is_empty() => {
169 output::print_header("Pruning Selected Repositories");
173
174 let mut total_freed: u64 = 0;
175 let mut pruned_count = 0;
176 let mut error_count = 0;
177 let mut pruned_dirs: Vec<crate::config::PrunedDir> = Vec::new();
181 let pass_at = chrono::Utc::now();
182
183 let opts = engine::PruneOptions {
189 idle_days: 0,
190 dry_run: false,
191 force: true,
192 only_dirs: None,
193 adapters: engine::AdapterFilter::default(),
194 min_size_bytes: registry
195 .settings
196 .min_size_mb
197 .saturating_mul(engine::BYTES_PER_MIB),
198 scan_depth: registry.settings.scan_depth,
199 allow_manifest_rewrite: registry.settings.allow_manifest_rewrite,
200 command_timeout_secs: registry.settings.command_timeout_secs,
201 build_idle_days: registry.settings.build_idle_days,
202 adapter_idle_days: registry.settings.adapter_idle_days.clone(),
203 };
204
205 for path in &candidates {
206 let recorded_before = pruned_dirs.len();
207 let results = engine::prune_repo_with(path, &opts);
208 for result in results {
209 match &result.status {
210 PruneStatus::Pruned => {
211 total_freed += result.size_freed;
212 pruned_count += 1;
213 registry.mark_pruned(&result.repo_path, result.size_freed);
214 pruned_dirs.push(crate::config::PrunedDir {
215 repo_path: result.repo_path.clone(),
216 bloat_dir: result.bloat_dir.clone(),
217 adapter: result.adapter_name.clone(),
218 size_freed: result.size_freed,
219 runtime: result.runtime.clone(),
220 });
221 output::print_success(&format!(
222 "{} → {} ({}) — {}",
223 output::clean_path(&result.repo_path),
224 result.bloat_dir,
225 output::format_bytes(result.size_freed),
226 result.adapter_name,
227 ));
228 }
229 PruneStatus::LockfileError(e) => {
230 error_count += 1;
231 crate::commands::run::report_lockfile_failure(&result, e);
232 }
233 PruneStatus::DeleteError(e) => {
234 error_count += 1;
235 if result.size_freed > 0 {
240 pruned_dirs.push(crate::config::PrunedDir {
241 repo_path: result.repo_path.clone(),
242 bloat_dir: result.bloat_dir.clone(),
243 adapter: result.adapter_name.clone(),
244 size_freed: result.size_freed,
245 runtime: result.runtime.clone(),
246 });
247 }
248 output::print_error(&format!(
249 "{} delete failed: {}",
250 output::clean_path(&result.repo_path),
251 e,
252 ));
253 }
254 PruneStatus::ConfigError(e) => {
255 error_count += 1;
256 output::print_error(&format!(
257 "{} skipped — unreadable .devprune.json: {}",
258 output::clean_path(&result.repo_path),
259 e,
260 ));
261 }
262 PruneStatus::SkippedSymlink(e) => {
265 output::print_warning(&format!(
266 "{} → {}",
267 output::clean_path(&result.repo_path),
268 e.trim(),
269 ));
270 }
271 _ => {}
272 }
273 }
274
275 if pruned_dirs.len() > recorded_before {
280 registry.record_prune_progress(pass_at, pruned_dirs.clone());
281 let _ = registry.save();
282 }
283 }
284
285 registry.record_prune_progress(pass_at, pruned_dirs);
286 registry.save()?;
287
288 output::print_header("Summary");
289 output::print_success(&format!(
290 "Freed: {} across {pruned_count} directories",
291 output::format_bytes(total_freed)
292 ));
293 if error_count > 0 {
296 anyhow::bail!("{error_count} directories could not be pruned.");
297 }
298 }
299 Ok(_) => {
300 }
302 Err(e) => {
303 output::print_warning(&format!("Interactive view ended: {e:#}"));
307 status_view::render_status_plain(&repos);
308 }
309 }
310 } else {
311 status_view::render_status_plain(&repos);
313 }
314
315 Ok(())
316}
317
318fn run_drift(json_output: bool) -> Result<()> {
326 let registry = Registry::load()?;
327
328 let pb = (!json_output)
329 .then(|| output::create_spinner("Comparing environments against lockfiles..."));
330
331 let mut findings: Vec<ProjectDrift> = Vec::new();
332 for path in registry.repositories.keys() {
333 if !path.exists() {
334 continue;
335 }
336 let depth = workspace::resolve_depth(path, registry.settings.scan_depth);
337 for project in workspace::discover_to_depth(path, depth) {
338 for adapter in &project.adapters {
339 for report in adapter.drift(&project.path) {
340 findings.push(ProjectDrift {
341 repository: path.clone(),
342 project: project.relative.clone(),
343 adapter: adapter.name(),
344 report,
345 });
346 }
347 }
348 }
349 }
350 findings.sort_by(|a, b| {
353 (&a.repository, &a.project, a.adapter, &a.report.directory).cmp(&(
354 &b.repository,
355 &b.project,
356 b.adapter,
357 &b.report.directory,
358 ))
359 });
360
361 if let Some(pb) = pb {
362 pb.finish_and_clear();
363 }
364
365 if json_output {
366 return crate::json::emit(&crate::json::drift_document(&findings));
367 }
368
369 output::print_header("Lockfile drift");
370 println!();
371
372 if findings.is_empty() {
373 output::print_success(
374 "No drift found: nothing is installed that the lockfiles do not record.",
375 );
376 output::print_info(
377 "Checked where a cheap file-level comparison exists: node_modules against \
378 package-lock.json (npm), .venv against uv.lock (uv), and every virtual \
379 environment against requirements.txt (venv).",
380 );
381 return Ok(());
382 }
383
384 let mut last_repo: Option<&std::path::Path> = None;
385 for f in &findings {
386 if last_repo != Some(f.repository.as_path()) {
387 println!(" {}", output::clean_path(&f.repository));
388 last_repo = Some(f.repository.as_path());
389 }
390 let location = if f.project == "." {
391 f.report.directory.clone()
392 } else {
393 format!("{}/{}", f.project, f.report.directory)
394 };
395 let shown = f
396 .report
397 .unrecorded
398 .iter()
399 .take(10)
400 .map(String::as_str)
401 .collect::<Vec<_>>()
402 .join(", ");
403 let suffix = if f.report.unrecorded.len() > 10 {
404 format!(", … and {} more", f.report.unrecorded.len() - 10)
405 } else {
406 String::new()
407 };
408 println!(
409 " {} ({}): {} unrecorded {} — {shown}{suffix}",
410 location,
411 f.adapter,
412 f.report.unrecorded.len(),
413 output::plural(f.report.unrecorded.len(), "package", "packages"),
414 );
415 println!(" record them: {}", f.report.record_command);
416 println!();
417 }
418
419 output::print_info(
420 "A prune refuses to delete these environments as they are — the unrecorded \
421 packages would be lost with no way back. Record them with the command shown, \
422 or uninstall them, and the refusal goes away.",
423 );
424 Ok(())
425}
426
427fn format_duration(secs: u64) -> String {
429 match secs {
430 s if s > 0 && s % 3600 == 0 => format!("{}h", s / 3600),
431 s if s > 0 && s % 60 == 0 => format!("{}m", s / 60),
432 s => format!("{s}s"),
433 }
434}
435
436#[cfg(test)]
437mod tests {
438 use super::*;
439
440 #[test]
441 fn the_timeout_is_described_in_whatever_unit_fits_it() {
442 assert_eq!(format_duration(600), "10m");
444 assert_eq!(format_duration(3600), "1h");
445 assert_eq!(format_duration(90), "90s");
446 assert_eq!(format_duration(0), "0s");
447 }
448}