From b85a377b24838bf183c2b566b6ec1c8f963d26f8 Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Fri, 2 Oct 2026 23:43:04 +0200 Subject: [PATCH] prune,ui,docs: color the digest, add -f/--force and a sizing spinner - -f/--force skips the size pass and the confirmation prompt: remove immediately, reporting counts but no sizes. The fastest cleanup path; --dry-run still previews. - The default path shows the grouped digest and asks before removing (the raw-mode yes/no prompt; non-interactive sessions keep their headless path, so scripts reach for --force). - Sizes are computed under a spinner ("Computing sizes of directories to prune..."), and the digest colors the counts (bold) and the sizes (cyan) on terminals only. --- README.md | 3 +- src/main.rs | 140 ++++++++++++++++++++++++++++++++------------- src/prune.rs | 159 +++++++++++++++++++++++++++++++++++++-------------- src/ui.rs | 11 ++++ 4 files changed, 231 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index 98b39aa..106a3d1 100644 --- a/README.md +++ b/README.md @@ -188,7 +188,8 @@ the oldest degrades to a metadata-only record (manifest and log stay), and `pkh prune` removes records older than a week. `pkh prune --dry-run` previews everything it would remove, grouped by kind with sizes; `--all` ignores the age and removes everything a live build does not -hold, recent records and staged trees included. `pkh deb +hold, recent records and staged trees included; `-f/--force` skips the +size pass and the confirmation prompt, removing immediately. `pkh deb --incremental` never forks a record built for a different series/architecture. diff --git a/src/main.rs b/src/main.rs index 5fd5ef5..a7d60b3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,5 @@ use std::env; -use std::io::Write; +use std::io::{IsTerminal, Write}; extern crate clap; use clap::{Command, arg, command}; @@ -12,14 +12,35 @@ use log::{error, info}; /// Obtain the current working directory, exiting with a helpful message on failure. fn current_dir_or_exit() -> std::path::PathBuf { match std::env::current_dir() { - Ok(p) => p, - Err(e) => { - error!("Could not determine the current working directory: {}", e); + Ok(dir) => dir, + Err(err) => { + error!("cannot determine the current directory: {err}"); std::process::exit(1); } } } +/// Wrap `text` in the ANSI `code` (e.g. "1" for bold) when stderr is a +/// terminal; return it unchanged otherwise (piped output keeps no escape +/// codes). +fn ansi_wrap(code: &str, text: &str) -> String { + if std::io::stderr().is_terminal() { + format!("\x1b[{code}m{text}\x1b[0m") + } else { + text.to_string() + } +} + +/// Bold text for the prune digest. +fn bold(text: &str) -> String { + ansi_wrap("1", text) +} + +/// Cyan text for the prune digest. +fn cyan(text: &str) -> String { + ansi_wrap("36", text) +} + /// CLI-side Ctrl+C wiring. The passive state (interrupted flag, cleanup /// hook registry, reporter slot) lives in `pkh::interrupt`; everything that /// installs, prints or exits lives here: the SIGINT handler only wakes a @@ -552,6 +573,14 @@ fn main() { .help("Remove everything, including recent build records and staged trees") .long_help("Remove everything, whatever its age: recent build records and their staged trees (the incremental bases `pkh deb --incremental` forks) included, plus the cached chroot tarballs, which are expensive to re-download. Records and locks a live build holds are never touched."), ) + .arg( + clap::Arg::new("force") + .short('f') + .long("force") + .action(clap::ArgAction::SetTrue) + .help("Skip the size computation and the confirmation prompt") + .long_help("Skip the size computation and the confirmation prompt: remove immediately, reporting counts but no sizes. The fastest cleanup path; a --dry-run still previews, and -a decides the policy as usual."), + ) ) .get_matches(); @@ -1202,45 +1231,78 @@ fn main() { .copied() .unwrap_or(false); let all = sub_matches.get_one::("all").copied().unwrap_or(false); + let force = sub_matches + .get_one::("force") + .copied() + .unwrap_or(false); - let options = pkh::prune::PruneOptions { dry_run, all }; - match pkh::prune::prune(options) { - Ok(report) => { - if report.is_empty() { - info!("Nothing to prune."); - } else { - if report.dry_run { - info!("Dry run - would remove:"); - } else { - info!("Removing:"); - } - for group in &report.groups { - let size = if group.bytes > 0 { - format!(" for {}", pkh::prune::format_bytes(group.bytes)) - } else { - String::new() - }; - info!("{} {}{size}", group.count, group.label()); - } - for (path, err) in &report.failed { - error!("Failed to remove {}: {}", path.display(), err); - } - let total = pkh::prune::format_bytes(report.total_bytes); - if report.dry_run { - info!("Would have freed {total} total."); - } else { - info!("Freed {total} total."); - } - } - if !report.failed.is_empty() { - std::process::exit(1); - } + let options = pkh::prune::PruneOptions { + dry_run, + all, + force, + }; + // The size pass spins on a terminal; --force skips it entirely. + let spinner = (!force) + .then(|| pkh::ui::wait_spinner("Computing sizes of directories to prune...")); + let plan = pkh::prune::plan(options); + if let Some(spinner) = spinner { + spinner.finish_and_clear(); + } + + let digest = plan.report(); + if digest.is_empty() { + info!("Nothing to prune."); + return; + } + + // The digest: one grouped line per kind. Counts are bold and + // sizes cyan — when there are sizes at all (--force skips the + // walk and reports counts only). + let header = if dry_run { + "Dry run - would remove:" + } else { + "Would remove:" + }; + info!("{}", bold(header)); + for group in &digest.groups { + let size = if group.bytes > 0 { + format!(" for {}", cyan(&pkh::prune::format_bytes(group.bytes))) + } else { + String::new() + }; + info!("{} {}{size}", bold(&group.count.to_string()), group.label()); + } + let total = cyan(&pkh::prune::format_bytes(digest.total_bytes)); + if dry_run { + // --force skips the size pass: counts without a total. + if !force { + info!("Would have freed {total} total."); } - Err(e) => { - error!("{}", e); - std::process::exit(1); + return; + } + + // Confirmation for the interactive case (--force and + // non-interactive sessions skip it). + if !force { + let confirmed = + pkh::ui::prompt::confirm("Proceed with the removal?", false).unwrap_or(false); + if !confirmed { + info!("Aborted."); + return; } } + + info!("Removing:"); + let report = pkh::prune::apply(plan); + for (path, err) in &report.failed { + error!("Failed to remove {}: {}", path.display(), err); + } + if !force { + info!("Freed {} total.", total); + } + if !report.failed.is_empty() { + std::process::exit(1); + } } Some(("lint", sub_matches)) => { if sub_matches.get_flag("list_tags") { diff --git a/src/prune.rs b/src/prune.rs index ea794bb..e0d8cf5 100644 --- a/src/prune.rs +++ b/src/prune.rs @@ -60,9 +60,14 @@ pub struct PruneOptions { /// When true, do not remove anything; only report what would be removed. pub dry_run: bool, /// When true, also remove cached chroot tarballs (which are expensive to - /// re-download). Without this, tarballs are kept and only stale lockfiles - /// are removed from the cache directory. + /// re-download) and recent build records with their staged trees. + /// Without this, only artifacts beyond their retention windows are + /// removed. pub all: bool, + /// When true, skip the size computation and the confirmation prompt: + /// remove immediately, reporting counts but no sizes. A dry run + /// still wins over this. + pub force: bool, } /// Summary of a prune run. @@ -769,7 +774,9 @@ pub fn prune_in( cache_dir: Option<&Path>, options: PruneOptions, ) -> Result> { - prune_in_roots(temp_dir, cache_dir, None, None, None, options) + Ok(apply(plan_in_roots( + temp_dir, cache_dir, None, None, None, options, + ))) } /// Execute the prune operation against explicit roots. @@ -786,6 +793,46 @@ pub fn prune_in_roots( apt_cache_dir: Option<&Path>, options: PruneOptions, ) -> Result> { + Ok(apply(plan_in_roots( + temp_dir, + cache_dir, + sessions_dir, + tests_dir, + apt_cache_dir, + options, + ))) +} + +/// A plan produced by [`plan()`] or [`plan_in_roots()`]: the digest +/// report — groups with sizes, unless [`PruneOptions::force`] skipped +/// the sizing — and the artifacts [`apply()`] will remove. +pub struct PrunePlan { + report: PruneReport, + actionable: Vec<(Artifact, u64)>, +} + +impl PrunePlan { + /// The digest: what would be removed, grouped by kind with sizes + /// (counts only with [`PruneOptions::force`]). + pub fn report(&self) -> &PruneReport { + &self.report + } +} + +/// Discover and size the prunable artifacts against the given temp and +/// cache roots, removing nothing. This is the testable core of [`plan()`]: +/// callers pass in the temp directory and optional cache directory to +/// scan. Build sessions are deliberately out of scope here — tests scan +/// controlled roots, and a concurrent test suite must never touch the +/// live sessions under the real sessions root. Pair with [`apply()`]. +pub fn plan_in_roots( + temp_dir: &Path, + cache_dir: Option<&Path>, + sessions_dir: Option<&Path>, + tests_dir: Option<&Path>, + apt_cache_dir: Option<&Path>, + options: PruneOptions, +) -> PrunePlan { let mut artifacts = discover_artifacts(temp_dir, cache_dir); if let Some(sessions_dir) = sessions_dir { artifacts.extend(discover_sessions(sessions_dir, options.all)); @@ -804,8 +851,6 @@ pub fn prune_in_roots( let removable_logs = removable_logs(&artifacts, options.all); - // The actionable subset with sizes measured BEFORE anything is - // removed: a removed tree has no size left to report. let mut filtered: Vec<&Artifact> = Vec::new(); for artifact in &artifacts { // Cached tarballs are only removed when --all is requested: they are @@ -823,17 +868,53 @@ pub fn prune_in_roots( filtered.push(artifact); } - let sizes = size_artifacts(&filtered); - let actionable: Vec<(&Artifact, u64)> = filtered.iter().copied().zip(sizes).collect(); - for (artifact, _) in &actionable { - let path = artifact.path(); - log::debug!("{}: {}", artifact.kind(), path.display()); + // Sizes are measured during the plan (a removed tree has no size left + // to report) — unless --force skipped them: the plan then keeps the + // counts and loses the sizes, for a fast no-questions cleanup. + let sizes = if options.force { + vec![0; filtered.len()] + } else { + size_artifacts(&filtered) + }; - if options.dry_run { - report.removed.push(path.to_path_buf()); - continue; + let mut actionable: Vec<(Artifact, u64)> = Vec::new(); + for (artifact, bytes) in filtered.iter().zip(sizes) { + report.removed.push(artifact.path().to_path_buf()); + actionable.push(((*artifact).clone(), bytes)); + // Group by kind in first-seen order. + if let Some(group) = report + .groups + .iter_mut() + .find(|group| group.kind == artifact.kind()) + { + group.count += 1; + group.bytes += bytes; + } else { + report.groups.push(PruneGroup { + kind: artifact.kind(), + count: 1, + bytes, + }); } + } + report.total_bytes = report.groups.iter().map(|group| group.bytes).sum(); + + PrunePlan { report, actionable } +} + +/// 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 { + if plan.report.dry_run { + return plan.report; + } + let mut report = plan.report; + report.removed = Vec::new(); + for (artifact, _) in &plan.actionable { + let path = artifact.path(); // Residual build directories may hold bind-mounted /proc and overlay // filesystems that must be unmounted before the directory can be @@ -854,39 +935,15 @@ pub fn prune_in_roots( Err(e) => report.failed.push((path.to_path_buf(), e.to_string())), } } - - // Group by kind in first-seen order. - for (artifact, bytes) in &actionable { - if let Some(group) = report - .groups - .iter_mut() - .find(|group| group.kind == artifact.kind()) - { - group.count += 1; - group.bytes += bytes; - } else { - report.groups.push(PruneGroup { - kind: artifact.kind(), - count: 1, - bytes: *bytes, - }); - } - } - report.total_bytes = report.groups.iter().map(|group| group.bytes).sum(); - - Ok(report) + report } -/// Execute the prune operation, removing residual pkh artifacts. -/// -/// Convenience wrapper around [`prune_in()`] that uses the system temp -/// directory and the pkh cache directory. See the -/// [module documentation](self) for what is removed. Returns a [`PruneReport`] -/// describing what was removed (or, in dry-run mode, what would be removed). -pub fn prune(options: PruneOptions) -> Result> { +/// Plan the prune operation against the real roots: the digest without +/// removing anything. Pair with [`apply()`]. +pub fn plan(options: PruneOptions) -> PrunePlan { let temp_dir = std::env::temp_dir(); let cache = cache_dir(); - prune_in_roots( + plan_in_roots( &temp_dir, cache.as_deref(), Some(&crate::deb::session::sessions_root()), @@ -896,6 +953,16 @@ pub fn prune(options: PruneOptions) -> Result Result> { + Ok(apply(plan(options))) +} + #[cfg(test)] mod tests { use super::*; @@ -1133,6 +1200,7 @@ none /tmp/other proc rw 0 0 PruneOptions { dry_run: false, all: false, + force: false, }, ) .unwrap(); @@ -1160,6 +1228,7 @@ none /tmp/other proc rw 0 0 PruneOptions { dry_run: false, all: true, + force: false, }, ) .unwrap(); @@ -1208,6 +1277,7 @@ none /tmp/other proc rw 0 0 PruneOptions { dry_run: false, all: false, + force: false, }, ) .unwrap(); @@ -1258,6 +1328,7 @@ none /tmp/other proc rw 0 0 PruneOptions { dry_run: true, all: false, + force: false, }, ) .unwrap(); @@ -1331,6 +1402,7 @@ none /tmp/other proc rw 0 0 PruneOptions { dry_run: false, all: false, + force: false, }, ) .unwrap(); @@ -1381,6 +1453,7 @@ none /tmp/other proc rw 0 0 PruneOptions { dry_run: false, all: true, + force: false, }, ) .unwrap(); @@ -1430,6 +1503,7 @@ none /tmp/other proc rw 0 0 PruneOptions { dry_run: false, all: false, + force: false, }, ) .unwrap(); @@ -1448,6 +1522,7 @@ none /tmp/other proc rw 0 0 PruneOptions { dry_run: true, all: false, + force: false, }, ) .unwrap(); diff --git a/src/ui.rs b/src/ui.rs index a024359..b1689ef 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -40,6 +40,17 @@ pub(crate) fn transfer_style() -> ProgressStyle { .progress_chars("=> ") } +/// 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`]. +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_message(msg.to_string()); + pb.enable_steady_tick(Duration::from_millis(120)); + pb +} + /// Create a spinner-style progress bar attached to `multi`, returning the bar /// and a callback compatible with [`crate::ProgressCallback`] pub fn create_progress_bar(