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.
This commit is contained in:
2026-10-02 23:43:04 +02:00
parent 74c74f0a61
commit b85a377b24
4 changed files with 231 additions and 82 deletions
+2 -1
View File
@@ -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` and `pkh prune` removes records older than a week. `pkh prune --dry-run`
previews everything it would remove, grouped by kind with sizes; previews everything it would remove, grouped by kind with sizes;
`--all` ignores the age and removes everything a live build does not `--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 --incremental` never forks a record built for a different
series/architecture. series/architecture.
+101 -39
View File
@@ -1,5 +1,5 @@
use std::env; use std::env;
use std::io::Write; use std::io::{IsTerminal, Write};
extern crate clap; extern crate clap;
use clap::{Command, arg, command}; 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. /// Obtain the current working directory, exiting with a helpful message on failure.
fn current_dir_or_exit() -> std::path::PathBuf { fn current_dir_or_exit() -> std::path::PathBuf {
match std::env::current_dir() { match std::env::current_dir() {
Ok(p) => p, Ok(dir) => dir,
Err(e) => { Err(err) => {
error!("Could not determine the current working directory: {}", e); error!("cannot determine the current directory: {err}");
std::process::exit(1); 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 /// CLI-side Ctrl+C wiring. The passive state (interrupted flag, cleanup
/// hook registry, reporter slot) lives in `pkh::interrupt`; everything that /// hook registry, reporter slot) lives in `pkh::interrupt`; everything that
/// installs, prints or exits lives here: the SIGINT handler only wakes a /// 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") .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."), .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(); .get_matches();
@@ -1202,45 +1231,78 @@ fn main() {
.copied() .copied()
.unwrap_or(false); .unwrap_or(false);
let all = sub_matches.get_one::<bool>("all").copied().unwrap_or(false); let all = sub_matches.get_one::<bool>("all").copied().unwrap_or(false);
let force = sub_matches
.get_one::<bool>("force")
.copied()
.unwrap_or(false);
let options = pkh::prune::PruneOptions { dry_run, all }; let options = pkh::prune::PruneOptions {
match pkh::prune::prune(options) { dry_run,
Ok(report) => { all,
if report.is_empty() { force,
info!("Nothing to prune."); };
} else { // The size pass spins on a terminal; --force skips it entirely.
if report.dry_run { let spinner = (!force)
info!("Dry run - would remove:"); .then(|| pkh::ui::wait_spinner("Computing sizes of directories to prune..."));
} else { let plan = pkh::prune::plan(options);
info!("Removing:"); if let Some(spinner) = spinner {
} spinner.finish_and_clear();
for group in &report.groups { }
let size = if group.bytes > 0 {
format!(" for {}", pkh::prune::format_bytes(group.bytes)) let digest = plan.report();
} else { if digest.is_empty() {
String::new() info!("Nothing to prune.");
}; return;
info!("{} {}{size}", group.count, group.label()); }
}
for (path, err) in &report.failed { // The digest: one grouped line per kind. Counts are bold and
error!("Failed to remove {}: {}", path.display(), err); // sizes cyan — when there are sizes at all (--force skips the
} // walk and reports counts only).
let total = pkh::prune::format_bytes(report.total_bytes); let header = if dry_run {
if report.dry_run { "Dry run - would remove:"
info!("Would have freed {total} total."); } else {
} else { "Would remove:"
info!("Freed {total} total."); };
} info!("{}", bold(header));
} for group in &digest.groups {
if !report.failed.is_empty() { let size = if group.bytes > 0 {
std::process::exit(1); 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) => { return;
error!("{}", e); }
std::process::exit(1);
// 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)) => { Some(("lint", sub_matches)) => {
if sub_matches.get_flag("list_tags") { if sub_matches.get_flag("list_tags") {
+117 -42
View File
@@ -60,9 +60,14 @@ pub struct PruneOptions {
/// When true, do not remove anything; only report what would be removed. /// When true, do not remove anything; only report what would be removed.
pub dry_run: bool, pub dry_run: bool,
/// When true, also remove cached chroot tarballs (which are expensive to /// When true, also remove cached chroot tarballs (which are expensive to
/// re-download). Without this, tarballs are kept and only stale lockfiles /// re-download) and recent build records with their staged trees.
/// are removed from the cache directory. /// Without this, only artifacts beyond their retention windows are
/// removed.
pub all: bool, 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. /// Summary of a prune run.
@@ -769,7 +774,9 @@ pub fn prune_in(
cache_dir: Option<&Path>, cache_dir: Option<&Path>,
options: PruneOptions, options: PruneOptions,
) -> Result<PruneReport, Box<dyn std::error::Error>> { ) -> Result<PruneReport, Box<dyn std::error::Error>> {
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. /// Execute the prune operation against explicit roots.
@@ -786,6 +793,46 @@ pub fn prune_in_roots(
apt_cache_dir: Option<&Path>, apt_cache_dir: Option<&Path>,
options: PruneOptions, options: PruneOptions,
) -> Result<PruneReport, Box<dyn std::error::Error>> { ) -> Result<PruneReport, Box<dyn std::error::Error>> {
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); let mut artifacts = discover_artifacts(temp_dir, cache_dir);
if let Some(sessions_dir) = sessions_dir { if let Some(sessions_dir) = sessions_dir {
artifacts.extend(discover_sessions(sessions_dir, options.all)); 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); 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(); let mut filtered: Vec<&Artifact> = Vec::new();
for artifact in &artifacts { for artifact in &artifacts {
// Cached tarballs are only removed when --all is requested: they are // Cached tarballs are only removed when --all is requested: they are
@@ -823,17 +868,53 @@ pub fn prune_in_roots(
filtered.push(artifact); filtered.push(artifact);
} }
let sizes = size_artifacts(&filtered);
let actionable: Vec<(&Artifact, u64)> = filtered.iter().copied().zip(sizes).collect();
for (artifact, _) in &actionable { // Sizes are measured during the plan (a removed tree has no size left
let path = artifact.path(); // to report) — unless --force skipped them: the plan then keeps the
log::debug!("{}: {}", artifact.kind(), path.display()); // 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 { let mut actionable: Vec<(Artifact, u64)> = Vec::new();
report.removed.push(path.to_path_buf()); for (artifact, bytes) in filtered.iter().zip(sizes) {
continue; 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 // 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
@@ -854,39 +935,15 @@ pub fn prune_in_roots(
Err(e) => report.failed.push((path.to_path_buf(), e.to_string())), Err(e) => report.failed.push((path.to_path_buf(), e.to_string())),
} }
} }
report
// 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)
} }
/// Execute the prune operation, removing residual pkh artifacts. /// Plan the prune operation against the real roots: the digest without
/// /// removing anything. Pair with [`apply()`].
/// Convenience wrapper around [`prune_in()`] that uses the system temp pub fn plan(options: PruneOptions) -> PrunePlan {
/// 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<PruneReport, Box<dyn std::error::Error>> {
let temp_dir = std::env::temp_dir(); let temp_dir = std::env::temp_dir();
let cache = cache_dir(); let cache = cache_dir();
prune_in_roots( plan_in_roots(
&temp_dir, &temp_dir,
cache.as_deref(), cache.as_deref(),
Some(&crate::deb::session::sessions_root()), Some(&crate::deb::session::sessions_root()),
@@ -896,6 +953,16 @@ pub fn prune(options: PruneOptions) -> Result<PruneReport, Box<dyn std::error::E
) )
} }
/// Execute the prune operation, removing residual pkh artifacts.
///
/// Convenience wrapper 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<PruneReport, Box<dyn std::error::Error>> {
Ok(apply(plan(options)))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -1133,6 +1200,7 @@ none /tmp/other proc rw 0 0
PruneOptions { PruneOptions {
dry_run: false, dry_run: false,
all: false, all: false,
force: false,
}, },
) )
.unwrap(); .unwrap();
@@ -1160,6 +1228,7 @@ none /tmp/other proc rw 0 0
PruneOptions { PruneOptions {
dry_run: false, dry_run: false,
all: true, all: true,
force: false,
}, },
) )
.unwrap(); .unwrap();
@@ -1208,6 +1277,7 @@ none /tmp/other proc rw 0 0
PruneOptions { PruneOptions {
dry_run: false, dry_run: false,
all: false, all: false,
force: false,
}, },
) )
.unwrap(); .unwrap();
@@ -1258,6 +1328,7 @@ none /tmp/other proc rw 0 0
PruneOptions { PruneOptions {
dry_run: true, dry_run: true,
all: false, all: false,
force: false,
}, },
) )
.unwrap(); .unwrap();
@@ -1331,6 +1402,7 @@ none /tmp/other proc rw 0 0
PruneOptions { PruneOptions {
dry_run: false, dry_run: false,
all: false, all: false,
force: false,
}, },
) )
.unwrap(); .unwrap();
@@ -1381,6 +1453,7 @@ none /tmp/other proc rw 0 0
PruneOptions { PruneOptions {
dry_run: false, dry_run: false,
all: true, all: true,
force: false,
}, },
) )
.unwrap(); .unwrap();
@@ -1430,6 +1503,7 @@ none /tmp/other proc rw 0 0
PruneOptions { PruneOptions {
dry_run: false, dry_run: false,
all: false, all: false,
force: false,
}, },
) )
.unwrap(); .unwrap();
@@ -1448,6 +1522,7 @@ none /tmp/other proc rw 0 0
PruneOptions { PruneOptions {
dry_run: true, dry_run: true,
all: false, all: false,
force: false,
}, },
) )
.unwrap(); .unwrap();
+11
View File
@@ -40,6 +40,17 @@ pub(crate) fn transfer_style() -> ProgressStyle {
.progress_chars("=> ") .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 /// 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(