dev_prune/commands/status.rs
1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Handler for the `dev-prune status` command.
5//
6// Displays a rich overview of all registered repositories: status, skip
7// reason, last activity, last pruned date, adapters, and reclaimable space.
8// Also allows launching a prune pass directly from the status view.
9
10use 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
22/// One project's lockfile drift, located: which repository, which project inside it,
23/// which adapter found it, and what it found.
24pub struct ProjectDrift {
25 /// The registered repository the project lives in.
26 pub repository: std::path::PathBuf,
27 /// Project path relative to the repository root, `/`-separated; `"."` is the root.
28 pub project: String,
29 /// The adapter that made the comparison.
30 pub adapter: &'static str,
31 /// The drifted directory, the unrecorded packages, and the command that records them.
32 pub report: DriftReport,
33}
34
35/// Run the `status` command.
36///
37/// `json` replaces the dashboard with one machine-readable document — no banner, no
38/// TUI, no prompt to prune. It deletes nothing, which is what makes it safe to hand to
39/// an agent or a monitoring job. It may still *register* the repository the caller is
40/// standing in, on both paths and deliberately: an agent that asks about a repository
41/// and a human who asks about the same one must not get different answers.
42///
43/// `top` trims the repository list to the biggest reclaims. It never changes the totals:
44/// those are computed over every registered repository, so `--top 5` cannot make a
45/// machine look tidier than it is.
46///
47/// `drift` replaces the dashboard with the lockfile-drift report — the environments
48/// holding packages their lockfile never recorded, found before a prune would refuse
49/// on them.
50pub 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 // Before anything is reported: the repository the user is standing in may be one
57 // `git init` created, which fires no Git hook and so has never registered itself.
58 // Asking `devp status` about it is the most likely way to notice, so answer it here
59 // rather than showing a dashboard that is missing the one repository being asked
60 // about. See `link::adopt_enclosing_repo` for the guards.
61 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 // Both halves of the hook installation, not just the files. Hook scripts on disk with
70 // `core.hooksPath` pointing at another tool never run, and reporting that as "Active"
71 // is the difference between "my repos register themselves" and silently not.
72 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 // Only on the human path: JSON output is a contract, and a version notice printed
100 // into it would corrupt the document.
101 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 // The minutes are derived, not a hardcoded "(10m)" — that read as the default even
113 // after `devp config set command_timeout_secs 60`.
114 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 // Nothing registered is the first-run state, not an error — but an empty dashboard
144 // with no explanation reads as "the tool is broken", so say how to fill it instead.
145 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 // Gather full per-repo detail for ALL registered repositories, then trim the list —
154 // after the totals above, which are deliberately computed over all of them.
155 //
156 // Never on the `--json` path: the bar writes to stderr, but a machine-readable mode
157 // should produce one document and nothing else, and a progress bar in a log capture
158 // is noise a script has to learn to ignore.
159 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 // Over every repository, before `--top` trims the list, for the same reason the
168 // totals above are: `--top 5` must not make the machine look cheaper to undo than
169 // it is.
170 let estimate = restore_estimate_line(®istry, &scanned);
171 let repos = engine::take_top(&scanned, top);
172 if let Some(pb) = scan_bar {
173 // `finish_and_clear`, not `finish`: the dashboard is what the user asked for, and
174 // a completed progress bar left above it is scaffolding.
175 pb.finish_and_clear();
176 }
177
178 if let Some(line) = estimate {
179 output::print_info(&line);
180 println!();
181 }
182
183 // Both ends: the dashboard draws on stdout but reads keys from stdin, and with
184 // stdin redirected it would open on a screen no keypress can ever leave.
185 if io::stdout().is_terminal() && io::stdin().is_terminal() {
186 // Interactive TUI — pass a loader closure so the TUI can reload after
187 // the user toggles ignore config in .devprune.json or presence of ignore.devprune.json on any repo.
188 // It applies the same trim, so the indices it hands back still address `repos`.
189 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 // User confirmed a prune from within the status view. The TUI hands
195 // back paths, not indices — an `i` toggle reloads its list, and
196 // indices into the reloaded list do not address `repos` above.
197 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 // A prune is a prune wherever it was started from. Without this the
203 // dashboard's own pass left no record, so `devp restore --last-run`
204 // silently restored an *older* one.
205 let mut pruned_dirs: Vec<crate::config::PrunedDir> = Vec::new();
206 let pass_at = chrono::Utc::now();
207
208 // `force` here means the idle check is the user's to make, not a
209 // bypass of anything else: the dashboard shows how long each repository
210 // has been idle and they picked these rows off that screen, so asking
211 // the engine the same question again would only refuse the active ones
212 // they deliberately checked. It gates nothing but the two idle
213 // thresholds — lockfile verification still runs on every directory, and
214 // a repository whose lockfile cannot be trusted is refused below.
215 //
216 // Everything else follows the user's settings, exactly as `devp run`
217 // resolves them. The bare `prune_repo` defaults used here before ignored
218 // the configured scan depth, command timeout and manifest-rewrite policy.
219 let opts = engine::PruneOptions {
220 idle_days: 0,
221 dry_run: false,
222 force: true,
223 only_dirs: None,
224 adapters: engine::AdapterFilter::default(),
225 min_size_bytes: registry
226 .settings
227 .min_size_mb
228 .saturating_mul(engine::BYTES_PER_MIB),
229 scan_depth: registry.settings.scan_depth,
230 allow_manifest_rewrite: registry.settings.allow_manifest_rewrite,
231 command_timeout_secs: registry.settings.command_timeout_secs,
232 build_idle_days: registry.settings.build_idle_days,
233 adapter_idle_days: registry.settings.adapter_idle_days.clone(),
234 };
235
236 for path in &candidates {
237 let recorded_before = pruned_dirs.len();
238 let results = engine::prune_repo_with(path, &opts);
239 for result in results {
240 match &result.status {
241 PruneStatus::Pruned => {
242 total_freed += result.size_freed;
243 pruned_count += 1;
244 registry.mark_pruned(&result.repo_path, result.size_freed);
245 pruned_dirs.push(crate::config::PrunedDir {
246 repo_path: result.repo_path.clone(),
247 bloat_dir: result.bloat_dir.clone(),
248 adapter: result.adapter_name.clone(),
249 size_freed: result.size_freed,
250 runtime: result.runtime.clone(),
251 });
252 output::print_success(&format!(
253 "{} → {} ({}) — {}",
254 output::clean_path(&result.repo_path),
255 result.bloat_dir,
256 output::format_bytes(result.size_freed),
257 result.adapter_name,
258 ));
259 }
260 PruneStatus::LockfileError(e) => {
261 error_count += 1;
262 crate::commands::run::report_lockfile_failure(&result, e);
263 }
264 PruneStatus::DeleteError(e) => {
265 error_count += 1;
266 // A non-zero size_freed on a delete error means the
267 // delete got half-way: the directory is corrupt, not
268 // intact. Record it so `devp restore --last-run` can
269 // rebuild it — the error still fails the pass.
270 if result.size_freed > 0 {
271 pruned_dirs.push(crate::config::PrunedDir {
272 repo_path: result.repo_path.clone(),
273 bloat_dir: result.bloat_dir.clone(),
274 adapter: result.adapter_name.clone(),
275 size_freed: result.size_freed,
276 runtime: result.runtime.clone(),
277 });
278 }
279 output::print_error(&format!(
280 "{} delete failed: {}",
281 output::clean_path(&result.repo_path),
282 e,
283 ));
284 }
285 PruneStatus::ConfigError(e) => {
286 error_count += 1;
287 output::print_error(&format!(
288 "{} skipped — unreadable .devprune.json: {}",
289 output::clean_path(&result.repo_path),
290 e,
291 ));
292 }
293 // A warning, not an error: linked storage and a refused
294 // declaration are both deliberately left alone and must
295 // not fail the pass.
296 PruneStatus::SkippedSymlink(e)
297 | PruneStatus::SkippedDeclaration(e)
298 | PruneStatus::SkippedNestedRepo(e) => {
299 output::print_warning(&format!(
300 "{} → {}",
301 output::clean_path(&result.repo_path),
302 e.trim(),
303 ));
304 }
305 _ => {}
306 }
307 }
308
309 // Persisted after every repository, same as `devp run`: a pass
310 // killed half-way through must not leave `--last-run` describing
311 // the previous one. A save failure here is silent — the final
312 // save below reports it.
313 if pruned_dirs.len() > recorded_before {
314 registry.record_prune_progress(pass_at, pruned_dirs.clone());
315 crate::history::record(
316 pass_at,
317 crate::history::Trigger::Dashboard,
318 &pruned_dirs,
319 );
320 let _ = registry.save();
321 }
322 }
323
324 crate::history::record(pass_at, crate::history::Trigger::Dashboard, &pruned_dirs);
325 registry.record_prune_progress(pass_at, pruned_dirs);
326 registry.save()?;
327
328 output::print_header(i18n::t("run.summary"));
329 output::print_success(&i18n::tf(
330 "run.freed",
331 &[
332 ("size", &output::format_bytes(total_freed)),
333 ("count", &pruned_count.to_string()),
334 ],
335 ));
336 // Same contract as `devp run`: a prune that failed exits non-zero,
337 // whether it was started from the dashboard or from the command line.
338 if error_count > 0 {
339 anyhow::bail!("{error_count} directories could not be pruned.");
340 }
341 }
342 Ok(_) => {
343 // User quit without pruning — nothing to do
344 }
345 Err(e) => {
346 // Not necessarily a terminal that cannot do raw mode: toggling ignore
347 // with `i` also ends the view if the config write fails. `{e}` carries
348 // the real reason, so this line does not guess at one.
349 output::print_warning(&format!("Interactive view ended: {e:#}"));
350 status_view::render_status_plain(&repos);
351 }
352 }
353 } else {
354 // Non-TTY: plain text table
355 status_view::render_status_plain(&repos);
356 }
357
358 Ok(())
359}
360
361/// The `--drift` mode: every registered repository, checked for installed-but-unrecorded
362/// packages.
363///
364/// This is the same comparison a prune refuses on, run early and as a pure read — no
365/// package manager is executed and nothing is written. Only the adapters that can
366/// compare an environment against its lockfile from files alone take part (npm, uv,
367/// venv); the others have nothing cheap to say and stay silent rather than guessing.
368fn run_drift(json_output: bool) -> Result<()> {
369 let registry = Registry::load()?;
370
371 let pb = (!json_output)
372 .then(|| output::create_spinner("Comparing environments against lockfiles..."));
373
374 let mut findings: Vec<ProjectDrift> = Vec::new();
375 for path in registry.repositories.keys() {
376 if !path.exists() {
377 continue;
378 }
379 let depth = workspace::resolve_depth(path, registry.settings.scan_depth);
380 for project in workspace::discover_to_depth(path, depth) {
381 for adapter in &project.adapters {
382 for report in adapter.drift(&project.path) {
383 findings.push(ProjectDrift {
384 repository: path.clone(),
385 project: project.relative.clone(),
386 adapter: adapter.name(),
387 report,
388 });
389 }
390 }
391 }
392 }
393 // The registry is a HashMap; without this the same machine lists its drift in a
394 // different order on every run, which reads like the drift itself changed.
395 findings.sort_by(|a, b| {
396 (&a.repository, &a.project, a.adapter, &a.report.directory).cmp(&(
397 &b.repository,
398 &b.project,
399 b.adapter,
400 &b.report.directory,
401 ))
402 });
403
404 if let Some(pb) = pb {
405 pb.finish_and_clear();
406 }
407
408 if json_output {
409 return crate::json::emit(&crate::json::drift_document(&findings));
410 }
411
412 output::print_header(i18n::t("status.header.drift"));
413 println!();
414
415 if findings.is_empty() {
416 output::print_success(
417 "No drift found: nothing is installed that the lockfiles do not record.",
418 );
419 output::print_info(
420 "Checked where a cheap file-level comparison exists: node_modules against \
421 package-lock.json (npm), .venv against uv.lock (uv), and every virtual \
422 environment against requirements.txt (venv).",
423 );
424 return Ok(());
425 }
426
427 let mut last_repo: Option<&std::path::Path> = None;
428 for f in &findings {
429 if last_repo != Some(f.repository.as_path()) {
430 println!(" {}", output::clean_path(&f.repository));
431 last_repo = Some(f.repository.as_path());
432 }
433 let location = if f.project == "." {
434 f.report.directory.clone()
435 } else {
436 format!("{}/{}", f.project, f.report.directory)
437 };
438 let shown = f
439 .report
440 .unrecorded
441 .iter()
442 .take(10)
443 .map(String::as_str)
444 .collect::<Vec<_>>()
445 .join(", ");
446 let suffix = if f.report.unrecorded.len() > 10 {
447 format!(", … and {} more", f.report.unrecorded.len() - 10)
448 } else {
449 String::new()
450 };
451 println!(
452 " {} ({}): {} unrecorded {} — {shown}{suffix}",
453 location,
454 f.adapter,
455 f.report.unrecorded.len(),
456 output::plural(f.report.unrecorded.len(), "package", "packages"),
457 );
458 println!(" record them: {}", f.report.record_command);
459 println!();
460 }
461
462 output::print_info(
463 "A prune refuses to delete these environments as they are — the unrecorded \
464 packages would be lost with no way back. Record them with the command shown, \
465 or uninstall them, and the refusal goes away.",
466 );
467 Ok(())
468}
469
470/// "How long is this to undo?", answered only when this machine has measured enough to
471/// answer it.
472///
473/// The question `devp status` could not answer before. Space it already reports; what
474/// people hesitate over is the reinstall, and every number in this line comes from
475/// restores timed on this machine by `devp restore --last-run` — never from a table of
476/// typical speeds, which would be a number about somebody else's laptop. An adapter that
477/// has never been timed here contributes nothing and is subtracted from the coverage,
478/// so a partial answer says it is partial instead of reading as a whole one.
479fn restore_estimate_line(registry: &Registry, repos: &[engine::RepoStatusEntry]) -> Option<String> {
480 let mut by_adapter: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();
481 for repo in repos {
482 for (adapter, bytes) in &repo.reclaimable_by_adapter {
483 *by_adapter.entry(adapter.clone()).or_default() += bytes;
484 }
485 }
486 let total: u64 = by_adapter.values().sum();
487 let tallied: Vec<(String, u64)> = by_adapter.into_iter().collect();
488 let (secs, covered) = registry.estimate_restore(&tallied)?;
489
490 let samples: usize = registry
491 .restore_rates
492 .values()
493 .map(|r| r.samples as usize)
494 .sum();
495 let mut line = format!(
496 "Estimated Restore Cost: ~{} to put it all back, from {} timed {} on this machine",
497 output::format_seconds(secs.round() as u64),
498 samples,
499 output::plural(samples, "restore", "restores"),
500 );
501 if covered < total {
502 line.push_str(&format!(
503 " (covers {} of {} — the rest has never been restored here)",
504 output::format_bytes(covered),
505 output::format_bytes(total),
506 ));
507 }
508 Some(line)
509}
510
511/// A seconds count as the unit a human would have typed it in.
512fn format_duration(secs: u64) -> String {
513 match secs {
514 s if s > 0 && s % 3600 == 0 => format!("{}h", s / 3600),
515 s if s > 0 && s % 60 == 0 => format!("{}m", s / 60),
516 s => format!("{s}s"),
517 }
518}
519
520#[cfg(test)]
521mod tests {
522 use super::*;
523
524 #[test]
525 fn the_timeout_is_described_in_whatever_unit_fits_it() {
526 // The old line hardcoded "(10m)", so every value looked like the default.
527 assert_eq!(format_duration(600), "10m");
528 assert_eq!(format_duration(3600), "1h");
529 assert_eq!(format_duration(90), "90s");
530 assert_eq!(format_duration(0), "0s");
531 }
532}