From 019380cc2acc8f18075e921bac10dbffa92846e5 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Sat, 29 Aug 2026 21:57:08 +0100 Subject: [PATCH 1/7] Rebuild the agent-worktree sweep on the site subtree The unit is a site and everything nested inside it, decided bottom-up and conjunctively, so a worktree nested inside one being removed cannot be lost with it: the collectable arm of a verdict is reachable only when every nested site handed one back, and the caller passes no child list. That is T1 from PR #442's second review, made unrepresentable rather than guarded. What decides a site is a verdict rather than a boolean: Collectable(Proof) | Stands(NonEmpty), where the proof is a private-field witness only a probe that answered can mint, and reasons accumulate up the subtree, so a site that is both dirty and locked reports both and a parent's line names the child. A lock is an unproved, never a loss. The metadata operation is `git worktree remove `, per registration by name, and the clone-wide `git worktree prune` is deleted rather than gated: its domain is a readdir at act time, so a registration created after the plan was printed was inside its blast radius and no plan could name it. Ownership is a join against this clone's own listing, never the gitfile tail: a live worktree of another repository, nested in one of ours, used to be offered for removal unopposed under a reason that was false. The clone is the root of the same forest, which closes the clone-level guard's blindness to nested worktrees, and it takes #522's `BareCache` and passes it to the probe underneath rather than deciding the tag question for itself. A site's own reachability probe gives no tag account: it names one revision and asks the clone about it, where the tag question is about which of a clone's refs the mirror does not have, so `by_tags: None` is the honest answer. `Unsaved` survives as the wire flattening. --- CHANGELOG.md | 33 + README.md | 13 +- docs/cleanup.md | 150 ++ rust/devlaunch-core/src/clients/git.rs | 138 ++ .../src/domain/workspace_state.rs | 20 - .../src/domain/workspace_state/tests.rs | 33 +- .../src/flows/agent_worktrees.rs | 2156 +++++++++++++++++ .../src/flows/agent_worktrees/tests.rs | 1245 ++++++++++ rust/devlaunch-core/src/flows/lifecycle.rs | 670 +++-- rust/devlaunch-core/src/flows/listing.rs | 152 +- rust/devlaunch-core/src/flows/mod.rs | 2 + .../tests/api_removal_is_self_sufficient.rs | 15 +- rust/devlaunch-runner/tests/one_seam.rs | 13 +- rust/dl/src/cli.rs | 69 +- rust/dl/src/commands.rs | 49 +- rust/dl/src/render.rs | 268 +- rust/dl/tests/completion_tables.rs | 6 +- rust/dl/tests/lifecycle.rs | 6 +- 18 files changed, 4744 insertions(+), 294 deletions(-) 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/CHANGELOG.md b/CHANGELOG.md index 9de844d0..88dae660 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -90,6 +90,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 path, and a `metadata.json` written by an older `dl` reads exactly as it did. [docs/cleanup.md](docs/cleanup.md) has the detail. +- **`dl --prune` reclaims the agent git worktrees stranded inside the clones it + keeps, and `--force-worktrees` is the one flag that carries one past a + refusal.** An agent harness working inside a workspace makes its own git + worktrees under `/.claude/worktrees//`, one per task, and nothing + ever collected them: 72 of them on the reference host, 104.5 GB, about 82% of + everything under `repos/`. Every one sat inside a clone belonging to a live + workspace, so the orphan rule not only missed them, it must never fire there. + + The unit is a **site and everything nested inside it**, decided bottom-up and + conjunctively, so a worktree nested inside one being removed cannot be lost + with it: the collectable arm of a verdict is reachable only when every nested + site handed one back. What decides it is a verdict rather than a boolean, + `Collectable(Proof) | Stands(NonEmpty)`, where the proof is a witness + only a probe that answered can mint and reasons accumulate up the subtree, so + a site that is both dirty and locked reports both and a parent's line names + the child that caused it. Dirt is asked per working tree and reachability per + repository, which closes the clone-level guard's blindness to nested worktrees. + + The metadata operation is `git worktree remove `, per + registration, by name, and the clone-wide `git worktree prune` is deleted + rather than gated: its domain is a directory read at act time, so a + registration created after the plan was printed was inside its blast radius and + no plan could name it. Every name the sweep forgets came out of a listing it + read, and the forget only ever follows a directory removal that completed. + + A worktree of a *different* repository nested inside one of ours now stands, is + reported and is never probed: ownership is a join against this clone's own + listing, not a reading of the directory's gitfile tail. Before this it was + offered for removal unopposed, and the reason printed for it was false. + + `dl --ls --size` and `--ls --json`'s `disk` object also name the worktree share + of a clone's figure, as a part of it and never an addition. + ### Changed - **A workspace gets one reusable OpenSSH connection instead of a new one per diff --git a/README.md b/README.md index a9639bfe..e5a0f680 100644 --- a/README.md +++ b/README.md @@ -253,7 +253,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, and reclaim the volumes of workspaces devpod has forgotten | +| `dl --prune` | Remove the clone directories no workspace opens any more, the agent git worktrees inside the clones it keeps, and the volumes of workspaces devpod has forgotten | | `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 | @@ -263,6 +263,9 @@ 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, and [docs/cleanup.md](docs/cleanup.md) says what it carries one past and why it is not +`--force`. ```bash $ dl --version @@ -368,13 +371,17 @@ different jobs: | Command | Takes | Leaves | |---|---|---| -| `dl --prune` | Clone directories no workspace opens, and the volumes of workspaces devpod no longer lists | Every workspace, container and image | +| `dl --prune` | Clone directories no workspace opens, collectable agent git worktrees inside the ones it keeps, and the volumes of workspaces devpod no longer lists | Every workspace, container and image | | `dl --purge` | The workspaces devlaunch created, and its caches | Workspaces it did not create, each named with its source before it asks | | `dl --reconcile` | Nothing | Repairs records that stopped matching the disk | Two promises worth knowing. **Nothing deletes work that exists nowhere else:** a clone with uncommitted or unpushed changes, or one git cannot read to find out, is kept and named, and -`--force` is what overrides that. And **`dl` does not decide which workspaces are finished**, +`--force` is what overrides that. An agent harness working inside a workspace makes its own git +worktrees under the clone, and nothing used to collect them. `dl --ls --size` 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. And **`dl` does not decide which workspaces are finished**, 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. diff --git a/docs/cleanup.md b/docs/cleanup.md index 8bc50905..649be0b5 100644 --- a/docs/cleanup.md +++ b/docs/cleanup.md @@ -387,6 +387,156 @@ 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 non-resolution is what makes the metadata operation work rather +than what stops it: `git worktree remove ` drops exactly +that one registration when the path does not resolve, and refuses when it +resolves to something unrelated, which `--force` cannot argue it out of. + +##### The unit is a site and everything nested in it + +A **site** is a place inside the clone of the shape +`.claude/worktrees/`, nested as deep as the harness nested it. A site is +collectable only when it **and every site nested inside it** are, decided +bottom-up. That is the whole of what makes a nested worktree safe, and it is +structural rather than a check: the removal is a subtree removal, so a unit +narrower than the operation would leave the difference unprotected, which is how +a nested worktree holding an afternoon of work, an unpushed commit, or a lock +used to be deleted with its parent with no flag typed. + +Containment comes from the filesystem walk, never from comparing the paths git +recorded. On a host every recorded path is a string about another machine, and a +worktree of a different repository has no entry in this clone's listing at all. +Recorded paths are used for exactly two things, neither of which resolves them: +matching a registration to a place, and naming the registration to forget. + +There is no `git worktree prune` here, and its absence is the design rather than +an omission. That command's domain is a directory read **at the moment it runs**, +so a registration created after the plan was printed is inside its blast radius +and no plan can name it. Three registration states it reaches never appear in any +listing either. An operation whose domain `dl` cannot enumerate is one it cannot +fail towards keeping with, so the sweep drops registrations one at a time, by +name, and every name came out of a listing it read. Those unlisted states are +reclaimed by `git gc`'s own `worktree prune --expire`, on `gc.worktreePruneExpire`, +three months by default: a named third party rather than a straggler. + +##### What proves one safe + +A site's verdict is **collectable, with a proof, or standing, with at least one +reason**. A reason is either work that was found or a question that could not be +put, and reasons accumulate up the subtree, so a site that is both dirty and +locked reports both and a parent's line names the child that caused it. There is +no third value and no way to reach the collectable arm by nothing having +objected: the proof is a witness that only a probe which actually answered can +mint, so "nothing objected" and "nothing was asked" are different answers. + +Four questions, and their scopes are not the same. What is at the site at all is +per site. **Whether the working tree holds anything that exists nowhere else is +per working tree**, which is why the clone's own `git status` cannot answer for +what is nested in it: `.claude/worktrees/` is ordinarily gitignored, and a nested +worktree has an index of its own. Ignored files count, because +`git worktree remove` deletes a worktree whose only content is gitignored, exit 0 +and silent. **Whether the commits exist somewhere else is per repository**, and +it is asked of the sibling `.bare` cache first: a workspace clone is cut from the +bare and then 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. Whether a third party claims the site is +the lock, and a lock is an *unproved*, never a loss: git documents it as saying +nothing about whether anybody is working in there, so reporting it as work would +be inventing work that may not exist. + +Stashes need no probe. A `git stash push` from inside a linked worktree writes +the clone's own `refs/stash`, survives the directory, and is reached by +`rev-list --all`; nothing here removes the shared ref store. + +`--force-worktrees` is the one flag that carries a site 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. + +##### A worktree of another repository + +A directory in the worktrees place whose registration is not in **this** clone's +listing is not `dl`'s to remove. That covers a worktree of a different +repository, a plain directory, an unreadable gitfile and a symbolic link. Each +stands, is named, and pins everything above it, and none of them is ever probed. + +Ownership is the registration join and not the `.git` gitfile: a gitfile tail +says a directory is a worktree of *some* repository, and reading it as this one's +is how a live worktree of another repository, holding uncommitted work, was once +offered for removal unopposed under the printed reason "git has already forgotten +it". That was false: the repository that registered it had forgotten nothing. git contributes nothing to that case, so nothing here leans on it: its +one unforceable refusal fires on a recorded path handed to `git worktree remove`, +an invocation `dl` never makes for a foreign worktree, and it says nothing at all +about a directory removal. + +`dl` will never reclaim those, and says so with the owning repository named. +`--force-worktrees` is what removes one, and the honest thing to do first is +usually to take it back from the repository that owns it. + +##### In a container + +Nothing here detects containers and no arm exists to protect one. Two properties +do that instead. `--prune`'s domain is enumerated from the cache directory alone, +and `dl` never mounts a cache clone into a container at a path inside that +container's own cache, so a container's own clone is never in the domain. And the +directory goes before the forget, with nothing forgotten on a partial removal, so +the recorded path does not resolve at the moment the forget runs even where it +resolved a moment earlier. + +``` +$ dl --prune +Clone directories under /home/you/.cache/devlaunch/repos: + +Leaving 1: + - /home/you/.cache/devlaunch/repos/blooop/devlaunch/devlaunch-main-zovo: workspace devlaunch-main-zovo still opens it + +Agent git worktrees inside the clones above -- 6.0 GiB: + + /home/you/.cache/devlaunch/repos/blooop/devlaunch/devlaunch-main-zovo: + - removing .../.claude/worktrees/agent-a49a (5.8 GiB), and dropping its 1 registration(s) + - removing .../.claude/worktrees/agent-a8da (204.0 MiB), and dropping its 2 registration(s) + - leaving .../.claude/worktrees/agent-b120: git is holding it locked (claude session) -- 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] +``` + +The plan states two byte 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%. + +For an **orphan** clone that has agent worktrees in it, reclaiming takes two runs. +The clone's own verdict conjoins every site inside it, correctly, because removing +the clone destroys 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 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/src/clients/git.rs b/rust/devlaunch-core/src/clients/git.rs index 2d773be4..5fa7c9f2 100644 --- a/rust/devlaunch-core/src/clients/git.rs +++ b/rust/devlaunch-core/src/clients/git.rs @@ -388,6 +388,16 @@ fn returncode(exit: Exit) -> i32 { /// The one git client. Holds the runner and nothing else — no cache, no state, /// no configuration: every verb is told which repository it is about. +/// Whether a `worktree remove` may carry `--force --force` — the spelling that +/// takes a forget past a lock, and nothing else: the refusal that matters (a +/// recorded path resolving to something unrelated) survives it. Its own two-arm +/// type rather than a boolean so a call site reads as the decision it is. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ForgetForce { + AsAsked, + PastALock, +} + #[derive(Clone, Copy)] pub struct Git<'r> { runner: &'r dyn Runner, @@ -622,6 +632,134 @@ impl<'r> Git<'r> { .map(|stdout| tag_refs_in(stdout.trim_end_matches('\n').to_owned())) } + // --------------------------------------------- agent worktrees (#426, #454) + + /// 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` 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 records 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 exactly one registration, by the path the listing printed. + /// + /// This is a supported single-registration prune, and the reason it works + /// from a host is the opposite of what an earlier build wrote here: the + /// recorded path **does not resolve**, so git skips its working-tree + /// validation and its dirty check and drops the one name. Measured on git + /// 2.51.1 and 2.43.0 (devlaunch#448, re-measured on #445): a recorded path + /// that resolves to something unrelated is refused, and `-f -f` does not get + /// past that refusal — the one direction git holds on its own. + /// + /// Because the dirty check sits inside git's own `file_exists(wt->path)`, a + /// non-resolving path is dropped with **no safety check at all** — exit 0, + /// silent, whatever the host directory holds. The caller's verdict is the + /// entirety of the protection, which is why the caller only ever forgets a + /// name after the directory it accounted for is gone. + /// + /// `--force` twice is what carries a forget past a lock, and it is passed + /// only when the caller's plan carried that insistence for this unit. + /// + /// There is deliberately no `worktree prune` beside this. Its domain is a + /// readdir over `$GIT_DIR/worktrees` at act time — registrations created + /// after a plan was printed included — so no plan can name its blast radius, + /// and devlaunch deleted it rather than gating it (devlaunch#445). + pub(crate) fn worktree_remove( + &self, + clone: &Path, + recorded: &Path, + force: ForgetForce, + ) -> GitAnswer { + let recorded = recorded.display().to_string(); + let mut args = vec!["worktree", "remove"]; + if let ForgetForce::PastALock = force { + args.extend(["--force", "--force"]); + } + args.push(&recorded); + self.about(clone, &args) + } + + /// The porcelain status of one linked worktree, asked through the clone's + /// admin directory for it, **ignored files included**. + /// + /// **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. `--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. + /// + /// `--ignored` is load-bearing, not thoroughness: `git worktree remove` + /// deletes a worktree whose only content is gitignored, exit 0 and silent, + /// so if this answer omitted ignored files the caller's predicate — the only + /// protection those bytes have — would be blind to them (devlaunch#462). + /// + /// Every line git prints comes back, nested agent worktrees included. 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. + 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(), + "--ignored".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()) + } + + /// Commits reachable from *rev* in *clone* that no remote-tracking ref + /// contains — the per-revision spelling of [`Git::unpushed_commits`], for a + /// question that has to attribute its answer to one worktree's checkout + /// rather than to the clone as a whole. + pub(crate) fn unpushed_commits_from(&self, clone: &Path, rev: &str) -> GitAnswer { + self.about(clone, &["log", "--oneline", rev, "--not", "--remotes"]) + } + + /// 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/domain/workspace_state.rs b/rust/devlaunch-core/src/domain/workspace_state.rs index 4eeba4f0..29ba2c12 100644 --- a/rust/devlaunch-core/src/domain/workspace_state.rs +++ b/rust/devlaunch-core/src/domain/workspace_state.rs @@ -147,26 +147,6 @@ pub enum Unsaved { CouldNotTell(CouldNotTell), } -impl Unsaved { - /// How an answer reads to a tool: one key, and the key says which kind it is. - /// - /// Deliberately not a nullable string. A caller that reads `nothingToLose` - /// has been told nothing would be lost; a caller that reads `couldNotTell` - /// has been told dl does not know, and cannot have got there by finding a - /// field absent or null. The shape `disk_usage`'s rendering already uses, for - /// the same reason. - /// - /// `null` survives one level up, in the listing, where it keeps its other - /// meaning: there is no clone of dl's own there to inspect. - pub(crate) fn as_json(&self) -> serde_json::Value { - match self { - Self::NothingToLose => serde_json::json!({ "nothingToLose": true }), - Self::WouldLose(losses) => serde_json::json!({ "wouldLose": losses.describe() }), - Self::CouldNotTell(cause) => serde_json::json!({ "couldNotTell": cause.describe() }), - } - } -} - // There is deliberately no `may_delete()` here. Two of the three arms refuse a // delete, and a bool saying so is the sentinel this module exists to not have: // the guard has to name the arm anyway — it prints what would be lost, or what diff --git a/rust/devlaunch-core/src/domain/workspace_state/tests.rs b/rust/devlaunch-core/src/domain/workspace_state/tests.rs index f1042b4e..fc2aede5 100644 --- a/rust/devlaunch-core/src/domain/workspace_state/tests.rs +++ b/rust/devlaunch-core/src/domain/workspace_state/tests.rs @@ -1220,26 +1220,35 @@ fn a_recorded_path_that_is_not_a_path_at_all_is_could_not_tell() { #[test] fn each_arm_renders_as_one_key_that_names_it() { - // The exact wire format: wf parses this. + // The exact wire format: wf parses this. The verdict is the internal answer + // now, and this is its one flattening to the wire — a clone with no agent + // worktrees reads exactly as it always did. + use crate::flows::agent_worktrees::{Blank, Place, Reason, Verdict}; assert_eq!( - Unsaved::NothingToLose.as_json().to_string(), + Verdict::test_collectable().unsaved_json().to_string(), r#"{"nothingToLose":true}"# ); assert_eq!( - Unsaved::WouldLose(Losses::one(Loss::Unpushed { - commits: NonEmpty::one("abc123 more".to_owned()), - by_tags: None, - })) - .as_json() + Verdict::test_stands(vec![Reason::Holds { + at: Place::TheCloneItself, + losses: Box::new(Losses::one(Loss::Unpushed { + commits: NonEmpty::one("abc123 more".to_owned()), + by_tags: None, + })), + }]) + .unsaved_json() .to_string(), r#"{"wouldLose":"1 unpushed commit(s)"}"# ); assert_eq!( - Unsaved::CouldNotTell(CouldNotTell::GitCouldNotRead { - clone: PathBuf::from("/c"), - reason: "git said no".to_owned(), - }) - .as_json() + Verdict::test_stands(vec![Reason::CouldNotProve { + at: Place::TheCloneItself, + blank: Blank::GitWouldNotSay(CouldNotTell::GitCouldNotRead { + clone: PathBuf::from("/c"), + reason: "git said no".to_owned(), + }), + }]) + .unsaved_json() .to_string(), r#"{"couldNotTell":"git could not read /c: git said no"}"# ); 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..f007ef81 --- /dev/null +++ b/rust/devlaunch-core/src/flows/agent_worktrees.rs @@ -0,0 +1,2156 @@ +//! 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. +//! +//! # The unit is a site subtree (devlaunch#445) +//! +//! The sweep reasons about **sites**: places inside the clone of the shape +//! `.claude/worktrees/[/.claude/worktrees/…]`, together with +//! everything nested inside them. A site's verdict is decided bottom-up and +//! conjunctively — a site is collectable only when it *and every site nested in +//! it* are — because the operation that removes bytes is a subtree removal, and a +//! unit narrower than the operation's blast radius is how a nested worktree's +//! uncommitted work, unpushed commits or lock got deleted with its parent, with +//! no flag typed (devlaunch#442 review, T1). That state has no representation +//! here: the collectable arm of a verdict is derived by a recursion that visits +//! every child itself, so a parent whose subtree holds a standing site cannot be +//! handed a collectable verdict by anyone. +//! +//! Containment edges come from the **filesystem walk**, never from +//! prefix-comparing recorded path strings: on a host every recorded path is a +//! string about another machine, and a worktree of a different repository has no +//! edge in this clone's listing at all. Recorded paths are used for exactly two +//! things, neither of which resolves them — the suffix join, and the argument to +//! `git worktree remove`. +//! +//! # The two operations, and their two radii +//! +//! - **Removing bytes** is [`remove_tree_as_far_as_it_goes`], whose radius is a +//! subtree. Its unit is the site subtree above. +//! - **Forgetting a registration** is `git worktree remove `, per registration, by name. Measured on git 2.51.1 and 2.43.0: it +//! drops exactly one registration when the recorded path does not resolve — +//! which is every container-registered worktree seen from a host — and it +//! *works because the path does not resolve*, not despite it. When the recorded +//! path resolves to something unrelated, git refuses, and `-f -f` does not get +//! past that refusal. Its unit is one name. +//! +//! There is deliberately no `git worktree prune` here. Its domain is a `readdir` +//! over `$GIT_DIR/worktrees` **at act time**, so no plan-time unit can equal it: +//! a registration created after the plan was printed is in its blast radius, and +//! three registration states it reaches never appear in any listing. An operation +//! whose domain devlaunch cannot enumerate is an operation devlaunch cannot fail +//! towards keeping with, so it is deleted rather than gated (devlaunch#445, and +//! review T2 on devlaunch#442 is what the gate kept failing to hold). The +//! registrations git's own `gc` reclaims under `gc.worktreePruneExpire` are a +//! named third party's, not stragglers. +//! +//! `git worktree remove` also applies **no dirty check at all** when the recorded +//! path does not resolve — the cleanliness check sits inside git's own +//! `file_exists(wt->path)` — so the metadata operation contributes no safety of +//! its own. The verdict here is the entirety of what stands between a +//! registration and somebody's afternoon, and nothing in this module leans on git +//! refusing. +//! +//! # Ownership is a registration join, never a gitfile tail (devlaunch#463) +//! +//! A directory's own `.git` gitfile tail (`…/.git/worktrees/`) says only +//! that the directory is *a* linked worktree, registered under **some** +//! repository's admin directory — and reading that as "one of ours" is how a live +//! worktree of a different repository, nested inside one of our clones and +//! holding uncommitted work, was offered for removal unopposed under the printed +//! reason "git has already forgotten it", which was false. So the tail is the +//! test for *is this a worktree at all*, never for *whose*: a site is ours only +//! when a registration **from this clone's own listing** joins it by its place +//! inside the clone, and the admin directory used to probe it is derived from +//! that joined registration, never taken from a name. A worktree this clone's +//! listing does not account for stands, is reported, and is never probed. +//! +//! git contributes nothing to that case. Its one unforceable refusal fires only +//! on a recorded path handed to `worktree remove`, which is an invocation this +//! module never makes for a foreign worktree; it says nothing about the byte +//! removal, which destroys a nested foreign tree exit 0 and silent; and `is not a +//! working tree` is this repository declining to recognise, not git protecting a +//! foreign owner. +//! +//! # What protects a run inside a container +//! +//! Nothing in this module detects containers, and no arm of it exists to protect +//! one. Two properties carry that instead (devlaunch#462): +//! +//! - **P1, locality.** The domain `--prune` sweeps is enumerated from the cache +//! directory alone, and devlaunch never mounts a cache clone into a container +//! at a path inside *that* container's own cache. So every registration in an +//! enumerated clone was recorded one namespace in from the one enumerating it, +//! and the container's own clone — bind-mounted at `/workspaces/` — is +//! never in the domain at all. +//! - **P2, ordering.** The directory goes first and the forget follows, and +//! nothing is forgotten on a partial removal. So the recorded path does not +//! resolve at the moment the forget runs, even where it resolved a moment +//! earlier. +//! +//! git contributes none of this either: see the note on the missing dirty check +//! above. +//! +//! # What a lock is, and what it is not +//! +//! git documents a lock as saying the worktree may be on a portable device, and +//! nothing else. It is a claim over the site by a party this pass cannot +//! interrogate — which is precisely *could not be proved*, so it lands in +//! [`Blank::ThirdPartyClaim`] and never in [`Reason::Holds`]: reporting a lock as +//! a loss would be inventing work that may not exist. Nothing on a host can prove +//! a worktree idle either, so nothing here claims it; every refusal names the +//! fact it rests on. +//! +//! The race with a container running `git worktree add` is real and cannot be +//! closed from here — a container is not a participant in devlaunch's repository +//! lock. What this module bounds is its radius: the acting pass re-derives every +//! verdict immediately before acting, a site the plan did not approve cannot be +//! collectable then, and the only thing left between the re-check and the act is +//! one subtree the pass walked microseconds earlier or one registration it just +//! looked at. +//! +//! # Stashes are not a question +//! +//! Measured: a `git stash push` from inside a linked worktree writes the clone's +//! own `refs/stash`, survives the worktree directory, and is reached by +//! `rev-list --all`. It is in the shared ref store, which nothing here removes. +//! No probe asks about it, and none should be added. + +use std::path::{Path, PathBuf}; + +use crate::clients::git::{ForgetForce, Git}; +use crate::domain::workspace_state::{ + self, BareCache, CouldNotTell, Loss, Losses, NonEmpty, Unsaved, +}; +use crate::flows::disk_usage::{self, DiskUsage}; +use crate::flows::lifecycle::Insistence; +use crate::flows::repo_manager::{Refusal, TreeSweep, 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"]; + +/// The `.git` gitfile's prefix, and the admin directory tail it names. +const GITFILE_PREFIX: &str = "gitdir:"; +const ADMIN_DIR: [&str; 2] = [".git", "worktrees"]; + +// =========================================================================== +// two newtypes that carry the module's discipline +// =========================================================================== + +/// The path git printed for one registration, kept whole. +/// +/// The only constructor parses `git worktree list --porcelain`, so a `Recorded` +/// in hand *is* the fact that a listing named it — which is what makes "no git +/// invocation ever names a registration the pass did not read from a listing" +/// structural rather than a rule: the forget's argument is a `Recorded`, and a +/// registration created after the plan was printed has never been one. +/// +/// It is never resolved and never assembled from parts. It is used for two +/// things: the suffix join against the walk, and the argument to +/// `git worktree remove`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Recorded { + path: PathBuf, +} + +impl Recorded { + /// The path as git printed it, for the report and for the forget. Never a + /// path to hand to the filesystem. + pub fn as_path(&self) -> &Path { + &self.path + } +} + +/// Where inside a clone a site sits: +/// `.claude/worktrees/[/.claude/worktrees/…]`. +/// +/// The join key between the two views of one set — the directories the walk +/// finds and the places the listing's registrations name — and the only thing a +/// directory is ever matched on. Built from a recorded path's components or from +/// a walked path relative to the clone; never from anything else. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct Inside { + place: String, +} + +impl Inside { + pub fn as_str(&self) -> &str { + &self.place + } +} + +/// What a standing reason is about: the clone itself, or one site inside it. +/// +/// Two arms rather than an optional site, because "no site" is a different fact +/// from "the clone", and a report that interpolates the wrong one misattributes +/// the loss. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Place { + TheCloneItself, + ASite(Inside), +} + +// =========================================================================== +// what git's listing says +// =========================================================================== + +/// 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 reachability question is asked +/// about the *commit* 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 for a + /// branch, so a branch and a tag of one name cannot be taken for each other, + /// and the commit itself for a detached head. + 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/`. +/// +/// There is deliberately no `prunable` field. `prunable` is git's claim about +/// whether `/.git` exists as a filesystem entry — a fact about a path +/// this module's whole discipline is to not resolve — and reading it is +/// resolving by proxy (devlaunch#446 §5c). Nothing here reads it, and the +/// `Held` arm that used to hang off it is gone with it. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Registration { + /// Where inside a clone the registration sits — the join key. + inside: Inside, + /// The path git printed. See [`Recorded`]. + recorded: Recorded, + head: WorktreeHead, + locked: Option, +} + +/// 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 sites inside one clone, and a +/// registration outside has no site 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 recorded = None; + let mut reference = None; + let mut commit = None; + let mut locked = None; + 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)); + recorded = Some(Recorded { + path: PathBuf::from(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()), + }); + } + _ => {} + } + } + let (Some(inside), Some(recorded), Some(commit)) = (inside, recorded, commit) else { + continue; + }; + let head = match reference { + Some(reference) => WorktreeHead::Branch { reference, commit }, + None => WorktreeHead::Detached { commit }, + }; + found.push(Registration { + inside, + recorded, + head, + locked, + }); + } + 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(Inside { + place: 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) +} + +/// Whether `directory`'s own `.git` gitfile names a `…/.git/worktrees/` +/// admin directory — the test for *is this a linked worktree at all*. +/// +/// **Never the test for whose.** The tail says "a git worktree, registered under +/// some repository's admin directory", and *some* is the whole of what it says: +/// reading it as *this clone's* is the defect devlaunch#463 measured, a live +/// foreign worktree destroyed unopposed. Ownership is [`ClonePicture`]'s join. +/// +/// A `..` tail would name the clone's own `.git` and `.` its `.git/worktrees`, +/// and the tail is file content, which is not trusted to be a name +/// (devlaunch#442 review, S6) — those shapes read as *not a worktree*, which +/// stands the site. +fn is_a_linked_worktree(directory: &Path) -> Option { + let gitfile = directory.join(".git"); + let Ok(stat) = std::fs::symlink_metadata(&gitfile) else { + // No `.git` at all: a plain directory, which the caller tells apart + // from a gitfile that would not read. + return Some(false); + }; + if !stat.is_file() { + return Some(false); + } + let content = std::fs::read_to_string(&gitfile).ok()?; + let Some(named) = content.trim().strip_prefix(GITFILE_PREFIX) else { + return Some(false); + }; + let parts: Vec = Path::new(named.trim()) + .components() + .map(|part| part.as_os_str().to_string_lossy().into_owned()) + .collect(); + let Some((name, rest)) = parts.split_last() else { + return Some(false); + }; + let Some((worktrees, rest)) = rest.split_last() else { + return Some(false); + }; + let Some(dot_git) = rest.last() else { + return Some(false); + }; + if name == "." || name == ".." || name.is_empty() { + return Some(false); + } + Some(dot_git == ADMIN_DIR[0] && worktrees == ADMIN_DIR[1]) +} + +// =========================================================================== +// the picture: one listing, one admin readdir, taken together +// =========================================================================== + +/// What this clone's own records say, read once per pass. +/// +/// The acting pass has to classify every site *again* immediately before acting, +/// 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, + /// Each admin directory under `/.git/worktrees/`, with the recorded + /// path its own `gitdir` file names. The *path* half comes from a readdir of + /// the clone's own `.git`, never from file content; the content half is used + /// as a comparison key against the listing and nothing else. + admins: Vec, +} + +struct AdminDir { + path: PathBuf, + /// The `gitdir` file's content, trimmed: `/.git`. + records: String, +} + +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 site as unaccounted-for, + /// and while that direction now *stands* sites rather than deleting them, a + /// plan built on a refusal is still a plan that is not about the clone. + pub(crate) fn of(git: &Git<'_>, clone: &Path) -> Option { + let listing = git.worktree_listing(clone).said()?; + Some(Self { + registered: registrations(&listing), + admins: admin_dirs(clone), + }) + } + + /// The admin directory recording exactly `recorded`, when there is one. + /// + /// The join is the whole recorded path as one string against the whole + /// `gitdir` content — `git worktree list` itself is generated from those + /// same files, so the two spell the path identically — and it is what makes + /// an admin directory a *channel* derived from the join rather than + /// ownership evidence: a foreign worktree whose leaf name collides with a + /// name this clone has an admin directory for can never be handed that + /// admin directory, because no registration of this clone records its path + /// (devlaunch#463). + fn admin_for(&self, recorded: &Recorded) -> Option<&Path> { + let names = format!("{}/.git", recorded.as_path().display()); + self.admins + .iter() + .find(|admin| admin.records == names) + .map(|admin| admin.path.as_path()) + } + + fn joined_to(&self, inside: &Inside) -> Vec<&Registration> { + self.registered + .iter() + .filter(|it| &it.inside == inside) + .collect() + } + + /// Registrations under a `.claude/worktrees/` with nothing at their place in + /// this clone. The place is a join of the clone root and the suffix, built + /// for one existence check; the recorded path itself is never resolved. + fn nothing_at_their_place(&self, clone: &Path) -> Vec<&Registration> { + self.registered + .iter() + .filter(|it| !clone.join(&it.inside.place).exists()) + .collect() + } +} + +/// Every admin directory under `/.git/worktrees/`, with what its `gitdir` +/// file records. +fn admin_dirs(clone: &Path) -> Vec { + let root = clone.join(ADMIN_DIR[0]).join(ADMIN_DIR[1]); + let Ok(entries) = std::fs::read_dir(&root) else { + return Vec::new(); + }; + entries + .filter_map(Result::ok) + .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())) + .filter_map(|entry| { + let path = entry.path(); + let records = std::fs::read_to_string(path.join("gitdir")).ok()?; + Some(AdminDir { + path, + records: records.trim().to_owned(), + }) + }) + .collect() +} + +// =========================================================================== +// the forest of sites +// =========================================================================== + +/// What occupies one site. Three arms, no catch-all. +#[derive(Debug)] +enum SiteKind { + /// A linked worktree of this clone: a registration from this clone's own + /// listing joins the place. `admin` is derived from the joined registration + /// inside [`ours_here`] and never taken from a gitfile name, so "ours on the + /// strength of a gitfile" has no representation. + OursHere { + at: PathBuf, + joined: NonEmpty, + admin: Option, + }, + /// Registered in this clone's listing, with nothing at its place. No bytes; + /// the forget is the whole of the work — and it still needs the reachability + /// question answered, because a registration can be the last ref reaching a + /// detached worktree's commits (devlaunch#446 §5a). + OursGone { joined: NonEmpty }, + /// A directory in the worktrees place this clone's listing cannot account + /// for. Never provable safe from here, so never collectable. + NotOurs { at: PathBuf, why: Unaccountable }, +} + +/// One registration joined to a site. +#[derive(Debug, Clone)] +struct Joined { + recorded: Recorded, + head: WorktreeHead, + locked: Option, +} + +impl From<&Registration> for Joined { + fn from(registration: &Registration) -> Self { + Self { + recorded: registration.recorded.clone(), + head: registration.head.clone(), + locked: registration.locked.clone(), + } + } +} + +/// Why a site is not this clone's to account for. +/// +/// Named arms rather than one, because the report's words differ and only +/// [`Unaccountable::RegisteredElsewhere`] has a named reclaimer: the repository +/// that registered it, which is the only party that can complete the removal. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Unaccountable { + /// A linked worktree — its gitfile names some repository's admin directory — + /// that this clone's listing does not account for. A worktree of a different + /// repository, or one whose registration a routine `git gc` expired; the two + /// are indistinguishable from here, and neither is this module's to remove. + RegisteredElsewhere, + /// A `.git` that could not be read as a worktree gitfile: unreadable, or a + /// tail that does not normalise to `…/.git/worktrees/`. + GitfileUnreadable, + /// No gitfile at all: a plain directory somebody put here. + PlainDirectory, + /// A symbolic link. Never followed — following one walks a removal out of + /// the tree `--prune` is scoped to. + SymlinkInThePlace, +} + +/// One site: a place inside the clone, what occupies it, and every site nested +/// inside it. `nested` comes from the walk, which is the one source of +/// containment edges. +#[derive(Debug)] +struct Site { + inside: Inside, + kind: SiteKind, + nested: Vec, +} + +impl Site { + /// Where the site sits on this host, for the report and for the removal. + /// The clone root joined with the walk's own suffix — never a recorded path. + fn at(&self, clone: &Path) -> PathBuf { + clone.join(&self.inside.place) + } + + /// Every path in this subtree that is a site, this one included — what the + /// dirt filter uses to tell "accounted for by the forest" apart from plain + /// content. + fn paths_into(&self, clone: &Path, into: &mut Vec) { + into.push(self.at(clone)); + for child in &self.nested { + child.paths_into(clone, into); + } + } +} + +/// Build one clone's forest: nodes from the union of the walk and the listing, +/// edges from the walk alone. +fn forest_of(clone: &Path, picture: &ClonePicture) -> Vec { + let mut joined_somewhere: Vec<&Inside> = Vec::new(); + let mut roots = walk_sites(clone, clone, picture, &mut joined_somewhere); + // Registrations with nothing at their place become sites of their own. They + // have no filesystem presence, so the walk gives them no edges: they stand + // beside the walked roots, and their whole work is the forget. + let mut gone: Vec<&Registration> = picture + .nothing_at_their_place(clone) + .into_iter() + .filter(|registration| !joined_somewhere.contains(&®istration.inside)) + .collect(); + gone.sort_by(|left, right| left.inside.cmp(&right.inside)); + let mut by_place: Vec<(Inside, Vec)> = Vec::new(); + for registration in gone { + match by_place + .iter_mut() + .find(|(place, _)| place == ®istration.inside) + { + Some((_, list)) => list.push(Joined::from(registration)), + None => by_place.push(( + registration.inside.clone(), + vec![Joined::from(registration)], + )), + } + } + for (inside, list) in by_place { + let joined = NonEmpty::of(list).expect("grouped from at least one registration"); + roots.push(Site { + inside, + kind: SiteKind::OursGone { joined }, + nested: Vec::new(), + }); + } + roots +} + +/// The sites directly under `holder`'s `.claude/worktrees/`, each with its own +/// subtree. `holder` is the clone itself at the top and a site's directory below. +fn walk_sites<'p>( + clone: &Path, + holder: &Path, + picture: &'p ClonePicture, + joined_somewhere: &mut Vec<&'p Inside>, +) -> Vec { + let root = worktrees_dir(holder); + let Ok(entries) = std::fs::read_dir(&root) else { + return Vec::new(); + }; + let mut children: Vec<(PathBuf, bool)> = entries + .filter_map(Result::ok) + .filter_map(|entry| { + let kind = entry.file_type().ok()?; + if kind.is_symlink() { + Some((entry.path(), true)) + } else if kind.is_dir() { + Some((entry.path(), false)) + } else { + // A file directly in the worktrees place is not a site. It is + // untracked content, which the holder's own dirt question keeps. + None + } + }) + .collect(); + children.sort(); + let mut sites = Vec::new(); + for (at, is_symlink) in children { + let Some(inside) = inside_the_clone(clone, &at) else { + continue; + }; + if is_symlink { + sites.push(Site { + inside, + kind: SiteKind::NotOurs { + at, + why: Unaccountable::SymlinkInThePlace, + }, + nested: Vec::new(), + }); + continue; + } + let joined = picture.joined_to(&inside); + let kind = if joined.is_empty() { + SiteKind::NotOurs { + at: at.clone(), + why: match is_a_linked_worktree(&at) { + Some(true) => Unaccountable::RegisteredElsewhere, + Some(false) => match std::fs::symlink_metadata(at.join(".git")) { + Ok(_) => Unaccountable::GitfileUnreadable, + Err(_) => Unaccountable::PlainDirectory, + }, + None => Unaccountable::GitfileUnreadable, + }, + } + } else { + joined_somewhere.extend(joined.iter().map(|it| &it.inside)); + ours_here(&at, &joined, picture) + }; + // Descend whatever the kind. The verdict recursion covers everything — + // a worktree of ours nested inside a foreign site is still ours and + // still collectable on its own proof — where the byte recursion stops at + // the outermost thing that goes. The two recursions are not the same + // recursion, and conflating them was the T1 hole. + let nested = walk_sites(clone, &at, picture, joined_somewhere); + sites.push(Site { + inside, + kind, + nested, + }); + } + sites +} + +/// The one constructor for "this site is ours": takes the joined registrations +/// and derives the admin channel from them. +fn ours_here(at: &Path, joined: &[&Registration], picture: &ClonePicture) -> SiteKind { + let admin = joined + .iter() + .find_map(|registration| picture.admin_for(®istration.recorded)) + .map(Path::to_path_buf); + let joined = NonEmpty::of(joined.iter().map(|it| Joined::from(*it))) + .expect("ours_here is called only with a join in hand"); + SiteKind::OursHere { + at: at.to_path_buf(), + joined, + admin, + } +} + +// =========================================================================== +// the verdict (devlaunch#446) +// =========================================================================== + +/// What this pass established about one site, or about a clone as the root of +/// the same forest. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Verdict { + /// Every question that bears on this subtree was put, and answered clear. + Collectable(Proof), + /// At least one reason it stands, and reasons accumulate up the subtree. + Stands(Standing), +} + +/// The witness that every question was asked and answered clear. +/// +/// Private fields, no `Default`, no public constructor: "nothing objected" and +/// "nothing was asked" are different values, and `Safe` is not the fallthrough +/// of a filter — it is a thing you have to be handed. The `public-api.rest.txt` +/// snapshot is where the absent constructor is pinned. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Proof { + how: ProofHow, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum ProofHow { + /// A present worktree of ours: dirt, reachability and the lock all answered. + EverythingAsked { + _clean: Clean, + _elsewhere: Elsewhere, + _unclaimed: Unclaimed, + }, + /// A registered site with nothing at it: no bytes, and the commits the + /// registration still names were found somewhere else. Emptiness alone never + /// carries a registered site. + HoldsNothing { + _empty: NoBytes, + _elsewhere: Elsewhere, + }, + /// The clone root's own probes answered clear. Minted from + /// [`Unsaved::NothingToLose`], whose own discipline (devlaunch#171) is that + /// it is produced only by probes that answered. + CloneProbesAnsweredClear, + /// devlaunch has no clone of its own here, so there is nothing of ours to + /// protect and no business inspecting somebody's checkout to find something. + NothingOfOurs, +} + +/// Q2 answered: the working tree holds nothing that exists nowhere else. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Clean(()); + +/// Q3 answered: every commit reachable from here is reachable from a ref in a +/// repository this pass does not remove — as of the last fetch, which is what +/// the report says. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Elsewhere(()); + +/// Q4 answered: no third party asserts a claim on the site. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Unclaimed(()); + +/// The bounded walk found nothing at the site. +#[derive(Debug, Clone, PartialEq, Eq)] +struct NoBytes(()); + +/// At least one reason a subtree stands. Non-empty by construction, and the +/// fields are private so "stands, and here is the empty list" has no +/// representation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Standing { + first: Reason, + rest: Vec, +} + +impl Standing { + fn one(reason: Reason) -> Self { + Self { + first: reason, + rest: Vec::new(), + } + } + + fn of(reasons: Vec) -> Option { + let mut reasons = reasons.into_iter(); + let first = reasons.next()?; + Some(Self { + first, + rest: reasons.collect(), + }) + } + + pub fn iter(&self) -> impl Iterator { + std::iter::once(&self.first).chain(self.rest.iter()) + } +} + +/// One thing standing a subtree: proved unsafe, or could not be proved. Each +/// names the place it came from, which is what makes a parent's refusal report +/// the *child* that caused it rather than the parent it pinned. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Reason { + /// A probe answered, and the answer was work that exists nowhere else. + /// + /// Boxed, and the reason is the shape of everything around it: a `Reason` + /// rides inside a `Standing`, which rides inside a `Verdict`, which is the + /// answer every clone and every site is carried as — most of them + /// collectable, holding nothing. Inline, one arm's losses set the size of + /// all of them. The indirection is paid only where there is a loss to + /// describe. + Holds { at: Place, losses: Box }, + /// A question could not be put, or a party this pass cannot interrogate has + /// a claim. An unproved is never reported as a loss: that would be inventing + /// work that may not exist. + CouldNotProve { at: Place, blank: Blank }, +} + +impl Reason { + pub fn at(&self) -> &Place { + match self { + Self::Holds { at, .. } | Self::CouldNotProve { at, .. } => at, + } + } + + /// Whose assertion this reason is — a claim by a party that may be using + /// the site, or an account of what content is provable. + /// + /// Wildcard-free on purpose: this is the fold devlaunch#468's derivative + /// reclaim reads (a tagged environment inside a standing site is still + /// derivable unless a *claimant* stands the site), so a new arm added here + /// must answer it explicitly rather than inheriting a default. + pub fn subject(&self) -> Subject { + match self { + Self::Holds { .. } => Subject::GitsAccountOfContent, + Self::CouldNotProve { blank, .. } => match blank { + Blank::ThirdPartyClaim(_) => Subject::AClaim, + Blank::NothingToAskThrough + | Blank::GitWouldNotSay(_) + // Decided on devlaunch#468: another repository's env, tagged, is + // still derivable, and whose repository it is was never part of + // that argument. + | Blank::NotThisClonesToAccountFor(_) => Subject::GitsAccountOfContent, + }, + } + } +} + +impl Verdict { + /// The `unsaved` value `dl --ls --json` prints — the one place the verdict + /// is flattened to the wire, and the flattening is additive rather than + /// lossy: a standing that holds both kinds of reason emits **both** + /// `wouldLose` and `couldNotTell`, so no reader keyed on key presence breaks + /// and no answer is dropped to fit the shape (devlaunch#446 §6). A clone + /// with no agent worktrees reads exactly as it always did. + pub fn unsaved_json(&self) -> serde_json::Value { + match self { + Self::Collectable(_) => serde_json::json!({ "nothingToLose": true }), + Self::Stands(standing) => { + let mut value = serde_json::json!({}); + if let Some(holds) = standing.would_lose() { + value["wouldLose"] = serde_json::Value::String(holds); + } + if let Some(blanks) = standing.could_not_tell() { + value["couldNotTell"] = serde_json::Value::String(blanks); + } + value + } + } + } +} + +impl Standing { + /// Every proved loss in here, in words, or nothing when every reason is an + /// unproved. The wire payload behind `wouldLose`. + pub fn would_lose(&self) -> Option { + let parts: Vec = self + .iter() + .filter_map(|reason| match reason { + Reason::Holds { at, losses } => Some(located(&losses.describe(), at)), + Reason::CouldNotProve { .. } => None, + }) + .collect(); + joined(parts) + } + + /// Every unproved in here, in words, or nothing when every reason is a + /// proved loss. The wire payload behind `couldNotTell`. An unproved is never + /// folded into [`Standing::would_lose`]: reporting a lock or a refused probe + /// as a loss would be inventing work that may not exist. + pub fn could_not_tell(&self) -> Option { + let parts: Vec = self + .iter() + .filter_map(|reason| match reason { + Reason::Holds { .. } => None, + Reason::CouldNotProve { at, blank } => Some(located(&blank.describe(), at)), + }) + .collect(); + joined(parts) + } + + /// The whole standing in words, both kinds, for a refusal a person reads. + pub fn describe(&self) -> String { + let parts: Vec = self + .iter() + .map(|reason| match reason { + Reason::Holds { at, losses } => located(&losses.describe(), at), + Reason::CouldNotProve { at, blank } => located(&blank.describe(), at), + }) + .collect(); + parts.join(" and ") + } + + /// Whether anything in here is an unproved rather than a proved loss — what + /// decides between "push or commit it" and "look at it" in a refusal. + pub fn any_unproved(&self) -> bool { + self.iter() + .any(|reason| matches!(reason, Reason::CouldNotProve { .. })) + } +} + +fn located(words: &str, at: &Place) -> String { + match at { + Place::TheCloneItself => words.to_owned(), + Place::ASite(inside) => format!("{words} (in {})", inside.as_str()), + } +} + +fn joined(parts: Vec) -> Option { + (!parts.is_empty()).then(|| parts.join(" and ")) +} + +impl Blank { + /// The words a report interpolates for one unproved. Like + /// `CouldNotTell::describe`, this is a wire payload rather than rendering: + /// it reaches a tool through `--ls --json`'s `couldNotTell`. + pub fn describe(&self) -> String { + match self { + Self::NothingToAskThrough => { + "there is nothing to ask git through about what it holds".to_owned() + } + Self::GitWouldNotSay(cause) => cause.describe(), + Self::ThirdPartyClaim(None) => "git is holding it locked".to_owned(), + Self::ThirdPartyClaim(Some(reason)) => { + format!("git is holding it locked ({reason})") + } + Self::NotThisClonesToAccountFor(why) => why.describe().to_owned(), + } + } +} + +impl Unaccountable { + /// One sentence per arm, each naming the fact it rests on — and for + /// [`Unaccountable::RegisteredElsewhere`], the only party that can end it. + pub fn describe(&self) -> &'static str { + match self { + Self::RegisteredElsewhere => { + "a git worktree this clone's listing does not account for; devlaunch will \ + never reclaim it, and only the repository that registered it can" + } + Self::GitfileUnreadable => { + "its .git could not be read as a worktree gitfile, so it is not devlaunch's \ + to remove" + } + Self::PlainDirectory => { + "a plain directory, not a git worktree, so it is not devlaunch's to remove" + } + Self::SymlinkInThePlace => { + "a symbolic link, which devlaunch never follows and never removes" + } + } + } +} + +/// What kind of thing asserts a standing reason. See [`Reason::subject`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Subject { + /// A party that may be using the site: a lock, or anything else that speaks + /// for itself rather than about bytes. + AClaim, + /// An account of what content could or could not be proved safe. + GitsAccountOfContent, +} + +#[cfg(test)] +impl Verdict { + /// A collectable for unit tests with no probe to run. Test-only: the + /// production constructors all take witnesses a probe minted, and this + /// crate's `public-api.rest.txt` pins that no public constructor exists. + pub(crate) fn test_collectable() -> Self { + Self::Collectable(Proof { + how: ProofHow::NothingOfOurs, + }) + } + + /// A standing for unit tests, from at least one reason. + pub(crate) fn test_stands(reasons: Vec) -> Self { + Self::Stands(Standing::of(reasons).expect("test_stands takes at least one reason")) + } +} + +#[cfg(test)] +impl Standing { + pub(crate) fn test_of(reasons: Vec) -> Self { + Self::of(reasons).expect("test_of takes at least one reason") + } +} + +/// Why a question could not be put, one arm per cause. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Blank { + /// No admin directory and no registration: the working tree cannot be asked + /// anything at all, and there are bytes here. + NothingToAskThrough, + /// A probe ran and refused. Carries git's words. + GitWouldNotSay(CouldNotTell), + /// `locked`, with its reason where git printed one. + ThirdPartyClaim(Option), + /// This site is not this clone's to account for; see [`Unaccountable`]. + NotThisClonesToAccountFor(Unaccountable), +} + +// =========================================================================== +// the probes +// =========================================================================== + +/// 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 { + 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, + CouldNotSay, +} + +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. The clone is asked next, + // and only both failing to find the work anywhere stands the site. + None => InTheCache::Beyond, + Some(count) => match count.trim().parse::() { + Ok(0) => InTheCache::Reached, + Ok(_) => InTheCache::Beyond, + Err(_) => InTheCache::CouldNotSay, + }, + } +} + +/// Everything the probes need to weigh one clone's forest. +struct Weigher<'a, 'r> { + git: &'a Git<'r>, + clone: &'a Path, + bare: Option<&'a Path>, +} + +impl Weigher<'_, '_> { + /// Q3 for one head: the commits it reaches exist somewhere else, or the + /// reason they could not be shown to. + /// + /// The cache goes first, for [`InTheCache`]'s reason. The clone's probe is + /// keyed on this site's own revision rather than on `--all`, because a loss + /// has to be attributed to the directory holding it — the clone-scope + /// `--all` question is asked once of the clone itself, elsewhere. + fn elsewhere(&self, at: &Place, head: &WorktreeHead) -> Result { + match in_the_cache(self.git, self.bare, head.commit()) { + InTheCache::Reached => Ok(Elsewhere(())), + InTheCache::Beyond | InTheCache::CouldNotSay => { + match self + .git + .unpushed_commits_from(self.clone, head.revision()) + .said() + { + None => Err(Reason::CouldNotProve { + at: at.clone(), + blank: Blank::GitWouldNotSay(CouldNotTell::UnpushedNotListed { + clone: self.clone.to_path_buf(), + reason: format!( + "neither the repository cache nor the clone could say whether \ + the commits on {} are anywhere else", + head.named() + ), + }), + }), + Some(unpushed) => match NonEmpty::of(unpushed.lines().map(str::to_owned)) { + None => Ok(Elsewhere(())), + Some(commits) => Err(Reason::Holds { + at: at.clone(), + // No tag account: this probe names one revision and + // asks the clone about it, where #522's account is + // about which of a clone's refs are tags the mirror + // does not have. A site has no such question. + losses: Box::new(Losses::one(Loss::Unpushed { + commits, + by_tags: None, + })), + }), + }, + } + } + } + } + + /// Q2 for one present worktree, asked through the admin directory derived + /// from the join. + /// + /// **The answer includes ignored files.** Measured: `git worktree remove` + /// deletes a worktree whose only content is gitignored, exit 0 and silent — + /// git's own dirty check does not count ignored files — so this predicate is + /// the whole of the protection, and an ignored file is as gone as a tracked + /// one (devlaunch#462). + /// + /// An untracked or ignored entry is dropped from the answer only when + /// everything under it is a site the child forest accounts for — each such + /// site answers for itself, with its own verdict, attributed to its own + /// path. Excluding by what a thing *is* rather than where it sits is + /// devlaunch#442 review S4; a tracked change is never dropped, whatever its + /// path. + fn clean( + &self, + at: &Place, + admin: &Path, + directory: &Path, + forest: &[PathBuf], + ) -> Result { + let dirt = match self.git.worktree_dirt(admin, directory).said() { + None => { + return Err(Reason::CouldNotProve { + at: at.clone(), + blank: Blank::GitWouldNotSay(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| !accounted_for_by_the_forest(directory, line, forest)) + .map(str::to_owned); + match NonEmpty::of(lines) { + None => Ok(Clean(())), + Some(changed) => Err(Reason::Holds { + at: at.clone(), + losses: Box::new(Losses::one(Loss::Uncommitted(changed))), + }), + } + } + + /// Q4: the listing already answered; this reads it. + fn unclaimed(&self, at: &Place, joined: &NonEmpty) -> Result { + match joined.iter().find_map(|it| it.locked.clone()) { + None => Ok(Unclaimed(())), + Some(lock) => Err(Reason::CouldNotProve { + at: at.clone(), + blank: Blank::ThirdPartyClaim(lock.reason), + }), + } + } + + /// This site's own verdict, children not consulted — the conjunction over + /// children is [`weigh`]'s and only [`weigh`]'s. + fn own_verdict(&self, site: &Site, forest: &[PathBuf]) -> Verdict { + let at = Place::ASite(site.inside.clone()); + match &site.kind { + SiteKind::NotOurs { why, .. } => { + Verdict::Stands(Standing::one(Reason::CouldNotProve { + at, + blank: Blank::NotThisClonesToAccountFor(*why), + })) + } + SiteKind::OursGone { joined } => { + let mut reasons = Vec::new(); + let mut elsewhere = None; + for one in joined.iter() { + match self.elsewhere(&at, &one.head) { + Ok(witness) => elsewhere = Some(witness), + Err(reason) => reasons.push(reason), + } + } + if let Err(reason) = self.unclaimed(&at, joined) { + reasons.push(reason); + } + match (Standing::of(reasons), elsewhere) { + (Some(standing), _) => Verdict::Stands(standing), + (None, Some(elsewhere)) => Verdict::Collectable(Proof { + how: ProofHow::HoldsNothing { + _empty: NoBytes(()), + _elsewhere: elsewhere, + }, + }), + // Unreachable in practice — a joined site has at least one + // head to ask about — but the honest answer for it is that + // nothing was proved, not that nothing objected. + (None, None) => Verdict::Stands(Standing::one(Reason::CouldNotProve { + at, + blank: Blank::NothingToAskThrough, + })), + } + } + SiteKind::OursHere { + at: dir, + joined, + admin, + } => { + let mut reasons = Vec::new(); + let clean = match admin { + Some(admin) => match self.clean(&at, admin, dir, forest) { + Ok(witness) => Some(witness), + Err(reason) => { + reasons.push(reason); + None + } + }, + // Registered a moment ago and its admin directory already + // gone: a concurrent prune's window. The working tree cannot + // be asked, and an unasked question is not a clean answer. + None => { + reasons.push(Reason::CouldNotProve { + at: at.clone(), + blank: Blank::NothingToAskThrough, + }); + None + } + }; + let mut elsewhere = None; + for one in joined.iter() { + match self.elsewhere(&at, &one.head) { + Ok(witness) => elsewhere = Some(witness), + Err(reason) => reasons.push(reason), + } + } + let unclaimed = match self.unclaimed(&at, joined) { + Ok(witness) => Some(witness), + Err(reason) => { + reasons.push(reason); + None + } + }; + match (Standing::of(reasons), clean, elsewhere, unclaimed) { + (Some(standing), _, _, _) => Verdict::Stands(standing), + (None, Some(clean), Some(elsewhere), Some(unclaimed)) => { + Verdict::Collectable(Proof { + how: ProofHow::EverythingAsked { + _clean: clean, + _elsewhere: elsewhere, + _unclaimed: unclaimed, + }, + }) + } + // No reason and no full witness set cannot happen — every + // missing witness pushed a reason — but the arm exists and + // it points towards keeping. + (None, _, _, _) => Verdict::Stands(Standing::one(Reason::CouldNotProve { + at, + blank: Blank::NothingToAskThrough, + })), + } + } + } + } +} + +/// Whether one `git status` line is nothing but sites the forest already +/// accounts for. +/// +/// Only ever true of an **untracked or ignored** entry on the +/// `.claude/worktrees/` spine. A tracked change under the same path is +/// somebody's edit to a file the repository knows about and is never dropped, +/// and an entry anywhere else is not this module's business. +fn accounted_for_by_the_forest(root: &Path, line: &str, forest: &[PathBuf]) -> bool { + let Some(entry) = line + .strip_prefix("?? ") + .or_else(|| 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) && covered_by_sites(&root.join(entry), forest) +} + +/// Whether `entry` is the `.claude/worktrees/` spine or something under it — +/// the cheap guard in front of the walk, so an untracked `build/` is not walked +/// in full to establish what everyone already knows. +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 site the forest holds. +/// +/// The forest, not the gitfile: "shaped like a worktree" is not "accounted +/// for", and the tail-only reading here is the other half of the defect +/// devlaunch#463 measured. A site of any kind qualifies, because every site +/// carries its own verdict and pins its own ancestors — so dropping it from the +/// dirt answer trades an opaque `??` line for a report line that names it. +fn covered_by_sites(path: &Path, forest: &[PathBuf]) -> bool { + if std::fs::symlink_metadata(path).is_ok_and(|it| it.is_symlink()) { + return forest.iter().any(|site| site == path); + } + if forest.iter().any(|site| site == path) { + return true; + } + let Ok(entries) = std::fs::read_dir(path) else { + // A file, or a directory that will not be read. Neither is a site, so + // neither is something to drop from the answer. + return false; + }; + entries + .filter_map(Result::ok) + .all(|entry| covered_by_sites(&entry.path(), forest)) +} + +// =========================================================================== +// the plan: what goes, what stands +// =========================================================================== + +/// One collectable unit, exactly matching one operation's radius. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Collectable { + /// A subtree to remove, and every registration the one removal accounts + /// for: this site's and every nested site's. + Directory(GoingDirectory), + /// No directory. The forget is the whole of the work. + Registration(GoingRegistration), +} + +/// A subtree that goes. Private fields — a value a caller could fill could pair +/// a removal with forgets that never rode on it, which is the state +/// devlaunch#445 makes unrepresentable. The `public-api.rest.txt` snapshot pins +/// the absent constructor. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GoingDirectory { + at: PathBuf, + usage: DiskUsage, + forgets: Vec, +} + +impl GoingDirectory { + pub fn at(&self) -> &Path { + &self.at + } + + pub fn usage(&self) -> &DiskUsage { + &self.usage + } + + /// The registrations this one removal accounts for, in the order they will + /// be forgotten. Each is a path a listing printed. + pub fn forgets(&self) -> &[Recorded] { + &self.forgets + } +} + +/// A registration with nothing at its place, going by name alone. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GoingRegistration { + place: Inside, + forgets: Vec, +} + +impl GoingRegistration { + pub fn place(&self) -> &Inside { + &self.place + } + + pub fn forgets(&self) -> &[Recorded] { + &self.forgets + } +} + +/// Nothing objected, or `--force-worktrees` carried this unit past what did. +/// +/// Carried per unit rather than read from a run-wide flag: a plan-wide boolean +/// says "the user insisted" about every unit in the plan, including the ones +/// nothing objected to, and the acting pass would then skip its re-check for all +/// of them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WorktreePromotion { + Unopposed, + Insisted { despite: Standing }, +} + +impl WorktreePromotion { + fn insistence(&self) -> Insistence { + match self { + Self::Unopposed => Insistence::NotInsisted, + Self::Insisted { .. } => Insistence::Insisted, + } + } +} + +/// One unit this run will act on, and on what authority. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Going { + what: Collectable, + promotion: WorktreePromotion, +} + +impl Going { + pub fn what(&self) -> &Collectable { + &self.what + } + + pub fn promotion(&self) -> &WorktreePromotion { + &self.promotion + } + + fn identity(&self) -> GoingIdentity<'_> { + match &self.what { + Collectable::Directory(directory) => GoingIdentity::At(&directory.at), + Collectable::Registration(registration) => GoingIdentity::Place(®istration.place), + } + } +} + +#[derive(PartialEq, Eq)] +enum GoingIdentity<'a> { + At(&'a Path), + Place(&'a Inside), +} + +/// One site this run leaves standing, with its own reasons. +/// +/// One entry per standing site, never one per pinned ancestor: an ancestor that +/// stands only because of what is nested in it is accounted for by the nested +/// site's own line, which names the child — a parent reported as "kept" with the +/// child unnamed is the invisible straggler. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StandingSite { + at: PathBuf, + reasons: Standing, +} + +impl StandingSite { + pub fn at(&self) -> &Path { + &self.at + } + + pub fn reasons(&self) -> &Standing { + &self.reasons + } +} + +/// One clone's share of the sweep. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CloneWorktrees { + // Private, all of them: this is an answer, and a caller that could fill it + // could pair one clone with a classification of another clone's sites. + clone: PathBuf, + owner: String, + repo: String, + going: Vec, + standing: Vec, +} + +impl CloneWorktrees { + pub fn clone_path(&self) -> &Path { + &self.clone + } + + pub fn owner(&self) -> &str { + &self.owner + } + + pub fn repo(&self) -> &str { + &self.repo + } + + /// The units this run will act on: directory removals biggest first, then + /// bare forgets, path breaking ties, so two runs over an unchanged cache + /// read alike. + pub fn going(&self) -> &[Going] { + &self.going + } + + /// The sites this run leaves, each with its own reasons. + pub fn standing(&self) -> &[StandingSite] { + &self.standing + } + + fn nothing_to_do(&self) -> bool { + self.going.is_empty() + } + + fn nothing_to_say(&self) -> bool { + self.going.is_empty() && self.standing.is_empty() + } +} + +/// 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 { + pub fn clones(&self) -> &[CloneWorktrees] { + &self.clones + } + + /// What the whole sweep would free. + pub fn freed(&self) -> DiskUsage { + disk_usage::total_usage(self.clones.iter().flat_map(|clone| { + clone.going.iter().filter_map(|going| match &going.what { + Collectable::Directory(directory) => Some(directory.usage.clone()), + Collectable::Registration(_) => None, + }) + })) + } + + pub fn nothing_to_do(&self) -> bool { + self.clones.iter().all(CloneWorktrees::nothing_to_do) + } + + pub fn nothing_to_say(&self) -> bool { + self.clones.is_empty() + } + + pub(crate) fn record(&mut self, found: Option) { + if let Some(found) = found.filter(|it| !it.nothing_to_say()) { + self.clones.push(found); + } + } +} + +/// What one subtree's weighing concluded. +struct Weighed { + /// When the whole subtree may go: everything the one removal accounts for. + /// `None` means something in it stands. + removable: Option, + /// Going roots strictly inside this subtree, materialized because an + /// ancestor stands. Empty while `removable` is `Some`. + going: Vec, + /// Standing sites in this subtree, own reasons only. Empty while + /// `removable` is `Some` — an insisted subtree's reasons ride in `despite`. + standing: Vec, +} + +/// The whole subtree, ready to be one [`Going`] if the parent absorbs it or to +/// be materialized if not. +struct Removable { + /// The subtree root to remove, when there are bytes. `None` for a + /// registration with nothing at its place. + dir: Option, + place: Inside, + forgets: Vec, + /// Everything `--force-worktrees` carried this subtree past, child sites' + /// reasons included, so the plan line names what insisting means here. + despite: Vec, +} + +/// Weigh one site bottom-up: children first, then the conjunction. +/// +/// This recursion is the T1 answer, so it is worth saying what cannot happen in +/// it: the collectable arm is reachable only when every child's recursion +/// handed one back, and the caller passes no child list — so "a parent goes +/// while a nested site stands" is not guarded against, it has no path. +fn weigh( + weigher: &Weigher<'_, '_>, + site: &Site, + insistence: Insistence, + forest: &[PathBuf], +) -> Weighed { + let children: Vec = site + .nested + .iter() + .map(|child| weigh(weigher, child, insistence, forest)) + .collect(); + let own = weigher.own_verdict(site, forest); + let own_removable: Option> = match &own { + Verdict::Collectable(_) => Some(Vec::new()), + Verdict::Stands(standing) => match insistence { + Insistence::Insisted => Some(standing.iter().cloned().collect()), + Insistence::NotInsisted => None, + }, + }; + let every_child_removable = children.iter().all(|child| child.removable.is_some()); + if let (Some(despite), true) = (own_removable, every_child_removable) { + let mut merged = Removable { + dir: match &site.kind { + SiteKind::OursHere { at, .. } | SiteKind::NotOurs { at, .. } => Some(at.clone()), + SiteKind::OursGone { .. } => None, + }, + place: site.inside.clone(), + forgets: own_forgets(site), + despite, + }; + for child in children { + let removable = child + .removable + .expect("every_child_removable checked every one"); + merged.forgets.extend(removable.forgets); + merged.despite.extend(removable.despite); + } + return Weighed { + removable: Some(merged), + going: Vec::new(), + standing: Vec::new(), + }; + } + // Something here stands, so nothing above this site can go: materialize the + // children that can go on their own, and attribute the standing to the + // sites that stand of their own accord. + let mut going = Vec::new(); + let mut standing = Vec::new(); + for child in children { + if let Some(removable) = child.removable { + going.push(materialize(removable)); + } + going.extend(child.going); + standing.extend(child.standing); + } + if let Verdict::Stands(reasons) = own { + standing.push(StandingSite { + at: site.at(weigher.clone), + reasons, + }); + } + Weighed { + removable: None, + going, + standing, + } +} + +/// The registrations this site's own removal accounts for. A foreign site's +/// registration lives in a listing devlaunch does not own, so there is nothing +/// to forget and **no git invocation ever names it** — under insistence +/// included. +fn own_forgets(site: &Site) -> Vec { + match &site.kind { + SiteKind::OursHere { joined, .. } | SiteKind::OursGone { joined } => { + joined.iter().map(|it| it.recorded.clone()).collect() + } + SiteKind::NotOurs { .. } => Vec::new(), + } +} + +/// Turn one removable subtree into the unit the plan carries. The byte figure +/// is measured here, once, at the outermost thing that goes — the byte +/// recursion stops where the verdict recursion did not. +fn materialize(removable: Removable) -> Going { + let promotion = match Standing::of(removable.despite) { + None => WorktreePromotion::Unopposed, + Some(despite) => WorktreePromotion::Insisted { despite }, + }; + let what = match removable.dir { + Some(at) => Collectable::Directory(GoingDirectory { + usage: disk_usage::exclusive_usage(&at), + at, + forgets: removable.forgets, + }), + None => Collectable::Registration(GoingRegistration { + place: removable.place, + forgets: removable.forgets, + }), + }; + Going { what, promotion } +} + +/// Classify the agent worktrees inside one clone, as `--prune`'s planning pass. +/// +/// `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 { + if std::fs::read_dir(worktrees_dir(clone)).is_err() { + return None; + } + let picture = ClonePicture::of(git, clone)?; + let (going, standing) = weigh_clone(git, clone, bare, &picture, |_| insistence); + Some(CloneWorktrees { + clone: clone.to_path_buf(), + owner: owner.to_owned(), + repo: repo.to_owned(), + going, + standing, + }) +} + +/// Weigh every root in one clone's forest, with an insistence per going root. +fn weigh_clone( + git: &Git<'_>, + clone: &Path, + bare: Option<&Path>, + picture: &ClonePicture, + insist: impl Fn(&Site) -> Insistence, +) -> (Vec, Vec) { + let weigher = Weigher { git, clone, bare }; + let roots = forest_of(clone, picture); + let mut forest_paths = Vec::new(); + for root in &roots { + root.paths_into(clone, &mut forest_paths); + } + let mut going = Vec::new(); + let mut standing = Vec::new(); + for root in &roots { + let weighed = weigh(&weigher, root, insist(root), &forest_paths); + if let Some(removable) = weighed.removable { + going.push(materialize(removable)); + } + going.extend(weighed.going); + standing.extend(weighed.standing); + } + going.sort_by(|left, right| { + let bytes = |unit: &Going| match &unit.what { + Collectable::Directory(directory) => Some(directory.usage.known_bytes()), + Collectable::Registration(_) => None, + }; + let path = |unit: &Going| match &unit.what { + Collectable::Directory(directory) => directory.at.display().to_string(), + Collectable::Registration(registration) => registration.place.place.clone(), + }; + bytes(right) + .cmp(&bytes(left)) + .then_with(|| path(left).cmp(&path(right))) + }); + standing.sort_by(|left, right| left.at.cmp(&right.at)); + (going, standing) +} + +// =========================================================================== +// the acting pass +// =========================================================================== + +/// One unit the plan meant to act on 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: Standing, +} + +/// One subtree the acting pass removed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemovedWorktree { + pub path: PathBuf, + /// The figure the plan measured, so what somebody is told they got back is + /// what they said yes to. + pub usage: DiskUsage, +} + +/// One forget git refused. The registration is still there, the next listing +/// still prints it, and the next run offers it again. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ForgetRefused { + /// The path git printed for the registration — reported as text, resolved by + /// nothing. + pub registered: PathBuf, + pub reason: String, +} + +/// 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. Nothing was forgotten for a + /// unit in here: the forget is a consequence of a removal that completed. + pub refused: Vec, + /// Registrations dropped, by name, each one read from a listing. + pub forgotten: usize, + pub forget_refused: Vec, +} + +impl WorktreeReport { + /// What this run actually freed. + 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.forgotten == 0 + && self.forget_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 site is classified again, +/// under that lock, immediately before anything goes**, by the same weighing +/// the plan ran — one implementation, so the plan and the outcome cannot answer +/// different questions. Only a unit this pass *also* finds collectable, *and* +/// the plan approved, is acted on: the approved set can shrink between the +/// report and the act and can never grow, now over a tree rather than a list. A +/// site that appeared after the plan has no approval, so it cannot be +/// collectable here — and it is still classified, so it still objects, so it +/// still makes every parent stand. +/// +/// **The directory goes first and the forgets follow, and nothing is forgotten +/// on a partial removal.** That ordering is P2 (devlaunch#462): the recorded +/// path does not resolve at the moment the forget runs, even where it resolved +/// a moment earlier, so the forget can only ever drop the one name it was +/// handed. Interrupted between the two, git is left holding a registration with +/// nothing at its place — which is exactly the shape the next run's +/// [`SiteKind::OursGone`] handles. +pub(crate) fn reclaim( + git: &Git<'_>, + plan: &CloneWorktrees, + bare: Option<&Path>, + report: &mut WorktreeReport, +) { + let Some(picture) = ClonePicture::of(git, &plan.clone) else { + // git will not say what this clone holds any more, so nothing in it is + // removable: the classification the plan rests on cannot be re-taken. + report + .withheld + .extend(plan.going.iter().map(|going| WithheldWorktree { + path: going_path(&plan.clone, going), + because: Standing::one(Reason::CouldNotProve { + at: Place::TheCloneItself, + blank: Blank::GitWouldNotSay(CouldNotTell::GitCouldNotRead { + clone: plan.clone.clone(), + reason: + "git would not list this clone's worktrees a second time".to_owned(), + }), + }), + })); + return; + }; + // The same weighing as the plan, with the plan's own per-unit insistence: a + // root the plan promoted is re-weighed as promoted, everything else as not. + let (fresh, fresh_standing) = weigh_clone(git, &plan.clone, bare, &picture, |root| { + plan.going + .iter() + .find(|going| approves(going, root, &plan.clone)) + .map(|going| going.promotion.insistence()) + .unwrap_or(Insistence::NotInsisted) + }); + for planned in &plan.going { + let Some(confirmed) = fresh + .iter() + .find(|fresh| fresh.identity() == planned.identity()) + else { + // The re-check would not hand this unit back: something under it + // stands now, or it is not what it was. Say why, with the fresh + // reasons where the fresh pass attributed some to this subtree. + let path = going_path(&plan.clone, planned); + let because = fresh_standing + .iter() + .find(|standing| standing.at.starts_with(&path) || path.starts_with(&standing.at)) + .map(|standing| standing.reasons.clone()) + .unwrap_or_else(|| { + Standing::one(Reason::CouldNotProve { + at: Place::TheCloneItself, + blank: Blank::NothingToAskThrough, + }) + }); + report.withheld.push(WithheldWorktree { path, because }); + continue; + }; + act_on(git, &plan.clone, confirmed, report); + } +} + +/// Whether one planned unit is the approval for `root`. +fn approves(going: &Going, root: &Site, clone: &Path) -> bool { + match &going.what { + Collectable::Directory(directory) => directory.at == root.at(clone), + Collectable::Registration(registration) => registration.place == root.inside, + } +} + +fn going_path(clone: &Path, going: &Going) -> PathBuf { + match &going.what { + Collectable::Directory(directory) => directory.at.clone(), + Collectable::Registration(registration) => clone.join(®istration.place.place), + } +} + +/// One unit, carried out: bytes first, then every name that rode in on it. +fn act_on(git: &Git<'_>, clone: &Path, confirmed: &Going, report: &mut WorktreeReport) { + let insistence = confirmed.promotion.insistence(); + match &confirmed.what { + Collectable::Directory(directory) => { + match remove_tree_as_far_as_it_goes(&directory.at) { + TreeSweep::Everything => { + report.removed.push(RemovedWorktree { + path: directory.at.clone(), + usage: directory.usage.clone(), + }); + forget(git, clone, &directory.forgets, insistence, report); + } + TreeSweep::WhatItCould(refused) | TreeSweep::Nothing(refused) => { + // Nothing is forgotten: a registration outliving its bytes + // is the state the next run reads correctly, where bytes + // outliving their registration is the state that used to + // read as "git has already forgotten it" and delete. + report.refused.extend(refused.iter().cloned()); + } + } + } + Collectable::Registration(registration) => { + forget(git, clone, ®istration.forgets, insistence, report); + } + } +} + +/// Drop each registration by the name a listing printed. +/// +/// A refusal is contained: it is reported, it forgets nothing else, and the +/// registration it refused is still in the next listing. Measured, the refusal +/// that matters — a recorded path that resolves to something unrelated — is one +/// git will not be argued out of. +fn forget( + git: &Git<'_>, + clone: &Path, + forgets: &[Recorded], + insistence: Insistence, + report: &mut WorktreeReport, +) { + let force = match insistence { + Insistence::Insisted => ForgetForce::PastALock, + Insistence::NotInsisted => ForgetForce::AsAsked, + }; + for recorded in forgets { + match git.worktree_remove(clone, recorded.as_path(), force).said() { + Some(_) => report.forgotten += 1, + None => report.forget_refused.push(ForgetRefused { + registered: recorded.as_path().to_path_buf(), + reason: "git would not drop this registration".to_owned(), + }), + } + } +} + +// =========================================================================== +// the clone as the root of the same forest +// =========================================================================== + +/// What one clone holds, everything nested in it included. +/// +/// The clone's own probes conjoined with the verdict of every site inside it — +/// one predicate with one set of readers (`dl rm`'s guard, `--prune`'s +/// orphan arm, `--ls --json`) rather than a clone predicate and a worktree +/// predicate that can disagree. The clone-level answer alone is structurally +/// blind to nested agent worktrees: `.claude/worktrees/` is ordinarily +/// gitignored, so `git status` at the clone root says nothing about an +/// afternoon of unsaved work one level in. Dirt is per working tree; +/// reachability is per repository (devlaunch#446). +pub(crate) fn clone_verdict(git: &Git<'_>, clone: &Path, bare: BareCache<'_>) -> Verdict { + let own = workspace_state::holds_unsaved_work(git, clone, bare); + let own_reasons = lift(own); + let site_reasons = site_reasons(git, clone); + match Standing::of(own_reasons.into_iter().chain(site_reasons).collect()) { + Some(standing) => Verdict::Stands(standing), + None => Verdict::Collectable(Proof { + how: ProofHow::CloneProbesAnsweredClear, + }), + } +} + +/// The clone's branch and its verdict, read together for the listing row. +pub(crate) struct CloneAccount { + pub(crate) branch: Option, + pub(crate) holds: Verdict, +} + +pub(crate) fn account_of(git: &Git<'_>, clone: &Path, bare: BareCache<'_>) -> CloneAccount { + let state = workspace_state::read_clone(git, clone, bare); + let own_reasons = lift(state.unsaved); + let site_reasons = site_reasons(git, clone); + let holds = match Standing::of(own_reasons.into_iter().chain(site_reasons).collect()) { + Some(standing) => Verdict::Stands(standing), + None => Verdict::Collectable(Proof { + how: ProofHow::CloneProbesAnsweredClear, + }), + }; + CloneAccount { + branch: state.branch, + holds, + } +} + +/// The one verdict for a workspace devlaunch has no clone of its own for: +/// nothing of ours to protect. +pub(crate) fn nothing_of_ours() -> Verdict { + Verdict::Collectable(Proof { + how: ProofHow::NothingOfOurs, + }) +} + +/// A verdict for a clone whose directory could not even be named or read — +/// the guard's refusing arm, carried in the same type as every other answer. +pub(crate) fn could_not_prove(cause: CouldNotTell) -> Verdict { + Verdict::Stands(Standing::one(Reason::CouldNotProve { + at: Place::TheCloneItself, + blank: Blank::GitWouldNotSay(cause), + })) +} + +/// The clone's own [`Unsaved`] answer, lifted into reasons. The lossy direction +/// (a re-encoding of the verdict *into* `Unsaved`) exists nowhere: `Unsaved` +/// survives at the JSON edge as key names, not as a value the verdict is +/// squeezed through. +fn lift(own: Unsaved) -> Vec { + match own { + Unsaved::NothingToLose => Vec::new(), + Unsaved::WouldLose(losses) => vec![Reason::Holds { + at: Place::TheCloneItself, + losses: Box::new(losses), + }], + Unsaved::CouldNotTell(cause) => vec![Reason::CouldNotProve { + at: Place::TheCloneItself, + blank: Blank::GitWouldNotSay(cause), + }], + } +} + +/// The standing reasons of every site in `clone`, or nothing where there are no +/// sites — which is nearly every clone, at the cost of one failed `read_dir`. +/// +/// The sibling bare is derived from the clone's place in the cache +/// (`///.bare` beside `///`), +/// which is the same sibling `--prune`'s sweep is handed by the repo manager. +fn site_reasons(git: &Git<'_>, clone: &Path) -> Vec { + if std::fs::read_dir(worktrees_dir(clone)).is_err() { + return Vec::new(); + } + let Some(picture) = ClonePicture::of(git, clone) else { + return vec![Reason::CouldNotProve { + at: Place::TheCloneItself, + blank: Blank::GitWouldNotSay(CouldNotTell::GitCouldNotRead { + clone: clone.to_path_buf(), + reason: "git would not list this clone's worktrees".to_owned(), + }), + }]; + }; + let bare = clone.parent().map(|parent| parent.join(".bare")); + let bare = bare.as_deref().filter(|path| path.is_dir()); + let (_, standing) = weigh_clone(git, clone, bare, &picture, |_| Insistence::NotInsisted); + standing + .into_iter() + .flat_map(|site| site.reasons.iter().cloned().collect::>()) + .collect() +} + +// =========================================================================== +// attribution for `dl --ls --size` +// =========================================================================== + +/// 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(crate) 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]) +} + +#[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..c80425cc --- /dev/null +++ b/rust/devlaunch-core/src/flows/agent_worktrees/tests.rs @@ -0,0 +1,1245 @@ +//! What the agent-worktree sweep decides, 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 used to 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. +//! +//! Where a decision says a state is **unrepresentable**, the test here asserts +//! the absence of a path — a plan that contains no unit for it, a spawn log that +//! contains no invocation naming it — rather than a guard firing. + +use std::cell::RefCell; + +use devlaunch_runner::{ + CapturedText, DetachOutcome, Invocation, Outcome, ProcessRunner, Runner, SpawnSpec, +}; + +use super::*; +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 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`'s planning pass 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") + } + + /// Carry `plan` out, over a recording runner, and hand back what happened + /// and every argv the pass spawned. + fn act(&self, plan: &CloneWorktrees) -> (WorktreeReport, Vec>) { + self.act_expecting_absent_forgets(plan, true) + } + + fn act_expecting_absent_forgets( + &self, + plan: &CloneWorktrees, + forgets_must_be_absent: bool, + ) -> (WorktreeReport, Vec>) { + let calls = RefCell::new(Vec::new()); + let runner = Recording { + real: ProcessRunner::new(), + calls: &calls, + forgets_must_be_absent, + }; + let git = Git::new(&runner); + let mut report = WorktreeReport::default(); + reclaim(&git, plan, Some(&self.bare), &mut report); + (report, calls.into_inner()) + } + + fn listing(&self) -> String { + run_git(&self.clone, &["worktree", "list", "--porcelain"]) + } +} + +/// A second, unrelated repository, for the cross-repository fixtures +/// (devlaunch#463). Its worktrees are made *inside* the first clone's worktrees +/// place, which takes nothing more than a `git -C worktree add `. +struct OtherRepository { + repo: PathBuf, +} + +impl OtherRepository { + fn new(root: &Path) -> Self { + let repo = root.join("other-repo"); + std::fs::create_dir_all(&repo).expect("the other repository"); + run_git(root, &["init", "-b", "main", &repo.display().to_string()]); + std::fs::write(repo.join("THEIRS.md"), "theirs\n").expect("their file"); + commit(&repo, "theirs"); + Self { repo } + } + + /// A live worktree of this repository at `at`, on its own branch, holding + /// uncommitted work. + fn worktree_at(&self, at: &Path, branch: &str) -> PathBuf { + std::fs::create_dir_all(at.parent().expect("a parent")).expect("the nesting directory"); + run_git( + &self.repo, + &["worktree", "add", "-b", branch, &at.display().to_string()], + ); + std::fs::write(at.join("UNSAVED.txt"), "an afternoon nobody else has\n") + .expect("their unsaved work"); + at.to_path_buf() + } + + fn listing(&self) -> String { + run_git(&self.repo, &["worktree", "list", "--porcelain"]) + } +} + +/// A wrapper that does real work and keeps every argv, so a test can assert an +/// invocation was never made — the absence half of T2 and of the foreign-site +/// rules — and that the forget's argument does not exist at the moment the +/// forget is invoked, which is P2 asserted directly (devlaunch#462). +struct Recording<'a> { + real: ProcessRunner, + calls: &'a RefCell>>, + /// Assert P2 at every forget: the argument must not exist when the spawn + /// happens. Off for the one fixture whose recorded path deliberately + /// resolves into another repository, where the point is git's refusal. + forgets_must_be_absent: bool, +} + +impl Runner for Recording<'_> { + fn capture(&self, spec: &SpawnSpec) -> Outcome { + let argv = spec.invocation.argv(); + if self.forgets_must_be_absent + && let Some(at) = argv.iter().position(|arg| arg == "remove") + && argv.iter().any(|arg| arg == "worktree") + { + let target = argv[at..] + .iter() + .skip(1) + .find(|arg| !arg.starts_with("--")) + .expect("worktree remove names a path"); + assert!( + !Path::new(target).exists(), + "P2: the forget's argument must not exist at the moment the forget is \ + invoked, and {target} does" + ); + } + self.calls.borrow_mut().push(argv); + self.real.capture(spec) + } + + fn passthrough(&self, spec: &SpawnSpec) -> Outcome { + self.real.passthrough(spec) + } + + fn session(&self, spec: &SpawnSpec, on_stderr_line: &mut dyn FnMut(&str)) -> Outcome { + self.real.session(spec, on_stderr_line) + } + + fn detach(&self, what: &Invocation) -> DetachOutcome { + self.real.detach(what) + } +} + +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 directories a plan removes, in the order it reports them. +fn going_dirs(found: &CloneWorktrees) -> Vec { + found + .going() + .iter() + .filter_map(|going| match going.what() { + Collectable::Directory(directory) => Some(directory.at().to_path_buf()), + Collectable::Registration(_) => None, + }) + .collect() +} + +/// The registration-only forgets a plan carries. +fn going_places(found: &CloneWorktrees) -> Vec { + found + .going() + .iter() + .filter_map(|going| match going.what() { + Collectable::Directory(_) => None, + Collectable::Registration(registration) => { + Some(registration.place().as_str().to_owned()) + } + }) + .collect() +} + +/// The paths of the standing sites, own reasons only. +fn standing_paths(found: &CloneWorktrees) -> Vec { + found + .standing() + .iter() + .map(|site| site.at().to_path_buf()) + .collect() +} + +/// The reasons `path`'s site stands on, failing loudly when it is not reported +/// at all — because "it is still on disk" is true of a site kept for the right +/// reason and of one no verdict ever looked at. +fn reasons_at<'a>(found: &'a CloneWorktrees, path: &Path) -> Vec<&'a Reason> { + let mut matched: Vec<&StandingSite> = found + .standing() + .iter() + .filter(|site| site.at() == path) + .collect(); + assert_eq!( + matched.len(), + 1, + "expected exactly one standing line for {}: {:?}", + path.display(), + found.standing() + ); + matched.remove(0).reasons().iter().collect() +} + +// ======================================================================= +// 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!(world.sweep(Insistence::NotInsisted).is_none()); +} + +// ======================================================================= +// the ordinary shapes: registered, finished, and their refusals +// ======================================================================= + +#[test] +fn a_registered_worktree_clean_and_pushed_is_collectable() { + let world = Clone::new(); + let worktree = world.worktree("agent-one"); + world.containerise(); + + let plan = world.plan(); + + assert_eq!(going_dirs(&plan), [worktree]); + assert!(plan.standing().is_empty(), "{:?}", plan.standing()); + let Collectable::Directory(directory) = plan.going()[0].what() else { + panic!("expected a directory unit"); + }; + assert_eq!( + directory.forgets().len(), + 1, + "the one removal accounts for the one registration" + ); +} + +#[test] +fn an_uncommitted_edit_stands_a_worktree_that_is_otherwise_collectable() { + let world = Clone::new(); + let worktree = world.worktree("agent-one"); + std::fs::write(worktree.join("notes.md"), "an afternoon\n").expect("a note"); + world.containerise(); + + let plan = world.plan(); + + assert!(going_dirs(&plan).is_empty(), "{:?}", plan.going()); + let reasons = reasons_at(&plan, &worktree); + assert!(matches!(reasons[0], Reason::Holds { .. }), "{reasons:?}"); +} + +#[test] +fn a_site_whose_only_content_is_gitignored_stands() { + // Measured: `git worktree remove` deletes a worktree whose only content is + // gitignored, exit 0 and silent — git's own dirty check does not count + // ignored files — so this predicate is the whole of the protection + // (devlaunch#462). The .gitignore itself is committed and pushed, so the + // ignored file really is the only thing that exists nowhere else. + let world = Clone::new(); + let worktree = world.worktree("agent-one"); + std::fs::write(worktree.join(".gitignore"), "scratch.db\n").expect("an ignore file"); + commit(&worktree, "ignore scratch"); + run_git(&world.clone, &["push", "origin", "agent-one"]); + world.fetch(); + std::fs::write(worktree.join("scratch.db"), "bytes nowhere else\n").expect("ignored bytes"); + world.containerise(); + + let plan = world.plan(); + + assert!(going_dirs(&plan).is_empty(), "{:?}", plan.going()); + let reasons = reasons_at(&plan, &worktree); + assert!(matches!(reasons[0], Reason::Holds { .. }), "{reasons:?}"); +} + +#[test] +fn a_commit_that_was_never_pushed_stands_the_worktree() { + let world = Clone::new(); + let worktree = world.worktree("agent-one"); + std::fs::write(worktree.join("work.md"), "work\n").expect("work"); + commit(&worktree, "work nowhere else"); + world.containerise(); + + let plan = world.plan(); + + assert!(going_dirs(&plan).is_empty()); + let reasons = reasons_at(&plan, &worktree); + assert!( + reasons.iter().any(|reason| matches!( + reason, + Reason::Holds { losses, .. } + if losses.describe().contains("unpushed commit") + )), + "{reasons:?}" + ); +} + +#[test] +fn a_detached_head_that_is_ahead_stands_and_one_the_cache_reaches_goes() { + let world = Clone::new(); + let reached = world.worktree("agent-one"); + let head = run_git(&world.clone, &["rev-parse", "HEAD"]); + let detached = worktrees_dir(&world.clone).join("agent-two"); + run_git( + &world.clone, + &[ + "worktree", + "add", + "--detach", + &detached.display().to_string(), + head.trim(), + ], + ); + // Turn agent-one detached too, onto a commit the cache never saw. + run_git(&reached, &["checkout", "--detach"]); + std::fs::write(reached.join("ahead.md"), "ahead\n").expect("ahead"); + commit(&reached, "detached and ahead"); + world.containerise(); + + let plan = world.plan(); + + assert_eq!(going_dirs(&plan), [detached]); + let reasons = reasons_at(&plan, &reached); + assert!(matches!(reasons[0], Reason::Holds { .. }), "{reasons:?}"); +} + +#[test] +fn work_pushed_after_the_clone_was_cut_is_found_in_the_cache() { + // The stale-ref trap: the clone is never fetched into, so asking it alone + // reports pushed-and-merged branches as unpushed forever. The bare next + // door is the thing that gets fetched, and it is asked first. + let world = Clone::new(); + let worktree = world.worktree("agent-one"); + std::fs::write(worktree.join("late.md"), "late\n").expect("late work"); + commit(&worktree, "pushed after the clone was cut"); + run_git(&world.clone, &["push", "origin", "agent-one"]); + world.fetch(); + world.containerise(); + + let plan = world.plan(); + + assert_eq!(going_dirs(&plan), [worktree]); +} + +// ======================================================================= +// T1: a worktree nested inside one that is going (devlaunch#442, review +// 5019431339). Three probes, each asserting the absence of a unit rather +// than a guard firing: the plan contains nothing that removes the parent. +// ======================================================================= + +#[test] +fn a_nested_worktree_holding_an_uncommitted_note_stands_everything_above_it() { + let world = Clone::new(); + let outer = world.worktree("agent-outer"); + let inner = world.nested(&outer, "agent-inner"); + std::fs::write(inner.join("UNSAVED.txt"), "an afternoon nobody else has\n").expect("the note"); + world.containerise(); + + let plan = world.plan(); + + assert!( + going_dirs(&plan).is_empty(), + "no unit in this plan may contain the outer worktree: {:?}", + plan.going() + ); + // The printed line names the child, not the parent it pins. + assert_eq!(standing_paths(&plan), std::slice::from_ref(&inner)); + let reasons = reasons_at(&plan, &inner); + assert!(matches!(reasons[0], Reason::Holds { .. }), "{reasons:?}"); +} + +#[test] +fn a_nested_worktree_holding_an_unpushed_commit_stands_everything_above_it() { + let world = Clone::new(); + let outer = world.worktree("agent-outer"); + let inner = world.nested(&outer, "agent-inner"); + std::fs::write(inner.join("work.md"), "work\n").expect("work"); + commit(&inner, "nested work nowhere else"); + world.containerise(); + + let plan = world.plan(); + + assert!(going_dirs(&plan).is_empty(), "{:?}", plan.going()); + assert_eq!(standing_paths(&plan), [inner]); +} + +#[test] +fn a_locked_nested_worktree_cannot_be_inside_anything_that_goes() { + // devlaunch#426 Ask 2, made structural: with no flag typed, a locked + // worktree cannot be inside any unit the plan carries. + let world = Clone::new(); + let outer = world.worktree("agent-outer"); + let inner = world.nested(&outer, "agent-inner"); + run_git( + &world.clone, + &[ + "worktree", + "lock", + "--reason", + "claude session", + &inner.display().to_string(), + ], + ); + world.containerise(); + + let plan = world.plan(); + + assert!(going_dirs(&plan).is_empty(), "{:?}", plan.going()); + let reasons = reasons_at(&plan, &inner); + assert!( + matches!( + reasons[0], + Reason::CouldNotProve { + blank: Blank::ThirdPartyClaim(Some(reason)), + .. + } if reason == "claude session" + ), + "a lock is an unproved with the reason git printed, never a loss: {reasons:?}" + ); + + // The one flag carries the whole subtree past it, with the claim on the + // plan line rather than swallowed. + let insisted = world + .sweep(Insistence::Insisted) + .expect("a sweep of a clone that has worktrees"); + assert_eq!(going_dirs(&insisted), [outer]); + let WorktreePromotion::Insisted { despite } = insisted.going()[0].promotion() else { + panic!("insisting past a lock must say so"); + }; + assert!(despite.describe().contains("claude session")); +} + +// ======================================================================= +// the conjunction the other way round, and the byte recursion +// ======================================================================= + +#[test] +fn a_collectable_worktree_inside_a_kept_one_is_reclaimed_on_its_own() { + let world = Clone::new(); + let outer = world.worktree("agent-outer"); + let inner = world.nested(&outer, "agent-inner"); + std::fs::write(outer.join("notes.md"), "outer work\n").expect("outer note"); + world.containerise(); + + let plan = world.plan(); + + assert_eq!(going_dirs(&plan), [inner]); + let reasons = reasons_at(&plan, &outer); + assert!(matches!(reasons[0], Reason::Holds { .. }), "{reasons:?}"); +} + +#[test] +fn a_collectable_subtree_is_one_unit_with_every_registration_riding_on_it() { + // The byte recursion stops at the outermost thing that goes — one unit, + // one figure, no double count — while the verdict recursion descended into + // everything to earn it. + let world = Clone::new(); + let outer = world.worktree("agent-outer"); + let inner = world.nested(&outer, "agent-inner"); + world.containerise(); + + let plan = world.plan(); + + assert_eq!(going_dirs(&plan), std::slice::from_ref(&outer)); + let Collectable::Directory(directory) = plan.going()[0].what() else { + panic!("expected a directory unit"); + }; + assert_eq!( + directory.forgets().len(), + 2, + "the one removal accounts for both registrations" + ); + + let (report, _) = world.act(&plan); + assert_eq!(report.removed.len(), 1); + assert_eq!(report.forgotten, 2); + assert!(!outer.exists()); + assert!(!inner.exists()); + let listing = world.listing(); + assert!( + !listing.contains("agent-outer") && !listing.contains("agent-inner"), + "both registrations are gone: {listing}" + ); +} + +// ======================================================================= +// T2: a registration created after the plan was printed. The metadata +// operation is per registration by name, and a name exists only because a +// listing printed it — so the new registration is never named. The spawn +// log is the absence assertion. +// ======================================================================= + +#[test] +fn a_registration_created_after_the_plan_is_never_named() { + let world = Clone::new(); + let finished = world.worktree("agent-finished"); + world.containerise(); + let plan = world.plan(); + assert_eq!(going_dirs(&plan), std::slice::from_ref(&finished)); + + // The container registers a new worktree while the question is on screen. + let fresh = worktrees_dir(&world.clone).join("agent-fresh"); + run_git( + &world.clone, + &[ + "worktree", + "add", + "-b", + "agent-fresh", + &fresh.display().to_string(), + ], + ); + std::fs::write(fresh.join("notes.md"), "live work\n").expect("live work"); + + let (report, calls) = world.act(&plan); + + assert_eq!(report.removed.len(), 1); + assert!(!finished.exists()); + assert!(fresh.exists(), "the fresh worktree is untouched"); + assert!( + world.listing().contains("agent-fresh"), + "its registration is untouched too" + ); + // The absence itself: no git invocation names a registration the pass did + // not read from a listing, and the fresh worktree was approved by nobody. + let fresh_spelled = fresh.display().to_string(); + assert!( + !calls + .iter() + .filter(|argv| argv.iter().any(|arg| arg == "remove")) + .any(|argv| argv.iter().any(|arg| arg.contains(&fresh_spelled))), + "no removal invocation may name the fresh registration: {calls:?}" + ); +} + +#[test] +fn a_worktree_that_went_dirty_while_the_question_was_open_is_withheld() { + // The re-check is the same weighing as the plan, run again under the lock. + // And with `git worktree prune` deleted there is no second operation for + // the withheld worktree's registration to be lost to: the next run reads + // it as registered-and-dirty, not as forgotten. + let world = Clone::new(); + let finished = world.worktree("agent-finished"); + let unsaved = world.worktree("agent-unsaved"); + world.containerise(); + let plan = world.plan(); + assert_eq!(going_dirs(&plan).len(), 2, "{:?}", plan.going()); + + // 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 (report, _) = world.act(&plan); + + assert_eq!(report.removed.len(), 1); + assert_eq!(report.withheld.len(), 1); + assert!(!finished.exists(), "nothing objected to that one"); + assert!(unsaved.exists(), "it holds a note nowhere else"); + assert!( + world.listing().contains("agent-unsaved"), + "the registration is untouched: it is what tells the next run this is not \ + an unaccounted-for directory" + ); + + // And the run after it: the site stands, offered to nobody. + let again = world.plan(); + assert!(going_dirs(&again).is_empty(), "{:?}", again.going()); + assert_eq!( + std::fs::read_to_string(unsaved.join("notes.md")).expect("the note is still here"), + "an afternoon\n" + ); +} + +// ======================================================================= +// locks at the top level, and what insisting means +// ======================================================================= + +#[test] +fn a_locked_worktree_is_never_removed_implicitly_and_the_flag_carries_it() { + let world = Clone::new(); + let worktree = world.worktree("agent-one"); + run_git( + &world.clone, + &["worktree", "lock", &worktree.display().to_string()], + ); + world.containerise(); + + let plan = world.plan(); + assert!(going_dirs(&plan).is_empty()); + let reasons = reasons_at(&plan, &worktree); + assert!( + matches!( + reasons[0], + Reason::CouldNotProve { + blank: Blank::ThirdPartyClaim(None), + .. + } + ), + "a lock taken with no reason carries no invented one: {reasons:?}" + ); + + let insisted = world + .sweep(Insistence::Insisted) + .expect("a sweep of a clone that has worktrees"); + assert_eq!(going_dirs(&insisted), std::slice::from_ref(&worktree)); + let (report, _) = world.act(&insisted); + assert_eq!(report.removed.len(), 1); + assert_eq!(report.forgotten, 1, "{:?}", report.forget_refused); + assert!(!worktree.exists()); + assert!(!world.listing().contains("agent-one")); +} + +#[test] +fn a_locked_and_dirty_worktree_reports_both_reasons() { + // A report naming one of two reasons is telling half the truth; the + // standing accumulates rather than short-circuits. + let world = Clone::new(); + let worktree = world.worktree("agent-one"); + std::fs::write(worktree.join("notes.md"), "work\n").expect("work"); + run_git( + &world.clone, + &["worktree", "lock", &worktree.display().to_string()], + ); + world.containerise(); + + let plan = world.plan(); + + let reasons = reasons_at(&plan, &worktree); + assert_eq!(reasons.len(), 2, "{reasons:?}"); + assert!(reasons.iter().any(|it| matches!(it, Reason::Holds { .. }))); + assert!( + reasons + .iter() + .any(|it| matches!(it, Reason::CouldNotProve { .. })) + ); +} + +// ======================================================================= +// registrations with nothing at their place (devlaunch#446 §5a) +// ======================================================================= + +#[test] +fn a_registration_with_nothing_here_is_forgotten_once_its_commits_are_reached() { + let world = Clone::new(); + let worktree = world.worktree("agent-gone"); + world.containerise(); + std::fs::remove_dir_all(&worktree).expect("removed by hand"); + + let plan = world.plan(); + assert_eq!(going_places(&plan), [".claude/worktrees/agent-gone"]); + + let (report, _) = world.act(&plan); + assert_eq!(report.forgotten, 1); + assert!(!world.listing().contains("agent-gone")); + + // And the run after that has nothing to offer. + let again = world.plan(); + assert!(again.going().is_empty(), "{:?}", again.going()); + assert!(again.standing().is_empty(), "{:?}", again.standing()); +} + +#[test] +fn emptiness_alone_never_carries_a_registered_site() { + // Measured on devlaunch#446: a registration can be the last ref reaching a + // detached worktree's commits, so forgetting it on emptiness alone hands + // the commits to the next gc. The reachability question is asked whether + // or not there are bytes. + let world = Clone::new(); + let worktree = world.worktree("agent-gone"); + run_git(&worktree, &["checkout", "--detach"]); + std::fs::write(worktree.join("late.md"), "work\n").expect("work"); + commit(&worktree, "reachable only from this registration"); + world.containerise(); + std::fs::remove_dir_all(&worktree).expect("removed by hand"); + + let plan = world.plan(); + + assert!(plan.going().is_empty(), "{:?}", plan.going()); + let reasons = reasons_at(&plan, &worktree); + assert!(matches!(reasons[0], Reason::Holds { .. }), "{reasons:?}"); + assert!( + world.listing().contains("agent-gone"), + "the registration is what keeps those commits alive" + ); +} + +// ======================================================================= +// sites this clone cannot account for +// ======================================================================= + +#[test] +fn a_plain_directory_in_the_worktrees_place_stands_and_is_reported() { + let world = Clone::new(); + world.worktree("agent-one"); + let plain = worktrees_dir(&world.clone).join("not-a-worktree"); + std::fs::create_dir_all(&plain).expect("a plain directory"); + std::fs::write(plain.join("keep.txt"), "keep\n").expect("their content"); + world.containerise(); + + let plan = world.plan(); + + let reasons = reasons_at(&plan, &plain); + assert!( + matches!( + reasons[0], + Reason::CouldNotProve { + blank: Blank::NotThisClonesToAccountFor(Unaccountable::PlainDirectory), + .. + } + ), + "{reasons:?}" + ); + let (report, _) = world.act(&plan); + assert!(plain.exists(), "not devlaunch's to remove: {report:?}"); +} + +#[test] +fn a_gitfile_that_does_not_normalise_is_not_a_worktree_and_stands() { + // devlaunch#442 review S6, carried: a `..` tail would name the clone's own + // `.git`. There is no deleting arm for it to land on any more — the site + // stands, and the reason names what could not be read. + let world = Clone::new(); + world.worktree("agent-one"); + let doctored = worktrees_dir(&world.clone).join("doctored"); + std::fs::create_dir_all(&doctored).expect("a directory"); + std::fs::write(doctored.join(".git"), "gitdir: /x/.git/worktrees/..\n") + .expect("a doctored gitfile"); + world.containerise(); + + let plan = world.plan(); + + let reasons = reasons_at(&plan, &doctored); + assert!( + matches!( + reasons[0], + Reason::CouldNotProve { + blank: Blank::NotThisClonesToAccountFor(Unaccountable::GitfileUnreadable), + .. + } + ), + "{reasons:?}" + ); +} + +#[test] +fn a_symlink_in_the_worktrees_place_is_never_followed() { + let world = Clone::new(); + world.worktree("agent-one"); + let elsewhere = world.tmp().join("elsewhere"); + std::fs::create_dir_all(&elsewhere).expect("a directory outside the clone"); + std::fs::write(elsewhere.join("keep.txt"), "keep\n").expect("content"); + let link = worktrees_dir(&world.clone).join("a-link"); + std::os::unix::fs::symlink(&elsewhere, &link).expect("a symlink"); + world.containerise(); + + let plan = world.plan(); + + let reasons = reasons_at(&plan, &link); + assert!( + matches!( + reasons[0], + Reason::CouldNotProve { + blank: Blank::NotThisClonesToAccountFor(Unaccountable::SymlinkInThePlace), + .. + } + ), + "{reasons:?}" + ); + assert!(elsewhere.join("keep.txt").exists()); +} + +// ======================================================================= +// a worktree of a different repository (devlaunch#463) +// ======================================================================= + +#[test] +fn a_foreign_worktree_stands_its_ancestors_and_is_never_probed_or_named() { + // The measured defect: this exact shape was offered for removal unopposed + // under "git has already forgotten it", and answering `y` destroyed the + // other repository's uncommitted work and left it unable to check out its + // branch. Ownership is a registration join now, so the site stands, every + // ancestor stands, and no git invocation of ours ever names it. + let world = Clone::new(); + let outer = world.worktree("agent-outer"); + let other = OtherRepository::new(world.tmp()); + let theirs = other.worktree_at(&worktrees_dir(&outer).join("nested2"), "feat"); + world.containerise(); + + let plan = world.plan(); + + assert!( + going_dirs(&plan).is_empty(), + "the ancestor is pinned: {:?}", + plan.going() + ); + // One line, attributed to the foreign site rather than to each ancestor. + assert_eq!(standing_paths(&plan), std::slice::from_ref(&theirs)); + let reasons = reasons_at(&plan, &theirs); + assert!( + matches!( + reasons[0], + Reason::CouldNotProve { + blank: Blank::NotThisClonesToAccountFor(Unaccountable::RegisteredElsewhere), + .. + } + ), + "{reasons:?}" + ); + + let (report, calls) = world.act(&plan); + assert!( + report.removed.is_empty() && report.forgotten == 0, + "{report:?}" + ); + assert!(theirs.join("UNSAVED.txt").exists()); + // No invocation names the foreign registration — probe or forget. + let theirs_spelled = theirs.display().to_string(); + assert!( + !calls + .iter() + .any(|argv| argv.iter().any(|arg| arg.contains(&theirs_spelled))), + "{calls:?}" + ); + // The other repository is untouched: still listing its worktree, with no + // prunable line, its work intact. + let their_listing = other.listing(); + assert!(their_listing.contains("nested2"), "{their_listing}"); + assert!(!their_listing.contains("prunable"), "{their_listing}"); +} + +#[test] +fn a_foreign_leaf_colliding_with_our_admin_name_is_not_probed_through_our_index() { + // devlaunch#446 §8's suffix-join hazard, closed structurally: the admin + // directory is derived from the joined registration's recorded path, so a + // foreign worktree whose leaf name matches one of our admin directories can + // never be handed our index. Two worktrees cut from the same commit with no + // edits would read as a clean Q2 through a borrowed index — the fail-safe + // the old shape leaned on does not exist here to lean on. + let world = Clone::new(); + let outer = world.worktree("agent-outer"); + let other = OtherRepository::new(world.tmp()); + // The foreign worktree's leaf is `agent-outer`, so its own admin directory + // over in the other repository is named `agent-outer` — the same name as + // ours. + let theirs = other.worktree_at(&worktrees_dir(&outer).join("agent-outer"), "agent-outer"); + world.containerise(); + + let calls = RefCell::new(Vec::new()); + let runner = Recording { + real: ProcessRunner::new(), + calls: &calls, + forgets_must_be_absent: true, + }; + let git = Git::new(&runner); + let plan = sweep_clone( + &git, + &world.clone, + OWNER, + REPO, + Some(&world.bare), + Insistence::NotInsisted, + ) + .expect("a sweep"); + + let reasons = reasons_at(&plan, &theirs); + assert!( + matches!( + reasons[0], + Reason::CouldNotProve { + blank: Blank::NotThisClonesToAccountFor(Unaccountable::RegisteredElsewhere), + .. + } + ), + "{reasons:?}" + ); + // The absence: no status probe was pointed at the foreign directory + // through anything of ours. + let theirs_spelled = format!("--work-tree={}", theirs.display()); + assert!( + !calls + .borrow() + .iter() + .any(|argv| argv.iter().any(|arg| arg == &theirs_spelled)), + "the foreign site must never be probed: {:?}", + calls.borrow() + ); +} + +#[test] +fn a_recorded_path_resolving_into_another_clone_is_refused_and_contained() { + // The suffix join pairs a registration with the directory at its place — + // and the forget's argument is the recorded path, which git resolves by + // path, not by registration identity. Doctored to point into another + // repository's live worktree, the forget is refused by git, nothing else is + // forgotten, and the other repository's bytes are untouched. + let world = Clone::new(); + let ours = world.worktree("agent-one"); + world.containerise(); + // Another repository, holding a live worktree whose path carries the same + // `.claude/worktrees/agent-one` suffix as ours. + let other = OtherRepository::new(world.tmp()); + let their_place = other + .repo + .join(".claude") + .join("worktrees") + .join("agent-one"); + let theirs = other.worktree_at(&their_place, "agent-one"); + // The doctored gitdir: our registration now records their path. + let gitdir = world + .clone + .join(".git") + .join("worktrees") + .join("agent-one") + .join("gitdir"); + std::fs::write(&gitdir, format!("{}/.git\n", theirs.display())).expect("doctored"); + + let plan = world.plan(); + // The join still pairs the registration with our directory at that place, + // and the site reads collectable — the hazard is all in the forget. + assert_eq!(going_dirs(&plan), std::slice::from_ref(&ours)); + + let (report, _) = world.act_expecting_absent_forgets(&plan, false); + + assert!(!ours.exists(), "our directory went"); + assert_eq!( + report.forget_refused.len(), + 1, + "git refused the forget whose argument resolves into another repository: {report:?}" + ); + assert!( + theirs.join("UNSAVED.txt").exists(), + "the other repository's work is untouched" + ); + let their_listing = other.listing(); + assert!(their_listing.contains("agent-one"), "{their_listing}"); +} + +// ======================================================================= +// the resolving fixture (devlaunch#462): registrations that record paths +// which really do resolve to worktrees of this clone +// ======================================================================= + +#[test] +fn a_resolving_site_with_uncommitted_work_stands() { + let world = Clone::new(); + let worktree = world.worktree("agent-one"); + std::fs::write(worktree.join("notes.md"), "work\n").expect("work"); + // No containerise: the recorded paths resolve. + + let plan = world.plan(); + + assert!(going_dirs(&plan).is_empty(), "{:?}", plan.going()); + let reasons = reasons_at(&plan, &worktree); + assert!(matches!(reasons[0], Reason::Holds { .. }), "{reasons:?}"); +} + +#[test] +fn a_collected_resolving_site_is_forgotten_only_after_its_directory_is_gone() { + // P2 asserted directly: the recorded path does not resolve at the moment + // the forget is invoked, even though it resolved a moment earlier. The + // recording runner asserts it at the spawn itself. + let world = Clone::new(); + let worktree = world.worktree("agent-one"); + + let plan = world.plan(); + assert_eq!(going_dirs(&plan), std::slice::from_ref(&worktree)); + + let (report, calls) = world.act(&plan); + + assert_eq!(report.removed.len(), 1); + assert_eq!(report.forgotten, 1, "{:?}", report.forget_refused); + assert!(!worktree.exists()); + assert!(!world.listing().contains("agent-one")); + assert!( + calls + .iter() + .any(|argv| argv.iter().any(|arg| arg == "remove")), + "the forget really was a per-registration git invocation: {calls:?}" + ); +} + +// ======================================================================= +// parsing details that carry the join +// ======================================================================= + +#[test] +fn the_clones_own_entry_and_worktrees_elsewhere_are_not_candidates() { + let world = Clone::new(); + world.worktree("agent-one"); + // A worktree of this clone parked outside any worktrees place. + let elsewhere = world.tmp().join("parked"); + run_git( + &world.clone, + &[ + "worktree", + "add", + "-b", + "parked", + &elsewhere.display().to_string(), + ], + ); + world.containerise(); + + let plan = world.plan(); + + assert_eq!(going_dirs(&plan).len(), 1); + assert!(plan.standing().is_empty(), "{:?}", plan.standing()); + assert!(elsewhere.exists()); +} + +#[test] +fn a_nested_registration_keeps_its_whole_place_inside_the_clone() { + // The join key is the suffix from the first `.claude/worktrees` onwards, so + // a nested worktree cannot be confused with a same-named one at the top. + let world = Clone::new(); + let outer = world.worktree("agent-a"); + let inner = world.nested(&outer, "agent-a-nested"); + world.containerise(); + + let plan = world.plan(); + + assert_eq!(going_dirs(&plan), [outer]); + let Collectable::Directory(directory) = plan.going()[0].what() else { + panic!("expected a directory unit"); + }; + let places: Vec = directory + .forgets() + .iter() + .map(|recorded| recorded.as_path().display().to_string()) + .collect(); + assert!( + places + .iter() + .any(|place| place.ends_with("agent-a/.claude/worktrees/agent-a-nested")), + "{places:?}" + ); + drop(inner); +} + +// ======================================================================= +// the clone as the root of the same forest +// ======================================================================= + +#[test] +fn a_clean_clone_with_a_dirty_gitignored_worktree_stands_on_the_sites_account() { + // The nested half of the dirt blindness (devlaunch#459 by way of #446): + // `.claude/` is gitignored, so the clone's own status says nothing, and + // the shipped guard read this exact shape as nothing to lose. + let world = Clone::new(); + std::fs::write(world.clone.join(".gitignore"), ".claude/\n").expect("gitignore"); + commit(&world.clone, "ignore the agent worktrees"); + run_git(&world.clone, &["push", "origin", "main"]); + world.fetch(); + let worktree = world.worktree("agent-one"); + std::fs::write(worktree.join("UNSAVED.txt"), "an afternoon\n").expect("the note"); + world.containerise(); + + let runner = ProcessRunner::new(); + let git = Git::new(&runner); + let verdict = clone_verdict(&git, &world.clone, BareCache::At(&world.bare)); + + let Verdict::Stands(standing) = &verdict else { + panic!("the clone must stand on the site's account, got {verdict:?}"); + }; + let json = verdict.unsaved_json(); + let would_lose = json["wouldLose"].as_str().expect("a wouldLose key"); + assert!( + would_lose.contains("agent-one"), + "the loss is attributed to the site holding it: {would_lose}" + ); + assert!(standing.would_lose().is_some()); +} + +#[test] +fn a_standing_that_holds_both_kinds_emits_both_wire_keys() { + // The flattening is additive, never lossy: a dirty clone with a locked + // worktree in it says both things, in both keys, so no reader keyed on key + // presence breaks and no answer is dropped to fit the shape. + let world = Clone::new(); + std::fs::write(world.clone.join(".gitignore"), ".claude/\n").expect("gitignore"); + commit(&world.clone, "ignore the agent worktrees"); + run_git(&world.clone, &["push", "origin", "main"]); + world.fetch(); + let worktree = world.worktree("agent-one"); + run_git( + &world.clone, + &["worktree", "lock", &worktree.display().to_string()], + ); + std::fs::write(world.clone.join("dirty.md"), "clone-level work\n").expect("clone dirt"); + world.containerise(); + + let runner = ProcessRunner::new(); + let git = Git::new(&runner); + let json = clone_verdict(&git, &world.clone, BareCache::At(&world.bare)).unsaved_json(); + + assert!(json["wouldLose"].is_string(), "{json}"); + assert!(json["couldNotTell"].is_string(), "{json}"); + assert!(json["nothingToLose"].is_null(), "{json}"); +} + +#[test] +fn a_clean_clone_with_collectable_worktrees_reads_as_nothing_to_lose() { + // Collectable sites do not stand the clone: the verdict conjoins standing + // reasons, and a finished worktree contributes none. + let world = Clone::new(); + std::fs::write(world.clone.join(".gitignore"), ".claude/\n").expect("gitignore"); + commit(&world.clone, "ignore the agent worktrees"); + run_git(&world.clone, &["push", "origin", "main"]); + world.fetch(); + world.worktree("agent-one"); + world.containerise(); + + let runner = ProcessRunner::new(); + let git = Git::new(&runner); + let json = clone_verdict(&git, &world.clone, BareCache::At(&world.bare)).unsaved_json(); + + assert_eq!(json, serde_json::json!({ "nothingToLose": true })); +} diff --git a/rust/devlaunch-core/src/flows/lifecycle.rs b/rust/devlaunch-core/src/flows/lifecycle.rs index 91c40695..ff122075 100644 --- a/rust/devlaunch-core/src/flows/lifecycle.rs +++ b/rust/devlaunch-core/src/flows/lifecycle.rs @@ -83,7 +83,8 @@ use crate::clients::git::Git; use crate::domain::locks::{self, LockError}; use crate::domain::metadata::{self, MetadataStorage, RecordUpdate, WorktreeFilter}; use crate::domain::model::{SweepNote, SweepTrouble, WorktreeInfo}; -use crate::domain::workspace_state::{self, BareCache, CouldNotTell, Losses, NonEmpty, Unsaved}; +use crate::domain::workspace_state::{BareCache, NonEmpty}; +use crate::flows::agent_worktrees::{self, Standing, Verdict, WorktreeReport, WorktreeSweep}; use crate::flows::completion_cache; use crate::flows::disk_usage::{self, DiskUsage}; use crate::flows::kept_copies::{self, KeptCopies}; @@ -1008,25 +1009,50 @@ 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. + /// + /// 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, + } + } +} + /// Why `dl rm` will not delete this workspace. /// -/// Two arms and no third, because [`Unsaved`] has three and one of them is -/// permission. Each carries what its sentence interpolates and nothing else. +/// Carries the verdict's whole standing rather than one arm of it, because a +/// clone can hold work *and* have a question that could not be put — a dirty +/// tree beside a refused reachability probe, or unsaved work in a nested agent +/// worktree beside the clone's own — and a refusal that named one would be +/// telling half the truth (devlaunch#446). "Could not be proved" is refused for +/// not knowing: the work is still on disk and nothing has shown it exists +/// anywhere else, which is the same standing as unpushed work and gets the same +/// refusal and the same way past it (devlaunch#171). #[derive(Debug, Clone, PartialEq, Eq)] -pub enum RemovalRefused { - /// The clone holds work that exists nowhere else. - WouldLose { - workspace_id: String, - losses: Losses, - }, - /// dl could not establish what the clone holds. Refused for not knowing, and - /// it says which: the work is still on disk and nothing has shown that it - /// exists anywhere else, which is the same standing as unpushed work and gets - /// the same refusal and the same way past it (devlaunch#171). - CouldNotTell { - workspace_id: String, - cause: CouldNotTell, - }, +pub struct RemovalRefused { + pub workspace_id: String, + pub standing: Standing, } /// What the guard decided. @@ -1043,29 +1069,24 @@ pub(crate) enum Guarded { /// whether the work is finished — dl has no way to know that — but about whether /// this clone is the only place the work exists. /// -/// Total over [`Unsaved`]'s three arms, and only one of them is permission -/// (devlaunch#171). Python needed `unhandled_unsaved()` behind an `isinstance` -/// chain so that an answer the guard did not name would raise rather than slide -/// through an `else` into a deletion; here the `match` is exhaustive at compile -/// time, so a fourth arm breaks this function instead. +/// Total over [`Verdict`]'s two arms, and only one of them is permission — and +/// that one carries a proof no caller can mint (devlaunch#446), where +/// devlaunch#171's version of this guard could still be handed a "nothing to +/// lose" that nothing had established. /// /// `--force` is checked *after* the answer is read, not instead of reading it, so /// the refusal a forced delete carried past is still available to the caller — and /// so a future `--force` that wanted to report what it overrode has it. pub(crate) fn guard_removal( workspace_id: &str, - unsaved: Unsaved, + verdict: Verdict, insistence: Insistence, ) -> Guarded { - let refusal = match unsaved { - Unsaved::NothingToLose => return Guarded::MayRemove, - Unsaved::WouldLose(losses) => RemovalRefused::WouldLose { + let refusal = match verdict { + Verdict::Collectable(_) => return Guarded::MayRemove, + Verdict::Stands(standing) => RemovalRefused { workspace_id: workspace_id.to_owned(), - losses, - }, - Unsaved::CouldNotTell(cause) => RemovalRefused::CouldNotTell { - workspace_id: workspace_id.to_owned(), - cause, + standing, }, }; match insistence { @@ -1114,11 +1135,12 @@ impl ClonePathResolver for CloneDirectories<'_, '_> { } } -/// What deleting `workspace_id` would destroy, as far as dl can establish. +/// What deleting `workspace_id` would destroy, as far as dl can establish — +/// the clone's own probes and every agent worktree nested in it, one verdict. /// /// [`listing::unsaved_work_in`]'s reader, wired to the production resolver. The -/// answer for a workspace dl has no record of is [`Unsaved::NothingToLose`], which -/// is the honest answer rather than a permissive one: those are workspaces opened +/// answer for a workspace dl has no record of is collectable, which is the +/// honest answer rather than a permissive one: those are workspaces opened /// from a path or a URL that dl never cloned and does not manage, so it has no /// clone of its own to protect and no business inspecting somebody's checkout to /// find one. @@ -1129,7 +1151,7 @@ pub(crate) fn unsaved_work_in( cache_dir: &Path, workspace_id: &str, notices: &mut dyn Notices, -) -> Unsaved { +) -> Verdict { let directories = CloneDirectories::of(clones); let view = listing::DlView { cache_dir, @@ -2213,35 +2235,6 @@ fn subdirectories(path: &Path) -> Vec { // prune: what one clone directory is // =========================================================================== -/// What removing a clone would destroy or risk — the two answers `--prune` acts -/// on. -/// -/// The one place devlaunch#171's three answers become the two `decide` acts on: -/// something to say, or nothing. "Could not tell" arrives here as an *objection* -/// rather than as an absence, so the clone is kept for the same reason unpushed -/// work keeps one. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Objection { - /// Deleting it would destroy this. Carries the losses, so the description a - /// report prints is derived rather than passed along as text. - WouldLose(Losses), - /// git could not be asked about it, and this is what it said. - CouldNotTell(CouldNotTell), -} - -/// What removing `unsaved`'s clone would cost, or nothing when it would cost -/// nothing. -/// -/// Total over [`Unsaved`]'s arms, so a fourth answer stops the build rather than -/// falling through into a deletion. -pub fn objection(unsaved: &Unsaved) -> Option { - match unsaved { - Unsaved::NothingToLose => None, - Unsaved::WouldLose(losses) => Some(Objection::WouldLose(losses.clone())), - Unsaved::CouldNotTell(cause) => Some(Objection::CouldNotTell(cause.clone())), - } -} - /// Which arm one clone directory is. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum CloneStatus { @@ -2249,14 +2242,18 @@ pub(crate) enum CloneStatus { Referenced { workspace_id: String }, /// Nothing opens this directory and no record ties it to a live workspace. /// - /// `unsaved` sits inside this arm rather than beside the classification because - /// it is only ever actionable here: "unsaved work on a clone that is staying - /// anyway" is a sentence this type cannot say. `usage` is here for the same - /// reason and earns its place twice over — the walk behind it is O(files) with - /// no ceiling, and this is the only arm whose bytes anybody is going to get - /// back, so putting it here is what keeps the other two arms from being walked - /// at all. - Orphaned { unsaved: Unsaved, usage: DiskUsage }, + /// `verdict` sits inside this arm rather than beside the classification + /// because it is only ever actionable here: "unsaved work on a clone that is + /// staying anyway" is a sentence this type cannot say. It is + /// [`agent_worktrees::clone_verdict`] — the clone's own probes conjoined + /// with every agent worktree nested in it, so an orphan clone whose + /// gitignored `.claude/worktrees/` holds an afternoon of unsaved work can no + /// longer read as free to delete (devlaunch#446). `usage` is here for the + /// same reason and earns its place twice over — the walk behind it is + /// O(files) with no ceiling, and this is the only arm whose bytes anybody is + /// going to get back, so putting it here is what keeps the other two arms + /// from being walked at all. + Orphaned { verdict: Verdict, usage: DiskUsage }, /// devpod lists a workspace whose records and devlaunch's disagree about where /// it is. /// @@ -2340,7 +2337,7 @@ pub(crate) fn clone_status( }; } CloneStatus::Orphaned { - unsaved: workspace_state::holds_unsaved_work(git, clone, BareCache::At(bare)), + verdict: agent_worktrees::clone_verdict(git, clone, BareCache::At(bare)), usage: disk_usage::exclusive_usage(clone), } } @@ -2353,8 +2350,9 @@ pub(crate) fn clone_status( pub enum KeptBecause { /// A live workspace still opens it. StillOpened { workspace_id: String }, - /// It holds something, and `--force` was not typed. - Objected(Objection), + /// At least one thing stands it — work it holds, or a question that could + /// not be put — and `--force` was not typed. + Objected(Standing), /// devpod lists the workspace this directory's record names, and sources it /// somewhere else. See devlaunch#88. RecordsDisagree { @@ -2375,7 +2373,7 @@ pub enum KeptBecause { #[derive(Debug, Clone, PartialEq, Eq)] pub enum Promotion { Unopposed, - Insisted { despite: Objection }, + Insisted { despite: Standing }, } impl Promotion { @@ -2421,17 +2419,17 @@ pub(crate) fn decide(status: CloneStatus, insistence: Insistence) -> Decision { CloneStatus::Referenced { workspace_id } => { Decision::Keep(KeptBecause::StillOpened { workspace_id }) } - CloneStatus::Orphaned { unsaved, usage } => match objection(&unsaved) { - None => Decision::Remove { + CloneStatus::Orphaned { verdict, usage } => match verdict { + Verdict::Collectable(_) => Decision::Remove { usage, promotion: Promotion::Unopposed, }, - Some(objected) => match insistence { + Verdict::Stands(standing) => match insistence { Insistence::Insisted => Decision::Remove { usage, - promotion: Promotion::Insisted { despite: objected }, + promotion: Promotion::Insisted { despite: standing }, }, - Insistence::NotInsisted => Decision::Keep(KeptBecause::Objected(objected)), + Insistence::NotInsisted => Decision::Keep(KeptBecause::Objected(standing)), }, }, CloneStatus::Disputed { @@ -2530,16 +2528,31 @@ pub struct PrunePlan { /// copies of what devpod substituted. A second enumeration beside the clone /// walk rather than a column on it: see [`prune_plan`]. reclaiming: 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.reclaiming.is_empty() + self.removing.is_empty() + && self.stale_records.is_empty() + && self.reclaiming.is_empty() + && self.worktrees.nothing_to_do() } - /// What the whole run would free. - pub fn freed(&self) -> DiskUsage { + /// 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())) } @@ -2567,6 +2580,11 @@ impl PrunePlan { pub fn reclaiming(&self) -> &[ReclaimableVolumes] { &self.reclaiming } + + /// The agent git worktrees inside the clones this run is keeping. + pub fn worktrees(&self) -> &WorktreeSweep { + &self.worktrees + } } /// The directory `--prune` scans, canonicalised once. @@ -2666,12 +2684,13 @@ pub fn prune_plan( workspaces: &[Workspace], copies: &KeptCopies, 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); @@ -2708,7 +2727,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(), @@ -2716,10 +2735,25 @@ 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 — a clone that is + // going already accounts for everything inside it — 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, + }); + } } } } @@ -2739,6 +2773,7 @@ pub fn prune_plan( keeping, stale_records, reclaiming: reclaimable_volumes(copies, workspaces), + worktrees, }) } @@ -2871,18 +2906,26 @@ pub struct PruneReport { /// The workspaces whose volumes stayed, and why. Their copies are kept, so /// every one of these is retryable. pub volumes_kept: Vec, + /// What the run did about the agent worktrees inside the clones it kept. + pub worktrees: WorktreeReport, } 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 { + /// + /// 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 { self.refused.is_empty() + && self.worktrees.refused.is_empty() + && self.worktrees.forget_refused.is_empty() } } @@ -2952,6 +2995,7 @@ pub fn prune_clones( refused: Vec::new(), reclaimed: Vec::new(), volumes_kept: Vec::new(), + worktrees: WorktreeReport::default(), }; let mut forget: Vec = Vec::new(); for ((owner, repo), reclaimables) in by_repo { @@ -3004,6 +3048,24 @@ pub fn prune_clones( // below: these volumes belong to workspaces with no clone directory in this // plan at all, so no repository lock is about them. reclaim_volumes(context.runner(), copies, plan, &workspaces, &mut report); + // 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 the loop above only ever removes whole clones — so it + // takes each repository's lock again rather than sharing the loop, 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 @@ -3585,6 +3647,7 @@ mod tests { use crate::domain::metadata::MetadataStorage; use crate::domain::model::{BaseRepository, Timestamp, WorktreeInfo}; use crate::domain::workspace_id::WorkspaceId; + use crate::domain::workspace_state; use crate::flows::repo_manager::tests::{refusing_reads, refusing_writes, run_git}; use crate::flows::repo_manager::{RefusalReason, RemoveTreeError, bare_dir}; use crate::flows::workspace_clone::GitLfs; @@ -4021,6 +4084,17 @@ 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"); @@ -4031,7 +4105,7 @@ mod tests { &workspaces, &world.copies(), &placement, - insistence, + insisted, &mut ignoring(), ) .expect("a plan") @@ -5932,60 +6006,76 @@ mod tests { // the delete guard // ======================================================================= - fn losses_of(unsaved: &Unsaved) -> String { - match unsaved { - Unsaved::WouldLose(losses) => losses.describe(), + fn losses_of(verdict: &Verdict) -> String { + match verdict { + Verdict::Stands(standing) => standing + .would_lose() + .unwrap_or_else(|| panic!("expected losses, got {standing:?}")), other => panic!("expected losses, got {other:?}"), } } + /// A standing built from one proved loss, for the guard's unit tests. + fn stands_holding(losses: workspace_state::Losses) -> Standing { + Standing::test_of(vec![agent_worktrees::Reason::Holds { + at: agent_worktrees::Place::TheCloneItself, + losses: Box::new(losses), + }]) + } + #[test] - fn nothing_to_lose_is_the_only_answer_that_is_permission() { + fn a_collectable_verdict_is_the_only_answer_that_is_permission() { assert_eq!( - guard_removal("ws", Unsaved::NothingToLose, Insistence::NotInsisted), + guard_removal("ws", Verdict::test_collectable(), Insistence::NotInsisted), Guarded::MayRemove ); } #[test] fn work_saved_nowhere_else_stops_the_delete_and_names_what_it_is() { - let losses = Losses::one(workspace_state::Loss::Unpushed { - commits: NonEmpty::one("abc1234 later".to_owned()), - by_tags: None, - }); + let standing = stands_holding(workspace_state::Losses::one( + workspace_state::Loss::Unpushed { + commits: NonEmpty::one("abc1234 later".to_owned()), + by_tags: None, + }, + )); let guarded = guard_removal( "ws", - Unsaved::WouldLose(losses.clone()), + Verdict::Stands(standing.clone()), Insistence::NotInsisted, ); assert_eq!( guarded, - Guarded::Refused(RemovalRefused::WouldLose { + Guarded::Refused(RemovalRefused { workspace_id: "ws".to_owned(), - losses + standing }) ); } #[test] fn an_answer_git_would_not_give_stops_the_delete_too() { - // devlaunch#171: "could not tell" refuses exactly as "would lose" does. The - // files are still on disk and nothing has established that they exist - // anywhere else. - let cause = CouldNotTell::GitCouldNotRead { + // devlaunch#171: "could not be proved" refuses exactly as "would lose" + // does. The files are still on disk and nothing has established that + // they exist anywhere else. + let cause = workspace_state::CouldNotTell::GitCouldNotRead { clone: PathBuf::from("/x"), reason: "not a repository".to_owned(), }; + let standing = Standing::test_of(vec![agent_worktrees::Reason::CouldNotProve { + at: agent_worktrees::Place::TheCloneItself, + blank: agent_worktrees::Blank::GitWouldNotSay(cause), + }]); let guarded = guard_removal( "ws", - Unsaved::CouldNotTell(cause.clone()), + Verdict::Stands(standing.clone()), Insistence::NotInsisted, ); assert_eq!( guarded, - Guarded::Refused(RemovalRefused::CouldNotTell { + Guarded::Refused(RemovalRefused { workspace_id: "ws".to_owned(), - cause + standing }) ); } @@ -5993,23 +6083,28 @@ mod tests { #[test] fn force_gets_past_both_refusals() { // The caller who means it is not blocked by dl declining to guess. - for unsaved in [ - Unsaved::WouldLose(Losses::one(workspace_state::Loss::Uncommitted( - NonEmpty::one("?? scratch.md".to_owned()), + for verdict in [ + Verdict::Stands(stands_holding(workspace_state::Losses::one( + workspace_state::Loss::Uncommitted(NonEmpty::one("?? scratch.md".to_owned())), ))), - Unsaved::CouldNotTell(CouldNotTell::DirectoryUnknown { - workspace_id: "ws".to_owned(), - }), + Verdict::test_stands(vec![agent_worktrees::Reason::CouldNotProve { + at: agent_worktrees::Place::TheCloneItself, + blank: agent_worktrees::Blank::GitWouldNotSay( + workspace_state::CouldNotTell::DirectoryUnknown { + workspace_id: "ws".to_owned(), + }, + ), + }]), ] { assert_eq!( - guard_removal("ws", unsaved, Insistence::Insisted), + guard_removal("ws", verdict, Insistence::Insisted), Guarded::MayRemove ); } } /// The guard's answer about `workspace_id`, over a real cache and real git. - fn guard_reads(world: &World, workspace_id: &str) -> Unsaved { + fn guard_reads(world: &World, workspace_id: &str) -> Verdict { let clones = clones_for(&world.repos_dir, &world.devpod); let git = Git::new(&world.devpod); unsaved_work_in( @@ -6027,7 +6122,10 @@ mod tests { let mut world = World::empty(); let clone = world.clone_at("r-main-aa", "main"); world.record("r-main-aa", "main", &clone); - assert_eq!(guard_reads(&world, "r-main-aa"), Unsaved::NothingToLose); + assert!(matches!( + guard_reads(&world, "r-main-aa"), + Verdict::Collectable(_) + )); } #[test] @@ -6069,7 +6167,10 @@ mod tests { let clone = world.clone_at("r-main-aa", "main"); std::fs::write(clone.join("an-hour-of-work.md"), "half a plan\n").expect("their work"); - assert_eq!(guard_reads(&world, "r-main-aa"), Unsaved::NothingToLose); + assert!(matches!( + guard_reads(&world, "r-main-aa"), + Verdict::Collectable(_) + )); assert!(clone.join("an-hour-of-work.md").exists()); } @@ -6110,16 +6211,13 @@ mod tests { let stale = world.repo_dir.join("not-on-disk"); world.record("r-evil-aaa", "--evil", &stale); - let unsaved = guard_reads(&world, "r-evil-aaa"); + let verdict = guard_reads(&world, "r-evil-aaa"); - let Unsaved::CouldNotTell(cause) = &unsaved else { - panic!("expected a refusal, got {unsaved:?}"); + let Verdict::Stands(standing) = &verdict else { + panic!("expected a refusal, got {verdict:?}"); }; - assert!( - cause.describe().contains("r-evil-aaa"), - "{}", - cause.describe() - ); + let words = standing.could_not_tell().expect("an unproved, not a loss"); + assert!(words.contains("r-evil-aaa"), "{words}"); } #[test] @@ -6133,9 +6231,12 @@ mod tests { std::fs::write(broken.join("scratch.md"), "an agent's notes\n").expect("their work"); world.record("r-broken-aa", "broken", &broken); - let unsaved = guard_reads(&world, "r-broken-aa"); + let verdict = guard_reads(&world, "r-broken-aa"); - assert!(matches!(unsaved, Unsaved::CouldNotTell(_)), "{unsaved:?}"); + let Verdict::Stands(standing) = &verdict else { + panic!("expected a refusal, got {verdict:?}"); + }; + assert!(standing.could_not_tell().is_some(), "{standing:?}"); assert!(broken.join("scratch.md").exists()); } @@ -6148,7 +6249,10 @@ mod tests { world.record("r-main-aa", "main", &clone); std::fs::remove_dir_all(&clone).expect("removed by hand"); - assert_eq!(guard_reads(&world, "r-main-aa"), Unsaved::NothingToLose); + assert!(matches!( + guard_reads(&world, "r-main-aa"), + Verdict::Collectable(_) + )); } // ======================================================================= @@ -7362,10 +7466,12 @@ mod tests { workspace_id: "referenced".to_owned() } ); - assert!(matches!( - kept_because(&plan, &four.orphan_dirty), - KeptBecause::Objected(Objection::WouldLose(_)) - )); + match kept_because(&plan, &four.orphan_dirty) { + KeptBecause::Objected(standing) => { + assert!(standing.would_lose().is_some(), "{standing:?}"); + } + other => panic!("expected a standing, got {other:?}"), + } assert!(matches!( kept_because(&plan, &four.disputed), KeptBecause::RecordsDisagree { .. } @@ -7391,10 +7497,12 @@ mod tests { [] as [&Path; 0], "nothing is safe to remove" ); - assert!(matches!( - kept_because(&plan, &four.orphan_clean), - KeptBecause::Objected(Objection::WouldLose(_)) - )); + match kept_because(&plan, &four.orphan_clean) { + KeptBecause::Objected(standing) => { + assert!(standing.would_lose().is_some(), "{standing:?}"); + } + other => panic!("expected a standing, got {other:?}"), + } } #[test] @@ -7457,12 +7565,12 @@ mod tests { .iter() .find(|(path, _)| *path == four.orphan_dirty) .expect("the dirty orphan"); - assert!(matches!( - dirty.1, - Promotion::Insisted { - despite: Objection::WouldLose(_) + match &dirty.1 { + Promotion::Insisted { despite } => { + assert!(despite.would_lose().is_some(), "{despite:?}"); } - )); + other => panic!("expected an insisted promotion, got {other:?}"), + } } #[test] @@ -7476,10 +7584,12 @@ mod tests { std::fs::write(broken.join("something.txt"), "x\n").expect("a file in it"); let kept = plan_for(&world, Insistence::NotInsisted); - assert!(matches!( - kept_because(&kept, &broken), - KeptBecause::Objected(Objection::CouldNotTell(_)) - )); + match kept_because(&kept, &broken) { + KeptBecause::Objected(standing) => { + assert!(standing.could_not_tell().is_some(), "{standing:?}"); + } + other => panic!("expected a standing, got {other:?}"), + } assert!(removing(&kept).is_empty()); let forced = plan_for(&world, Insistence::Insisted); @@ -7526,7 +7636,7 @@ mod tests { &workspaces, &KeptCopies::under(dir.path()), &placement, - Insistence::NotInsisted, + Insisted::nothing(), &mut ignoring(), ) .expect("a plan"); @@ -7641,7 +7751,7 @@ 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] @@ -8268,6 +8378,262 @@ mod tests { assert_eq!(deleting.world.copies().copied(), ["r-main-aa"]); } + // ======================================================================= + // agent worktrees inside the clones a prune keeps (devlaunch#426, #454) + // ======================================================================= + + /// 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 used to read as "git has + /// forgotten these", the answer that deleted. + 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 + } + + /// A worktree nested inside another, the way an agent session running in a + /// worktree makes one. + fn a_nested_worktree(clone: &Path, inside: &Path, leaf: &str) -> PathBuf { + let path = inside.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. + 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"); + } + } + + /// 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) + } + + /// Every directory the sweep would remove, across every clone in the plan. + fn sweeping(plan: &PrunePlan) -> Vec { + plan.worktrees() + .clones() + .iter() + .flat_map(|clone| clone.going()) + .filter_map(|going| match going.what() { + agent_worktrees::Collectable::Directory(directory) => { + Some(directory.at().to_path_buf()) + } + agent_worktrees::Collectable::Registration(_) => None, + }) + .collect() + } + + /// Every site the sweep would leave standing, across every clone. + fn sweep_standing(plan: &PrunePlan) -> Vec { + plan.worktrees() + .clones() + .iter() + .flat_map(|clone| clone.standing()) + .map(|site| site.at().to_path_buf()) + .collect() + } + + #[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!(sweeping(&plan), [worktree]); + 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 standing the + // worktrees put in its way: the clone-level verdict conjoins every site + // in it, and an untracked `.claude/` is uncommitted work besides. 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_forgets_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 copies = world.copies(); + + let outcome = prune_clones( + &mut context, + &clones, + &mut world.storage, + &copies, + &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_eq!(report.worktrees.forgotten, 1); + assert!(!worktree.exists()); + assert!(clone.exists(), "the clone itself is untouched"); + let listing = run_git(&clone, &["worktree", "list", "--porcelain"]); + assert!( + !listing.contains("agent-one"), + "git still lists it: {listing}" + ); + } + + #[test] + fn a_nested_worktree_holding_work_stands_the_whole_subtree_end_to_end() { + // T1 through the real command's two passes: the plan offers nothing + // containing the outer worktree, and the acting pass removes nothing. + let mut world = World::empty(); + let clone = world.clone_at("r-live-aa", "live"); + world.record("r-live-aa", "live", &clone); + let outer = an_agent_worktree(&clone, "agent-outer"); + let inner = a_nested_worktree(&clone, &outer, "agent-inner"); + std::fs::write(inner.join("UNSAVED.txt"), "an afternoon\n").expect("the note"); + as_a_host_sees_them(&clone); + world.devpod.lists(&[listed("live", &clone)]); + + let plan = plan_for(&world, Insistence::NotInsisted); + assert!(sweeping(&plan).is_empty(), "{:?}", plan.worktrees()); + assert_eq!(sweep_standing(&plan), std::slice::from_ref(&inner)); + + let clones = clones_for(&world.repos_dir, &world.devpod); + let mut context = CommandContext::new(&world.devpod); + let copies = world.copies(); + prune_clones( + &mut context, + &clones, + &mut world.storage, + &copies, + &plan, + &mut ignoring(), + ) + .expect("the pass ran"); + + assert!(outer.exists(), "the outer worktree is untouched"); + assert_eq!( + std::fs::read_to_string(inner.join("UNSAVED.txt")).expect("the note is still here"), + "an afternoon\n" + ); + } + + #[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!( + sweeping(&kept).is_empty(), + "--force is not --force-worktrees: {:?}", + kept.worktrees() + ); + assert_eq!(sweep_standing(&kept), std::slice::from_ref(&worktree)); + + let removed = plan_insisting( + &world, + Insisted { + clones: Insistence::NotInsisted, + worktrees: Insistence::Insisted, + }, + ); + assert_eq!(sweeping(&removed), [worktree]); + assert!(clone.exists()); + } + + #[test] + fn an_orphan_clone_whose_gitignored_worktrees_hold_work_is_kept() { + // The nested half of the dirt blindness, at the surface that destroys + // things: `.claude/` is gitignored, so the clone's own status says + // nothing, and the shipped orphan rule removed the clone outright. + let world = World::empty(); + let orphan = world.clone_at("r-orphan-aa", "orphan"); + std::fs::write(orphan.join(".gitignore"), ".claude/\n").expect("a gitignore"); + commit(&orphan, "ignore the agent worktrees"); + run_git(&orphan, &["push", "origin", "orphan"]); + let worktree = an_agent_worktree(&orphan, "agent-one"); + std::fs::write(worktree.join("UNSAVED.txt"), "an afternoon\n").expect("the note"); + as_a_host_sees_them(&orphan); + world.devpod.lists(&[]); + + let plan = plan_for(&world, Insistence::NotInsisted); + + assert!( + removing(&plan).is_empty(), + "the clone holds work one level in: {:?}", + removing(&plan) + ); + match kept_because(&plan, &orphan) { + KeptBecause::Objected(standing) => { + let words = standing.describe(); + assert!(words.contains("agent-one"), "{words}"); + } + other => panic!("expected a standing, got {other:?}"), + } + } + // ======================================================================= // reconcile (devlaunch#88) // ======================================================================= diff --git a/rust/devlaunch-core/src/flows/listing.rs b/rust/devlaunch-core/src/flows/listing.rs index 271e281c..98f43c67 100644 --- a/rust/devlaunch-core/src/flows/listing.rs +++ b/rust/devlaunch-core/src/flows/listing.rs @@ -65,9 +65,8 @@ use crate::clients::devpod::{ use crate::clients::git::{Git, GitAnswer}; use crate::domain::metadata::MetadataStorage; use crate::domain::model::{SweepNote, WorktreeInfo}; -use crate::domain::workspace_state::{ - self, BareCache, CloneState, CouldNotTell, NonEmpty, Unsaved, -}; +use crate::domain::workspace_state::{BareCache, CouldNotTell, NonEmpty}; +use crate::flows::agent_worktrees::{self, Verdict}; use crate::flows::disk_usage::{self, DiskUsage}; use crate::runner::Runner; use crate::timing; @@ -861,7 +860,11 @@ pub(crate) struct DevlaunchClone { /// to call it. pub(crate) path: PathBuf, pub(crate) recorded: Option, - pub(crate) state: CloneState, + /// What the clone has checked out, or `None` when git could not say. + pub(crate) checked_out: Option, + /// What deleting it would destroy — the clone's own probes and every agent + /// worktree nested in it, one verdict (devlaunch#446). + pub(crate) holds: Verdict, } /// The `disk` field: absent unless `--size` was asked for, null where dl has no @@ -882,7 +885,7 @@ pub(crate) struct DevlaunchClone { pub(crate) enum DiskField { NotAsked, NothingOfOurs, - Freed(DiskUsage), + Freed(CloneDisk), } impl DiskField { @@ -890,7 +893,73 @@ 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))), } } } @@ -983,8 +1052,10 @@ fn enriched_row( // No record is no repository to name a mirror for, and the row falls back // to the safe reading: every tag in that clone counts as local. let bare = record.and_then(|record| view.clones.bare_path(record)); + let account = agent_worktrees::account_of(git, &path, BareCache::of(bare.as_deref())); DevlaunchClone { - state: workspace_state::read_clone(git, &path, BareCache::of(bare.as_deref())), + checked_out: account.branch, + holds: account.holds, path, recorded: record.map(Recorded::of), } @@ -1097,17 +1168,17 @@ fn container_state(runner: &dyn Runner, workspace_id: &str) -> Option, view: &DlView<'_>, workspace_id: &str) -> Unsaved { +pub(crate) fn unsaved_work_in(git: &Git<'_>, view: &DlView<'_>, workspace_id: &str) -> Verdict { let Some(record) = view.storage.get_worktree_by_workspace_id(workspace_id) else { - return Unsaved::NothingToLose; + return agent_worktrees::nothing_of_ours(); }; match view.clones.clone_path(record) { - Some(clone) => workspace_state::holds_unsaved_work( + Some(clone) => agent_worktrees::clone_verdict( git, &clone, BareCache::of(view.clones.bare_path(record).as_deref()), ), - None => Unsaved::CouldNotTell(CouldNotTell::DirectoryUnknown { + None => agent_worktrees::could_not_prove(CouldNotTell::DirectoryUnknown { workspace_id: workspace_id.to_owned(), }), } @@ -1164,7 +1235,7 @@ fn json_row(row: &ListedWorkspace) -> serde_json::Value { checked_out: row .clone .as_ref() - .and_then(|clone| clone.state.branch.clone()), + .and_then(|clone| clone.checked_out.clone()), path: row .clone .as_ref() @@ -1174,10 +1245,7 @@ fn json_row(row: &ListedWorkspace) -> serde_json::Value { .as_ref() .map(|state| state.as_devpod_word().to_owned()), last_used: row.last_used.clone(), - unsaved: row - .clone - .as_ref() - .map(|clone| clone.state.unsaved.as_json()), + unsaved: row.clone.as_ref().map(|clone| clone.holds.unsaved_json()), }; let mut value = serde_json::to_value(&wire).unwrap_or_else(|_| serde_json::json!({})); match &row.disk { @@ -1187,7 +1255,7 @@ 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)), } // Appended, and absent unless there is a note. Both halves are the contract // (#251 §7): `wf` parses this document, so every key it already reads keeps @@ -1201,6 +1269,26 @@ fn json_row(row: &ListedWorkspace) -> serde_json::Value { 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 +} + /// One sweep note as the wire carries it: which trouble, and what git said. /// /// `said` is `null` rather than absent where nothing spoke, because that is a fact @@ -1230,7 +1318,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 @@ -1309,7 +1397,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), } } @@ -2740,7 +2828,10 @@ mod tests { .expect("the dirty row"); let clone = dirty.clone.as_ref().expect("dl's own clone"); - assert!(matches!(clone.state.unsaved, Unsaved::WouldLose(_))); + let Verdict::Stands(standing) = &clone.holds else { + panic!("expected a refusal, got {:?}", clone.holds); + }; + assert!(standing.would_lose().is_some(), "{standing:?}"); assert_eq!(clone.path, scene.dirty); } @@ -3049,10 +3140,10 @@ mod tests { let scene = Scene::build(); let git = Git::new(&scene.runner); - assert_eq!( + assert!(matches!( unsaved_work_in(&git, &scene.view(), "someone-elses"), - Unsaved::NothingToLose - ); + Verdict::Collectable(_) + )); } #[test] @@ -3060,10 +3151,11 @@ mod tests { let scene = Scene::build(); let git = Git::new(&scene.runner); - assert!(matches!( - unsaved_work_in(&git, &scene.view(), scene.id("dirty")), - Unsaved::WouldLose(_) - )); + let Verdict::Stands(standing) = unsaved_work_in(&git, &scene.view(), scene.id("dirty")) + else { + panic!("expected a refusal"); + }; + assert!(standing.would_lose().is_some(), "{standing:?}"); } #[test] @@ -3079,8 +3171,10 @@ mod tests { let git = Git::new(&scene.runner); match unsaved_work_in(&git, &view, scene.id("clean")) { - Unsaved::CouldNotTell(CouldNotTell::DirectoryUnknown { workspace_id }) => { - assert_eq!(workspace_id, scene.id("clean")); + Verdict::Stands(standing) => { + let words = standing.could_not_tell().expect("an unproved, not a loss"); + let id = scene.id("clean"); + assert!(words.contains(id), "{words}"); } other => panic!("expected a refusal, got {other:?}"), } diff --git a/rust/devlaunch-core/src/flows/mod.rs b/rust/devlaunch-core/src/flows/mod.rs index f806d9a4..8e5c2ff1 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; diff --git a/rust/devlaunch-core/tests/api_removal_is_self_sufficient.rs b/rust/devlaunch-core/tests/api_removal_is_self_sufficient.rs index 5a556bad..a289d549 100644 --- a/rust/devlaunch-core/tests/api_removal_is_self_sufficient.rs +++ b/rust/devlaunch-core/tests/api_removal_is_self_sufficient.rs @@ -58,18 +58,23 @@ fn a_guarded_removal_refuses_over_unsaved_work_and_never_asks_devpod() { let outcome = machine.remove(Removal::Guarded); - let RemoveOutcome::Refused(RemovalRefused::WouldLose { + let RemoveOutcome::Refused(RemovalRefused { workspace_id, - losses, + standing, }) = outcome else { panic!("expected a refusal that names what would be lost, got {outcome:?}"); }; assert_eq!(workspace_id, WORKSPACE); + // The refusal carries the whole standing rather than one arm of it, because + // a clone can hold work *and* have a question that could not be put, and a + // refusal naming one would be telling half the truth (devlaunch#446). + let lost = standing + .would_lose() + .expect("a proved loss, not an unproved"); assert!( - losses.describe().contains("an-hour-of-work.md"), - "the refusal has to carry what would be lost, not a count: {}", - losses.describe() + lost.contains("an-hour-of-work.md"), + "the refusal has to carry what would be lost, not a count: {lost}" ); assert!( machine.devpod_argvs().is_empty(), diff --git a/rust/devlaunch-runner/tests/one_seam.rs b/rust/devlaunch-runner/tests/one_seam.rs index be11d547..15a67d8e 100644 --- a/rust/devlaunch-runner/tests/one_seam.rs +++ b/rust/devlaunch-runner/tests/one_seam.rs @@ -41,9 +41,16 @@ use std::fs; use std::path::{Path, PathBuf}; /// Files whose every impl is test code but which do not say so in the file: the -/// module is declared `#[cfg(test)] mod ...` somewhere else. One entry today, and -/// a new one arrives as a failure here rather than as silence. -const TEST_ONLY_FILES: &[&str] = &["devlaunch-core/src/testing.rs"]; +/// module is declared `#[cfg(test)] mod ...` somewhere else. Each one arrived as +/// a failure here rather than as silence, which is the point of the list. +/// +/// `agent_worktrees/tests.rs` holds a wrapper that does real work on purpose: +/// the sweep's answers come out of real `git worktree list` output, and it keeps +/// every argv so a test can assert an invocation was *never* made. +const TEST_ONLY_FILES: &[&str] = &[ + "devlaunch-core/src/testing.rs", + "devlaunch-core/src/flows/agent_worktrees/tests.rs", +]; /// The one implementation that may do real work. const PRODUCTION: &str = "ProcessRunner"; diff --git a/rust/dl/src/cli.rs b/rust/dl/src/cli.rs index c912fa88..7770a0d3 100644 --- a/rust/dl/src/cli.rs +++ b/rust/dl/src/cli.rs @@ -394,8 +394,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]` @@ -579,6 +583,11 @@ 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, hold + /// commits nothing else reaches, or could not be proved safe. 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. @@ -865,6 +874,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 { @@ -885,6 +905,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 }, @@ -919,6 +940,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 @@ -1449,14 +1476,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" }) ); } @@ -2247,7 +2305,8 @@ mod tests { resolved(&["--force", "--prune"]), Some(Ok(Command::Prune { yes: false, - force: true + force: true, + force_worktrees: false })) ); assert_eq!( diff --git a/rust/dl/src/commands.rs b/rust/dl/src/commands.rs index b07763a8..60d17767 100644 --- a/rust/dl/src/commands.rs +++ b/rust/dl/src/commands.rs @@ -21,8 +21,8 @@ use devlaunch_core::flows::kept_copies::KeptCopies; use devlaunch_core::flows::kill; use devlaunch_core::flows::launch::{ColdPath, LaunchNotice}; use devlaunch_core::flows::lifecycle::{ - self, ChildWork, DeleteStalled, Insistence, LifecycleNotice, PruneError, PruneOutcome, Refresh, - RefreshReason, Removal, RemoveOutcome, StopOutcome, + self, ChildWork, DeleteStalled, Insisted, Insistence, LifecycleNotice, PruneError, + PruneOutcome, Refresh, RefreshReason, Removal, RemoveOutcome, StopOutcome, }; use devlaunch_core::flows::listing::{self, CommandContext, DlView, Sizes}; use devlaunch_core::flows::records::{Records, StartupError, open_records, open_storage}; @@ -110,7 +110,17 @@ 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, cache, yes, force), + Command::Prune { + yes, + force, + force_worktrees, + } => render_prune( + runner, + &mut context, + cache, + yes, + insisted(force, force_worktrees), + ), Command::Reconcile { yes } => render_reconcile(runner, &mut context, refresh, yes), Command::Purge { yes } => render_purge(&mut context, cache, yes), // The two arms that name a verb are the two that can be `rme`, and the @@ -1125,9 +1135,29 @@ fn render_prune( context: &mut CommandContext<'_>, cache: &Path, yes: bool, - force: bool, + insisted: Insisted, ) -> Ending { - prune_clone_directories(runner, context, cache, yes, force).with_the_boundary() + prune_clone_directories(runner, context, cache, 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_of(force), + worktrees: insistence_of(force_worktrees), + } +} + +fn insistence_of(insisted: bool) -> Insistence { + if insisted { + Insistence::Insisted + } else { + Insistence::NotInsisted + } } fn prune_clone_directories( @@ -1135,7 +1165,7 @@ fn prune_clone_directories( context: &mut CommandContext<'_>, cache: &Path, yes: bool, - force: bool, + insisted: Insisted, ) -> Cleanup { let mut records = match open_records(runner) { Err(refused) => return Cleanup::Raised(refuse_startup(&refused)), @@ -1156,11 +1186,6 @@ fn prune_clone_directories( )); return Cleanup::Ended(Ending::Refused); } - let insistence = if force { - Insistence::Insisted - } else { - Insistence::NotInsisted - }; let mut notices: Vec = Vec::new(); // devlaunch's copies of what devpod substituted, under the cache the binary // already resolved. This is what makes a run pointed at a scratch @@ -1172,7 +1197,7 @@ fn prune_clone_directories( &workspaces, &copies, &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 2f1fe2b3..c8071a6e 100644 --- a/rust/dl/src/render.rs +++ b/rust/dl/src/render.rs @@ -21,6 +21,9 @@ use devlaunch_core::domain::model::SweepTrouble; 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::{ + Collectable, Standing as WorktreeStanding, 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; @@ -33,12 +36,12 @@ use devlaunch_core::flows::launch::{ SessionRefused, }; use devlaunch_core::flows::lifecycle::{ - Insistence, KeptBecause, LifecycleNotice, NotAdopted, Objection, Promotion, PrunePlan, - PruneReport, PurgeOutcome, PurgePlan, PurgeStep, ReconcilePlan, RemovalRefused, SweepOccasion, - Unlocatable, VolumeRefusal, VolumesKeptBecause, + Insistence, KeptBecause, LifecycleNotice, NotAdopted, Promotion, PrunePlan, PruneReport, + PurgeOutcome, PurgePlan, PurgeStep, ReconcilePlan, RemovalRefused, SweepOccasion, Unlocatable, + VolumeRefusal, VolumesKeptBecause, }; use devlaunch_core::flows::listing::{ - self, LastUsed, SizeCell, Sizes, SourceKind, SweptRepoNote, TableRow, WorkspaceTable, + self, CloneDisk, LastUsed, SizeCell, Sizes, SourceKind, SweptRepoNote, TableRow, WorkspaceTable, }; use devlaunch_core::flows::migration::{Listing, MigrationReport}; use devlaunch_core::flows::provision::{BundleFailed, FailureLevel, ProvisionEvent}; @@ -197,11 +200,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 { @@ -1182,21 +1203,31 @@ pub(crate) fn unsafe_name(refused: &UnsafeName) -> String { /// to the two-step `rme` exists to collapse: delete, wait, then close the tab by /// hand. Both words are offered the way past *they* asked for. pub(crate) fn removal_refusal(refused: &RemovalRefused, spec: &str, word: &str) -> String { - match refused { - RemovalRefused::WouldLose { - workspace_id, - losses, - } => format!( - "{workspace_id} holds {}. Push or commit it, or run: dl {spec} {word} --force", - losses.describe() + let workspace_id = &refused.workspace_id; + match ( + refused.standing.would_lose(), + refused.standing.could_not_tell(), + ) { + (Some(holds), None) => format!( + "{workspace_id} holds {holds}. Push or commit it, or run: dl {spec} {word} --force" ), - RemovalRefused::CouldNotTell { - workspace_id, - cause, - } => format!( - "{workspace_id}: {}. devlaunch will not delete a clone it cannot check. Look at it, \ - or run: dl {spec} {word} --force", - cause.describe() + (None, Some(blank)) => format!( + "{workspace_id}: {blank}. devlaunch will not delete a clone it cannot check. Look \ + at it, or run: dl {spec} {word} --force" + ), + // Both at once -- a dirty tree beside a refused probe, or a nested + // worktree's loss beside a lock. Saying one would be telling half the + // truth, so both are said (devlaunch#446). + (Some(holds), Some(blank)) => format!( + "{workspace_id} holds {holds}, and {blank}. devlaunch will not delete a clone it \ + cannot check. Push or commit it, look at it, or run: dl {spec} {word} --force" + ), + // A standing is non-empty by construction, so one of the arms above + // said something; this arm exists for the match to be total and points + // the reader at the raw reasons rather than inventing a sentence. + (None, None) => format!( + "{workspace_id}: {}. Run: dl {spec} {word} --force", + refused.standing.describe() ), } } @@ -1224,21 +1255,25 @@ pub(crate) fn rm_on_exit_removing(spec: &str) -> String { /// No way past is offered, because there is nothing to offer: this is the last /// line before the delete, and the workspace is gone by the next one. pub(crate) fn removing_over_work(refused: &RemovalRefused) -> String { - match refused { - RemovalRefused::WouldLose { - workspace_id, - losses, - } => format!( - "{workspace_id} holds {}, and kill is deleting it anyway.", - losses.describe() + let workspace_id = &refused.workspace_id; + match ( + refused.standing.would_lose(), + refused.standing.could_not_tell(), + ) { + (Some(holds), None) => { + format!("{workspace_id} holds {holds}, and kill is deleting it anyway.") + } + (None, Some(blank)) => format!( + "{workspace_id}: {blank}, so there may be work here that is nowhere else. kill is \ + deleting it anyway." ), - RemovalRefused::CouldNotTell { - workspace_id, - cause, - } => format!( - "{workspace_id}: {}, so there may be work here that is nowhere else. kill is deleting \ - it anyway.", - cause.describe() + (Some(holds), Some(blank)) => format!( + "{workspace_id} holds {holds}, and {blank}, so there may be more here that is \ + nowhere else. kill is deleting it anyway." + ), + (None, None) => format!( + "{workspace_id}: {}. kill is deleting it anyway.", + refused.standing.describe() ), } } @@ -1974,7 +2009,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!( @@ -1986,7 +2021,7 @@ pub(crate) fn prune_plan_lines(plan: &PrunePlan) -> Vec { // for. Without it the plan reads the same for a clone holding an // afternoon's uncommitted work as for an empty one. if let Promotion::Insisted { despite } = &reclaimable.promotion { - line = format!("{line} -- holds {}, removing anyway", objection(despite)); + line = format!("{line} -- {}; removing anyway", standing_words(despite)); } lines.push(line); } @@ -2032,22 +2067,163 @@ 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 +/// site 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. +/// +/// One line per standing site, attributed to the site rather than to each +/// ancestor it pins: a parent reported as "kept" with the child unnamed is the +/// invisible straggler, and a three-deep chain pinned by one site is one line. +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 going in found.going() { + let mut line = match going.what() { + Collectable::Directory(directory) => format!( + " - removing {} ({}), and dropping its {} registration(s)", + directory.at().display(), + describe_usage(directory.usage()), + directory.forgets().len() + ), + Collectable::Registration(registration) => format!( + " - forgetting the registration for {}: nothing is at it in this \ + clone, and its commits are all somewhere else", + registration.place().as_str() + ), + }; + // What `--force-worktrees` is answering, on the line of the unit 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 } = going.promotion() { + line = format!("{line} -- {}; removing anyway", standing_words(despite)); + } + lines.push(line); + } + for standing in found.standing() { + lines.push(format!( + " - leaving {}: {} -- add --force-worktrees to remove it anyway", + standing.at().display(), + standing.reasons().describe() + )); + } + } + 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 +} + +/// Everything a standing says, as the clause a keep or an insistence line +/// interpolates. Both kinds of reason, never one standing in for the other. +fn standing_words(standing: &WorktreeStanding) -> String { + let mut parts = Vec::new(); + if let Some(holds) = standing.would_lose() { + parts.push(format!("holds {holds}")); + } + if let Some(blank) = standing.could_not_tell() { + parts.push(format!("could not be proved safe: {blank}")); + } + if parts.is_empty() { + // A standing is non-empty by construction; this is the match staying + // total rather than a case anything reaches. + return standing.describe(); + } + parts.join(", and ") +} + +/// 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 write into 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()) + )]; + if report.forgotten > 0 { + lines.push(format!( + "Dropped {} worktree registration(s), each by the path git printed for it.", + report.forgotten + )); + } + for withheld in &report.withheld { + lines.push(format!( + "Left {}: {} -- add --force-worktrees to remove it anyway. That was not so when \ + the plan above was printed.", + withheld.path.display(), + withheld.because.describe() + )); + } + for refused in &report.forget_refused { + lines.push(format!( + "git would not drop the registration for {}: {}. The next --prune will offer it \ + again.", + refused.registered.display(), + refused.reason + )); + } + 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 +} + /// Why one clone directory is staying, as the report says it. fn kept_because(because: &KeptBecause) -> String { match because { KeptBecause::StillOpened { workspace_id } => { format!("workspace {workspace_id} still opens it") } - KeptBecause::Objected(objected) => { + KeptBecause::Objected(standing) => { format!( - "holds {} -- add --force to remove it anyway", - objection(objected) + "{} -- add --force to remove it anyway", + standing_words(standing) ) } KeptBecause::RecordsDisagree { @@ -2060,16 +2236,6 @@ fn kept_because(because: &KeptBecause) -> String { } } -/// What removing a clone would destroy or risk, as the clause after "holds". -fn objection(objected: &Objection) -> String { - match objected { - Objection::WouldLose(losses) => losses.describe(), - Objection::CouldNotTell(cause) => { - format!("work git could not be asked about ({})", cause.describe()) - } - } -} - /// Which live workspaces could not be placed, and that nothing went. /// /// Not a warning above a report: a workspace whose source cannot be followed could @@ -2103,7 +2269,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!( @@ -2145,6 +2311,7 @@ pub(crate) fn prune_report_lines(report: &PruneReport) -> Vec { &by_hand, )); } + lines.extend(worktree_report_lines(&report.worktrees)); lines } @@ -2971,7 +3138,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::kill::{HostProcess, Signalled}; use devlaunch_core::flows::launch::TerminalTitle; use devlaunch_core::flows::listing::{SourceDescription, SourceKind}; @@ -3367,7 +3533,7 @@ mod tests { "a", SourceKind::Local, "/x", - SizeCell::Measured(DiskUsage::measured(2048)), + SizeCell::Measured(CloneDisk::measured(2048, None)), LastUsed::Never, ), row( diff --git a/rust/dl/tests/completion_tables.rs b/rust/dl/tests/completion_tables.rs index 318172f4..df917d2f 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 alone, which has already been typed by then", + ), ]; #[test] diff --git a/rust/dl/tests/lifecycle.rs b/rust/dl/tests/lifecycle.rs index e679ed92..8276773d 100644 --- a/rust/dl/tests/lifecycle.rs +++ b/rust/dl/tests/lifecycle.rs @@ -2252,10 +2252,14 @@ fn force_removes_the_clone_holding_work_and_says_what_it_was() { let run = world.dl(&["--prune", "-y", "--force"]); run.exited(0); let out = without_sizes(&run.out); + // A semicolon rather than a comma before "removing anyway", because what + // --force is answering can now be two clauses joined by one: a clone that + // holds work *and* has a question that could not be put says both + // (devlaunch#446), and a comma there would read as a third item. assert!( out.contains( " - {ROOT}/cache/devlaunch/repos/blooop/devlaunch/devlaunch-gone-dirty () -- \ - holds 1 uncommitted change(s) (scratch.txt), removing anyway\n" + holds 1 uncommitted change(s) (scratch.txt); removing anyway\n" ), "what --force is answering belongs on the line of the directory it answers \ for: {out}" From 2edf1c55a94d1ec3b07fe536a35a41dac82832b4 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Sat, 29 Aug 2026 21:41:57 +0100 Subject: [PATCH 2/7] Weigh ignored bytes at neither scope, and match the two passes by radius MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two blockers from the fresh-context review, and the first one was mine to undo. `worktree_dirt` passed `--ignored`, which `status_porcelain` does not, so one conjunction had two definitions of dirty. The site-level half then stood every worktree carrying an installed `.pixi/envs/default` — 18 of the 72 on the reference host, the difference between 104 GB and about 10 — behind `--force-worktrees`, which is also the flag that carries past a lock and past another repository's worktree. That trades the whole of this sweep's yield for a habit of typing the flag that switches its protections off, and it made `dl rm` refuse on the ordinary open/work/rm loop. The flag goes; the limit is stated with its reason in three places instead. The second is a real plan/act divergence. `reclaim` matched the two passes' units by identity, which is a root, where the blast radius is a subtree: a site created inside an approved parent after the plan was printed was weighed, found collectable, absorbed into the parent's unit, and handed to `git worktree remove` — one plan naming one registration acting on two. A confirmed unit is now acted on only when every registration it names was named by the plan, and the report carries the plan's byte figure rather than the acting pass's re-measurement, which is what the clone arm beside it has always done. Also: the reachability answer is memoised per revision for one clone's weighing, and `nothing_at_their_place` asks the walk's question so a dangling symlink cannot produce two sites for one place. --- CHANGELOG.md | 11 +- docs/cleanup.md | 25 +- rust/devlaunch-core/src/clients/git.rs | 24 +- .../src/flows/agent_worktrees.rs | 216 ++++++++++++++++-- .../src/flows/agent_worktrees/tests.rs | 181 ++++++++++++++- 5 files changed, 413 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88dae660..0059b021 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -113,7 +113,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 rather than gated: its domain is a directory read at act time, so a registration created after the plan was printed was inside its blast radius and no plan could name it. Every name the sweep forgets came out of a listing it - read, and the forget only ever follows a directory removal that completed. + read, the acting pass acts only on units whose registrations the plan named, + and the forget only ever follows a directory removal that completed. + + Gitignored content is deliberately **not** weighed, at either scope. An + installed `.pixi/envs/default` is ignored content and is what makes these + directories worth reclaiming at all, so weighing it would have put the whole of + the reclaim behind `--force-worktrees` -- the flag that also carries past a + lock and past another repository's worktree. A clone's own ignored bytes have + never counted either, and one conjunction wants one definition of dirty. + `docs/cleanup.md` carries the limit with its reason. A worktree of a *different* repository nested inside one of ours now stands, is reported and is never probed: ownership is a join against this clone's own diff --git a/docs/cleanup.md b/docs/cleanup.md index 649be0b5..4e9fb112 100644 --- a/docs/cleanup.md +++ b/docs/cleanup.md @@ -448,9 +448,8 @@ Four questions, and their scopes are not the same. What is at the site at all is per site. **Whether the working tree holds anything that exists nowhere else is per working tree**, which is why the clone's own `git status` cannot answer for what is nested in it: `.claude/worktrees/` is ordinarily gitignored, and a nested -worktree has an index of its own. Ignored files count, because -`git worktree remove` deletes a worktree whose only content is gitignored, exit 0 -and silent. **Whether the commits exist somewhere else is per repository**, and +worktree has an index of its own. **Whether the commits exist somewhere else is +per repository**, and it is asked of the sibling `.bare` cache first: a workspace clone is cut from the bare and then 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 @@ -459,6 +458,23 @@ the lock, and a lock is an *unproved*, never a loss: git documents it as saying nothing about whether anybody is working in there, so reporting it as work would be inventing work that may not exist. +**One limit, stated because it is a limit and not an oversight: gitignored +content is not weighed.** `git worktree remove` deletes a worktree whose only +content is gitignored, exit 0 and silent, and so does the removal here. It is +left that way because a clone's own ignored bytes have never been weighed either +- `dl rm` and the orphan rule above both `rm -rf` past them - and one +conjunction wants one definition of what makes a tree dirty rather than two that +disagree about the same bytes. + +Weighing it at the site alone was tried and taken back out, and the cost is worth +recording. An installed `.pixi/envs/default` is ignored content; it is 18 of the +72 directories on the reference host and the difference between 104 GB and about +10. Weighing it put every one of them behind `--force-worktrees`, which is also +the flag that carries past a lock and past another repository's worktree, so +getting the disk back would have meant typing the flag that switches off every +protection described here. Whether ignored bytes should be weighed is a real +question and it is one question for both scopes, not a special case for this one. + Stashes need no probe. A `git stash push` from inside a linked worktree writes the clone's own `refs/stash`, survives the directory, and is reached by `rev-list --all`; nothing here removes the shared ref store. @@ -535,7 +551,8 @@ rather than about 10, and they cannot be pointed at the shared package cache: on 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. +bytes come back, which is what this does: an env is gitignored content, and by +the limit above it does not by itself keep a finished worktree standing. #### The disk neither command frees diff --git a/rust/devlaunch-core/src/clients/git.rs b/rust/devlaunch-core/src/clients/git.rs index 5fa7c9f2..53f33e8d 100644 --- a/rust/devlaunch-core/src/clients/git.rs +++ b/rust/devlaunch-core/src/clients/git.rs @@ -689,7 +689,7 @@ impl<'r> Git<'r> { } /// The porcelain status of one linked worktree, asked through the clone's - /// admin directory for it, **ignored files included**. + /// 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 @@ -700,10 +700,23 @@ impl<'r> Git<'r> { /// repository reached from the side that does resolve here, and /// `--work-tree` is the directory on this host. /// - /// `--ignored` is load-bearing, not thoroughness: `git worktree remove` - /// deletes a worktree whose only content is gitignored, exit 0 and silent, - /// so if this answer omitted ignored files the caller's predicate — the only - /// protection those bytes have — would be blind to them (devlaunch#462). + /// **`--ignored` is deliberately not passed, and that is a decision rather + /// than an omission.** This asks the same question [`Git::status_porcelain`] + /// asks of a whole clone, in the same words, so there is one definition of + /// what makes a tree dirty rather than two that disagree about the same + /// bytes. A clone's own ignored content has never counted — `dl rm` and + /// `--prune`'s orphan arm both `rm -rf` past it — and counting it one level + /// in but not at the root is the same rule written twice. + /// + /// It was passed once, and the cost is worth recording so nobody re-adds it + /// as thoroughness: an installed `.pixi/envs/default` is ignored content, it + /// is what makes an agent worktree worth reclaiming at all, and weighing it + /// put every such directory behind `--force-worktrees` — the one flag that + /// also carries past a lock and past another repository's worktree. That + /// trades the whole of this sweep's yield for a habit of typing the flag + /// that switches its protections off. Whether ignored bytes should be + /// weighed is a real question, and it is **one question for both scopes**; + /// `docs/cleanup.md` records it as a limit with its reason. /// /// Every line git prints comes back, nested agent worktrees included. Which /// entries to disregard is a question about what a directory *is*, which is @@ -714,7 +727,6 @@ impl<'r> Git<'r> { format!("--work-tree={}", work_tree.display()), "status".to_owned(), "--porcelain".to_owned(), - "--ignored".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 f007ef81..0f28908b 100644 --- a/rust/devlaunch-core/src/flows/agent_worktrees.rs +++ b/rust/devlaunch-core/src/flows/agent_worktrees.rs @@ -132,6 +132,8 @@ //! `rev-list --all`. It is in the shared ref store, which nothing here removes. //! No probe asks about it, and none should be added. +use std::cell::RefCell; +use std::collections::HashMap; use std::path::{Path, PathBuf}; use crate::clients::git::{ForgetForce, Git}; @@ -478,10 +480,17 @@ impl ClonePicture { /// Registrations under a `.claude/worktrees/` with nothing at their place in /// this clone. The place is a join of the clone root and the suffix, built /// for one existence check; the recorded path itself is never resolved. + /// + /// **`symlink_metadata` rather than `exists`, to agree with the walk.** + /// `Path::exists` follows links, so a *dangling* symlink at a registered + /// place reads as absent here and as a present entry to [`walk_sites`] — + /// and the two answers together produce two sites for one `Inside`, one of + /// them offering a forget for a registration whose place is occupied. The + /// walk is the authority on what is there, so this asks the walk's question. fn nothing_at_their_place(&self, clone: &Path) -> Vec<&Registration> { self.registered .iter() - .filter(|it| !clone.join(&it.inside.place).exists()) + .filter(|it| std::fs::symlink_metadata(clone.join(&it.inside.place)).is_err()) .collect() } } @@ -850,6 +859,21 @@ pub enum Reason { } impl Reason { + /// The same reason, said about `at` instead. + /// + /// Only for a memoised reachability answer, which is a fact about a commit + /// rather than about a place: the site it was first asked from is not the + /// site a later reader is deciding about, and a report that named the first + /// one would send somebody to the wrong directory. + fn about(mut self, at: &Place) -> Self { + match &mut self { + Self::Holds { at: was, .. } | Self::CouldNotProve { at: was, .. } => { + *was = at.clone(); + } + } + self + } + pub fn at(&self) -> &Place { match self { Self::Holds { at, .. } | Self::CouldNotProve { at, .. } => at, @@ -868,6 +892,10 @@ impl Reason { Self::Holds { .. } => Subject::GitsAccountOfContent, Self::CouldNotProve { blank, .. } => match blank { Blank::ThirdPartyClaim(_) => Subject::AClaim, + // Somebody may be working in it right now; that is the whole + // reason it was not in the plan. A claimant, so #468's + // derivative reclaim does not reach into it either. + Blank::AppearedAfterThePlan => Subject::AClaim, Blank::NothingToAskThrough | Blank::GitWouldNotSay(_) // Decided on devlaunch#468: another repository's env, tagged, is @@ -977,6 +1005,11 @@ impl Blank { Self::ThirdPartyClaim(Some(reason)) => { format!("git is holding it locked ({reason})") } + Self::AppearedAfterThePlan => { + "it appeared inside this directory after the plan was printed, so nobody has \ + said yes to removing it" + .to_owned() + } Self::NotThisClonesToAccountFor(why) => why.describe().to_owned(), } } @@ -1051,6 +1084,11 @@ pub enum Blank { ThirdPartyClaim(Option), /// This site is not this clone's to account for; see [`Unaccountable`]. NotThisClonesToAccountFor(Unaccountable), + /// A site inside an approved subtree that the plan did not name, found by + /// the acting pass. Nothing establishes that the person who said yes to the + /// plan meant this one, so the whole unit is withheld and offered again next + /// run — by which time it is in the plan they read. + AppearedAfterThePlan, } // =========================================================================== @@ -1104,6 +1142,21 @@ struct Weigher<'a, 'r> { git: &'a Git<'r>, clone: &'a Path, bare: Option<&'a Path>, + /// What Q3 already answered, keyed on the revision it was asked about. + /// + /// The question is about a commit and a repository, and both are fixed for + /// the whole of one clone's weighing, so two sites cut from the same commit + /// asked it twice and got the same answer twice. On the reference host's + /// shape that is most of the git this pass runs. Keyed on the revision + /// rather than on the site, because the revision is the whole of what the + /// answer depends on — a cache keyed on anything wider would be a second + /// definition of what the question is. + /// + /// Held for one `weigh_clone` call and dropped with it, so nothing here + /// survives into a later pass with a staler answer than the pass that took + /// it. `Result` rather than the witness alone: a refusal is an answer and + /// re-asking it would not make it a different one. + reachability: RefCell>>, } impl Weigher<'_, '_> { @@ -1115,6 +1168,21 @@ impl Weigher<'_, '_> { /// has to be attributed to the directory holding it — the clone-scope /// `--all` question is asked once of the clone itself, elsewhere. fn elsewhere(&self, at: &Place, head: &WorktreeHead) -> Result { + if let Some(answered) = self.reachability.borrow().get(head.revision()) { + // The reason names the site it came from, and this one came from + // another: re-attribute it so a report still names the directory it + // is about. The answer itself is the same answer. + return answered.clone().map_err(|reason| reason.about(at)); + } + let answer = self.ask_elsewhere(at, head); + self.reachability + .borrow_mut() + .insert(head.revision().to_owned(), answer.clone()); + answer + } + + /// [`Weigher::elsewhere`] with the memo taken out of the way. + fn ask_elsewhere(&self, at: &Place, head: &WorktreeHead) -> Result { match in_the_cache(self.git, self.bare, head.commit()) { InTheCache::Reached => Ok(Elsewhere(())), InTheCache::Beyond | InTheCache::CouldNotSay => { @@ -1156,11 +1224,23 @@ impl Weigher<'_, '_> { /// Q2 for one present worktree, asked through the admin directory derived /// from the join. /// - /// **The answer includes ignored files.** Measured: `git worktree remove` - /// deletes a worktree whose only content is gitignored, exit 0 and silent — - /// git's own dirty check does not count ignored files — so this predicate is - /// the whole of the protection, and an ignored file is as gone as a tracked - /// one (devlaunch#462). + /// **Ignored content is not weighed, and it is not weighed at clone scope + /// either — that is the point.** One conjunction wants one definition of + /// what makes a tree dirty, and a clone's own ignored bytes have never + /// counted, so counting them one level in would be the same rule written + /// twice and disagreeing about the same bytes. It is a real limit rather + /// than an oversight: `git worktree remove` deletes a worktree whose only + /// content is gitignored, exit 0 and silent, and so does the removal here. + /// + /// The limit is stated rather than closed because closing it at this scope + /// alone costs more than it buys. An installed `.pixi/envs/default` is + /// ignored content and is the whole reason an agent worktree is worth + /// reclaiming — 18 of the 72 on the reference host, and the difference + /// between 104 GB and about 10 — so weighing it put every one of those + /// directories behind `--force-worktrees`, which also carries past a lock + /// and past another repository's worktree. Whether ignored bytes should be + /// weighed is one question for both scopes, and [`Git::worktree_dirt`] and + /// `docs/cleanup.md` carry it with its reason. /// /// An untracked or ignored entry is dropped from the answer only when /// everything under it is a site the child forest accounts for — each such @@ -1319,15 +1399,13 @@ impl Weigher<'_, '_> { /// Whether one `git status` line is nothing but sites the forest already /// accounts for. /// -/// Only ever true of an **untracked or ignored** entry on the -/// `.claude/worktrees/` spine. A tracked change under the same path is -/// somebody's edit to a file the repository knows about and is never dropped, -/// and an entry anywhere else is not this module's business. +/// Only ever true of an **untracked** entry on the `.claude/worktrees/` spine. +/// A tracked change under the same path is somebody's edit to a file the +/// repository knows about and is never dropped, and an entry anywhere else is +/// not this module's business. There is no ignored arm because the probe does +/// not ask for ignored entries; see [`Weigher::clean`]. fn accounted_for_by_the_forest(root: &Path, line: &str, forest: &[PathBuf]) -> bool { - let Some(entry) = line - .strip_prefix("?? ") - .or_else(|| line.strip_prefix("!! ")) - else { + let Some(entry) = line.strip_prefix("?? ") else { return false; }; // git quotes a path holding anything unusual. Quoted means unparsed here, @@ -1470,6 +1548,17 @@ impl Going { &self.promotion } + /// Every registration this one unit accounts for, whichever arm it is. + /// + /// The unit's blast radius over the metadata, in one place, so the acting + /// pass compares the two passes' radii rather than their roots. + pub fn forgets(&self) -> &[Recorded] { + match &self.what { + Collectable::Directory(directory) => directory.forgets(), + Collectable::Registration(registration) => registration.forgets(), + } + } + fn identity(&self) -> GoingIdentity<'_> { match &self.what { Collectable::Directory(directory) => GoingIdentity::At(&directory.at), @@ -1764,7 +1853,12 @@ fn weigh_clone( picture: &ClonePicture, insist: impl Fn(&Site) -> Insistence, ) -> (Vec, Vec) { - let weigher = Weigher { git, clone, bare }; + let weigher = Weigher { + git, + clone, + bare, + reachability: RefCell::new(HashMap::new()), + }; let roots = forest_of(clone, picture); let mut forest_paths = Vec::new(); for root in &roots { @@ -1863,12 +1957,24 @@ impl WorktreeReport { /// The caller holds the repository lock. **Every site is classified again, /// under that lock, immediately before anything goes**, by the same weighing /// the plan ran — one implementation, so the plan and the outcome cannot answer -/// different questions. Only a unit this pass *also* finds collectable, *and* -/// the plan approved, is acted on: the approved set can shrink between the -/// report and the act and can never grow, now over a tree rather than a list. A -/// site that appeared after the plan has no approval, so it cannot be -/// collectable here — and it is still classified, so it still objects, so it -/// still makes every parent stand. +/// different questions. +/// +/// **Matching the two passes by identity is not enough, and that is worth +/// spelling out because it was wrong here once.** A unit's identity is its root; +/// its blast radius is a subtree. A site created inside an approved parent after +/// the plan was printed is weighed by this pass on its own merits, and if it is +/// collectable it is *absorbed into the parent's unit* — so the unit count is +/// unchanged, the identity matches, and something the plan never named goes out +/// with the removal and is handed to `git worktree remove`. Measured on this +/// tree: a plan naming one registration acted on two. +/// +/// So a confirmed unit is acted on only when **every registration it now names +/// was named by the plan**. That is the subset check in [`grew_past`], and it is +/// the radius rather than the root: a fresh nested site of ours contributes its +/// registration and fails it, a fresh nested site that is *not* ours contributes +/// no registration but stands, which withholds the parent anyway. The approved +/// set can therefore shrink between the report and the act and can never grow, +/// over the bytes as well as over the list. /// /// **The directory goes first and the forgets follow, and nothing is forgotten /// on a partial removal.** That ordering is P2 (devlaunch#462): the recorded @@ -1932,10 +2038,38 @@ pub(crate) fn reclaim( report.withheld.push(WithheldWorktree { path, because }); continue; }; - act_on(git, &plan.clone, confirmed, report); + if let Some(grew) = grew_past(planned, confirmed) { + // The subtree gained something the plan did not name. Withhold the + // whole unit rather than removing a smaller part of it: the unit is + // the radius, and there is no smaller radius to fall back to. + report.withheld.push(WithheldWorktree { + path: going_path(&plan.clone, planned), + because: Standing::one(Reason::CouldNotProve { + at: Place::ASite(grew), + blank: Blank::AppearedAfterThePlan, + }), + }); + continue; + } + act_on(git, &plan.clone, planned, confirmed, report); } } +/// The first registration `confirmed` names that `planned` did not, or nothing +/// when the confirmed unit's radius is inside the approved one. +/// +/// Compared as recorded paths, which is what the forget is invoked with, so this +/// is the same value on both sides of the comparison rather than two derivations +/// of it that could disagree. +fn grew_past(planned: &Going, confirmed: &Going) -> Option { + let approved = planned.forgets(); + confirmed + .forgets() + .iter() + .find(|fresh| !approved.iter().any(|named| named == *fresh)) + .and_then(|fresh| inside_a_worktrees_dir(fresh.as_path())) +} + /// Whether one planned unit is the approval for `root`. fn approves(going: &Going, root: &Site, clone: &Path) -> bool { match &going.what { @@ -1952,7 +2086,20 @@ fn going_path(clone: &Path, going: &Going) -> PathBuf { } /// One unit, carried out: bytes first, then every name that rode in on it. -fn act_on(git: &Git<'_>, clone: &Path, confirmed: &Going, report: &mut WorktreeReport) { +/// +/// Takes both passes' views of the unit deliberately. What is *done* is the +/// confirmed one, because this pass is the one holding the lock; what is +/// *reported* is the plan's figure, because that is the number somebody said yes +/// to. Reporting the re-measurement made the two halves of one report mean +/// different things — the clone arm beside this one has always reported the +/// plan's. +fn act_on( + git: &Git<'_>, + clone: &Path, + planned: &Going, + confirmed: &Going, + report: &mut WorktreeReport, +) { let insistence = confirmed.promotion.insistence(); match &confirmed.what { Collectable::Directory(directory) => { @@ -1960,7 +2107,13 @@ fn act_on(git: &Git<'_>, clone: &Path, confirmed: &Going, report: &mut WorktreeR TreeSweep::Everything => { report.removed.push(RemovedWorktree { path: directory.at.clone(), - usage: directory.usage.clone(), + usage: match planned.what() { + Collectable::Directory(approved) => approved.usage().clone(), + // Unreachable: the subset check above passed, so the + // two units are the same shape. Falling back to what + // was measured is honest either way. + Collectable::Registration(_) => directory.usage.clone(), + }, }); forget(git, clone, &directory.forgets, insistence, report); } @@ -2093,6 +2246,21 @@ fn lift(own: Unsaved) -> Vec { /// The standing reasons of every site in `clone`, or nothing where there are no /// sites — which is nearly every clone, at the cost of one failed `read_dir`. /// +/// **That early return is the whole of what keeps `dl --ls` affordable**, and it +/// is worth naming because this function is on the listing path, which is a +/// read-only command people run casually. A clone that has never had an agent +/// worktree in it costs one `read_dir` that fails and no git at all: no +/// `worktree list`, no probe, no walk. A clone that *has* them costs one +/// `worktree list` plus, per site, one `status --porcelain` and at most one +/// `rev-list` — the reachability answer is memoised per revision for the whole +/// of one clone's weighing, and sites cut from one commit are the common shape. +/// +/// What is not done here, said plainly rather than left to be discovered: the +/// rows are still weighed one after another, and a host carrying dozens of agent +/// worktrees pays that serially on every `--ls --json`. `docs/performance.md` +/// covers the launch path and not this one, so nothing would catch a regression +/// in it. Measuring the listing and giving it a floor is its own piece of work. +/// /// The sibling bare is derived from the clone's place in the cache /// (`///.bare` beside `///`), /// which is the same sibling `--prune`'s sweep is handed by the repo manager. diff --git a/rust/devlaunch-core/src/flows/agent_worktrees/tests.rs b/rust/devlaunch-core/src/flows/agent_worktrees/tests.rs index c80425cc..a218d67f 100644 --- a/rust/devlaunch-core/src/flows/agent_worktrees/tests.rs +++ b/rust/devlaunch-core/src/flows/agent_worktrees/tests.rs @@ -379,12 +379,20 @@ fn an_uncommitted_edit_stands_a_worktree_that_is_otherwise_collectable() { } #[test] -fn a_site_whose_only_content_is_gitignored_stands() { - // Measured: `git worktree remove` deletes a worktree whose only content is - // gitignored, exit 0 and silent — git's own dirty check does not count - // ignored files — so this predicate is the whole of the protection - // (devlaunch#462). The .gitignore itself is committed and pushed, so the - // ignored file really is the only thing that exists nowhere else. +fn a_site_whose_only_content_is_gitignored_is_collectable_as_a_clone_would_be() { + // **The limit, pinned as behaviour rather than left to prose.** Ignored + // content is not weighed here, because it is not weighed of a whole clone + // either — `dl rm` and `--prune`'s orphan arm both `rm -rf` past a + // clone's own ignored bytes — and one conjunction wants one definition of + // dirty. Weighing it at this scope alone was tried and reverted: an + // installed `.pixi/envs/default` is exactly this shape and is the whole + // reason these directories are worth reclaiming, so it put the sweep's + // entire yield behind `--force-worktrees`, the flag that also carries past + // a lock and past another repository's worktree. + // + // The .gitignore itself is committed and pushed, so the ignored file really + // is the only thing here that exists nowhere else. That is the honest + // statement of what goes. let world = Clone::new(); let worktree = world.worktree("agent-one"); std::fs::write(worktree.join(".gitignore"), "scratch.db\n").expect("an ignore file"); @@ -396,9 +404,47 @@ fn a_site_whose_only_content_is_gitignored_stands() { let plan = world.plan(); - assert!(going_dirs(&plan).is_empty(), "{:?}", plan.going()); - let reasons = reasons_at(&plan, &worktree); - assert!(matches!(reasons[0], Reason::Holds { .. }), "{reasons:?}"); + assert_eq!(going_dirs(&plan), std::slice::from_ref(&worktree)); + assert!(plan.standing().is_empty(), "{:?}", plan.standing()); +} + +#[test] +fn an_installed_environment_does_not_need_the_flag_that_disables_the_guards() { + // The shape the whole ticket exists for: 18 of the 72 directories on the + // reference host carried a whole `.pixi/envs/default`, and they are the + // difference between 104 GB and about 10. `.pixi/` is gitignored the way + // every pixi project gitignores it. + // + // Asserted as the absence of a flag rather than as a byte count: the + // failure this pins is not "the env stays", it is "reclaiming the env costs + // you the lock guard, the foreign-repository guard and the unpushed guard, + // all at once, because one flag carries past all of them". + let world = Clone::new(); + let worktree = world.worktree("agent-one"); + std::fs::write(worktree.join(".gitignore"), ".pixi/\n").expect("an ignore file"); + commit(&worktree, "ignore the env"); + run_git(&world.clone, &["push", "origin", "agent-one"]); + world.fetch(); + let env = worktree + .join(".pixi") + .join("envs") + .join("default") + .join("lib"); + std::fs::create_dir_all(&env).expect("an installed environment"); + std::fs::write(env.join("libthing.so"), "a great many bytes\n").expect("env bytes"); + world.containerise(); + + let plan = world.plan(); + + assert_eq!( + going_dirs(&plan), + std::slice::from_ref(&worktree), + "an env must not need --force-worktrees: {:?}", + plan.standing() + ); + let WorktreePromotion::Unopposed = plan.going()[0].promotion() else { + panic!("nothing objected, so nothing was insisted past"); + }; } #[test] @@ -661,6 +707,94 @@ fn a_registration_created_after_the_plan_is_never_named() { ); } +#[test] +fn a_clean_site_nested_into_an_approved_one_after_the_plan_is_not_absorbed() { + // The half the sibling case above does not reach, and the one that was + // wrong: matching the two passes by *identity* let a site created inside an + // approved parent ride out on that parent's subtree removal. The unit count + // was unchanged, the root matched, and a registration the plan never named + // was handed to `git worktree remove`. + // + // Clean and nested are the two properties that make it slip through: dirty + // would stand it, and a sibling would be a unit of its own with no approval. + // So the unit is compared by its blast radius, not its root. + let world = Clone::new(); + let outer = world.worktree("agent-outer"); + world.containerise(); + let plan = world.plan(); + assert_eq!(going_dirs(&plan), std::slice::from_ref(&outer)); + assert_eq!( + plan.going()[0].forgets().len(), + 1, + "the plan names one registration" + ); + + // The container makes a worktree *inside* the approved one while the + // question is on screen. Nothing is written into it: it is finished work. + let fresh = world.nested(&outer, "agent-fresh"); + + let (report, calls) = world.act(&plan); + + assert!(report.removed.is_empty(), "{report:?}"); + assert_eq!(report.withheld.len(), 1, "{report:?}"); + assert_eq!(report.forgotten, 0, "{report:?}"); + assert!(outer.exists() && fresh.exists()); + // The absence: no *forget* names the registration the plan did not. The + // fresh site is deliberately probed — it is classified like any other, which + // is what makes it stand its parent — so the assertion is over the removal + // invocations rather than over every call. + let fresh_spelled = fresh.display().to_string(); + assert!( + !calls + .iter() + .filter(|argv| argv.iter().any(|arg| arg == "remove")) + .any(|argv| argv.iter().any(|arg| arg.contains(&fresh_spelled))), + "{calls:?}" + ); + // And the next run offers the whole subtree, which is now in the plan + // somebody reads. + let again = world.plan(); + assert_eq!(going_dirs(&again), std::slice::from_ref(&outer)); + assert_eq!(again.going()[0].forgets().len(), 2); +} + +#[test] +fn what_the_report_says_was_freed_is_what_the_plan_measured() { + // The two halves of one report have to mean the same thing. The acting pass + // re-measures, so a subtree that grew between the two would be reported at + // its new size against a plan somebody read at the old one. The clone arm + // beside this one has always reported the plan's figure. + let world = Clone::new(); + let worktree = world.worktree("agent-one"); + // Ignored, so these bytes are in the figure without making the tree dirty — + // which is exactly the shape a build output has. + std::fs::write(worktree.join(".gitignore"), "big.bin\n").expect("an ignore file"); + commit(&worktree, "ignore the build output"); + run_git(&world.clone, &["push", "origin", "agent-one"]); + world.fetch(); + std::fs::write(worktree.join("big.bin"), vec![7u8; 200_000]).expect("build output"); + world.containerise(); + let plan = world.plan(); + let Collectable::Directory(approved) = plan.going()[0].what() else { + panic!("expected a directory unit"); + }; + let planned_bytes = approved.usage().known_bytes(); + assert!(planned_bytes > 100_000, "the plan measured the bytes"); + + // A container tidies up after itself while the question is on screen, so + // the acting pass would measure a much smaller directory. + std::fs::remove_file(worktree.join("big.bin")).expect("tidied away"); + + let (report, _) = world.act(&plan); + + assert_eq!(report.removed.len(), 1, "{report:?}"); + assert_eq!( + report.freed().known_bytes(), + planned_bytes, + "the figure reported is the one that was said yes to" + ); +} + #[test] fn a_worktree_that_went_dirty_while_the_question_was_open_is_withheld() { // The re-check is the same weighing as the plan, run again under the lock. @@ -1225,6 +1359,35 @@ fn a_standing_that_holds_both_kinds_emits_both_wire_keys() { assert!(json["nothingToLose"].is_null(), "{json}"); } +#[test] +fn a_workspace_an_agent_built_in_does_not_start_refusing_dl_rm() { + // The ordinary loop: open a workspace, an agent works in it and installs an + // environment, `dl rm`. The clone verdict is the `rm` guard, so a site + // that stood for its build output would make the daily command refuse, and + // the only way past would be `dl rm --force` -- which also carries past + // the clone's own unpushed commits, which is the #171 guard this must not + // teach anybody to type. + // + // Pinned against the shipped answer rather than against a shape: this reads + // the same as the build before the sweep existed. + let world = Clone::new(); + std::fs::write(world.clone.join(".gitignore"), ".claude/\n.pixi/\n").expect("gitignore"); + commit(&world.clone, "ignore the agent's working directories"); + run_git(&world.clone, &["push", "origin", "main"]); + world.fetch(); + let worktree = world.worktree("agent-one"); + let env = worktree.join(".pixi").join("envs").join("default"); + std::fs::create_dir_all(&env).expect("an installed environment"); + std::fs::write(env.join("libthing.so"), "a great many bytes\n").expect("env bytes"); + world.containerise(); + + let runner = ProcessRunner::new(); + let git = Git::new(&runner); + let json = clone_verdict(&git, &world.clone, BareCache::At(&world.bare)).unsaved_json(); + + assert_eq!(json, serde_json::json!({ "nothingToLose": true }), "{json}"); +} + #[test] fn a_clean_clone_with_collectable_worktrees_reads_as_nothing_to_lose() { // Collectable sites do not stand the clone: the verdict conjoins standing From 99aeb7ac9fb713f09840d5eff8f8eb65f0a77656 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Sat, 29 Aug 2026 22:12:11 +0100 Subject: [PATCH 3/7] Regenerate the public-API snapshot Taken from CI's own diff, since this host has no nightly toolchain or cargo-public-api, and checked against the absences the design pins: Proof carries no impl block at all, GoingDirectory and Standing expose only readers, so none of them can be minted from outside a probe. --- rust/devlaunch-core/public-api.rest.txt | 372 ++++++++++++++++++++++-- 1 file changed, 354 insertions(+), 18 deletions(-) diff --git a/rust/devlaunch-core/public-api.rest.txt b/rust/devlaunch-core/public-api.rest.txt index b95d57f3..dcc26afd 100644 --- a/rust/devlaunch-core/public-api.rest.txt +++ b/rust/devlaunch-core/public-api.rest.txt @@ -645,6 +645,326 @@ pub fn devlaunch_core::domain::xdg::clone_root_in(&std::path::Path) -> std::path 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::Blank +pub devlaunch_core::flows::agent_worktrees::Blank::AppearedAfterThePlan +pub devlaunch_core::flows::agent_worktrees::Blank::GitWouldNotSay(devlaunch_core::domain::workspace_state::CouldNotTell) +pub devlaunch_core::flows::agent_worktrees::Blank::NotThisClonesToAccountFor(devlaunch_core::flows::agent_worktrees::Unaccountable) +pub devlaunch_core::flows::agent_worktrees::Blank::NothingToAskThrough +pub devlaunch_core::flows::agent_worktrees::Blank::ThirdPartyClaim(core::option::Option) +impl devlaunch_core::flows::agent_worktrees::Blank +pub fn devlaunch_core::flows::agent_worktrees::Blank::describe(&self) -> alloc::string::String +impl core::clone::Clone for devlaunch_core::flows::agent_worktrees::Blank +pub fn devlaunch_core::flows::agent_worktrees::Blank::clone(&self) -> devlaunch_core::flows::agent_worktrees::Blank +impl core::cmp::Eq for devlaunch_core::flows::agent_worktrees::Blank +impl core::cmp::PartialEq for devlaunch_core::flows::agent_worktrees::Blank +pub fn devlaunch_core::flows::agent_worktrees::Blank::eq(&self, &devlaunch_core::flows::agent_worktrees::Blank) -> bool +impl core::fmt::Debug for devlaunch_core::flows::agent_worktrees::Blank +pub fn devlaunch_core::flows::agent_worktrees::Blank::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::Blank +pub enum devlaunch_core::flows::agent_worktrees::Collectable +pub devlaunch_core::flows::agent_worktrees::Collectable::Directory(devlaunch_core::flows::agent_worktrees::GoingDirectory) +pub devlaunch_core::flows::agent_worktrees::Collectable::Registration(devlaunch_core::flows::agent_worktrees::GoingRegistration) +impl core::clone::Clone for devlaunch_core::flows::agent_worktrees::Collectable +pub fn devlaunch_core::flows::agent_worktrees::Collectable::clone(&self) -> devlaunch_core::flows::agent_worktrees::Collectable +impl core::cmp::Eq for devlaunch_core::flows::agent_worktrees::Collectable +impl core::cmp::PartialEq for devlaunch_core::flows::agent_worktrees::Collectable +pub fn devlaunch_core::flows::agent_worktrees::Collectable::eq(&self, &devlaunch_core::flows::agent_worktrees::Collectable) -> bool +impl core::fmt::Debug for devlaunch_core::flows::agent_worktrees::Collectable +pub fn devlaunch_core::flows::agent_worktrees::Collectable::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::Collectable +pub enum devlaunch_core::flows::agent_worktrees::Place +pub devlaunch_core::flows::agent_worktrees::Place::ASite(devlaunch_core::flows::agent_worktrees::Inside) +pub devlaunch_core::flows::agent_worktrees::Place::TheCloneItself +impl core::clone::Clone for devlaunch_core::flows::agent_worktrees::Place +pub fn devlaunch_core::flows::agent_worktrees::Place::clone(&self) -> devlaunch_core::flows::agent_worktrees::Place +impl core::cmp::Eq for devlaunch_core::flows::agent_worktrees::Place +impl core::cmp::PartialEq for devlaunch_core::flows::agent_worktrees::Place +pub fn devlaunch_core::flows::agent_worktrees::Place::eq(&self, &devlaunch_core::flows::agent_worktrees::Place) -> bool +impl core::fmt::Debug for devlaunch_core::flows::agent_worktrees::Place +pub fn devlaunch_core::flows::agent_worktrees::Place::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::Place +pub enum devlaunch_core::flows::agent_worktrees::Reason +pub devlaunch_core::flows::agent_worktrees::Reason::CouldNotProve +pub devlaunch_core::flows::agent_worktrees::Reason::CouldNotProve::at: devlaunch_core::flows::agent_worktrees::Place +pub devlaunch_core::flows::agent_worktrees::Reason::CouldNotProve::blank: devlaunch_core::flows::agent_worktrees::Blank +pub devlaunch_core::flows::agent_worktrees::Reason::Holds +pub devlaunch_core::flows::agent_worktrees::Reason::Holds::at: devlaunch_core::flows::agent_worktrees::Place +pub devlaunch_core::flows::agent_worktrees::Reason::Holds::losses: alloc::boxed::Box> +impl devlaunch_core::flows::agent_worktrees::Reason +pub fn devlaunch_core::flows::agent_worktrees::Reason::at(&self) -> &devlaunch_core::flows::agent_worktrees::Place +pub fn devlaunch_core::flows::agent_worktrees::Reason::subject(&self) -> devlaunch_core::flows::agent_worktrees::Subject +impl core::clone::Clone for devlaunch_core::flows::agent_worktrees::Reason +pub fn devlaunch_core::flows::agent_worktrees::Reason::clone(&self) -> devlaunch_core::flows::agent_worktrees::Reason +impl core::cmp::Eq for devlaunch_core::flows::agent_worktrees::Reason +impl core::cmp::PartialEq for devlaunch_core::flows::agent_worktrees::Reason +pub fn devlaunch_core::flows::agent_worktrees::Reason::eq(&self, &devlaunch_core::flows::agent_worktrees::Reason) -> bool +impl core::fmt::Debug for devlaunch_core::flows::agent_worktrees::Reason +pub fn devlaunch_core::flows::agent_worktrees::Reason::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::Reason +pub enum devlaunch_core::flows::agent_worktrees::Subject +pub devlaunch_core::flows::agent_worktrees::Subject::AClaim +pub devlaunch_core::flows::agent_worktrees::Subject::GitsAccountOfContent +impl core::clone::Clone for devlaunch_core::flows::agent_worktrees::Subject +pub fn devlaunch_core::flows::agent_worktrees::Subject::clone(&self) -> devlaunch_core::flows::agent_worktrees::Subject +impl core::cmp::Eq for devlaunch_core::flows::agent_worktrees::Subject +impl core::cmp::PartialEq for devlaunch_core::flows::agent_worktrees::Subject +pub fn devlaunch_core::flows::agent_worktrees::Subject::eq(&self, &devlaunch_core::flows::agent_worktrees::Subject) -> bool +impl core::fmt::Debug for devlaunch_core::flows::agent_worktrees::Subject +pub fn devlaunch_core::flows::agent_worktrees::Subject::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::Copy for devlaunch_core::flows::agent_worktrees::Subject +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::Subject +pub enum devlaunch_core::flows::agent_worktrees::Unaccountable +pub devlaunch_core::flows::agent_worktrees::Unaccountable::GitfileUnreadable +pub devlaunch_core::flows::agent_worktrees::Unaccountable::PlainDirectory +pub devlaunch_core::flows::agent_worktrees::Unaccountable::RegisteredElsewhere +pub devlaunch_core::flows::agent_worktrees::Unaccountable::SymlinkInThePlace +impl devlaunch_core::flows::agent_worktrees::Unaccountable +pub fn devlaunch_core::flows::agent_worktrees::Unaccountable::describe(&self) -> &'static str +impl core::clone::Clone for devlaunch_core::flows::agent_worktrees::Unaccountable +pub fn devlaunch_core::flows::agent_worktrees::Unaccountable::clone(&self) -> devlaunch_core::flows::agent_worktrees::Unaccountable +impl core::cmp::Eq for devlaunch_core::flows::agent_worktrees::Unaccountable +impl core::cmp::PartialEq for devlaunch_core::flows::agent_worktrees::Unaccountable +pub fn devlaunch_core::flows::agent_worktrees::Unaccountable::eq(&self, &devlaunch_core::flows::agent_worktrees::Unaccountable) -> bool +impl core::fmt::Debug for devlaunch_core::flows::agent_worktrees::Unaccountable +pub fn devlaunch_core::flows::agent_worktrees::Unaccountable::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::Copy for devlaunch_core::flows::agent_worktrees::Unaccountable +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::Unaccountable +pub enum devlaunch_core::flows::agent_worktrees::Verdict +pub devlaunch_core::flows::agent_worktrees::Verdict::Collectable(devlaunch_core::flows::agent_worktrees::Proof) +pub devlaunch_core::flows::agent_worktrees::Verdict::Stands(devlaunch_core::flows::agent_worktrees::Standing) +impl devlaunch_core::flows::agent_worktrees::Verdict +pub fn devlaunch_core::flows::agent_worktrees::Verdict::unsaved_json(&self) -> serde_json::value::Value +impl core::clone::Clone for devlaunch_core::flows::agent_worktrees::Verdict +pub fn devlaunch_core::flows::agent_worktrees::Verdict::clone(&self) -> devlaunch_core::flows::agent_worktrees::Verdict +impl core::cmp::Eq for devlaunch_core::flows::agent_worktrees::Verdict +impl core::cmp::PartialEq for devlaunch_core::flows::agent_worktrees::Verdict +pub fn devlaunch_core::flows::agent_worktrees::Verdict::eq(&self, &devlaunch_core::flows::agent_worktrees::Verdict) -> bool +impl core::fmt::Debug for devlaunch_core::flows::agent_worktrees::Verdict +pub fn devlaunch_core::flows::agent_worktrees::Verdict::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::Verdict +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::WorktreePromotion +pub devlaunch_core::flows::agent_worktrees::WorktreePromotion::Insisted +pub devlaunch_core::flows::agent_worktrees::WorktreePromotion::Insisted::despite: devlaunch_core::flows::agent_worktrees::Standing +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::going(&self) -> &[devlaunch_core::flows::agent_worktrees::Going] +pub fn devlaunch_core::flows::agent_worktrees::CloneWorktrees::owner(&self) -> &str +pub fn devlaunch_core::flows::agent_worktrees::CloneWorktrees::repo(&self) -> &str +pub fn devlaunch_core::flows::agent_worktrees::CloneWorktrees::standing(&self) -> &[devlaunch_core::flows::agent_worktrees::StandingSite] +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::ForgetRefused +pub devlaunch_core::flows::agent_worktrees::ForgetRefused::reason: alloc::string::String +pub devlaunch_core::flows::agent_worktrees::ForgetRefused::registered: std::path::PathBuf +impl core::clone::Clone for devlaunch_core::flows::agent_worktrees::ForgetRefused +pub fn devlaunch_core::flows::agent_worktrees::ForgetRefused::clone(&self) -> devlaunch_core::flows::agent_worktrees::ForgetRefused +impl core::cmp::Eq for devlaunch_core::flows::agent_worktrees::ForgetRefused +impl core::cmp::PartialEq for devlaunch_core::flows::agent_worktrees::ForgetRefused +pub fn devlaunch_core::flows::agent_worktrees::ForgetRefused::eq(&self, &devlaunch_core::flows::agent_worktrees::ForgetRefused) -> bool +impl core::fmt::Debug for devlaunch_core::flows::agent_worktrees::ForgetRefused +pub fn devlaunch_core::flows::agent_worktrees::ForgetRefused::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::ForgetRefused +pub struct devlaunch_core::flows::agent_worktrees::Going +impl devlaunch_core::flows::agent_worktrees::Going +pub fn devlaunch_core::flows::agent_worktrees::Going::forgets(&self) -> &[devlaunch_core::flows::agent_worktrees::Recorded] +pub fn devlaunch_core::flows::agent_worktrees::Going::promotion(&self) -> &devlaunch_core::flows::agent_worktrees::WorktreePromotion +pub fn devlaunch_core::flows::agent_worktrees::Going::what(&self) -> &devlaunch_core::flows::agent_worktrees::Collectable +impl core::clone::Clone for devlaunch_core::flows::agent_worktrees::Going +pub fn devlaunch_core::flows::agent_worktrees::Going::clone(&self) -> devlaunch_core::flows::agent_worktrees::Going +impl core::cmp::Eq for devlaunch_core::flows::agent_worktrees::Going +impl core::cmp::PartialEq for devlaunch_core::flows::agent_worktrees::Going +pub fn devlaunch_core::flows::agent_worktrees::Going::eq(&self, &devlaunch_core::flows::agent_worktrees::Going) -> bool +impl core::fmt::Debug for devlaunch_core::flows::agent_worktrees::Going +pub fn devlaunch_core::flows::agent_worktrees::Going::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::Going +pub struct devlaunch_core::flows::agent_worktrees::GoingDirectory +impl devlaunch_core::flows::agent_worktrees::GoingDirectory +pub fn devlaunch_core::flows::agent_worktrees::GoingDirectory::at(&self) -> &std::path::Path +pub fn devlaunch_core::flows::agent_worktrees::GoingDirectory::forgets(&self) -> &[devlaunch_core::flows::agent_worktrees::Recorded] +pub fn devlaunch_core::flows::agent_worktrees::GoingDirectory::usage(&self) -> &devlaunch_core::flows::disk_usage::DiskUsage +impl core::clone::Clone for devlaunch_core::flows::agent_worktrees::GoingDirectory +pub fn devlaunch_core::flows::agent_worktrees::GoingDirectory::clone(&self) -> devlaunch_core::flows::agent_worktrees::GoingDirectory +impl core::cmp::Eq for devlaunch_core::flows::agent_worktrees::GoingDirectory +impl core::cmp::PartialEq for devlaunch_core::flows::agent_worktrees::GoingDirectory +pub fn devlaunch_core::flows::agent_worktrees::GoingDirectory::eq(&self, &devlaunch_core::flows::agent_worktrees::GoingDirectory) -> bool +impl core::fmt::Debug for devlaunch_core::flows::agent_worktrees::GoingDirectory +pub fn devlaunch_core::flows::agent_worktrees::GoingDirectory::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::GoingDirectory +pub struct devlaunch_core::flows::agent_worktrees::GoingRegistration +impl devlaunch_core::flows::agent_worktrees::GoingRegistration +pub fn devlaunch_core::flows::agent_worktrees::GoingRegistration::forgets(&self) -> &[devlaunch_core::flows::agent_worktrees::Recorded] +pub fn devlaunch_core::flows::agent_worktrees::GoingRegistration::place(&self) -> &devlaunch_core::flows::agent_worktrees::Inside +impl core::clone::Clone for devlaunch_core::flows::agent_worktrees::GoingRegistration +pub fn devlaunch_core::flows::agent_worktrees::GoingRegistration::clone(&self) -> devlaunch_core::flows::agent_worktrees::GoingRegistration +impl core::cmp::Eq for devlaunch_core::flows::agent_worktrees::GoingRegistration +impl core::cmp::PartialEq for devlaunch_core::flows::agent_worktrees::GoingRegistration +pub fn devlaunch_core::flows::agent_worktrees::GoingRegistration::eq(&self, &devlaunch_core::flows::agent_worktrees::GoingRegistration) -> bool +impl core::fmt::Debug for devlaunch_core::flows::agent_worktrees::GoingRegistration +pub fn devlaunch_core::flows::agent_worktrees::GoingRegistration::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::GoingRegistration +pub struct devlaunch_core::flows::agent_worktrees::Inside +impl devlaunch_core::flows::agent_worktrees::Inside +pub fn devlaunch_core::flows::agent_worktrees::Inside::as_str(&self) -> &str +impl core::clone::Clone for devlaunch_core::flows::agent_worktrees::Inside +pub fn devlaunch_core::flows::agent_worktrees::Inside::clone(&self) -> devlaunch_core::flows::agent_worktrees::Inside +impl core::cmp::Eq for devlaunch_core::flows::agent_worktrees::Inside +impl core::cmp::Ord for devlaunch_core::flows::agent_worktrees::Inside +pub fn devlaunch_core::flows::agent_worktrees::Inside::cmp(&self, &devlaunch_core::flows::agent_worktrees::Inside) -> core::cmp::Ordering +impl core::cmp::PartialEq for devlaunch_core::flows::agent_worktrees::Inside +pub fn devlaunch_core::flows::agent_worktrees::Inside::eq(&self, &devlaunch_core::flows::agent_worktrees::Inside) -> bool +impl core::cmp::PartialOrd for devlaunch_core::flows::agent_worktrees::Inside +pub fn devlaunch_core::flows::agent_worktrees::Inside::partial_cmp(&self, &devlaunch_core::flows::agent_worktrees::Inside) -> core::option::Option +impl core::fmt::Debug for devlaunch_core::flows::agent_worktrees::Inside +pub fn devlaunch_core::flows::agent_worktrees::Inside::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::Inside +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::Proof +impl core::clone::Clone for devlaunch_core::flows::agent_worktrees::Proof +pub fn devlaunch_core::flows::agent_worktrees::Proof::clone(&self) -> devlaunch_core::flows::agent_worktrees::Proof +impl core::cmp::Eq for devlaunch_core::flows::agent_worktrees::Proof +impl core::cmp::PartialEq for devlaunch_core::flows::agent_worktrees::Proof +pub fn devlaunch_core::flows::agent_worktrees::Proof::eq(&self, &devlaunch_core::flows::agent_worktrees::Proof) -> bool +impl core::fmt::Debug for devlaunch_core::flows::agent_worktrees::Proof +pub fn devlaunch_core::flows::agent_worktrees::Proof::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::Proof +pub struct devlaunch_core::flows::agent_worktrees::Recorded +impl devlaunch_core::flows::agent_worktrees::Recorded +pub fn devlaunch_core::flows::agent_worktrees::Recorded::as_path(&self) -> &std::path::Path +impl core::clone::Clone for devlaunch_core::flows::agent_worktrees::Recorded +pub fn devlaunch_core::flows::agent_worktrees::Recorded::clone(&self) -> devlaunch_core::flows::agent_worktrees::Recorded +impl core::cmp::Eq for devlaunch_core::flows::agent_worktrees::Recorded +impl core::cmp::PartialEq for devlaunch_core::flows::agent_worktrees::Recorded +pub fn devlaunch_core::flows::agent_worktrees::Recorded::eq(&self, &devlaunch_core::flows::agent_worktrees::Recorded) -> bool +impl core::fmt::Debug for devlaunch_core::flows::agent_worktrees::Recorded +pub fn devlaunch_core::flows::agent_worktrees::Recorded::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::Recorded +pub struct devlaunch_core::flows::agent_worktrees::RemovedWorktree +pub devlaunch_core::flows::agent_worktrees::RemovedWorktree::path: std::path::PathBuf +pub devlaunch_core::flows::agent_worktrees::RemovedWorktree::usage: devlaunch_core::flows::disk_usage::DiskUsage +impl core::clone::Clone for devlaunch_core::flows::agent_worktrees::RemovedWorktree +pub fn devlaunch_core::flows::agent_worktrees::RemovedWorktree::clone(&self) -> devlaunch_core::flows::agent_worktrees::RemovedWorktree +impl core::cmp::Eq for devlaunch_core::flows::agent_worktrees::RemovedWorktree +impl core::cmp::PartialEq for devlaunch_core::flows::agent_worktrees::RemovedWorktree +pub fn devlaunch_core::flows::agent_worktrees::RemovedWorktree::eq(&self, &devlaunch_core::flows::agent_worktrees::RemovedWorktree) -> bool +impl core::fmt::Debug for devlaunch_core::flows::agent_worktrees::RemovedWorktree +pub fn devlaunch_core::flows::agent_worktrees::RemovedWorktree::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::RemovedWorktree +pub struct devlaunch_core::flows::agent_worktrees::Standing +impl devlaunch_core::flows::agent_worktrees::Standing +pub fn devlaunch_core::flows::agent_worktrees::Standing::any_unproved(&self) -> bool +pub fn devlaunch_core::flows::agent_worktrees::Standing::could_not_tell(&self) -> core::option::Option +pub fn devlaunch_core::flows::agent_worktrees::Standing::describe(&self) -> alloc::string::String +pub fn devlaunch_core::flows::agent_worktrees::Standing::would_lose(&self) -> core::option::Option +impl devlaunch_core::flows::agent_worktrees::Standing +pub fn devlaunch_core::flows::agent_worktrees::Standing::iter(&self) -> impl core::iter::traits::iterator::Iterator +impl core::clone::Clone for devlaunch_core::flows::agent_worktrees::Standing +pub fn devlaunch_core::flows::agent_worktrees::Standing::clone(&self) -> devlaunch_core::flows::agent_worktrees::Standing +impl core::cmp::Eq for devlaunch_core::flows::agent_worktrees::Standing +impl core::cmp::PartialEq for devlaunch_core::flows::agent_worktrees::Standing +pub fn devlaunch_core::flows::agent_worktrees::Standing::eq(&self, &devlaunch_core::flows::agent_worktrees::Standing) -> bool +impl core::fmt::Debug for devlaunch_core::flows::agent_worktrees::Standing +pub fn devlaunch_core::flows::agent_worktrees::Standing::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::Standing +pub struct devlaunch_core::flows::agent_worktrees::StandingSite +impl devlaunch_core::flows::agent_worktrees::StandingSite +pub fn devlaunch_core::flows::agent_worktrees::StandingSite::at(&self) -> &std::path::Path +pub fn devlaunch_core::flows::agent_worktrees::StandingSite::reasons(&self) -> &devlaunch_core::flows::agent_worktrees::Standing +impl core::clone::Clone for devlaunch_core::flows::agent_worktrees::StandingSite +pub fn devlaunch_core::flows::agent_worktrees::StandingSite::clone(&self) -> devlaunch_core::flows::agent_worktrees::StandingSite +impl core::cmp::Eq for devlaunch_core::flows::agent_worktrees::StandingSite +impl core::cmp::PartialEq for devlaunch_core::flows::agent_worktrees::StandingSite +pub fn devlaunch_core::flows::agent_worktrees::StandingSite::eq(&self, &devlaunch_core::flows::agent_worktrees::StandingSite) -> bool +impl core::fmt::Debug for devlaunch_core::flows::agent_worktrees::StandingSite +pub fn devlaunch_core::flows::agent_worktrees::StandingSite::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::StandingSite +pub struct devlaunch_core::flows::agent_worktrees::WithheldWorktree +pub devlaunch_core::flows::agent_worktrees::WithheldWorktree::because: devlaunch_core::flows::agent_worktrees::Standing +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::forget_refused: alloc::vec::Vec +pub devlaunch_core::flows::agent_worktrees::WorktreeReport::forgotten: usize +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::nothing_to_do(&self) -> bool +pub fn devlaunch_core::flows::agent_worktrees::WorktreeSweep::nothing_to_say(&self) -> bool +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 mod devlaunch_core::flows::branch_manager pub enum devlaunch_core::flows::branch_manager::BranchError pub devlaunch_core::flows::branch_manager::BranchError::NotCreated @@ -1179,7 +1499,7 @@ pub fn devlaunch_core::flows::lifecycle::ChildWork::fmt(&self, &mut core::fmt::F impl core::marker::Copy for devlaunch_core::flows::lifecycle::ChildWork impl core::marker::StructuralPartialEq for devlaunch_core::flows::lifecycle::ChildWork pub enum devlaunch_core::flows::lifecycle::KeptBecause -pub devlaunch_core::flows::lifecycle::KeptBecause::Objected(devlaunch_core::flows::lifecycle::Objection) +pub devlaunch_core::flows::lifecycle::KeptBecause::Objected(devlaunch_core::flows::agent_worktrees::Standing) pub devlaunch_core::flows::lifecycle::KeptBecause::RecordsDisagree pub devlaunch_core::flows::lifecycle::KeptBecause::RecordsDisagree::sourced_at: alloc::string::String pub devlaunch_core::flows::lifecycle::KeptBecause::RecordsDisagree::workspace_id: alloc::string::String @@ -1207,20 +1527,9 @@ pub fn devlaunch_core::flows::lifecycle::NotAdopted::eq(&self, &devlaunch_core:: impl core::fmt::Debug for devlaunch_core::flows::lifecycle::NotAdopted pub fn devlaunch_core::flows::lifecycle::NotAdopted::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::marker::StructuralPartialEq for devlaunch_core::flows::lifecycle::NotAdopted -pub enum devlaunch_core::flows::lifecycle::Objection -pub devlaunch_core::flows::lifecycle::Objection::CouldNotTell(devlaunch_core::domain::workspace_state::CouldNotTell) -pub devlaunch_core::flows::lifecycle::Objection::WouldLose(devlaunch_core::domain::workspace_state::NonEmpty) -impl core::clone::Clone for devlaunch_core::flows::lifecycle::Objection -pub fn devlaunch_core::flows::lifecycle::Objection::clone(&self) -> devlaunch_core::flows::lifecycle::Objection -impl core::cmp::Eq for devlaunch_core::flows::lifecycle::Objection -impl core::cmp::PartialEq for devlaunch_core::flows::lifecycle::Objection -pub fn devlaunch_core::flows::lifecycle::Objection::eq(&self, &devlaunch_core::flows::lifecycle::Objection) -> bool -impl core::fmt::Debug for devlaunch_core::flows::lifecycle::Objection -pub fn devlaunch_core::flows::lifecycle::Objection::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result -impl core::marker::StructuralPartialEq for devlaunch_core::flows::lifecycle::Objection pub enum devlaunch_core::flows::lifecycle::Promotion pub devlaunch_core::flows::lifecycle::Promotion::Insisted -pub devlaunch_core::flows::lifecycle::Promotion::Insisted::despite: devlaunch_core::flows::lifecycle::Objection +pub devlaunch_core::flows::lifecycle::Promotion::Insisted::despite: devlaunch_core::flows::agent_worktrees::Standing pub devlaunch_core::flows::lifecycle::Promotion::Unopposed impl core::clone::Clone for devlaunch_core::flows::lifecycle::Promotion pub fn devlaunch_core::flows::lifecycle::Promotion::clone(&self) -> devlaunch_core::flows::lifecycle::Promotion @@ -1421,6 +1730,18 @@ 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 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 @@ -1434,13 +1755,14 @@ 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::reclaiming(&self) -> &[devlaunch_core::flows::lifecycle::ReclaimableVolumes] 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 @@ -1455,9 +1777,10 @@ pub devlaunch_core::flows::lifecycle::PruneReport::refused: alloc::vec::Vec pub devlaunch_core::flows::lifecycle::PruneReport::volumes_kept: 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::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 @@ -1582,9 +1905,8 @@ pub fn devlaunch_core::flows::lifecycle::Withheld::fmt(&self, &mut core::fmt::Fo impl core::marker::StructuralPartialEq for devlaunch_core::flows::lifecycle::Withheld pub fn devlaunch_core::flows::lifecycle::apply_reconciliation(&mut devlaunch_core::flows::listing::CommandContext<'_>, &mut devlaunch_core::flows::lifecycle::Refresh<'_>, &mut devlaunch_core::domain::metadata::MetadataStorage, &devlaunch_core::clients::devpod_home::DevpodHome, &devlaunch_core::flows::lifecycle::ReconcilePlan, &mut dyn devlaunch_core::notices::Notices) -> devlaunch_core::flows::lifecycle::ReconcileReport pub fn devlaunch_core::flows::lifecycle::child_work(&std::path::Path, devlaunch_core::flows::lifecycle::RefreshReason) -> devlaunch_core::flows::lifecycle::ChildWork -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::kept_copies::KeptCopies, &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::kept_copies::KeptCopies, &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::kept_copies::KeptCopies, &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<&devlaunch_core::clients::devpod_home::DevpodHome>, &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 @@ -1603,7 +1925,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 @@ -1652,6 +1974,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::DlView<'a> pub devlaunch_core::flows::listing::DlView::cache_dir: &'a std::path::Path pub devlaunch_core::flows::listing::DlView::clones: &'a dyn devlaunch_core::flows::listing::ClonePathResolver From aeb5ab38ab8965059b6b80d99834ef5107fe22c2 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Sat, 29 Aug 2026 22:17:02 +0100 Subject: [PATCH 4/7] Record the promised tier's one deliberate change: RemovalRefused A diff in public-api.api.txt is a change to the tier an external consumer may depend on, so this is the one to read rather than skim. `RemovalRefused` stops being a two-arm enum and becomes a struct carrying the whole standing, which is #446's decision reaching the guard: a clone can hold work *and* have a question that could not be put, and a refusal that named one arm would be telling half the truth. It reaches the promised tier only because main promoted `RemovalRefused` into `api` in #520 while this branch was in flight. The consequence is that `agent_worktrees::Standing` is now named at that tier too, through the `standing` field, and its readers -- would_lose, could_not_tell, describe -- are what a consumer reads the refusal with. --- rust/devlaunch-core/public-api.api.txt | 52 +++++++++++--------------- 1 file changed, 22 insertions(+), 30 deletions(-) diff --git a/rust/devlaunch-core/public-api.api.txt b/rust/devlaunch-core/public-api.api.txt index 29d11c99..3fc5693c 100644 --- a/rust/devlaunch-core/public-api.api.txt +++ b/rust/devlaunch-core/public-api.api.txt @@ -188,21 +188,6 @@ impl core::fmt::Debug for devlaunch_core::flows::lifecycle::Removal pub fn devlaunch_core::flows::lifecycle::Removal::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::marker::Copy for devlaunch_core::flows::lifecycle::Removal impl core::marker::StructuralPartialEq for devlaunch_core::flows::lifecycle::Removal -pub enum devlaunch_core::api::RemovalRefused -pub devlaunch_core::api::RemovalRefused::CouldNotTell -pub devlaunch_core::api::RemovalRefused::CouldNotTell::cause: devlaunch_core::domain::workspace_state::CouldNotTell -pub devlaunch_core::api::RemovalRefused::CouldNotTell::workspace_id: alloc::string::String -pub devlaunch_core::api::RemovalRefused::WouldLose -pub devlaunch_core::api::RemovalRefused::WouldLose::losses: devlaunch_core::domain::workspace_state::NonEmpty -pub devlaunch_core::api::RemovalRefused::WouldLose::workspace_id: alloc::string::String -impl core::clone::Clone for devlaunch_core::flows::lifecycle::RemovalRefused -pub fn devlaunch_core::flows::lifecycle::RemovalRefused::clone(&self) -> devlaunch_core::flows::lifecycle::RemovalRefused -impl core::cmp::Eq for devlaunch_core::flows::lifecycle::RemovalRefused -impl core::cmp::PartialEq for devlaunch_core::flows::lifecycle::RemovalRefused -pub fn devlaunch_core::flows::lifecycle::RemovalRefused::eq(&self, &devlaunch_core::flows::lifecycle::RemovalRefused) -> bool -impl core::fmt::Debug for devlaunch_core::flows::lifecycle::RemovalRefused -pub fn devlaunch_core::flows::lifecycle::RemovalRefused::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result -impl core::marker::StructuralPartialEq for devlaunch_core::flows::lifecycle::RemovalRefused pub enum devlaunch_core::api::RemoveOutcome pub devlaunch_core::api::RemoveOutcome::Deleted pub devlaunch_core::api::RemoveOutcome::Deleted::clone: core::result::Result @@ -354,6 +339,17 @@ pub fn devlaunch_core::flows::lifecycle::Refresh<'a>::ask(&mut self, &dyn devlau pub fn devlaunch_core::flows::lifecycle::Refresh<'a>::new(&'a devlaunch_core::flows::lifecycle::SelfInvocation, &'a std::path::Path) -> Self pub fn devlaunch_core::flows::lifecycle::Refresh<'a>::rearm(&mut self) pub fn devlaunch_core::flows::lifecycle::Refresh<'a>::spawned(&self) -> bool +pub struct devlaunch_core::api::RemovalRefused +pub devlaunch_core::api::RemovalRefused::standing: devlaunch_core::flows::agent_worktrees::Standing +pub devlaunch_core::api::RemovalRefused::workspace_id: alloc::string::String +impl core::clone::Clone for devlaunch_core::flows::lifecycle::RemovalRefused +pub fn devlaunch_core::flows::lifecycle::RemovalRefused::clone(&self) -> devlaunch_core::flows::lifecycle::RemovalRefused +impl core::cmp::Eq for devlaunch_core::flows::lifecycle::RemovalRefused +impl core::cmp::PartialEq for devlaunch_core::flows::lifecycle::RemovalRefused +pub fn devlaunch_core::flows::lifecycle::RemovalRefused::eq(&self, &devlaunch_core::flows::lifecycle::RemovalRefused) -> bool +impl core::fmt::Debug for devlaunch_core::flows::lifecycle::RemovalRefused +pub fn devlaunch_core::flows::lifecycle::RemovalRefused::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::lifecycle::RemovalRefused pub struct devlaunch_core::api::SelfInvocation impl devlaunch_core::flows::lifecycle::SelfInvocation pub fn devlaunch_core::flows::lifecycle::SelfInvocation::new(impl core::convert::Into) -> Self @@ -679,21 +675,6 @@ impl core::fmt::Debug for devlaunch_core::flows::lifecycle::Removal pub fn devlaunch_core::flows::lifecycle::Removal::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::marker::Copy for devlaunch_core::flows::lifecycle::Removal impl core::marker::StructuralPartialEq for devlaunch_core::flows::lifecycle::Removal -pub enum devlaunch_core::flows::lifecycle::RemovalRefused -pub devlaunch_core::flows::lifecycle::RemovalRefused::CouldNotTell -pub devlaunch_core::flows::lifecycle::RemovalRefused::CouldNotTell::cause: devlaunch_core::domain::workspace_state::CouldNotTell -pub devlaunch_core::flows::lifecycle::RemovalRefused::CouldNotTell::workspace_id: alloc::string::String -pub devlaunch_core::flows::lifecycle::RemovalRefused::WouldLose -pub devlaunch_core::flows::lifecycle::RemovalRefused::WouldLose::losses: devlaunch_core::domain::workspace_state::NonEmpty -pub devlaunch_core::flows::lifecycle::RemovalRefused::WouldLose::workspace_id: alloc::string::String -impl core::clone::Clone for devlaunch_core::flows::lifecycle::RemovalRefused -pub fn devlaunch_core::flows::lifecycle::RemovalRefused::clone(&self) -> devlaunch_core::flows::lifecycle::RemovalRefused -impl core::cmp::Eq for devlaunch_core::flows::lifecycle::RemovalRefused -impl core::cmp::PartialEq for devlaunch_core::flows::lifecycle::RemovalRefused -pub fn devlaunch_core::flows::lifecycle::RemovalRefused::eq(&self, &devlaunch_core::flows::lifecycle::RemovalRefused) -> bool -impl core::fmt::Debug for devlaunch_core::flows::lifecycle::RemovalRefused -pub fn devlaunch_core::flows::lifecycle::RemovalRefused::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result -impl core::marker::StructuralPartialEq for devlaunch_core::flows::lifecycle::RemovalRefused pub enum devlaunch_core::flows::lifecycle::RemoveOutcome pub devlaunch_core::flows::lifecycle::RemoveOutcome::Deleted pub devlaunch_core::flows::lifecycle::RemoveOutcome::Deleted::clone: core::result::Result @@ -715,6 +696,17 @@ pub fn devlaunch_core::flows::lifecycle::Refresh<'a>::ask(&mut self, &dyn devlau pub fn devlaunch_core::flows::lifecycle::Refresh<'a>::new(&'a devlaunch_core::flows::lifecycle::SelfInvocation, &'a std::path::Path) -> Self pub fn devlaunch_core::flows::lifecycle::Refresh<'a>::rearm(&mut self) pub fn devlaunch_core::flows::lifecycle::Refresh<'a>::spawned(&self) -> bool +pub struct devlaunch_core::flows::lifecycle::RemovalRefused +pub devlaunch_core::flows::lifecycle::RemovalRefused::standing: devlaunch_core::flows::agent_worktrees::Standing +pub devlaunch_core::flows::lifecycle::RemovalRefused::workspace_id: alloc::string::String +impl core::clone::Clone for devlaunch_core::flows::lifecycle::RemovalRefused +pub fn devlaunch_core::flows::lifecycle::RemovalRefused::clone(&self) -> devlaunch_core::flows::lifecycle::RemovalRefused +impl core::cmp::Eq for devlaunch_core::flows::lifecycle::RemovalRefused +impl core::cmp::PartialEq for devlaunch_core::flows::lifecycle::RemovalRefused +pub fn devlaunch_core::flows::lifecycle::RemovalRefused::eq(&self, &devlaunch_core::flows::lifecycle::RemovalRefused) -> bool +impl core::fmt::Debug for devlaunch_core::flows::lifecycle::RemovalRefused +pub fn devlaunch_core::flows::lifecycle::RemovalRefused::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::lifecycle::RemovalRefused pub struct devlaunch_core::flows::lifecycle::SelfInvocation impl devlaunch_core::flows::lifecycle::SelfInvocation pub fn devlaunch_core::flows::lifecycle::SelfInvocation::new(impl core::convert::Into) -> Self From a2d8e7efe430fff1257110c7c644c450d1cf351f Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Sat, 29 Aug 2026 22:20:34 +0100 Subject: [PATCH 5/7] Say 37 types where the residual is 37 types The promised-tier change takes two types out of the binary-surface residual, so the figure three places carry moves from 39 to 37, and the row total from just over six hundred to 595. The guard that caught it exists for exactly this and says so in its own docstring: the sentence is cheapest to fix in the run that moves it. --- docs/development.md | 2 +- rust/devlaunch-core/src/lib.rs | 4 ++-- scripts/public-api-snapshots.sh | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/development.md b/docs/development.md index dbcf4d5d..32a1e464 100644 --- a/docs/development.md +++ b/docs/development.md @@ -69,7 +69,7 @@ once under the `api` section and once under the module that owns them. **What it still does not reach, and it is not one type.** A type `api` never re-exports but a promised signature hands back is reachable from outside and classified as binary surface. Counted -on the checked-in files rather than guessed at, that is **39 types owning over six hundred rows** in +on the checked-in files rather than guessed at, that is **37 types owning close to six hundred rows** in `public-api.rest.txt`, and the command that lists them needs no toolchain: ```bash diff --git a/rust/devlaunch-core/src/lib.rs b/rust/devlaunch-core/src/lib.rs index 5bc77a34..ace2ef5b 100644 --- a/rust/devlaunch-core/src/lib.rs +++ b/rust/devlaunch-core/src/lib.rs @@ -66,8 +66,8 @@ //! **What it still does not reach, and it is not one type.** A type [`api`] //! never re-exports but a promised signature names is reachable from outside //! and classified as binary surface, so a break in it diffs -//! `public-api.rest.txt` alone. Counted rather than guessed at: **39 such types -//! own over six hundred rows over there**, and `scripts/public-api-snapshots.sh +//! `public-api.rest.txt` alone. Counted rather than guessed at: **37 such types +//! own close to six hundred rows over there**, and `scripts/public-api-snapshots.sh //! --print-residual` lists them. //! //! The pointed one is `domain::spec::DevcontainerRefError`. [`api`] promises diff --git a/scripts/public-api-snapshots.sh b/scripts/public-api-snapshots.sh index e9e19382..952f24c0 100755 --- a/scripts/public-api-snapshots.sh +++ b/scripts/public-api-snapshots.sh @@ -37,7 +37,7 @@ # The limit that is left, and it is not one type. A type `api` never re-exports # but a promised signature names is reachable from outside and is classified as # binary surface, so a break in it diffs public-api.rest.txt alone. Measured on -# the checked-in files rather than guessed at: 39 such types own over six +# the checked-in files rather than guessed at: 37 such types own close to six # hundred rows over there. `--print-residual` lists them, needs no toolchain, # and prints the exact row count; the type count above is the figure # `test/test_public_api_snapshots_doc.py` diffs against it, because that is the From eb838b8576cdad2b43d686d820a7b2efd8dc8e4e Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Sat, 29 Aug 2026 22:33:11 +0100 Subject: [PATCH 6/7] Render the standing at the promise, do not promote it As pushed, `public-api.api.txt` named `agent_worktrees::Standing` in `RemovalRefused` and did not promise it: the type has no struct or impl rows in that file at all. A consumer holding only `api` got a struct with a field whose type it could not name, which is devlaunch#531's gap and would have been its third instance after #427 and #516. Promoting it honestly was the other option and it is not small. `Standing` reaches `StandingSite`, `Reason`, `Place`, `Blank`, `Subject` and `NonEmpty`, and `agent_worktrees` has over three hundred rows in the binary-surface snapshot. That is most of a module's internal vocabulary arriving in the one tier whose worth is being small and stable. So the promoted shape was either incomplete or far too wide, and rendering at the seam is the only option that is both complete and narrow. It is the move the `--ls --json` payload already makes for the wire, at the same boundary and for the same reason. `RemovalRefused` now carries a `RemovalGrounds`, which is made of `String`: `WouldLose`, `CouldNotTell`, or `BothAtOnce`. Three arms rather than two options, because a standing is non-empty and every reason in it answers one of the two, so "neither" cannot happen -- and both render sites carried a fourth arm apologising for being unreachable, which this deletes rather than comments. `BothAtOnce` is what keeps #446 true across the seam: a refusal still never picks one of two true things to say. Nothing inside `flows` changed. `Standing` is exactly as it was, the domain type still carries the whole standing, and the conversion is a private free function at the boundary rather than a method -- a public constructor taking a `Standing` would put it straight back into the promised tier's signatures. Also removes `Standing::any_unproved`, which this branch added and nothing ever called. `Standing` is in the residual, so an uncalled reader there is rows a consumer can bind to for nothing. --- CHANGELOG.md | 14 ++++ .../src/flows/agent_worktrees.rs | 12 +-- rust/devlaunch-core/src/flows/lifecycle.rs | 81 ++++++++++++++++--- rust/devlaunch-core/src/lib.rs | 3 +- .../tests/api_removal_is_self_sufficient.rs | 31 ++++--- rust/dl/src/render.rs | 43 ++++------ 6 files changed, 129 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0059b021..8d457c5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -116,6 +116,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 read, the acting pass acts only on units whose registrations the plan named, and the forget only ever follows a directory removal that completed. + **`api::RemovalRefused` changes shape, and it is a break.** It was two arms + that each named one thing -- `WouldLose` or `CouldNotTell` -- and a clone can + be both at once: a dirty tree beside a reachability probe that was refused, or + a nested agent worktree's loss beside the clone's own. Picking one to report + was telling half the truth. It is now a `workspace_id` and a + `RemovalGrounds`, which has an arm for each and a third for both. + + `RemovalGrounds` is made of `String`. The standing that decides it stays + inside `flows` and is rendered at the seam, the way the `--ls --json` payload + is rendered for the wire, because a promised type carrying + `agent_worktrees::Standing` would name a type the promise does not include -- + and promising it honestly would pull most of that module's vocabulary into the + one tier whose worth is being small and stable. + Gitignored content is deliberately **not** weighed, at either scope. An installed `.pixi/envs/default` is ignored content and is what makes these directories worth reclaiming at all, so weighing it would have put the whole of diff --git a/rust/devlaunch-core/src/flows/agent_worktrees.rs b/rust/devlaunch-core/src/flows/agent_worktrees.rs index 0f28908b..292e3014 100644 --- a/rust/devlaunch-core/src/flows/agent_worktrees.rs +++ b/rust/devlaunch-core/src/flows/agent_worktrees.rs @@ -972,12 +972,12 @@ impl Standing { parts.join(" and ") } - /// Whether anything in here is an unproved rather than a proved loss — what - /// decides between "push or commit it" and "look at it" in a refusal. - pub fn any_unproved(&self) -> bool { - self.iter() - .any(|reason| matches!(reason, Reason::CouldNotProve { .. })) - } + // `any_unproved` lived here and is deleted rather than kept for symmetry. + // It was written for the refusal's wording and never called: the render + // sites match on which words exist, which answers the same question without + // asking a second one that could disagree. `Standing` is in the binary + // surface residual, so an uncalled reader there is rows a consumer can bind + // to for nothing (devlaunch#531). } fn located(words: &str, at: &Place) -> String { diff --git a/rust/devlaunch-core/src/flows/lifecycle.rs b/rust/devlaunch-core/src/flows/lifecycle.rs index ff122075..94be07c0 100644 --- a/rust/devlaunch-core/src/flows/lifecycle.rs +++ b/rust/devlaunch-core/src/flows/lifecycle.rs @@ -1041,18 +1041,75 @@ impl Insisted { /// Why `dl rm` will not delete this workspace. /// -/// Carries the verdict's whole standing rather than one arm of it, because a -/// clone can hold work *and* have a question that could not be put — a dirty -/// tree beside a refused reachability probe, or unsaved work in a nested agent -/// worktree beside the clone's own — and a refusal that named one would be -/// telling half the truth (devlaunch#446). "Could not be proved" is refused for -/// not knowing: the work is still on disk and nothing has shown it exists -/// anywhere else, which is the same standing as unpushed work and gets the same -/// refusal and the same way past it (devlaunch#171). +/// **The standing is rendered into words here rather than carried, and that is +/// the seam doing its job.** This type is re-exported at [`crate::api`], so +/// every type it names is part of the promise. Carrying +/// [`agent_worktrees::Standing`] would name a type the promise does not +/// include — the defect the comment beside that re-export already records — +/// and promising it honestly would drag `StandingSite`, `Reason`, `Place`, +/// `Blank`, `Subject` and `NonEmpty` along with it, which is most of a +/// module's internal vocabulary arriving in the one tier whose value is being +/// small and stable. So the standing stays exactly as it is inside `flows`, +/// and what crosses the promise is [`RemovalGrounds`]: the same words, made of +/// `String`. It is the move [`agent_worktrees::Verdict::unsaved_json`] makes +/// for the wire, at the same boundary and for the same reason (devlaunch#531). +/// +/// Nothing about the modelling changed. #446's "a refusal carries the whole +/// standing" is still true of the domain type, and still true here: +/// [`RemovalGrounds`] +/// has an arm for holding work *and* having a question that could not be put, +/// so a refusal still never has to pick one of two true things to say. +/// "Could not be proved" is refused for not knowing: the work is still on disk +/// and nothing has shown it exists anywhere else, which is the same standing as +/// unpushed work and gets the same refusal and the same way past it +/// (devlaunch#171). #[derive(Debug, Clone, PartialEq, Eq)] pub struct RemovalRefused { pub workspace_id: String, - pub standing: Standing, + pub because: RemovalGrounds, +} + +/// What a refusal has to say, in the words it will be said in. +/// +/// **Three arms and not two `Option`s.** A standing is non-empty by +/// construction and every reason in it is either a proved loss or an unproved, +/// so "neither" cannot happen — and a pair of options is a type in which it can. +/// Both render sites used to match the pair and carry a fourth arm apologising +/// for being unreachable; this is that arm deleted rather than commented. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RemovalGrounds { + /// Work that exists nowhere else, and every question was answered. + WouldLose(String), + /// No proved loss, but a question that could not be put — which is refused + /// for not knowing rather than waved through. + CouldNotTell(String), + /// Both, which is the case that makes this a sum and not a choice. + BothAtOnce { + would_lose: String, + could_not_tell: String, + }, +} + +/// Render one standing into the words that cross the promise. +/// +/// Deliberately not a method on [`RemovalGrounds`] and not `pub`: a public +/// constructor taking a [`Standing`] would put that type back in the promised +/// tier's signature list, which is the whole thing this seam exists to avoid. +/// The conversion belongs to the boundary, so it lives at the boundary. +fn refusal_from(standing: &Standing) -> RemovalGrounds { + match (standing.would_lose(), standing.could_not_tell()) { + (Some(would_lose), Some(could_not_tell)) => RemovalGrounds::BothAtOnce { + would_lose, + could_not_tell, + }, + (Some(would_lose), None) => RemovalGrounds::WouldLose(would_lose), + (None, Some(could_not_tell)) => RemovalGrounds::CouldNotTell(could_not_tell), + // Unreachable: a standing is non-empty and each reason answers one of + // the two. Rendering the whole thing is the honest fallback -- it says + // what the reasons say, rather than inventing a sentence or panicking + // on a path a caller cannot trigger. + (None, None) => RemovalGrounds::CouldNotTell(standing.describe()), + } } /// What the guard decided. @@ -1086,7 +1143,7 @@ pub(crate) fn guard_removal( Verdict::Collectable(_) => return Guarded::MayRemove, Verdict::Stands(standing) => RemovalRefused { workspace_id: workspace_id.to_owned(), - standing, + because: refusal_from(&standing), }, }; match insistence { @@ -6048,7 +6105,7 @@ mod tests { guarded, Guarded::Refused(RemovalRefused { workspace_id: "ws".to_owned(), - standing + because: refusal_from(&standing) }) ); } @@ -6075,7 +6132,7 @@ mod tests { guarded, Guarded::Refused(RemovalRefused { workspace_id: "ws".to_owned(), - standing + because: refusal_from(&standing) }) ); } diff --git a/rust/devlaunch-core/src/lib.rs b/rust/devlaunch-core/src/lib.rs index ace2ef5b..2a8345ae 100644 --- a/rust/devlaunch-core/src/lib.rs +++ b/rust/devlaunch-core/src/lib.rs @@ -212,7 +212,8 @@ pub mod api { // store is a parameter of the removal, so it is promised with it. pub use crate::flows::kept_copies::KeptCopies; pub use crate::flows::lifecycle::{ - DeleteStalled, Insistence, LifecycleNotice, Removal, RemovalRefused, RemoveOutcome, + DeleteStalled, Insistence, LifecycleNotice, Removal, RemovalGrounds, RemovalRefused, + RemoveOutcome, }; pub use crate::flows::records::Records; pub use crate::flows::workspace_clone::WorkspaceCloneManager; diff --git a/rust/devlaunch-core/tests/api_removal_is_self_sufficient.rs b/rust/devlaunch-core/tests/api_removal_is_self_sufficient.rs index a289d549..b4c79019 100644 --- a/rust/devlaunch-core/tests/api_removal_is_self_sufficient.rs +++ b/rust/devlaunch-core/tests/api_removal_is_self_sufficient.rs @@ -27,8 +27,8 @@ use std::sync::{Mutex, MutexGuard}; use devlaunch_core::api::{ ColdPath, CommandContext, DeleteStalled, DevpodHome, KeptCopies, LifecycleNotice, Notices, - Records, RecordsNotice, Refresh, Removal, RemovalRefused, RemoveOutcome, SelfInvocation, - workspace_remove, + Records, RecordsNotice, Refresh, Removal, RemovalGrounds, RemovalRefused, RemoveOutcome, + SelfInvocation, workspace_remove, }; use devlaunch_core::runner::{ CapturedText, DetachOutcome, Invocation, Outcome, ProcessRunner, Runner, SpawnSpec, @@ -60,18 +60,31 @@ fn a_guarded_removal_refuses_over_unsaved_work_and_never_asks_devpod() { let RemoveOutcome::Refused(RemovalRefused { workspace_id, - standing, + because, }) = outcome else { panic!("expected a refusal that names what would be lost, got {outcome:?}"); }; assert_eq!(workspace_id, WORKSPACE); - // The refusal carries the whole standing rather than one arm of it, because - // a clone can hold work *and* have a question that could not be put, and a - // refusal naming one would be telling half the truth (devlaunch#446). - let lost = standing - .would_lose() - .expect("a proved loss, not an unproved"); + // **Destructured rather than read through a method, and that is this file's + // whole point.** Every type named here is reached through + // `devlaunch_core::api` and nothing else, so a promised shape carrying a + // type the promise does not include stops compiling right here instead of + // shipping (devlaunch#531). `RemovalGrounds` is made of `String`, which is + // why this reads with nothing imported from `flows`. + // + // Three arms and not two options: a standing is non-empty and every reason + // in it is either a proved loss or an unproved, so "neither" cannot happen + // and the type does not admit it. `BothAtOnce` is #446 surviving the + // flattening -- a clone can hold work *and* have a question that could not + // be put, and a refusal still never picks one of two true things. + let lost = match &because { + RemovalGrounds::WouldLose(lost) => lost, + RemovalGrounds::BothAtOnce { would_lose, .. } => would_lose, + RemovalGrounds::CouldNotTell(blank) => { + panic!("a dirty tree is a proved loss, not an unproved: {blank}") + } + }; assert!( lost.contains("an-hour-of-work.md"), "the refusal has to carry what would be lost, not a count: {lost}" diff --git a/rust/dl/src/render.rs b/rust/dl/src/render.rs index c8071a6e..8b2ab8aa 100644 --- a/rust/dl/src/render.rs +++ b/rust/dl/src/render.rs @@ -37,8 +37,8 @@ use devlaunch_core::flows::launch::{ }; use devlaunch_core::flows::lifecycle::{ Insistence, KeptBecause, LifecycleNotice, NotAdopted, Promotion, PrunePlan, PruneReport, - PurgeOutcome, PurgePlan, PurgeStep, ReconcilePlan, RemovalRefused, SweepOccasion, Unlocatable, - VolumeRefusal, VolumesKeptBecause, + PurgeOutcome, PurgePlan, PurgeStep, ReconcilePlan, RemovalGrounds, RemovalRefused, + SweepOccasion, Unlocatable, VolumeRefusal, VolumesKeptBecause, }; use devlaunch_core::flows::listing::{ self, CloneDisk, LastUsed, SizeCell, Sizes, SourceKind, SweptRepoNote, TableRow, WorkspaceTable, @@ -1204,31 +1204,24 @@ pub(crate) fn unsafe_name(refused: &UnsafeName) -> String { /// hand. Both words are offered the way past *they* asked for. pub(crate) fn removal_refusal(refused: &RemovalRefused, spec: &str, word: &str) -> String { let workspace_id = &refused.workspace_id; - match ( - refused.standing.would_lose(), - refused.standing.could_not_tell(), - ) { - (Some(holds), None) => format!( + match &refused.because { + RemovalGrounds::WouldLose(holds) => format!( "{workspace_id} holds {holds}. Push or commit it, or run: dl {spec} {word} --force" ), - (None, Some(blank)) => format!( + RemovalGrounds::CouldNotTell(blank) => format!( "{workspace_id}: {blank}. devlaunch will not delete a clone it cannot check. Look \ at it, or run: dl {spec} {word} --force" ), // Both at once -- a dirty tree beside a refused probe, or a nested // worktree's loss beside a lock. Saying one would be telling half the // truth, so both are said (devlaunch#446). - (Some(holds), Some(blank)) => format!( + RemovalGrounds::BothAtOnce { + would_lose: holds, + could_not_tell: blank, + } => format!( "{workspace_id} holds {holds}, and {blank}. devlaunch will not delete a clone it \ cannot check. Push or commit it, look at it, or run: dl {spec} {word} --force" ), - // A standing is non-empty by construction, so one of the arms above - // said something; this arm exists for the match to be total and points - // the reader at the raw reasons rather than inventing a sentence. - (None, None) => format!( - "{workspace_id}: {}. Run: dl {spec} {word} --force", - refused.standing.describe() - ), } } @@ -1256,25 +1249,21 @@ pub(crate) fn rm_on_exit_removing(spec: &str) -> String { /// line before the delete, and the workspace is gone by the next one. pub(crate) fn removing_over_work(refused: &RemovalRefused) -> String { let workspace_id = &refused.workspace_id; - match ( - refused.standing.would_lose(), - refused.standing.could_not_tell(), - ) { - (Some(holds), None) => { + match &refused.because { + RemovalGrounds::WouldLose(holds) => { format!("{workspace_id} holds {holds}, and kill is deleting it anyway.") } - (None, Some(blank)) => format!( + RemovalGrounds::CouldNotTell(blank) => format!( "{workspace_id}: {blank}, so there may be work here that is nowhere else. kill is \ deleting it anyway." ), - (Some(holds), Some(blank)) => format!( + RemovalGrounds::BothAtOnce { + would_lose: holds, + could_not_tell: blank, + } => format!( "{workspace_id} holds {holds}, and {blank}, so there may be more here that is \ nowhere else. kill is deleting it anyway." ), - (None, None) => format!( - "{workspace_id}: {}. kill is deleting it anyway.", - refused.standing.describe() - ), } } From 3484cc852132ccbf8c25accd0def44913de2466b Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Sat, 29 Aug 2026 22:38:19 +0100 Subject: [PATCH 7/7] Regenerate both snapshots, and say why the residual count fell The promised tier is self-sufficient again: zero `agent_worktrees` rows in `public-api.api.txt`, and `RemovalGrounds` is defined there rather than merely named, every leaf an `alloc::string::String`. The residual goes 37 to 36 and `Standing` is what left it. That is a different event from the last drop and the better one: it left because the promise stopped reaching it, not because the promise swallowed it. Both look like a smaller number, so `docs/development.md` now says how to tell them apart -- promoting `Standing` would have moved the count the same way while dragging `StandingSite`, `Reason`, `Place`, `Blank` and `Subject` into the tier that is supposed to stay small. Also drops the row for `Standing::any_unproved`, deleted in the previous commit. --- docs/development.md | 10 +++++++- rust/devlaunch-core/public-api.api.txt | 32 +++++++++++++++++++++++-- rust/devlaunch-core/public-api.rest.txt | 1 - rust/devlaunch-core/src/lib.rs | 2 +- scripts/public-api-snapshots.sh | 2 +- 5 files changed, 41 insertions(+), 6 deletions(-) diff --git a/docs/development.md b/docs/development.md index 32a1e464..f9b525f0 100644 --- a/docs/development.md +++ b/docs/development.md @@ -69,9 +69,17 @@ once under the `api` section and once under the module that owns them. **What it still does not reach, and it is not one type.** A type `api` never re-exports but a promised signature hands back is reachable from outside and classified as binary surface. Counted -on the checked-in files rather than guessed at, that is **37 types owning close to six hundred rows** in +on the checked-in files rather than guessed at, that is **36 types owning close to six hundred rows** in `public-api.rest.txt`, and the command that lists them needs no toolchain: +**A falling count is not automatically a win, and it is worth knowing which kind you are looking +at.** A type leaves this list either because the promise stopped reaching it or because the promise +swallowed it, and those are opposite events wearing the same number. `agent_worktrees::Standing` +left it the first way: a promised refusal used to carry the type itself, and now carries words +rendered at the seam, so the promise reaches less than it did. Had it been promoted instead the +count would also have fallen, while `StandingSite`, `Reason`, `Place`, `Blank` and `Subject` came +with it into the tier that is supposed to stay small. Read a drop by asking which happened. + ```bash scripts/public-api-snapshots.sh --print-residual ``` diff --git a/rust/devlaunch-core/public-api.api.txt b/rust/devlaunch-core/public-api.api.txt index 3fc5693c..c3a7a588 100644 --- a/rust/devlaunch-core/public-api.api.txt +++ b/rust/devlaunch-core/public-api.api.txt @@ -188,6 +188,20 @@ impl core::fmt::Debug for devlaunch_core::flows::lifecycle::Removal pub fn devlaunch_core::flows::lifecycle::Removal::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::marker::Copy for devlaunch_core::flows::lifecycle::Removal impl core::marker::StructuralPartialEq for devlaunch_core::flows::lifecycle::Removal +pub enum devlaunch_core::api::RemovalGrounds +pub devlaunch_core::api::RemovalGrounds::BothAtOnce +pub devlaunch_core::api::RemovalGrounds::BothAtOnce::could_not_tell: alloc::string::String +pub devlaunch_core::api::RemovalGrounds::BothAtOnce::would_lose: alloc::string::String +pub devlaunch_core::api::RemovalGrounds::CouldNotTell(alloc::string::String) +pub devlaunch_core::api::RemovalGrounds::WouldLose(alloc::string::String) +impl core::clone::Clone for devlaunch_core::flows::lifecycle::RemovalGrounds +pub fn devlaunch_core::flows::lifecycle::RemovalGrounds::clone(&self) -> devlaunch_core::flows::lifecycle::RemovalGrounds +impl core::cmp::Eq for devlaunch_core::flows::lifecycle::RemovalGrounds +impl core::cmp::PartialEq for devlaunch_core::flows::lifecycle::RemovalGrounds +pub fn devlaunch_core::flows::lifecycle::RemovalGrounds::eq(&self, &devlaunch_core::flows::lifecycle::RemovalGrounds) -> bool +impl core::fmt::Debug for devlaunch_core::flows::lifecycle::RemovalGrounds +pub fn devlaunch_core::flows::lifecycle::RemovalGrounds::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::lifecycle::RemovalGrounds pub enum devlaunch_core::api::RemoveOutcome pub devlaunch_core::api::RemoveOutcome::Deleted pub devlaunch_core::api::RemoveOutcome::Deleted::clone: core::result::Result @@ -340,7 +354,7 @@ pub fn devlaunch_core::flows::lifecycle::Refresh<'a>::new(&'a devlaunch_core::fl pub fn devlaunch_core::flows::lifecycle::Refresh<'a>::rearm(&mut self) pub fn devlaunch_core::flows::lifecycle::Refresh<'a>::spawned(&self) -> bool pub struct devlaunch_core::api::RemovalRefused -pub devlaunch_core::api::RemovalRefused::standing: devlaunch_core::flows::agent_worktrees::Standing +pub devlaunch_core::api::RemovalRefused::because: devlaunch_core::flows::lifecycle::RemovalGrounds pub devlaunch_core::api::RemovalRefused::workspace_id: alloc::string::String impl core::clone::Clone for devlaunch_core::flows::lifecycle::RemovalRefused pub fn devlaunch_core::flows::lifecycle::RemovalRefused::clone(&self) -> devlaunch_core::flows::lifecycle::RemovalRefused @@ -675,6 +689,20 @@ impl core::fmt::Debug for devlaunch_core::flows::lifecycle::Removal pub fn devlaunch_core::flows::lifecycle::Removal::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::marker::Copy for devlaunch_core::flows::lifecycle::Removal impl core::marker::StructuralPartialEq for devlaunch_core::flows::lifecycle::Removal +pub enum devlaunch_core::flows::lifecycle::RemovalGrounds +pub devlaunch_core::flows::lifecycle::RemovalGrounds::BothAtOnce +pub devlaunch_core::flows::lifecycle::RemovalGrounds::BothAtOnce::could_not_tell: alloc::string::String +pub devlaunch_core::flows::lifecycle::RemovalGrounds::BothAtOnce::would_lose: alloc::string::String +pub devlaunch_core::flows::lifecycle::RemovalGrounds::CouldNotTell(alloc::string::String) +pub devlaunch_core::flows::lifecycle::RemovalGrounds::WouldLose(alloc::string::String) +impl core::clone::Clone for devlaunch_core::flows::lifecycle::RemovalGrounds +pub fn devlaunch_core::flows::lifecycle::RemovalGrounds::clone(&self) -> devlaunch_core::flows::lifecycle::RemovalGrounds +impl core::cmp::Eq for devlaunch_core::flows::lifecycle::RemovalGrounds +impl core::cmp::PartialEq for devlaunch_core::flows::lifecycle::RemovalGrounds +pub fn devlaunch_core::flows::lifecycle::RemovalGrounds::eq(&self, &devlaunch_core::flows::lifecycle::RemovalGrounds) -> bool +impl core::fmt::Debug for devlaunch_core::flows::lifecycle::RemovalGrounds +pub fn devlaunch_core::flows::lifecycle::RemovalGrounds::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::lifecycle::RemovalGrounds pub enum devlaunch_core::flows::lifecycle::RemoveOutcome pub devlaunch_core::flows::lifecycle::RemoveOutcome::Deleted pub devlaunch_core::flows::lifecycle::RemoveOutcome::Deleted::clone: core::result::Result @@ -697,7 +725,7 @@ pub fn devlaunch_core::flows::lifecycle::Refresh<'a>::new(&'a devlaunch_core::fl pub fn devlaunch_core::flows::lifecycle::Refresh<'a>::rearm(&mut self) pub fn devlaunch_core::flows::lifecycle::Refresh<'a>::spawned(&self) -> bool pub struct devlaunch_core::flows::lifecycle::RemovalRefused -pub devlaunch_core::flows::lifecycle::RemovalRefused::standing: devlaunch_core::flows::agent_worktrees::Standing +pub devlaunch_core::flows::lifecycle::RemovalRefused::because: devlaunch_core::flows::lifecycle::RemovalGrounds pub devlaunch_core::flows::lifecycle::RemovalRefused::workspace_id: alloc::string::String impl core::clone::Clone for devlaunch_core::flows::lifecycle::RemovalRefused pub fn devlaunch_core::flows::lifecycle::RemovalRefused::clone(&self) -> devlaunch_core::flows::lifecycle::RemovalRefused diff --git a/rust/devlaunch-core/public-api.rest.txt b/rust/devlaunch-core/public-api.rest.txt index dcc26afd..fcf78727 100644 --- a/rust/devlaunch-core/public-api.rest.txt +++ b/rust/devlaunch-core/public-api.rest.txt @@ -893,7 +893,6 @@ pub fn devlaunch_core::flows::agent_worktrees::RemovedWorktree::fmt(&self, &mut impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::RemovedWorktree pub struct devlaunch_core::flows::agent_worktrees::Standing impl devlaunch_core::flows::agent_worktrees::Standing -pub fn devlaunch_core::flows::agent_worktrees::Standing::any_unproved(&self) -> bool pub fn devlaunch_core::flows::agent_worktrees::Standing::could_not_tell(&self) -> core::option::Option pub fn devlaunch_core::flows::agent_worktrees::Standing::describe(&self) -> alloc::string::String pub fn devlaunch_core::flows::agent_worktrees::Standing::would_lose(&self) -> core::option::Option diff --git a/rust/devlaunch-core/src/lib.rs b/rust/devlaunch-core/src/lib.rs index 2a8345ae..ed6df181 100644 --- a/rust/devlaunch-core/src/lib.rs +++ b/rust/devlaunch-core/src/lib.rs @@ -66,7 +66,7 @@ //! **What it still does not reach, and it is not one type.** A type [`api`] //! never re-exports but a promised signature names is reachable from outside //! and classified as binary surface, so a break in it diffs -//! `public-api.rest.txt` alone. Counted rather than guessed at: **37 such types +//! `public-api.rest.txt` alone. Counted rather than guessed at: **36 such types //! own close to six hundred rows over there**, and `scripts/public-api-snapshots.sh //! --print-residual` lists them. //! diff --git a/scripts/public-api-snapshots.sh b/scripts/public-api-snapshots.sh index 952f24c0..ce6ba0b2 100755 --- a/scripts/public-api-snapshots.sh +++ b/scripts/public-api-snapshots.sh @@ -37,7 +37,7 @@ # The limit that is left, and it is not one type. A type `api` never re-exports # but a promised signature names is reachable from outside and is classified as # binary surface, so a break in it diffs public-api.rest.txt alone. Measured on -# the checked-in files rather than guessed at: 37 such types own close to six +# the checked-in files rather than guessed at: 36 such types own close to six # hundred rows over there. `--print-residual` lists them, needs no toolchain, # and prints the exact row count; the type count above is the figure # `test/test_public_api_snapshots_doc.py` diffs against it, because that is the