diff --git a/crates/vibescrobble-setup/README.md b/crates/vibescrobble-setup/README.md index f7b59992..9eb50b00 100644 --- a/crates/vibescrobble-setup/README.md +++ b/crates/vibescrobble-setup/README.md @@ -180,6 +180,32 @@ out whether a harness restart took effect, and its exit status is the answer. The account is deleted whatever happens, including when a step fails: a check that strands an agent every time it runs is a check people stop running. +## Where the disk went + +A `target/` in this workspace is between eight and twenty gigabytes and there +is one per worktree. Four live worktrees have filled a machine mid-link more +than once, and the failure does not look like a full disk — it looks like the +linker crashing, which sends a developer reading a compiler bug tracker. + +```sh +vibescrobble-setup disk +vibescrobble-setup disk --clean # orphans only +``` + +```text + 22.3G this worktree …/.claude/worktrees/dev-setup/target + 10.1G in use …/.claude/worktrees/mst-diff-suite/target + 8.6G idle …/vibescrobble/target + + 41.0G in total +``` + +`--clean` removes exactly one category: a build directory whose worktree no +longer exists, which belongs to nothing and which nobody is building in. This +machine is shared between people and agents, so a directory touched in the last +hour is reported as **in use** and never offered, and one that is merely +**idle** is somebody else's to clear with `cargo clean` in it. + ## What it will not do Restart the harness, install a certificate authority, or bind a privileged diff --git a/crates/vibescrobble-setup/src/bin/vibescrobble-setup.rs b/crates/vibescrobble-setup/src/bin/vibescrobble-setup.rs index db0a81fd..43ee9cad 100644 --- a/crates/vibescrobble-setup/src/bin/vibescrobble-setup.rs +++ b/crates/vibescrobble-setup/src/bin/vibescrobble-setup.rs @@ -12,6 +12,7 @@ use std::process::ExitCode; use vibescrobble_setup::apply::{self, Undone}; use vibescrobble_setup::check::{self, Level}; +use vibescrobble_setup::disk; use vibescrobble_setup::harness::Scope; use vibescrobble_setup::{ledger::Ledger, verify}; use vibescrobble_stack::{Config as Stack, Profile, Service, ServiceKind}; @@ -27,6 +28,8 @@ vibescrobble-setup — wire this machine's harness to a vibescrobble stack vibescrobble-setup env the exports the dev scripts read vibescrobble-setup forget drop remembered accounts for this server vibescrobble-setup debug on|off turn detailed logging on for every component + vibescrobble-setup disk [--clean] what the build directories cost, and what + is safe to take back Describing this machine vibescrobble-setup show services, profiles, bindings @@ -118,6 +121,7 @@ fn main() -> ExitCode { Command::ProfileNew(ref name) => do_profile_new(&options, stack, &config_path, name), Command::Bind(ref profile) => do_bind(&options, stack, &config_path, Some(profile)), Command::Unbind => do_bind(&options, stack, &config_path, None), + Command::Disk(clean) => do_disk(&options, clean), } } @@ -373,6 +377,82 @@ async fn do_verify(dir: &Path, stack: &Stack) -> ExitCode { } } +/// Says where the disk went, and takes back the part that belongs to nobody. +/// +/// A `target/` in this workspace is between eight and twenty gigabytes and +/// there is one per worktree, so the machine fills up during a link and the +/// failure reads as a compiler bug rather than as a full disk. This is the +/// thing to run when a build dies for no reason. +fn do_disk(options: &Options, clean: bool) -> ExitCode { + // The main checkout rather than this one: a worktree is a full checkout + // with its own manifest, so walking up for a workspace root finds the + // worktree and never the directory that holds them all. + let Some(root) = disk::main_checkout(&options.dir) else { + eprintln!( + "vibescrobble-setup: {} is not a git checkout", + options.dir.display() + ); + return ExitCode::FAILURE; + }; + let worktrees = disk::worktrees(&root); + let targets = disk::survey(&root, &options.dir, &worktrees); + if targets.is_empty() { + println!(" nothing built yet"); + return ExitCode::SUCCESS; + } + + let total: u64 = targets.iter().map(|target| target.bytes).sum(); + let reclaimable: u64 = targets + .iter() + .filter(|target| target.owner.is_reclaimable()) + .map(|target| target.bytes) + .sum(); + + for target in &targets { + println!( + " {:>7} {:<14} {}", + disk::human(target.bytes), + target.owner.as_str(), + target.path.display() + ); + } + println!("\n {:>7} in total", disk::human(total)); + if reclaimable == 0 { + println!(" nothing here belongs to nobody."); + println!(" An idle worktree's build directory is its own to clear: cargo clean, in it."); + return ExitCode::SUCCESS; + } + println!( + " {:>7} orphaned — the worktree that built it is gone", + disk::human(reclaimable) + ); + + if !clean { + println!("\n vibescrobble-setup disk --clean removes the orphans and nothing else."); + return ExitCode::SUCCESS; + } + if !options.yes && !ask("\n remove them?") { + println!(" nothing removed."); + return ExitCode::SUCCESS; + } + + let mut freed = 0; + for target in targets + .iter() + .filter(|target| target.owner.is_reclaimable()) + { + match std::fs::remove_dir_all(&target.path) { + Ok(()) => { + println!(" removed {}", target.path.display()); + freed += target.bytes; + } + Err(err) => println!(" kept {}: {err}", target.path.display()), + } + } + println!("\n freed {}", disk::human(freed)); + ExitCode::SUCCESS +} + /// Prints what this machine has been told about itself. fn do_show(dir: &Path, stack: &Stack, config_path: &Path) -> ExitCode { println!( @@ -783,6 +863,8 @@ enum Command { Bind(String), /// Drop this directory's binding. Unbind, + /// Report what the build directories cost; `true` also reclaims orphans. + Disk(bool), } /// Everything the command line said. @@ -882,6 +964,7 @@ impl Options { Command::Bind(profile) } "unbind" => Command::Unbind, + "disk" => Command::Disk(false), other => return Err(format!("no such command: {other}")), }; // Every arm above that reads its own words has already @@ -897,6 +980,7 @@ impl Options { | Command::Debug(_) | Command::Show | Command::Unbind + | Command::Disk(_) ) { rest.next(); } @@ -905,6 +989,7 @@ impl Options { while let Some(arg) = rest.next() { match arg.as_str() { + "--clean" if matches!(command, Command::Disk(_)) => command = Command::Disk(true), "--yes" | "-y" => yes = true, "--install" => install = true, "--dir" => { diff --git a/crates/vibescrobble-setup/src/disk.rs b/crates/vibescrobble-setup/src/disk.rs new file mode 100644 index 00000000..eed94b92 --- /dev/null +++ b/crates/vibescrobble-setup/src/disk.rs @@ -0,0 +1,327 @@ +//! Where the disk went, and which of it is safe to take back. +//! +//! A `target/` directory in this workspace is between eight and twenty +//! gigabytes, and one per worktree. Four live worktrees have filled a machine +//! mid-link more than once, and the failure does not look like a full disk: it +//! looks like the linker crashing, which sends a developer reading a compiler +//! bug tracker. +//! +//! # The one unambiguous case +//! +//! A worktree that has been removed leaves its build directory behind, because +//! `git worktree remove` refuses a dirty tree and a person who deletes the +//! directory by hand takes the checkout with it and not always the rest. Those +//! belong to nothing and nobody is building in them. +//! +//! Everything else is somebody's. This machine is shared between people and +//! agents, and a live worktree's `target/` may have a link step running in it +//! right now — so a directory touched recently is reported and never offered, +//! and one that is merely idle is offered by name rather than swept. + +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime}; + +/// How recently a build directory must have changed to count as in use. +/// +/// Generous on purpose. The cost of waiting is a gigabyte; the cost of +/// deleting the object files out from under a colleague's link step is their +/// afternoon. +pub const ACTIVE_WITHIN: Duration = Duration::from_secs(60 * 60); + +/// What a build directory belongs to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Owner { + /// The worktree this command was run in. + Here, + /// Another worktree that still exists, and has been built in recently. + ActiveElsewhere, + /// Another worktree that still exists and is idle. + IdleElsewhere, + /// No worktree: whatever this belonged to has been removed. + Orphan, +} + +impl Owner { + /// Whether removing this directory is this command's business. + /// + /// Only an orphan. An idle worktree is offered to a person by name, and + /// deciding for them is how a tool gets uninstalled. + pub fn is_reclaimable(self) -> bool { + self == Self::Orphan + } + + /// The word this prints as. + pub fn as_str(self) -> &'static str { + match self { + Self::Here => "this worktree", + Self::ActiveElsewhere => "in use", + Self::IdleElsewhere => "idle", + Self::Orphan => "orphaned", + } + } +} + +/// One build directory. +#[derive(Debug, Clone)] +pub struct Target { + /// Where it is. + pub path: PathBuf, + /// What it belongs to. + pub owner: Owner, + /// How many bytes it holds. + pub bytes: u64, +} + +/// Every build directory under this workspace, largest first. +/// +/// `here` is the directory the command was run in, and `worktrees` is what +/// git says still exists. +pub fn survey(root: &Path, here: &Path, worktrees: &[PathBuf]) -> Vec { + let mut found = Vec::new(); + for candidate in candidates(root) { + let Some(owner_dir) = candidate.parent() else { + continue; + }; + let owner = classify(owner_dir, here, worktrees, &candidate); + found.push(Target { + bytes: size_of(&candidate), + path: candidate, + owner, + }); + } + // Largest first: the answer to "what is eating the disk" is the top line. + found.sort_by_key(|target| std::cmp::Reverse(target.bytes)); + found +} + +/// Which directory each build directory belongs to. +fn classify(owner_dir: &Path, here: &Path, worktrees: &[PathBuf], target: &Path) -> Owner { + if owner_dir == here { + return Owner::Here; + } + if !worktrees.iter().any(|worktree| worktree == owner_dir) { + return Owner::Orphan; + } + if touched_within(target, ACTIVE_WITHIN) { + Owner::ActiveElsewhere + } else { + Owner::IdleElsewhere + } +} + +/// The workspace's own `target/`, and one per directory beside the worktrees. +fn candidates(root: &Path) -> Vec { + let mut found = Vec::new(); + let own = root.join("target"); + if own.is_dir() { + found.push(own); + } + let Ok(entries) = std::fs::read_dir(root.join(".claude").join("worktrees")) else { + return found; + }; + for entry in entries.filter_map(Result::ok) { + let target = entry.path().join("target"); + if target.is_dir() { + found.push(target); + } + } + found +} + +/// Whether anything under a directory changed recently. +/// +/// The directory's own timestamp, and its immediate children's: a link step +/// writes into `target/debug`, which moves that entry's time without moving +/// `target`'s on every filesystem. Walking the whole tree to answer this would +/// cost as much as measuring it. +fn touched_within(path: &Path, window: Duration) -> bool { + let recent = |path: &Path| { + std::fs::metadata(path) + .and_then(|meta| meta.modified()) + .ok() + .and_then(|when| SystemTime::now().duration_since(when).ok()) + .is_some_and(|age| age < window) + }; + if recent(path) { + return true; + } + std::fs::read_dir(path) + .into_iter() + .flatten() + .filter_map(Result::ok) + .any(|entry| recent(&entry.path())) +} + +/// How many bytes a directory tree holds. +/// +/// Walked rather than shelled out to `du`, so this answers the same on a +/// machine without it. Symbolic links are counted as links and never followed: +/// a build directory holds them, and following one out of the tree would +/// double-count at best. +pub fn size_of(path: &Path) -> u64 { + let mut total = 0; + let mut pending = vec![path.to_owned()]; + while let Some(dir) = pending.pop() { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.filter_map(Result::ok) { + let Ok(kind) = entry.file_type() else { + continue; + }; + if kind.is_symlink() { + continue; + } + if kind.is_dir() { + pending.push(entry.path()); + } else if let Ok(meta) = entry.metadata() { + total += meta.len(); + } + } + } + total +} + +/// A size a person reads, rather than a number of bytes. +pub fn human(bytes: u64) -> String { + const UNITS: [(&str, u64); 4] = [ + ("T", 1 << 40), + ("G", 1 << 30), + ("M", 1 << 20), + ("K", 1 << 10), + ]; + for (suffix, scale) in UNITS { + if bytes >= scale { + return format!("{:.1}{suffix}", bytes as f64 / scale as f64); + } + } + format!("{bytes}B") +} + +/// The checkout the worktrees hang off, which is not the one you are in. +/// +/// A worktree is a full checkout with its own `Cargo.toml`, so walking up for +/// a workspace root finds the worktree itself and never the directory holding +/// `.claude/worktrees`. Git knows: the common git directory is shared by every +/// worktree and sits in the main checkout. +/// +/// `None` when this is not a git checkout at all, which is a reason to say so +/// rather than to guess. +pub fn main_checkout(dir: &Path) -> Option { + let output = std::process::Command::new("git") + .current_dir(dir) + .args(["rev-parse", "--path-format=absolute", "--git-common-dir"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let common = PathBuf::from(String::from_utf8(output.stdout).ok()?.trim()); + common.parent().map(Path::to_path_buf) +} + +/// The worktrees git says exist, as absolute paths. +pub fn worktrees(root: &Path) -> Vec { + let Ok(output) = std::process::Command::new("git") + .current_dir(root) + .args(["worktree", "list", "--porcelain"]) + .output() + else { + return Vec::new(); + }; + String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(|line| line.strip_prefix("worktree ")) + .map(PathBuf::from) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_an_orphan_is_this_commands_business() { + assert!(Owner::Orphan.is_reclaimable()); + for owner in [Owner::Here, Owner::ActiveElsewhere, Owner::IdleElsewhere] { + assert!( + !owner.is_reclaimable(), + "{} would be deleted out from under somebody", + owner.as_str() + ); + } + } + + #[test] + fn a_build_directory_with_no_worktree_is_an_orphan() { + let here = Path::new("/repo/.claude/worktrees/mine"); + let live = vec![ + PathBuf::from("/repo"), + PathBuf::from("/repo/.claude/worktrees/mine"), + ]; + assert_eq!( + classify( + Path::new("/repo/.claude/worktrees/removed"), + here, + &live, + Path::new("/nonexistent/target"), + ), + Owner::Orphan + ); + } + + #[test] + fn the_current_worktree_is_never_anything_else() { + // Even when it is idle, and even when git has not been asked. + let here = Path::new("/repo/.claude/worktrees/mine"); + assert_eq!( + classify(here, here, &[], Path::new("/nonexistent/target")), + Owner::Here + ); + } + + #[test] + fn a_live_worktree_nothing_has_touched_is_idle_rather_than_orphaned() { + let here = Path::new("/repo/.claude/worktrees/mine"); + let live = vec![PathBuf::from("/repo/.claude/worktrees/theirs")]; + assert_eq!( + classify( + Path::new("/repo/.claude/worktrees/theirs"), + here, + &live, + Path::new("/nonexistent/target"), + ), + Owner::IdleElsewhere + ); + } + + #[test] + fn sizes_read_the_way_a_person_says_them() { + assert_eq!(human(0), "0B"); + assert_eq!(human(999), "999B"); + assert_eq!(human(1 << 10), "1.0K"); + assert_eq!(human(21 * (1 << 30)), "21.0G"); + } + + #[test] + fn a_tree_is_measured_by_what_is_in_it() { + let dir = std::env::temp_dir().join(format!( + "vibescrobble-setup-disk-{}-{}", + std::process::id(), + time::OffsetDateTime::now_utc().unix_timestamp_nanos() + )); + std::fs::create_dir_all(dir.join("nested")).expect("scratch"); + std::fs::write(dir.join("a"), vec![0u8; 100]).expect("write"); + std::fs::write(dir.join("nested").join("b"), vec![0u8; 250]).expect("write"); + assert_eq!(size_of(&dir), 350); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn an_hour_is_the_window() { + // Named rather than asserted for its own sake: the number is a + // judgement about somebody else's link step, and changing it should + // fail a test that says so. + assert_eq!(ACTIVE_WITHIN.as_secs(), 3600); + } +} diff --git a/crates/vibescrobble-setup/src/lib.rs b/crates/vibescrobble-setup/src/lib.rs index ca34fb7e..557caeff 100644 --- a/crates/vibescrobble-setup/src/lib.rs +++ b/crates/vibescrobble-setup/src/lib.rs @@ -38,6 +38,7 @@ pub mod apply; pub mod check; +pub mod disk; pub mod edit; pub mod harness; pub mod ledger; diff --git a/plan/local-dev.md b/plan/local-dev.md index b7f8c8a6..e4d23f73 100644 --- a/plan/local-dev.md +++ b/plan/local-dev.md @@ -27,13 +27,16 @@ workable without a deployment. cloud with agents on a laptop moves the keys off the machine running capable code. Agents in cloud sandboxes as well makes browser isolation moot and unlocks instance-identity attestation. -- [ ] **A way to reclaim worktree build directories.** Each `target/` is - 7-15 GB; four live worktrees have filled this machine's disk mid-link - more than once, and the failure looks like a compiler bug rather than a - full disk. ## Done +- [x] **A way to reclaim worktree build directories.** + `vibescrobble-setup disk` reports every `target/` under the checkout with + what it costs and what it belongs to, and `--clean` removes exactly one + category: the ones whose worktree no longer exists. A machine shared + between people and agents cannot have a tool that sweeps: a directory + touched in the last hour is reported as in use and never offered, and an + idle one is somebody else's to clear. - [x] **An integration test harness: a server and a fake agent, in process.** `crates/vibescrobble/tests/hook_to_record.rs` drives a real listener, the real hook handler and the real scrobble host: a session opens, a payload