diff --git a/CHANGELOG.md b/CHANGELOG.md index 9de844d..8d457c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -90,6 +90,62 @@ 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, 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 + 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 + 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 a9639bf..e5a0f68 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 8bc5090..4e9fb11 100644 --- a/docs/cleanup.md +++ b/docs/cleanup.md @@ -387,6 +387,173 @@ 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. **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. + +**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. + +`--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: 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 Both commands end on the same line, in the same words: diff --git a/docs/development.md b/docs/development.md index dbcf4d5..f9b525f 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 **39 types owning over 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 29d11c9..c3a7a58 100644 --- a/rust/devlaunch-core/public-api.api.txt +++ b/rust/devlaunch-core/public-api.api.txt @@ -188,21 +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::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::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 @@ -354,6 +353,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::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 +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 +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::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::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 @@ -715,6 +724,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::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 +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 diff --git a/rust/devlaunch-core/public-api.rest.txt b/rust/devlaunch-core/public-api.rest.txt index b95d57f..fcf7872 100644 --- a/rust/devlaunch-core/public-api.rest.txt +++ b/rust/devlaunch-core/public-api.rest.txt @@ -645,6 +645,325 @@ 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::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 +1498,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 +1526,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 +1729,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 +1754,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 +1776,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 +1904,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 +1924,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 +1973,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 diff --git a/rust/devlaunch-core/src/clients/git.rs b/rust/devlaunch-core/src/clients/git.rs index 2d773be..53f33e8 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,146 @@ 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. + /// + /// **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 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 + /// `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(), + ]; + 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 4eeba4f..29ba2c1 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 f1042b4..fc2aede 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 0000000..292e301 --- /dev/null +++ b/rust/devlaunch-core/src/flows/agent_worktrees.rs @@ -0,0 +1,2324 @@ +//! 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::cell::RefCell; +use std::collections::HashMap; +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. + /// + /// **`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| std::fs::symlink_metadata(clone.join(&it.inside.place)).is_err()) + .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 { + /// 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, + } + } + + /// 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, + // 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 + // 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 ") + } + + // `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 { + 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::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(), + } + } +} + +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), + /// 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, +} + +// =========================================================================== +// 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>, + /// 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<'_, '_> { + /// 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 { + 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 => { + 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. + /// + /// **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 + /// 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** 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("?? ") 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 + } + + /// 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), + 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, + reachability: RefCell::new(HashMap::new()), + }; + 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. +/// +/// **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 +/// 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; + }; + 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 { + 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. +/// +/// 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) => { + match remove_tree_as_far_as_it_goes(&directory.at) { + TreeSweep::Everything => { + report.removed.push(RemovedWorktree { + path: directory.at.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); + } + 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`. +/// +/// **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. +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 0000000..a218d67 --- /dev/null +++ b/rust/devlaunch-core/src/flows/agent_worktrees/tests.rs @@ -0,0 +1,1408 @@ +//! 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_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"); + 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_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] +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_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. + // 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_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 + // 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 91c4069..94be07c 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,27 +1009,109 @@ 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. +/// **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 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 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. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum Guarded { @@ -1043,29 +1126,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, + because: refusal_from(&standing), }, }; match insistence { @@ -1114,11 +1192,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 +1208,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 +2292,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 +2299,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 +2394,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 +2407,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 +2430,7 @@ pub enum KeptBecause { #[derive(Debug, Clone, PartialEq, Eq)] pub enum Promotion { Unopposed, - Insisted { despite: Objection }, + Insisted { despite: Standing }, } impl Promotion { @@ -2421,17 +2476,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 +2585,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 +2637,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 +2741,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 +2784,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 +2792,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 +2830,7 @@ pub fn prune_plan( keeping, stale_records, reclaiming: reclaimable_volumes(copies, workspaces), + worktrees, }) } @@ -2871,18 +2963,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 +3052,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 +3105,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 +3704,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 +4141,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 +4162,7 @@ mod tests { &workspaces, &world.copies(), &placement, - insistence, + insisted, &mut ignoring(), ) .expect("a plan") @@ -5932,60 +6063,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 + because: refusal_from(&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 + because: refusal_from(&standing) }) ); } @@ -5993,23 +6140,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 +6179,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 +6224,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 +6268,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 +6288,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 +6306,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 +7523,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 +7554,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 +7622,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 +7641,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 +7693,7 @@ mod tests { &workspaces, &KeptCopies::under(dir.path()), &placement, - Insistence::NotInsisted, + Insisted::nothing(), &mut ignoring(), ) .expect("a plan"); @@ -7641,7 +7808,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 +8435,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 271e281..98f43c6 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 f806d9a..8e5c2ff 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/src/lib.rs b/rust/devlaunch-core/src/lib.rs index 5bc77a3..ed6df18 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: **36 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 @@ -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 5a556ba..b4c7901 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, @@ -58,18 +58,36 @@ 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, + because, }) = outcome else { panic!("expected a refusal that names what would be lost, got {outcome:?}"); }; assert_eq!(workspace_id, WORKSPACE); + // **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!( - 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 be11d54..15a67d8 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 c912fa8..7770a0d 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 b07763a..60d1776 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 2f1fe2b..8b2ab8a 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, RemovalGrounds, 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,24 @@ 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.because { + RemovalGrounds::WouldLose(holds) => format!( + "{workspace_id} holds {holds}. Push or commit it, or run: dl {spec} {word} --force" ), - RemovalRefused::CouldNotTell { - workspace_id, - cause, + 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). + RemovalGrounds::BothAtOnce { + would_lose: holds, + could_not_tell: blank, } => format!( - "{workspace_id}: {}. devlaunch will not delete a clone it cannot check. Look at it, \ - or run: dl {spec} {word} --force", - cause.describe() + "{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" ), } } @@ -1224,21 +1248,21 @@ 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.because { + RemovalGrounds::WouldLose(holds) => { + format!("{workspace_id} holds {holds}, and kill is deleting it anyway.") + } + RemovalGrounds::CouldNotTell(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, + RemovalGrounds::BothAtOnce { + would_lose: holds, + could_not_tell: blank, } => format!( - "{workspace_id}: {}, so there may be work here that is nowhere else. kill is deleting \ - it anyway.", - cause.describe() + "{workspace_id} holds {holds}, and {blank}, so there may be more here that is \ + nowhere else. kill is deleting it anyway." ), } } @@ -1974,7 +1998,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 +2010,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 +2056,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 +2225,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 +2258,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 +2300,7 @@ pub(crate) fn prune_report_lines(report: &PruneReport) -> Vec { &by_hand, )); } + lines.extend(worktree_report_lines(&report.worktrees)); lines } @@ -2971,7 +3127,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 +3522,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 318172f..df917d2 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 e679ed9..8276773 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}" diff --git a/scripts/public-api-snapshots.sh b/scripts/public-api-snapshots.sh index e9e1938..ce6ba0b 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: 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