prune,ui: show the removal pass, prefix the sizing spinner
CI / build (push) Successful in 6m23s
CI / test (push) Skipped
CI / publish (push) Skipped
CI / snap (push) Successful in 11m45s

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.
This commit is contained in:
2026-10-03 01:34:47 +02:00
parent f6f2b882a8
commit 486856da60
4 changed files with 129 additions and 14 deletions
+11 -1
View File
@@ -1293,7 +1293,17 @@ fn main() {
} }
info!("Removing:"); 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 { for (path, err) in &report.failed {
error!("Failed to remove {}: {}", path.display(), err); error!("Failed to remove {}: {}", path.display(), err);
} }
+70 -2
View File
@@ -810,18 +810,36 @@ pub fn plan_in_roots(
PrunePlan { report, actionable } 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 /// Execute a plan: remove the planned artifacts (unmounting the
/// filesystems under residual directories and session roots first) and /// filesystems under residual directories and session roots first) and
/// report what was removed and what failed. A dry-run plan removes /// report what was removed and what failed. A dry-run plan removes
/// nothing and returns its digest unchanged. /// nothing and returns its digest unchanged.
pub fn apply(plan: PrunePlan) -> PruneReport { 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<RemovalProgress<'_>>) -> PruneReport {
if plan.report.dry_run { if plan.report.dry_run {
return plan.report; return plan.report;
} }
let mut report = plan.report; let mut report = plan.report;
report.removed = Vec::new(); 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(); let path = artifact.path();
if let Some(progress) = progress {
progress(idx, count, path, done);
}
// Residual build directories may hold bind-mounted /proc and overlay // Residual build directories may hold bind-mounted /proc and overlay
// filesystems that must be unmounted before the directory can be // filesystems that must be unmounted before the directory can be
@@ -838,7 +856,10 @@ pub fn apply(plan: PrunePlan) -> PruneReport {
} }
match remove_path(path) { 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())), 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()); 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] #[test]
fn test_prune_empty_report_when_nothing_found() { fn test_prune_empty_report_when_nothing_found() {
let temp = tempdir().unwrap(); let temp = tempdir().unwrap();
+47 -3
View File
@@ -7,6 +7,7 @@ pub mod deb;
/// yes/no confirmation /// yes/no confirmation
pub mod prompt; pub mod prompt;
use crossterm::terminal;
#[cfg(test)] #[cfg(test)]
use indicatif::ProgressDrawTarget; use indicatif::ProgressDrawTarget;
use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; 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 /// A standalone spinner for one-line waits outside a build view (the
/// prune size pass): braille frames over the message, drawn only on /// prune size pass): braille frames over the message behind the `> `
/// terminals. Callers finish it with [`ProgressBar::finish_and_clear`]. /// prompt prefix, drawn only on terminals. Callers finish it with
/// [`ProgressBar::finish_and_clear`].
pub fn wait_spinner(msg: &str) -> ProgressBar { pub fn wait_spinner(msg: &str) -> ProgressBar {
let pb = ProgressBar::new_spinner(); 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.set_message(msg.to_string());
pb.enable_steady_tick(Duration::from_millis(120)); pb.enable_steady_tick(Duration::from_millis(120));
pb pb
} }
/// Width of the terminal in columns, or `None` when it cannot be determined
pub(crate) fn terminal_width() -> Option<usize> {
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 /// Create a spinner-style progress bar attached to `multi`, returning the bar
/// and a callback compatible with [`crate::ProgressCallback`] /// and a callback compatible with [`crate::ProgressCallback`]
pub fn create_progress_bar( pub fn create_progress_bar(
+1 -8
View File
@@ -488,7 +488,7 @@ fn drop_pane(shared: &Shared) {
/// Render the pane content with per-kind colors, ellipsizing lines that are /// 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 /// wider than the terminal so they do not overflow onto a wrapped line
fn render_pane(lines: &VecDeque<(Kind, String)>) -> String { 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) render_pane_with_width(lines, max_width)
} }
@@ -515,13 +515,6 @@ fn render_pane_with_width(lines: &VecDeque<(Kind, String)>, max_width: Option<us
/// Display width of the ` │ ` prefix rendered before each pane line /// Display width of the ` │ ` prefix rendered before each pane line
const PANE_PREFIX_WIDTH: usize = 4; const PANE_PREFIX_WIDTH: usize = 4;
/// Width of the terminal in columns, or `None` when it cannot be determined
fn terminal_width() -> Option<usize> {
crossterm::terminal::size()
.ok()
.map(|(cols, _)| cols as usize)
}
/// Ellipsize `text` to at most `max_width` display columns, keeping its head /// Ellipsize `text` to at most `max_width` display columns, keeping its head
/// and appending `…` when it does not fit /// and appending `…` when it does not fit
fn ellipsize(text: &str, max_width: usize) -> String { fn ellipsize(text: &str, max_width: usize) -> String {