Phase 7e: claw-cargo smart-clean — 3 local cleanup modes #67
@@ -110,6 +110,42 @@ enum Cmd {
|
||||
/// the network — how many mutations are queued, oldest / newest
|
||||
/// seq, storage path. Read-only.
|
||||
WalStatus,
|
||||
/// Phase 7e (2026-07-14): reclaim local target-dir disk in
|
||||
/// increasing bluntness. All modes are LOCAL only — the fleet
|
||||
/// cache is untouched.
|
||||
///
|
||||
/// Modes:
|
||||
/// * `incremental-only` — remove `target/*/incremental/`. Safest;
|
||||
/// keeps final artifacts + deps.
|
||||
/// * `soft` (default) — remove `target/` entirely. Blob still
|
||||
/// on the peer so a re-build restores from cache.
|
||||
/// * `hard` — soft, but only after --force. Reserved for
|
||||
/// operators who know their build is truly transient.
|
||||
SmartClean(SmartCleanArgs),
|
||||
}
|
||||
|
||||
#[derive(clap::Args, Debug, Clone)]
|
||||
struct SmartCleanArgs {
|
||||
#[command(flatten)]
|
||||
local: LocalArgs,
|
||||
/// Cleanup mode. See enum docs for details.
|
||||
#[arg(long, value_enum, default_value_t = SmartCleanMode::Soft)]
|
||||
mode: SmartCleanMode,
|
||||
/// Required for `--mode hard` — a safety belt so a stray
|
||||
/// `smart-clean` invocation doesn't nuke a working tree by
|
||||
/// accident.
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
/// Report what would be deleted without touching disk.
|
||||
#[arg(long)]
|
||||
dry_run: bool,
|
||||
}
|
||||
|
||||
#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum SmartCleanMode {
|
||||
IncrementalOnly,
|
||||
Soft,
|
||||
Hard,
|
||||
}
|
||||
|
||||
#[derive(clap::Args, Debug, Clone)]
|
||||
@@ -320,6 +356,7 @@ async fn main() -> Result<()> {
|
||||
Cmd::PeerMetrics(args) => cmd_peer_metrics(args).await,
|
||||
Cmd::Drain(args) => cmd_drain(args).await,
|
||||
Cmd::WalStatus => cmd_wal_status().await,
|
||||
Cmd::SmartClean(args) => cmd_smart_clean(args),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1261,6 +1298,119 @@ async fn cmd_drain(args: DrainArgs) -> Result<()> {
|
||||
|
||||
/// Phase 4e: `claw-cargo wal-status` — read-only view of the
|
||||
/// offline queue. No network.
|
||||
fn cmd_smart_clean(args: SmartCleanArgs) -> Result<()> {
|
||||
if args.mode == SmartCleanMode::Hard && !args.force {
|
||||
anyhow::bail!("--mode hard requires --force (safety belt)");
|
||||
}
|
||||
let workspace = args
|
||||
.local
|
||||
.workspace
|
||||
.clone()
|
||||
.unwrap_or_else(|| std::env::current_dir().expect("cwd"));
|
||||
let target = workspace.join("target");
|
||||
let (paths, description): (Vec<PathBuf>, &str) = match args.mode {
|
||||
SmartCleanMode::IncrementalOnly => {
|
||||
(find_incremental_dirs(&target)?, "incremental subdirs")
|
||||
}
|
||||
SmartCleanMode::Soft | SmartCleanMode::Hard => {
|
||||
if target.exists() {
|
||||
(vec![target.clone()], "entire target/ tree")
|
||||
} else {
|
||||
(Vec::new(), "entire target/ tree")
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut total_bytes: u64 = 0;
|
||||
for p in &paths {
|
||||
total_bytes = total_bytes.saturating_add(dir_size_bytes(p));
|
||||
}
|
||||
|
||||
println!("── claw-cargo smart-clean ──────────────────────────");
|
||||
println!("workspace: {}", workspace.display());
|
||||
println!("mode: {:?}", args.mode);
|
||||
println!("target: {} ({} entries)", description, paths.len());
|
||||
println!("would reclaim: {} bytes", total_bytes);
|
||||
if args.dry_run {
|
||||
for p in &paths {
|
||||
println!(" would remove: {}", p.display());
|
||||
}
|
||||
println!("dry-run: no filesystem changes.");
|
||||
println!("────────────────────────────────────────────────────");
|
||||
return Ok(());
|
||||
}
|
||||
let mut removed = 0usize;
|
||||
let mut errors = 0usize;
|
||||
for p in &paths {
|
||||
match std::fs::remove_dir_all(p) {
|
||||
Ok(()) => {
|
||||
println!(" removed: {}", p.display());
|
||||
removed += 1;
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => {
|
||||
println!(" FAILED: {} ({e})", p.display());
|
||||
errors += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("removed {} of {} entries, {} errors", removed, paths.len(), errors);
|
||||
println!("────────────────────────────────────────────────────");
|
||||
if errors > 0 {
|
||||
anyhow::bail!("{} path(s) failed to remove", errors);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Walk `target/` looking for `incremental/` subdirs (one per profile:
|
||||
/// `target/debug/incremental`, `target/release/incremental`, ...).
|
||||
/// Returns paths that exist; missing/absent target dir yields empty.
|
||||
fn find_incremental_dirs(target: &std::path::Path) -> Result<Vec<PathBuf>> {
|
||||
if !target.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for entry in std::fs::read_dir(target)? {
|
||||
let entry = entry?;
|
||||
if !entry.file_type()?.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let inc = entry.path().join("incremental");
|
||||
if inc.is_dir() {
|
||||
out.push(inc);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Recursive byte-count of a directory. Silently skips paths we
|
||||
/// can't read — this is only used for a reporting number, not a
|
||||
/// correctness-critical calculation.
|
||||
fn dir_size_bytes(root: &std::path::Path) -> u64 {
|
||||
let mut total: u64 = 0;
|
||||
let mut stack = vec![root.to_path_buf()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let entries = match std::fs::read_dir(&dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => continue,
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let ft = match entry.file_type() {
|
||||
Ok(t) => t,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if ft.is_dir() {
|
||||
stack.push(entry.path());
|
||||
} else if ft.is_file() {
|
||||
if let Ok(m) = entry.metadata() {
|
||||
total = total.saturating_add(m.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
async fn cmd_wal_status() -> Result<()> {
|
||||
let wal_path = default_wal_path();
|
||||
let queue = WalQueue::open(&wal_path).await?;
|
||||
@@ -1764,4 +1914,66 @@ mod tests {
|
||||
let good = "ab".repeat(32);
|
||||
assert!(super::parse_blob_id(&good).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_incremental_dirs_returns_only_existing_incremental() {
|
||||
// target/debug/incremental + target/release/incremental exist,
|
||||
// target/wasm32/incremental deliberately absent, target/misc
|
||||
// has no incremental subdir.
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let target = tmp.path().join("target");
|
||||
std::fs::create_dir_all(target.join("debug/incremental")).unwrap();
|
||||
std::fs::create_dir_all(target.join("release/incremental")).unwrap();
|
||||
std::fs::create_dir_all(target.join("misc")).unwrap();
|
||||
let mut found = super::find_incremental_dirs(&target).unwrap();
|
||||
found.sort();
|
||||
assert_eq!(found.len(), 2);
|
||||
assert!(found[0].ends_with("debug/incremental"));
|
||||
assert!(found[1].ends_with("release/incremental"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_incremental_dirs_returns_empty_when_target_missing() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let target = tmp.path().join("does-not-exist");
|
||||
assert!(super::find_incremental_dirs(&target).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dir_size_bytes_sums_recursively() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let root = tmp.path().join("payload");
|
||||
std::fs::create_dir_all(root.join("a")).unwrap();
|
||||
std::fs::create_dir_all(root.join("b/c")).unwrap();
|
||||
std::fs::write(root.join("a/f1"), b"1234").unwrap();
|
||||
std::fs::write(root.join("b/f2"), b"56789").unwrap();
|
||||
std::fs::write(root.join("b/c/f3"), b"0123456789").unwrap();
|
||||
assert_eq!(super::dir_size_bytes(&root), 4 + 5 + 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dir_size_bytes_returns_zero_for_missing() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
assert_eq!(super::dir_size_bytes(&tmp.path().join("nope")), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smart_clean_hard_without_force_rejects() {
|
||||
// Even in dry-run: --mode hard without --force is a
|
||||
// safety-belt violation. Fail fast rather than teaching
|
||||
// operators to trust the flag.
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let args = SmartCleanArgs {
|
||||
local: LocalArgs {
|
||||
workspace: Some(tmp.path().to_path_buf()),
|
||||
profile: None,
|
||||
features: None,
|
||||
},
|
||||
mode: SmartCleanMode::Hard,
|
||||
force: false,
|
||||
dry_run: true,
|
||||
};
|
||||
let err = super::cmd_smart_clean(args).unwrap_err();
|
||||
assert!(err.to_string().contains("--force"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user