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::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(&registry);
87        return crate::json::emit(&crate::json::status_document(
88            &registry, &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(&registry, &|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(&registry, &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 = &registry;
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                // The dashboard offered only repositories its own analysis classed as
209                // candidates, so the idle check is settled; everything else follows
210                // the user's settings, exactly as `devp run` resolves them. The bare
211                // `prune_repo` defaults used here before ignored the configured scan
212                // depth, command timeout and manifest-rewrite policy.
213                let opts = engine::PruneOptions {
214                    idle_days: 0,
215                    dry_run: false,
216                    force: true,
217                    only_dirs: None,
218                    adapters: engine::AdapterFilter::default(),
219                    min_size_bytes: registry
220                        .settings
221                        .min_size_mb
222                        .saturating_mul(engine::BYTES_PER_MIB),
223                    scan_depth: registry.settings.scan_depth,
224                    allow_manifest_rewrite: registry.settings.allow_manifest_rewrite,
225                    command_timeout_secs: registry.settings.command_timeout_secs,
226                    build_idle_days: registry.settings.build_idle_days,
227                    adapter_idle_days: registry.settings.adapter_idle_days.clone(),
228                };
229
230                for path in &candidates {
231                    let recorded_before = pruned_dirs.len();
232                    let results = engine::prune_repo_with(path, &opts);
233                    for result in results {
234                        match &result.status {
235                            PruneStatus::Pruned => {
236                                total_freed += result.size_freed;
237                                pruned_count += 1;
238                                registry.mark_pruned(&result.repo_path, result.size_freed);
239                                pruned_dirs.push(crate::config::PrunedDir {
240                                    repo_path: result.repo_path.clone(),
241                                    bloat_dir: result.bloat_dir.clone(),
242                                    adapter: result.adapter_name.clone(),
243                                    size_freed: result.size_freed,
244                                    runtime: result.runtime.clone(),
245                                });
246                                output::print_success(&format!(
247                                    "{} → {} ({}) — {}",
248                                    output::clean_path(&result.repo_path),
249                                    result.bloat_dir,
250                                    output::format_bytes(result.size_freed),
251                                    result.adapter_name,
252                                ));
253                            }
254                            PruneStatus::LockfileError(e) => {
255                                error_count += 1;
256                                crate::commands::run::report_lockfile_failure(&result, e);
257                            }
258                            PruneStatus::DeleteError(e) => {
259                                error_count += 1;
260                                // A non-zero size_freed on a delete error means the
261                                // delete got half-way: the directory is corrupt, not
262                                // intact. Record it so `devp restore --last-run` can
263                                // rebuild it — the error still fails the pass.
264                                if result.size_freed > 0 {
265                                    pruned_dirs.push(crate::config::PrunedDir {
266                                        repo_path: result.repo_path.clone(),
267                                        bloat_dir: result.bloat_dir.clone(),
268                                        adapter: result.adapter_name.clone(),
269                                        size_freed: result.size_freed,
270                                        runtime: result.runtime.clone(),
271                                    });
272                                }
273                                output::print_error(&format!(
274                                    "{} delete failed: {}",
275                                    output::clean_path(&result.repo_path),
276                                    e,
277                                ));
278                            }
279                            PruneStatus::ConfigError(e) => {
280                                error_count += 1;
281                                output::print_error(&format!(
282                                    "{} skipped — unreadable .devprune.json: {}",
283                                    output::clean_path(&result.repo_path),
284                                    e,
285                                ));
286                            }
287                            // A warning, not an error: linked storage and a refused
288                            // declaration are both deliberately left alone and must
289                            // not fail the pass.
290                            PruneStatus::SkippedSymlink(e) | PruneStatus::SkippedDeclaration(e) => {
291                                output::print_warning(&format!(
292                                    "{} → {}",
293                                    output::clean_path(&result.repo_path),
294                                    e.trim(),
295                                ));
296                            }
297                            _ => {}
298                        }
299                    }
300
301                    // Persisted after every repository, same as `devp run`: a pass
302                    // killed half-way through must not leave `--last-run` describing
303                    // the previous one. A save failure here is silent — the final
304                    // save below reports it.
305                    if pruned_dirs.len() > recorded_before {
306                        registry.record_prune_progress(pass_at, pruned_dirs.clone());
307                        let _ = registry.save();
308                    }
309                }
310
311                registry.record_prune_progress(pass_at, pruned_dirs);
312                registry.save()?;
313
314                output::print_header(i18n::t("run.summary"));
315                output::print_success(&i18n::tf(
316                    "run.freed",
317                    &[
318                        ("size", &output::format_bytes(total_freed)),
319                        ("count", &pruned_count.to_string()),
320                    ],
321                ));
322                // Same contract as `devp run`: a prune that failed exits non-zero,
323                // whether it was started from the dashboard or from the command line.
324                if error_count > 0 {
325                    anyhow::bail!("{error_count} directories could not be pruned.");
326                }
327            }
328            Ok(_) => {
329                // User quit without pruning — nothing to do
330            }
331            Err(e) => {
332                // Not necessarily a terminal that cannot do raw mode: toggling ignore
333                // with `i` also ends the view if the config write fails. `{e}` carries
334                // the real reason, so this line does not guess at one.
335                output::print_warning(&format!("Interactive view ended: {e:#}"));
336                status_view::render_status_plain(&repos);
337            }
338        }
339    } else {
340        // Non-TTY: plain text table
341        status_view::render_status_plain(&repos);
342    }
343
344    Ok(())
345}
346
347/// The `--drift` mode: every registered repository, checked for installed-but-unrecorded
348/// packages.
349///
350/// This is the same comparison a prune refuses on, run early and as a pure read — no
351/// package manager is executed and nothing is written. Only the adapters that can
352/// compare an environment against its lockfile from files alone take part (npm, uv,
353/// venv); the others have nothing cheap to say and stay silent rather than guessing.
354fn run_drift(json_output: bool) -> Result<()> {
355    let registry = Registry::load()?;
356
357    let pb = (!json_output)
358        .then(|| output::create_spinner("Comparing environments against lockfiles..."));
359
360    let mut findings: Vec<ProjectDrift> = Vec::new();
361    for path in registry.repositories.keys() {
362        if !path.exists() {
363            continue;
364        }
365        let depth = workspace::resolve_depth(path, registry.settings.scan_depth);
366        for project in workspace::discover_to_depth(path, depth) {
367            for adapter in &project.adapters {
368                for report in adapter.drift(&project.path) {
369                    findings.push(ProjectDrift {
370                        repository: path.clone(),
371                        project: project.relative.clone(),
372                        adapter: adapter.name(),
373                        report,
374                    });
375                }
376            }
377        }
378    }
379    // The registry is a HashMap; without this the same machine lists its drift in a
380    // different order on every run, which reads like the drift itself changed.
381    findings.sort_by(|a, b| {
382        (&a.repository, &a.project, a.adapter, &a.report.directory).cmp(&(
383            &b.repository,
384            &b.project,
385            b.adapter,
386            &b.report.directory,
387        ))
388    });
389
390    if let Some(pb) = pb {
391        pb.finish_and_clear();
392    }
393
394    if json_output {
395        return crate::json::emit(&crate::json::drift_document(&findings));
396    }
397
398    output::print_header(i18n::t("status.header.drift"));
399    println!();
400
401    if findings.is_empty() {
402        output::print_success(
403            "No drift found: nothing is installed that the lockfiles do not record.",
404        );
405        output::print_info(
406            "Checked where a cheap file-level comparison exists: node_modules against \
407             package-lock.json (npm), .venv against uv.lock (uv), and every virtual \
408             environment against requirements.txt (venv).",
409        );
410        return Ok(());
411    }
412
413    let mut last_repo: Option<&std::path::Path> = None;
414    for f in &findings {
415        if last_repo != Some(f.repository.as_path()) {
416            println!("  {}", output::clean_path(&f.repository));
417            last_repo = Some(f.repository.as_path());
418        }
419        let location = if f.project == "." {
420            f.report.directory.clone()
421        } else {
422            format!("{}/{}", f.project, f.report.directory)
423        };
424        let shown = f
425            .report
426            .unrecorded
427            .iter()
428            .take(10)
429            .map(String::as_str)
430            .collect::<Vec<_>>()
431            .join(", ");
432        let suffix = if f.report.unrecorded.len() > 10 {
433            format!(", … and {} more", f.report.unrecorded.len() - 10)
434        } else {
435            String::new()
436        };
437        println!(
438            "    {} ({}): {} unrecorded {} — {shown}{suffix}",
439            location,
440            f.adapter,
441            f.report.unrecorded.len(),
442            output::plural(f.report.unrecorded.len(), "package", "packages"),
443        );
444        println!("      record them: {}", f.report.record_command);
445        println!();
446    }
447
448    output::print_info(
449        "A prune refuses to delete these environments as they are — the unrecorded \
450         packages would be lost with no way back. Record them with the command shown, \
451         or uninstall them, and the refusal goes away.",
452    );
453    Ok(())
454}
455
456/// "How long is this to undo?", answered only when this machine has measured enough to
457/// answer it.
458///
459/// The question `devp status` could not answer before. Space it already reports; what
460/// people hesitate over is the reinstall, and every number in this line comes from
461/// restores timed on this machine by `devp restore --last-run` — never from a table of
462/// typical speeds, which would be a number about somebody else's laptop. An adapter that
463/// has never been timed here contributes nothing and is subtracted from the coverage,
464/// so a partial answer says it is partial instead of reading as a whole one.
465fn restore_estimate_line(registry: &Registry, repos: &[engine::RepoStatusEntry]) -> Option<String> {
466    let mut by_adapter: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();
467    for repo in repos {
468        for (adapter, bytes) in &repo.reclaimable_by_adapter {
469            *by_adapter.entry(adapter.clone()).or_default() += bytes;
470        }
471    }
472    let total: u64 = by_adapter.values().sum();
473    let tallied: Vec<(String, u64)> = by_adapter.into_iter().collect();
474    let (secs, covered) = registry.estimate_restore(&tallied)?;
475
476    let samples: usize = registry
477        .restore_rates
478        .values()
479        .map(|r| r.samples as usize)
480        .sum();
481    let mut line = format!(
482        "Estimated Restore Cost: ~{} to put it all back, from {} timed {} on this machine",
483        output::format_seconds(secs.round() as u64),
484        samples,
485        output::plural(samples, "restore", "restores"),
486    );
487    if covered < total {
488        line.push_str(&format!(
489            " (covers {} of {} — the rest has never been restored here)",
490            output::format_bytes(covered),
491            output::format_bytes(total),
492        ));
493    }
494    Some(line)
495}
496
497/// A seconds count as the unit a human would have typed it in.
498fn format_duration(secs: u64) -> String {
499    match secs {
500        s if s > 0 && s % 3600 == 0 => format!("{}h", s / 3600),
501        s if s > 0 && s % 60 == 0 => format!("{}m", s / 60),
502        s => format!("{s}s"),
503    }
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509
510    #[test]
511    fn the_timeout_is_described_in_whatever_unit_fits_it() {
512        // The old line hardcoded "(10m)", so every value looked like the default.
513        assert_eq!(format_duration(600), "10m");
514        assert_eq!(format_duration(3600), "1h");
515        assert_eq!(format_duration(90), "90s");
516        assert_eq!(format_duration(0), "0s");
517    }
518}