From 486856da60a74da02a19377f6274f04bf7ad05c1 Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Sat, 3 Oct 2026 01:28:30 +0200 Subject: [PATCH] prune,ui: show the removal pass, prefix the sizing spinner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirming a 500 GiB prune went silent under "Removing:" for minutes: drive an indicatif bar from the plan's sizes through a new apply_with_progress observer, in the deb live view's layout — the running count behind the spinner on the first line, the byte bar with its counters on the second. The removed path is not rendered: it fights the bar on a narrow terminal, and the failed-removal errors name the paths that matter. --force plans no sizes and gets the count line only. The dry-run size spinner lacked the "> " prompt prefix the other one-line waits carry; wait_spinner renders it too now. --- src/main.rs | 12 ++++++++- src/prune.rs | 72 +++++++++++++++++++++++++++++++++++++++++++++++++-- src/ui.rs | 50 ++++++++++++++++++++++++++++++++--- src/ui/deb.rs | 9 +------ 4 files changed, 129 insertions(+), 14 deletions(-) diff --git a/src/main.rs b/src/main.rs index a7d60b3..8005243 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1293,7 +1293,17 @@ fn main() { } info!("Removing:"); - let report = pkh::prune::apply(plan); + // The removal is slow (the big session trees take minutes): + // the plan's sizes drive a byte bar in the deb view's layout; + // --force planned no sizes and gets the count line only. + let bar = pkh::ui::removal_bar(&multi, digest.total_bytes); + let report = pkh::prune::apply_with_progress( + plan, + Some(&|idx, count, _path, done| { + pkh::ui::removal_progress(&bar, idx, count, done); + }), + ); + bar.finish_and_clear(); for (path, err) in &report.failed { error!("Failed to remove {}: {}", path.display(), err); } diff --git a/src/prune.rs b/src/prune.rs index 644ef4f..c03e39e 100644 --- a/src/prune.rs +++ b/src/prune.rs @@ -810,18 +810,36 @@ pub fn plan_in_roots( PrunePlan { report, actionable } } +/// Progress observer of [`apply_with_progress`]: the removal of the +/// `idx`-th of `count` artifact (0-based) starts now, and `done` bytes +/// have been freed by the artifacts removed before it. Sizes are the +/// plan's estimates (measured before the removal; `--force` plans no +/// sizes and reports zeros). +pub type RemovalProgress<'a> = &'a dyn Fn(usize, usize, &Path, u64); + /// Execute a plan: remove the planned artifacts (unmounting the /// filesystems under residual directories and session roots first) and /// report what was removed and what failed. A dry-run plan removes /// nothing and returns its digest unchanged. pub fn apply(plan: PrunePlan) -> PruneReport { + apply_with_progress(plan, None) +} + +/// [`apply`], reporting the removal as it progresses (the big session +/// trees take minutes): each artifact is announced just before it goes. +pub fn apply_with_progress(plan: PrunePlan, progress: Option>) -> PruneReport { if plan.report.dry_run { return plan.report; } let mut report = plan.report; report.removed = Vec::new(); - for (artifact, _) in &plan.actionable { + let count = plan.actionable.len(); + let mut done = 0u64; + for (idx, (artifact, bytes)) in plan.actionable.iter().enumerate() { let path = artifact.path(); + if let Some(progress) = progress { + progress(idx, count, path, done); + } // Residual build directories may hold bind-mounted /proc and overlay // filesystems that must be unmounted before the directory can be @@ -838,7 +856,10 @@ pub fn apply(plan: PrunePlan) -> PruneReport { } match remove_path(path) { - Ok(()) => report.removed.push(path.to_path_buf()), + Ok(()) => { + report.removed.push(path.to_path_buf()); + done += bytes; + } Err(e) => report.failed.push((path.to_path_buf(), e.to_string())), } } @@ -1468,6 +1489,53 @@ none /tmp/other proc rw 0 0 assert!(!temp_path.join("pkh-1700000001-2").exists()); } + /// The removal progress announces every artifact just before it goes, + /// with the cumulative bytes of the artifacts already removed: the + /// terminal drives its bar from it while the big trees take minutes. + #[test] + fn apply_reports_each_removal_with_cumulative_sizes() { + let temp = tempdir().unwrap(); + let temp_path = temp.path(); + + let big = temp_path.join("pkh-1700000000"); + fs::create_dir_all(&big).unwrap(); + fs::write(big.join("obj.o"), vec![0u8; 3000]).unwrap(); + let small = temp_path.join("pkh-1700000001"); + fs::create_dir_all(&small).unwrap(); + fs::write(small.join("o"), vec![0u8; 5]).unwrap(); + + let plan = plan_in_roots(temp_path, None, None, None, None, PruneOptions::default()); + assert_eq!(plan.report().total_bytes, 3005); + + let events = std::sync::Mutex::new(Vec::new()); + let report = apply_with_progress( + plan, + Some(&|idx, count, path, done| { + events + .lock() + .unwrap() + .push((idx, count, path.to_path_buf(), done)); + }), + ); + assert_eq!(report.removed.len(), 2); + assert!(!big.exists()); + assert!(!small.exists()); + + let events = events.into_inner().unwrap(); + assert_eq!(events.len(), 2); + // The first announcement opens the pass; the second names the + // other artifact and carries the first one's size (discovery + // order is the temp dir's read order, not ours). + assert_eq!(events[0].0, 0); + assert_eq!(events[0].1, 2); + assert_eq!(events[0].3, 0); + assert_eq!(events[1].0, 1); + assert_eq!(events[1].1, 2); + assert_ne!(events[1].2, events[0].2); + let expected_done = if events[0].2 == big { 3000 } else { 5 }; + assert_eq!(events[1].3, expected_done); + } + #[test] fn test_prune_empty_report_when_nothing_found() { let temp = tempdir().unwrap(); diff --git a/src/ui.rs b/src/ui.rs index b1689ef..5cbcec7 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -7,6 +7,7 @@ pub mod deb; /// yes/no confirmation pub mod prompt; +use crossterm::terminal; #[cfg(test)] use indicatif::ProgressDrawTarget; use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; @@ -41,16 +42,59 @@ pub(crate) fn transfer_style() -> ProgressStyle { } /// A standalone spinner for one-line waits outside a build view (the -/// prune size pass): braille frames over the message, drawn only on -/// terminals. Callers finish it with [`ProgressBar::finish_and_clear`]. +/// prune size pass): braille frames over the message behind the `> ` +/// prompt prefix, drawn only on terminals. Callers finish it with +/// [`ProgressBar::finish_and_clear`]. pub fn wait_spinner(msg: &str) -> ProgressBar { let pb = ProgressBar::new_spinner(); - pb.set_style(ProgressStyle::with_template("{spinner:.blue} {msg}").expect("spinner template")); + pb.set_style( + ProgressStyle::with_template("> {spinner:.blue} {msg}").expect("spinner template"), + ); pb.set_message(msg.to_string()); pb.enable_steady_tick(Duration::from_millis(120)); pb } +/// Width of the terminal in columns, or `None` when it cannot be determined +pub(crate) fn terminal_width() -> Option { + terminal::size().ok().map(|(cols, _)| cols as usize) +} + +/// Removal progress bar of `pkh prune`, in the deb live view's layout: +/// the running count behind the spinner on the first line, the byte bar +/// with its counters on the second when the plan carries sizes (`--force` +/// skips the sizing and gets the count line only). The removed path is +/// deliberately not rendered — it fights the bar on a narrow terminal, +/// and the failed-removal errors name the paths that matter. Drive it +/// with [`removal_progress`]; finish it with +/// [`ProgressBar::finish_and_clear`]. +pub fn removal_bar(multi: &MultiProgress, total_bytes: u64) -> ProgressBar { + let pb = multi.add(ProgressBar::new(total_bytes)); + let template = if total_bytes > 0 { + "> {spinner:.blue} {prefix}\n [{bar:24.cyan/blue}] {bytes}/{total_bytes}" + } else { + "> {spinner:.blue} {prefix}" + }; + pb.set_style( + ProgressStyle::with_template(template) + .expect("removal template") + .progress_chars("=> "), + ); + pb.enable_steady_tick(Duration::from_millis(120)); + pb +} + +/// Advance the removal bar to the `idx`-th of `count` artifact; +/// `done` bytes already freed +pub fn removal_progress(bar: &ProgressBar, idx: usize, count: usize, done: u64) { + bar.set_prefix(format!("Removing {}/{}", idx + 1, count)); + if let Some(total) = bar.length() + && total > 0 + { + bar.set_position(done.min(total)); + } +} + /// Create a spinner-style progress bar attached to `multi`, returning the bar /// and a callback compatible with [`crate::ProgressCallback`] pub fn create_progress_bar( diff --git a/src/ui/deb.rs b/src/ui/deb.rs index 7fa7735..6a7a6c9 100644 --- a/src/ui/deb.rs +++ b/src/ui/deb.rs @@ -488,7 +488,7 @@ fn drop_pane(shared: &Shared) { /// Render the pane content with per-kind colors, ellipsizing lines that are /// wider than the terminal so they do not overflow onto a wrapped line fn render_pane(lines: &VecDeque<(Kind, String)>) -> String { - let max_width = terminal_width().map(|w| w.saturating_sub(PANE_PREFIX_WIDTH)); + let max_width = crate::ui::terminal_width().map(|w| w.saturating_sub(PANE_PREFIX_WIDTH)); render_pane_with_width(lines, max_width) } @@ -515,13 +515,6 @@ fn render_pane_with_width(lines: &VecDeque<(Kind, String)>, max_width: Option Option { - crossterm::terminal::size() - .ok() - .map(|(cols, _)| cols as usize) -} - /// Ellipsize `text` to at most `max_width` display columns, keeping its head /// and appending `…` when it does not fit fn ellipsize(text: &str, max_width: usize) -> String {