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;
11
12use crate::adapters::DriftReport;
13use crate::commands::hook::HookState;
14use crate::config::Registry;
15use crate::engine::{self, PruneStatus};
16use crate::i18n;
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 deletes nothing, which is what makes it safe to hand to
38/// an agent or a monitoring job. It may still *register* the repository the caller is
39/// standing in, on both paths and deliberately: an agent that asks about a repository
40/// and a human who asks about the same one must not get different answers.
41///
42/// `top` trims the repository list to the biggest reclaims. It never changes the totals:
43/// those are computed over every registered repository, so `--top 5` cannot make a
44/// machine look tidier than it is.
45///
46/// `drift` replaces the dashboard with the lockfile-drift report — the environments
47/// holding packages their lockfile never recorded, found before a prune would refuse
48/// on them.
49pub fn run(top: Option<usize>, drift: bool, json_output: bool) -> Result<()> {
50    if drift {
51        return run_drift(json_output);
52    }
53    let mut registry = Registry::load()?;
54
55    // Auto-heal moved repositories (e.g. moved to Archive) and purge dead ephemeral AI worktrees.
56    let healed = registry.heal_dead_and_moved_repositories();
57    if healed > 0 {
58        let _ = registry.save();
59    }
60
61    // Before anything is reported: the repository the user is standing in may be one
62    // `git init` created, which fires no Git hook and so has never registered itself.
63    // Asking `devp status` about it is the most likely way to notice, so answer it here
64    // rather than showing a dashboard that is missing the one repository being asked
65    // about. See `link::adopt_enclosing_repo` for the guards.
66    let adopted = crate::commands::link::adopt_enclosing_repo(&mut registry);
67    if adopted.is_some() {
68        registry.save()?;
69    }
70
71    let daemon_st = crate::daemon::daemon_status()
72        .map(|s| s.to_string())
73        .unwrap_or_else(|_| "Unknown".to_string());
74    // Both halves of the hook installation, not just the files. Hook scripts on disk with
75    // `core.hooksPath` pointing at another tool never run, and reporting that as "Active"
76    // is the difference between "my repos register themselves" and silently not.
77    let hook_st = match crate::commands::hook::state() {
78        Ok(HookState::Active) => "Active (post-commit, post-checkout, post-merge)".to_string(),
79        Ok(HookState::Chained { previous, drifted }) if drifted.is_empty() => {
80            format!("Active, chained to {previous}")
81        }
82        Ok(HookState::Chained { previous, drifted }) => format!(
83            "Active, chained to {previous} ({} hook(s) not forwarded)",
84            drifted.len()
85        ),
86        Ok(HookState::Foreign(path)) => format!("Inactive (core.hooksPath belongs to {path})"),
87        Ok(HookState::Absent) | Err(_) => "Inactive".to_string(),
88    };
89
90    if json_output {
91        let repos = engine::get_full_status(&registry);
92        return crate::json::emit(&crate::json::status_document(
93            &registry, &repos, &daemon_st, &hook_st, top,
94        ));
95    }
96
97    output::print_banner();
98
99    if let Some(path) = &adopted {
100        crate::commands::link::report_cwd_adoption(path);
101        println!();
102    }
103
104    // Only on the human path: JSON output is a contract, and a version notice printed
105    // into it would corrupt the document.
106    if crate::commands::update::notify_if_outdated(&mut registry) {
107        let _ = registry.save();
108    }
109
110    let reg_path = Registry::registry_path()
111        .map(|p| output::clean_path(&p))
112        .unwrap_or_else(|_| "unknown".to_string());
113
114    output::print_info(&format!("Global Config Location: {}", reg_path));
115    output::print_info(&format!("Background OS Daemon:   {}", daemon_st));
116    output::print_info(&format!("Background Git Hooks:   {}", hook_st));
117    // The minutes are derived, not a hardcoded "(10m)" — that read as the default even
118    // after `devp config set command_timeout_secs 60`.
119    let timeout = registry.settings.command_timeout_secs;
120    output::print_info(&format!(
121        "Global Command Timeout: {timeout}s ({})",
122        format_duration(timeout)
123    ));
124    if registry.settings.min_size_mb > 0 {
125        output::print_info(&format!(
126            "Minimum Directory Size: {} MiB (smaller ones are left alone)",
127            registry.settings.min_size_mb
128        ));
129    }
130    output::print_info(&format!(
131        "Tracked Repositories:   {}",
132        registry.repo_count()
133    ));
134    output::print_info(&format!(
135        "Historical Space Saved: {} across {} prune {}",
136        output::format_bytes_styled(registry.total_freed_bytes),
137        registry.total_pruned_count,
138        output::plural(registry.total_pruned_count as usize, "pass", "passes")
139    ));
140    if let Some(n) = top {
141        output::print_info(&format!(
142            "Showing:                the {n} {} with the most reclaimable space",
143            output::plural(n, "repository", "repositories")
144        ));
145    }
146    println!();
147
148    // Nothing registered is the first-run state, not an error — but an empty dashboard
149    // with no explanation reads as "the tool is broken", so say how to fill it instead.
150    if registry.repositories.is_empty() {
151        output::print_info(
152            "No repositories are registered yet. `devp init <folder>` scans a folder and \
153             registers every Git repository in it; `devp link .` registers just one.",
154        );
155        return Ok(());
156    }
157
158    // Gather full per-repo detail for ALL registered repositories, then trim the list —
159    // after the totals above, which are deliberately computed over all of them.
160    //
161    // Never on the `--json` path: the bar writes to stderr, but a machine-readable mode
162    // should produce one document and nothing else, and a progress bar in a log capture
163    // is noise a script has to learn to ignore.
164    let scan_bar = (!json_output).then(|| {
165        output::create_progress_bar("Scanning repositories", registry.repositories.len() as u64)
166    });
167    let scanned = engine::get_full_status_reporting(&registry, &|done, _total| {
168        if let Some(pb) = &scan_bar {
169            pb.set_position(done as u64);
170        }
171    });
172    // Over every repository, before `--top` trims the list, for the same reason the
173    // totals above are: `--top 5` must not make the machine look cheaper to undo than
174    // it is.
175    let estimate = restore_estimate_line(&registry, &scanned);
176    let repos = engine::take_top(&scanned, top);
177    if let Some(pb) = scan_bar {
178        // `finish_and_clear`, not `finish`: the dashboard is what the user asked for, and
179        // a completed progress bar left above it is scaffolding.
180        pb.finish_and_clear();
181    }
182
183    if let Some(line) = estimate {
184        output::print_info(&line);
185        println!();
186    }
187
188    if crate::tui::full_screen_is_usable() {
189        // Interactive TUI — pass a loader closure so the TUI can reload after
190        // the user toggles ignore config in .devprune.json or presence of ignore.devprune.json on any repo.
191        // It applies the same trim, so the indices it hands back still address `repos`.
192        let registry_ref = &registry;
193        match status_view::render_status_tui(&|| {
194            engine::take_top(&engine::get_full_status(registry_ref), top)
195        }) {
196            Ok(Some(candidates)) if !candidates.is_empty() => {
197                // User confirmed a prune from within the status view. The TUI hands
198                // back paths, not indices — an `i` toggle reloads its list, and
199                // indices into the reloaded list do not address `repos` above.
200                output::print_header(i18n::t("status.header.pruning"));
201
202                let mut total_freed: u64 = 0;
203                let mut pruned_count = 0;
204                let mut error_count = 0;
205                // A prune is a prune wherever it was started from. Without this the
206                // dashboard's own pass left no record, so `devp restore --last-run`
207                // silently restored an *older* one.
208                let mut pruned_dirs: Vec<crate::config::PrunedDir> = Vec::new();
209                let pass_at = chrono::Utc::now();
210
211                // `force` here means the idle check is the user's to make, not a
212                // bypass of anything else: the dashboard shows how long each repository
213                // has been idle and they picked these rows off that screen, so asking
214                // the engine the same question again would only refuse the active ones
215                // they deliberately checked. It gates nothing but the two idle
216                // thresholds — lockfile verification still runs on every directory, and
217                // a repository whose lockfile cannot be trusted is refused below.
218                //
219                // Everything else follows the user's settings, exactly as `devp run`
220                // resolves them. The bare `prune_repo` defaults used here before ignored
221                // the configured scan depth, command timeout and manifest-rewrite policy.
222                let opts = engine::PruneOptions {
223                    idle_days: 0,
224                    dry_run: false,
225                    force: true,
226                    only_dirs: None,
227                    adapters: engine::AdapterFilter::default(),
228                    min_size_bytes: registry
229                        .settings
230                        .min_size_mb
231                        .saturating_mul(engine::BYTES_PER_MIB),
232                    scan_depth: registry.settings.scan_depth,
233                    allow_manifest_rewrite: registry.settings.allow_manifest_rewrite,
234                    command_timeout_secs: registry.settings.command_timeout_secs,
235                    build_idle_days: registry.settings.build_idle_days,
236                    adapter_idle_days: registry.settings.adapter_idle_days.clone(),
237                };
238
239                for path in &candidates {
240                    let recorded_before = pruned_dirs.len();
241                    let results = engine::prune_repo_with(path, &opts);
242                    for result in results {
243                        match &result.status {
244                            PruneStatus::Pruned => {
245                                total_freed += result.size_freed;
246                                pruned_count += 1;
247                                registry.mark_pruned(&result.repo_path, result.size_freed);
248                                pruned_dirs.push(crate::config::PrunedDir {
249                                    repo_path: result.repo_path.clone(),
250                                    bloat_dir: result.bloat_dir.clone(),
251                                    adapter: result.adapter_name.clone(),
252                                    size_freed: result.size_freed,
253                                    runtime: result.runtime.clone(),
254                                });
255                                output::print_success(&format!(
256                                    "{} → {} ({}) — {}",
257                                    output::clean_path(&result.repo_path),
258                                    result.bloat_dir,
259                                    output::format_bytes(result.size_freed),
260                                    result.adapter_name,
261                                ));
262                            }
263                            PruneStatus::LockfileError(e) => {
264                                error_count += 1;
265                                crate::commands::run::report_lockfile_failure(&result, e);
266                            }
267                            PruneStatus::DeleteError(e) => {
268                                error_count += 1;
269                                // A non-zero size_freed on a delete error means the
270                                // delete got half-way: the directory is corrupt, not
271                                // intact. Record it so `devp restore --last-run` can
272                                // rebuild it — the error still fails the pass.
273                                if result.size_freed > 0 {
274                                    pruned_dirs.push(crate::config::PrunedDir {
275                                        repo_path: result.repo_path.clone(),
276                                        bloat_dir: result.bloat_dir.clone(),
277                                        adapter: result.adapter_name.clone(),
278                                        size_freed: result.size_freed,
279                                        runtime: result.runtime.clone(),
280                                    });
281                                }
282                                output::print_error(&format!(
283                                    "{} delete failed: {}",
284                                    output::clean_path(&result.repo_path),
285                                    e,
286                                ));
287                            }
288                            PruneStatus::ConfigError(e) => {
289                                error_count += 1;
290                                output::print_error(&format!(
291                                    "{} skipped — unreadable .devprune.json: {}",
292                                    output::clean_path(&result.repo_path),
293                                    e,
294                                ));
295                            }
296                            // A warning, not an error: linked storage and a refused
297                            // declaration are both deliberately left alone and must
298                            // not fail the pass.
299                            PruneStatus::SkippedSymlink(e)
300                            | PruneStatus::SkippedDeclaration(e)
301                            | PruneStatus::SkippedNestedRepo(e) => {
302                                output::print_warning(&format!(
303                                    "{} → {}",
304                                    output::clean_path(&result.repo_path),
305                                    e.trim(),
306                                ));
307                            }
308                            _ => {}
309                        }
310                    }
311
312                    // Persisted after every repository, same as `devp run`: a pass
313                    // killed half-way through must not leave `--last-run` describing
314                    // the previous one. A save failure here is silent — the final
315                    // save below reports it.
316                    if pruned_dirs.len() > recorded_before {
317                        registry.record_prune_progress(pass_at, pruned_dirs.clone());
318                        crate::history::record(
319                            pass_at,
320                            crate::history::Trigger::Dashboard,
321                            &pruned_dirs,
322                        );
323                        let _ = registry.save();
324                    }
325                }
326
327                crate::history::record(pass_at, crate::history::Trigger::Dashboard, &pruned_dirs);
328                registry.record_prune_progress(pass_at, pruned_dirs);
329                registry.save()?;
330
331                output::print_header(i18n::t("run.summary"));
332                output::print_success(&i18n::tf(
333                    "run.freed",
334                    &[
335                        ("size", &output::format_bytes(total_freed)),
336                        ("count", &pruned_count.to_string()),
337                    ],
338                ));
339                // Same contract as `devp run`: a prune that failed exits non-zero,
340                // whether it was started from the dashboard or from the command line.
341                if error_count > 0 {
342                    anyhow::bail!("{error_count} directories could not be pruned.");
343                }
344            }
345            Ok(_) => {
346                // User quit without pruning — nothing to do
347            }
348            Err(e) => {
349                // Not necessarily a terminal that cannot do raw mode: toggling ignore
350                // with `i` also ends the view if the config write fails. `{e}` carries
351                // the real reason, so this line does not guess at one.
352                output::print_warning(&format!("Interactive view ended: {e:#}"));
353                status_view::render_status_plain(&repos);
354            }
355        }
356    } else {
357        // Non-TTY: plain text table
358        status_view::render_status_plain(&repos);
359    }
360
361    Ok(())
362}
363
364/// The `--drift` mode: every registered repository, checked for installed-but-unrecorded
365/// packages.
366///
367/// This is the same comparison a prune refuses on, run early and as a pure read — no
368/// package manager is executed and nothing is written. Only the adapters that can
369/// compare an environment against its lockfile from files alone take part (npm, uv,
370/// venv); the others have nothing cheap to say and stay silent rather than guessing.
371fn run_drift(json_output: bool) -> Result<()> {
372    let registry = Registry::load()?;
373
374    let pb = (!json_output)
375        .then(|| output::create_spinner("Comparing environments against lockfiles..."));
376
377    let mut findings: Vec<ProjectDrift> = Vec::new();
378    for path in registry.repositories.keys() {
379        if !path.exists() {
380            continue;
381        }
382        let depth = workspace::resolve_depth(path, registry.settings.scan_depth);
383        for project in workspace::discover_to_depth(path, depth) {
384            for adapter in &project.adapters {
385                for report in adapter.drift(&project.path) {
386                    findings.push(ProjectDrift {
387                        repository: path.clone(),
388                        project: project.relative.clone(),
389                        adapter: adapter.name(),
390                        report,
391                    });
392                }
393            }
394        }
395    }
396    // The registry is a HashMap; without this the same machine lists its drift in a
397    // different order on every run, which reads like the drift itself changed.
398    findings.sort_by(|a, b| {
399        (&a.repository, &a.project, a.adapter, &a.report.directory).cmp(&(
400            &b.repository,
401            &b.project,
402            b.adapter,
403            &b.report.directory,
404        ))
405    });
406
407    if let Some(pb) = pb {
408        pb.finish_and_clear();
409    }
410
411    if json_output {
412        return crate::json::emit(&crate::json::drift_document(&findings));
413    }
414
415    output::print_header(i18n::t("status.header.drift"));
416    println!();
417
418    if findings.is_empty() {
419        output::print_success(
420            "No drift found: nothing is installed that the lockfiles do not record.",
421        );
422        output::print_info(
423            "Checked where a cheap file-level comparison exists: node_modules against \
424             package-lock.json (npm), .venv against uv.lock (uv), and every virtual \
425             environment against requirements.txt (venv).",
426        );
427        return Ok(());
428    }
429
430    let mut last_repo: Option<&std::path::Path> = None;
431    for f in &findings {
432        if last_repo != Some(f.repository.as_path()) {
433            println!("  {}", output::clean_path(&f.repository));
434            last_repo = Some(f.repository.as_path());
435        }
436        let location = if f.project == "." {
437            f.report.directory.clone()
438        } else {
439            format!("{}/{}", f.project, f.report.directory)
440        };
441        let shown = f
442            .report
443            .unrecorded
444            .iter()
445            .take(10)
446            .map(String::as_str)
447            .collect::<Vec<_>>()
448            .join(", ");
449        let suffix = if f.report.unrecorded.len() > 10 {
450            format!(", … and {} more", f.report.unrecorded.len() - 10)
451        } else {
452            String::new()
453        };
454        println!(
455            "    {} ({}): {} unrecorded {} — {shown}{suffix}",
456            location,
457            f.adapter,
458            f.report.unrecorded.len(),
459            output::plural(f.report.unrecorded.len(), "package", "packages"),
460        );
461        println!("      record them: {}", f.report.record_command);
462        println!();
463    }
464
465    output::print_info(
466        "A prune refuses to delete these environments as they are — the unrecorded \
467         packages would be lost with no way back. Record them with the command shown, \
468         or uninstall them, and the refusal goes away.",
469    );
470    Ok(())
471}
472
473/// "How long is this to undo?", answered only when this machine has measured enough to
474/// answer it.
475///
476/// The question `devp status` could not answer before. Space it already reports; what
477/// people hesitate over is the reinstall, and every number in this line comes from
478/// restores timed on this machine by `devp restore --last-run` — never from a table of
479/// typical speeds, which would be a number about somebody else's laptop. An adapter that
480/// has never been timed here contributes nothing and is subtracted from the coverage,
481/// so a partial answer says it is partial instead of reading as a whole one.
482fn restore_estimate_line(registry: &Registry, repos: &[engine::RepoStatusEntry]) -> Option<String> {
483    let mut by_adapter: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();
484    for repo in repos {
485        for (adapter, bytes) in &repo.reclaimable_by_adapter {
486            *by_adapter.entry(adapter.clone()).or_default() += bytes;
487        }
488    }
489    let total: u64 = by_adapter.values().sum();
490    let tallied: Vec<(String, u64)> = by_adapter.into_iter().collect();
491    let (secs, covered) = registry.estimate_restore(&tallied)?;
492
493    let samples: usize = registry
494        .restore_rates
495        .values()
496        .map(|r| r.samples as usize)
497        .sum();
498    let mut line = format!(
499        "Estimated Restore Cost: ~{} to put it all back, from {} timed {} on this machine",
500        output::format_seconds(secs.round() as u64),
501        samples,
502        output::plural(samples, "restore", "restores"),
503    );
504    if covered < total {
505        line.push_str(&format!(
506            " (covers {} of {} — the rest has never been restored here)",
507            output::format_bytes(covered),
508            output::format_bytes(total),
509        ));
510    }
511    Some(line)
512}
513
514/// A seconds count as the unit a human would have typed it in.
515fn format_duration(secs: u64) -> String {
516    match secs {
517        s if s > 0 && s % 3600 == 0 => format!("{}h", s / 3600),
518        s if s > 0 && s % 60 == 0 => format!("{}m", s / 60),
519        s => format!("{s}s"),
520    }
521}
522
523#[cfg(test)]
524mod tests {
525    use super::*;
526
527    #[test]
528    fn the_timeout_is_described_in_whatever_unit_fits_it() {
529        // The old line hardcoded "(10m)", so every value looked like the default.
530        assert_eq!(format_duration(600), "10m");
531        assert_eq!(format_duration(3600), "1h");
532        assert_eq!(format_duration(90), "90s");
533        assert_eq!(format_duration(0), "0s");
534    }
535}