Phase 7e: claw-cargo smart-clean — 3 local cleanup modes #67

Merged
osobh merged 1 commits from phase-7e-smart-clean into main 2026-07-14 19:08:49 +00:00
Showing only changes of commit fa863fdc7d - Show all commits
+212
View File
@@ -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"));
}
}