From 7ec9f835a926d27c419e00bee60a9e7ce0621be6 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Tue, 25 Aug 2026 11:43:05 +0000 Subject: [PATCH 1/7] Classify the agent worktrees inside a clone git worktree list is the authority for locked and prunable; the join to a directory on disk is by its place inside the clone, because the registered path is a container path that resolves to nothing on a host. The dirty check goes through the clone's admin directory for the worktree, which is the only side that resolves here, and excludes .claude/worktrees so a worktree holding a nested one does not read dirty forever. Reachability asks the sibling bare cache first: the clone is never fetched into, so its remote-tracking refs are as of clone time and asking them alone reports pushed-and-merged branches as unpushed. --- rust/devlaunch-core/src/clients/git.rs | 96 ++ .../src/flows/agent_worktrees.rs | 859 ++++++++++++++++++ .../src/flows/agent_worktrees/tests.rs | 709 +++++++++++++++ rust/devlaunch-core/src/flows/mod.rs | 2 + 4 files changed, 1666 insertions(+) create mode 100644 rust/devlaunch-core/src/flows/agent_worktrees.rs create mode 100644 rust/devlaunch-core/src/flows/agent_worktrees/tests.rs diff --git a/rust/devlaunch-core/src/clients/git.rs b/rust/devlaunch-core/src/clients/git.rs index 4fc53c00..8e57a162 100644 --- a/rust/devlaunch-core/src/clients/git.rs +++ b/rust/devlaunch-core/src/clients/git.rs @@ -359,6 +359,102 @@ impl<'r> Git<'r> { self.about(clone, &["log", "--oneline", branch, "--not", "--remotes"]) } + /// Every worktree registered in *clone*, as `worktree list --porcelain` + /// writes it: the clone's own entry first, then one paragraph per linked + /// worktree carrying its `branch` or `detached`, and `locked` or `prunable` + /// where git says so. + /// + /// **The registered paths are not necessarily paths on this machine.** An + /// agent harness running inside a devcontainer registers its worktrees at the + /// container's `/workspaces//…`, and the very same directories are + /// reached from the host through the clone. So this output is read for what + /// git *thinks* about each registration, and a registration is matched to a + /// directory by its place inside the clone rather than by resolving the path + /// git prints, which on a host resolves to nothing (devlaunch#426). + pub(crate) fn worktree_listing(&self, clone: &Path) -> GitAnswer { + self.about(clone, &["worktree", "list", "--porcelain"]) + } + + /// Drop the registrations whose worktree directory git can no longer find. + /// + /// Locked registrations are skipped by git itself, which is what makes a lock + /// survive this and go on protecting a worktree somebody may be working in. + /// + /// All-or-nothing across the clone: git offers no way to drop one + /// registration and keep another, which is why the caller decides whether a + /// clone may be pruned at all rather than which of its registrations goes. + pub(crate) fn worktree_prune(&self, clone: &Path) -> GitAnswer { + self.about(clone, &["worktree", "prune"]) + } + + /// The porcelain status of one linked worktree, asked through the clone's + /// admin directory for it. + /// + /// **Not [`Git::about`], and the difference is what makes this answerable at + /// all.** A linked worktree's own `.git` is a gitfile, and for an agent + /// worktree that gitfile names a path inside a container. Pointing + /// `--git-dir` at it on a host resolves nothing and git refuses — so a dirty + /// check that went the ordinary way would report every one of these as + /// unreadable, which is a refusal to reclaim anything. + /// `--git-dir=/.git/worktrees/` is the same repository reached + /// from the side that does resolve here, and `--work-tree` is the directory + /// on this host. + /// + /// **`.claude/worktrees/` is excluded from the walk.** A worktree holding a + /// nested worktree would otherwise always read dirty — the nested directory + /// is untracked — so it would be kept forever while the bytes that matter sat + /// inside it. Those nested directories are what the sweep reasons about + /// separately, not somebody's unsaved work. The exclusion is a pathspec so + /// git never walks the subtree, which also keeps a multi-gigabyte `.pixi` + /// inside one out of the status walk. + pub(crate) fn worktree_dirt(&self, admin: &Path, work_tree: &Path) -> GitAnswer { + let args = [ + format!("--git-dir={}", admin.display()), + format!("--work-tree={}", work_tree.display()), + "status".to_owned(), + "--porcelain".to_owned(), + "--".to_owned(), + // Spelled here rather than taken from `flows::agent_worktrees`, which + // is where the directory is named and reasoned about: a client does + // not import a flow. The two have to move together. + ":!.claude/worktrees".to_owned(), + ]; + self.captured( + "status --porcelain", + &SpawnSpec::new( + Invocation::new(PROGRAM) + .with_args(args) + .with_cwd(work_tree.to_path_buf()), + ) + .with_timeout(ABOUT_ONE_REPO), + ) + .map(|stdout| stdout.trim_end_matches('\n').to_owned()) + } + + /// How many commits are reachable from *rev* and from no ref in *repo*. + /// + /// Asked of the sibling bare cache, which is the repository devlaunch + /// actually fetches into, so `--all` there means "everything the forge had at + /// the last fetch". `0` is therefore the one answer that says a commit is + /// safely somewhere else. + /// + /// A refusal is usually `bad object`: the cache has never seen the commit, + /// which is what an unpushed branch looks like from over there. The caller + /// reads it that way and asks the clone as well rather than treating it as an + /// error. + pub(crate) fn commits_beyond_every_ref(&self, repo: &Path, rev: &str) -> GitAnswer { + self.captured( + "rev-list", + &SpawnSpec::new( + Invocation::new(PROGRAM) + .with_args(["rev-list", "--count", rev, "--not", "--all"]) + .with_cwd(repo.to_path_buf()), + ) + .with_timeout(ABOUT_ONE_REPO), + ) + .map(trimmed) + } + // ------------------------------------------------------- the bare cache /// `git clone --bare ` — the cache for one repository. diff --git a/rust/devlaunch-core/src/flows/agent_worktrees.rs b/rust/devlaunch-core/src/flows/agent_worktrees.rs new file mode 100644 index 00000000..187e1e09 --- /dev/null +++ b/rust/devlaunch-core/src/flows/agent_worktrees.rs @@ -0,0 +1,859 @@ +//! The git worktrees an agent harness leaves inside a workspace clone. +//! +//! # What this is about, and what the word means here +//! +//! An agent harness working inside a devcontainer makes its own git worktrees +//! under `/.claude/worktrees//`, one per task, and nothing ever +//! collects them. Measured on one host (devlaunch#426): 72 such directories, +//! 104.5 GB, 18 of them carrying a whole `.pixi/envs/default` — about 82% of +//! everything under `repos/`. Every one of them was inside a clone belonging to a +//! **live** devpod workspace, so [`crate::flows::lifecycle`]'s orphan rule not +//! only missed them, it must never fire on them: firing would delete a live +//! workspace's checkout. +//! +//! **`WorktreeInfo` is not this.** [`crate::domain::model::WorktreeInfo`] is +//! devlaunch's own long-standing name for *a workspace clone of one branch*, and +//! has nothing to do with anything in this module. Here "worktree" means git's +//! own thing — a second checkout registered in a repository, which +//! `git worktree list` prints and `git worktree prune` forgets. +//! +//! # Four categories, four safety profiles +//! +//! These are *registered* worktrees rather than stray directories, and they were +//! registered from inside the container, so the path git holds for one is +//! `/workspaces//.claude/worktrees/` — a path that does not resolve on +//! the host at all. On the reference host: 6 locked and 33 prunable registrations +//! against 72 directories on disk. So a directory here is one of four things, and +//! [`decide`] is the only place any of them becomes deletable: +//! +//! - **Forgotten.** No registration names it. git has already let go and the +//! directory is the whole of what is left. +//! - **Prunable.** Registered, and git's own listing says the registration is +//! collectable — which on a host is what a container-registered worktree looks +//! like, because the path it names is not there. +//! - **Locked.** Registered and locked. Never removed implicitly. +//! - **Held.** Registered, and git calls it neither locked nor prunable, so git +//! still believes in it. Always kept, and this is the arm that stops a run +//! *inside* a container — where the registered paths do resolve — from +//! collecting its own live worktrees. +//! +//! # What a lock is, and what it is not +//! +//! A lock is the harness's courtesy, so `locked` is neither necessary nor +//! sufficient for "somebody is working in here": a killed session leaves one +//! behind, and a live session that never took one is indistinguishable from an +//! abandoned directory. **Nothing on a host can prove a worktree is idle**, so +//! nothing here claims to. Every refusal names the fact it rests on — registered, +//! locked, dirty, holding commits nothing else reaches — and never idleness. +//! +//! That also means the race is real and cannot be closed from here. `--prune` +//! holds devlaunch's per-repo lock, and a container running `git worktree add` is +//! not a participant in it: the scan can say prunable and the container can +//! re-register before the removal lands. So every directory is classified **again, +//! immediately before it goes**, which is the same reasoning that put a +//! [`WorktreePromotion`] on each candidate rather than a run-wide boolean. +//! +//! # Why a path is never resolved, and a name is matched instead +//! +//! The registrations and the directories are two views of one set, and the only +//! thing they reliably share is the place inside the clone: a `.claude/worktrees/` +//! and a leaf, possibly nested. So a registration is joined to a directory by that +//! suffix, and the path git printed is never handed to the filesystem. The +//! container prefix is also why `git worktree remove` is no use from a host — it +//! resolves the registered path — so a removal here is a directory removal +//! followed by [`Git::worktree_prune`], in that order. +//! +//! `.claude/worktrees/` is the match rather than the `agent-` prefix the names +//! happen to carry: the prefix is the harness's business and can change, where +//! the containing directory is the thing devlaunch is reasoning about. And a +//! directory sitting in that place is confirmed to be a worktree by its own `.git` +//! gitfile naming a `…/.git/worktrees/` admin directory, because a plain +//! directory that happens to be there is not devlaunch's to delete. +//! +//! # Nesting +//! +//! An agent session running *inside* an agent worktree makes its worktrees under +//! *that* directory, and that nesting is how one clone on the reference host +//! reached 55 GB. So the scan recurses — but only into the worktrees it is +//! **keeping**. A directory that is going takes everything inside it, so +//! descending into one would report the same bytes twice and offer to remove a +//! directory that will not be there. + +use std::path::{Path, PathBuf}; + +use crate::clients::git::Git; +use crate::domain::workspace_state::{CouldNotTell, Loss, Losses, NonEmpty, Unsaved}; +use crate::flows::disk_usage::{self, DiskUsage}; +use crate::flows::lifecycle::{Insistence, Objection, objection}; + +/// The directory an agent harness puts its worktrees in, relative to a clone. +const WORKTREES_DIR: [&str; 2] = [".claude", "worktrees"]; + +/// The `.git` gitfile's prefix, and the admin directory it names inside a clone. +const GITFILE_PREFIX: &str = "gitdir:"; +const ADMIN_DIR: [&str; 2] = [".git", "worktrees"]; + +// =========================================================================== +// what git says about one registration +// =========================================================================== + +/// What one worktree has checked out, as `worktree list --porcelain` says it. +/// +/// Two arms rather than an optional branch, because a detached worktree's commits +/// are as losable as a branch's and the probe needs *something* to ask git about +/// either way. An absent branch that meant "ask nothing" would be the answer that +/// deletes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WorktreeHead { + /// `branch refs/heads/`, with the commit git printed beside it. + Branch { reference: String, commit: String }, + /// `detached`, and the commit `HEAD` named. + Detached { commit: String }, +} + +impl WorktreeHead { + /// What a report calls it. + pub fn named(&self) -> String { + match self { + Self::Branch { reference, .. } => reference + .strip_prefix("refs/heads/") + .unwrap_or(reference) + .to_owned(), + Self::Detached { commit } => format!("a detached HEAD at {}", short(commit)), + } + } + + /// What the clone is asked about it. + /// + /// The full `refs/heads/…` spelling rather than the short name, so a branch + /// and a tag of one name cannot be taken for each other. + fn revision(&self) -> &str { + match self { + Self::Branch { reference, .. } => reference, + Self::Detached { commit } => commit, + } + } + + /// The commit itself, which is what the sibling bare cache is asked about: + /// the branch *name* means nothing over there. + fn commit(&self) -> &str { + match self { + Self::Branch { commit, .. } | Self::Detached { commit } => commit, + } + } +} + +fn short(commit: &str) -> String { + commit.chars().take(8).collect() +} + +/// git is holding this worktree, and what it says about why. +/// +/// The reason is genuinely absent for a `git worktree lock` with no `--reason`, +/// which is what a harness does, so this is an absence and not a stand-in. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Lock { + pub reason: Option, +} + +/// One paragraph of `git worktree list --porcelain`, for a worktree registered +/// somewhere under a `.claude/worktrees/`. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Registration { + /// Where inside a clone the registration sits, as + /// `.claude/worktrees/[/.claude/worktrees/…]`. This is the join + /// key; the path git printed is deliberately not kept, because on a host it + /// names nothing. + inside: String, + head: WorktreeHead, + locked: Option, + prunable: bool, +} + +/// Every registration under a `.claude/worktrees/`, from `worktree list +/// --porcelain`. +/// +/// The clone's own entry, and any worktree registered somewhere else entirely, +/// are dropped here: this module reasons about directories inside one clone, and +/// a registration outside has no directory here to be joined to. +fn registrations(listing: &str) -> Vec { + let mut found = Vec::new(); + for paragraph in listing.split("\n\n") { + let mut inside = None; + let mut reference = None; + let mut commit = None; + let mut locked = None; + let mut prunable = false; + for line in paragraph.lines() { + let (key, rest) = match line.split_once(' ') { + Some((key, rest)) => (key, Some(rest)), + None => (line, None), + }; + match (key, rest) { + ("worktree", Some(path)) => inside = inside_a_worktrees_dir(Path::new(path)), + ("HEAD", Some(sha)) => commit = Some(sha.to_owned()), + ("branch", Some(name)) => reference = Some(name.to_owned()), + ("locked", reason) => { + locked = Some(Lock { + reason: reason.map(str::to_owned).filter(|it| !it.is_empty()), + }); + } + ("prunable", _) => prunable = true, + _ => {} + } + } + let (Some(inside), Some(commit)) = (inside, commit) else { + continue; + }; + let head = match reference { + Some(reference) => WorktreeHead::Branch { reference, commit }, + None => WorktreeHead::Detached { commit }, + }; + found.push(Registration { + inside, + head, + locked, + prunable, + }); + } + found +} + +/// Where in a clone `path` sits, as the join key, when it sits under a +/// `.claude/worktrees/` at all. +/// +/// The suffix from the *first* `.claude/worktrees` onwards, so a nested worktree +/// keeps its whole path inside the clone and cannot be confused with a +/// same-named one at the top. Read off the components git printed rather than off +/// the filesystem, because the point of this module is that the path is not a +/// path here. +fn inside_a_worktrees_dir(path: &Path) -> Option { + let parts: Vec = path + .components() + .map(|part| part.as_os_str().to_string_lossy().into_owned()) + .collect(); + let at = (0..parts.len().saturating_sub(2)).find(|&at| { + parts[at] == WORKTREES_DIR[0] && parts[at + 1] == WORKTREES_DIR[1] && at + 2 < parts.len() + })?; + Some(parts[at..].join("/")) +} + +/// Where `directory` sits inside `clone`, as the same join key. +fn inside_the_clone(clone: &Path, directory: &Path) -> Option { + let relative = directory.strip_prefix(clone).ok()?; + inside_a_worktrees_dir(relative) +} + +/// The admin directory name `directory`'s own `.git` gitfile claims, when +/// `directory` really is a linked git worktree. +/// +/// **This is the confirmation, and it is not the path shape.** A plain directory +/// sitting under `.claude/worktrees/`, or a file, or a symlink, is not +/// devlaunch's to remove; a linked worktree has a `.git` *file* reading +/// `gitdir: …/.git/worktrees/`. The prefix of that path is the container's +/// and is ignored — only the `…/.git/worktrees/` tail is read, which is +/// what says "a git worktree, registered under some repository's admin +/// directory". +fn linked_worktree_name(directory: &Path) -> Option { + let gitfile = directory.join(".git"); + if !std::fs::symlink_metadata(&gitfile).ok()?.is_file() { + return None; + } + let content = std::fs::read_to_string(&gitfile).ok()?; + let named = content.trim().strip_prefix(GITFILE_PREFIX)?.trim(); + let parts: Vec = Path::new(named) + .components() + .map(|part| part.as_os_str().to_string_lossy().into_owned()) + .collect(); + let (name, rest) = parts.split_last()?; + let (worktrees, rest) = rest.split_last()?; + let dot_git = rest.last()?; + (dot_git == ADMIN_DIR[0] && worktrees == ADMIN_DIR[1]).then(|| name.clone()) +} + +// =========================================================================== +// what one directory is +// =========================================================================== + +/// Which arm one directory under a `.claude/worktrees/` is. +/// +/// The module docs carry what the four arms mean. `usage` rides on the three +/// removable arms and not on [`Self::Held`], for the reason +/// `lifecycle::CloneStatus` does the same: the walk behind it is O(files) with no +/// ceiling, and an arm nobody can reclaim is an arm nobody should pay to weigh. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum WorktreeStatus { + /// No registration names it, so there is no admin directory to ask git + /// anything through either. + /// + /// Nothing is probed here and that is a real limit, not an oversight: with + /// the admin directory gone there is no index and no HEAD, so no `git status` + /// can be run against the directory at all. devlaunch#426 calls this category + /// safe to delete outright, and it is the one category where devlaunch takes + /// git's word for it rather than checking. + Forgotten { usage: DiskUsage }, + /// Registered, and git's own listing calls the registration prunable. + Prunable { + head: WorktreeHead, + holds: Unsaved, + usage: DiskUsage, + }, + /// Registered and locked. + Locked { + lock: Lock, + head: WorktreeHead, + holds: Unsaved, + usage: DiskUsage, + }, + /// Registered, and git calls it neither locked nor prunable. + Held { head: WorktreeHead }, +} + +/// Whether the sibling bare cache's refs already reach a commit. +/// +/// **This is the fix for a stale-ref trap and not a nicety.** A workspace clone +/// is cut from the sibling `.bare` and then has its remote repointed at the +/// forge, with no fetch of its own (see `flows::workspace_clone`'s module +/// header), so the clone's `refs/remotes/origin/*` is as of clone time and can be +/// arbitrarily old or absent. The `.bare` next door is the thing that gets +/// fetched. Ask the clone alone and branches that were pushed and merged months +/// ago read as unpushed, which keeps every byte forever and makes the flag the +/// only way to reclaim anything. +/// +/// Both answers are **as of the last fetch**, which is what the report says. No +/// network call is added here: `--prune` is a local cleanup, and one that failed +/// offline would be a worse command than one that is sometimes out of date. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum InTheCache { + /// Every commit is reachable from a ref the cache holds. + Reached, + /// The cache does not reach it — including the case where it has never seen + /// the commit at all, which is what an unpushed branch looks like from there. + Beyond, + /// The cache could not be asked, or there is none. + CouldNotSay, +} + +/// Whether `commit` is inside what the bare cache already has. +fn in_the_cache(git: &Git<'_>, bare: Option<&Path>, commit: &str) -> InTheCache { + let Some(bare) = bare else { + return InTheCache::CouldNotSay; + }; + match git.commits_beyond_every_ref(bare, commit).said() { + // A refusal here is overwhelmingly `bad object` — the cache has never + // seen this commit, which is exactly what an unpushed branch looks like + // from the cache and is a fact, not a failure. A genuinely broken cache + // lands here too and reads the same way, which is the conservative + // direction: the clone is asked next, and only both of them failing to + // find the work anywhere keeps the directory. + None => InTheCache::Beyond, + Some(count) => match count.trim().parse::() { + Ok(0) => InTheCache::Reached, + Ok(_) => InTheCache::Beyond, + Err(_) => InTheCache::CouldNotSay, + }, + } +} + +/// What removing one registered worktree directory would destroy, or that git +/// could not say. +/// +/// Mirrors `workspace_state`'s clone-level probe and answers in the same type, so +/// the report reads in the same words — but every question is asked differently, +/// because a host cannot ask this worktree anything the ordinary way: +/// +/// - **The dirty check goes through the admin directory.** The worktree's own +/// `.git` gitfile names a container path, so pointing git at the directory +/// refuses. `--git-dir=/.git/worktrees/` with +/// `--work-tree=` is the same repository reached from the side that +/// does resolve here. +/// - **`.claude/worktrees/` is excluded from it.** A worktree holding a nested +/// worktree would otherwise always read dirty — the nested directory is +/// untracked — and would be kept forever while the bytes that matter sat inside +/// it. Those nested directories are what this sweep reasons about separately, +/// not somebody's unsaved work. +/// - **Reachability asks the cache first.** See [`InTheCache`]. +fn unsaved_in( + git: &Git<'_>, + clone: &Path, + bare: Option<&Path>, + admin: &Path, + directory: &Path, + head: &WorktreeHead, +) -> Unsaved { + let dirt = match git.worktree_dirt(admin, directory).said() { + None => { + return Unsaved::CouldNotTell(CouldNotTell::GitCouldNotRead { + clone: directory.to_path_buf(), + reason: "git could not read this worktree through the clone's admin directory" + .to_owned(), + }); + } + Some(dirt) => dirt, + }; + let mut losses = Vec::new(); + if let Some(changed) = NonEmpty::of(dirt.lines().map(str::to_owned)) { + losses.push(Loss::Uncommitted(changed)); + } + match in_the_cache(git, bare, head.commit()) { + InTheCache::Reached => {} + InTheCache::Beyond | InTheCache::CouldNotSay => { + match git.unpushed_commits(clone, head.revision()).said() { + None => { + return Unsaved::CouldNotTell(CouldNotTell::UnpushedNotListed { + clone: directory.to_path_buf(), + branch: head.named(), + reason: "neither the repository cache nor the clone could say whether \ + these commits are anywhere else" + .to_owned(), + }); + } + Some(unpushed) => { + if let Some(commits) = NonEmpty::of(unpushed.lines().map(str::to_owned)) { + losses.push(Loss::Unpushed(commits)); + } + } + } + } + } + match Losses::of(losses) { + Some(losses) => Unsaved::WouldLose(losses), + None => Unsaved::NothingToLose, + } +} + +/// Which arm `directory` is, asked in the order that fails towards keeping it. +fn worktree_status( + git: &Git<'_>, + clone: &Path, + bare: Option<&Path>, + directory: &Path, + admin: Option<&Path>, + registered: Option<&Registration>, +) -> WorktreeStatus { + let (Some(registration), Some(admin)) = (registered, admin) else { + return WorktreeStatus::Forgotten { + usage: disk_usage::exclusive_usage(directory), + }; + }; + if !registration.prunable && registration.locked.is_none() { + return WorktreeStatus::Held { + head: registration.head.clone(), + }; + } + let holds = unsaved_in(git, clone, bare, admin, directory, ®istration.head); + let usage = disk_usage::exclusive_usage(directory); + match registration.locked.clone() { + Some(lock) => WorktreeStatus::Locked { + lock, + head: registration.head.clone(), + holds, + usage, + }, + None => WorktreeStatus::Prunable { + head: registration.head.clone(), + holds, + usage, + }, + } +} + +// =========================================================================== +// what is done about it +// =========================================================================== + +/// One thing arguing against removing a worktree directory. +/// +/// A list of these rather than one, because a locked worktree that is also dirty +/// has two things wrong with it and a report naming one would be telling half the +/// truth. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WorktreeObjection { + /// git is holding it. Which says the harness asked for it to be left alone, + /// and says nothing whatever about whether anyone is working in it. + Locked { lock: Lock }, + /// Removing it would destroy this, or git could not say. + Holds(Objection), +} + +/// Why one worktree directory is staying. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WorktreeKept { + /// git still holds the registration and calls it neither locked nor + /// prunable, so as far as git is concerned this worktree is live. + StillHeld { head: WorktreeHead }, + /// At least one thing objected and `--force-worktrees` was not typed. + Objected(NonEmpty), +} + +/// Nothing objected, or `--force-worktrees` carried this directory past what did. +/// +/// Carried per directory rather than read from a run-wide flag, for the reason +/// `lifecycle::Promotion` spells out at length: a plan-wide boolean says "the +/// user insisted" about every directory in the plan, including the ones nothing +/// objected to, and the acting pass then skips its re-check for all of them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WorktreePromotion { + Unopposed, + Insisted { + despite: NonEmpty, + }, +} + +impl WorktreePromotion { + /// The insistence the acting pass re-applies to this directory alone. + pub(crate) fn insistence(&self) -> Insistence { + match self { + Self::Unopposed => Insistence::NotInsisted, + Self::Insisted { .. } => Insistence::Insisted, + } + } +} + +/// How git saw a directory this run is removing. +/// +/// Reported because the categories are different sentences: "git had already +/// forgotten these" needs nothing else done, where "git will let go of these once +/// it is asked" is what [`Git::worktree_prune`] follows for. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SeenAs { + Forgotten, + Prunable, + Locked, +} + +/// What `--prune` does about one directory under a `.claude/worktrees/`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum WorktreeDecision { + Remove { + seen_as: SeenAs, + usage: DiskUsage, + promotion: WorktreePromotion, + }, + Keep(WorktreeKept), +} + +/// What `--prune` does about one worktree directory. The only such place. +/// +/// Total over [`WorktreeStatus`]'s arms, so a fifth arm added later stops the +/// build rather than falling through into a deletion. +/// +/// `--force-worktrees` promotes exactly the arms that carry objections. It is not +/// a general override: [`WorktreeStatus::Held`] is not a refusal to be insisted +/// past, it is git saying this worktree is live, and there is nothing for a person +/// to mean by insisting on it. +pub(crate) fn decide(status: WorktreeStatus, insistence: Insistence) -> WorktreeDecision { + let (seen_as, usage, objections) = match status { + WorktreeStatus::Held { head } => { + return WorktreeDecision::Keep(WorktreeKept::StillHeld { head }); + } + WorktreeStatus::Forgotten { usage } => (SeenAs::Forgotten, usage, Vec::new()), + WorktreeStatus::Prunable { holds, usage, .. } => { + (SeenAs::Prunable, usage, objections_of(None, &holds)) + } + WorktreeStatus::Locked { + lock, holds, usage, .. + } => (SeenAs::Locked, usage, objections_of(Some(lock), &holds)), + }; + match NonEmpty::of(objections) { + None => WorktreeDecision::Remove { + seen_as, + usage, + promotion: WorktreePromotion::Unopposed, + }, + Some(objected) => match insistence { + Insistence::Insisted => WorktreeDecision::Remove { + seen_as, + usage, + promotion: WorktreePromotion::Insisted { despite: objected }, + }, + Insistence::NotInsisted => WorktreeDecision::Keep(WorktreeKept::Objected(objected)), + }, + } +} + +/// Everything arguing against removing one registered worktree, in the order a +/// report reads best in: what git is doing about it, then what it holds. +fn objections_of(lock: Option, holds: &Unsaved) -> Vec { + let mut objections = Vec::new(); + if let Some(lock) = lock { + objections.push(WorktreeObjection::Locked { lock }); + } + if let Some(objected) = objection(holds) { + objections.push(WorktreeObjection::Holds(objected)); + } + objections +} + +// =========================================================================== +// the sweep +// =========================================================================== + +/// One worktree directory this run will remove, what it frees, and why it may. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReclaimableWorktree { + pub path: PathBuf, + pub seen_as: SeenAs, + pub usage: DiskUsage, + pub promotion: WorktreePromotion, +} + +/// One worktree directory this run will leave standing, and why. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KeptWorktree { + pub path: PathBuf, + pub because: WorktreeKept, +} + +/// One clone's share of the sweep. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CloneWorktrees { + // Private, all of them, for the reason `PrunePlan`'s fields are: this is an + // answer, and a caller that could fill it could pair one clone with a + // classification of another clone's directories. + clone: PathBuf, + owner: String, + repo: String, + removing: Vec, + keeping: Vec, + registrations_with_nothing_here: usize, +} + +impl CloneWorktrees { + /// The clone these worktrees live inside. + pub fn clone_path(&self) -> &Path { + &self.clone + } + + pub fn owner(&self) -> &str { + &self.owner + } + + pub fn repo(&self) -> &str { + &self.repo + } + + /// Biggest first, path breaking ties, so two runs over an unchanged cache + /// read alike. + pub fn removing(&self) -> &[ReclaimableWorktree] { + &self.removing + } + + pub fn keeping(&self) -> &[KeptWorktree] { + &self.keeping + } + + /// Registrations under a `.claude/worktrees/` with no directory here at all. + /// + /// Worth its own count because it is the one category with **no bytes behind + /// it**: the registration is either a container path that never resolved on + /// this host or a directory somebody removed by hand, and either way there is + /// nothing to free. `git worktree prune` is the whole of the work. + pub fn registrations_with_nothing_here(&self) -> usize { + self.registrations_with_nothing_here + } + + /// What removing this clone's share would free. + pub fn freed(&self) -> DiskUsage { + disk_usage::total_usage(self.removing.iter().map(|it| it.usage.clone())) + } + + /// Whether `git worktree prune` may run in this clone once the removals are + /// done. + /// + /// **A data-loss guard, not tidiness.** `git worktree prune` is + /// all-or-nothing across a clone, and on a host it drops the registration of + /// *every* container-registered worktree — including one being kept because + /// it is dirty or holds commits nothing else reaches. That registration is the + /// only reason a later run can tell the directory apart from a forgotten one, + /// and a forgotten one is removed outright. So pruning here would protect a + /// worktree once and hand it over the second time. + /// + /// A lock survives a prune by git's own rule, so a worktree kept only for + /// being locked does not hold the prune back. + pub fn metadata_may_be_pruned(&self) -> bool { + !self.keeping.iter().any(|kept| match &kept.because { + WorktreeKept::StillHeld { .. } => false, + WorktreeKept::Objected(objections) => objections + .iter() + .any(|objection| matches!(objection, WorktreeObjection::Holds(_))), + }) + } + + fn nothing_to_say(&self) -> bool { + self.removing.is_empty() + && self.keeping.is_empty() + && self.registrations_with_nothing_here == 0 + } +} + +/// Every agent worktree inside the clones one `--prune` is keeping. +/// +/// Empty on the overwhelming majority of hosts, and cheap to find out: a clone +/// with no `.claude/worktrees/` costs one failed `read_dir` and no git at all. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct WorktreeSweep { + clones: Vec, +} + +impl WorktreeSweep { + /// One entry per clone that has anything to say, in scan order. + pub fn clones(&self) -> &[CloneWorktrees] { + &self.clones + } + + /// How many directories this run would remove. + pub fn removing(&self) -> usize { + self.clones.iter().map(|it| it.removing.len()).sum() + } + + /// How many it would leave, whatever the reason. + pub fn keeping(&self) -> usize { + self.clones.iter().map(|it| it.keeping.len()).sum() + } + + /// What the whole sweep would free. + pub fn freed(&self) -> DiskUsage { + disk_usage::total_usage( + self.clones + .iter() + .flat_map(|it| it.removing.iter().map(|worktree| worktree.usage.clone())), + ) + } + + /// Whether this sweep would change anything on disk or in git. + pub fn nothing_to_do(&self) -> bool { + self.clones + .iter() + .all(|it| it.removing.is_empty() && it.registrations_with_nothing_here == 0) + } + + /// Whether there is nothing to say about it either. + pub fn nothing_to_say(&self) -> bool { + self.clones.is_empty() + } + + fn record(&mut self, found: Option) { + if let Some(found) = found.filter(|it| !it.nothing_to_say()) { + self.clones.push(found); + } + } +} + +/// Classify the agent worktrees inside one clone. +/// +/// `None` when there is no `.claude/worktrees/` at all, which is the answer for +/// nearly every clone and what makes this affordable on the prune path: no +/// `git worktree list`, no disk walk, one `read_dir` that fails. +/// +/// `bare` is the sibling repository cache, consulted for reachability; see +/// [`InTheCache`] for why the clone alone is not enough. +pub(crate) fn sweep_clone( + git: &Git<'_>, + clone: &Path, + owner: &str, + repo: &str, + bare: Option<&Path>, + insistence: Insistence, +) -> Option { + let mut pending = children_of(&worktrees_dir(clone))?; + // A git that will not answer takes the whole clone out of the sweep. Reading + // a refusal as "git named no registrations" would classify every directory as + // forgotten, and forgotten is the arm that deletes. + let listing = git.worktree_listing(clone).said()?; + let registered = registrations(&listing); + let mut removing = Vec::new(); + let mut keeping = Vec::new(); + while let Some(directory) = pending.pop() { + let (Some(inside), Some(name)) = ( + inside_the_clone(clone, &directory), + linked_worktree_name(&directory), + ) else { + // Not a linked worktree: a plain directory that happens to sit here, + // or one whose `.git` says something else. Not devlaunch's to remove + // and not descended into either. + continue; + }; + let registration = registered.iter().find(|it| it.inside == inside); + let admin = admin_dir(clone, &name); + let status = worktree_status(git, clone, bare, &directory, admin.as_deref(), registration); + match decide(status, insistence) { + WorktreeDecision::Remove { + seen_as, + usage, + promotion, + } => removing.push(ReclaimableWorktree { + path: directory, + seen_as, + usage, + promotion, + }), + WorktreeDecision::Keep(because) => { + // Only into the ones that are staying. A directory that is going + // takes everything inside it, so descending into one would count + // the same bytes twice and offer a directory that will not be + // there. + pending.extend(children_of(&worktrees_dir(&directory)).unwrap_or_default()); + keeping.push(KeptWorktree { + path: directory, + because, + }); + } + } + } + removing.sort_by(|left, right| { + right + .usage + .known_bytes() + .cmp(&left.usage.known_bytes()) + .then_with(|| left.path.cmp(&right.path)) + }); + keeping.sort_by(|left, right| left.path.cmp(&right.path)); + let registrations_with_nothing_here = registered + .iter() + .filter(|registration| !clone.join(®istration.inside).exists()) + .count(); + Some(CloneWorktrees { + clone: clone.to_path_buf(), + owner: owner.to_owned(), + repo: repo.to_owned(), + removing, + keeping, + registrations_with_nothing_here, + }) +} + +/// The `.claude/worktrees/` inside one directory. +fn worktrees_dir(directory: &Path) -> PathBuf { + directory.join(WORKTREES_DIR[0]).join(WORKTREES_DIR[1]) +} + +/// The admin directory `name` names inside `clone`, when it is there. +fn admin_dir(clone: &Path, name: &str) -> Option { + let admin = clone.join(ADMIN_DIR[0]).join(ADMIN_DIR[1]).join(name); + admin.is_dir().then_some(admin) +} + +/// The directories directly inside `root`, or nothing when there is no `root`. +/// +/// Symlinks are skipped rather than followed, for the reason the clone scan skips +/// them: following one walks a removal out of the cache directory `--prune` is +/// scoped to, and that scoping is what makes a scratch-cache run harmless. +fn children_of(root: &Path) -> Option> { + let mut found: Vec = std::fs::read_dir(root) + .ok()? + .filter_map(Result::ok) + .filter(|entry| { + entry + .file_type() + .map(|kind| kind.is_dir() && !kind.is_symlink()) + .unwrap_or(false) + }) + .map(|entry| entry.path()) + .collect(); + found.sort(); + Some(found) +} + +#[cfg(test)] +mod tests; diff --git a/rust/devlaunch-core/src/flows/agent_worktrees/tests.rs b/rust/devlaunch-core/src/flows/agent_worktrees/tests.rs new file mode 100644 index 00000000..32393873 --- /dev/null +++ b/rust/devlaunch-core/src/flows/agent_worktrees/tests.rs @@ -0,0 +1,709 @@ +//! What the agent-worktree sweep classifies, at the seam the whole ticket turns +//! on. +//! +//! **Real git, real filesystem, real registrations.** Every fact this module acts +//! on comes out of `git worktree list --porcelain` and out of `git status` run +//! through an admin directory, and a faked spawn answers a clean exit with empty +//! output — which reads as "git named no registrations" and "this worktree is +//! clean", the two answers that delete. So these build a clone with real +//! worktrees in it and then rewrite the registrations to container paths, which is +//! the shape a host sees and the shape none of this can be tested without. + +use std::path::{Path, PathBuf}; + +use devlaunch_runner::ProcessRunner; + +use super::*; +use crate::domain::workspace_state::Unsaved; +use crate::flows::repo_manager::tests::run_git; + +/// A cache holding one bare repository and one clone of it, with real worktrees +/// under the clone's `.claude/worktrees/`. +/// +/// The layout is the one `--prune` scans: `///.bare` beside +/// `///`, so the sibling cache the reachability probe +/// consults is where the real thing puts it. +struct Clone { + dir: tempfile::TempDir, + bare: PathBuf, + clone: PathBuf, +} + +const OWNER: &str = "o"; +const REPO: &str = "r"; + +impl Clone { + /// A clone of a one-commit repository, with nothing under `.claude/` yet. + fn new() -> Self { + let dir = tempfile::tempdir().expect("a scratch directory"); + let root = dir.path().to_path_buf(); + let seed = root.join("seed"); + std::fs::create_dir_all(&seed).expect("a seed directory"); + run_git(&root, &["init", "-b", "main", &seed.display().to_string()]); + std::fs::write(seed.join("README.md"), "seed\n").expect("a README"); + commit(&seed, "seed"); + + // The forge stands in for GitHub; the bare cache is what devlaunch fetches + // into and what the reachability probe asks. + let forge = root.join("forge.git"); + clone_bare(&root, &seed, &forge); + let repo_dir = root.join("repos").join(OWNER).join(REPO); + std::fs::create_dir_all(&repo_dir).expect("the repository directory"); + let bare = repo_dir.join(".bare"); + clone_bare(&root, &forge, &bare); + + let clone = repo_dir.join("ws-one"); + run_git( + &root, + &[ + "clone", + &bare.display().to_string(), + &clone.display().to_string(), + ], + ); + run_git( + &clone, + &["remote", "set-url", "origin", &forge.display().to_string()], + ); + Self { dir, bare, clone } + } + + fn tmp(&self) -> &Path { + self.dir.path() + } + + /// One real agent worktree at `/.claude/worktrees/` on its own + /// branch, pushed to the forge and fetched into the cache — the ordinary + /// finished-task shape, holding nothing. + fn worktree(&self, leaf: &str) -> PathBuf { + let path = worktrees_dir(&self.clone).join(leaf); + std::fs::create_dir_all(worktrees_dir(&self.clone)).expect("the worktrees directory"); + run_git( + &self.clone, + &["worktree", "add", "-b", leaf, &path.display().to_string()], + ); + run_git(&self.clone, &["push", "origin", leaf]); + self.fetch(); + path + } + + /// A worktree nested inside another one, the way an agent session running in + /// a worktree creates one. + fn nested(&self, inside: &Path, leaf: &str) -> PathBuf { + let path = worktrees_dir(inside).join(leaf); + std::fs::create_dir_all(worktrees_dir(inside)).expect("the nested worktrees directory"); + run_git( + &self.clone, + &["worktree", "add", "-b", leaf, &path.display().to_string()], + ); + run_git(&self.clone, &["push", "origin", leaf]); + self.fetch(); + path + } + + /// Bring the cache up to date with the forge, the way the fetch sweep does. + fn fetch(&self) { + run_git( + &self.bare, + &["fetch", "origin", "+refs/heads/*:refs/heads/*", "--prune"], + ); + } + + /// Rewrite every registration to the container path it would really carry, + /// which is what makes git call them prunable and what a host actually sees. + fn containerise(&self) { + let admin = self.clone.join(".git").join("worktrees"); + for entry in std::fs::read_dir(&admin).expect("the admin directory") { + let gitdir = entry.expect("an admin entry").path().join("gitdir"); + let registered = std::fs::read_to_string(&gitdir).expect("a gitdir file"); + std::fs::write( + &gitdir, + registered.replace( + &self.clone.display().to_string(), + "/workspaces/devlaunch-container", + ), + ) + .expect("the rewritten gitdir"); + } + } + + /// The sweep, as `--prune` would take it. + fn sweep(&self, insistence: Insistence) -> Option { + let runner = ProcessRunner::new(); + let git = Git::new(&runner); + sweep_clone(&git, &self.clone, OWNER, REPO, Some(&self.bare), insistence) + } + + /// The sweep, insisting on nothing. + fn plan(&self) -> CloneWorktrees { + self.sweep(Insistence::NotInsisted) + .expect("a sweep of a clone that has worktrees") + } +} + +fn clone_bare(cwd: &Path, from: &Path, to: &Path) { + run_git( + cwd, + &[ + "clone", + "--bare", + &from.display().to_string(), + &to.display().to_string(), + ], + ); +} + +fn commit(work: &Path, message: &str) { + run_git(work, &["add", "-A"]); + run_git(work, &["commit", "-m", message]); +} + +/// The paths a sweep would remove, in the order it reports them. +fn removing(found: &CloneWorktrees) -> Vec { + found.removing().iter().map(|it| it.path.clone()).collect() +} + +/// The paths it would leave. +fn keeping(found: &CloneWorktrees) -> Vec { + found.keeping().iter().map(|it| it.path.clone()).collect() +} + +/// Why it keeps `path`, failing loudly if it is not in the sweep at all. +/// +/// Every assertion about a directory *surviving* goes through here rather than +/// through an existence check, because "it is still there" is true of a worktree +/// kept for the right reason and of one no guard ever looked at. +fn kept_because(found: &CloneWorktrees, path: &Path) -> WorktreeKept { + let mut matched: Vec<&KeptWorktree> = found + .keeping() + .iter() + .filter(|kept| kept.path == path) + .collect(); + assert_eq!( + matched.len(), + 1, + "expected exactly one report line for {}: {:?}", + path.display(), + found.keeping() + ); + matched.remove(0).because.clone() +} + +// ======================================================================= +// a clone with nothing in it costs nothing +// ======================================================================= + +#[test] +fn a_clone_with_no_worktrees_directory_is_not_swept_at_all() { + // The answer for nearly every clone, and the reason this is affordable on the + // prune path: no `git worktree list`, no disk walk. + let world = Clone::new(); + + assert_eq!(world.sweep(Insistence::NotInsisted), None); +} + +// ======================================================================= +// the four categories (devlaunch#426) +// ======================================================================= + +#[test] +fn a_worktree_git_still_holds_is_left_alone() { + // The registrations resolve, so git calls this worktree live. This is the arm + // that stops a run inside a container collecting its own worktrees. + let world = Clone::new(); + let live = world.worktree("agent-live"); + + let found = world.plan(); + + assert!(removing(&found).is_empty(), "{:?}", removing(&found)); + assert!(matches!( + kept_because(&found, &live), + WorktreeKept::StillHeld { .. } + )); +} + +#[test] +fn a_registration_git_calls_prunable_is_reclaimed() { + // The host's ordinary case: registered from inside the container, so the path + // git holds resolves to nothing and git itself says the registration can go. + let world = Clone::new(); + let finished = world.worktree("agent-finished"); + world.containerise(); + + let found = world.plan(); + + assert_eq!(removing(&found), [finished]); + assert_eq!(found.removing()[0].seen_as, SeenAs::Prunable); + assert_eq!( + found.removing()[0].promotion, + WorktreePromotion::Unopposed, + "nothing objected, so nothing was insisted past" + ); +} + +#[test] +fn a_directory_git_has_already_forgotten_is_reclaimed() { + // devlaunch#426's category 1: `git worktree prune` has already dropped the + // metadata and the directory is the whole of what is left. + let world = Clone::new(); + let left_behind = world.worktree("agent-forgotten"); + world.containerise(); + run_git(&world.clone, &["worktree", "prune"]); + + let found = world.plan(); + + assert_eq!(removing(&found), [left_behind]); + assert_eq!(found.removing()[0].seen_as, SeenAs::Forgotten); +} + +#[test] +fn a_locked_worktree_is_never_removed_without_being_asked_for() { + // A harness locks a worktree so it is not collected mid-run, so a lock may + // mean in use right now or may be what a killed session left behind. + let world = Clone::new(); + let locked = world.worktree("agent-locked"); + run_git( + &world.clone, + &["worktree", "lock", &locked.display().to_string()], + ); + world.containerise(); + + let found = world.plan(); + + assert!(removing(&found).is_empty(), "{:?}", removing(&found)); + let because = kept_because(&found, &locked); + let WorktreeKept::Objected(objections) = &because else { + panic!("expected an objection, got {because:?}"); + }; + assert!( + objections + .iter() + .any(|it| matches!(it, WorktreeObjection::Locked { .. })), + "{objections:?}" + ); +} + +#[test] +fn insisting_carries_a_locked_worktree_past_its_lock() { + let world = Clone::new(); + let locked = world.worktree("agent-locked"); + run_git( + &world.clone, + &["worktree", "lock", &locked.display().to_string()], + ); + world.containerise(); + + let found = world + .sweep(Insistence::Insisted) + .expect("a sweep of a clone that has worktrees"); + + assert_eq!(removing(&found), [locked]); + assert_eq!(found.removing()[0].seen_as, SeenAs::Locked); + // What was insisted past travels with the directory it was insisted past for, + // so the report can say it on that line. + assert!(matches!( + &found.removing()[0].promotion, + WorktreePromotion::Insisted { .. } + )); +} + +// ======================================================================= +// what a worktree holds (the sharpened Ask 3) +// ======================================================================= + +#[test] +fn an_uncommitted_edit_keeps_a_worktree_that_is_otherwise_collectable() { + // Reachability says nothing about the working tree. A worktree on a + // fully-merged branch with unstaged edits in it reads as safe to delete under + // a commits-only rule, and deleting it loses the edits. + let world = Clone::new(); + let dirty = world.worktree("agent-dirty"); + std::fs::write(dirty.join("README.md"), "an afternoon of edits\n").expect("an edit"); + world.containerise(); + + let found = world.plan(); + + assert!(removing(&found).is_empty(), "{:?}", removing(&found)); + assert!(matches!( + kept_because(&found, &dirty), + WorktreeKept::Objected(_) + )); +} + +#[test] +fn an_untracked_file_keeps_it_too() { + // An agent's scratch notes are not less lost for never having been added. + let world = Clone::new(); + let dirty = world.worktree("agent-notes"); + std::fs::write(dirty.join("notes.md"), "what I was about to do\n").expect("a note"); + world.containerise(); + + let found = world.plan(); + + assert!(removing(&found).is_empty(), "{:?}", removing(&found)); +} + +#[test] +fn a_commit_that_was_never_pushed_keeps_the_worktree() { + let world = Clone::new(); + let ahead = world.worktree("agent-ahead"); + std::fs::write(ahead.join("work.md"), "committed and nowhere else\n").expect("a file"); + commit(&ahead, "ahead"); + world.containerise(); + + let found = world.plan(); + + assert!(removing(&found).is_empty(), "{:?}", removing(&found)); + assert!(matches!( + kept_because(&found, &ahead), + WorktreeKept::Objected(_) + )); +} + +#[test] +fn a_detached_head_that_is_ahead_keeps_the_worktree() { + // An agent worktree need not be on a branch, and a check keyed on a branch + // name finds no branch and therefore nothing to protect. + let world = Clone::new(); + let detached = world.worktree("agent-detached"); + std::fs::write(detached.join("work.md"), "committed on no branch\n").expect("a file"); + commit(&detached, "ahead"); + run_git(&detached, &["checkout", "--detach"]); + world.containerise(); + + let found = world.plan(); + + assert!(removing(&found).is_empty(), "{:?}", removing(&found)); + assert!(matches!( + kept_because(&found, &detached), + WorktreeKept::Objected(_) + )); +} + +#[test] +fn a_detached_head_on_a_commit_the_cache_has_is_collectable() { + // Detached is not by itself a reason to keep anything: the question is whether + // what it points at is anywhere else. + let world = Clone::new(); + let detached = world.worktree("agent-detached"); + run_git(&detached, &["checkout", "--detach"]); + world.containerise(); + + let found = world.plan(); + + assert_eq!(removing(&found), [detached]); +} + +// ======================================================================= +// the stale-ref trap +// ======================================================================= + +#[test] +fn work_pushed_after_the_clone_was_cut_is_found_in_the_cache() { + // The clone's own `refs/remotes/origin/*` is as of clone time and never + // fetched again, so asking it alone reports pushed-and-merged branches as + // unpushed -- which keeps every byte forever and makes the flag the only way + // to reclaim anything. The sibling cache is the thing that gets fetched. + let world = Clone::new(); + let pushed = world.worktree("agent-pushed"); + std::fs::write(pushed.join("work.md"), "pushed after the clone was cut\n").expect("a file"); + commit(&pushed, "pushed"); + run_git(&pushed, &["push", "origin", "agent-pushed"]); + world.fetch(); + // The clone's own view of the remote goes back to what it was when the clone + // was cut. That is the state the module header describes -- a clone that is + // never fetched into, whose `refs/remotes/origin/*` is as of clone time and + // can be absent -- reached here in one step instead of by waiting for a + // container to be rebuilt. + run_git( + &world.clone, + &["update-ref", "-d", "refs/remotes/origin/agent-pushed"], + ); + world.containerise(); + + // The clone alone now cannot see it. This is the trap, asserted so the + // fixture cannot quietly stop reproducing it. + let runner = ProcessRunner::new(); + let git = Git::new(&runner); + let seen_by_the_clone = git + .unpushed_commits(&world.clone, "refs/heads/agent-pushed") + .said() + .expect("the clone answers"); + assert!( + !seen_by_the_clone.trim().is_empty(), + "the clone should still think this branch is unpushed" + ); + + let found = world.plan(); + + assert_eq!(removing(&found), [pushed]); +} + +// ======================================================================= +// nesting (55 GB in one clone) +// ======================================================================= + +#[test] +fn a_worktree_inside_a_kept_worktree_is_reclaimed_on_its_own() { + // An agent session running inside an agent worktree makes its worktrees under + // that one. Scanning only the top level is how one clone reached 55 GB. + let world = Clone::new(); + let outer = world.worktree("agent-outer"); + let inner = world.nested(&outer, "agent-inner"); + // The outer one is dirty for a reason of its own, so it stays and the scan has + // to descend into it to find the inner one. + std::fs::write(outer.join("notes.md"), "keep me\n").expect("a note"); + world.containerise(); + + let found = world.plan(); + + assert_eq!(removing(&found), [inner]); + assert_eq!(keeping(&found), [outer]); +} + +#[test] +fn a_worktree_inside_one_that_is_going_is_not_reported_twice() { + // The outer directory takes everything inside it, so reporting the inner one + // as well would count the same bytes twice and offer a directory that will not + // be there. + let world = Clone::new(); + let outer = world.worktree("agent-outer"); + world.nested(&outer, "agent-inner"); + world.containerise(); + + let found = world.plan(); + + assert_eq!(removing(&found), [outer]); +} + +// ======================================================================= +// what is and is not devlaunch's to delete +// ======================================================================= + +#[test] +fn a_plain_directory_sitting_in_the_worktrees_place_is_not_touched() { + // Confirmed by the `.git` gitfile naming an admin directory, not by the path + // shape: a directory that happens to be here is not devlaunch's to remove. + let world = Clone::new(); + let intruder = worktrees_dir(&world.clone).join("not-a-worktree"); + std::fs::create_dir_all(&intruder).expect("a plain directory"); + std::fs::write(intruder.join("a-file"), "mine\n").expect("a file"); + + let found = world + .sweep(Insistence::Insisted) + .expect("a clone with a `.claude/worktrees/` is swept"); + + assert!( + removing(&found).is_empty() && keeping(&found).is_empty(), + "nothing here is a worktree, so there is nothing to say: {found:?}" + ); + assert!(intruder.exists()); +} + +#[test] +fn a_symlink_in_the_worktrees_place_is_stepped_over() { + // Following one would walk a removal out of the cache directory `--prune` is + // scoped to, and that scoping is what makes a scratch-cache run harmless. + let world = Clone::new(); + let outside = world.tmp().join("somewhere-else"); + std::fs::create_dir_all(&outside).expect("a directory outside the cache"); + std::fs::create_dir_all(worktrees_dir(&world.clone)).expect("the worktrees directory"); + let link = worktrees_dir(&world.clone).join("agent-link"); + std::os::unix::fs::symlink(&outside, &link).expect("a symlink"); + + let found = world + .sweep(Insistence::Insisted) + .expect("a clone with a `.claude/worktrees/` is swept"); + + assert!( + removing(&found).is_empty() && keeping(&found).is_empty(), + "a symlink is not a candidate: {found:?}" + ); + assert!(outside.exists()); +} + +// ======================================================================= +// the prune-metadata guard +// ======================================================================= + +#[test] +fn metadata_is_pruned_when_nothing_was_held_back_for_what_it_holds() { + let world = Clone::new(); + world.worktree("agent-finished"); + world.containerise(); + + let found = world.plan(); + + assert!(found.metadata_may_be_pruned()); +} + +#[test] +fn metadata_is_not_pruned_while_a_worktree_is_kept_for_what_it_holds() { + // `git worktree prune` is all-or-nothing across a clone and would drop the + // registration of the dirty one too -- turning it into a forgotten directory, + // which the next run removes outright. The guard would protect it once and + // hand it over the second time. + let world = Clone::new(); + world.worktree("agent-finished"); + let dirty = world.worktree("agent-dirty"); + std::fs::write(dirty.join("notes.md"), "unsaved\n").expect("a note"); + world.containerise(); + + let found = world.plan(); + + assert_eq!(removing(&found).len(), 1); + assert!(!found.metadata_may_be_pruned()); +} + +#[test] +fn a_lock_does_not_hold_the_metadata_prune_back() { + // git skips locked registrations itself, so a locked worktree keeps its + // registration through a prune and goes on being protected by it. + let world = Clone::new(); + world.worktree("agent-finished"); + let locked = world.worktree("agent-locked"); + run_git( + &world.clone, + &["worktree", "lock", &locked.display().to_string()], + ); + world.containerise(); + + let found = world.plan(); + + assert_eq!(removing(&found).len(), 1); + assert!(found.metadata_may_be_pruned()); +} + +// ======================================================================= +// registrations with nothing behind them +// ======================================================================= + +#[test] +fn a_registration_whose_directory_is_gone_is_counted_and_frees_nothing() { + // Prunable metadata with no host bytes behind it: either a container path + // that never resolved here or a directory somebody removed by hand. + let world = Clone::new(); + let removed_by_hand = world.worktree("agent-gone"); + world.worktree("agent-here"); + world.containerise(); + std::fs::remove_dir_all(&removed_by_hand).expect("a directory removed by hand"); + + let found = world.plan(); + + assert_eq!(found.registrations_with_nothing_here(), 1); + assert_eq!(removing(&found).len(), 1, "{:?}", removing(&found)); +} + +// ======================================================================= +// the porcelain parse +// ======================================================================= + +#[test] +fn the_clones_own_entry_and_worktrees_elsewhere_are_not_candidates() { + // Only registrations under a `.claude/worktrees/` have a directory here to be + // joined to; the clone's own entry is not one of them. + let parsed = registrations( + "worktree /cache/repos/o/r/ws-one\n\ + HEAD 1111111111111111111111111111111111111111\n\ + branch refs/heads/main\n\ + \n\ + worktree /somewhere/else/entirely\n\ + HEAD 2222222222222222222222222222222222222222\n\ + branch refs/heads/other\n\ + \n\ + worktree /workspaces/ws/.claude/worktrees/agent-a\n\ + HEAD 3333333333333333333333333333333333333333\n\ + branch refs/heads/task\n\ + prunable gitdir file points to non-existent location\n", + ); + + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].inside, ".claude/worktrees/agent-a"); + assert!(parsed[0].prunable); +} + +#[test] +fn a_nested_registration_keeps_its_whole_place_inside_the_clone() { + // The join key has to carry the nesting, or a nested worktree and a top-level + // one of the same leaf name would be joined to each other. + let parsed = registrations( + "worktree /workspaces/ws/.claude/worktrees/outer/.claude/worktrees/inner\n\ + HEAD 4444444444444444444444444444444444444444\n\ + detached\n\ + locked\n", + ); + + assert_eq!(parsed.len(), 1); + assert_eq!( + parsed[0].inside, + ".claude/worktrees/outer/.claude/worktrees/inner" + ); + assert!(matches!(parsed[0].head, WorktreeHead::Detached { .. })); + assert_eq!(parsed[0].locked, Some(Lock { reason: None })); +} + +#[test] +fn a_lock_reason_is_carried_and_an_absent_one_is_absent() { + let parsed = registrations( + "worktree /workspaces/ws/.claude/worktrees/agent-a\n\ + HEAD 5555555555555555555555555555555555555555\n\ + branch refs/heads/task\n\ + locked an agent is working in here\n", + ); + + assert_eq!( + parsed[0].locked, + Some(Lock { + reason: Some("an agent is working in here".to_owned()) + }) + ); +} + +// ======================================================================= +// the decision is total +// ======================================================================= + +#[test] +fn a_worktree_git_holds_is_never_promoted_by_insisting() { + // `--force-worktrees` is not a general override: git saying a worktree is + // live is not a refusal for a person to insist past. + let held = WorktreeStatus::Held { + head: WorktreeHead::Branch { + reference: "refs/heads/task".to_owned(), + commit: "6666666666666666666666666666666666666666".to_owned(), + }, + }; + + assert!(matches!( + decide(held, Insistence::Insisted), + WorktreeDecision::Keep(WorktreeKept::StillHeld { .. }) + )); +} + +#[test] +fn a_lock_and_what_it_holds_are_both_reported() { + // A locked worktree that is also dirty has two things wrong with it, and a + // report naming one would be telling half the truth. + let status = WorktreeStatus::Locked { + lock: Lock { reason: None }, + head: WorktreeHead::Branch { + reference: "refs/heads/task".to_owned(), + commit: "7777777777777777777777777777777777777777".to_owned(), + }, + holds: Unsaved::WouldLose( + Losses::of([Loss::Uncommitted( + NonEmpty::of(["?? notes.md".to_owned()]).expect("one line"), + )]) + .expect("one loss"), + ), + usage: DiskUsage::measured(4096), + }; + + let WorktreeDecision::Keep(WorktreeKept::Objected(objections)) = + decide(status, Insistence::NotInsisted) + else { + panic!("expected two objections"); + }; + + assert_eq!(objections.len(), 2); +} diff --git a/rust/devlaunch-core/src/flows/mod.rs b/rust/devlaunch-core/src/flows/mod.rs index 3be471d1..c6c33d86 100644 --- a/rust/devlaunch-core/src/flows/mod.rs +++ b/rust/devlaunch-core/src/flows/mod.rs @@ -1,3 +1,5 @@ +// binary surface — not part of the frozen wf API (#251 §7) +pub mod agent_worktrees; // binary surface — not part of the frozen wf API (#251 §7): the branch decision's // refusal travels inside a launch refusal, and the words for it are the binary's. pub mod branch_manager; From bf3f70d8491db1df76ed3e861d339205599da208 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Tue, 25 Aug 2026 11:56:56 +0000 Subject: [PATCH 2/7] Reclaim agent worktrees in the clones --prune keeps The sweep goes into the plan the user already answers, so there is one dry run and not two. Its bytes are counted only for clones the run is keeping, because a clone that is going already accounts for everything inside it. --force-worktrees is its own flag. --force already means 'past a clone holding work nowhere else' and people type it; letting it reach a locked or dirty worktree would widen it into permission to remove one somebody may be working in. The acting pass removes the directory and only then runs git worktree prune, so an interrupted run leaves the prunable state the next run already handles. It re-classifies every directory immediately before removing it: a container running git worktree add is not a participant in devlaunch's repo lock. --- .../src/flows/agent_worktrees.rs | 211 +++++++++++- rust/devlaunch-core/src/flows/lifecycle.rs | 318 +++++++++++++++++- rust/dl/src/cli.rs | 65 +++- rust/dl/src/commands.rs | 43 ++- rust/dl/src/render.rs | 174 ++++++++++ rust/dl/tests/completion_tables.rs | 6 +- rust/dl/tests/lifecycle.rs | 122 +++++++ rust/dl/tests/lifecycle_scenario.py | 32 ++ 8 files changed, 926 insertions(+), 45 deletions(-) diff --git a/rust/devlaunch-core/src/flows/agent_worktrees.rs b/rust/devlaunch-core/src/flows/agent_worktrees.rs index 187e1e09..567184e3 100644 --- a/rust/devlaunch-core/src/flows/agent_worktrees.rs +++ b/rust/devlaunch-core/src/flows/agent_worktrees.rs @@ -85,6 +85,7 @@ use crate::clients::git::Git; use crate::domain::workspace_state::{CouldNotTell, Loss, Losses, NonEmpty, Unsaved}; use crate::flows::disk_usage::{self, DiskUsage}; use crate::flows::lifecycle::{Insistence, Objection, objection}; +use crate::flows::repo_manager::{Refusal, Removal, remove_tree_as_far_as_it_goes}; /// The directory an agent harness puts its worktrees in, relative to a clone. const WORKTREES_DIR: [&str; 2] = [".claude", "worktrees"]; @@ -733,13 +734,69 @@ impl WorktreeSweep { self.clones.is_empty() } - fn record(&mut self, found: Option) { + pub(crate) fn record(&mut self, found: Option) { if let Some(found) = found.filter(|it| !it.nothing_to_say()) { self.clones.push(found); } } } +/// What git says about one clone's worktrees, read once. +/// +/// One `git worktree list` per clone rather than one per candidate, and the +/// reason it is a value rather than a parameter list: the acting pass has to +/// classify each directory *again* immediately before removing it, and it must +/// re-read git to do that. Sharing this type is what keeps the two passes asking +/// the same question in the same words. +pub(crate) struct ClonePicture { + registered: Vec, +} + +impl ClonePicture { + /// What git says about `clone`, or nothing when git will not say. + /// + /// A refusal takes the whole clone out of the sweep. Reading one as "git named + /// no registrations" would classify every directory as forgotten, and + /// forgotten is the arm that deletes. + pub(crate) fn of(git: &Git<'_>, clone: &Path) -> Option { + let listing = git.worktree_listing(clone).said()?; + Some(Self { + registered: registrations(&listing), + }) + } + + /// Which arm `directory` is, or nothing when it is not a linked worktree of + /// this clone at all. + pub(crate) fn status_of( + &self, + git: &Git<'_>, + clone: &Path, + bare: Option<&Path>, + directory: &Path, + ) -> Option { + let inside = inside_the_clone(clone, directory)?; + let name = linked_worktree_name(directory)?; + let registration = self.registered.iter().find(|it| it.inside == inside); + let admin = admin_dir(clone, &name); + Some(worktree_status( + git, + clone, + bare, + directory, + admin.as_deref(), + registration, + )) + } + + /// Registrations under a `.claude/worktrees/` with no directory in `clone`. + fn registrations_with_nothing_here(&self, clone: &Path) -> usize { + self.registered + .iter() + .filter(|registration| !clone.join(®istration.inside).exists()) + .count() + } +} + /// Classify the agent worktrees inside one clone. /// /// `None` when there is no `.claude/worktrees/` at all, which is the answer for @@ -757,26 +814,16 @@ pub(crate) fn sweep_clone( insistence: Insistence, ) -> Option { let mut pending = children_of(&worktrees_dir(clone))?; - // A git that will not answer takes the whole clone out of the sweep. Reading - // a refusal as "git named no registrations" would classify every directory as - // forgotten, and forgotten is the arm that deletes. - let listing = git.worktree_listing(clone).said()?; - let registered = registrations(&listing); + let picture = ClonePicture::of(git, clone)?; let mut removing = Vec::new(); let mut keeping = Vec::new(); while let Some(directory) = pending.pop() { - let (Some(inside), Some(name)) = ( - inside_the_clone(clone, &directory), - linked_worktree_name(&directory), - ) else { + let Some(status) = picture.status_of(git, clone, bare, &directory) else { // Not a linked worktree: a plain directory that happens to sit here, // or one whose `.git` says something else. Not devlaunch's to remove // and not descended into either. continue; }; - let registration = registered.iter().find(|it| it.inside == inside); - let admin = admin_dir(clone, &name); - let status = worktree_status(git, clone, bare, &directory, admin.as_deref(), registration); match decide(status, insistence) { WorktreeDecision::Remove { seen_as, @@ -809,10 +856,7 @@ pub(crate) fn sweep_clone( .then_with(|| left.path.cmp(&right.path)) }); keeping.sort_by(|left, right| left.path.cmp(&right.path)); - let registrations_with_nothing_here = registered - .iter() - .filter(|registration| !clone.join(®istration.inside).exists()) - .count(); + let registrations_with_nothing_here = picture.registrations_with_nothing_here(clone); Some(CloneWorktrees { clone: clone.to_path_buf(), owner: owner.to_owned(), @@ -855,5 +899,138 @@ fn children_of(root: &Path) -> Option> { Some(found) } +// =========================================================================== +// the acting pass +// =========================================================================== + +/// One worktree directory the plan meant to remove that the acting pass would +/// not. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WithheldWorktree { + pub path: PathBuf, + /// Why it is staying — and it is worth saying this was not so when the plan + /// was printed. + pub because: WorktreeKept, +} + +/// What the acting pass did about the agent worktrees. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct WorktreeReport { + pub removed: Vec, + pub withheld: Vec, + /// Directories that would not come away. Not empty means the run is + /// unfinished, and what did go is still gone. + pub refused: Vec, + /// Clones whose `git worktree prune` was held back, because something in + /// them is being kept for what it holds and the registration is what keeps on + /// protecting it. Named rather than counted: it is the one line that explains + /// why git still lists a worktree whose directory is not there. + pub metadata_held_back: Vec, + /// Clones where the prune ran and would not. + pub metadata_refused: Vec, +} + +impl WorktreeReport { + /// What this run actually freed, with the figures the plan measured, so what + /// somebody is told they got back is what they said yes to. + pub fn freed(&self) -> DiskUsage { + disk_usage::total_usage(self.removed.iter().map(|it| it.usage.clone())) + } + + pub fn nothing_to_say(&self) -> bool { + self.removed.is_empty() + && self.withheld.is_empty() + && self.refused.is_empty() + && self.metadata_held_back.is_empty() + && self.metadata_refused.is_empty() + } +} + +/// Carry out one clone's share of the sweep, and add what happened to `report`. +/// +/// The caller holds the repository lock. **Every directory is classified again, +/// under that lock, immediately before it goes**, and only what this pass *also* +/// finds removable is removed. The lock is not enough on its own and cannot be +/// made enough: a container running `git worktree add` is not a participant in +/// it, so the plan a person answered can have been overtaken by a worktree that +/// is now registered and live. The approved set can therefore shrink between the +/// report and the act and can never grow, which is the direction that costs a +/// command rather than somebody's afternoon. +/// +/// **The directory goes first and `git worktree prune` follows.** Interrupted +/// between the two, git is left holding a registration whose directory is gone — +/// which is exactly the prunable state the next run already handles, so the run +/// heals itself. The other order leaves a registered, present worktree with its +/// metadata dropped, which nothing recognises and the next run removes outright. +pub(crate) fn reclaim( + git: &Git<'_>, + clone: &CloneWorktrees, + bare: Option<&Path>, + report: &mut WorktreeReport, +) { + let Some(picture) = ClonePicture::of(git, &clone.clone) else { + // git will not say what it holds any more, so nothing here is removable: + // the classification the plan rests on cannot be re-taken. + report + .withheld + .extend(clone.removing.iter().map(|worktree| WithheldWorktree { + path: worktree.path.clone(), + because: WorktreeKept::Objected(NonEmpty::one(WorktreeObjection::Holds( + Objection::CouldNotTell(CouldNotTell::GitCouldNotRead { + clone: clone.clone.clone(), + reason: + "git would not list this clone's worktrees a second time".to_owned(), + }), + ))), + })); + return; + }; + let mut removed_anything = false; + for worktree in &clone.removing { + let status = picture.status_of(git, &clone.clone, bare, &worktree.path); + let decision = match status { + // The directory is no longer a linked worktree of this clone — it was + // removed by hand, or something else is there now. Either way this + // pass has nothing it can say is safe to delete. + None => WorktreeDecision::Keep(WorktreeKept::Objected(NonEmpty::one( + WorktreeObjection::Holds(Objection::CouldNotTell(CouldNotTell::CouldNotLook { + clone: worktree.path.clone(), + error: "this is no longer a linked worktree of the clone".to_owned(), + })), + ))), + Some(status) => decide(status, worktree.promotion.insistence()), + }; + match decision { + WorktreeDecision::Keep(because) => { + report.withheld.push(WithheldWorktree { + path: worktree.path.clone(), + because, + }); + } + WorktreeDecision::Remove { .. } => { + match remove_tree_as_far_as_it_goes(&worktree.path) { + Removal::Everything => { + removed_anything = true; + report.removed.push(worktree.clone()); + } + Removal::WhatItCould(refused) | Removal::Nothing(refused) => { + report.refused.extend(refused.iter().cloned()); + } + } + } + } + } + if !removed_anything { + return; + } + if !clone.metadata_may_be_pruned() { + report.metadata_held_back.push(clone.clone.clone()); + return; + } + if git.worktree_prune(&clone.clone).said().is_none() { + report.metadata_refused.push(clone.clone.clone()); + } +} + #[cfg(test)] mod tests; diff --git a/rust/devlaunch-core/src/flows/lifecycle.rs b/rust/devlaunch-core/src/flows/lifecycle.rs index 0d77b8c7..080e0e05 100644 --- a/rust/devlaunch-core/src/flows/lifecycle.rs +++ b/rust/devlaunch-core/src/flows/lifecycle.rs @@ -75,6 +75,7 @@ use crate::domain::locks::{self, LockError}; use crate::domain::metadata::{self, MetadataStorage, WorktreeFilter}; use crate::domain::model::WorktreeInfo; use crate::domain::workspace_state::{self, CouldNotTell, Losses, NonEmpty, Unsaved}; +use crate::flows::agent_worktrees::{self, WorktreeReport, WorktreeSweep}; use crate::flows::completion_cache; use crate::flows::disk_usage::{self, DiskUsage}; use crate::flows::listing::{ @@ -759,6 +760,32 @@ pub enum Insistence { NotInsisted, } +/// What one `dl --prune` was told to go ahead despite, and which flag said it. +/// +/// One value with named fields rather than two [`Insistence`] parameters side by +/// side, because they answer different hazards and a caller could not be stopped +/// from swapping them. The swap in the dangerous direction is `--force` reaching +/// the worktree sweep, which would quietly widen a flag people already type from +/// "past a clone holding work nowhere else" to "past a locked worktree somebody +/// may be working in". +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Insisted { + /// `--force`. + pub clones: Insistence, + /// `--force-worktrees`. + pub worktrees: Insistence, +} + +impl Insisted { + /// Nothing insisted on: what a plain `dl --prune` means. + pub fn nothing() -> Self { + Self { + clones: Insistence::NotInsisted, + worktrees: Insistence::NotInsisted, + } + } +} + /// Why `dl rm` will not delete this workspace. /// /// Two arms and no third, because [`Unsaved`] has three and one of them is @@ -2024,17 +2051,27 @@ pub struct PrunePlan { keeping: Vec, /// Worktree records whose directory is definitively not there any more. stale_records: Vec, + /// The agent git worktrees inside the clones this run is *keeping* + /// (devlaunch#426). Only the kept ones, which is what stops their bytes being + /// counted twice: a clone this run removes already accounts for everything + /// inside it. + worktrees: WorktreeSweep, } impl PrunePlan { /// Whether this run would change nothing at all. pub fn nothing_to_do(&self) -> bool { - self.removing.is_empty() && self.stale_records.is_empty() + self.removing.is_empty() && self.stale_records.is_empty() && self.worktrees.nothing_to_do() } /// What the whole run would free. pub fn freed(&self) -> DiskUsage { - disk_usage::total_usage(self.removing.iter().map(|it| it.usage.clone())) + disk_usage::total_usage( + self.removing + .iter() + .map(|it| it.usage.clone()) + .chain(std::iter::once(self.worktrees.freed())), + ) } /// The directory the plan's candidates were scanned under. @@ -2056,6 +2093,11 @@ impl PrunePlan { pub fn stale_records(&self) -> &[WorktreeInfo] { &self.stale_records } + + /// The agent git worktrees inside the clones this run is keeping. + pub fn worktrees(&self) -> &WorktreeSweep { + &self.worktrees + } } /// The directory `--prune` scans, canonicalised once. @@ -2136,12 +2178,13 @@ pub fn prune_plan( storage: &MetadataStorage, workspaces: &[Workspace], placement: &ClonePlacement, - insistence: Insistence, + insisted: Insisted, notices: &mut dyn Notices, ) -> Result { let ClonePlacement { root, locations } = placement; let mut removing: Vec = Vec::new(); let mut keeping: Vec = Vec::new(); + let mut worktrees = WorktreeSweep::default(); let mut cache_notices = Vec::new(); let record_for = records_by_directory(clones, storage, &mut cache_notices); let listed_at = sources_by_workspace(workspaces); @@ -2179,7 +2222,7 @@ pub fn prune_plan( &record_for, &listed_at, ); - match decide(status, insistence) { + match decide(status, insisted.clones) { Decision::Remove { usage, promotion } => removing.push(Reclaimable { path: clone, owner: owner.clone(), @@ -2187,10 +2230,24 @@ pub fn prune_plan( usage, promotion, }), - Decision::Keep(because) => keeping.push(Kept { - path: clone, - because, - }), + Decision::Keep(because) => { + // The agent worktrees inside a clone are swept only where + // the clone itself is staying, and the sweep runs here, + // under the same repository lock the classification was + // taken under. + worktrees.record(agent_worktrees::sweep_clone( + &git, + &clone, + &owner, + &repo, + bare.as_deref(), + insisted.worktrees, + )); + keeping.push(Kept { + path: clone, + because, + }); + } } } } @@ -2209,6 +2266,7 @@ pub fn prune_plan( removing, keeping, stale_records, + worktrees, }) } @@ -2312,6 +2370,8 @@ pub struct PruneReport { /// unfinished, and the clones that *did* go are still gone — which is why this /// is a report and not an abort. pub refused: Vec, + /// What the run did about the agent worktrees inside the clones it kept. + pub worktrees: WorktreeReport, } impl PruneReport { @@ -2319,11 +2379,18 @@ impl PruneReport { /// figures the plan measured, so what a person is told they got back is what /// they said yes to. pub fn freed(&self) -> DiskUsage { - disk_usage::total_usage(self.removed.iter().map(|it| it.usage.clone())) + disk_usage::total_usage( + self.removed + .iter() + .map(|it| it.usage.clone()) + .chain(std::iter::once(self.worktrees.freed())), + ) } pub fn finished(&self) -> bool { self.refused.is_empty() + && self.worktrees.refused.is_empty() + && self.worktrees.metadata_refused.is_empty() } } @@ -2390,6 +2457,7 @@ pub fn prune_clones( removed: Vec::new(), withheld: Vec::new(), refused: Vec::new(), + worktrees: WorktreeReport::default(), }; let mut forget: Vec = Vec::new(); for ((owner, repo), reclaimables) in by_repo { @@ -2434,6 +2502,24 @@ pub fn prune_clones( } } } + // The agent worktrees inside the clones this run is keeping. A second pass + // over a disjoint set of directories — the sweep only ever covers clones the + // plan keeps, and this loop only ever removes things inside them — so it takes + // each repository's lock again rather than sharing the loop above, which is + // holding a lock for as short a time as the work needs. + for found in plan.worktrees.clones() { + let _lock = clones + .repo_manager() + .hold_repo_lock(found.owner(), found.repo()) + .map_err(PruneError::Lock)?; + let bare = canonical( + &clones + .repo_manager() + .bare_dir(found.owner(), found.repo()) + .to_string_lossy(), + ); + agent_worktrees::reclaim(&git, found, bare.as_deref(), &mut report.worktrees); + } // Outside every repo lock, because the repo lock is what protects the // *directory* work and a record drop touches only `metadata.json`, which has a // lock of its own. Keeping it out means a repository is held for exactly as @@ -3662,6 +3748,17 @@ pub(crate) mod tests { /// The plan `--prune` would print. fn plan_for(world: &World, insistence: Insistence) -> PrunePlan { + plan_insisting( + world, + Insisted { + clones: insistence, + worktrees: Insistence::NotInsisted, + }, + ) + } + + /// The plan, with both insistences named. + fn plan_insisting(world: &World, insisted: Insisted) -> PrunePlan { let clones = clones_for(&world.repos_dir, &world.devpod); let mut context = CommandContext::new(&world.devpod); let workspaces = context.workspaces().expect("a listing"); @@ -3671,7 +3768,7 @@ pub(crate) mod tests { &world.storage, &workspaces, &placement, - insistence, + insisted, &mut ignoring(), ) .expect("a plan") @@ -6713,7 +6810,7 @@ pub(crate) mod tests { &storage, &workspaces, &placement, - Insistence::NotInsisted, + Insisted::nothing(), &mut ignoring(), ) .expect("a plan"); @@ -7100,6 +7197,205 @@ pub(crate) mod tests { ); } + // ======================================================================= + // agent worktrees inside the clones a prune keeps (devlaunch#426) + // ======================================================================= + + /// One real agent git worktree inside `clone`, on its own pushed branch. + /// + /// The directory the harness makes, made the way the harness makes it, because + /// the classification is git's own `worktree list` and a stub would report no + /// registrations at all -- which reads as "git has forgotten these", the answer + /// that deletes. + fn an_agent_worktree(clone: &Path, leaf: &str) -> PathBuf { + let path = clone.join(".claude").join("worktrees").join(leaf); + std::fs::create_dir_all(path.parent().expect("a parent")).expect("the worktrees directory"); + run_git( + clone, + &["worktree", "add", "-b", leaf, &path.display().to_string()], + ); + run_git(clone, &["push", "-u", "origin", leaf]); + path + } + + /// Rewrite `clone`'s worktree registrations to the container paths they really + /// carry, which is what a host sees and what makes git call them prunable. + fn as_a_host_sees_them(clone: &Path) { + let admin = clone.join(".git").join("worktrees"); + for entry in std::fs::read_dir(&admin).expect("the admin directory") { + let gitdir = entry.expect("an admin entry").path().join("gitdir"); + let registered = std::fs::read_to_string(&gitdir).expect("a gitdir file"); + std::fs::write( + &gitdir, + registered.replace(&clone.display().to_string(), "/workspaces/a-container"), + ) + .expect("the rewritten gitdir"); + } + } + + /// Put one registration back where it resolves, which is what a container + /// running `git worktree add` does to a plan that is already on screen. + fn registered_again(clone: &Path, leaf: &str) { + let gitdir = clone + .join(".git") + .join("worktrees") + .join(leaf) + .join("gitdir"); + let registered = std::fs::read_to_string(&gitdir).expect("a gitdir file"); + std::fs::write( + &gitdir, + registered.replace("/workspaces/a-container", &clone.display().to_string()), + ) + .expect("the restored gitdir"); + } + + /// A live workspace whose clone holds one collectable agent worktree. + fn a_live_clone_with_an_agent_worktree() -> (World, PathBuf, PathBuf) { + let mut world = World::empty(); + let clone = world.clone_at("r-live-aa", "live"); + world.record("r-live-aa", "live", &clone); + let worktree = an_agent_worktree(&clone, "agent-one"); + as_a_host_sees_them(&clone); + world.devpod.lists(&[listed("live", &clone)]); + (world, clone, worktree) + } + + #[test] + fn the_plan_reaches_inside_a_clone_it_is_keeping() { + // The whole of devlaunch#426: every one of the 72 directories measured was + // inside a clone belonging to a *live* workspace, so the orphan rule not + // only missed them, it must never fire on them. + let (world, clone, worktree) = a_live_clone_with_an_agent_worktree(); + + let plan = plan_for(&world, Insistence::NotInsisted); + + assert!( + removing(&plan).is_empty(), + "the clone itself is staying: {:?}", + removing(&plan) + ); + assert!(matches!( + kept_because(&plan, &clone), + KeptBecause::StillOpened { .. } + )); + assert_eq!(plan.worktrees().removing(), 1); + assert_eq!( + plan.worktrees().clones()[0].removing()[0].path, + worktree, + "{:?}", + plan.worktrees() + ); + assert!( + !plan.nothing_to_do(), + "a plan with worktrees to reclaim has something to do" + ); + } + + #[test] + fn a_worktree_inside_a_clone_that_is_going_is_not_swept_separately() { + // Its bytes are already in the clone's own figure, so sweeping it would + // count them twice and offer a directory that will not be there. + let world = World::empty(); + let orphan = world.clone_at("r-orphan-aa", "orphan"); + an_agent_worktree(&orphan, "agent-one"); + as_a_host_sees_them(&orphan); + world.devpod.lists(&[]); + + // `--force` is what carries the clone itself past the objection the + // worktrees put in its way: `.claude/` is untracked, so the clone-level + // guard sees uncommitted work and says so. That guard is right to count + // them -- removing the clone destroys whatever they hold -- so the + // insistence is the fixture, not a workaround. + let plan = plan_for(&world, Insistence::Insisted); + + assert_eq!(removing(&plan), [orphan]); + assert!(plan.worktrees().nothing_to_say(), "{:?}", plan.worktrees()); + } + + #[test] + fn the_acting_pass_removes_the_worktree_and_drops_its_registration() { + let (mut world, clone, worktree) = a_live_clone_with_an_agent_worktree(); + let plan = plan_for(&world, Insistence::NotInsisted); + let clones = clones_for(&world.repos_dir, &world.devpod); + let mut context = CommandContext::new(&world.devpod); + + let outcome = prune_clones( + &mut context, + &clones, + &mut world.storage, + &plan, + &mut ignoring(), + ) + .expect("the pass ran"); + + let PruneOutcome::Acted(report) = &outcome else { + panic!("expected the pass to act, got {outcome:?}"); + }; + assert!(report.finished()); + assert_eq!(report.worktrees.removed.len(), 1); + assert!(!worktree.exists()); + assert!(clone.exists(), "the clone itself is untouched"); + // The metadata went with it, so git does not go on listing a worktree that + // is not there. + let listing = run_git(&clone, &["worktree", "list", "--porcelain"]); + assert!( + !listing.contains("agent-one"), + "git still lists it: {listing}" + ); + } + + #[test] + fn a_worktree_registered_again_while_the_question_was_open_is_left_alone() { + // A container running `git worktree add` is not a participant in + // devlaunch's repository lock, so the plan can be overtaken. The approved + // set has to be able to shrink between the report and the act. + let (mut world, _clone, worktree) = a_live_clone_with_an_agent_worktree(); + let plan = plan_for(&world, Insistence::NotInsisted); + assert_eq!(plan.worktrees().removing(), 1); + registered_again(&world.repo_dir.join("r-live-aa"), "agent-one"); + + let clones = clones_for(&world.repos_dir, &world.devpod); + let mut context = CommandContext::new(&world.devpod); + let outcome = prune_clones( + &mut context, + &clones, + &mut world.storage, + &plan, + &mut ignoring(), + ) + .expect("the pass ran"); + + let PruneOutcome::Acted(report) = &outcome else { + panic!("expected the pass to act, got {outcome:?}"); + }; + assert!(report.worktrees.removed.is_empty()); + assert_eq!(report.worktrees.withheld.len(), 1); + assert!(worktree.exists(), "it is registered and live again"); + } + + #[test] + fn what_a_worktree_holds_is_reported_and_kept_until_the_flag_says_otherwise() { + let (world, clone, worktree) = a_live_clone_with_an_agent_worktree(); + std::fs::write(worktree.join("notes.md"), "an afternoon\n").expect("a note"); + + let kept = plan_for(&world, Insistence::Insisted); + assert_eq!( + kept.worktrees().removing(), + 0, + "--force is not --force-worktrees" + ); + assert_eq!(kept.worktrees().keeping(), 1); + + let removed = plan_insisting( + &world, + Insisted { + clones: Insistence::NotInsisted, + worktrees: Insistence::Insisted, + }, + ); + assert_eq!(removed.worktrees().removing(), 1); + assert!(clone.exists()); + } // ======================================================================= // reconcile (devlaunch#88) // ======================================================================= diff --git a/rust/dl/src/cli.rs b/rust/dl/src/cli.rs index 1da78197..53ab39db 100644 --- a/rust/dl/src/cli.rs +++ b/rust/dl/src/cli.rs @@ -287,8 +287,12 @@ pub(crate) enum Command { Refresh, /// `dl --install []` Install { rc: Option }, - /// `dl --prune [-y] [--force]` - Prune { yes: bool, force: bool }, + /// `dl --prune [-y] [--force] [--force-worktrees]` + Prune { + yes: bool, + force: bool, + force_worktrees: bool, + }, /// `dl --reconcile [-y]` Reconcile { yes: bool }, /// `dl --purge [-y]` @@ -472,6 +476,10 @@ pub(crate) struct Cli { /// regardless of the cache's age (`--update-cache`). #[arg(long)] force: bool, + /// With `--prune`: remove agent git worktrees that are locked, dirty, or hold + /// commits nothing else reaches. Reported and left alone without it. + #[arg(long = "force-worktrees")] + force_worktrees: bool, /// Use a non-default devcontainer.json. A bare name means /// `.devcontainer//devcontainer.json`. Stored with the workspace, so /// pass it once. @@ -700,6 +708,17 @@ fn global_command(cli: &Cli, chosen: Chosen) -> Result { command: name, }); } + // Its own flag rather than a second meaning for `--force`, and refused + // everywhere else for the same reason: `--force` is a word people already + // type, and letting it reach the worktree sweep would widen it from "past a + // clone holding work nowhere else" to "past a worktree somebody may be + // working in". + if cli.force_worktrees && !matches!(chosen, Chosen::Prune) { + return Err(GrammarError::ModifierNotAllowed { + modifier: "--force-worktrees", + command: name, + }); + } Ok(match chosen { Chosen::Ls => Command::List { output: if cli.json { @@ -720,6 +739,7 @@ fn global_command(cli: &Cli, chosen: Chosen) -> Result { Chosen::Prune => Command::Prune { yes: cli.yes, force: cli.force, + force_worktrees: cli.force_worktrees, }, Chosen::Reconcile => Command::Reconcile { yes: cli.yes }, Chosen::Purge => Command::Purge { yes: cli.yes }, @@ -754,6 +774,12 @@ fn workspace_command(cli: Cli, argv: &[String]) -> Result command: "a workspace command", }); } + if cli.force_worktrees { + return Err(GrammarError::ModifierNotAllowed { + modifier: "--force-worktrees", + command: "a workspace command", + }); + } // Before `force_placement`, and that ordering is the whole of the fix it is. // `--force`'s meaning is recovered from its *position* in the word stream, and // `--rm` is a word in that stream — so `dl --rm --force` reads `--force` as @@ -1116,14 +1142,45 @@ mod tests { parse(&["--prune"]), Ok(Command::Prune { yes: false, - force: false + force: false, + force_worktrees: false }) ); assert_eq!( parse(&["--prune", "-y", "--force"]), Ok(Command::Prune { yes: true, - force: true + force: true, + force_worktrees: false + }) + ); + } + + #[test] + fn force_worktrees_is_its_own_flag_and_only_the_prune_takes_it() { + // Not a second meaning for `--force`: that flag already means "past work + // that is nowhere else" and people type it, so letting it reach the agent + // worktrees would widen it into "past a worktree somebody may be in". + assert_eq!( + parse(&["--prune", "--force-worktrees"]), + Ok(Command::Prune { + yes: false, + force: false, + force_worktrees: true + }) + ); + assert_eq!( + parse(&["--ls", "--force-worktrees"]), + Err(GrammarError::ModifierNotAllowed { + modifier: "--force-worktrees", + command: "--ls" + }) + ); + assert_eq!( + parse(&["ws", "--force-worktrees"]), + Err(GrammarError::ModifierNotAllowed { + modifier: "--force-worktrees", + command: "a workspace command" }) ); } diff --git a/rust/dl/src/commands.rs b/rust/dl/src/commands.rs index 52b07493..0a52e4fd 100644 --- a/rust/dl/src/commands.rs +++ b/rust/dl/src/commands.rs @@ -15,8 +15,8 @@ use devlaunch_core::flows::completion::{self, FileState, InstallError, Installed use devlaunch_core::flows::completion_cache::{self, Refreshed}; use devlaunch_core::flows::launch::LaunchNotice; use devlaunch_core::flows::lifecycle::{ - self, ChildWork, DeleteOutcome, Guarded, Insistence, LifecycleNotice, PruneError, PruneOutcome, - Refresh, RefreshReason, StopOutcome, + self, ChildWork, DeleteOutcome, Guarded, Insisted, Insistence, LifecycleNotice, PruneError, + PruneOutcome, Refresh, RefreshReason, StopOutcome, }; use devlaunch_core::flows::listing::{self, CommandContext, DlView, Sizes}; use devlaunch_core::flows::repo_manager::CacheNotice; @@ -103,7 +103,11 @@ pub(crate) fn dispatch( Command::UpdateCache { force } => render_update_cache(runner, &mut context, cache, force), Command::Refresh => render_refresh(&mut context, cache), Command::Install { rc } => render_install(&mut context, cache, rc.as_deref()), - Command::Prune { yes, force } => render_prune(runner, &mut context, yes, force), + Command::Prune { + yes, + force, + force_worktrees, + } => render_prune(runner, &mut context, yes, insisted(force, force_worktrees)), Command::Reconcile { yes } => render_reconcile(runner, &mut context, refresh, yes), Command::Purge { yes } => render_purge(&mut context, cache, yes), Command::Select { verb, devcontainer } => render_select( @@ -857,16 +861,36 @@ fn render_prune( runner: &dyn Runner, context: &mut CommandContext<'_>, yes: bool, - force: bool, + insisted: Insisted, ) -> Ending { - prune_clone_directories(runner, context, yes, force).with_the_boundary() + prune_clone_directories(runner, context, yes, insisted).with_the_boundary() +} + +/// What the two insistence flags mean, in one value. +/// +/// Built here rather than passed as two booleans, so the pair cannot be handed +/// over the wrong way round: they answer different hazards and `--force` reaching +/// the worktree sweep would widen a flag people already type. +fn insisted(force: bool, force_worktrees: bool) -> Insisted { + Insisted { + clones: insistence(force), + worktrees: insistence(force_worktrees), + } +} + +fn insistence(insisted: bool) -> Insistence { + if insisted { + Insistence::Insisted + } else { + Insistence::NotInsisted + } } fn prune_clone_directories( runner: &dyn Runner, context: &mut CommandContext<'_>, yes: bool, - force: bool, + insisted: Insisted, ) -> Cleanup { let mut records = match session::open_records(runner) { Err(refused) => return Cleanup::Raised(refuse_startup(&refused)), @@ -887,18 +911,13 @@ fn prune_clone_directories( )); return Cleanup::Ended(Ending::Refused); } - let insistence = if force { - Insistence::Insisted - } else { - Insistence::NotInsisted - }; let mut notices: Vec = Vec::new(); let plan = match lifecycle::prune_plan( &records.clones, &records.storage, &workspaces, &placement, - insistence, + insisted, &mut notices, ) { Err(refused) => return Cleanup::Raised(refuse_prune(&refused)), diff --git a/rust/dl/src/render.rs b/rust/dl/src/render.rs index 9e05d72a..6b763020 100644 --- a/rust/dl/src/render.rs +++ b/rust/dl/src/render.rs @@ -18,6 +18,9 @@ use devlaunch_core::domain::metadata; use devlaunch_core::domain::workspace_id::{NamePart, UnsafeName}; use devlaunch_core::domain::workspace_state::NonEmpty; use devlaunch_core::domain::xdg; +use devlaunch_core::flows::agent_worktrees::{ + SeenAs, WorktreeKept, WorktreeObjection, WorktreePromotion, WorktreeReport, WorktreeSweep, +}; use devlaunch_core::flows::branch_manager::BranchError; use devlaunch_core::flows::completion_cache::CompletionData; use devlaunch_core::flows::disk_usage::describe_usage; @@ -1396,12 +1399,130 @@ pub(crate) fn prune_plan_lines(plan: &PrunePlan) -> Vec { )); lines.push(String::new()); } + lines.extend(worktree_plan_lines(plan.worktrees())); if plan.nothing_to_do() { lines.push("Nothing to prune.".to_owned()); } lines } +/// The agent git worktrees inside the clones this run is keeping, and what each +/// of them is (devlaunch#426). +/// +/// Its own section under the clone plan rather than rows mixed into it, because +/// these are a different kind of thing: every one of them is inside a clone the +/// run has just said it is *not* touching, and the rules that reach them are +/// their own. Nothing at all is printed when there is nothing to say, which is +/// every host that has never run an agent in a workspace. +fn worktree_plan_lines(sweep: &WorktreeSweep) -> Vec { + if sweep.nothing_to_say() { + return Vec::new(); + } + let mut lines = vec![ + format!( + "Agent git worktrees inside the clones above -- {}:", + describe_usage(&sweep.freed()) + ), + String::new(), + ]; + for found in sweep.clones() { + lines.push(format!(" {}:", found.clone_path().display())); + for worktree in found.removing() { + let mut line = format!( + " - removing {} ({}): {}", + worktree.path.display(), + describe_usage(&worktree.usage), + seen_as(worktree.seen_as) + ); + // What `--force-worktrees` is answering, on the line of the directory + // it answers for. Without it the plan reads the same for a worktree + // holding an afternoon's work as for a finished one. + if let WorktreePromotion::Insisted { despite } = &worktree.promotion { + line = format!("{line}, and {}; removing anyway", objected(despite)); + } + lines.push(line); + } + for kept in found.keeping() { + lines.push(format!( + " - leaving {}: {}", + kept.path.display(), + worktree_kept_because(&kept.because) + )); + } + if found.registrations_with_nothing_here() > 0 { + lines.push(format!( + " - {} registration(s) here name no directory, so nothing is freed by \ + forgetting them", + found.registrations_with_nothing_here() + )); + } + if !found.metadata_may_be_pruned() { + lines.push( + " - git worktree prune is held back here: it is all-or-nothing across a \ + clone, and it would drop the registration that is keeping a worktree above" + .to_owned(), + ); + } + } + lines.push(String::new()); + // Said once, rather than implied by every line above it. `--prune` is a local + // command and deliberately does not fetch, so "nothing else reaches these + // commits" is a statement about the last fetch and not about the forge now. + lines.push( + "Whether a worktree's commits are anywhere else is as of the last fetch into the \ + repository cache; --prune does not fetch." + .to_owned(), + ); + lines.push(String::new()); + lines +} + +/// How git saw a directory that is going. +fn seen_as(seen: SeenAs) -> &'static str { + match seen { + SeenAs::Forgotten => "git has already forgotten it", + SeenAs::Prunable => "git says the registration for it can go", + SeenAs::Locked => "git is holding it locked", + } +} + +/// Why one worktree directory is staying, as the report says it. +/// +/// Every arm names the fact it rests on and none of them claims the worktree is +/// idle, because nothing on a host can establish that: a lock is the agent +/// harness's courtesy, and a killed session leaves one behind. +fn worktree_kept_because(because: &WorktreeKept) -> String { + match because { + WorktreeKept::StillHeld { head } => format!( + "git still holds it and does not offer it up, on {}", + head.named() + ), + WorktreeKept::Objected(objections) => format!( + "{} -- add --force-worktrees to remove it anyway", + objected(objections) + ), + } +} + +/// Everything arguing against removing one worktree, joined as one clause. +fn objected(objections: &NonEmpty) -> String { + objections + .iter() + .map(worktree_objection) + .collect::>() + .join(" and ") +} + +fn worktree_objection(objected: &WorktreeObjection) -> String { + match objected { + WorktreeObjection::Locked { lock } => match &lock.reason { + None => "git is holding it locked".to_owned(), + Some(reason) => format!("git is holding it locked ({reason})"), + }, + WorktreeObjection::Holds(holds) => format!("holds {}", objection(holds)), + } +} + /// Why one clone directory is staying, as the report says it. fn kept_because(because: &KeptBecause) -> String { match because { @@ -1488,6 +1609,59 @@ pub(crate) fn prune_report_lines(report: &PruneReport) -> Vec { &by_hand, )); } + lines.extend(worktree_report_lines(&report.worktrees)); + lines +} + +/// What the run did about the agent worktrees. +/// +/// The withheld lines say *that this was not so when the plan was printed*, which +/// is the whole of what a second classification has to tell somebody who has +/// already read the first one — and here it is not a rare race: a container is +/// not a participant in devlaunch's repository lock, so it can register a +/// worktree while the plan is on screen. +fn worktree_report_lines(report: &WorktreeReport) -> Vec { + if report.nothing_to_say() { + return Vec::new(); + } + let mut lines = vec![format!( + "Removed {} agent worktree(s) -- {}.", + report.removed.len(), + describe_usage(&report.freed()) + )]; + for withheld in &report.withheld { + lines.push(format!( + "Left {}: {}. That was not so when the plan above was printed.", + withheld.path.display(), + worktree_kept_because(&withheld.because) + )); + } + for clone in &report.metadata_held_back { + lines.push(format!( + "Did not run git worktree prune in {}: a worktree there is being kept, and the \ + registration is what goes on protecting it.", + clone.display() + )); + } + for clone in &report.metadata_refused { + lines.push(format!( + "git worktree prune would not run in {}, so git still lists worktrees that are \ + gone. The next --prune will offer them again.", + clone.display() + )); + } + if !report.refused.is_empty() { + let by_hand: Vec = report + .refused + .iter() + .map(|refusal| refusal.path.clone()) + .collect(); + lines.extend(report_refusals( + report.refused.iter(), + "Some agent worktrees would not come away. These refused:", + &by_hand, + )); + } lines } diff --git a/rust/dl/tests/completion_tables.rs b/rust/dl/tests/completion_tables.rs index 318172f4..1c59b60b 100644 --- a/rust/dl/tests/completion_tables.rs +++ b/rust/dl/tests/completion_tables.rs @@ -286,7 +286,7 @@ fn aid_flag_list(rewrite: &str, name: &str) -> BTreeSet { /// none of these is one. They modify a line that already named one, and the script /// offers nothing in that position at all (a first word starting with `--` ends /// completion) — a gap worth closing, but a different change than this. -const NOT_OFFERED_FIRST: [(&str, &str); 4] = [ +const NOT_OFFERED_FIRST: [(&str, &str); 5] = [ ( "--json", "only with --ls, which has already been typed by then", @@ -300,6 +300,10 @@ const NOT_OFFERED_FIRST: [(&str, &str); 4] = [ "--force", "modifies rm, --prune or --update-cache, never alone", ), + ( + "--force-worktrees", + "modifies --prune, which has already been typed by then", + ), ]; #[test] diff --git a/rust/dl/tests/lifecycle.rs b/rust/dl/tests/lifecycle.rs index 012a3436..4ecb4763 100644 --- a/rust/dl/tests/lifecycle.rs +++ b/rust/dl/tests/lifecycle.rs @@ -1343,3 +1343,125 @@ fn a_reconcile_that_cannot_write_a_record_says_which_and_exits_one() { "it reported a repair it did not make" ); } + +// --------------------------------------------------------------------------- +// --prune reaches the agent worktrees inside the clones it keeps (devlaunch#426) +// --------------------------------------------------------------------------- + +/// The clone every one of these worktrees lives inside: opened by a live +/// workspace, so `--prune` was never going to touch it. +const AGENT_CLONE: &str = "{ROOT}/cache/devlaunch/repos/blooop/devlaunch/devlaunch-main-legacy"; + +#[test] +fn a_prune_names_the_agent_worktrees_inside_a_clone_it_is_keeping() { + // devlaunch#426: 104.5 GB in 72 of these, every one inside a clone belonging + // to a live workspace. The dry run is the plan that already existed, grown a + // section -- not a second dry-run path. + let world = World::with(&["--agent-worktrees"]); + let run = world.answering("no\n", &["--prune"]); + run.exited(0); + let out = without_sizes(&run.out); + assert!( + out.contains(&format!( + "Agent git worktrees inside the clones above -- :\n\n {AGENT_CLONE}:\n" + )), + "{out}" + ); + assert!( + out.contains(&format!( + " - removing {AGENT_CLONE}/.claude/worktrees/agent-finished (): git says \ + the registration for it can go\n" + )), + "{out}" + ); + assert!( + out.contains(&format!( + " - leaving {AGENT_CLONE}/.claude/worktrees/agent-unsaved: holds 1 uncommitted \ + change(s) (notes.md) -- add --force-worktrees to remove it anyway\n" + )), + "{out}" + ); + // Said once, and it is the honest scope of the answer: `--prune` does not + // fetch, so "nowhere else" is as of the last one. + assert!( + out.contains( + "Whether a worktree's commits are anywhere else is as of the last fetch into the \ + repository cache; --prune does not fetch.\n" + ), + "{out}" + ); + // And the plan still ends in the question, so the dry run is the same one + // dry run it always was. + assert!(out.ends_with(&format!("Are you sure? [y/N] Aborted.\n{DOCKER_BOUNDARY}"))); +} + +#[test] +fn a_prune_removes_the_collectable_agent_worktree_and_keeps_the_clone() { + let world = World::with(&["--agent-worktrees"]); + let clone = world + .root + .join("cache/devlaunch/repos/blooop/devlaunch/devlaunch-main-legacy"); + let trees = clone.join(".claude").join("worktrees"); + + let run = world.dl(&["--prune", "-y"]); + + run.exited(0); + let out = without_sizes(&run.out); + assert!( + out.contains("Removed 1 agent worktree(s) -- ."), + "{out}" + ); + assert!(!trees.join("agent-finished").exists(), "{out}"); + assert!( + trees.join("agent-unsaved").exists(), + "the one holding a note is still here" + ); + assert!(clone.join(".git").exists(), "the clone itself is untouched"); + // `git worktree prune` is held back while a worktree there is kept for what it + // holds: it is all-or-nothing across a clone, and running it would drop the + // registration that is the only reason the next run can tell that directory + // from a forgotten one. + assert!(out.contains("Did not run git worktree prune in"), "{out}"); +} + +#[test] +fn the_flag_is_what_carries_an_agent_worktree_past_what_it_holds() { + let world = World::with(&["--agent-worktrees"]); + let trees = world + .root + .join("cache/devlaunch/repos/blooop/devlaunch/devlaunch-main-legacy") + .join(".claude") + .join("worktrees"); + + let run = world.dl(&["--prune", "-y", "--force-worktrees"]); + + run.exited(0); + let out = without_sizes(&run.out); + assert!( + out.contains("Removed 2 agent worktree(s) -- ."), + "{out}" + ); + assert!(!trees.join("agent-unsaved").exists(), "{out}"); +} + +#[test] +fn plain_force_does_not_reach_the_agent_worktrees() { + // The two flags answer different hazards. `--force` is a word people already + // type at `--prune`, and widening it here would turn it into permission to + // remove a worktree somebody may be working in. + let world = World::with(&["--agent-worktrees"]); + let trees = world + .root + .join("cache/devlaunch/repos/blooop/devlaunch/devlaunch-main-legacy") + .join(".claude") + .join("worktrees"); + + let run = world.dl(&["--prune", "-y", "--force"]); + + run.exited(0); + assert!( + trees.join("agent-unsaved").exists(), + "--force removed a worktree only --force-worktrees answers for: {}", + without_sizes(&run.out) + ); +} diff --git a/rust/dl/tests/lifecycle_scenario.py b/rust/dl/tests/lifecycle_scenario.py index 92dd22d9..d441817f 100755 --- a/rust/dl/tests/lifecycle_scenario.py +++ b/rust/dl/tests/lifecycle_scenario.py @@ -17,6 +17,7 @@ [--unwritable] [--no-cache] [--no-workspaces] [--devcontainer-volumes] + [--agent-worktrees] The base world, under the root it is given: @@ -69,6 +70,14 @@ PRUNABLE_LEAF = "devlaunch-gone-nobody" PRUNABLE_DIRTY_LEAF = "devlaunch-gone-dirty" +# --agent-worktrees: two real git worktrees an agent harness would have made +# inside the *clean* clone, which a live workspace opens. Registered from inside a +# container, so the paths git holds are container paths and git calls them +# prunable -- the shape devlaunch#426 measured 72 of. One is finished and +# collectable; the other holds an untracked note, so it is reported and kept. +AGENT_GONE = "agent-finished" +AGENT_HELD = "agent-unsaved" + # --stale-record: a record whose directory is definitively not there. STALE_LEAF = "devlaunch-ancient-forgotten" @@ -287,6 +296,27 @@ def build(root: pathlib.Path, shim: pathlib.Path, wanted: set) -> None: dirty=True, ) + if "agent-worktrees" in wanted: + # `git worktree add` from inside the clone, then the registrations rewritten + # to the container paths they would really carry. Both steps matter: the + # classification is git's own `worktree list`, and it is the container path + # that makes a registration prunable on a host. + trees = clean / ".claude" / "worktrees" + trees.mkdir(parents=True, exist_ok=True) + for leaf in (AGENT_GONE, AGENT_HELD): + git(clean, "worktree", "add", "-q", "-b", leaf, str(trees / leaf)) + git(clean, "push", "-q", "origin", leaf) + git(bare, "fetch", "-q", "origin", "+refs/heads/*:refs/heads/*", "--prune") + (trees / AGENT_HELD / "notes.md").write_text("half a thought\n", encoding="utf-8") + for leaf in (AGENT_GONE, AGENT_HELD): + gitdir = clean / ".git" / "worktrees" / leaf / "gitdir" + gitdir.write_text( + gitdir.read_text(encoding="utf-8").replace( + str(clean), "/workspaces/devlaunch-main-legacy" + ), + encoding="utf-8", + ) + if "unpushed" in wanted: # A commit that exists in this clone and nowhere else. Uncommitted work and # unpushed commits are different losses and the refusal names which. @@ -472,6 +502,7 @@ def build(root: pathlib.Path, shim: pathlib.Path, wanted: set) -> None: "[--stale-record] [--orphan] [--unplaceable] [--unwritable] " "[--no-cache] [--no-workspaces] [--not-a-clone] [--unpushed] " "[--sealed-cache] [--symlinked-cache] [--v1-cache] " + "[--agent-worktrees] " "[--devcontainer-volumes]" ) flags = {argument.lstrip("-") for argument in sys.argv[3:]} @@ -489,6 +520,7 @@ def build(root: pathlib.Path, shim: pathlib.Path, wanted: set) -> None: "symlinked-cache", "v1-cache", "devcontainer-volumes", + "agent-worktrees", } if unknown: raise SystemExit(f"lifecycle_scenario.py: unknown fixture(s): {sorted(unknown)}") From 749ed92818143af1d9c6973c6b93df7e4ded154d Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Tue, 25 Aug 2026 12:03:48 +0000 Subject: [PATCH 3/7] Attribute the agent-worktree bytes in dl --ls --size The figure was invisible on the host that filled up, and it was 82% of the cache. A part of the clone's number rather than an addition: the worktrees are inside it. The object store is not in the attribution, because a linked worktree shares the clone's and the clone's objects are hardlinked from the bare next door. --- .../src/flows/agent_worktrees.rs | 25 +++++ rust/devlaunch-core/src/flows/listing.rs | 95 ++++++++++++++++++- rust/dl/src/render.rs | 61 +++++++++++- rust/dl/tests/lifecycle.rs | 54 +++++++++++ 4 files changed, 226 insertions(+), 9 deletions(-) diff --git a/rust/devlaunch-core/src/flows/agent_worktrees.rs b/rust/devlaunch-core/src/flows/agent_worktrees.rs index 567184e3..ef2dc28c 100644 --- a/rust/devlaunch-core/src/flows/agent_worktrees.rs +++ b/rust/devlaunch-core/src/flows/agent_worktrees.rs @@ -867,6 +867,31 @@ pub(crate) fn sweep_clone( }) } +/// How much of a clone's bytes are agent git worktrees, or nothing when it has +/// none. +/// +/// **Attribution, not an addition.** These bytes are inside the clone, so they are +/// already in what `dl --ls --size` says the clone would free; this says how much +/// of that figure is worktrees. It reached 82% of a whole cache on the reference +/// host while being invisible in `--ls --size`, which is how it got to a full disk +/// (devlaunch#426). +/// +/// One walk of the whole `.claude/worktrees/` tree rather than one per worktree, +/// which also means nesting is counted once and counted right. The object store is +/// not in it: a linked worktree shares the clone's, and the clone's objects are +/// hardlinked out of the `.bare` next door, so billing them here would count them +/// two or three times — which [`disk_usage::exclusive_usage`] already refuses to +/// do, because a file's bytes are a tree's only when every link to it is inside +/// that tree. +/// +/// `None` rather than a zero, because "this clone has never had an agent worktree +/// in it" and "it has some and they cost nothing" are different facts, and the +/// first is what nearly every clone is. +pub fn bytes_in(clone: &Path) -> Option { + let root = worktrees_dir(clone); + root.is_dir().then(|| disk_usage::exclusive_usage(&root)) +} + /// The `.claude/worktrees/` inside one directory. fn worktrees_dir(directory: &Path) -> PathBuf { directory.join(WORKTREES_DIR[0]).join(WORKTREES_DIR[1]) diff --git a/rust/devlaunch-core/src/flows/listing.rs b/rust/devlaunch-core/src/flows/listing.rs index b074b5f6..a7f78e4b 100644 --- a/rust/devlaunch-core/src/flows/listing.rs +++ b/rust/devlaunch-core/src/flows/listing.rs @@ -65,6 +65,7 @@ use crate::domain::metadata::MetadataStorage; use crate::domain::model::WorktreeInfo; use crate::domain::workspace_id; use crate::domain::workspace_state::{self, CloneState, CouldNotTell, NonEmpty, Unsaved}; +use crate::flows::agent_worktrees; use crate::flows::disk_usage::{self, DiskUsage}; use crate::runner::Runner; use crate::timing; @@ -784,7 +785,7 @@ pub(crate) struct DevlaunchClone { pub(crate) enum DiskField { NotAsked, NothingOfOurs, - Freed(DiskUsage), + Freed(CloneDisk), } impl DiskField { @@ -792,7 +793,71 @@ impl DiskField { match (sizes, measurable) { (Sizes::Skip, _) => Self::NotAsked, (Sizes::Measure, None) => Self::NothingOfOurs, - (Sizes::Measure, Some(clone)) => Self::Freed(disk_usage::exclusive_usage(clone)), + (Sizes::Measure, Some(clone)) => Self::Freed(CloneDisk::of(clone)), + } + } +} + +/// What deleting one clone would free, and how much of that is agent git +/// worktrees (devlaunch#426). +/// +/// The second figure is a **part of** the first and never an addition: the +/// worktrees are inside the clone, so their bytes are already in what deleting it +/// would free. It is here because on the reference host they were 82% of a whole +/// cache while being invisible in `--ls --size`, which is how the disk filled. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CloneDisk { + freed: DiskUsage, + /// `None` when the clone has no `.claude/worktrees/` at all, which is nearly + /// every clone — and a different fact from "it has some and they cost + /// nothing", which is a `Some` of zero. + /// + /// Boxed because this value rides inside `WorkspaceTable`, which is one + /// variant against an empty one and is measured for exactly that: a second + /// inline `DiskUsage` here pushes the whole table enum past the size a + /// `Nothing` arm should have to carry. The allocation happens only on a clone + /// that has agent worktrees in it, which is where there is something to say. + in_worktrees: Option>, +} + +impl CloneDisk { + fn of(clone: &Path) -> Self { + Self { + freed: disk_usage::exclusive_usage(clone), + in_worktrees: agent_worktrees::bytes_in(clone).map(Box::new), + } + } + + /// What deleting the whole clone would free. + pub fn freed(&self) -> &DiskUsage { + &self.freed + } + + /// How much of that is agent git worktrees, or nothing when there are none. + pub fn in_worktrees(&self) -> Option<&DiskUsage> { + self.in_worktrees.as_deref() + } + + /// The same figure, but only when it is worth a person's attention: a clone + /// with an empty `.claude/worktrees/` has a measurement and nothing to say, + /// and a table cell that said "0 B in worktrees" would be noise in every row + /// on a host that has ever run one agent. + /// + /// Here rather than in the binary because the comparison needs the bytes, + /// which a usage does not hand out: printing them stripped of which arm they + /// are is what turns a floor into a total. + pub fn worktrees_worth_naming(&self) -> Option<&DiskUsage> { + self.in_worktrees + .as_deref() + .filter(|usage| usage.known_bytes() > 0) + } + + /// `pub` for the binary's rendering tests, which have no reachable + /// measurement to borrow. Binary surface, not part of the frozen `wf` API. + pub fn measured(freed: u64, in_worktrees: Option) -> Self { + Self { + freed: DiskUsage::measured(freed), + in_worktrees: in_worktrees.map(|bytes| Box::new(DiskUsage::measured(bytes))), } } } @@ -1018,7 +1083,27 @@ fn json_row(row: &ListedWorkspace) -> serde_json::Value { // Null where there is no clone of dl's own, the same way `repo` and // `branch` already say "not mine". DiskField::NothingOfOurs => insert(&mut value, "disk", serde_json::Value::Null), - DiskField::Freed(usage) => insert(&mut value, "disk", disk_usage::usage_as_json(usage)), + DiskField::Freed(disk) => insert(&mut value, "disk", disk_as_json(disk)), + } + value +} + +/// The `disk` object: what the clone would free, plus a `worktrees` key when any +/// of it is agent git worktrees. +/// +/// The key is **absent when there are none**, and that is unambiguous here in a +/// way it would not be one level up: `disk` itself is already absent unless +/// `--size` was asked for, so a `disk` object that has no `worktrees` key has +/// been measured and found none. Present-and-zero stays available for the clone +/// that has an empty `.claude/worktrees/`. +fn disk_as_json(disk: &CloneDisk) -> serde_json::Value { + let mut value = disk_usage::usage_as_json(disk.freed()); + if let Some(in_worktrees) = disk.in_worktrees() { + insert( + &mut value, + "worktrees", + disk_usage::usage_as_json(in_worktrees), + ); } value } @@ -1044,7 +1129,7 @@ fn insert(object: &mut serde_json::Value, key: &str, field: serde_json::Value) { pub enum SizeCell { NoColumn, NotOurs, - Measured(DiskUsage), + Measured(CloneDisk), } /// The `LAST USED` cell: devpod's stamp, cut to its date and time, or that there @@ -1123,7 +1208,7 @@ fn size_cell(workspace: &Workspace, cache_dir: &Path, sizes: Sizes) -> SizeCell match DiskField::of(sizes, measurable_clone(workspace, cache_dir).as_deref()) { DiskField::NotAsked => SizeCell::NoColumn, DiskField::NothingOfOurs => SizeCell::NotOurs, - DiskField::Freed(usage) => SizeCell::Measured(usage), + DiskField::Freed(disk) => SizeCell::Measured(disk), } } diff --git a/rust/dl/src/render.rs b/rust/dl/src/render.rs index 6b763020..c08dab27 100644 --- a/rust/dl/src/render.rs +++ b/rust/dl/src/render.rs @@ -32,7 +32,9 @@ use devlaunch_core::flows::lifecycle::{ PurgeOutcome, PurgePlan, PurgeStep, ReconcilePlan, RemovalRefused, RepointFailure, Unlocatable, VolumeRefusal, }; -use devlaunch_core::flows::listing::{LastUsed, SizeCell, Sizes, TableRow, WorkspaceTable}; +use devlaunch_core::flows::listing::{ + CloneDisk, LastUsed, SizeCell, Sizes, TableRow, WorkspaceTable, +}; use devlaunch_core::flows::migration::{Listing, MigrationReport}; use devlaunch_core::flows::provision::{BundleFailed, FailureLevel, ProvisionEvent}; use devlaunch_core::flows::repo_manager::{ @@ -147,11 +149,29 @@ fn size_cell(row: &TableRow, sizes: Sizes) -> String { // Not `0 B`: nothing was measured here, and a zero would say the // opposite of that. SizeCell::NotOurs => "-".to_owned(), - SizeCell::Measured(usage) => describe_usage(usage), + SizeCell::Measured(disk) => size_of(disk), }, } } +/// One clone's size, with the part of it that is agent git worktrees named. +/// +/// The parenthetical appears only where there is something to say, so the column +/// reads as it always did on a machine that has never run an agent in a +/// workspace. Where there is, it is the number that would otherwise be invisible: +/// on the host devlaunch#426 was found on, the worktrees were 82% of the cache and +/// no `--ls --size` row said so. +/// +/// A part of the figure beside it and never an addition — the worktrees are inside +/// the clone. +fn size_of(disk: &CloneDisk) -> String { + let total = describe_usage(disk.freed()); + match disk.worktrees_worth_naming() { + Some(worktrees) => format!("{total} ({} in worktrees)", describe_usage(worktrees)), + None => total, + } +} + /// The `LAST USED` cell. fn last_used(stamp: &LastUsed) -> String { match stamp { @@ -2443,7 +2463,6 @@ pub(crate) fn provision_event(event: &ProvisionEvent) -> Option { mod tests { use std::path::PathBuf; - use devlaunch_core::flows::disk_usage::DiskUsage; use devlaunch_core::flows::launch::TerminalTitle; use devlaunch_core::flows::listing::{SourceDescription, SourceKind}; @@ -2719,7 +2738,7 @@ mod tests { "a", SourceKind::Local, "/x", - SizeCell::Measured(DiskUsage::measured(2048)), + SizeCell::Measured(CloneDisk::measured(2048, None)), LastUsed::Never, ), row( @@ -2736,6 +2755,40 @@ mod tests { assert!(lines[3].contains(" - never"), "{:?}", lines[3]); } + #[test] + fn a_size_cell_names_the_part_of_it_that_is_agent_worktrees() { + // On the host devlaunch#426 was found on, the worktrees were 82% of the + // whole cache and no row said so, which is how it reached 100%. + let lines = table_lines( + &table(vec![ + row( + "a", + SourceKind::Local, + "/x", + SizeCell::Measured(CloneDisk::measured(4096, Some(3072))), + LastUsed::Never, + ), + row( + "b", + SourceKind::Local, + "/y", + SizeCell::Measured(CloneDisk::measured(2048, Some(0))), + LastUsed::Never, + ), + ]), + Sizes::Measure, + ); + + assert!( + lines[2].contains("4.0 KiB (3.0 KiB in worktrees)"), + "{:?}", + lines[2] + ); + // A clone with an empty `.claude/worktrees/` has a measurement and nothing + // to say, and a "0 B in worktrees" in every row would be noise. + assert!(lines[3].contains("2.0 KiB never"), "{:?}", lines[3]); + } + #[test] fn the_unknown_source_column_is_the_kind_word_and_the_payload() { let lines = table_lines( diff --git a/rust/dl/tests/lifecycle.rs b/rust/dl/tests/lifecycle.rs index 4ecb4763..739b78ba 100644 --- a/rust/dl/tests/lifecycle.rs +++ b/rust/dl/tests/lifecycle.rs @@ -1465,3 +1465,57 @@ fn plain_force_does_not_reach_the_agent_worktrees() { without_sizes(&run.out) ); } + +#[test] +fn the_listing_attributes_the_bytes_that_are_agent_worktrees() { + // The ask this one is on a different surface for: it was invisible in + // `--ls --size` on the host that filled up, which is why it got to 100%. + let world = World::with(&["--agent-worktrees"]); + + let table = world.dl(&["--ls", "--size"]); + table.exited(0); + assert!( + table.out.contains(" in worktrees)"), + "no row named the worktree bytes: {}", + table.out + ); + + let json = world.dl(&["--ls", "--size", "--json"]); + json.exited(0); + let rows: serde_json::Value = + serde_json::from_str(&json.out).expect("--ls --json prints one document"); + let clone_row = rows + .as_array() + .expect("an array") + .iter() + .find(|row| row["id"] == "devlaunch-main-legacy") + .expect("the clone the worktrees are inside") + .clone(); + assert!( + clone_row["disk"]["worktrees"]["exclusiveBytes"] + .as_u64() + .expect("worktree bytes") + > 0, + "{clone_row}" + ); + // Attribution and never an addition: they are inside the clone, so they are + // already part of what deleting it would free. + assert!( + clone_row["disk"]["exclusiveBytes"].as_u64().expect("bytes") + >= clone_row["disk"]["worktrees"]["exclusiveBytes"] + .as_u64() + .expect("worktree bytes"), + "{clone_row}" + ); + // A clone that has never had one says nothing about worktrees at all: `disk` + // is already absent unless `--size` was asked for, so an absent key here has + // been measured and found none. + let plain = rows + .as_array() + .expect("an array") + .iter() + .find(|row| row["id"] == "devlaunch-dirty-dofaraji") + .expect("the other clone") + .clone(); + assert!(plain["disk"].get("worktrees").is_none(), "{plain}"); +} From b812411f8593cfc70dbdb907881dfccaabab90bb Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Tue, 25 Aug 2026 12:06:01 +0000 Subject: [PATCH 4/7] Document the worktree reclamation, and regenerate the API snapshots The README names --force-worktrees because every flag dl offers has to appear there; the rules and the two things the report is careful about live in docs/cleanup.md, which is where depth goes. Only the tripwire file moved. prune_plan takes one Insisted instead of a bare Insistence, and SizeCell::Measured carries the clone's disk with its worktree share attributed; the promised api tier is byte-identical. --- README.md | 14 +- docs/cleanup.md | 91 +++++++++++ rust/devlaunch-core/public-api.rest.txt | 201 +++++++++++++++++++++++- 3 files changed, 302 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7c35a7b4..68fa26a2 100644 --- a/README.md +++ b/README.md @@ -222,7 +222,7 @@ instead. [docs/cli.md](docs/cli.md) has the full `--rm` contract, including whic | `dl --ls` | List every workspace | | `dl --ls --json` | The same, machine-readable, with what each workspace would lose if deleted | | `dl --ls --size` | Add what deleting each one would free. Opt-in: it walks every file | -| `dl --prune` | Remove the clone directories no workspace opens any more | +| `dl --prune` | Remove the clone directories no workspace opens any more, and the agent git worktrees inside the clones it keeps | | `dl --reconcile` | Re-point workspaces whose recorded source folder went missing. Deletes nothing | | `dl --purge` | Remove devlaunch's own workspaces and caches | | `dl --install` | Install shell completions | @@ -232,6 +232,10 @@ instead. [docs/cli.md](docs/cli.md) has the full `--rm` contract, including whic `--prune`, `--reconcile` and `--purge` print their plan and ask first. `-y` skips the question, and for `--prune` and `rm`, `--force` goes ahead despite work that is nowhere else. +`--force-worktrees` is the separate answer for the agent git worktrees `--prune` finds inside a +clone: the locked ones, the dirty ones, and the ones holding commits nothing else reaches are +reported and left alone without it. It is not `--force` because those are different hazards, and +`--force` is a word people already type. ```bash $ dl --version @@ -321,7 +325,7 @@ different jobs: | Command | Takes | Leaves | |---|---|---| -| `dl --prune` | Clone directories no workspace opens | Every workspace, container, image and volume | +| `dl --prune` | Clone directories no workspace opens, and collectable agent git worktrees inside the ones it keeps | Every workspace, container, image and volume | | `dl --purge` | The workspaces devlaunch created, and its caches | Workspaces it did not create, named before it asks | | `dl --reconcile` | Nothing | Repairs records that stopped matching the disk | @@ -331,6 +335,12 @@ uncommitted or unpushed changes, or one git cannot read to find out, is kept and because that is a fact about a ticket or somebody's intent. It reports what exists and what each one holds, via `dl --ls --json`, and leaves the choosing to you or to a tool that knows. +An agent harness working inside a workspace makes its own git worktrees under the clone, and +nothing used to collect them: on one host they were 82% of everything in the cache, 104 GB, and +no `dl --ls --size` row said so. That figure is now named, and `--prune` reaches inside the +clones it keeps to reclaim the ones that are finished. [docs/cleanup.md](docs/cleanup.md) has the +rules and what each refusal is asserting. + Deleting a workspace takes its clone and the named Docker volumes its devcontainer created. Images are yours: `docker system df` is what shows those. diff --git a/docs/cleanup.md b/docs/cleanup.md index ede0e72f..b86143b5 100644 --- a/docs/cleanup.md +++ b/docs/cleanup.md @@ -245,6 +245,97 @@ It also drops the `metadata.json` records of directories that are already gone. That file was append-only in practice, 49 records for 17 live workspaces on the same host, and this is the first thing that prunes it. +#### The agent worktrees inside a clone it keeps + +An agent harness working inside a workspace makes its own git worktrees under +`/.claude/worktrees//`, one per task, and nothing ever collected +them. Measured on one host: **72 of them, 104.5 GB, 18 carrying a whole +`.pixi/envs/default`, about 82% of everything under `repos/`.** One clone held 55 +GB on its own. Every one of them was inside a clone belonging to a **live** +workspace, so the rule above not only missed them, it must never fire on them: +firing would delete a live workspace's checkout. So this is a second rule, and it +runs only on the clones the first one is keeping. A clone that is going already +accounts for everything inside it. + +The word "worktree" is git's here, not `dl`'s. These are real registered +worktrees, made from inside the container, so the path git holds for one is +`/workspaces//.claude/worktrees/`, which does not resolve on the host +at all. That is why `git worktree remove` is no use from outside: `dl` removes +the directory and then runs `git worktree prune` in the clone, in that order, so +a run interrupted between the two leaves git holding a registration whose +directory is gone, which is precisely the state the next run already handles. + +A directory found there is one of four things: + +- **git has already forgotten it.** No registration names it and the directory is + all that is left. Removed. +- **git says the registration can go.** Which on a host is what a + container-registered worktree looks like, because the path it names is not + there. Removed, unless it holds something. +- **git is holding it locked.** Reported and left alone. `--force-worktrees` is + what removes one. +- **git still holds it and does not offer it up.** Kept, always. This is the arm + that stops a run *inside* a container, where the registered paths do resolve, + from collecting its own live worktrees. + +Removing one is refused for the same two things a clone's removal is refused +for, asked of the worktree rather than of the clone: uncommitted or untracked +work in it, and commits nothing else reaches. Both matter. A worktree on a +fully-merged branch with an afternoon of unstaged edits in it reads as finished +if you only ask about commits, and a worktree on no branch at all has no branch +for a branch-keyed question to find. `--force-worktrees` is the one flag that +carries a worktree past any of this, and it is deliberately not `--force`: +`--force` is a word people already type at `--prune`, and widening it would turn +it into permission to remove a worktree somebody may be working in. + +Two things the report is careful about. **It never claims a worktree is idle**, +because nothing on a host can establish that: a lock is the harness's courtesy, +a killed session leaves one behind, and a live session that never took one looks +exactly like an abandoned directory. Each line says the fact it rests on and +nothing more. And **whether commits are anywhere else is as of the last fetch**. +The question is asked of the sibling `.bare` cache first, because that is the +repository `dl` actually fetches into: a workspace clone is cut from the bare and +then has its remote repointed at the forge with no fetch of its own, so its +`refs/remotes/origin/*` is as of clone time and asking it alone reports +pushed-and-merged branches as unpushed. `--prune` does not fetch, and the report +says so rather than implying a live answer. + +``` +$ dl --prune +Clone directories under /home/you/.cache/devlaunch/repos: + +Leaving 1: + - /home/you/.cache/devlaunch/repos/blooop/devlaunch/devlaunch-main-zovomobo: workspace devlaunch-main-zovomobo still opens it + +Agent git worktrees inside the clones above -- 6.0 GiB: + + /home/you/.cache/devlaunch/repos/blooop/devlaunch/devlaunch-main-zovomobo: + - removing .../.claude/worktrees/agent-a49a (5.8 GiB): git says the registration for it can go + - removing .../.claude/worktrees/agent-a8da (204.0 MiB): git has already forgotten it + - leaving .../.claude/worktrees/agent-b120: git is holding it locked -- add --force-worktrees to remove it anyway + +Whether a worktree's commits are anywhere else is as of the last fetch into the repository cache; --prune does not fetch. + +Are you sure? [y/N] +``` + +`git worktree prune` is held back in a clone where a worktree is being kept for +what it holds, and the report says so. That is not tidiness: the prune is +all-or-nothing across a clone, so running it would drop the registration of the +kept worktree too, turning it into a forgotten directory, which the next run +removes outright. Held back, the guard keeps working. + +The bytes are also attributed in `dl --ls --size`, as a part of the clone's +figure and never an addition, because the worktrees are inside it. They were +invisible there on the host above, which is how it reached 100%. + +The 18 duplicated `.pixi/envs/default` copies are the reason the figure is 104 GB +rather than about 10, and they cannot be pointed at the shared package cache: +only the pixi *download* cache is shared, because installed environments bake +absolute paths (see "The shared pixi package cache" in +[workspace-tools.md](workspace-tools.md)). Removing the worktree is the way those +bytes come back, which is what this does. + #### The disk neither command frees Both commands end on the same line, in the same words: diff --git a/rust/devlaunch-core/public-api.rest.txt b/rust/devlaunch-core/public-api.rest.txt index 0096b7af..ce4dd482 100644 --- a/rust/devlaunch-core/public-api.rest.txt +++ b/rust/devlaunch-core/public-api.rest.txt @@ -625,6 +625,173 @@ impl core::marker::StructuralPartialEq for devlaunch_core::domain::xdg::NoHomeDi pub fn devlaunch_core::domain::xdg::config_home() -> core::result::Result pub fn devlaunch_core::domain::xdg::devlaunch_cache() -> core::result::Result pub mod devlaunch_core::flows +pub mod devlaunch_core::flows::agent_worktrees +pub enum devlaunch_core::flows::agent_worktrees::SeenAs +pub devlaunch_core::flows::agent_worktrees::SeenAs::Forgotten +pub devlaunch_core::flows::agent_worktrees::SeenAs::Locked +pub devlaunch_core::flows::agent_worktrees::SeenAs::Prunable +impl core::clone::Clone for devlaunch_core::flows::agent_worktrees::SeenAs +pub fn devlaunch_core::flows::agent_worktrees::SeenAs::clone(&self) -> devlaunch_core::flows::agent_worktrees::SeenAs +impl core::cmp::Eq for devlaunch_core::flows::agent_worktrees::SeenAs +impl core::cmp::PartialEq for devlaunch_core::flows::agent_worktrees::SeenAs +pub fn devlaunch_core::flows::agent_worktrees::SeenAs::eq(&self, &devlaunch_core::flows::agent_worktrees::SeenAs) -> bool +impl core::fmt::Debug for devlaunch_core::flows::agent_worktrees::SeenAs +pub fn devlaunch_core::flows::agent_worktrees::SeenAs::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::Copy for devlaunch_core::flows::agent_worktrees::SeenAs +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::SeenAs +pub enum devlaunch_core::flows::agent_worktrees::WorktreeHead +pub devlaunch_core::flows::agent_worktrees::WorktreeHead::Branch +pub devlaunch_core::flows::agent_worktrees::WorktreeHead::Branch::commit: alloc::string::String +pub devlaunch_core::flows::agent_worktrees::WorktreeHead::Branch::reference: alloc::string::String +pub devlaunch_core::flows::agent_worktrees::WorktreeHead::Detached +pub devlaunch_core::flows::agent_worktrees::WorktreeHead::Detached::commit: alloc::string::String +impl devlaunch_core::flows::agent_worktrees::WorktreeHead +pub fn devlaunch_core::flows::agent_worktrees::WorktreeHead::named(&self) -> alloc::string::String +impl core::clone::Clone for devlaunch_core::flows::agent_worktrees::WorktreeHead +pub fn devlaunch_core::flows::agent_worktrees::WorktreeHead::clone(&self) -> devlaunch_core::flows::agent_worktrees::WorktreeHead +impl core::cmp::Eq for devlaunch_core::flows::agent_worktrees::WorktreeHead +impl core::cmp::PartialEq for devlaunch_core::flows::agent_worktrees::WorktreeHead +pub fn devlaunch_core::flows::agent_worktrees::WorktreeHead::eq(&self, &devlaunch_core::flows::agent_worktrees::WorktreeHead) -> bool +impl core::fmt::Debug for devlaunch_core::flows::agent_worktrees::WorktreeHead +pub fn devlaunch_core::flows::agent_worktrees::WorktreeHead::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::WorktreeHead +pub enum devlaunch_core::flows::agent_worktrees::WorktreeKept +pub devlaunch_core::flows::agent_worktrees::WorktreeKept::Objected(devlaunch_core::domain::workspace_state::NonEmpty) +pub devlaunch_core::flows::agent_worktrees::WorktreeKept::StillHeld +pub devlaunch_core::flows::agent_worktrees::WorktreeKept::StillHeld::head: devlaunch_core::flows::agent_worktrees::WorktreeHead +impl core::clone::Clone for devlaunch_core::flows::agent_worktrees::WorktreeKept +pub fn devlaunch_core::flows::agent_worktrees::WorktreeKept::clone(&self) -> devlaunch_core::flows::agent_worktrees::WorktreeKept +impl core::cmp::Eq for devlaunch_core::flows::agent_worktrees::WorktreeKept +impl core::cmp::PartialEq for devlaunch_core::flows::agent_worktrees::WorktreeKept +pub fn devlaunch_core::flows::agent_worktrees::WorktreeKept::eq(&self, &devlaunch_core::flows::agent_worktrees::WorktreeKept) -> bool +impl core::fmt::Debug for devlaunch_core::flows::agent_worktrees::WorktreeKept +pub fn devlaunch_core::flows::agent_worktrees::WorktreeKept::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::WorktreeKept +pub enum devlaunch_core::flows::agent_worktrees::WorktreeObjection +pub devlaunch_core::flows::agent_worktrees::WorktreeObjection::Holds(devlaunch_core::flows::lifecycle::Objection) +pub devlaunch_core::flows::agent_worktrees::WorktreeObjection::Locked +pub devlaunch_core::flows::agent_worktrees::WorktreeObjection::Locked::lock: devlaunch_core::flows::agent_worktrees::Lock +impl core::clone::Clone for devlaunch_core::flows::agent_worktrees::WorktreeObjection +pub fn devlaunch_core::flows::agent_worktrees::WorktreeObjection::clone(&self) -> devlaunch_core::flows::agent_worktrees::WorktreeObjection +impl core::cmp::Eq for devlaunch_core::flows::agent_worktrees::WorktreeObjection +impl core::cmp::PartialEq for devlaunch_core::flows::agent_worktrees::WorktreeObjection +pub fn devlaunch_core::flows::agent_worktrees::WorktreeObjection::eq(&self, &devlaunch_core::flows::agent_worktrees::WorktreeObjection) -> bool +impl core::fmt::Debug for devlaunch_core::flows::agent_worktrees::WorktreeObjection +pub fn devlaunch_core::flows::agent_worktrees::WorktreeObjection::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::WorktreeObjection +pub enum devlaunch_core::flows::agent_worktrees::WorktreePromotion +pub devlaunch_core::flows::agent_worktrees::WorktreePromotion::Insisted +pub devlaunch_core::flows::agent_worktrees::WorktreePromotion::Insisted::despite: devlaunch_core::domain::workspace_state::NonEmpty +pub devlaunch_core::flows::agent_worktrees::WorktreePromotion::Unopposed +impl core::clone::Clone for devlaunch_core::flows::agent_worktrees::WorktreePromotion +pub fn devlaunch_core::flows::agent_worktrees::WorktreePromotion::clone(&self) -> devlaunch_core::flows::agent_worktrees::WorktreePromotion +impl core::cmp::Eq for devlaunch_core::flows::agent_worktrees::WorktreePromotion +impl core::cmp::PartialEq for devlaunch_core::flows::agent_worktrees::WorktreePromotion +pub fn devlaunch_core::flows::agent_worktrees::WorktreePromotion::eq(&self, &devlaunch_core::flows::agent_worktrees::WorktreePromotion) -> bool +impl core::fmt::Debug for devlaunch_core::flows::agent_worktrees::WorktreePromotion +pub fn devlaunch_core::flows::agent_worktrees::WorktreePromotion::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::WorktreePromotion +pub struct devlaunch_core::flows::agent_worktrees::CloneWorktrees +impl devlaunch_core::flows::agent_worktrees::CloneWorktrees +pub fn devlaunch_core::flows::agent_worktrees::CloneWorktrees::clone_path(&self) -> &std::path::Path +pub fn devlaunch_core::flows::agent_worktrees::CloneWorktrees::freed(&self) -> devlaunch_core::flows::disk_usage::DiskUsage +pub fn devlaunch_core::flows::agent_worktrees::CloneWorktrees::keeping(&self) -> &[devlaunch_core::flows::agent_worktrees::KeptWorktree] +pub fn devlaunch_core::flows::agent_worktrees::CloneWorktrees::metadata_may_be_pruned(&self) -> bool +pub fn devlaunch_core::flows::agent_worktrees::CloneWorktrees::owner(&self) -> &str +pub fn devlaunch_core::flows::agent_worktrees::CloneWorktrees::registrations_with_nothing_here(&self) -> usize +pub fn devlaunch_core::flows::agent_worktrees::CloneWorktrees::removing(&self) -> &[devlaunch_core::flows::agent_worktrees::ReclaimableWorktree] +pub fn devlaunch_core::flows::agent_worktrees::CloneWorktrees::repo(&self) -> &str +impl core::clone::Clone for devlaunch_core::flows::agent_worktrees::CloneWorktrees +pub fn devlaunch_core::flows::agent_worktrees::CloneWorktrees::clone(&self) -> devlaunch_core::flows::agent_worktrees::CloneWorktrees +impl core::cmp::Eq for devlaunch_core::flows::agent_worktrees::CloneWorktrees +impl core::cmp::PartialEq for devlaunch_core::flows::agent_worktrees::CloneWorktrees +pub fn devlaunch_core::flows::agent_worktrees::CloneWorktrees::eq(&self, &devlaunch_core::flows::agent_worktrees::CloneWorktrees) -> bool +impl core::fmt::Debug for devlaunch_core::flows::agent_worktrees::CloneWorktrees +pub fn devlaunch_core::flows::agent_worktrees::CloneWorktrees::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::CloneWorktrees +pub struct devlaunch_core::flows::agent_worktrees::KeptWorktree +pub devlaunch_core::flows::agent_worktrees::KeptWorktree::because: devlaunch_core::flows::agent_worktrees::WorktreeKept +pub devlaunch_core::flows::agent_worktrees::KeptWorktree::path: std::path::PathBuf +impl core::clone::Clone for devlaunch_core::flows::agent_worktrees::KeptWorktree +pub fn devlaunch_core::flows::agent_worktrees::KeptWorktree::clone(&self) -> devlaunch_core::flows::agent_worktrees::KeptWorktree +impl core::cmp::Eq for devlaunch_core::flows::agent_worktrees::KeptWorktree +impl core::cmp::PartialEq for devlaunch_core::flows::agent_worktrees::KeptWorktree +pub fn devlaunch_core::flows::agent_worktrees::KeptWorktree::eq(&self, &devlaunch_core::flows::agent_worktrees::KeptWorktree) -> bool +impl core::fmt::Debug for devlaunch_core::flows::agent_worktrees::KeptWorktree +pub fn devlaunch_core::flows::agent_worktrees::KeptWorktree::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::KeptWorktree +pub struct devlaunch_core::flows::agent_worktrees::Lock +pub devlaunch_core::flows::agent_worktrees::Lock::reason: core::option::Option +impl core::clone::Clone for devlaunch_core::flows::agent_worktrees::Lock +pub fn devlaunch_core::flows::agent_worktrees::Lock::clone(&self) -> devlaunch_core::flows::agent_worktrees::Lock +impl core::cmp::Eq for devlaunch_core::flows::agent_worktrees::Lock +impl core::cmp::PartialEq for devlaunch_core::flows::agent_worktrees::Lock +pub fn devlaunch_core::flows::agent_worktrees::Lock::eq(&self, &devlaunch_core::flows::agent_worktrees::Lock) -> bool +impl core::fmt::Debug for devlaunch_core::flows::agent_worktrees::Lock +pub fn devlaunch_core::flows::agent_worktrees::Lock::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::Lock +pub struct devlaunch_core::flows::agent_worktrees::ReclaimableWorktree +pub devlaunch_core::flows::agent_worktrees::ReclaimableWorktree::path: std::path::PathBuf +pub devlaunch_core::flows::agent_worktrees::ReclaimableWorktree::promotion: devlaunch_core::flows::agent_worktrees::WorktreePromotion +pub devlaunch_core::flows::agent_worktrees::ReclaimableWorktree::seen_as: devlaunch_core::flows::agent_worktrees::SeenAs +pub devlaunch_core::flows::agent_worktrees::ReclaimableWorktree::usage: devlaunch_core::flows::disk_usage::DiskUsage +impl core::clone::Clone for devlaunch_core::flows::agent_worktrees::ReclaimableWorktree +pub fn devlaunch_core::flows::agent_worktrees::ReclaimableWorktree::clone(&self) -> devlaunch_core::flows::agent_worktrees::ReclaimableWorktree +impl core::cmp::Eq for devlaunch_core::flows::agent_worktrees::ReclaimableWorktree +impl core::cmp::PartialEq for devlaunch_core::flows::agent_worktrees::ReclaimableWorktree +pub fn devlaunch_core::flows::agent_worktrees::ReclaimableWorktree::eq(&self, &devlaunch_core::flows::agent_worktrees::ReclaimableWorktree) -> bool +impl core::fmt::Debug for devlaunch_core::flows::agent_worktrees::ReclaimableWorktree +pub fn devlaunch_core::flows::agent_worktrees::ReclaimableWorktree::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::ReclaimableWorktree +pub struct devlaunch_core::flows::agent_worktrees::WithheldWorktree +pub devlaunch_core::flows::agent_worktrees::WithheldWorktree::because: devlaunch_core::flows::agent_worktrees::WorktreeKept +pub devlaunch_core::flows::agent_worktrees::WithheldWorktree::path: std::path::PathBuf +impl core::clone::Clone for devlaunch_core::flows::agent_worktrees::WithheldWorktree +pub fn devlaunch_core::flows::agent_worktrees::WithheldWorktree::clone(&self) -> devlaunch_core::flows::agent_worktrees::WithheldWorktree +impl core::cmp::Eq for devlaunch_core::flows::agent_worktrees::WithheldWorktree +impl core::cmp::PartialEq for devlaunch_core::flows::agent_worktrees::WithheldWorktree +pub fn devlaunch_core::flows::agent_worktrees::WithheldWorktree::eq(&self, &devlaunch_core::flows::agent_worktrees::WithheldWorktree) -> bool +impl core::fmt::Debug for devlaunch_core::flows::agent_worktrees::WithheldWorktree +pub fn devlaunch_core::flows::agent_worktrees::WithheldWorktree::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::WithheldWorktree +pub struct devlaunch_core::flows::agent_worktrees::WorktreeReport +pub devlaunch_core::flows::agent_worktrees::WorktreeReport::metadata_held_back: alloc::vec::Vec +pub devlaunch_core::flows::agent_worktrees::WorktreeReport::metadata_refused: alloc::vec::Vec +pub devlaunch_core::flows::agent_worktrees::WorktreeReport::refused: alloc::vec::Vec +pub devlaunch_core::flows::agent_worktrees::WorktreeReport::removed: alloc::vec::Vec +pub devlaunch_core::flows::agent_worktrees::WorktreeReport::withheld: alloc::vec::Vec +impl devlaunch_core::flows::agent_worktrees::WorktreeReport +pub fn devlaunch_core::flows::agent_worktrees::WorktreeReport::freed(&self) -> devlaunch_core::flows::disk_usage::DiskUsage +pub fn devlaunch_core::flows::agent_worktrees::WorktreeReport::nothing_to_say(&self) -> bool +impl core::clone::Clone for devlaunch_core::flows::agent_worktrees::WorktreeReport +pub fn devlaunch_core::flows::agent_worktrees::WorktreeReport::clone(&self) -> devlaunch_core::flows::agent_worktrees::WorktreeReport +impl core::cmp::Eq for devlaunch_core::flows::agent_worktrees::WorktreeReport +impl core::cmp::PartialEq for devlaunch_core::flows::agent_worktrees::WorktreeReport +pub fn devlaunch_core::flows::agent_worktrees::WorktreeReport::eq(&self, &devlaunch_core::flows::agent_worktrees::WorktreeReport) -> bool +impl core::default::Default for devlaunch_core::flows::agent_worktrees::WorktreeReport +pub fn devlaunch_core::flows::agent_worktrees::WorktreeReport::default() -> devlaunch_core::flows::agent_worktrees::WorktreeReport +impl core::fmt::Debug for devlaunch_core::flows::agent_worktrees::WorktreeReport +pub fn devlaunch_core::flows::agent_worktrees::WorktreeReport::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::WorktreeReport +pub struct devlaunch_core::flows::agent_worktrees::WorktreeSweep +impl devlaunch_core::flows::agent_worktrees::WorktreeSweep +pub fn devlaunch_core::flows::agent_worktrees::WorktreeSweep::clones(&self) -> &[devlaunch_core::flows::agent_worktrees::CloneWorktrees] +pub fn devlaunch_core::flows::agent_worktrees::WorktreeSweep::freed(&self) -> devlaunch_core::flows::disk_usage::DiskUsage +pub fn devlaunch_core::flows::agent_worktrees::WorktreeSweep::keeping(&self) -> usize +pub fn devlaunch_core::flows::agent_worktrees::WorktreeSweep::nothing_to_do(&self) -> bool +pub fn devlaunch_core::flows::agent_worktrees::WorktreeSweep::nothing_to_say(&self) -> bool +pub fn devlaunch_core::flows::agent_worktrees::WorktreeSweep::removing(&self) -> usize +impl core::clone::Clone for devlaunch_core::flows::agent_worktrees::WorktreeSweep +pub fn devlaunch_core::flows::agent_worktrees::WorktreeSweep::clone(&self) -> devlaunch_core::flows::agent_worktrees::WorktreeSweep +impl core::cmp::Eq for devlaunch_core::flows::agent_worktrees::WorktreeSweep +impl core::cmp::PartialEq for devlaunch_core::flows::agent_worktrees::WorktreeSweep +pub fn devlaunch_core::flows::agent_worktrees::WorktreeSweep::eq(&self, &devlaunch_core::flows::agent_worktrees::WorktreeSweep) -> bool +impl core::default::Default for devlaunch_core::flows::agent_worktrees::WorktreeSweep +pub fn devlaunch_core::flows::agent_worktrees::WorktreeSweep::default() -> devlaunch_core::flows::agent_worktrees::WorktreeSweep +impl core::fmt::Debug for devlaunch_core::flows::agent_worktrees::WorktreeSweep +pub fn devlaunch_core::flows::agent_worktrees::WorktreeSweep::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::WorktreeSweep +pub fn devlaunch_core::flows::agent_worktrees::bytes_in(&std::path::Path) -> core::option::Option pub mod devlaunch_core::flows::branch_manager pub enum devlaunch_core::flows::branch_manager::BranchError pub devlaunch_core::flows::branch_manager::BranchError::NotCreated @@ -1382,6 +1549,20 @@ pub fn devlaunch_core::flows::lifecycle::ClonePlacement::eq(&self, &devlaunch_co impl core::fmt::Debug for devlaunch_core::flows::lifecycle::ClonePlacement pub fn devlaunch_core::flows::lifecycle::ClonePlacement::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::marker::StructuralPartialEq for devlaunch_core::flows::lifecycle::ClonePlacement +pub struct devlaunch_core::flows::lifecycle::Insisted +pub devlaunch_core::flows::lifecycle::Insisted::clones: devlaunch_core::flows::lifecycle::Insistence +pub devlaunch_core::flows::lifecycle::Insisted::worktrees: devlaunch_core::flows::lifecycle::Insistence +impl devlaunch_core::flows::lifecycle::Insisted +pub fn devlaunch_core::flows::lifecycle::Insisted::nothing() -> Self +impl core::clone::Clone for devlaunch_core::flows::lifecycle::Insisted +pub fn devlaunch_core::flows::lifecycle::Insisted::clone(&self) -> devlaunch_core::flows::lifecycle::Insisted +impl core::cmp::Eq for devlaunch_core::flows::lifecycle::Insisted +impl core::cmp::PartialEq for devlaunch_core::flows::lifecycle::Insisted +pub fn devlaunch_core::flows::lifecycle::Insisted::eq(&self, &devlaunch_core::flows::lifecycle::Insisted) -> bool +impl core::fmt::Debug for devlaunch_core::flows::lifecycle::Insisted +pub fn devlaunch_core::flows::lifecycle::Insisted::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::Copy for devlaunch_core::flows::lifecycle::Insisted +impl core::marker::StructuralPartialEq for devlaunch_core::flows::lifecycle::Insisted pub struct devlaunch_core::flows::lifecycle::Kept pub devlaunch_core::flows::lifecycle::Kept::because: devlaunch_core::flows::lifecycle::KeptBecause pub devlaunch_core::flows::lifecycle::Kept::path: std::path::PathBuf @@ -1401,6 +1582,7 @@ pub fn devlaunch_core::flows::lifecycle::PrunePlan::nothing_to_do(&self) -> bool pub fn devlaunch_core::flows::lifecycle::PrunePlan::removing(&self) -> &[devlaunch_core::flows::lifecycle::Reclaimable] pub fn devlaunch_core::flows::lifecycle::PrunePlan::root(&self) -> &std::path::Path pub fn devlaunch_core::flows::lifecycle::PrunePlan::stale_records(&self) -> &[devlaunch_core::domain::model::WorktreeInfo] +pub fn devlaunch_core::flows::lifecycle::PrunePlan::worktrees(&self) -> &devlaunch_core::flows::agent_worktrees::WorktreeSweep impl core::clone::Clone for devlaunch_core::flows::lifecycle::PrunePlan pub fn devlaunch_core::flows::lifecycle::PrunePlan::clone(&self) -> devlaunch_core::flows::lifecycle::PrunePlan impl core::cmp::Eq for devlaunch_core::flows::lifecycle::PrunePlan @@ -1413,6 +1595,7 @@ pub struct devlaunch_core::flows::lifecycle::PruneReport pub devlaunch_core::flows::lifecycle::PruneReport::refused: alloc::vec::Vec pub devlaunch_core::flows::lifecycle::PruneReport::removed: alloc::vec::Vec pub devlaunch_core::flows::lifecycle::PruneReport::withheld: alloc::vec::Vec +pub devlaunch_core::flows::lifecycle::PruneReport::worktrees: devlaunch_core::flows::agent_worktrees::WorktreeReport impl devlaunch_core::flows::lifecycle::PruneReport pub fn devlaunch_core::flows::lifecycle::PruneReport::finished(&self) -> bool pub fn devlaunch_core::flows::lifecycle::PruneReport::freed(&self) -> devlaunch_core::flows::disk_usage::DiskUsage @@ -1539,7 +1722,7 @@ pub fn devlaunch_core::flows::lifecycle::devpod_home() -> core::option::Option devlaunch_core::flows::lifecycle::Guarded pub fn devlaunch_core::flows::lifecycle::objection(&devlaunch_core::domain::workspace_state::Unsaved) -> core::option::Option pub fn devlaunch_core::flows::lifecycle::prune_clones(&mut devlaunch_core::flows::listing::CommandContext<'_>, &devlaunch_core::flows::workspace_clone::WorkspaceCloneManager<'_>, &mut devlaunch_core::domain::metadata::MetadataStorage, &devlaunch_core::flows::lifecycle::PrunePlan, &mut dyn devlaunch_core::notices::Notices) -> core::result::Result -pub fn devlaunch_core::flows::lifecycle::prune_plan(&devlaunch_core::flows::workspace_clone::WorkspaceCloneManager<'_>, &devlaunch_core::domain::metadata::MetadataStorage, &[devlaunch_core::clients::devpod::Workspace], &devlaunch_core::flows::lifecycle::ClonePlacement, devlaunch_core::flows::lifecycle::Insistence, &mut dyn devlaunch_core::notices::Notices) -> core::result::Result +pub fn devlaunch_core::flows::lifecycle::prune_plan(&devlaunch_core::flows::workspace_clone::WorkspaceCloneManager<'_>, &devlaunch_core::domain::metadata::MetadataStorage, &[devlaunch_core::clients::devpod::Workspace], &devlaunch_core::flows::lifecycle::ClonePlacement, devlaunch_core::flows::lifecycle::Insisted, &mut dyn devlaunch_core::notices::Notices) -> core::result::Result pub fn devlaunch_core::flows::lifecycle::purge_all_data(&mut devlaunch_core::flows::listing::CommandContext<'_>, &devlaunch_core::flows::lifecycle::PurgePlan, core::option::Option<&std::path::Path>, &mut dyn core::ops::function::FnMut(devlaunch_core::flows::lifecycle::PurgeStep)) -> core::result::Result pub fn devlaunch_core::flows::lifecycle::purge_plan(&mut devlaunch_core::flows::listing::CommandContext<'_>, &std::path::Path) -> core::result::Result pub fn devlaunch_core::flows::lifecycle::reconcile_plan(&devlaunch_core::flows::workspace_clone::WorkspaceCloneManager<'_>, &devlaunch_core::domain::metadata::MetadataStorage, &[devlaunch_core::clients::devpod::Workspace], &devlaunch_core::flows::lifecycle::ClonePlacement, &mut dyn devlaunch_core::notices::Notices) -> devlaunch_core::flows::lifecycle::ReconcilePlan @@ -1561,7 +1744,7 @@ impl core::fmt::Debug for devlaunch_core::flows::listing::LastUsed pub fn devlaunch_core::flows::listing::LastUsed::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::marker::StructuralPartialEq for devlaunch_core::flows::listing::LastUsed pub enum devlaunch_core::flows::listing::SizeCell -pub devlaunch_core::flows::listing::SizeCell::Measured(devlaunch_core::flows::disk_usage::DiskUsage) +pub devlaunch_core::flows::listing::SizeCell::Measured(devlaunch_core::flows::listing::CloneDisk) pub devlaunch_core::flows::listing::SizeCell::NoColumn pub devlaunch_core::flows::listing::SizeCell::NotOurs impl core::clone::Clone for devlaunch_core::flows::listing::SizeCell @@ -1610,6 +1793,20 @@ pub fn devlaunch_core::flows::listing::WorkspaceTable::eq(&self, &devlaunch_core impl core::fmt::Debug for devlaunch_core::flows::listing::WorkspaceTable pub fn devlaunch_core::flows::listing::WorkspaceTable::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::marker::StructuralPartialEq for devlaunch_core::flows::listing::WorkspaceTable +pub struct devlaunch_core::flows::listing::CloneDisk +impl devlaunch_core::flows::listing::CloneDisk +pub fn devlaunch_core::flows::listing::CloneDisk::freed(&self) -> &devlaunch_core::flows::disk_usage::DiskUsage +pub fn devlaunch_core::flows::listing::CloneDisk::in_worktrees(&self) -> core::option::Option<&devlaunch_core::flows::disk_usage::DiskUsage> +pub fn devlaunch_core::flows::listing::CloneDisk::measured(u64, core::option::Option) -> Self +pub fn devlaunch_core::flows::listing::CloneDisk::worktrees_worth_naming(&self) -> core::option::Option<&devlaunch_core::flows::disk_usage::DiskUsage> +impl core::clone::Clone for devlaunch_core::flows::listing::CloneDisk +pub fn devlaunch_core::flows::listing::CloneDisk::clone(&self) -> devlaunch_core::flows::listing::CloneDisk +impl core::cmp::Eq for devlaunch_core::flows::listing::CloneDisk +impl core::cmp::PartialEq for devlaunch_core::flows::listing::CloneDisk +pub fn devlaunch_core::flows::listing::CloneDisk::eq(&self, &devlaunch_core::flows::listing::CloneDisk) -> bool +impl core::fmt::Debug for devlaunch_core::flows::listing::CloneDisk +pub fn devlaunch_core::flows::listing::CloneDisk::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::listing::CloneDisk pub struct devlaunch_core::flows::listing::CommandContext<'r> impl<'r> devlaunch_core::flows::listing::CommandContext<'r> pub fn devlaunch_core::flows::listing::CommandContext<'r>::git(&self) -> devlaunch_core::clients::git::Git<'r> From 913df8c9509e9862107dcec4b13afda0d1972b2b Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Tue, 25 Aug 2026 12:56:14 +0000 Subject: [PATCH 5/7] Gate the worktree metadata prune on what the run did The review on #442 reproduced a data loss with no flag typed at either run. `reclaim` asked `clone.metadata_may_be_pruned()`, which folds the *plan's* `keeping` and nothing else. A candidate the acting pass re-classified and withheld -- a worktree written into while the `[y/N]` question was on screen, which is the window this module already documents as not rare -- landed in `report.withheld`, which the gate never saw. So no holdback fired, `git worktree prune` ran, and it took the withheld worktree's registration with it. Next run the directory read `Forgotten`, the one arm with no probe, and went outright. Of the two directions the review offered, the first is what decided it: **the holdback has to be computed from what the acting pass did, not from what the plan predicted.** A prediction and an outcome that can disagree is the same shape as the plan-wide `force` boolean `PrunePlan`'s doc comment records as having caused a bug here already, and fixing the one call site would have left the shape. So the answer is a value that gets folded, `MetadataGate`, seeded from the plan's keeps and fed every outcome the acting pass reaches; `CloneWorktrees::metadata_gate` is the plan's fold of the same rule and reads as the forecast it is. There is one rule and two folds of it, where there were two rules that could disagree. The second direction is taken as far as it goes, because fixing only the gate leaves the blind deletion one bug away from returning. `Forgotten` now carries an `Unsaved` and is probed wherever there is an admin directory to probe through: reaching that arm with one present means git dropped the name or this module's suffix join missed, and no wrong classification should cost somebody an afternoon. With the admin directory gone there is genuinely no index and no HEAD, so nothing can be asked, and that stays the limit rather than becoming a claim. The `..` tail in a gitfile went the same way: without a guard it did not fail safe as the review supposed, it removed the directory as forgotten. The rest of the review, in one pass because it is all the same code: - **S2.** Worktree bytes were chained into `PrunePlan::freed`/`PruneReport::freed` and then spent on clone sentences: 128.0 KiB claimed over a 120.0 KiB directory, and `Removed 0 clone director(ies) -- 8.0 KiB.` over a run that removed no clone. Both are now `clones_freed`, the worktree section keeps its own figure, and a boundary test holds the headline to the rows under it. - **S3.** A registration with nothing behind it no longer constitutes work by itself. It frees nothing, so a run clears it and the run after has nothing to say -- where before `--prune` asked the question and no-opped forever. - **S4.** The `:!.claude/worktrees` pathspec excluded the place rather than the thing, hiding a tracked file modified there and, worse, plain content under a candidate's `.claude/worktrees/` that the sweep also skips: neither reported nor protected, and gone with its parent. git is now asked without a pathspec and untracked entries are dropped only where everything under them is a confirmed worktree. The motivating case -- a worktree holding a nested one reading dirty forever -- is still covered, by its own test. - **S5.** `registrations_with_nothing_here` tells a container path that never resolved here apart from a path in this clone with nothing at it, which is what the sharpened spec asked for and one number said neither of. - **N1.** The README's two paragraphs of rationale go to `docs/cleanup.md`, which already carried both nearly verbatim. The flag stays named. - **N2.** `CloneWorktrees::freed` had no caller and is gone; `bytes_in` is `pub(crate)`; `WorktreeSweep::removing`/`keeping` and `Insisted::nothing` are `#[cfg(test)]`, which is what they were for. `public-api.api.txt` is byte-identical. `public-api.rest.txt` removes ten rows -- `CloneWorktrees::{freed, metadata_may_be_pruned}`, the old `usize` signature of `registrations_with_nothing_here`, `PrunePlan::freed`, `PruneReport::freed`, `bytes_in`, `WorktreeSweep::{removing, keeping}`, and `Insisted::nothing` with its impl header -- and adds `MetadataGate`, `RegistrationsWithNothingHere` and the two `clones_freed` renames. Left alone: `dl/tests/read_side.rs`, which the review established this diff cannot flake, and #401 documents the load shape that does. Closes #426 --- README.md | 13 +- docs/cleanup.md | 38 +- rust/devlaunch-core/public-api.rest.txt | 44 +- rust/devlaunch-core/src/clients/git.rs | 21 +- .../src/flows/agent_worktrees.rs | 410 ++++++++++++++---- .../src/flows/agent_worktrees/tests.rs | 144 +++++- rust/devlaunch-core/src/flows/lifecycle.rs | 159 ++++++- rust/dl/src/render.rs | 27 +- rust/dl/tests/lifecycle.rs | 51 +++ 9 files changed, 771 insertions(+), 136 deletions(-) diff --git a/README.md b/README.md index 68fa26a2..42130b8f 100644 --- a/README.md +++ b/README.md @@ -233,9 +233,8 @@ instead. [docs/cli.md](docs/cli.md) has the full `--rm` contract, including whic `--prune`, `--reconcile` and `--purge` print their plan and ask first. `-y` skips the question, and for `--prune` and `rm`, `--force` goes ahead despite work that is nowhere else. `--force-worktrees` is the separate answer for the agent git worktrees `--prune` finds inside a -clone: the locked ones, the dirty ones, and the ones holding commits nothing else reaches are -reported and left alone without it. It is not `--force` because those are different hazards, and -`--force` is a word people already type. +clone, and [docs/cleanup.md](docs/cleanup.md) says what it carries one past and why it is not +`--force`. ```bash $ dl --version @@ -336,10 +335,10 @@ because that is a fact about a ticket or somebody's intent. It reports what exis one holds, via `dl --ls --json`, and leaves the choosing to you or to a tool that knows. An agent harness working inside a workspace makes its own git worktrees under the clone, and -nothing used to collect them: on one host they were 82% of everything in the cache, 104 GB, and -no `dl --ls --size` row said so. That figure is now named, and `--prune` reaches inside the -clones it keeps to reclaim the ones that are finished. [docs/cleanup.md](docs/cleanup.md) has the -rules and what each refusal is asserting. +nothing used to collect them. `dl --ls --size` now says how much of a clone is worktrees, and +`--prune` reaches inside the clones it keeps to reclaim the ones that are finished. +[docs/cleanup.md](docs/cleanup.md) has the rules, the measurements, and what each refusal is +asserting. Deleting a workspace takes its clone and the named Docker volumes its devcontainer created. Images are yours: `docker system df` is what shows those. diff --git a/docs/cleanup.md b/docs/cleanup.md index b86143b5..53b573b3 100644 --- a/docs/cleanup.md +++ b/docs/cleanup.md @@ -280,7 +280,12 @@ A directory found there is one of four things: Removing one is refused for the same two things a clone's removal is refused for, asked of the worktree rather than of the clone: uncommitted or untracked -work in it, and commits nothing else reaches. Both matter. A worktree on a +work in it, and commits nothing else reaches. The nested agent worktrees inside a +worktree are the one thing left out of the first question, because they are what +this sweep reasons about separately and a worktree holding one would otherwise +read dirty forever. They are left out by *being* worktrees, not by where they sit: +content under a `.claude/worktrees/` that is not a worktree is somebody's, and a +tracked file modified there is an edit like any other. Both matter. A worktree on a fully-merged branch with an afternoon of unstaged edits in it reads as finished if you only ask about commits, and a worktree on no branch at all has no branch for a branch-keyed question to find. `--force-worktrees` is the one flag that @@ -325,6 +330,37 @@ all-or-nothing across a clone, so running it would drop the registration of the kept worktree too, turning it into a forgotten directory, which the next run removes outright. Held back, the guard keeps working. +**The holdback answers to what the run did, not to what the plan said it would +do.** Those two can differ, and the gap is exactly the window this command +already documents: the plan is on screen, a container writes into a worktree, and +the re-check refuses one the plan meant to remove. Ask the plan and no holdback +fires, the prune takes the refused worktree's registration with it, and the next +run reads a forgotten directory and removes it. So the two passes fold their +outcomes into one gate rather than one of them reading the other's forecast. + +For the same reason, a directory with no registration is still asked what it holds +whenever there is anything left to ask through. "git has already forgotten it" is +the one arm that removes without a probe, so it is the arm a wrong answer is most +expensive on: if the admin directory is still there, so are an index and a HEAD, +and the question gets put. With the admin directory gone there is genuinely +nothing to ask, and that is the limit rather than a choice. + +A registration with no directory behind it frees nothing, and the report says +which kind it is: a container path, which never resolved on this host and is the +ordinary shape of every worktree an agent made inside a devcontainer, or a path in +this clone with nothing at it, which is somebody's own removal or a run +interrupted between the removal and the prune. Clearing one is a `git worktree +prune` and no more, so a run does it and the run after that has nothing to say. It +is not by itself something for `--prune` to ask about: while the prune is held +back the registration is being kept on purpose, and counting it as work made the +command ask the question and do nothing, every run. + +One more thing about the ordering, for an **orphan** clone that has agent +worktrees in it: reclaiming it takes two runs. The clone-level probe counts the +worktrees' contents as uncommitted work, correctly, because removing the clone +would destroy whatever they hold. So run one keeps the clone and sweeps the +worktrees; run two finds the clone empty of them and reclaims it with no flag. + The bytes are also attributed in `dl --ls --size`, as a part of the clone's figure and never an addition, because the worktrees are inside it. They were invisible there on the host above, which is how it reached 100%. diff --git a/rust/devlaunch-core/public-api.rest.txt b/rust/devlaunch-core/public-api.rest.txt index ce4dd482..163d8b7f 100644 --- a/rust/devlaunch-core/public-api.rest.txt +++ b/rust/devlaunch-core/public-api.rest.txt @@ -694,11 +694,10 @@ impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktree pub struct devlaunch_core::flows::agent_worktrees::CloneWorktrees impl devlaunch_core::flows::agent_worktrees::CloneWorktrees pub fn devlaunch_core::flows::agent_worktrees::CloneWorktrees::clone_path(&self) -> &std::path::Path -pub fn devlaunch_core::flows::agent_worktrees::CloneWorktrees::freed(&self) -> devlaunch_core::flows::disk_usage::DiskUsage pub fn devlaunch_core::flows::agent_worktrees::CloneWorktrees::keeping(&self) -> &[devlaunch_core::flows::agent_worktrees::KeptWorktree] -pub fn devlaunch_core::flows::agent_worktrees::CloneWorktrees::metadata_may_be_pruned(&self) -> bool +pub fn devlaunch_core::flows::agent_worktrees::CloneWorktrees::metadata_gate(&self) -> devlaunch_core::flows::agent_worktrees::MetadataGate pub fn devlaunch_core::flows::agent_worktrees::CloneWorktrees::owner(&self) -> &str -pub fn devlaunch_core::flows::agent_worktrees::CloneWorktrees::registrations_with_nothing_here(&self) -> usize +pub fn devlaunch_core::flows::agent_worktrees::CloneWorktrees::registrations_with_nothing_here(&self) -> devlaunch_core::flows::agent_worktrees::RegistrationsWithNothingHere pub fn devlaunch_core::flows::agent_worktrees::CloneWorktrees::removing(&self) -> &[devlaunch_core::flows::agent_worktrees::ReclaimableWorktree] pub fn devlaunch_core::flows::agent_worktrees::CloneWorktrees::repo(&self) -> &str impl core::clone::Clone for devlaunch_core::flows::agent_worktrees::CloneWorktrees @@ -730,6 +729,20 @@ pub fn devlaunch_core::flows::agent_worktrees::Lock::eq(&self, &devlaunch_core:: impl core::fmt::Debug for devlaunch_core::flows::agent_worktrees::Lock pub fn devlaunch_core::flows::agent_worktrees::Lock::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::Lock +pub struct devlaunch_core::flows::agent_worktrees::MetadataGate +impl devlaunch_core::flows::agent_worktrees::MetadataGate +pub fn devlaunch_core::flows::agent_worktrees::MetadataGate::open(self) -> bool +impl core::clone::Clone for devlaunch_core::flows::agent_worktrees::MetadataGate +pub fn devlaunch_core::flows::agent_worktrees::MetadataGate::clone(&self) -> devlaunch_core::flows::agent_worktrees::MetadataGate +impl core::cmp::Eq for devlaunch_core::flows::agent_worktrees::MetadataGate +impl core::cmp::PartialEq for devlaunch_core::flows::agent_worktrees::MetadataGate +pub fn devlaunch_core::flows::agent_worktrees::MetadataGate::eq(&self, &devlaunch_core::flows::agent_worktrees::MetadataGate) -> bool +impl core::default::Default for devlaunch_core::flows::agent_worktrees::MetadataGate +pub fn devlaunch_core::flows::agent_worktrees::MetadataGate::default() -> devlaunch_core::flows::agent_worktrees::MetadataGate +impl core::fmt::Debug for devlaunch_core::flows::agent_worktrees::MetadataGate +pub fn devlaunch_core::flows::agent_worktrees::MetadataGate::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::Copy for devlaunch_core::flows::agent_worktrees::MetadataGate +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::MetadataGate pub struct devlaunch_core::flows::agent_worktrees::ReclaimableWorktree pub devlaunch_core::flows::agent_worktrees::ReclaimableWorktree::path: std::path::PathBuf pub devlaunch_core::flows::agent_worktrees::ReclaimableWorktree::promotion: devlaunch_core::flows::agent_worktrees::WorktreePromotion @@ -743,6 +756,22 @@ pub fn devlaunch_core::flows::agent_worktrees::ReclaimableWorktree::eq(&self, &d impl core::fmt::Debug for devlaunch_core::flows::agent_worktrees::ReclaimableWorktree pub fn devlaunch_core::flows::agent_worktrees::ReclaimableWorktree::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::ReclaimableWorktree +pub struct devlaunch_core::flows::agent_worktrees::RegistrationsWithNothingHere +impl devlaunch_core::flows::agent_worktrees::RegistrationsWithNothingHere +pub fn devlaunch_core::flows::agent_worktrees::RegistrationsWithNothingHere::container_paths(self) -> usize +pub fn devlaunch_core::flows::agent_worktrees::RegistrationsWithNothingHere::deleted(self) -> usize +pub fn devlaunch_core::flows::agent_worktrees::RegistrationsWithNothingHere::none(self) -> bool +impl core::clone::Clone for devlaunch_core::flows::agent_worktrees::RegistrationsWithNothingHere +pub fn devlaunch_core::flows::agent_worktrees::RegistrationsWithNothingHere::clone(&self) -> devlaunch_core::flows::agent_worktrees::RegistrationsWithNothingHere +impl core::cmp::Eq for devlaunch_core::flows::agent_worktrees::RegistrationsWithNothingHere +impl core::cmp::PartialEq for devlaunch_core::flows::agent_worktrees::RegistrationsWithNothingHere +pub fn devlaunch_core::flows::agent_worktrees::RegistrationsWithNothingHere::eq(&self, &devlaunch_core::flows::agent_worktrees::RegistrationsWithNothingHere) -> bool +impl core::default::Default for devlaunch_core::flows::agent_worktrees::RegistrationsWithNothingHere +pub fn devlaunch_core::flows::agent_worktrees::RegistrationsWithNothingHere::default() -> devlaunch_core::flows::agent_worktrees::RegistrationsWithNothingHere +impl core::fmt::Debug for devlaunch_core::flows::agent_worktrees::RegistrationsWithNothingHere +pub fn devlaunch_core::flows::agent_worktrees::RegistrationsWithNothingHere::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::Copy for devlaunch_core::flows::agent_worktrees::RegistrationsWithNothingHere +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::RegistrationsWithNothingHere pub struct devlaunch_core::flows::agent_worktrees::WithheldWorktree pub devlaunch_core::flows::agent_worktrees::WithheldWorktree::because: devlaunch_core::flows::agent_worktrees::WorktreeKept pub devlaunch_core::flows::agent_worktrees::WithheldWorktree::path: std::path::PathBuf @@ -777,10 +806,8 @@ pub struct devlaunch_core::flows::agent_worktrees::WorktreeSweep impl devlaunch_core::flows::agent_worktrees::WorktreeSweep pub fn devlaunch_core::flows::agent_worktrees::WorktreeSweep::clones(&self) -> &[devlaunch_core::flows::agent_worktrees::CloneWorktrees] pub fn devlaunch_core::flows::agent_worktrees::WorktreeSweep::freed(&self) -> devlaunch_core::flows::disk_usage::DiskUsage -pub fn devlaunch_core::flows::agent_worktrees::WorktreeSweep::keeping(&self) -> usize pub fn devlaunch_core::flows::agent_worktrees::WorktreeSweep::nothing_to_do(&self) -> bool pub fn devlaunch_core::flows::agent_worktrees::WorktreeSweep::nothing_to_say(&self) -> bool -pub fn devlaunch_core::flows::agent_worktrees::WorktreeSweep::removing(&self) -> usize impl core::clone::Clone for devlaunch_core::flows::agent_worktrees::WorktreeSweep pub fn devlaunch_core::flows::agent_worktrees::WorktreeSweep::clone(&self) -> devlaunch_core::flows::agent_worktrees::WorktreeSweep impl core::cmp::Eq for devlaunch_core::flows::agent_worktrees::WorktreeSweep @@ -791,7 +818,6 @@ pub fn devlaunch_core::flows::agent_worktrees::WorktreeSweep::default() -> devla impl core::fmt::Debug for devlaunch_core::flows::agent_worktrees::WorktreeSweep pub fn devlaunch_core::flows::agent_worktrees::WorktreeSweep::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::WorktreeSweep -pub fn devlaunch_core::flows::agent_worktrees::bytes_in(&std::path::Path) -> core::option::Option pub mod devlaunch_core::flows::branch_manager pub enum devlaunch_core::flows::branch_manager::BranchError pub devlaunch_core::flows::branch_manager::BranchError::NotCreated @@ -1552,8 +1578,6 @@ impl core::marker::StructuralPartialEq for devlaunch_core::flows::lifecycle::Clo pub struct devlaunch_core::flows::lifecycle::Insisted pub devlaunch_core::flows::lifecycle::Insisted::clones: devlaunch_core::flows::lifecycle::Insistence pub devlaunch_core::flows::lifecycle::Insisted::worktrees: devlaunch_core::flows::lifecycle::Insistence -impl devlaunch_core::flows::lifecycle::Insisted -pub fn devlaunch_core::flows::lifecycle::Insisted::nothing() -> Self impl core::clone::Clone for devlaunch_core::flows::lifecycle::Insisted pub fn devlaunch_core::flows::lifecycle::Insisted::clone(&self) -> devlaunch_core::flows::lifecycle::Insisted impl core::cmp::Eq for devlaunch_core::flows::lifecycle::Insisted @@ -1576,7 +1600,7 @@ pub fn devlaunch_core::flows::lifecycle::Kept::fmt(&self, &mut core::fmt::Format impl core::marker::StructuralPartialEq for devlaunch_core::flows::lifecycle::Kept pub struct devlaunch_core::flows::lifecycle::PrunePlan impl devlaunch_core::flows::lifecycle::PrunePlan -pub fn devlaunch_core::flows::lifecycle::PrunePlan::freed(&self) -> devlaunch_core::flows::disk_usage::DiskUsage +pub fn devlaunch_core::flows::lifecycle::PrunePlan::clones_freed(&self) -> devlaunch_core::flows::disk_usage::DiskUsage pub fn devlaunch_core::flows::lifecycle::PrunePlan::keeping(&self) -> &[devlaunch_core::flows::lifecycle::Kept] pub fn devlaunch_core::flows::lifecycle::PrunePlan::nothing_to_do(&self) -> bool pub fn devlaunch_core::flows::lifecycle::PrunePlan::removing(&self) -> &[devlaunch_core::flows::lifecycle::Reclaimable] @@ -1597,8 +1621,8 @@ pub devlaunch_core::flows::lifecycle::PruneReport::removed: alloc::vec::Vec pub devlaunch_core::flows::lifecycle::PruneReport::worktrees: devlaunch_core::flows::agent_worktrees::WorktreeReport impl devlaunch_core::flows::lifecycle::PruneReport +pub fn devlaunch_core::flows::lifecycle::PruneReport::clones_freed(&self) -> devlaunch_core::flows::disk_usage::DiskUsage pub fn devlaunch_core::flows::lifecycle::PruneReport::finished(&self) -> bool -pub fn devlaunch_core::flows::lifecycle::PruneReport::freed(&self) -> devlaunch_core::flows::disk_usage::DiskUsage impl core::clone::Clone for devlaunch_core::flows::lifecycle::PruneReport pub fn devlaunch_core::flows::lifecycle::PruneReport::clone(&self) -> devlaunch_core::flows::lifecycle::PruneReport impl core::cmp::Eq for devlaunch_core::flows::lifecycle::PruneReport diff --git a/rust/devlaunch-core/src/clients/git.rs b/rust/devlaunch-core/src/clients/git.rs index 8e57a162..202e6ab7 100644 --- a/rust/devlaunch-core/src/clients/git.rs +++ b/rust/devlaunch-core/src/clients/git.rs @@ -400,24 +400,21 @@ impl<'r> Git<'r> { /// from the side that does resolve here, and `--work-tree` is the directory /// on this host. /// - /// **`.claude/worktrees/` is excluded from the walk.** A worktree holding a - /// nested worktree would otherwise always read dirty — the nested directory - /// is untracked — so it would be kept forever while the bytes that matter sat - /// inside it. Those nested directories are what the sweep reasons about - /// separately, not somebody's unsaved work. The exclusion is a pathspec so - /// git never walks the subtree, which also keeps a multi-gigabyte `.pixi` - /// inside one out of the status walk. + /// **Every line git prints comes back, including the nested agent worktrees + /// the caller reasons about separately.** This used to carry a + /// `:!.claude/worktrees` pathspec, which excluded the *place* rather than the + /// thing and so also hid a tracked file modified under that path and plain + /// content sitting there that is not a worktree at all (devlaunch#442 review, + /// S4). Which entries to disregard is a question about what a directory *is*, + /// which is `flows::agent_worktrees`'s question and not a git client's, so the + /// filtering is done there and the name of the directory stays in the one + /// module that reasons about it. pub(crate) fn worktree_dirt(&self, admin: &Path, work_tree: &Path) -> GitAnswer { let args = [ format!("--git-dir={}", admin.display()), format!("--work-tree={}", work_tree.display()), "status".to_owned(), "--porcelain".to_owned(), - "--".to_owned(), - // Spelled here rather than taken from `flows::agent_worktrees`, which - // is where the directory is named and reasoned about: a client does - // not import a flow. The two have to move together. - ":!.claude/worktrees".to_owned(), ]; self.captured( "status --porcelain", diff --git a/rust/devlaunch-core/src/flows/agent_worktrees.rs b/rust/devlaunch-core/src/flows/agent_worktrees.rs index ef2dc28c..57fb4417 100644 --- a/rust/devlaunch-core/src/flows/agent_worktrees.rs +++ b/rust/devlaunch-core/src/flows/agent_worktrees.rs @@ -27,7 +27,9 @@ //! [`decide`] is the only place any of them becomes deletable: //! //! - **Forgotten.** No registration names it. git has already let go and the -//! directory is the whole of what is left. +//! directory is the whole of what is left. This is the arm that deletes without +//! git's help, so it is asked what it holds wherever there is still an admin +//! directory to ask through, and only takes git's word when there is not. //! - **Prunable.** Registered, and git's own listing says the registration is //! collectable — which on a host is what a container-registered worktree looks //! like, because the path it names is not there. @@ -163,9 +165,13 @@ pub struct Lock { struct Registration { /// Where inside a clone the registration sits, as /// `.claude/worktrees/[/.claude/worktrees/…]`. This is the join - /// key; the path git printed is deliberately not kept, because on a host it - /// names nothing. + /// key, and the only thing a directory is ever matched on. inside: String, + /// The path git printed, kept for one question and never handed to the + /// filesystem: whether it is a path *in this clone* or a container's. That is + /// the whole of what tells a registration whose directory somebody deleted + /// apart from one that never resolved on this host. + registered_at: PathBuf, head: WorktreeHead, locked: Option, prunable: bool, @@ -181,6 +187,7 @@ fn registrations(listing: &str) -> Vec { let mut found = Vec::new(); for paragraph in listing.split("\n\n") { let mut inside = None; + let mut registered_at = None; let mut reference = None; let mut commit = None; let mut locked = None; @@ -191,7 +198,10 @@ fn registrations(listing: &str) -> Vec { None => (line, None), }; match (key, rest) { - ("worktree", Some(path)) => inside = inside_a_worktrees_dir(Path::new(path)), + ("worktree", Some(path)) => { + inside = inside_a_worktrees_dir(Path::new(path)); + registered_at = Some(PathBuf::from(path)); + } ("HEAD", Some(sha)) => commit = Some(sha.to_owned()), ("branch", Some(name)) => reference = Some(name.to_owned()), ("locked", reason) => { @@ -203,7 +213,8 @@ fn registrations(listing: &str) -> Vec { _ => {} } } - let (Some(inside), Some(commit)) = (inside, commit) else { + let (Some(inside), Some(registered_at), Some(commit)) = (inside, registered_at, commit) + else { continue; }; let head = match reference { @@ -212,6 +223,7 @@ fn registrations(listing: &str) -> Vec { }; found.push(Registration { inside, + registered_at, head, locked, prunable, @@ -269,6 +281,14 @@ fn linked_worktree_name(directory: &Path) -> Option { let (name, rest) = parts.split_last()?; let (worktrees, rest) = rest.split_last()?; let dot_git = rest.last()?; + // `..` would name the clone's own `.git` and `.` its `.git/worktrees`, so a + // gitfile carrying either would have this module probe, and then remove, + // something that is not one worktree -- and without this guard it *did* + // remove it, as a directory git had forgotten. The tail is file content and + // file content is not trusted to be a name (devlaunch#442 review, S6). + if name == "." || name == ".." || name.is_empty() { + return None; + } (dot_git == ADMIN_DIR[0] && worktrees == ADMIN_DIR[1]).then(|| name.clone()) } @@ -284,15 +304,20 @@ fn linked_worktree_name(directory: &Path) -> Option { /// ceiling, and an arm nobody can reclaim is an arm nobody should pay to weigh. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum WorktreeStatus { - /// No registration names it, so there is no admin directory to ask git - /// anything through either. + /// No registration names it: git has let go of the name. /// - /// Nothing is probed here and that is a real limit, not an oversight: with - /// the admin directory gone there is no index and no HEAD, so no `git status` - /// can be run against the directory at all. devlaunch#426 calls this category - /// safe to delete outright, and it is the one category where devlaunch takes - /// git's word for it rather than checking. - Forgotten { usage: DiskUsage }, + /// devlaunch#426 calls this category safe to delete outright, and it is the + /// one arm that takes git's word rather than checking — **but only where there + /// is nothing left to check with**. With the admin directory gone there is no + /// index and no HEAD, so no question can be put at all, and `holds` is + /// [`Unsaved::NothingToLose`] because that is the honest answer rather than a + /// shortcut. Where the admin directory *is* here the directory can still be + /// asked what it holds, and it is asked: reaching this arm with one present + /// means git dropped the name or the suffix join missed, and no wrong + /// classification should be able to cost somebody work (devlaunch#442 review, + /// S1). This is the arm that deletes, so it is the arm that has to be hardest + /// to reach by accident. + Forgotten { holds: Unsaved, usage: DiskUsage }, /// Registered, and git's own listing calls the registration prunable. Prunable { head: WorktreeHead, @@ -368,11 +393,11 @@ fn in_the_cache(git: &Git<'_>, bare: Option<&Path>, commit: &str) -> InTheCache /// refuses. `--git-dir=/.git/worktrees/` with /// `--work-tree=` is the same repository reached from the side that /// does resolve here. -/// - **`.claude/worktrees/` is excluded from it.** A worktree holding a nested -/// worktree would otherwise always read dirty — the nested directory is -/// untracked — and would be kept forever while the bytes that matter sat inside -/// it. Those nested directories are what this sweep reasons about separately, -/// not somebody's unsaved work. +/// - **Nested worktrees are dropped from the answer, and nothing else is.** A +/// worktree holding a nested one would otherwise always read dirty — the nested +/// directory is untracked — and would be kept forever while the bytes that +/// matter sat inside it. [`dirt_in`] carries which entries go and why the +/// exclusion is per-entry rather than per-path. /// - **Reachability asks the cache first.** See [`InTheCache`]. fn unsaved_in( git: &Git<'_>, @@ -382,19 +407,11 @@ fn unsaved_in( directory: &Path, head: &WorktreeHead, ) -> Unsaved { - let dirt = match git.worktree_dirt(admin, directory).said() { - None => { - return Unsaved::CouldNotTell(CouldNotTell::GitCouldNotRead { - clone: directory.to_path_buf(), - reason: "git could not read this worktree through the clone's admin directory" - .to_owned(), - }); - } - Some(dirt) => dirt, - }; let mut losses = Vec::new(); - if let Some(changed) = NonEmpty::of(dirt.lines().map(str::to_owned)) { - losses.push(Loss::Uncommitted(changed)); + match dirt_in(git, admin, directory) { + Err(could_not_tell) => return could_not_tell, + Ok(None) => {} + Ok(Some(changed)) => losses.push(changed), } match in_the_cache(git, bare, head.commit()) { InTheCache::Reached => {} @@ -423,6 +440,116 @@ fn unsaved_in( } } +/// What one worktree directory holds that is not committed, asked through the +/// admin directory, or that git would not say. +/// +/// **The nested-worktree exclusion lives here rather than in a pathspec, and that +/// is the whole point** (devlaunch#442 review, S4). `:!.claude/worktrees` kept the +/// motivating case working — a worktree holding a nested one would otherwise read +/// dirty forever, and be kept forever while the bytes that matter sat inside it — +/// but it excluded the place rather than the thing, so it also hid two kinds of +/// real work: a *tracked* file modified under that path, and plain content +/// somebody put under a `.claude/worktrees/` that is not a worktree at all. The +/// second is the dangerous one, because the sweep skips it too (it is not a linked +/// worktree), so nothing reported it and nothing protected it, and it went when +/// its parent did. +/// +/// So git is asked without a pathspec and the answer is filtered by what an entry +/// *is*: an untracked entry is dropped only when everything under it is a +/// confirmed linked worktree, which is exactly the set this sweep reasons about +/// separately. A tracked change is never dropped, whatever its path. +fn dirt_in(git: &Git<'_>, admin: &Path, directory: &Path) -> Result, Unsaved> { + let dirt = match git.worktree_dirt(admin, directory).said() { + None => { + return Err(Unsaved::CouldNotTell(CouldNotTell::GitCouldNotRead { + clone: directory.to_path_buf(), + reason: "git could not read this worktree through the clone's admin directory" + .to_owned(), + })); + } + Some(dirt) => dirt, + }; + let lines = dirt + .lines() + .filter(|line| !is_only_nested_worktrees(directory, line)) + .map(str::to_owned); + Ok(NonEmpty::of(lines).map(Loss::Uncommitted)) +} + +/// The dirt half alone, for a directory with no registration to ask about +/// reachability with. +fn dirt_only(git: &Git<'_>, admin: &Path, directory: &Path) -> Unsaved { + match dirt_in(git, admin, directory) { + Err(could_not_tell) => could_not_tell, + Ok(None) => Unsaved::NothingToLose, + Ok(Some(loss)) => match Losses::of([loss]) { + Some(losses) => Unsaved::WouldLose(losses), + None => Unsaved::NothingToLose, + }, + } +} + +/// Whether one `git status --porcelain` line is nothing but agent worktrees the +/// sweep is reasoning about separately. +/// +/// Only ever true of an **untracked** entry on the `.claude/worktrees/` spine. +/// Both halves of that matter: a tracked change under the same path is somebody's +/// edit to a file the repository knows about, and an untracked entry anywhere else +/// is not this module's business and must not cost a walk to find out. +fn is_only_nested_worktrees(root: &Path, line: &str) -> bool { + let Some(entry) = line.strip_prefix("?? ") else { + return false; + }; + // git quotes a path holding anything unusual. Quoted means unparsed here, + // which reads as work, which is the direction that keeps the directory. + if entry.starts_with('"') { + return false; + } + let entry = Path::new(entry.trim_end_matches('/')); + on_the_worktrees_spine(entry) && holds_only_worktrees(&root.join(entry)) +} + +/// Whether `entry` is the `.claude/worktrees/` spine or something under it. +/// +/// The cheap guard in front of [`holds_only_worktrees`], which walks: without it +/// an untracked `build/` would be walked in full to establish what everyone +/// already knows, which is what the pathspec was buying. +fn on_the_worktrees_spine(entry: &Path) -> bool { + let parts: Vec = entry + .components() + .map(|part| part.as_os_str().to_string_lossy().into_owned()) + .collect(); + parts.first().is_some_and(|first| first == WORKTREES_DIR[0]) + && parts.get(1).is_none_or(|second| second == WORKTREES_DIR[1]) +} + +/// Whether every leaf under `path` is inside a confirmed linked worktree. +/// +/// Stops at each worktree rather than descending into it, which is what bounds +/// the walk: the multi-gigabyte `.pixi/` that makes these directories worth +/// reclaiming is always inside one, so the only thing walked is content that is +/// *not* a worktree — which is precisely the content this is looking for. +/// +/// A symlink is not descended into, which is what makes the recursion terminate: +/// a link back up its own tree would otherwise be walked forever. It reads as +/// something to keep, like anything else here that is not a confirmed worktree. +fn holds_only_worktrees(path: &Path) -> bool { + if std::fs::symlink_metadata(path).is_ok_and(|it| it.is_symlink()) { + return false; + } + if linked_worktree_name(path).is_some() { + return true; + } + let Ok(entries) = std::fs::read_dir(path) else { + // A file, or a directory that will not be read. Neither is a worktree, so + // neither is something to drop from the answer. + return false; + }; + entries + .filter_map(Result::ok) + .all(|entry| holds_only_worktrees(&entry.path())) +} + /// Which arm `directory` is, asked in the order that fails towards keeping it. fn worktree_status( git: &Git<'_>, @@ -432,10 +559,24 @@ fn worktree_status( admin: Option<&Path>, registered: Option<&Registration>, ) -> WorktreeStatus { - let (Some(registration), Some(admin)) = (registered, admin) else { - return WorktreeStatus::Forgotten { - usage: disk_usage::exclusive_usage(directory), - }; + let (registration, admin) = match (registered, admin) { + (Some(registration), Some(admin)) => (registration, admin), + // An admin directory with nothing joined to it. git has dropped the name, + // or this module's suffix join missed one -- and either way the index and + // HEAD are here, so the directory is asked what it holds before it is + // treated as an empty leftover. + (None, Some(admin)) => { + return WorktreeStatus::Forgotten { + holds: dirt_only(git, admin, directory), + usage: disk_usage::exclusive_usage(directory), + }; + } + _ => { + return WorktreeStatus::Forgotten { + holds: Unsaved::NothingToLose, + usage: disk_usage::exclusive_usage(directory), + }; + } }; if !registration.prunable && registration.locked.is_none() { return WorktreeStatus::Held { @@ -548,7 +689,9 @@ pub(crate) fn decide(status: WorktreeStatus, insistence: Insistence) -> Worktree WorktreeStatus::Held { head } => { return WorktreeDecision::Keep(WorktreeKept::StillHeld { head }); } - WorktreeStatus::Forgotten { usage } => (SeenAs::Forgotten, usage, Vec::new()), + WorktreeStatus::Forgotten { holds, usage } => { + (SeenAs::Forgotten, usage, objections_of(None, &holds)) + } WorktreeStatus::Prunable { holds, usage, .. } => { (SeenAs::Prunable, usage, objections_of(None, &holds)) } @@ -617,7 +760,7 @@ pub struct CloneWorktrees { repo: String, removing: Vec, keeping: Vec, - registrations_with_nothing_here: usize, + registrations_with_nothing_here: RegistrationsWithNothingHere, } impl CloneWorktrees { @@ -647,44 +790,142 @@ impl CloneWorktrees { /// Registrations under a `.claude/worktrees/` with no directory here at all. /// /// Worth its own count because it is the one category with **no bytes behind - /// it**: the registration is either a container path that never resolved on - /// this host or a directory somebody removed by hand, and either way there is - /// nothing to free. `git worktree prune` is the whole of the work. - pub fn registrations_with_nothing_here(&self) -> usize { + /// it**: there is nothing to free, and [`Git::worktree_prune`] is the whole of + /// the work. + pub fn registrations_with_nothing_here(&self) -> RegistrationsWithNothingHere { self.registrations_with_nothing_here } - /// What removing this clone's share would free. - pub fn freed(&self) -> DiskUsage { - disk_usage::total_usage(self.removing.iter().map(|it| it.usage.clone())) + /// How the plan expects `git worktree prune` to go in this clone. + /// + /// **A forecast, and it says so.** It is a fold over the worktrees the *plan* + /// is keeping, which is everything the plan can know — and the acting pass + /// re-classifies every candidate, so it can withhold one the plan meant to + /// remove. That pass therefore builds its own gate from this one and folds its + /// own outcomes in, rather than reading a prediction: see [`reclaim`]. + pub fn metadata_gate(&self) -> MetadataGate { + self.keeping + .iter() + .fold(MetadataGate::default(), |gate, kept| { + gate.and_keeping(&kept.because) + }) } - /// Whether `git worktree prune` may run in this clone once the removals are - /// done. + /// Whether this clone's share of the sweep would change anything. /// - /// **A data-loss guard, not tidiness.** `git worktree prune` is - /// all-or-nothing across a clone, and on a host it drops the registration of - /// *every* container-registered worktree — including one being kept because - /// it is dirty or holds commits nothing else reaches. That registration is the - /// only reason a later run can tell the directory apart from a forgotten one, - /// and a forgotten one is removed outright. So pruning here would protect a - /// worktree once and hand it over the second time. - /// - /// A lock survives a prune by git's own rule, so a worktree kept only for - /// being locked does not hold the prune back. - pub fn metadata_may_be_pruned(&self) -> bool { - !self.keeping.iter().any(|kept| match &kept.because { - WorktreeKept::StillHeld { .. } => false, - WorktreeKept::Objected(objections) => objections - .iter() - .any(|objection| matches!(objection, WorktreeObjection::Holds(_))), - }) + /// **A registration with nothing behind it is not by itself work.** It frees + /// no bytes, and the only thing that clears it is the metadata prune — which + /// runs only where the gate is open. Where the gate is closed the registration + /// is being kept on purpose, so counting it as work makes `--prune` print a + /// section, ask the question and do nothing, every run, for as long as the + /// worktree it is protecting stays protected (devlaunch#442 review, S3). + fn nothing_to_do(&self) -> bool { + self.removing.is_empty() + && (self.registrations_with_nothing_here.none() || !self.metadata_gate().open()) } fn nothing_to_say(&self) -> bool { self.removing.is_empty() && self.keeping.is_empty() - && self.registrations_with_nothing_here == 0 + && self.registrations_with_nothing_here.none() + } +} + +/// Whether `git worktree prune` may still run in one clone. +/// +/// **A data-loss guard, not tidiness, and it answers to outcomes rather than to +/// predictions.** `git worktree prune` is all-or-nothing across a clone, and on a +/// host it drops the registration of *every* container-registered worktree — +/// including one being kept because it is dirty or holds commits nothing else +/// reaches. That registration is the only reason a later run can tell the +/// directory apart from a forgotten one, and [`WorktreeStatus::Forgotten`] is the +/// arm that deletes. So pruning there protects a worktree once and hands it over +/// the second time. +/// +/// Which is why this is a value that gets folded, and not a method on +/// [`CloneWorktrees`]. A gate read off the plan's keeps alone misses the candidate +/// the acting pass re-classified and withheld — the prune then ran, took that +/// worktree's registration with it, and the next run removed the directory and an +/// afternoon's uncommitted work outright, with no flag typed at either run +/// (devlaunch#442 review, S1). The plan and the act disagree by design; the fold +/// is what stops that disagreement being spendable. +/// +/// A lock survives a prune by git's own rule, so a worktree kept only for being +/// locked does not close the gate. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct MetadataGate { + closed: bool, +} + +impl MetadataGate { + /// Fold in one worktree that is staying, whichever pass decided it. + pub(crate) fn and_keeping(mut self, because: &WorktreeKept) -> Self { + self.closed |= registration_goes_on_protecting(because); + self + } + + /// Whether the prune may run. + pub fn open(self) -> bool { + !self.closed + } +} + +/// Whether the registration is what goes on protecting a worktree that is +/// staying. The single rule, which both passes reach through [`MetadataGate`]. +fn registration_goes_on_protecting(because: &WorktreeKept) -> bool { + match because { + // git holds this one, and git skips what it holds when it prunes. + WorktreeKept::StillHeld { .. } => false, + WorktreeKept::Objected(objections) => objections + .iter() + .any(|objection| matches!(objection, WorktreeObjection::Holds(_))), + } +} + +/// Registrations under a `.claude/worktrees/` with no directory in the clone, +/// told apart by *why* there is nothing there. +/// +/// **Two counts rather than one, because the sharpened spec on devlaunch#426 asks +/// the two apart and they are different facts.** Neither has host bytes behind it, +/// so `git worktree prune` is the whole of the work either way — but a registered +/// container path that never resolved here is the ordinary shape of every worktree +/// an agent made inside a devcontainer, where a registration naming a directory in +/// *this* clone that is not there is either somebody's own `rm -rf` or a previous +/// run interrupted between the removal and the prune. One is routine and one is +/// worth reading, and a single number said neither. +/// +/// The registered path is compared as a prefix and never resolved, which is the +/// module's whole discipline about these paths: on a host a container path names +/// nothing, and asking the filesystem about it is how that fact gets lost. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct RegistrationsWithNothingHere { + container_paths: usize, + deleted: usize, +} + +impl RegistrationsWithNothingHere { + /// Registered at a path outside this clone — a container's, which is what + /// every worktree an agent made inside a devcontainer carries. + pub fn container_paths(self) -> usize { + self.container_paths + } + + /// Registered at a path inside this clone, with nothing at it. + pub fn deleted(self) -> usize { + self.deleted + } + + /// Whether there are none of either. + pub fn none(self) -> bool { + self.container_paths == 0 && self.deleted == 0 + } + + fn count(&mut self, clone: &Path, registered_at: &Path) { + if registered_at.starts_with(clone) { + self.deleted += 1; + } else { + self.container_paths += 1; + } } } @@ -704,12 +945,17 @@ impl WorktreeSweep { } /// How many directories this run would remove. - pub fn removing(&self) -> usize { + /// + /// The counts are what the tests assert against; `dl` reads the per-clone + /// lists, so nothing outside this crate has ever wanted either of these. + #[cfg(test)] + pub(crate) fn removing(&self) -> usize { self.clones.iter().map(|it| it.removing.len()).sum() } /// How many it would leave, whatever the reason. - pub fn keeping(&self) -> usize { + #[cfg(test)] + pub(crate) fn keeping(&self) -> usize { self.clones.iter().map(|it| it.keeping.len()).sum() } @@ -724,9 +970,7 @@ impl WorktreeSweep { /// Whether this sweep would change anything on disk or in git. pub fn nothing_to_do(&self) -> bool { - self.clones - .iter() - .all(|it| it.removing.is_empty() && it.registrations_with_nothing_here == 0) + self.clones.iter().all(CloneWorktrees::nothing_to_do) } /// Whether there is nothing to say about it either. @@ -788,12 +1032,16 @@ impl ClonePicture { )) } - /// Registrations under a `.claude/worktrees/` with no directory in `clone`. - fn registrations_with_nothing_here(&self, clone: &Path) -> usize { - self.registered - .iter() - .filter(|registration| !clone.join(®istration.inside).exists()) - .count() + /// Registrations under a `.claude/worktrees/` with no directory in `clone`, + /// counted by why there is nothing there. + fn registrations_with_nothing_here(&self, clone: &Path) -> RegistrationsWithNothingHere { + let mut counted = RegistrationsWithNothingHere::default(); + for registration in &self.registered { + if !clone.join(®istration.inside).exists() { + counted.count(clone, ®istration.registered_at); + } + } + counted } } @@ -887,7 +1135,7 @@ pub(crate) fn sweep_clone( /// `None` rather than a zero, because "this clone has never had an agent worktree /// in it" and "it has some and they cost nothing" are different facts, and the /// first is what nearly every clone is. -pub fn bytes_in(clone: &Path) -> Option { +pub(crate) fn bytes_in(clone: &Path) -> Option { let root = worktrees_dir(clone); root.is_dir().then(|| disk_usage::exclusive_usage(&root)) } @@ -1010,6 +1258,10 @@ pub(crate) fn reclaim( })); return; }; + // Seeded from the plan's keeps and then fed every outcome this pass reaches, + // so the thing the prune is gated on is what happened rather than what was + // foreseen. See [`MetadataGate`] for what reading the forecast here cost. + let mut gate = clone.metadata_gate(); let mut removed_anything = false; for worktree in &clone.removing { let status = picture.status_of(git, &clone.clone, bare, &worktree.path); @@ -1027,6 +1279,7 @@ pub(crate) fn reclaim( }; match decision { WorktreeDecision::Keep(because) => { + gate = gate.and_keeping(&because); report.withheld.push(WithheldWorktree { path: worktree.path.clone(), because, @@ -1045,10 +1298,13 @@ pub(crate) fn reclaim( } } } - if !removed_anything { + // A clone whose only outstanding work is a registration with nothing behind it + // still has that work done, or the plan would go on offering it forever + // (devlaunch#442 review, S3). + if !removed_anything && clone.registrations_with_nothing_here.none() { return; } - if !clone.metadata_may_be_pruned() { + if !gate.open() { report.metadata_held_back.push(clone.clone.clone()); return; } diff --git a/rust/devlaunch-core/src/flows/agent_worktrees/tests.rs b/rust/devlaunch-core/src/flows/agent_worktrees/tests.rs index 32393873..70e0c82a 100644 --- a/rust/devlaunch-core/src/flows/agent_worktrees/tests.rs +++ b/rust/devlaunch-core/src/flows/agent_worktrees/tests.rs @@ -522,6 +522,115 @@ fn a_symlink_in_the_worktrees_place_is_stepped_over() { assert!(outside.exists()); } +// ======================================================================= +// what the dirty check sees under a `.claude/worktrees/` (devlaunch#442, S4) +// ======================================================================= + +#[test] +fn plain_content_under_a_candidates_worktrees_place_is_work() { + // The dangerous residue of excluding the place instead of the thing. This + // directory is not a linked worktree, so the sweep never reports it -- and + // with `.claude/worktrees/` excluded from the dirty check wholesale, nothing + // protected it either. It went when its parent did, unreported. + let world = Clone::new(); + let finished = world.worktree("agent-finished"); + let scratch = worktrees_dir(&finished).join("scratch"); + std::fs::create_dir_all(&scratch).expect("a plain directory"); + std::fs::write(scratch.join("notes.md"), "an afternoon\n").expect("a note"); + world.containerise(); + + let found = world.plan(); + + assert!(removing(&found).is_empty(), "{:?}", removing(&found)); + assert!( + matches!(kept_because(&found, &finished), WorktreeKept::Objected(_)), + "content nothing else accounts for has to object" + ); +} + +#[test] +fn a_tracked_file_modified_under_the_worktrees_place_is_still_work() { + // Contrived -- the harness's directory is normally ignored -- but it is real + // uncommitted work, and a pathspec that hides a whole path hides this too. + let world = Clone::new(); + let finished = world.worktree("agent-tracked"); + let tracked = worktrees_dir(&finished).join("keep.md"); + std::fs::create_dir_all(tracked.parent().expect("a parent")).expect("the directory"); + std::fs::write(&tracked, "committed\n").expect("the file"); + commit(&finished, "a tracked file where the sweep looks"); + run_git(&finished, &["push", "origin", "agent-tracked"]); + world.fetch(); + std::fs::write(&tracked, "edited, and nowhere else\n").expect("the edit"); + world.containerise(); + + let found = world.plan(); + + assert!(removing(&found).is_empty(), "{:?}", removing(&found)); + assert!( + matches!(kept_because(&found, &finished), WorktreeKept::Objected(_)), + "an edit to a file the repository knows about is work" + ); +} + +// ======================================================================= +// the arm that deletes is the hardest one to reach (devlaunch#442, S1) +// ======================================================================= + +#[test] +fn a_directory_whose_admin_directory_is_here_is_asked_what_it_holds() { + // git has let go of the name -- nothing in the listing joins to this + // directory any more -- but the admin directory it wrote is still here, so + // there is an index and a HEAD to ask through. A classification that lands on + // the deleting arm with a probe available must take the probe: neither git + // dropping a name nor this module's suffix join missing one should cost + // somebody an afternoon. + let world = Clone::new(); + let unsaved = world.worktree("agent-unsaved"); + std::fs::write(unsaved.join("notes.md"), "an afternoon\n").expect("a note"); + let gitdir = world + .clone + .join(".git") + .join("worktrees") + .join("agent-unsaved") + .join("gitdir"); + std::fs::write(&gitdir, "/workspaces/a-container/elsewhere/.git\n") + .expect("a registration that no longer looks like a worktrees path"); + + let found = world.plan(); + + assert!(removing(&found).is_empty(), "{:?}", removing(&found)); + assert!( + matches!(kept_because(&found, &unsaved), WorktreeKept::Objected(_)), + "an unjoinable registration is not a licence to delete" + ); +} + +#[test] +fn a_gitfile_naming_the_clones_own_admin_directory_is_not_a_worktree() { + // The tail of a gitfile is file content, and file content is not trusted to + // be a name: `..` would name the clone's own `.git` and have this module + // probe, and then remove, something that is not one worktree. + let world = Clone::new(); + let liar = worktrees_dir(&world.clone).join("agent-liar"); + std::fs::create_dir_all(&liar).expect("a directory"); + std::fs::write( + liar.join(".git"), + "gitdir: /workspaces/x/.git/worktrees/..\n", + ) + .expect("a gitfile naming no worktree"); + std::fs::write(liar.join("a-file"), "mine\n").expect("a file"); + + let found = world + .sweep(Insistence::Insisted) + .expect("a clone with a `.claude/worktrees/` is swept"); + + assert!( + removing(&found).is_empty() && keeping(&found).is_empty(), + "nothing here names one worktree, so there is nothing to say: {found:?}" + ); + assert!(liar.exists()); +} + // ======================================================================= // the prune-metadata guard // ======================================================================= @@ -534,7 +643,7 @@ fn metadata_is_pruned_when_nothing_was_held_back_for_what_it_holds() { let found = world.plan(); - assert!(found.metadata_may_be_pruned()); + assert!(found.metadata_gate().open()); } #[test] @@ -552,7 +661,7 @@ fn metadata_is_not_pruned_while_a_worktree_is_kept_for_what_it_holds() { let found = world.plan(); assert_eq!(removing(&found).len(), 1); - assert!(!found.metadata_may_be_pruned()); + assert!(!found.metadata_gate().open()); } #[test] @@ -571,7 +680,7 @@ fn a_lock_does_not_hold_the_metadata_prune_back() { let found = world.plan(); assert_eq!(removing(&found).len(), 1); - assert!(found.metadata_may_be_pruned()); + assert!(found.metadata_gate().open()); } // ======================================================================= @@ -590,10 +699,37 @@ fn a_registration_whose_directory_is_gone_is_counted_and_frees_nothing() { let found = world.plan(); - assert_eq!(found.registrations_with_nothing_here(), 1); + assert_eq!(found.registrations_with_nothing_here().container_paths(), 1); + assert_eq!( + found.registrations_with_nothing_here().deleted(), + 0, + "the registration named a container path, not a path in this clone" + ); assert_eq!(removing(&found).len(), 1, "{:?}", removing(&found)); } +#[test] +fn a_registration_naming_a_path_in_this_clone_is_told_apart_from_a_container_one() { + // The sharpened spec asks the two apart. Neither has bytes behind it, so the + // metadata prune is the whole of the work either way -- but a container path + // is the ordinary shape of every worktree an agent made inside a + // devcontainer, and a path in this clone with nothing at it is somebody's own + // removal or a run interrupted between the removal and the prune. One number + // said neither (devlaunch#442 review, S5). + let world = Clone::new(); + let deleted = world.worktree("agent-deleted"); + world.worktree("agent-here"); + // No `containerise` for this one: the registration keeps the host path it was + // made with, and the directory it names is gone. + std::fs::remove_dir_all(&deleted).expect("a directory removed by hand"); + + let found = world.plan(); + + let nothing_here = found.registrations_with_nothing_here(); + assert_eq!(nothing_here.deleted(), 1); + assert_eq!(nothing_here.container_paths(), 0); +} + // ======================================================================= // the porcelain parse // ======================================================================= diff --git a/rust/devlaunch-core/src/flows/lifecycle.rs b/rust/devlaunch-core/src/flows/lifecycle.rs index 080e0e05..fb0e2c7a 100644 --- a/rust/devlaunch-core/src/flows/lifecycle.rs +++ b/rust/devlaunch-core/src/flows/lifecycle.rs @@ -778,7 +778,11 @@ pub struct Insisted { impl Insisted { /// Nothing insisted on: what a plain `dl --prune` means. - pub fn nothing() -> Self { + /// + /// The command builds its pair from the flags it was given, so this spelling + /// of it is the tests' convenience and nothing else's. + #[cfg(test)] + pub(crate) fn nothing() -> Self { Self { clones: Insistence::NotInsisted, worktrees: Insistence::NotInsisted, @@ -2064,14 +2068,16 @@ impl PrunePlan { self.removing.is_empty() && self.stale_records.is_empty() && self.worktrees.nothing_to_do() } - /// What the whole run would free. - pub fn freed(&self) -> DiskUsage { - disk_usage::total_usage( - self.removing - .iter() - .map(|it| it.usage.clone()) - .chain(std::iter::once(self.worktrees.freed())), - ) + /// What removing the *clone directories* would free. + /// + /// **The agent worktrees are deliberately not in it** (devlaunch#442 review, + /// S2). Their bytes have their own sentence, because they are a different + /// claim: every one of them is inside a clone this run has just said it is + /// keeping, so folding them in here made the headline number describe + /// directories that are not going, and then said the same bytes twice. Ask + /// [`Self::worktrees`] for that figure. + pub fn clones_freed(&self) -> DiskUsage { + disk_usage::total_usage(self.removing.iter().map(|it| it.usage.clone())) } /// The directory the plan's candidates were scanned under. @@ -2375,16 +2381,15 @@ pub struct PruneReport { } impl PruneReport { - /// What this run actually freed — a total over the things it removed, with the + /// What the *clone directories* this run removed actually freed — with the /// figures the plan measured, so what a person is told they got back is what /// they said yes to. - pub fn freed(&self) -> DiskUsage { - disk_usage::total_usage( - self.removed - .iter() - .map(|it| it.usage.clone()) - .chain(std::iter::once(self.worktrees.freed())), - ) + /// + /// The agent worktrees are not in it, for the reason + /// [`PrunePlan::clones_freed`] gives: they are inside clones this run kept, + /// and [`WorktreeReport::freed`] is where their bytes are stated. + pub fn clones_freed(&self) -> DiskUsage { + disk_usage::total_usage(self.removed.iter().map(|it| it.usage.clone())) } pub fn finished(&self) -> bool { @@ -6925,7 +6930,7 @@ pub(crate) mod tests { let plan = plan_for(&world, Insistence::NotInsisted); assert_eq!(removing(&plan), [big, small]); - assert!(plan.freed().known_bytes() > 2 * 1024 * 1024); + assert!(plan.clones_freed().known_bytes() > 2 * 1024 * 1024); } #[test] @@ -7373,6 +7378,124 @@ pub(crate) mod tests { assert!(worktree.exists(), "it is registered and live again"); } + #[test] + fn a_worktree_that_went_dirty_while_the_question_was_open_keeps_its_registration() { + // devlaunch#442 review, S1. The window is the `[y/N]` question, and a + // container writing into a worktree is not a participant in devlaunch's + // repository lock. The re-check withholds the worktree, which is the easy + // half. The hard half is `git worktree prune`: it is all-or-nothing across + // a clone, so running it drops the withheld worktree's registration too -- + // and that registration is the only thing telling the next run this is not + // a directory git has forgotten. Forgotten is the arm that deletes. + let mut world = World::empty(); + let clone = world.clone_at("r-live-aa", "live"); + world.record("r-live-aa", "live", &clone); + let finished = an_agent_worktree(&clone, "agent-finished"); + let unsaved = an_agent_worktree(&clone, "agent-unsaved"); + as_a_host_sees_them(&clone); + world.devpod.lists(&[listed("live", &clone)]); + + let plan = plan_for(&world, Insistence::NotInsisted); + assert_eq!( + plan.worktrees().removing(), + 2, + "both read collectable when the question is asked: {:?}", + plan.worktrees() + ); + + // The write the plan on screen could not have known about. + std::fs::write(unsaved.join("notes.md"), "an afternoon\n").expect("a note"); + + let clones = clones_for(&world.repos_dir, &world.devpod); + let mut context = CommandContext::new(&world.devpod); + let outcome = prune_clones( + &mut context, + &clones, + &mut world.storage, + &plan, + &mut ignoring(), + ) + .expect("the pass ran"); + + let PruneOutcome::Acted(report) = &outcome else { + panic!("expected the pass to act, got {outcome:?}"); + }; + assert_eq!(report.worktrees.removed.len(), 1); + assert_eq!(report.worktrees.withheld.len(), 1); + assert!(!finished.exists(), "nothing objected to that one"); + assert!(unsaved.exists(), "it holds a note nowhere else"); + assert_eq!( + report.worktrees.metadata_held_back, + std::slice::from_ref(&clone), + "the prune has to answer to what this pass withheld, not to what the plan predicted" + ); + let listing = run_git(&clone, &["worktree", "list", "--porcelain"]); + assert!( + listing.contains("agent-unsaved"), + "the registration is what stops the next run reading this as forgotten: {listing}" + ); + drop(clones); + + // And the run after it, which is where the loss actually landed: with the + // registration still there this reads prunable-and-dirty rather than + // forgotten, so it is offered to nobody and the note is still on disk. + let again = plan_for(&world, Insistence::NotInsisted); + assert_eq!(again.worktrees().removing(), 0, "{:?}", again.worktrees()); + assert_eq!( + std::fs::read_to_string(unsaved.join("notes.md")).expect("the note is still here"), + "an afternoon\n" + ); + } + + #[test] + fn a_registration_with_nothing_behind_it_is_not_by_itself_something_to_prune() { + // devlaunch#442 review, S3. It frees no bytes, and the metadata prune is + // the whole of the work -- so a run does it, and the run after that has + // nothing to say. Reported as work every time and cleared by none of them, + // `--prune` asked the question and did nothing, forever. + let mut world = World::empty(); + let clone = world.clone_at("r-live-aa", "live"); + world.record("r-live-aa", "live", &clone); + let removed_by_hand = an_agent_worktree(&clone, "agent-gone"); + as_a_host_sees_them(&clone); + std::fs::remove_dir_all(&removed_by_hand).expect("a directory removed by hand"); + world.devpod.lists(&[listed("live", &clone)]); + + let plan = plan_for(&world, Insistence::NotInsisted); + assert_eq!( + plan.worktrees().clones()[0] + .registrations_with_nothing_here() + .container_paths(), + 1 + ); + assert!( + !plan.nothing_to_do(), + "the registration can be cleared, so clearing it is work" + ); + + let clones = clones_for(&world.repos_dir, &world.devpod); + { + let mut context = CommandContext::new(&world.devpod); + prune_clones( + &mut context, + &clones, + &mut world.storage, + &plan, + &mut ignoring(), + ) + .expect("the pass ran"); + } + drop(clones); + + let again = plan_for(&world, Insistence::NotInsisted); + + assert!( + again.nothing_to_do(), + "the prune cleared it, so there is nothing left to offer: {:?}", + again.worktrees() + ); + } + #[test] fn what_a_worktree_holds_is_reported_and_kept_until_the_flag_says_otherwise() { let (world, clone, worktree) = a_live_clone_with_an_agent_worktree(); diff --git a/rust/dl/src/render.rs b/rust/dl/src/render.rs index c08dab27..32c8db7a 100644 --- a/rust/dl/src/render.rs +++ b/rust/dl/src/render.rs @@ -1383,7 +1383,7 @@ pub(crate) fn prune_plan_lines(plan: &PrunePlan) -> Vec { lines.push(format!( "Removing {} that nothing references -- {}:", plan.removing().len(), - describe_usage(&plan.freed()) + describe_usage(&plan.clones_freed()) )); for reclaimable in plan.removing() { let mut line = format!( @@ -1469,14 +1469,27 @@ fn worktree_plan_lines(sweep: &WorktreeSweep) -> Vec { worktree_kept_because(&kept.because) )); } - if found.registrations_with_nothing_here() > 0 { + // Two sentences rather than one number, because the two are different + // facts: a container path is what every worktree an agent made inside a + // devcontainer carries, and a path in this clone with nothing at it is + // somebody's own removal or a run interrupted halfway. Neither frees + // anything. + let nothing_here = found.registrations_with_nothing_here(); + if nothing_here.container_paths() > 0 { lines.push(format!( - " - {} registration(s) here name no directory, so nothing is freed by \ - forgetting them", - found.registrations_with_nothing_here() + " - {} registration(s) here name a path inside a container, which never \ + resolved on this host, so nothing is freed by forgetting them", + nothing_here.container_paths() )); } - if !found.metadata_may_be_pruned() { + if nothing_here.deleted() > 0 { + lines.push(format!( + " - {} registration(s) here name a directory in this clone that is not there \ + any more, so nothing is freed by forgetting them", + nothing_here.deleted() + )); + } + if !found.metadata_gate().open() { lines.push( " - git worktree prune is held back here: it is all-or-nothing across a \ clone, and it would drop the registration that is keeping a worktree above" @@ -1608,7 +1621,7 @@ pub(crate) fn prune_report_lines(report: &PruneReport) -> Vec { let mut lines = vec![format!( "Removed {} clone director(ies) -- {}.", report.removed.len(), - describe_usage(&report.freed()) + describe_usage(&report.clones_freed()) )]; for withheld in &report.withheld { lines.push(format!( diff --git a/rust/dl/tests/lifecycle.rs b/rust/dl/tests/lifecycle.rs index 739b78ba..16649481 100644 --- a/rust/dl/tests/lifecycle.rs +++ b/rust/dl/tests/lifecycle.rs @@ -1466,6 +1466,57 @@ fn plain_force_does_not_reach_the_agent_worktrees() { ); } +#[test] +fn the_clone_sentences_count_clone_bytes_and_the_worktree_sentence_counts_worktree_bytes() { + // devlaunch#442 review, S2. The worktree total used to be chained into the + // plan's and the report's figures and then spent on clone sentences, so + // "Removing 1 that nothing references -- 128.0 KiB" sat above a single + // 120.0 KiB row, and "Removed 0 clone director(ies) -- 8.0 KiB." was printed + // over a run that removed no clone at all. Scaled to the host in devlaunch#426 + // that is 104 GB folded into the number somebody says yes to. + let world = World::with(&["--prunable", "--agent-worktrees"]); + let run = world.answering("no\n", &["--prune"]); + run.exited(0); + + let headline = a_size_in(&run.out, "Removing 1 that nothing references -- "); + let only_row = a_size_in(&run.out, "devlaunch-gone-nobody ("); + assert_eq!( + headline, only_row, + "the headline is a total over the clone rows under it, and there is one: {}", + run.out + ); + + // And the same bytes are not stated twice: the worktree section has its own. + let worktrees = a_size_in(&run.out, "Agent git worktrees inside the clones above -- "); + assert!(worktrees > 0.0, "{}", run.out); + assert_ne!( + worktrees, headline, + "these are different claims about different directories: {}", + run.out + ); +} + +/// The size `dl` printed straight after `after`, in whatever unit it chose. +/// +/// The unit is not normalised because nothing here compares across units: every +/// figure in one run of this fixture is KiB, and a comparison that silently +/// succeeded across units would be the bug rather than the check. +fn a_size_in(text: &str, after: &str) -> f64 { + let at = text + .find(after) + .unwrap_or_else(|| panic!("no {after:?} in {text}")) + + after.len(); + let number: String = text[at..] + .chars() + .take_while(|c| c.is_ascii_digit() || *c == '.') + .collect(); + assert!( + text[at + number.len()..].starts_with(" KiB"), + "this fixture's figures are all KiB: {text}" + ); + number.parse().expect("a number") +} + #[test] fn the_listing_attributes_the_bytes_that_are_agent_worktrees() { // The ask this one is on a different surface for: it was invisible in From c63164610d5a33d3eb8fb346cb0d5040a004a6fb Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Tue, 25 Aug 2026 13:04:06 +0000 Subject: [PATCH 6/7] Say in the docs that the prune states two byte figures The plan's clone total and its worktree total are two claims about two sets of directories, which is the point of separating them; docs/cleanup.md described the --ls attribution and not this. --- docs/cleanup.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/cleanup.md b/docs/cleanup.md index 53b573b3..e7891d26 100644 --- a/docs/cleanup.md +++ b/docs/cleanup.md @@ -361,6 +361,11 @@ worktrees' contents as uncommitted work, correctly, because removing the clone would destroy whatever they hold. So run one keeps the clone and sweeps the worktrees; run two finds the clone empty of them and reclaims it with no flag. +The plan states two figures and they are two different claims: what removing the +clone directories would free, and what the worktrees inside the clones it is +keeping would free. Folding the second into the first made the headline number +describe directories that are not going, and then said the same bytes twice. + The bytes are also attributed in `dl --ls --size`, as a part of the clone's figure and never an addition, because the worktrees are inside it. They were invisible there on the host above, which is how it reached 100%. From 9b6180b41294805300d5432861b5b815a56a411e Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Tue, 25 Aug 2026 13:07:25 +0000 Subject: [PATCH 7/7] Ask a registration about its commits when its admin directory has gone The forgotten arm now has a probe wherever one is possible, and `worktree_status` enumerates the four cases instead of catching two of them in a wildcard. The last one to gain a probe is a registration whose admin directory a concurrent prune took between the listing and the look: no index and no HEAD, so nothing can be asked of the working tree, but the registration still names a head and the commits can be asked about. Narrow, and the arm it lands on is the arm that deletes, which is the whole reason to bother. No public surface moves: both new functions are private. --- .../src/flows/agent_worktrees.rs | 92 +++++++++++++++---- .../src/flows/agent_worktrees/tests.rs | 59 ++++++++++++ 2 files changed, 134 insertions(+), 17 deletions(-) diff --git a/rust/devlaunch-core/src/flows/agent_worktrees.rs b/rust/devlaunch-core/src/flows/agent_worktrees.rs index 57fb4417..76e45348 100644 --- a/rust/devlaunch-core/src/flows/agent_worktrees.rs +++ b/rust/devlaunch-core/src/flows/agent_worktrees.rs @@ -413,30 +413,69 @@ fn unsaved_in( Ok(None) => {} Ok(Some(changed)) => losses.push(changed), } + match unreachable_commits_in(git, clone, bare, directory, head) { + Err(could_not_tell) => return could_not_tell, + Ok(None) => {} + Ok(Some(commits)) => losses.push(commits), + } + match Losses::of(losses) { + Some(losses) => Unsaved::WouldLose(losses), + None => Unsaved::NothingToLose, + } +} + +/// The commits `head` holds that neither the sibling bare cache nor the clone can +/// reach, or that neither could be asked. +/// +/// The cache goes first, for the stale-ref reason [`InTheCache`] carries. +fn unreachable_commits_in( + git: &Git<'_>, + clone: &Path, + bare: Option<&Path>, + directory: &Path, + head: &WorktreeHead, +) -> Result, Unsaved> { match in_the_cache(git, bare, head.commit()) { - InTheCache::Reached => {} + InTheCache::Reached => Ok(None), InTheCache::Beyond | InTheCache::CouldNotSay => { match git.unpushed_commits(clone, head.revision()).said() { - None => { - return Unsaved::CouldNotTell(CouldNotTell::UnpushedNotListed { - clone: directory.to_path_buf(), - branch: head.named(), - reason: "neither the repository cache nor the clone could say whether \ - these commits are anywhere else" - .to_owned(), - }); - } + None => Err(Unsaved::CouldNotTell(CouldNotTell::UnpushedNotListed { + clone: directory.to_path_buf(), + branch: head.named(), + reason: "neither the repository cache nor the clone could say whether these \ + commits are anywhere else" + .to_owned(), + })), Some(unpushed) => { - if let Some(commits) = NonEmpty::of(unpushed.lines().map(str::to_owned)) { - losses.push(Loss::Unpushed(commits)); - } + Ok(NonEmpty::of(unpushed.lines().map(str::to_owned)).map(Loss::Unpushed)) } } } } - match Losses::of(losses) { - Some(losses) => Unsaved::WouldLose(losses), - None => Unsaved::NothingToLose, +} + +/// What a registration can still be asked when its admin directory has gone. +/// +/// A narrow window -- `git worktree list` reads the admin directories, so a +/// registration means one was there a moment ago, and only a concurrent prune +/// takes it away between the listing and the look. But the arm this lands on is +/// the arm that deletes, and the registration still names a head, so the commits +/// can be asked about even though the working tree cannot. Fails towards keeping, +/// like everything else here. +fn unsaved_without_an_admin_dir( + git: &Git<'_>, + clone: &Path, + bare: Option<&Path>, + directory: &Path, + head: &WorktreeHead, +) -> Unsaved { + match unreachable_commits_in(git, clone, bare, directory, head) { + Err(could_not_tell) => could_not_tell, + Ok(None) => Unsaved::NothingToLose, + Ok(Some(commits)) => match Losses::of([commits]) { + Some(losses) => Unsaved::WouldLose(losses), + None => Unsaved::NothingToLose, + }, } } @@ -571,7 +610,26 @@ fn worktree_status( usage: disk_usage::exclusive_usage(directory), }; } - _ => { + // A registration whose admin directory has gone: a concurrent prune, in + // the window between the listing and this look. No index and no HEAD, so + // the working tree cannot be asked -- but the registration still names a + // head, and the commits can be. + (Some(registration), None) => { + return WorktreeStatus::Forgotten { + holds: unsaved_without_an_admin_dir( + git, + clone, + bare, + directory, + ®istration.head, + ), + usage: disk_usage::exclusive_usage(directory), + }; + } + // Neither. Nothing can be asked at all, and that is devlaunch#426's + // category 1: git has let go and the directory is the whole of what is + // left. + (None, None) => { return WorktreeStatus::Forgotten { holds: Unsaved::NothingToLose, usage: disk_usage::exclusive_usage(directory), diff --git a/rust/devlaunch-core/src/flows/agent_worktrees/tests.rs b/rust/devlaunch-core/src/flows/agent_worktrees/tests.rs index 70e0c82a..8f7f0cf3 100644 --- a/rust/devlaunch-core/src/flows/agent_worktrees/tests.rs +++ b/rust/devlaunch-core/src/flows/agent_worktrees/tests.rs @@ -605,6 +605,65 @@ fn a_directory_whose_admin_directory_is_here_is_asked_what_it_holds() { ); } +#[test] +fn a_registration_whose_admin_directory_vanished_is_still_asked_about_its_commits() { + // The window is a concurrent `git worktree prune` between the listing and the + // look. No index and no HEAD, so nothing can be asked of the working tree -- + // but the registration still names a head, and this is the arm that deletes, + // so the commits get asked about. + let world = Clone::new(); + let unpushed = worktrees_dir(&world.clone).join("agent-unpushed"); + std::fs::create_dir_all(worktrees_dir(&world.clone)).expect("the worktrees directory"); + run_git( + &world.clone, + &[ + "worktree", + "add", + "-b", + "agent-unpushed", + &unpushed.display().to_string(), + ], + ); + // A commit that was never pushed anywhere, so nothing else reaches it. + std::fs::write(unpushed.join("work.md"), "nowhere else\n").expect("a file"); + commit(&unpushed, "work nothing else has"); + world.containerise(); + // The listing still names it; the admin directory it named does not exist. + let listing = run_git(&world.clone, &["worktree", "list", "--porcelain"]); + assert!(listing.contains("agent-unpushed"), "{listing}"); + let picture = { + let runner = ProcessRunner::new(); + let git = Git::new(&runner); + ClonePicture::of(&git, &world.clone).expect("git listed the clone") + }; + std::fs::remove_dir_all( + world + .clone + .join(".git") + .join("worktrees") + .join("agent-unpushed"), + ) + .expect("the admin directory, as a concurrent prune leaves it"); + + let runner = ProcessRunner::new(); + let git = Git::new(&runner); + let status = picture + .status_of(&git, &world.clone, Some(&world.bare), &unpushed) + .expect("a linked worktree of this clone"); + + let WorktreeStatus::Forgotten { holds, .. } = &status else { + panic!("expected the forgotten arm, got {status:?}"); + }; + assert!( + matches!(holds, Unsaved::WouldLose(_)), + "the commit is nowhere else and the registration could say so: {holds:?}" + ); + assert!(matches!( + decide(status, Insistence::NotInsisted), + WorktreeDecision::Keep(_) + )); +} + #[test] fn a_gitfile_naming_the_clones_own_admin_directory_is_not_a_worktree() { // The tail of a gitfile is file content, and file content is not trusted to