Skip to main content

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::output;
18use crate::tui::status_view;
19use crate::workspace;
20
21/// One project's lockfile drift, located: which repository, which project inside it,
22/// which adapter found it, and what it found.
23pub struct ProjectDrift {
24    /// The registered repository the project lives in.
25    pub repository: std::path::PathBuf,
26    /// Project path relative to the repository root, `/`-separated; `"."` is the root.
27    pub project: String,
28    /// The adapter that made the comparison.
29    pub adapter: &'static str,
30    /// The drifted directory, the unrecorded packages, and the command that records them.
31    pub report: DriftReport,
32}
33
34/// Run the `status` command.
35///
36/// `json` replaces the dashboard with one machine-readable document — no banner, no
37/// TUI, no prompt to prune. It is a pure read of state, which is what makes it safe to
38/// hand to an agent or a monitoring job.
39///
40/// `top` trims the repository list to the biggest reclaims. It never changes the totals:
41/// those are computed over every registered repository, so `--top 5` cannot make a
42/// machine look tidier than it is.
43///
44/// `drift` replaces the dashboard with the lockfile-drift report — the environments
45/// holding packages their lockfile never recorded, found before a prune would refuse
46/// on them.
47pub 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    // Both halves of the hook installation, not just the files. Hook scripts on disk with
57    // `core.hooksPath` pointing at another tool never run, and reporting that as "Active"
58    // is the difference between "my repos register themselves" and silently not.
59    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(&registry);
74        return crate::json::emit(&crate::json::status_document(
75            &registry, &repos, &daemon_st, &hook_st, top,
76        ));
77    }
78
79    output::print_banner();
80
81    // Only on the human path: JSON output is a contract, and a version notice printed
82    // into it would corrupt the document.
83    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    // The minutes are derived, not a hardcoded "(10m)" — that read as the default even
95    // after `devp config set command_timeout_secs 60`.
96    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    // Nothing registered is the first-run state, not an error — but an empty dashboard
126    // with no explanation reads as "the tool is broken", so say how to fill it instead.
127    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    // Gather full per-repo detail for ALL registered repositories, then trim the list —
136    // after the totals above, which are deliberately computed over all of them.
137    //
138    // Never on the `--json` path: the bar writes to stderr, but a machine-readable mode
139    // should produce one document and nothing else, and a progress bar in a log capture
140    // is noise a script has to learn to ignore.
141    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(&registry, &|done, _total| {
145        if let Some(pb) = &scan_bar {
146            pb.set_position(done as u64);
147        }
148    });
149    // Over every repository, before `--top` trims the list, for the same reason the
150    // totals above are: `--top 5` must not make the machine look cheaper to undo than
151    // it is.
152    let estimate = restore_estimate_line(&registry, &scanned);
153    let repos = engine::take_top(&scanned, top);
154    if let Some(pb) = scan_bar {
155        // `finish_and_clear`, not `finish`: the dashboard is what the user asked for, and
156        // a completed progress bar left above it is scaffolding.
157        pb.finish_and_clear();
158    }
159
160    if let Some(line) = estimate {
161        output::print_info(&line);
162        println!();
163    }
164
165    // Both ends: the dashboard draws on stdout but reads keys from stdin, and with
166    // stdin redirected it would open on a screen no keypress can ever leave.
167    if io::stdout().is_terminal() && io::stdin().is_terminal() {
168        // Interactive TUI — pass a loader closure so the TUI can reload after
169        // the user toggles ignore config in .devprune.json or presence of ignore.devprune.json on any repo.
170        // It applies the same trim, so the indices it hands back still address `repos`.
171        let registry_ref = &registry;
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                // User confirmed a prune from within the status view. The TUI hands
177                // back paths, not indices — an `i` toggle reloads its list, and
178                // indices into the reloaded list do not address `repos` above.
179                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                // A prune is a prune wherever it was started from. Without this the
185                // dashboard's own pass left no record, so `devp restore --last-run`
186                // silently restored an *older* one.
187                let mut pruned_dirs: Vec<crate::config::PrunedDir> = Vec::new();
188                let pass_at = chrono::Utc::now();
189
190                // The dashboard offered only repositories its own analysis classed as
191                // candidates, so the idle check is settled; everything else follows
192                // the user's settings, exactly as `devp run` resolves them. The bare
193                // `prune_repo` defaults used here before ignored the configured scan
194                // depth, command timeout and manifest-rewrite policy.
195                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                                // A non-zero size_freed on a delete error means the
243                                // delete got half-way: the directory is corrupt, not
244                                // intact. Record it so `devp restore --last-run` can
245                                // rebuild it — the error still fails the pass.
246                                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                            // A warning, not an error: linked storage is deliberately
270                            // left alone and must not fail the pass.
271                            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                    // Persisted after every repository, same as `devp run`: a pass
283                    // killed half-way through must not leave `--last-run` describing
284                    // the previous one. A save failure here is silent — the final
285                    // save below reports it.
286                    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                // Same contract as `devp run`: a prune that failed exits non-zero,
301                // whether it was started from the dashboard or from the command line.
302                if error_count > 0 {
303                    anyhow::bail!("{error_count} directories could not be pruned.");
304                }
305            }
306            Ok(_) => {
307                // User quit without pruning — nothing to do
308            }
309            Err(e) => {
310                // Not necessarily a terminal that cannot do raw mode: toggling ignore
311                // with `i` also ends the view if the config write fails. `{e}` carries
312                // the real reason, so this line does not guess at one.
313                output::print_warning(&format!("Interactive view ended: {e:#}"));
314                status_view::render_status_plain(&repos);
315            }
316        }
317    } else {
318        // Non-TTY: plain text table
319        status_view::render_status_plain(&repos);
320    }
321
322    Ok(())
323}
324
325/// The `--drift` mode: every registered repository, checked for installed-but-unrecorded
326/// packages.
327///
328/// This is the same comparison a prune refuses on, run early and as a pure read — no
329/// package manager is executed and nothing is written. Only the adapters that can
330/// compare an environment against its lockfile from files alone take part (npm, uv,
331/// venv); the others have nothing cheap to say and stay silent rather than guessing.
332fn 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    // The registry is a HashMap; without this the same machine lists its drift in a
358    // different order on every run, which reads like the drift itself changed.
359    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
434/// "How long is this to undo?", answered only when this machine has measured enough to
435/// answer it.
436///
437/// The question `devp status` could not answer before. Space it already reports; what
438/// people hesitate over is the reinstall, and every number in this line comes from
439/// restores timed on this machine by `devp restore --last-run` — never from a table of
440/// typical speeds, which would be a number about somebody else's laptop. An adapter that
441/// has never been timed here contributes nothing and is subtracted from the coverage,
442/// so a partial answer says it is partial instead of reading as a whole one.
443fn 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
475/// A seconds count as the unit a human would have typed it in.
476fn 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        // The old line hardcoded "(10m)", so every value looked like the default.
491        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}