Something went wrong. Try again.
Identities for entities did.bot
agent llm did
Something went wrong. Try again.
11 kB · 327 lines
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328//! 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<Target> { 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<PathBuf> { 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<PathBuf> { 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<PathBuf> { 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!( "didbot-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); }}