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