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:
@@ -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.
|
||||
|
||||
|
||||
+101
-39
@@ -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::<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 };
|
||||
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") {
|
||||
|
||||
+117
-42
@@ -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<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.
|
||||
@@ -786,6 +793,46 @@ pub fn prune_in_roots(
|
||||
apt_cache_dir: Option<&Path>,
|
||||
options: PruneOptions,
|
||||
) -> 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);
|
||||
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<PruneReport, Box<dyn std::error::Error>> {
|
||||
/// 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<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)]
|
||||
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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user