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
|
/// the network — how many mutations are queued, oldest / newest
|
||||||
/// seq, storage path. Read-only.
|
/// seq, storage path. Read-only.
|
||||||
WalStatus,
|
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)]
|
#[derive(clap::Args, Debug, Clone)]
|
||||||
@@ -320,6 +356,7 @@ async fn main() -> Result<()> {
|
|||||||
Cmd::PeerMetrics(args) => cmd_peer_metrics(args).await,
|
Cmd::PeerMetrics(args) => cmd_peer_metrics(args).await,
|
||||||
Cmd::Drain(args) => cmd_drain(args).await,
|
Cmd::Drain(args) => cmd_drain(args).await,
|
||||||
Cmd::WalStatus => cmd_wal_status().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
|
/// Phase 4e: `claw-cargo wal-status` — read-only view of the
|
||||||
/// offline queue. No network.
|
/// 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<()> {
|
async fn cmd_wal_status() -> Result<()> {
|
||||||
let wal_path = default_wal_path();
|
let wal_path = default_wal_path();
|
||||||
let queue = WalQueue::open(&wal_path).await?;
|
let queue = WalQueue::open(&wal_path).await?;
|
||||||
@@ -1764,4 +1914,66 @@ mod tests {
|
|||||||
let good = "ab".repeat(32);
|
let good = "ab".repeat(32);
|
||||||
assert!(super::parse_blob_id(&good).is_ok());
|
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