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::commands::hook::HookState;
14use crate::config::Registry;
15use crate::engine::{self, PruneStatus};
16use crate::output;
17use crate::tui::status_view;
18
19/// Run the `status` command.
20///
21/// `json` replaces the dashboard with one machine-readable document — no banner, no
22/// TUI, no prompt to prune. It is a pure read of state, which is what makes it safe to
23/// hand to an agent or a monitoring job.
24pub fn run(json_output: bool) -> Result<()> {
25    let mut registry = Registry::load()?;
26
27    let daemon_st = crate::daemon::daemon_status()
28        .map(|s| s.to_string())
29        .unwrap_or_else(|_| "Unknown".to_string());
30    // Both halves of the hook installation, not just the files. Hook scripts on disk with
31    // `core.hooksPath` pointing at another tool never run, and reporting that as "Active"
32    // is the difference between "my repos register themselves" and silently not.
33    let hook_st = match crate::commands::hook::state() {
34        Ok(HookState::Active) => "Active (post-commit, post-checkout, post-merge)".to_string(),
35        Ok(HookState::Chained { previous, drifted }) if drifted.is_empty() => {
36            format!("Active, chained to {previous}")
37        }
38        Ok(HookState::Chained { previous, drifted }) => format!(
39            "Active, chained to {previous} ({} hook(s) not forwarded)",
40            drifted.len()
41        ),
42        Ok(HookState::Foreign(path)) => format!("Inactive (core.hooksPath belongs to {path})"),
43        Ok(HookState::Absent) | Err(_) => "Inactive".to_string(),
44    };
45
46    if json_output {
47        let repos = engine::get_full_status(&registry);
48        return crate::json::emit(&crate::json::status_document(
49            &registry, &repos, &daemon_st, &hook_st,
50        ));
51    }
52
53    output::print_banner();
54
55    // Only on the human path: JSON output is a contract, and a version notice printed
56    // into it would corrupt the document.
57    if crate::commands::update::notify_if_outdated(&mut registry) {
58        let _ = registry.save();
59    }
60
61    let reg_path = Registry::registry_path()
62        .map(|p| output::clean_path(&p))
63        .unwrap_or_else(|_| "unknown".to_string());
64
65    output::print_info(&format!("Global Config Location: {}", reg_path));
66    output::print_info(&format!("Background OS Daemon:   {}", daemon_st));
67    output::print_info(&format!("Background Git Hooks:   {}", hook_st));
68    // The minutes are derived, not a hardcoded "(10m)" — that read as the default even
69    // after `devp config set command_timeout_secs 60`.
70    let timeout = registry.settings.command_timeout_secs;
71    output::print_info(&format!(
72        "Global Command Timeout: {timeout}s ({})",
73        format_duration(timeout)
74    ));
75    if registry.settings.min_size_mb > 0 {
76        output::print_info(&format!(
77            "Minimum Directory Size: {} MiB (smaller ones are left alone)",
78            registry.settings.min_size_mb
79        ));
80    }
81    output::print_info(&format!(
82        "Tracked Repositories:   {}",
83        registry.repo_count()
84    ));
85    output::print_info(&format!(
86        "Historical Space Saved: {} across {} prune passes",
87        output::format_bytes(registry.total_freed_bytes),
88        registry.total_pruned_count
89    ));
90    println!();
91
92    // Gather full per-repo detail for ALL registered repositories
93    let repos = engine::get_full_status(&registry);
94
95    if io::stdout().is_terminal() {
96        // Interactive TUI — pass a loader closure so the TUI can reload after
97        // the user toggles ignore config in .devprune.json or presence of ignore.devprune.json on any repo.
98        let registry_ref = &registry;
99        match status_view::render_status_tui(&|| engine::get_full_status(registry_ref)) {
100            Ok(Some(selected_indices)) if !selected_indices.is_empty() => {
101                // User confirmed a prune from within the status view
102                let candidates: Vec<_> = selected_indices
103                    .iter()
104                    .map(|&i| repos[i].path.clone())
105                    .collect();
106
107                output::print_header("Pruning Selected Repositories");
108
109                let mut total_freed: u64 = 0;
110                let mut pruned_count = 0;
111                let mut error_count = 0;
112
113                for path in &candidates {
114                    let results = engine::prune_repo(path, 0, false, true);
115                    for result in results {
116                        match &result.status {
117                            PruneStatus::Pruned => {
118                                total_freed += result.size_freed;
119                                pruned_count += 1;
120                                registry.mark_pruned(&result.repo_path, result.size_freed);
121                                output::print_success(&format!(
122                                    "{} → {} ({}) — {}",
123                                    output::clean_path(&result.repo_path),
124                                    result.bloat_dir,
125                                    output::format_bytes(result.size_freed),
126                                    result.adapter_name,
127                                ));
128                            }
129                            PruneStatus::LockfileError(e) => {
130                                error_count += 1;
131                                output::print_error(&format!(
132                                    "{} lockfile sync failed: {}",
133                                    output::clean_path(&result.repo_path),
134                                    e,
135                                ));
136                            }
137                            PruneStatus::DeleteError(e) => {
138                                error_count += 1;
139                                output::print_error(&format!(
140                                    "{} delete failed: {}",
141                                    output::clean_path(&result.repo_path),
142                                    e,
143                                ));
144                            }
145                            PruneStatus::ConfigError(e) => {
146                                error_count += 1;
147                                output::print_error(&format!(
148                                    "{} skipped — unreadable .devprune.json: {}",
149                                    output::clean_path(&result.repo_path),
150                                    e,
151                                ));
152                            }
153                            _ => {}
154                        }
155                    }
156                }
157
158                registry.save()?;
159
160                output::print_header("Summary");
161                output::print_success(&format!(
162                    "Freed: {} across {pruned_count} directories",
163                    output::format_bytes(total_freed)
164                ));
165                // Same contract as `devp run`: a prune that failed exits non-zero,
166                // whether it was started from the dashboard or from the command line.
167                if error_count > 0 {
168                    anyhow::bail!("{error_count} directories could not be pruned.");
169                }
170            }
171            Ok(_) => {
172                // User quit without pruning — nothing to do
173            }
174            Err(e) => {
175                // Not necessarily a terminal that cannot do raw mode: toggling ignore
176                // with `i` also ends the view if the config write fails. `{e}` carries
177                // the real reason, so this line does not guess at one.
178                output::print_warning(&format!("Interactive view ended: {e:#}"));
179                status_view::render_status_plain(&repos);
180            }
181        }
182    } else {
183        // Non-TTY: plain text table
184        status_view::render_status_plain(&repos);
185    }
186
187    Ok(())
188}
189
190/// A seconds count as the unit a human would have typed it in.
191fn format_duration(secs: u64) -> String {
192    match secs {
193        s if s > 0 && s % 3600 == 0 => format!("{}h", s / 3600),
194        s if s > 0 && s % 60 == 0 => format!("{}m", s / 60),
195        s => format!("{s}s"),
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn the_timeout_is_described_in_whatever_unit_fits_it() {
205        // The old line hardcoded "(10m)", so every value looked like the default.
206        assert_eq!(format_duration(600), "10m");
207        assert_eq!(format_duration(3600), "1h");
208        assert_eq!(format_duration(90), "90s");
209        assert_eq!(format_duration(0), "0s");
210    }
211}