prune,ui: show the removal pass, prefix the sizing spinner
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:
+11
-1
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user