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
+117 -42
View File
@@ -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();