diff --git a/README.md b/README.md index 29ee8df0..779abe75 100644 --- a/README.md +++ b/README.md @@ -222,7 +222,7 @@ instead. [docs/cli.md](docs/cli.md) has the full `--rm` contract, including whic | `dl --ls` | List every workspace | | `dl --ls --json` | The same, machine-readable, with what each workspace would lose if deleted | | `dl --ls --size` | Add what deleting each one would free. Opt-in: it walks every file | -| `dl --prune` | Remove the clone directories no workspace opens any more | +| `dl --prune` | Remove the clone directories no workspace opens any more, and the agent git worktrees inside the clones it keeps | | `dl --reconcile` | Re-point workspaces whose recorded source folder went missing. Deletes nothing | | `dl --purge` | Remove devlaunch's own workspaces and caches | | `dl --install` | Install shell completions | @@ -232,6 +232,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 @@ -321,7 +324,7 @@ different jobs: | Command | Takes | Leaves | |---|---|---| -| `dl --prune` | Clone directories no workspace opens | Every workspace, container, image and volume | +| `dl --prune` | Clone directories no workspace opens, and collectable agent git worktrees inside the ones it keeps | Every workspace, container, image and volume | | `dl --purge` | The workspaces devlaunch created, and its caches | Workspaces it did not create, named before it asks | | `dl --reconcile` | Nothing | Repairs records that stopped matching the disk | @@ -331,6 +334,12 @@ uncommitted or unpushed changes, or one git cannot read to find out, is kept and because that is a fact about a ticket or somebody's intent. It reports what exists and what each one holds, via `dl --ls --json`, and leaves the choosing to you or to a tool that knows. +An agent harness working inside a workspace makes its own git worktrees under the clone, and +nothing used to collect them. `dl --ls --size` now says how much of a clone is worktrees, and +`--prune` reaches inside the clones it keeps to reclaim the ones that are finished. +[docs/cleanup.md](docs/cleanup.md) has the rules, the measurements, and what each refusal is +asserting. + Deleting a workspace takes its clone and the named Docker volumes its devcontainer created. Images are yours: `docker system df` is what shows those. diff --git a/docs/cleanup.md b/docs/cleanup.md index ede0e72f..e7891d26 100644 --- a/docs/cleanup.md +++ b/docs/cleanup.md @@ -245,6 +245,138 @@ It also drops the `metadata.json` records of directories that are already gone. That file was append-only in practice, 49 records for 17 live workspaces on the same host, and this is the first thing that prunes it. +#### The agent worktrees inside a clone it keeps + +An agent harness working inside a workspace makes its own git worktrees under +`/.claude/worktrees//`, one per task, and nothing ever collected +them. Measured on one host: **72 of them, 104.5 GB, 18 carrying a whole +`.pixi/envs/default`, about 82% of everything under `repos/`.** One clone held 55 +GB on its own. Every one of them was inside a clone belonging to a **live** +workspace, so the rule above not only missed them, it must never fire on them: +firing would delete a live workspace's checkout. So this is a second rule, and it +runs only on the clones the first one is keeping. A clone that is going already +accounts for everything inside it. + +The word "worktree" is git's here, not `dl`'s. These are real registered +worktrees, made from inside the container, so the path git holds for one is +`/workspaces//.claude/worktrees/`, which does not resolve on the host +at all. That is why `git worktree remove` is no use from outside: `dl` removes +the directory and then runs `git worktree prune` in the clone, in that order, so +a run interrupted between the two leaves git holding a registration whose +directory is gone, which is precisely the state the next run already handles. + +A directory found there is one of four things: + +- **git has already forgotten it.** No registration names it and the directory is + all that is left. Removed. +- **git says the registration can go.** Which on a host is what a + container-registered worktree looks like, because the path it names is not + there. Removed, unless it holds something. +- **git is holding it locked.** Reported and left alone. `--force-worktrees` is + what removes one. +- **git still holds it and does not offer it up.** Kept, always. This is the arm + that stops a run *inside* a container, where the registered paths do resolve, + from collecting its own live worktrees. + +Removing one is refused for the same two things a clone's removal is refused +for, asked of the worktree rather than of the clone: uncommitted or untracked +work in it, and commits nothing else reaches. The nested agent worktrees inside a +worktree are the one thing left out of the first question, because they are what +this sweep reasons about separately and a worktree holding one would otherwise +read dirty forever. They are left out by *being* worktrees, not by where they sit: +content under a `.claude/worktrees/` that is not a worktree is somebody's, and a +tracked file modified there is an edit like any other. Both matter. A worktree on a +fully-merged branch with an afternoon of unstaged edits in it reads as finished +if you only ask about commits, and a worktree on no branch at all has no branch +for a branch-keyed question to find. `--force-worktrees` is the one flag that +carries a worktree past any of this, and it is deliberately not `--force`: +`--force` is a word people already type at `--prune`, and widening it would turn +it into permission to remove a worktree somebody may be working in. + +Two things the report is careful about. **It never claims a worktree is idle**, +because nothing on a host can establish that: a lock is the harness's courtesy, +a killed session leaves one behind, and a live session that never took one looks +exactly like an abandoned directory. Each line says the fact it rests on and +nothing more. And **whether commits are anywhere else is as of the last fetch**. +The question is asked of the sibling `.bare` cache first, because that is the +repository `dl` actually fetches into: a workspace clone is cut from the bare and +then has its remote repointed at the forge with no fetch of its own, so its +`refs/remotes/origin/*` is as of clone time and asking it alone reports +pushed-and-merged branches as unpushed. `--prune` does not fetch, and the report +says so rather than implying a live answer. + +``` +$ dl --prune +Clone directories under /home/you/.cache/devlaunch/repos: + +Leaving 1: + - /home/you/.cache/devlaunch/repos/blooop/devlaunch/devlaunch-main-zovomobo: workspace devlaunch-main-zovomobo still opens it + +Agent git worktrees inside the clones above -- 6.0 GiB: + + /home/you/.cache/devlaunch/repos/blooop/devlaunch/devlaunch-main-zovomobo: + - removing .../.claude/worktrees/agent-a49a (5.8 GiB): git says the registration for it can go + - removing .../.claude/worktrees/agent-a8da (204.0 MiB): git has already forgotten it + - leaving .../.claude/worktrees/agent-b120: git is holding it locked -- add --force-worktrees to remove it anyway + +Whether a worktree's commits are anywhere else is as of the last fetch into the repository cache; --prune does not fetch. + +Are you sure? [y/N] +``` + +`git worktree prune` is held back in a clone where a worktree is being kept for +what it holds, and the report says so. That is not tidiness: the prune is +all-or-nothing across a clone, so running it would drop the registration of the +kept worktree too, turning it into a forgotten directory, which the next run +removes outright. Held back, the guard keeps working. + +**The holdback answers to what the run did, not to what the plan said it would +do.** Those two can differ, and the gap is exactly the window this command +already documents: the plan is on screen, a container writes into a worktree, and +the re-check refuses one the plan meant to remove. Ask the plan and no holdback +fires, the prune takes the refused worktree's registration with it, and the next +run reads a forgotten directory and removes it. So the two passes fold their +outcomes into one gate rather than one of them reading the other's forecast. + +For the same reason, a directory with no registration is still asked what it holds +whenever there is anything left to ask through. "git has already forgotten it" is +the one arm that removes without a probe, so it is the arm a wrong answer is most +expensive on: if the admin directory is still there, so are an index and a HEAD, +and the question gets put. With the admin directory gone there is genuinely +nothing to ask, and that is the limit rather than a choice. + +A registration with no directory behind it frees nothing, and the report says +which kind it is: a container path, which never resolved on this host and is the +ordinary shape of every worktree an agent made inside a devcontainer, or a path in +this clone with nothing at it, which is somebody's own removal or a run +interrupted between the removal and the prune. Clearing one is a `git worktree +prune` and no more, so a run does it and the run after that has nothing to say. It +is not by itself something for `--prune` to ask about: while the prune is held +back the registration is being kept on purpose, and counting it as work made the +command ask the question and do nothing, every run. + +One more thing about the ordering, for an **orphan** clone that has agent +worktrees in it: reclaiming it takes two runs. The clone-level probe counts the +worktrees' contents as uncommitted work, correctly, because removing the clone +would destroy whatever they hold. So run one keeps the clone and sweeps the +worktrees; run two finds the clone empty of them and reclaims it with no flag. + +The plan states two figures and they are two different claims: what removing the +clone directories would free, and what the worktrees inside the clones it is +keeping would free. Folding the second into the first made the headline number +describe directories that are not going, and then said the same bytes twice. + +The bytes are also attributed in `dl --ls --size`, as a part of the clone's +figure and never an addition, because the worktrees are inside it. They were +invisible there on the host above, which is how it reached 100%. + +The 18 duplicated `.pixi/envs/default` copies are the reason the figure is 104 GB +rather than about 10, and they cannot be pointed at the shared package cache: +only the pixi *download* cache is shared, because installed environments bake +absolute paths (see "The shared pixi package cache" in +[workspace-tools.md](workspace-tools.md)). Removing the worktree is the way those +bytes come back, which is what this does. + #### The disk neither command frees Both commands end on the same line, in the same words: diff --git a/rust/devlaunch-core/public-api.rest.txt b/rust/devlaunch-core/public-api.rest.txt index ae0542b8..a446b7fc 100644 --- a/rust/devlaunch-core/public-api.rest.txt +++ b/rust/devlaunch-core/public-api.rest.txt @@ -678,6 +678,199 @@ impl core::marker::StructuralPartialEq for devlaunch_core::domain::xdg::NoHomeDi pub fn devlaunch_core::domain::xdg::config_home() -> core::result::Result pub fn devlaunch_core::domain::xdg::devlaunch_cache() -> core::result::Result pub mod devlaunch_core::flows +pub mod devlaunch_core::flows::agent_worktrees +pub enum devlaunch_core::flows::agent_worktrees::SeenAs +pub devlaunch_core::flows::agent_worktrees::SeenAs::Forgotten +pub devlaunch_core::flows::agent_worktrees::SeenAs::Locked +pub devlaunch_core::flows::agent_worktrees::SeenAs::Prunable +impl core::clone::Clone for devlaunch_core::flows::agent_worktrees::SeenAs +pub fn devlaunch_core::flows::agent_worktrees::SeenAs::clone(&self) -> devlaunch_core::flows::agent_worktrees::SeenAs +impl core::cmp::Eq for devlaunch_core::flows::agent_worktrees::SeenAs +impl core::cmp::PartialEq for devlaunch_core::flows::agent_worktrees::SeenAs +pub fn devlaunch_core::flows::agent_worktrees::SeenAs::eq(&self, &devlaunch_core::flows::agent_worktrees::SeenAs) -> bool +impl core::fmt::Debug for devlaunch_core::flows::agent_worktrees::SeenAs +pub fn devlaunch_core::flows::agent_worktrees::SeenAs::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::Copy for devlaunch_core::flows::agent_worktrees::SeenAs +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::SeenAs +pub enum devlaunch_core::flows::agent_worktrees::WorktreeHead +pub devlaunch_core::flows::agent_worktrees::WorktreeHead::Branch +pub devlaunch_core::flows::agent_worktrees::WorktreeHead::Branch::commit: alloc::string::String +pub devlaunch_core::flows::agent_worktrees::WorktreeHead::Branch::reference: alloc::string::String +pub devlaunch_core::flows::agent_worktrees::WorktreeHead::Detached +pub devlaunch_core::flows::agent_worktrees::WorktreeHead::Detached::commit: alloc::string::String +impl devlaunch_core::flows::agent_worktrees::WorktreeHead +pub fn devlaunch_core::flows::agent_worktrees::WorktreeHead::named(&self) -> alloc::string::String +impl core::clone::Clone for devlaunch_core::flows::agent_worktrees::WorktreeHead +pub fn devlaunch_core::flows::agent_worktrees::WorktreeHead::clone(&self) -> devlaunch_core::flows::agent_worktrees::WorktreeHead +impl core::cmp::Eq for devlaunch_core::flows::agent_worktrees::WorktreeHead +impl core::cmp::PartialEq for devlaunch_core::flows::agent_worktrees::WorktreeHead +pub fn devlaunch_core::flows::agent_worktrees::WorktreeHead::eq(&self, &devlaunch_core::flows::agent_worktrees::WorktreeHead) -> bool +impl core::fmt::Debug for devlaunch_core::flows::agent_worktrees::WorktreeHead +pub fn devlaunch_core::flows::agent_worktrees::WorktreeHead::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::WorktreeHead +pub enum devlaunch_core::flows::agent_worktrees::WorktreeKept +pub devlaunch_core::flows::agent_worktrees::WorktreeKept::Objected(devlaunch_core::domain::workspace_state::NonEmpty) +pub devlaunch_core::flows::agent_worktrees::WorktreeKept::StillHeld +pub devlaunch_core::flows::agent_worktrees::WorktreeKept::StillHeld::head: devlaunch_core::flows::agent_worktrees::WorktreeHead +impl core::clone::Clone for devlaunch_core::flows::agent_worktrees::WorktreeKept +pub fn devlaunch_core::flows::agent_worktrees::WorktreeKept::clone(&self) -> devlaunch_core::flows::agent_worktrees::WorktreeKept +impl core::cmp::Eq for devlaunch_core::flows::agent_worktrees::WorktreeKept +impl core::cmp::PartialEq for devlaunch_core::flows::agent_worktrees::WorktreeKept +pub fn devlaunch_core::flows::agent_worktrees::WorktreeKept::eq(&self, &devlaunch_core::flows::agent_worktrees::WorktreeKept) -> bool +impl core::fmt::Debug for devlaunch_core::flows::agent_worktrees::WorktreeKept +pub fn devlaunch_core::flows::agent_worktrees::WorktreeKept::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::WorktreeKept +pub enum devlaunch_core::flows::agent_worktrees::WorktreeObjection +pub devlaunch_core::flows::agent_worktrees::WorktreeObjection::Holds(devlaunch_core::flows::lifecycle::Objection) +pub devlaunch_core::flows::agent_worktrees::WorktreeObjection::Locked +pub devlaunch_core::flows::agent_worktrees::WorktreeObjection::Locked::lock: devlaunch_core::flows::agent_worktrees::Lock +impl core::clone::Clone for devlaunch_core::flows::agent_worktrees::WorktreeObjection +pub fn devlaunch_core::flows::agent_worktrees::WorktreeObjection::clone(&self) -> devlaunch_core::flows::agent_worktrees::WorktreeObjection +impl core::cmp::Eq for devlaunch_core::flows::agent_worktrees::WorktreeObjection +impl core::cmp::PartialEq for devlaunch_core::flows::agent_worktrees::WorktreeObjection +pub fn devlaunch_core::flows::agent_worktrees::WorktreeObjection::eq(&self, &devlaunch_core::flows::agent_worktrees::WorktreeObjection) -> bool +impl core::fmt::Debug for devlaunch_core::flows::agent_worktrees::WorktreeObjection +pub fn devlaunch_core::flows::agent_worktrees::WorktreeObjection::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::WorktreeObjection +pub enum devlaunch_core::flows::agent_worktrees::WorktreePromotion +pub devlaunch_core::flows::agent_worktrees::WorktreePromotion::Insisted +pub devlaunch_core::flows::agent_worktrees::WorktreePromotion::Insisted::despite: devlaunch_core::domain::workspace_state::NonEmpty +pub devlaunch_core::flows::agent_worktrees::WorktreePromotion::Unopposed +impl core::clone::Clone for devlaunch_core::flows::agent_worktrees::WorktreePromotion +pub fn devlaunch_core::flows::agent_worktrees::WorktreePromotion::clone(&self) -> devlaunch_core::flows::agent_worktrees::WorktreePromotion +impl core::cmp::Eq for devlaunch_core::flows::agent_worktrees::WorktreePromotion +impl core::cmp::PartialEq for devlaunch_core::flows::agent_worktrees::WorktreePromotion +pub fn devlaunch_core::flows::agent_worktrees::WorktreePromotion::eq(&self, &devlaunch_core::flows::agent_worktrees::WorktreePromotion) -> bool +impl core::fmt::Debug for devlaunch_core::flows::agent_worktrees::WorktreePromotion +pub fn devlaunch_core::flows::agent_worktrees::WorktreePromotion::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::WorktreePromotion +pub struct devlaunch_core::flows::agent_worktrees::CloneWorktrees +impl devlaunch_core::flows::agent_worktrees::CloneWorktrees +pub fn devlaunch_core::flows::agent_worktrees::CloneWorktrees::clone_path(&self) -> &std::path::Path +pub fn devlaunch_core::flows::agent_worktrees::CloneWorktrees::keeping(&self) -> &[devlaunch_core::flows::agent_worktrees::KeptWorktree] +pub fn devlaunch_core::flows::agent_worktrees::CloneWorktrees::metadata_gate(&self) -> devlaunch_core::flows::agent_worktrees::MetadataGate +pub fn devlaunch_core::flows::agent_worktrees::CloneWorktrees::owner(&self) -> &str +pub fn devlaunch_core::flows::agent_worktrees::CloneWorktrees::registrations_with_nothing_here(&self) -> devlaunch_core::flows::agent_worktrees::RegistrationsWithNothingHere +pub fn devlaunch_core::flows::agent_worktrees::CloneWorktrees::removing(&self) -> &[devlaunch_core::flows::agent_worktrees::ReclaimableWorktree] +pub fn devlaunch_core::flows::agent_worktrees::CloneWorktrees::repo(&self) -> &str +impl core::clone::Clone for devlaunch_core::flows::agent_worktrees::CloneWorktrees +pub fn devlaunch_core::flows::agent_worktrees::CloneWorktrees::clone(&self) -> devlaunch_core::flows::agent_worktrees::CloneWorktrees +impl core::cmp::Eq for devlaunch_core::flows::agent_worktrees::CloneWorktrees +impl core::cmp::PartialEq for devlaunch_core::flows::agent_worktrees::CloneWorktrees +pub fn devlaunch_core::flows::agent_worktrees::CloneWorktrees::eq(&self, &devlaunch_core::flows::agent_worktrees::CloneWorktrees) -> bool +impl core::fmt::Debug for devlaunch_core::flows::agent_worktrees::CloneWorktrees +pub fn devlaunch_core::flows::agent_worktrees::CloneWorktrees::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::CloneWorktrees +pub struct devlaunch_core::flows::agent_worktrees::KeptWorktree +pub devlaunch_core::flows::agent_worktrees::KeptWorktree::because: devlaunch_core::flows::agent_worktrees::WorktreeKept +pub devlaunch_core::flows::agent_worktrees::KeptWorktree::path: std::path::PathBuf +impl core::clone::Clone for devlaunch_core::flows::agent_worktrees::KeptWorktree +pub fn devlaunch_core::flows::agent_worktrees::KeptWorktree::clone(&self) -> devlaunch_core::flows::agent_worktrees::KeptWorktree +impl core::cmp::Eq for devlaunch_core::flows::agent_worktrees::KeptWorktree +impl core::cmp::PartialEq for devlaunch_core::flows::agent_worktrees::KeptWorktree +pub fn devlaunch_core::flows::agent_worktrees::KeptWorktree::eq(&self, &devlaunch_core::flows::agent_worktrees::KeptWorktree) -> bool +impl core::fmt::Debug for devlaunch_core::flows::agent_worktrees::KeptWorktree +pub fn devlaunch_core::flows::agent_worktrees::KeptWorktree::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::KeptWorktree +pub struct devlaunch_core::flows::agent_worktrees::Lock +pub devlaunch_core::flows::agent_worktrees::Lock::reason: core::option::Option +impl core::clone::Clone for devlaunch_core::flows::agent_worktrees::Lock +pub fn devlaunch_core::flows::agent_worktrees::Lock::clone(&self) -> devlaunch_core::flows::agent_worktrees::Lock +impl core::cmp::Eq for devlaunch_core::flows::agent_worktrees::Lock +impl core::cmp::PartialEq for devlaunch_core::flows::agent_worktrees::Lock +pub fn devlaunch_core::flows::agent_worktrees::Lock::eq(&self, &devlaunch_core::flows::agent_worktrees::Lock) -> bool +impl core::fmt::Debug for devlaunch_core::flows::agent_worktrees::Lock +pub fn devlaunch_core::flows::agent_worktrees::Lock::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::Lock +pub struct devlaunch_core::flows::agent_worktrees::MetadataGate +impl devlaunch_core::flows::agent_worktrees::MetadataGate +pub fn devlaunch_core::flows::agent_worktrees::MetadataGate::open(self) -> bool +impl core::clone::Clone for devlaunch_core::flows::agent_worktrees::MetadataGate +pub fn devlaunch_core::flows::agent_worktrees::MetadataGate::clone(&self) -> devlaunch_core::flows::agent_worktrees::MetadataGate +impl core::cmp::Eq for devlaunch_core::flows::agent_worktrees::MetadataGate +impl core::cmp::PartialEq for devlaunch_core::flows::agent_worktrees::MetadataGate +pub fn devlaunch_core::flows::agent_worktrees::MetadataGate::eq(&self, &devlaunch_core::flows::agent_worktrees::MetadataGate) -> bool +impl core::default::Default for devlaunch_core::flows::agent_worktrees::MetadataGate +pub fn devlaunch_core::flows::agent_worktrees::MetadataGate::default() -> devlaunch_core::flows::agent_worktrees::MetadataGate +impl core::fmt::Debug for devlaunch_core::flows::agent_worktrees::MetadataGate +pub fn devlaunch_core::flows::agent_worktrees::MetadataGate::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::Copy for devlaunch_core::flows::agent_worktrees::MetadataGate +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::MetadataGate +pub struct devlaunch_core::flows::agent_worktrees::ReclaimableWorktree +pub devlaunch_core::flows::agent_worktrees::ReclaimableWorktree::path: std::path::PathBuf +pub devlaunch_core::flows::agent_worktrees::ReclaimableWorktree::promotion: devlaunch_core::flows::agent_worktrees::WorktreePromotion +pub devlaunch_core::flows::agent_worktrees::ReclaimableWorktree::seen_as: devlaunch_core::flows::agent_worktrees::SeenAs +pub devlaunch_core::flows::agent_worktrees::ReclaimableWorktree::usage: devlaunch_core::flows::disk_usage::DiskUsage +impl core::clone::Clone for devlaunch_core::flows::agent_worktrees::ReclaimableWorktree +pub fn devlaunch_core::flows::agent_worktrees::ReclaimableWorktree::clone(&self) -> devlaunch_core::flows::agent_worktrees::ReclaimableWorktree +impl core::cmp::Eq for devlaunch_core::flows::agent_worktrees::ReclaimableWorktree +impl core::cmp::PartialEq for devlaunch_core::flows::agent_worktrees::ReclaimableWorktree +pub fn devlaunch_core::flows::agent_worktrees::ReclaimableWorktree::eq(&self, &devlaunch_core::flows::agent_worktrees::ReclaimableWorktree) -> bool +impl core::fmt::Debug for devlaunch_core::flows::agent_worktrees::ReclaimableWorktree +pub fn devlaunch_core::flows::agent_worktrees::ReclaimableWorktree::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::ReclaimableWorktree +pub struct devlaunch_core::flows::agent_worktrees::RegistrationsWithNothingHere +impl devlaunch_core::flows::agent_worktrees::RegistrationsWithNothingHere +pub fn devlaunch_core::flows::agent_worktrees::RegistrationsWithNothingHere::container_paths(self) -> usize +pub fn devlaunch_core::flows::agent_worktrees::RegistrationsWithNothingHere::deleted(self) -> usize +pub fn devlaunch_core::flows::agent_worktrees::RegistrationsWithNothingHere::none(self) -> bool +impl core::clone::Clone for devlaunch_core::flows::agent_worktrees::RegistrationsWithNothingHere +pub fn devlaunch_core::flows::agent_worktrees::RegistrationsWithNothingHere::clone(&self) -> devlaunch_core::flows::agent_worktrees::RegistrationsWithNothingHere +impl core::cmp::Eq for devlaunch_core::flows::agent_worktrees::RegistrationsWithNothingHere +impl core::cmp::PartialEq for devlaunch_core::flows::agent_worktrees::RegistrationsWithNothingHere +pub fn devlaunch_core::flows::agent_worktrees::RegistrationsWithNothingHere::eq(&self, &devlaunch_core::flows::agent_worktrees::RegistrationsWithNothingHere) -> bool +impl core::default::Default for devlaunch_core::flows::agent_worktrees::RegistrationsWithNothingHere +pub fn devlaunch_core::flows::agent_worktrees::RegistrationsWithNothingHere::default() -> devlaunch_core::flows::agent_worktrees::RegistrationsWithNothingHere +impl core::fmt::Debug for devlaunch_core::flows::agent_worktrees::RegistrationsWithNothingHere +pub fn devlaunch_core::flows::agent_worktrees::RegistrationsWithNothingHere::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::Copy for devlaunch_core::flows::agent_worktrees::RegistrationsWithNothingHere +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::RegistrationsWithNothingHere +pub struct devlaunch_core::flows::agent_worktrees::WithheldWorktree +pub devlaunch_core::flows::agent_worktrees::WithheldWorktree::because: devlaunch_core::flows::agent_worktrees::WorktreeKept +pub devlaunch_core::flows::agent_worktrees::WithheldWorktree::path: std::path::PathBuf +impl core::clone::Clone for devlaunch_core::flows::agent_worktrees::WithheldWorktree +pub fn devlaunch_core::flows::agent_worktrees::WithheldWorktree::clone(&self) -> devlaunch_core::flows::agent_worktrees::WithheldWorktree +impl core::cmp::Eq for devlaunch_core::flows::agent_worktrees::WithheldWorktree +impl core::cmp::PartialEq for devlaunch_core::flows::agent_worktrees::WithheldWorktree +pub fn devlaunch_core::flows::agent_worktrees::WithheldWorktree::eq(&self, &devlaunch_core::flows::agent_worktrees::WithheldWorktree) -> bool +impl core::fmt::Debug for devlaunch_core::flows::agent_worktrees::WithheldWorktree +pub fn devlaunch_core::flows::agent_worktrees::WithheldWorktree::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::WithheldWorktree +pub struct devlaunch_core::flows::agent_worktrees::WorktreeReport +pub devlaunch_core::flows::agent_worktrees::WorktreeReport::metadata_held_back: alloc::vec::Vec +pub devlaunch_core::flows::agent_worktrees::WorktreeReport::metadata_refused: alloc::vec::Vec +pub devlaunch_core::flows::agent_worktrees::WorktreeReport::refused: alloc::vec::Vec +pub devlaunch_core::flows::agent_worktrees::WorktreeReport::removed: alloc::vec::Vec +pub devlaunch_core::flows::agent_worktrees::WorktreeReport::withheld: alloc::vec::Vec +impl devlaunch_core::flows::agent_worktrees::WorktreeReport +pub fn devlaunch_core::flows::agent_worktrees::WorktreeReport::freed(&self) -> devlaunch_core::flows::disk_usage::DiskUsage +pub fn devlaunch_core::flows::agent_worktrees::WorktreeReport::nothing_to_say(&self) -> bool +impl core::clone::Clone for devlaunch_core::flows::agent_worktrees::WorktreeReport +pub fn devlaunch_core::flows::agent_worktrees::WorktreeReport::clone(&self) -> devlaunch_core::flows::agent_worktrees::WorktreeReport +impl core::cmp::Eq for devlaunch_core::flows::agent_worktrees::WorktreeReport +impl core::cmp::PartialEq for devlaunch_core::flows::agent_worktrees::WorktreeReport +pub fn devlaunch_core::flows::agent_worktrees::WorktreeReport::eq(&self, &devlaunch_core::flows::agent_worktrees::WorktreeReport) -> bool +impl core::default::Default for devlaunch_core::flows::agent_worktrees::WorktreeReport +pub fn devlaunch_core::flows::agent_worktrees::WorktreeReport::default() -> devlaunch_core::flows::agent_worktrees::WorktreeReport +impl core::fmt::Debug for devlaunch_core::flows::agent_worktrees::WorktreeReport +pub fn devlaunch_core::flows::agent_worktrees::WorktreeReport::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_worktrees::WorktreeReport +pub struct devlaunch_core::flows::agent_worktrees::WorktreeSweep +impl devlaunch_core::flows::agent_worktrees::WorktreeSweep +pub fn devlaunch_core::flows::agent_worktrees::WorktreeSweep::clones(&self) -> &[devlaunch_core::flows::agent_worktrees::CloneWorktrees] +pub fn devlaunch_core::flows::agent_worktrees::WorktreeSweep::freed(&self) -> devlaunch_core::flows::disk_usage::DiskUsage +pub fn devlaunch_core::flows::agent_worktrees::WorktreeSweep::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 @@ -1417,6 +1610,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 @@ -1430,12 +1635,13 @@ pub fn devlaunch_core::flows::lifecycle::Kept::fmt(&self, &mut core::fmt::Format impl core::marker::StructuralPartialEq for devlaunch_core::flows::lifecycle::Kept pub struct devlaunch_core::flows::lifecycle::PrunePlan impl devlaunch_core::flows::lifecycle::PrunePlan -pub fn devlaunch_core::flows::lifecycle::PrunePlan::freed(&self) -> devlaunch_core::flows::disk_usage::DiskUsage +pub fn devlaunch_core::flows::lifecycle::PrunePlan::clones_freed(&self) -> devlaunch_core::flows::disk_usage::DiskUsage pub fn devlaunch_core::flows::lifecycle::PrunePlan::keeping(&self) -> &[devlaunch_core::flows::lifecycle::Kept] pub fn devlaunch_core::flows::lifecycle::PrunePlan::nothing_to_do(&self) -> bool pub fn devlaunch_core::flows::lifecycle::PrunePlan::removing(&self) -> &[devlaunch_core::flows::lifecycle::Reclaimable] 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 @@ -1448,9 +1654,10 @@ pub struct devlaunch_core::flows::lifecycle::PruneReport pub devlaunch_core::flows::lifecycle::PruneReport::refused: alloc::vec::Vec pub devlaunch_core::flows::lifecycle::PruneReport::removed: alloc::vec::Vec pub devlaunch_core::flows::lifecycle::PruneReport::withheld: alloc::vec::Vec +pub devlaunch_core::flows::lifecycle::PruneReport::worktrees: devlaunch_core::flows::agent_worktrees::WorktreeReport impl devlaunch_core::flows::lifecycle::PruneReport +pub fn devlaunch_core::flows::lifecycle::PruneReport::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 @@ -1573,7 +1780,7 @@ pub fn devlaunch_core::flows::lifecycle::child_work(&std::path::Path, devlaunch_ pub fn devlaunch_core::flows::lifecycle::guard_removal(&str, devlaunch_core::domain::workspace_state::Unsaved, devlaunch_core::flows::lifecycle::Insistence) -> devlaunch_core::flows::lifecycle::Guarded pub fn devlaunch_core::flows::lifecycle::objection(&devlaunch_core::domain::workspace_state::Unsaved) -> core::option::Option pub fn devlaunch_core::flows::lifecycle::prune_clones(&mut devlaunch_core::flows::listing::CommandContext<'_>, &devlaunch_core::flows::workspace_clone::WorkspaceCloneManager<'_>, &mut devlaunch_core::domain::metadata::MetadataStorage, &devlaunch_core::flows::lifecycle::PrunePlan, &mut dyn devlaunch_core::notices::Notices) -> core::result::Result -pub fn devlaunch_core::flows::lifecycle::prune_plan(&devlaunch_core::flows::workspace_clone::WorkspaceCloneManager<'_>, &devlaunch_core::domain::metadata::MetadataStorage, &[devlaunch_core::clients::devpod::Workspace], &devlaunch_core::flows::lifecycle::ClonePlacement, devlaunch_core::flows::lifecycle::Insistence, &mut dyn devlaunch_core::notices::Notices) -> core::result::Result +pub fn devlaunch_core::flows::lifecycle::prune_plan(&devlaunch_core::flows::workspace_clone::WorkspaceCloneManager<'_>, &devlaunch_core::domain::metadata::MetadataStorage, &[devlaunch_core::clients::devpod::Workspace], &devlaunch_core::flows::lifecycle::ClonePlacement, devlaunch_core::flows::lifecycle::Insisted, &mut dyn devlaunch_core::notices::Notices) -> core::result::Result pub fn devlaunch_core::flows::lifecycle::purge_all_data(&mut devlaunch_core::flows::listing::CommandContext<'_>, &devlaunch_core::flows::lifecycle::PurgePlan, core::option::Option<&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 @@ -1595,7 +1802,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 @@ -1644,6 +1851,20 @@ pub fn devlaunch_core::flows::listing::WorkspaceTable::eq(&self, &devlaunch_core impl core::fmt::Debug for devlaunch_core::flows::listing::WorkspaceTable pub fn devlaunch_core::flows::listing::WorkspaceTable::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::marker::StructuralPartialEq for devlaunch_core::flows::listing::WorkspaceTable +pub struct devlaunch_core::flows::listing::CloneDisk +impl devlaunch_core::flows::listing::CloneDisk +pub fn devlaunch_core::flows::listing::CloneDisk::freed(&self) -> &devlaunch_core::flows::disk_usage::DiskUsage +pub fn devlaunch_core::flows::listing::CloneDisk::in_worktrees(&self) -> core::option::Option<&devlaunch_core::flows::disk_usage::DiskUsage> +pub fn devlaunch_core::flows::listing::CloneDisk::measured(u64, core::option::Option) -> Self +pub fn devlaunch_core::flows::listing::CloneDisk::worktrees_worth_naming(&self) -> core::option::Option<&devlaunch_core::flows::disk_usage::DiskUsage> +impl core::clone::Clone for devlaunch_core::flows::listing::CloneDisk +pub fn devlaunch_core::flows::listing::CloneDisk::clone(&self) -> devlaunch_core::flows::listing::CloneDisk +impl core::cmp::Eq for devlaunch_core::flows::listing::CloneDisk +impl core::cmp::PartialEq for devlaunch_core::flows::listing::CloneDisk +pub fn devlaunch_core::flows::listing::CloneDisk::eq(&self, &devlaunch_core::flows::listing::CloneDisk) -> bool +impl core::fmt::Debug for devlaunch_core::flows::listing::CloneDisk +pub fn devlaunch_core::flows::listing::CloneDisk::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::listing::CloneDisk pub struct devlaunch_core::flows::listing::CommandContext<'r> impl<'r> devlaunch_core::flows::listing::CommandContext<'r> pub fn devlaunch_core::flows::listing::CommandContext<'r>::git(&self) -> devlaunch_core::clients::git::Git<'r> diff --git a/rust/devlaunch-core/src/clients/git.rs b/rust/devlaunch-core/src/clients/git.rs index 1a75e825..4698c7b2 100644 --- a/rust/devlaunch-core/src/clients/git.rs +++ b/rust/devlaunch-core/src/clients/git.rs @@ -489,6 +489,99 @@ impl<'r> Git<'r> { self.about(clone, &["log", "--oneline", branch, "--not", "--remotes"]) } + /// Every worktree registered in *clone*, as `worktree list --porcelain` + /// writes it: the clone's own entry first, then one paragraph per linked + /// worktree carrying its `branch` or `detached`, and `locked` or `prunable` + /// where git says so. + /// + /// **The registered paths are not necessarily paths on this machine.** An + /// agent harness running inside a devcontainer registers its worktrees at the + /// container's `/workspaces//…`, and the very same directories are + /// reached from the host through the clone. So this output is read for what + /// git *thinks* about each registration, and a registration is matched to a + /// directory by its place inside the clone rather than by resolving the path + /// git prints, which on a host resolves to nothing (devlaunch#426). + pub(crate) fn worktree_listing(&self, clone: &Path) -> GitAnswer { + self.about(clone, &["worktree", "list", "--porcelain"]) + } + + /// Drop the registrations whose worktree directory git can no longer find. + /// + /// Locked registrations are skipped by git itself, which is what makes a lock + /// survive this and go on protecting a worktree somebody may be working in. + /// + /// All-or-nothing across the clone: git offers no way to drop one + /// registration and keep another, which is why the caller decides whether a + /// clone may be pruned at all rather than which of its registrations goes. + pub(crate) fn worktree_prune(&self, clone: &Path) -> GitAnswer { + self.about(clone, &["worktree", "prune"]) + } + + /// The porcelain status of one linked worktree, asked through the clone's + /// admin directory for it. + /// + /// **Not [`Git::about`], and the difference is what makes this answerable at + /// all.** A linked worktree's own `.git` is a gitfile, and for an agent + /// worktree that gitfile names a path inside a container. Pointing + /// `--git-dir` at it on a host resolves nothing and git refuses — so a dirty + /// check that went the ordinary way would report every one of these as + /// unreadable, which is a refusal to reclaim anything. + /// `--git-dir=/.git/worktrees/` is the same repository reached + /// from the side that does resolve here, and `--work-tree` is the directory + /// on this host. + /// + /// **Every line git prints comes back, including the nested agent worktrees + /// the caller reasons about separately.** This used to carry a + /// `:!.claude/worktrees` pathspec, which excluded the *place* rather than the + /// thing and so also hid a tracked file modified under that path and plain + /// content sitting there that is not a worktree at all (devlaunch#442 review, + /// S4). Which entries to disregard is a question about what a directory *is*, + /// which is `flows::agent_worktrees`'s question and not a git client's, so the + /// filtering is done there and the name of the directory stays in the one + /// module that reasons about it. + pub(crate) fn worktree_dirt(&self, admin: &Path, work_tree: &Path) -> GitAnswer { + let args = [ + format!("--git-dir={}", admin.display()), + format!("--work-tree={}", work_tree.display()), + "status".to_owned(), + "--porcelain".to_owned(), + ]; + self.captured( + "status --porcelain", + &SpawnSpec::new( + Invocation::new(PROGRAM) + .with_args(args) + .with_cwd(work_tree.to_path_buf()), + ) + .with_timeout(ABOUT_ONE_REPO), + ) + .map(|stdout| stdout.trim_end_matches('\n').to_owned()) + } + + /// How many commits are reachable from *rev* and from no ref in *repo*. + /// + /// Asked of the sibling bare cache, which is the repository devlaunch + /// actually fetches into, so `--all` there means "everything the forge had at + /// the last fetch". `0` is therefore the one answer that says a commit is + /// safely somewhere else. + /// + /// A refusal is usually `bad object`: the cache has never seen the commit, + /// which is what an unpushed branch looks like from over there. The caller + /// reads it that way and asks the clone as well rather than treating it as an + /// error. + pub(crate) fn commits_beyond_every_ref(&self, repo: &Path, rev: &str) -> GitAnswer { + self.captured( + "rev-list", + &SpawnSpec::new( + Invocation::new(PROGRAM) + .with_args(["rev-list", "--count", rev, "--not", "--all"]) + .with_cwd(repo.to_path_buf()), + ) + .with_timeout(ABOUT_ONE_REPO), + ) + .map(trimmed) + } + // ------------------------------------------------------- the bare cache /// `git clone --bare ` — the cache for one repository. diff --git a/rust/devlaunch-core/src/flows/agent_worktrees.rs b/rust/devlaunch-core/src/flows/agent_worktrees.rs new file mode 100644 index 00000000..76e45348 --- /dev/null +++ b/rust/devlaunch-core/src/flows/agent_worktrees.rs @@ -0,0 +1,1375 @@ +//! The git worktrees an agent harness leaves inside a workspace clone. +//! +//! # What this is about, and what the word means here +//! +//! An agent harness working inside a devcontainer makes its own git worktrees +//! under `/.claude/worktrees//`, one per task, and nothing ever +//! collects them. Measured on one host (devlaunch#426): 72 such directories, +//! 104.5 GB, 18 of them carrying a whole `.pixi/envs/default` — about 82% of +//! everything under `repos/`. Every one of them was inside a clone belonging to a +//! **live** devpod workspace, so [`crate::flows::lifecycle`]'s orphan rule not +//! only missed them, it must never fire on them: firing would delete a live +//! workspace's checkout. +//! +//! **`WorktreeInfo` is not this.** [`crate::domain::model::WorktreeInfo`] is +//! devlaunch's own long-standing name for *a workspace clone of one branch*, and +//! has nothing to do with anything in this module. Here "worktree" means git's +//! own thing — a second checkout registered in a repository, which +//! `git worktree list` prints and `git worktree prune` forgets. +//! +//! # Four categories, four safety profiles +//! +//! These are *registered* worktrees rather than stray directories, and they were +//! registered from inside the container, so the path git holds for one is +//! `/workspaces//.claude/worktrees/` — a path that does not resolve on +//! the host at all. On the reference host: 6 locked and 33 prunable registrations +//! against 72 directories on disk. So a directory here is one of four things, and +//! [`decide`] is the only place any of them becomes deletable: +//! +//! - **Forgotten.** No registration names it. git has already let go and the +//! directory is the whole of what is left. This is the arm that deletes without +//! git's help, so it is asked what it holds wherever there is still an admin +//! directory to ask through, and only takes git's word when there is not. +//! - **Prunable.** Registered, and git's own listing says the registration is +//! collectable — which on a host is what a container-registered worktree looks +//! like, because the path it names is not there. +//! - **Locked.** Registered and locked. Never removed implicitly. +//! - **Held.** Registered, and git calls it neither locked nor prunable, so git +//! still believes in it. Always kept, and this is the arm that stops a run +//! *inside* a container — where the registered paths do resolve — from +//! collecting its own live worktrees. +//! +//! # What a lock is, and what it is not +//! +//! A lock is the harness's courtesy, so `locked` is neither necessary nor +//! sufficient for "somebody is working in here": a killed session leaves one +//! behind, and a live session that never took one is indistinguishable from an +//! abandoned directory. **Nothing on a host can prove a worktree is idle**, so +//! nothing here claims to. Every refusal names the fact it rests on — registered, +//! locked, dirty, holding commits nothing else reaches — and never idleness. +//! +//! That also means the race is real and cannot be closed from here. `--prune` +//! holds devlaunch's per-repo lock, and a container running `git worktree add` is +//! not a participant in it: the scan can say prunable and the container can +//! re-register before the removal lands. So every directory is classified **again, +//! immediately before it goes**, which is the same reasoning that put a +//! [`WorktreePromotion`] on each candidate rather than a run-wide boolean. +//! +//! # Why a path is never resolved, and a name is matched instead +//! +//! The registrations and the directories are two views of one set, and the only +//! thing they reliably share is the place inside the clone: a `.claude/worktrees/` +//! and a leaf, possibly nested. So a registration is joined to a directory by that +//! suffix, and the path git printed is never handed to the filesystem. The +//! container prefix is also why `git worktree remove` is no use from a host — it +//! resolves the registered path — so a removal here is a directory removal +//! followed by [`Git::worktree_prune`], in that order. +//! +//! `.claude/worktrees/` is the match rather than the `agent-` prefix the names +//! happen to carry: the prefix is the harness's business and can change, where +//! the containing directory is the thing devlaunch is reasoning about. And a +//! directory sitting in that place is confirmed to be a worktree by its own `.git` +//! gitfile naming a `…/.git/worktrees/` admin directory, because a plain +//! directory that happens to be there is not devlaunch's to delete. +//! +//! # Nesting +//! +//! An agent session running *inside* an agent worktree makes its worktrees under +//! *that* directory, and that nesting is how one clone on the reference host +//! reached 55 GB. So the scan recurses — but only into the worktrees it is +//! **keeping**. A directory that is going takes everything inside it, so +//! descending into one would report the same bytes twice and offer to remove a +//! directory that will not be there. + +use std::path::{Path, PathBuf}; + +use crate::clients::git::Git; +use crate::domain::workspace_state::{CouldNotTell, Loss, Losses, NonEmpty, Unsaved}; +use crate::flows::disk_usage::{self, DiskUsage}; +use crate::flows::lifecycle::{Insistence, Objection, objection}; +use crate::flows::repo_manager::{Refusal, Removal, remove_tree_as_far_as_it_goes}; + +/// The directory an agent harness puts its worktrees in, relative to a clone. +const WORKTREES_DIR: [&str; 2] = [".claude", "worktrees"]; + +/// The `.git` gitfile's prefix, and the admin directory it names inside a clone. +const GITFILE_PREFIX: &str = "gitdir:"; +const ADMIN_DIR: [&str; 2] = [".git", "worktrees"]; + +// =========================================================================== +// what git says about one registration +// =========================================================================== + +/// What one worktree has checked out, as `worktree list --porcelain` says it. +/// +/// Two arms rather than an optional branch, because a detached worktree's commits +/// are as losable as a branch's and the probe needs *something* to ask git about +/// either way. An absent branch that meant "ask nothing" would be the answer that +/// deletes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WorktreeHead { + /// `branch refs/heads/`, with the commit git printed beside it. + Branch { reference: String, commit: String }, + /// `detached`, and the commit `HEAD` named. + Detached { commit: String }, +} + +impl WorktreeHead { + /// What a report calls it. + pub fn named(&self) -> String { + match self { + Self::Branch { reference, .. } => reference + .strip_prefix("refs/heads/") + .unwrap_or(reference) + .to_owned(), + Self::Detached { commit } => format!("a detached HEAD at {}", short(commit)), + } + } + + /// What the clone is asked about it. + /// + /// The full `refs/heads/…` spelling rather than the short name, so a branch + /// and a tag of one name cannot be taken for each other. + fn revision(&self) -> &str { + match self { + Self::Branch { reference, .. } => reference, + Self::Detached { commit } => commit, + } + } + + /// The commit itself, which is what the sibling bare cache is asked about: + /// the branch *name* means nothing over there. + fn commit(&self) -> &str { + match self { + Self::Branch { commit, .. } | Self::Detached { commit } => commit, + } + } +} + +fn short(commit: &str) -> String { + commit.chars().take(8).collect() +} + +/// git is holding this worktree, and what it says about why. +/// +/// The reason is genuinely absent for a `git worktree lock` with no `--reason`, +/// which is what a harness does, so this is an absence and not a stand-in. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Lock { + pub reason: Option, +} + +/// One paragraph of `git worktree list --porcelain`, for a worktree registered +/// somewhere under a `.claude/worktrees/`. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Registration { + /// Where inside a clone the registration sits, as + /// `.claude/worktrees/[/.claude/worktrees/…]`. This is the join + /// key, and the only thing a directory is ever matched on. + inside: String, + /// The path git printed, kept for one question and never handed to the + /// filesystem: whether it is a path *in this clone* or a container's. That is + /// the whole of what tells a registration whose directory somebody deleted + /// apart from one that never resolved on this host. + registered_at: PathBuf, + head: WorktreeHead, + locked: Option, + prunable: bool, +} + +/// Every registration under a `.claude/worktrees/`, from `worktree list +/// --porcelain`. +/// +/// The clone's own entry, and any worktree registered somewhere else entirely, +/// are dropped here: this module reasons about directories inside one clone, and +/// a registration outside has no directory here to be joined to. +fn registrations(listing: &str) -> Vec { + let mut found = Vec::new(); + for paragraph in listing.split("\n\n") { + let mut inside = None; + let mut registered_at = None; + let mut reference = None; + let mut commit = None; + let mut locked = None; + let mut prunable = false; + for line in paragraph.lines() { + let (key, rest) = match line.split_once(' ') { + Some((key, rest)) => (key, Some(rest)), + None => (line, None), + }; + match (key, rest) { + ("worktree", Some(path)) => { + inside = inside_a_worktrees_dir(Path::new(path)); + registered_at = Some(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()), + }); + } + ("prunable", _) => prunable = true, + _ => {} + } + } + let (Some(inside), Some(registered_at), Some(commit)) = (inside, registered_at, commit) + else { + continue; + }; + let head = match reference { + Some(reference) => WorktreeHead::Branch { reference, commit }, + None => WorktreeHead::Detached { commit }, + }; + found.push(Registration { + inside, + registered_at, + head, + locked, + prunable, + }); + } + found +} + +/// Where in a clone `path` sits, as the join key, when it sits under a +/// `.claude/worktrees/` at all. +/// +/// The suffix from the *first* `.claude/worktrees` onwards, so a nested worktree +/// keeps its whole path inside the clone and cannot be confused with a +/// same-named one at the top. Read off the components git printed rather than off +/// the filesystem, because the point of this module is that the path is not a +/// path here. +fn inside_a_worktrees_dir(path: &Path) -> Option { + let parts: Vec = path + .components() + .map(|part| part.as_os_str().to_string_lossy().into_owned()) + .collect(); + let at = (0..parts.len().saturating_sub(2)).find(|&at| { + parts[at] == WORKTREES_DIR[0] && parts[at + 1] == WORKTREES_DIR[1] && at + 2 < parts.len() + })?; + Some(parts[at..].join("/")) +} + +/// Where `directory` sits inside `clone`, as the same join key. +fn inside_the_clone(clone: &Path, directory: &Path) -> Option { + let relative = directory.strip_prefix(clone).ok()?; + inside_a_worktrees_dir(relative) +} + +/// The admin directory name `directory`'s own `.git` gitfile claims, when +/// `directory` really is a linked git worktree. +/// +/// **This is the confirmation, and it is not the path shape.** A plain directory +/// sitting under `.claude/worktrees/`, or a file, or a symlink, is not +/// devlaunch's to remove; a linked worktree has a `.git` *file* reading +/// `gitdir: …/.git/worktrees/`. The prefix of that path is the container's +/// and is ignored — only the `…/.git/worktrees/` tail is read, which is +/// what says "a git worktree, registered under some repository's admin +/// directory". +fn linked_worktree_name(directory: &Path) -> Option { + let gitfile = directory.join(".git"); + if !std::fs::symlink_metadata(&gitfile).ok()?.is_file() { + return None; + } + let content = std::fs::read_to_string(&gitfile).ok()?; + let named = content.trim().strip_prefix(GITFILE_PREFIX)?.trim(); + let parts: Vec = Path::new(named) + .components() + .map(|part| part.as_os_str().to_string_lossy().into_owned()) + .collect(); + let (name, rest) = parts.split_last()?; + let (worktrees, rest) = rest.split_last()?; + let dot_git = rest.last()?; + // `..` would name the clone's own `.git` and `.` its `.git/worktrees`, so a + // gitfile carrying either would have this module probe, and then remove, + // something that is not one worktree -- and without this guard it *did* + // remove it, as a directory git had forgotten. The tail is file content and + // file content is not trusted to be a name (devlaunch#442 review, S6). + if name == "." || name == ".." || name.is_empty() { + return None; + } + (dot_git == ADMIN_DIR[0] && worktrees == ADMIN_DIR[1]).then(|| name.clone()) +} + +// =========================================================================== +// what one directory is +// =========================================================================== + +/// Which arm one directory under a `.claude/worktrees/` is. +/// +/// The module docs carry what the four arms mean. `usage` rides on the three +/// removable arms and not on [`Self::Held`], for the reason +/// `lifecycle::CloneStatus` does the same: the walk behind it is O(files) with no +/// ceiling, and an arm nobody can reclaim is an arm nobody should pay to weigh. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum WorktreeStatus { + /// No registration names it: git has let go of the name. + /// + /// devlaunch#426 calls this category safe to delete outright, and it is the + /// one arm that takes git's word rather than checking — **but only where there + /// is nothing left to check with**. With the admin directory gone there is no + /// index and no HEAD, so no question can be put at all, and `holds` is + /// [`Unsaved::NothingToLose`] because that is the honest answer rather than a + /// shortcut. Where the admin directory *is* here the directory can still be + /// asked what it holds, and it is asked: reaching this arm with one present + /// means git dropped the name or the suffix join missed, and no wrong + /// classification should be able to cost somebody work (devlaunch#442 review, + /// S1). This is the arm that deletes, so it is the arm that has to be hardest + /// to reach by accident. + Forgotten { holds: Unsaved, usage: DiskUsage }, + /// Registered, and git's own listing calls the registration prunable. + Prunable { + head: WorktreeHead, + holds: Unsaved, + usage: DiskUsage, + }, + /// Registered and locked. + Locked { + lock: Lock, + head: WorktreeHead, + holds: Unsaved, + usage: DiskUsage, + }, + /// Registered, and git calls it neither locked nor prunable. + Held { head: WorktreeHead }, +} + +/// Whether the sibling bare cache's refs already reach a commit. +/// +/// **This is the fix for a stale-ref trap and not a nicety.** A workspace clone +/// is cut from the sibling `.bare` and then has its remote repointed at the +/// forge, with no fetch of its own (see `flows::workspace_clone`'s module +/// header), so the clone's `refs/remotes/origin/*` is as of clone time and can be +/// arbitrarily old or absent. The `.bare` next door is the thing that gets +/// fetched. Ask the clone alone and branches that were pushed and merged months +/// ago read as unpushed, which keeps every byte forever and makes the flag the +/// only way to reclaim anything. +/// +/// Both answers are **as of the last fetch**, which is what the report says. No +/// network call is added here: `--prune` is a local cleanup, and one that failed +/// offline would be a worse command than one that is sometimes out of date. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum InTheCache { + /// Every commit is reachable from a ref the cache holds. + Reached, + /// The cache does not reach it — including the case where it has never seen + /// the commit at all, which is what an unpushed branch looks like from there. + Beyond, + /// The cache could not be asked, or there is none. + CouldNotSay, +} + +/// Whether `commit` is inside what the bare cache already has. +fn in_the_cache(git: &Git<'_>, bare: Option<&Path>, commit: &str) -> InTheCache { + let Some(bare) = bare else { + return InTheCache::CouldNotSay; + }; + match git.commits_beyond_every_ref(bare, commit).said() { + // A refusal here is overwhelmingly `bad object` — the cache has never + // seen this commit, which is exactly what an unpushed branch looks like + // from the cache and is a fact, not a failure. A genuinely broken cache + // lands here too and reads the same way, which is the conservative + // direction: the clone is asked next, and only both of them failing to + // find the work anywhere keeps the directory. + None => InTheCache::Beyond, + Some(count) => match count.trim().parse::() { + Ok(0) => InTheCache::Reached, + Ok(_) => InTheCache::Beyond, + Err(_) => InTheCache::CouldNotSay, + }, + } +} + +/// What removing one registered worktree directory would destroy, or that git +/// could not say. +/// +/// Mirrors `workspace_state`'s clone-level probe and answers in the same type, so +/// the report reads in the same words — but every question is asked differently, +/// because a host cannot ask this worktree anything the ordinary way: +/// +/// - **The dirty check goes through the admin directory.** The worktree's own +/// `.git` gitfile names a container path, so pointing git at the directory +/// refuses. `--git-dir=/.git/worktrees/` with +/// `--work-tree=` is the same repository reached from the side that +/// does resolve here. +/// - **Nested worktrees are dropped from the answer, and nothing else is.** A +/// worktree holding a nested one would otherwise always read dirty — the nested +/// directory is untracked — and would be kept forever while the bytes that +/// matter sat inside it. [`dirt_in`] carries which entries go and why the +/// exclusion is per-entry rather than per-path. +/// - **Reachability asks the cache first.** See [`InTheCache`]. +fn unsaved_in( + git: &Git<'_>, + clone: &Path, + bare: Option<&Path>, + admin: &Path, + directory: &Path, + head: &WorktreeHead, +) -> Unsaved { + let mut losses = Vec::new(); + match dirt_in(git, admin, directory) { + Err(could_not_tell) => return could_not_tell, + Ok(None) => {} + Ok(Some(changed)) => losses.push(changed), + } + match unreachable_commits_in(git, clone, bare, directory, head) { + Err(could_not_tell) => return could_not_tell, + Ok(None) => {} + Ok(Some(commits)) => losses.push(commits), + } + match Losses::of(losses) { + Some(losses) => Unsaved::WouldLose(losses), + None => Unsaved::NothingToLose, + } +} + +/// The commits `head` holds that neither the sibling bare cache nor the clone can +/// reach, or that neither could be asked. +/// +/// The cache goes first, for the stale-ref reason [`InTheCache`] carries. +fn unreachable_commits_in( + git: &Git<'_>, + clone: &Path, + bare: Option<&Path>, + directory: &Path, + head: &WorktreeHead, +) -> Result, Unsaved> { + match in_the_cache(git, bare, head.commit()) { + InTheCache::Reached => Ok(None), + InTheCache::Beyond | InTheCache::CouldNotSay => { + match git.unpushed_commits(clone, head.revision()).said() { + None => Err(Unsaved::CouldNotTell(CouldNotTell::UnpushedNotListed { + clone: directory.to_path_buf(), + branch: head.named(), + reason: "neither the repository cache nor the clone could say whether these \ + commits are anywhere else" + .to_owned(), + })), + Some(unpushed) => { + Ok(NonEmpty::of(unpushed.lines().map(str::to_owned)).map(Loss::Unpushed)) + } + } + } + } +} + +/// What a registration can still be asked when its admin directory has gone. +/// +/// A narrow window -- `git worktree list` reads the admin directories, so a +/// registration means one was there a moment ago, and only a concurrent prune +/// takes it away between the listing and the look. But the arm this lands on is +/// the arm that deletes, and the registration still names a head, so the commits +/// can be asked about even though the working tree cannot. Fails towards keeping, +/// like everything else here. +fn unsaved_without_an_admin_dir( + git: &Git<'_>, + clone: &Path, + bare: Option<&Path>, + directory: &Path, + head: &WorktreeHead, +) -> Unsaved { + match unreachable_commits_in(git, clone, bare, directory, head) { + Err(could_not_tell) => could_not_tell, + Ok(None) => Unsaved::NothingToLose, + Ok(Some(commits)) => match Losses::of([commits]) { + Some(losses) => Unsaved::WouldLose(losses), + None => Unsaved::NothingToLose, + }, + } +} + +/// What one worktree directory holds that is not committed, asked through the +/// admin directory, or that git would not say. +/// +/// **The nested-worktree exclusion lives here rather than in a pathspec, and that +/// is the whole point** (devlaunch#442 review, S4). `:!.claude/worktrees` kept the +/// motivating case working — a worktree holding a nested one would otherwise read +/// dirty forever, and be kept forever while the bytes that matter sat inside it — +/// but it excluded the place rather than the thing, so it also hid two kinds of +/// real work: a *tracked* file modified under that path, and plain content +/// somebody put under a `.claude/worktrees/` that is not a worktree at all. The +/// second is the dangerous one, because the sweep skips it too (it is not a linked +/// worktree), so nothing reported it and nothing protected it, and it went when +/// its parent did. +/// +/// So git is asked without a pathspec and the answer is filtered by what an entry +/// *is*: an untracked entry is dropped only when everything under it is a +/// confirmed linked worktree, which is exactly the set this sweep reasons about +/// separately. A tracked change is never dropped, whatever its path. +fn dirt_in(git: &Git<'_>, admin: &Path, directory: &Path) -> Result, Unsaved> { + let dirt = match git.worktree_dirt(admin, directory).said() { + None => { + return Err(Unsaved::CouldNotTell(CouldNotTell::GitCouldNotRead { + clone: directory.to_path_buf(), + reason: "git could not read this worktree through the clone's admin directory" + .to_owned(), + })); + } + Some(dirt) => dirt, + }; + let lines = dirt + .lines() + .filter(|line| !is_only_nested_worktrees(directory, line)) + .map(str::to_owned); + Ok(NonEmpty::of(lines).map(Loss::Uncommitted)) +} + +/// The dirt half alone, for a directory with no registration to ask about +/// reachability with. +fn dirt_only(git: &Git<'_>, admin: &Path, directory: &Path) -> Unsaved { + match dirt_in(git, admin, directory) { + Err(could_not_tell) => could_not_tell, + Ok(None) => Unsaved::NothingToLose, + Ok(Some(loss)) => match Losses::of([loss]) { + Some(losses) => Unsaved::WouldLose(losses), + None => Unsaved::NothingToLose, + }, + } +} + +/// Whether one `git status --porcelain` line is nothing but agent worktrees the +/// sweep is reasoning about separately. +/// +/// Only ever true of an **untracked** entry on the `.claude/worktrees/` spine. +/// Both halves of that matter: a tracked change under the same path is somebody's +/// edit to a file the repository knows about, and an untracked entry anywhere else +/// is not this module's business and must not cost a walk to find out. +fn is_only_nested_worktrees(root: &Path, line: &str) -> bool { + let Some(entry) = line.strip_prefix("?? ") else { + return false; + }; + // git quotes a path holding anything unusual. Quoted means unparsed here, + // which reads as work, which is the direction that keeps the directory. + if entry.starts_with('"') { + return false; + } + let entry = Path::new(entry.trim_end_matches('/')); + on_the_worktrees_spine(entry) && holds_only_worktrees(&root.join(entry)) +} + +/// Whether `entry` is the `.claude/worktrees/` spine or something under it. +/// +/// The cheap guard in front of [`holds_only_worktrees`], which walks: without it +/// an untracked `build/` would be walked in full to establish what everyone +/// already knows, which is what the pathspec was buying. +fn on_the_worktrees_spine(entry: &Path) -> bool { + let parts: Vec = entry + .components() + .map(|part| part.as_os_str().to_string_lossy().into_owned()) + .collect(); + parts.first().is_some_and(|first| first == WORKTREES_DIR[0]) + && parts.get(1).is_none_or(|second| second == WORKTREES_DIR[1]) +} + +/// Whether every leaf under `path` is inside a confirmed linked worktree. +/// +/// Stops at each worktree rather than descending into it, which is what bounds +/// the walk: the multi-gigabyte `.pixi/` that makes these directories worth +/// reclaiming is always inside one, so the only thing walked is content that is +/// *not* a worktree — which is precisely the content this is looking for. +/// +/// A symlink is not descended into, which is what makes the recursion terminate: +/// a link back up its own tree would otherwise be walked forever. It reads as +/// something to keep, like anything else here that is not a confirmed worktree. +fn holds_only_worktrees(path: &Path) -> bool { + if std::fs::symlink_metadata(path).is_ok_and(|it| it.is_symlink()) { + return false; + } + if linked_worktree_name(path).is_some() { + return true; + } + let Ok(entries) = std::fs::read_dir(path) else { + // A file, or a directory that will not be read. Neither is a worktree, so + // neither is something to drop from the answer. + return false; + }; + entries + .filter_map(Result::ok) + .all(|entry| holds_only_worktrees(&entry.path())) +} + +/// Which arm `directory` is, asked in the order that fails towards keeping it. +fn worktree_status( + git: &Git<'_>, + clone: &Path, + bare: Option<&Path>, + directory: &Path, + admin: Option<&Path>, + registered: Option<&Registration>, +) -> WorktreeStatus { + let (registration, admin) = match (registered, admin) { + (Some(registration), Some(admin)) => (registration, admin), + // An admin directory with nothing joined to it. git has dropped the name, + // or this module's suffix join missed one -- and either way the index and + // HEAD are here, so the directory is asked what it holds before it is + // treated as an empty leftover. + (None, Some(admin)) => { + return WorktreeStatus::Forgotten { + holds: dirt_only(git, admin, directory), + usage: disk_usage::exclusive_usage(directory), + }; + } + // A registration whose admin directory has gone: a concurrent prune, in + // the window between the listing and this look. No index and no HEAD, so + // the working tree cannot be asked -- but the registration still names a + // head, and the commits can be. + (Some(registration), None) => { + return WorktreeStatus::Forgotten { + holds: unsaved_without_an_admin_dir( + git, + clone, + bare, + directory, + ®istration.head, + ), + usage: disk_usage::exclusive_usage(directory), + }; + } + // Neither. Nothing can be asked at all, and that is devlaunch#426's + // category 1: git has let go and the directory is the whole of what is + // left. + (None, None) => { + return WorktreeStatus::Forgotten { + holds: Unsaved::NothingToLose, + usage: disk_usage::exclusive_usage(directory), + }; + } + }; + if !registration.prunable && registration.locked.is_none() { + return WorktreeStatus::Held { + head: registration.head.clone(), + }; + } + let holds = unsaved_in(git, clone, bare, admin, directory, ®istration.head); + let usage = disk_usage::exclusive_usage(directory); + match registration.locked.clone() { + Some(lock) => WorktreeStatus::Locked { + lock, + head: registration.head.clone(), + holds, + usage, + }, + None => WorktreeStatus::Prunable { + head: registration.head.clone(), + holds, + usage, + }, + } +} + +// =========================================================================== +// what is done about it +// =========================================================================== + +/// One thing arguing against removing a worktree directory. +/// +/// A list of these rather than one, because a locked worktree that is also dirty +/// has two things wrong with it and a report naming one would be telling half the +/// truth. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WorktreeObjection { + /// git is holding it. Which says the harness asked for it to be left alone, + /// and says nothing whatever about whether anyone is working in it. + Locked { lock: Lock }, + /// Removing it would destroy this, or git could not say. + Holds(Objection), +} + +/// Why one worktree directory is staying. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WorktreeKept { + /// git still holds the registration and calls it neither locked nor + /// prunable, so as far as git is concerned this worktree is live. + StillHeld { head: WorktreeHead }, + /// At least one thing objected and `--force-worktrees` was not typed. + Objected(NonEmpty), +} + +/// Nothing objected, or `--force-worktrees` carried this directory past what did. +/// +/// Carried per directory rather than read from a run-wide flag, for the reason +/// `lifecycle::Promotion` spells out at length: a plan-wide boolean says "the +/// user insisted" about every directory in the plan, including the ones nothing +/// objected to, and the acting pass then skips its re-check for all of them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WorktreePromotion { + Unopposed, + Insisted { + despite: NonEmpty, + }, +} + +impl WorktreePromotion { + /// The insistence the acting pass re-applies to this directory alone. + pub(crate) fn insistence(&self) -> Insistence { + match self { + Self::Unopposed => Insistence::NotInsisted, + Self::Insisted { .. } => Insistence::Insisted, + } + } +} + +/// How git saw a directory this run is removing. +/// +/// Reported because the categories are different sentences: "git had already +/// forgotten these" needs nothing else done, where "git will let go of these once +/// it is asked" is what [`Git::worktree_prune`] follows for. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SeenAs { + Forgotten, + Prunable, + Locked, +} + +/// What `--prune` does about one directory under a `.claude/worktrees/`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum WorktreeDecision { + Remove { + seen_as: SeenAs, + usage: DiskUsage, + promotion: WorktreePromotion, + }, + Keep(WorktreeKept), +} + +/// What `--prune` does about one worktree directory. The only such place. +/// +/// Total over [`WorktreeStatus`]'s arms, so a fifth arm added later stops the +/// build rather than falling through into a deletion. +/// +/// `--force-worktrees` promotes exactly the arms that carry objections. It is not +/// a general override: [`WorktreeStatus::Held`] is not a refusal to be insisted +/// past, it is git saying this worktree is live, and there is nothing for a person +/// to mean by insisting on it. +pub(crate) fn decide(status: WorktreeStatus, insistence: Insistence) -> WorktreeDecision { + let (seen_as, usage, objections) = match status { + WorktreeStatus::Held { head } => { + return WorktreeDecision::Keep(WorktreeKept::StillHeld { head }); + } + WorktreeStatus::Forgotten { holds, usage } => { + (SeenAs::Forgotten, usage, objections_of(None, &holds)) + } + WorktreeStatus::Prunable { holds, usage, .. } => { + (SeenAs::Prunable, usage, objections_of(None, &holds)) + } + WorktreeStatus::Locked { + lock, holds, usage, .. + } => (SeenAs::Locked, usage, objections_of(Some(lock), &holds)), + }; + match NonEmpty::of(objections) { + None => WorktreeDecision::Remove { + seen_as, + usage, + promotion: WorktreePromotion::Unopposed, + }, + Some(objected) => match insistence { + Insistence::Insisted => WorktreeDecision::Remove { + seen_as, + usage, + promotion: WorktreePromotion::Insisted { despite: objected }, + }, + Insistence::NotInsisted => WorktreeDecision::Keep(WorktreeKept::Objected(objected)), + }, + } +} + +/// Everything arguing against removing one registered worktree, in the order a +/// report reads best in: what git is doing about it, then what it holds. +fn objections_of(lock: Option, holds: &Unsaved) -> Vec { + let mut objections = Vec::new(); + if let Some(lock) = lock { + objections.push(WorktreeObjection::Locked { lock }); + } + if let Some(objected) = objection(holds) { + objections.push(WorktreeObjection::Holds(objected)); + } + objections +} + +// =========================================================================== +// the sweep +// =========================================================================== + +/// One worktree directory this run will remove, what it frees, and why it may. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReclaimableWorktree { + pub path: PathBuf, + pub seen_as: SeenAs, + pub usage: DiskUsage, + pub promotion: WorktreePromotion, +} + +/// One worktree directory this run will leave standing, and why. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KeptWorktree { + pub path: PathBuf, + pub because: WorktreeKept, +} + +/// One clone's share of the sweep. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CloneWorktrees { + // Private, all of them, for the reason `PrunePlan`'s fields are: this is an + // answer, and a caller that could fill it could pair one clone with a + // classification of another clone's directories. + clone: PathBuf, + owner: String, + repo: String, + removing: Vec, + keeping: Vec, + registrations_with_nothing_here: RegistrationsWithNothingHere, +} + +impl CloneWorktrees { + /// The clone these worktrees live inside. + pub fn clone_path(&self) -> &Path { + &self.clone + } + + pub fn owner(&self) -> &str { + &self.owner + } + + pub fn repo(&self) -> &str { + &self.repo + } + + /// Biggest first, path breaking ties, so two runs over an unchanged cache + /// read alike. + pub fn removing(&self) -> &[ReclaimableWorktree] { + &self.removing + } + + pub fn keeping(&self) -> &[KeptWorktree] { + &self.keeping + } + + /// Registrations under a `.claude/worktrees/` with no directory here at all. + /// + /// Worth its own count because it is the one category with **no bytes behind + /// it**: there is nothing to free, and [`Git::worktree_prune`] is the whole of + /// the work. + pub fn registrations_with_nothing_here(&self) -> RegistrationsWithNothingHere { + self.registrations_with_nothing_here + } + + /// How the plan expects `git worktree prune` to go in this clone. + /// + /// **A forecast, and it says so.** It is a fold over the worktrees the *plan* + /// is keeping, which is everything the plan can know — and the acting pass + /// re-classifies every candidate, so it can withhold one the plan meant to + /// remove. That pass therefore builds its own gate from this one and folds its + /// own outcomes in, rather than reading a prediction: see [`reclaim`]. + pub fn metadata_gate(&self) -> MetadataGate { + self.keeping + .iter() + .fold(MetadataGate::default(), |gate, kept| { + gate.and_keeping(&kept.because) + }) + } + + /// Whether this clone's share of the sweep would change anything. + /// + /// **A registration with nothing behind it is not by itself work.** It frees + /// no bytes, and the only thing that clears it is the metadata prune — which + /// runs only where the gate is open. Where the gate is closed the registration + /// is being kept on purpose, so counting it as work makes `--prune` print a + /// section, ask the question and do nothing, every run, for as long as the + /// worktree it is protecting stays protected (devlaunch#442 review, S3). + fn nothing_to_do(&self) -> bool { + self.removing.is_empty() + && (self.registrations_with_nothing_here.none() || !self.metadata_gate().open()) + } + + fn nothing_to_say(&self) -> bool { + self.removing.is_empty() + && self.keeping.is_empty() + && self.registrations_with_nothing_here.none() + } +} + +/// Whether `git worktree prune` may still run in one clone. +/// +/// **A data-loss guard, not tidiness, and it answers to outcomes rather than to +/// predictions.** `git worktree prune` is all-or-nothing across a clone, and on a +/// host it drops the registration of *every* container-registered worktree — +/// including one being kept because it is dirty or holds commits nothing else +/// reaches. That registration is the only reason a later run can tell the +/// directory apart from a forgotten one, and [`WorktreeStatus::Forgotten`] is the +/// arm that deletes. So pruning there protects a worktree once and hands it over +/// the second time. +/// +/// Which is why this is a value that gets folded, and not a method on +/// [`CloneWorktrees`]. A gate read off the plan's keeps alone misses the candidate +/// the acting pass re-classified and withheld — the prune then ran, took that +/// worktree's registration with it, and the next run removed the directory and an +/// afternoon's uncommitted work outright, with no flag typed at either run +/// (devlaunch#442 review, S1). The plan and the act disagree by design; the fold +/// is what stops that disagreement being spendable. +/// +/// A lock survives a prune by git's own rule, so a worktree kept only for being +/// locked does not close the gate. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct MetadataGate { + closed: bool, +} + +impl MetadataGate { + /// Fold in one worktree that is staying, whichever pass decided it. + pub(crate) fn and_keeping(mut self, because: &WorktreeKept) -> Self { + self.closed |= registration_goes_on_protecting(because); + self + } + + /// Whether the prune may run. + pub fn open(self) -> bool { + !self.closed + } +} + +/// Whether the registration is what goes on protecting a worktree that is +/// staying. The single rule, which both passes reach through [`MetadataGate`]. +fn registration_goes_on_protecting(because: &WorktreeKept) -> bool { + match because { + // git holds this one, and git skips what it holds when it prunes. + WorktreeKept::StillHeld { .. } => false, + WorktreeKept::Objected(objections) => objections + .iter() + .any(|objection| matches!(objection, WorktreeObjection::Holds(_))), + } +} + +/// Registrations under a `.claude/worktrees/` with no directory in the clone, +/// told apart by *why* there is nothing there. +/// +/// **Two counts rather than one, because the sharpened spec on devlaunch#426 asks +/// the two apart and they are different facts.** Neither has host bytes behind it, +/// so `git worktree prune` is the whole of the work either way — but a registered +/// container path that never resolved here is the ordinary shape of every worktree +/// an agent made inside a devcontainer, where a registration naming a directory in +/// *this* clone that is not there is either somebody's own `rm -rf` or a previous +/// run interrupted between the removal and the prune. One is routine and one is +/// worth reading, and a single number said neither. +/// +/// The registered path is compared as a prefix and never resolved, which is the +/// module's whole discipline about these paths: on a host a container path names +/// nothing, and asking the filesystem about it is how that fact gets lost. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct RegistrationsWithNothingHere { + container_paths: usize, + deleted: usize, +} + +impl RegistrationsWithNothingHere { + /// Registered at a path outside this clone — a container's, which is what + /// every worktree an agent made inside a devcontainer carries. + pub fn container_paths(self) -> usize { + self.container_paths + } + + /// Registered at a path inside this clone, with nothing at it. + pub fn deleted(self) -> usize { + self.deleted + } + + /// Whether there are none of either. + pub fn none(self) -> bool { + self.container_paths == 0 && self.deleted == 0 + } + + fn count(&mut self, clone: &Path, registered_at: &Path) { + if registered_at.starts_with(clone) { + self.deleted += 1; + } else { + self.container_paths += 1; + } + } +} + +/// Every agent worktree inside the clones one `--prune` is keeping. +/// +/// Empty on the overwhelming majority of hosts, and cheap to find out: a clone +/// with no `.claude/worktrees/` costs one failed `read_dir` and no git at all. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct WorktreeSweep { + clones: Vec, +} + +impl WorktreeSweep { + /// One entry per clone that has anything to say, in scan order. + pub fn clones(&self) -> &[CloneWorktrees] { + &self.clones + } + + /// How many directories this run would remove. + /// + /// The counts are what the tests assert against; `dl` reads the per-clone + /// lists, so nothing outside this crate has ever wanted either of these. + #[cfg(test)] + pub(crate) fn removing(&self) -> usize { + self.clones.iter().map(|it| it.removing.len()).sum() + } + + /// How many it would leave, whatever the reason. + #[cfg(test)] + pub(crate) fn keeping(&self) -> usize { + self.clones.iter().map(|it| it.keeping.len()).sum() + } + + /// What the whole sweep would free. + pub fn freed(&self) -> DiskUsage { + disk_usage::total_usage( + self.clones + .iter() + .flat_map(|it| it.removing.iter().map(|worktree| worktree.usage.clone())), + ) + } + + /// Whether this sweep would change anything on disk or in git. + pub fn nothing_to_do(&self) -> bool { + self.clones.iter().all(CloneWorktrees::nothing_to_do) + } + + /// Whether there is nothing to say about it either. + 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 git says about one clone's worktrees, read once. +/// +/// One `git worktree list` per clone rather than one per candidate, and the +/// reason it is a value rather than a parameter list: the acting pass has to +/// classify each directory *again* immediately before removing it, and it must +/// re-read git to do that. Sharing this type is what keeps the two passes asking +/// the same question in the same words. +pub(crate) struct ClonePicture { + registered: Vec, +} + +impl ClonePicture { + /// What git says about `clone`, or nothing when git will not say. + /// + /// A refusal takes the whole clone out of the sweep. Reading one as "git named + /// no registrations" would classify every directory as forgotten, and + /// forgotten is the arm that deletes. + pub(crate) fn of(git: &Git<'_>, clone: &Path) -> Option { + let listing = git.worktree_listing(clone).said()?; + Some(Self { + registered: registrations(&listing), + }) + } + + /// Which arm `directory` is, or nothing when it is not a linked worktree of + /// this clone at all. + pub(crate) fn status_of( + &self, + git: &Git<'_>, + clone: &Path, + bare: Option<&Path>, + directory: &Path, + ) -> Option { + let inside = inside_the_clone(clone, directory)?; + let name = linked_worktree_name(directory)?; + let registration = self.registered.iter().find(|it| it.inside == inside); + let admin = admin_dir(clone, &name); + Some(worktree_status( + git, + clone, + bare, + directory, + admin.as_deref(), + registration, + )) + } + + /// Registrations under a `.claude/worktrees/` with no directory in `clone`, + /// counted by why there is nothing there. + fn registrations_with_nothing_here(&self, clone: &Path) -> RegistrationsWithNothingHere { + let mut counted = RegistrationsWithNothingHere::default(); + for registration in &self.registered { + if !clone.join(®istration.inside).exists() { + counted.count(clone, ®istration.registered_at); + } + } + counted + } +} + +/// Classify the agent worktrees inside one clone. +/// +/// `None` when there is no `.claude/worktrees/` at all, which is the answer for +/// nearly every clone and what makes this affordable on the prune path: no +/// `git worktree list`, no disk walk, one `read_dir` that fails. +/// +/// `bare` is the sibling repository cache, consulted for reachability; see +/// [`InTheCache`] for why the clone alone is not enough. +pub(crate) fn sweep_clone( + git: &Git<'_>, + clone: &Path, + owner: &str, + repo: &str, + bare: Option<&Path>, + insistence: Insistence, +) -> Option { + let mut pending = children_of(&worktrees_dir(clone))?; + let picture = ClonePicture::of(git, clone)?; + let mut removing = Vec::new(); + let mut keeping = Vec::new(); + while let Some(directory) = pending.pop() { + let Some(status) = picture.status_of(git, clone, bare, &directory) else { + // Not a linked worktree: a plain directory that happens to sit here, + // or one whose `.git` says something else. Not devlaunch's to remove + // and not descended into either. + continue; + }; + match decide(status, insistence) { + WorktreeDecision::Remove { + seen_as, + usage, + promotion, + } => removing.push(ReclaimableWorktree { + path: directory, + seen_as, + usage, + promotion, + }), + WorktreeDecision::Keep(because) => { + // Only into the ones that are staying. A directory that is going + // takes everything inside it, so descending into one would count + // the same bytes twice and offer a directory that will not be + // there. + pending.extend(children_of(&worktrees_dir(&directory)).unwrap_or_default()); + keeping.push(KeptWorktree { + path: directory, + because, + }); + } + } + } + removing.sort_by(|left, right| { + right + .usage + .known_bytes() + .cmp(&left.usage.known_bytes()) + .then_with(|| left.path.cmp(&right.path)) + }); + keeping.sort_by(|left, right| left.path.cmp(&right.path)); + let registrations_with_nothing_here = picture.registrations_with_nothing_here(clone); + Some(CloneWorktrees { + clone: clone.to_path_buf(), + owner: owner.to_owned(), + repo: repo.to_owned(), + removing, + keeping, + registrations_with_nothing_here, + }) +} + +/// 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]) +} + +/// The admin directory `name` names inside `clone`, when it is there. +fn admin_dir(clone: &Path, name: &str) -> Option { + let admin = clone.join(ADMIN_DIR[0]).join(ADMIN_DIR[1]).join(name); + admin.is_dir().then_some(admin) +} + +/// The directories directly inside `root`, or nothing when there is no `root`. +/// +/// Symlinks are skipped rather than followed, for the reason the clone scan skips +/// them: following one walks a removal out of the cache directory `--prune` is +/// scoped to, and that scoping is what makes a scratch-cache run harmless. +fn children_of(root: &Path) -> Option> { + let mut found: Vec = std::fs::read_dir(root) + .ok()? + .filter_map(Result::ok) + .filter(|entry| { + entry + .file_type() + .map(|kind| kind.is_dir() && !kind.is_symlink()) + .unwrap_or(false) + }) + .map(|entry| entry.path()) + .collect(); + found.sort(); + Some(found) +} + +// =========================================================================== +// the acting pass +// =========================================================================== + +/// One worktree directory the plan meant to remove that the acting pass would +/// not. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WithheldWorktree { + pub path: PathBuf, + /// Why it is staying — and it is worth saying this was not so when the plan + /// was printed. + pub because: WorktreeKept, +} + +/// What the acting pass did about the agent worktrees. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct WorktreeReport { + pub removed: Vec, + pub withheld: Vec, + /// Directories that would not come away. Not empty means the run is + /// unfinished, and what did go is still gone. + pub refused: Vec, + /// Clones whose `git worktree prune` was held back, because something in + /// them is being kept for what it holds and the registration is what keeps on + /// protecting it. Named rather than counted: it is the one line that explains + /// why git still lists a worktree whose directory is not there. + pub metadata_held_back: Vec, + /// Clones where the prune ran and would not. + pub metadata_refused: Vec, +} + +impl WorktreeReport { + /// What this run actually freed, with the figures the plan measured, so what + /// somebody is told they got back is what they said yes to. + pub fn freed(&self) -> DiskUsage { + disk_usage::total_usage(self.removed.iter().map(|it| it.usage.clone())) + } + + pub fn nothing_to_say(&self) -> bool { + self.removed.is_empty() + && self.withheld.is_empty() + && self.refused.is_empty() + && self.metadata_held_back.is_empty() + && self.metadata_refused.is_empty() + } +} + +/// Carry out one clone's share of the sweep, and add what happened to `report`. +/// +/// The caller holds the repository lock. **Every directory is classified again, +/// under that lock, immediately before it goes**, and only what this pass *also* +/// finds removable is removed. The lock is not enough on its own and cannot be +/// made enough: a container running `git worktree add` is not a participant in +/// it, so the plan a person answered can have been overtaken by a worktree that +/// is now registered and live. The approved set can therefore shrink between the +/// report and the act and can never grow, which is the direction that costs a +/// command rather than somebody's afternoon. +/// +/// **The directory goes first and `git worktree prune` follows.** Interrupted +/// between the two, git is left holding a registration whose directory is gone — +/// which is exactly the prunable state the next run already handles, so the run +/// heals itself. The other order leaves a registered, present worktree with its +/// metadata dropped, which nothing recognises and the next run removes outright. +pub(crate) fn reclaim( + git: &Git<'_>, + clone: &CloneWorktrees, + bare: Option<&Path>, + report: &mut WorktreeReport, +) { + let Some(picture) = ClonePicture::of(git, &clone.clone) else { + // git will not say what it holds any more, so nothing here is removable: + // the classification the plan rests on cannot be re-taken. + report + .withheld + .extend(clone.removing.iter().map(|worktree| WithheldWorktree { + path: worktree.path.clone(), + because: WorktreeKept::Objected(NonEmpty::one(WorktreeObjection::Holds( + Objection::CouldNotTell(CouldNotTell::GitCouldNotRead { + clone: clone.clone.clone(), + reason: + "git would not list this clone's worktrees a second time".to_owned(), + }), + ))), + })); + return; + }; + // Seeded from the plan's keeps and then fed every outcome this pass reaches, + // so the thing the prune is gated on is what happened rather than what was + // foreseen. See [`MetadataGate`] for what reading the forecast here cost. + let mut gate = clone.metadata_gate(); + let mut removed_anything = false; + for worktree in &clone.removing { + let status = picture.status_of(git, &clone.clone, bare, &worktree.path); + let decision = match status { + // The directory is no longer a linked worktree of this clone — it was + // removed by hand, or something else is there now. Either way this + // pass has nothing it can say is safe to delete. + None => WorktreeDecision::Keep(WorktreeKept::Objected(NonEmpty::one( + WorktreeObjection::Holds(Objection::CouldNotTell(CouldNotTell::CouldNotLook { + clone: worktree.path.clone(), + error: "this is no longer a linked worktree of the clone".to_owned(), + })), + ))), + Some(status) => decide(status, worktree.promotion.insistence()), + }; + match decision { + WorktreeDecision::Keep(because) => { + gate = gate.and_keeping(&because); + report.withheld.push(WithheldWorktree { + path: worktree.path.clone(), + because, + }); + } + WorktreeDecision::Remove { .. } => { + match remove_tree_as_far_as_it_goes(&worktree.path) { + Removal::Everything => { + removed_anything = true; + report.removed.push(worktree.clone()); + } + Removal::WhatItCould(refused) | Removal::Nothing(refused) => { + report.refused.extend(refused.iter().cloned()); + } + } + } + } + } + // A clone whose only outstanding work is a registration with nothing behind it + // still has that work done, or the plan would go on offering it forever + // (devlaunch#442 review, S3). + if !removed_anything && clone.registrations_with_nothing_here.none() { + return; + } + if !gate.open() { + report.metadata_held_back.push(clone.clone.clone()); + return; + } + if git.worktree_prune(&clone.clone).said().is_none() { + report.metadata_refused.push(clone.clone.clone()); + } +} + +#[cfg(test)] +mod tests; diff --git a/rust/devlaunch-core/src/flows/agent_worktrees/tests.rs b/rust/devlaunch-core/src/flows/agent_worktrees/tests.rs new file mode 100644 index 00000000..8f7f0cf3 --- /dev/null +++ b/rust/devlaunch-core/src/flows/agent_worktrees/tests.rs @@ -0,0 +1,904 @@ +//! What the agent-worktree sweep classifies, at the seam the whole ticket turns +//! on. +//! +//! **Real git, real filesystem, real registrations.** Every fact this module acts +//! on comes out of `git worktree list --porcelain` and out of `git status` run +//! through an admin directory, and a faked spawn answers a clean exit with empty +//! output — which reads as "git named no registrations" and "this worktree is +//! clean", the two answers that delete. So these build a clone with real +//! worktrees in it and then rewrite the registrations to container paths, which is +//! the shape a host sees and the shape none of this can be tested without. + +use std::path::{Path, PathBuf}; + +use devlaunch_runner::ProcessRunner; + +use super::*; +use crate::domain::workspace_state::Unsaved; +use crate::flows::repo_manager::tests::run_git; + +/// A cache holding one bare repository and one clone of it, with real worktrees +/// under the clone's `.claude/worktrees/`. +/// +/// The layout is the one `--prune` scans: `///.bare` beside +/// `///`, so the sibling cache the reachability probe +/// consults is where the real thing puts it. +struct Clone { + dir: tempfile::TempDir, + bare: PathBuf, + clone: PathBuf, +} + +const OWNER: &str = "o"; +const REPO: &str = "r"; + +impl Clone { + /// A clone of a one-commit repository, with nothing under `.claude/` yet. + fn new() -> Self { + let dir = tempfile::tempdir().expect("a scratch directory"); + let root = dir.path().to_path_buf(); + let seed = root.join("seed"); + std::fs::create_dir_all(&seed).expect("a seed directory"); + run_git(&root, &["init", "-b", "main", &seed.display().to_string()]); + std::fs::write(seed.join("README.md"), "seed\n").expect("a README"); + commit(&seed, "seed"); + + // The forge stands in for GitHub; the bare cache is what devlaunch fetches + // into and what the reachability probe asks. + let forge = root.join("forge.git"); + clone_bare(&root, &seed, &forge); + let repo_dir = root.join("repos").join(OWNER).join(REPO); + std::fs::create_dir_all(&repo_dir).expect("the repository directory"); + let bare = repo_dir.join(".bare"); + clone_bare(&root, &forge, &bare); + + let clone = repo_dir.join("ws-one"); + run_git( + &root, + &[ + "clone", + &bare.display().to_string(), + &clone.display().to_string(), + ], + ); + run_git( + &clone, + &["remote", "set-url", "origin", &forge.display().to_string()], + ); + Self { dir, bare, clone } + } + + fn tmp(&self) -> &Path { + self.dir.path() + } + + /// One real agent worktree at `/.claude/worktrees/` on its own + /// branch, pushed to the forge and fetched into the cache — the ordinary + /// finished-task shape, holding nothing. + fn worktree(&self, leaf: &str) -> PathBuf { + let path = worktrees_dir(&self.clone).join(leaf); + std::fs::create_dir_all(worktrees_dir(&self.clone)).expect("the worktrees directory"); + run_git( + &self.clone, + &["worktree", "add", "-b", leaf, &path.display().to_string()], + ); + run_git(&self.clone, &["push", "origin", leaf]); + self.fetch(); + path + } + + /// A worktree nested inside another one, the way an agent session running in + /// a worktree creates one. + fn nested(&self, inside: &Path, leaf: &str) -> PathBuf { + let path = worktrees_dir(inside).join(leaf); + std::fs::create_dir_all(worktrees_dir(inside)).expect("the nested worktrees directory"); + run_git( + &self.clone, + &["worktree", "add", "-b", leaf, &path.display().to_string()], + ); + run_git(&self.clone, &["push", "origin", leaf]); + self.fetch(); + path + } + + /// Bring the cache up to date with the forge, the way the fetch sweep does. + fn fetch(&self) { + run_git( + &self.bare, + &["fetch", "origin", "+refs/heads/*:refs/heads/*", "--prune"], + ); + } + + /// Rewrite every registration to the container path it would really carry, + /// which is what makes git call them prunable and what a host actually sees. + fn containerise(&self) { + let admin = self.clone.join(".git").join("worktrees"); + for entry in std::fs::read_dir(&admin).expect("the admin directory") { + let gitdir = entry.expect("an admin entry").path().join("gitdir"); + let registered = std::fs::read_to_string(&gitdir).expect("a gitdir file"); + std::fs::write( + &gitdir, + registered.replace( + &self.clone.display().to_string(), + "/workspaces/devlaunch-container", + ), + ) + .expect("the rewritten gitdir"); + } + } + + /// The sweep, as `--prune` would take it. + fn sweep(&self, insistence: Insistence) -> Option { + let runner = ProcessRunner::new(); + let git = Git::new(&runner); + sweep_clone(&git, &self.clone, OWNER, REPO, Some(&self.bare), insistence) + } + + /// The sweep, insisting on nothing. + fn plan(&self) -> CloneWorktrees { + self.sweep(Insistence::NotInsisted) + .expect("a sweep of a clone that has worktrees") + } +} + +fn clone_bare(cwd: &Path, from: &Path, to: &Path) { + run_git( + cwd, + &[ + "clone", + "--bare", + &from.display().to_string(), + &to.display().to_string(), + ], + ); +} + +fn commit(work: &Path, message: &str) { + run_git(work, &["add", "-A"]); + run_git(work, &["commit", "-m", message]); +} + +/// The paths a sweep would remove, in the order it reports them. +fn removing(found: &CloneWorktrees) -> Vec { + found.removing().iter().map(|it| it.path.clone()).collect() +} + +/// The paths it would leave. +fn keeping(found: &CloneWorktrees) -> Vec { + found.keeping().iter().map(|it| it.path.clone()).collect() +} + +/// Why it keeps `path`, failing loudly if it is not in the sweep at all. +/// +/// Every assertion about a directory *surviving* goes through here rather than +/// through an existence check, because "it is still there" is true of a worktree +/// kept for the right reason and of one no guard ever looked at. +fn kept_because(found: &CloneWorktrees, path: &Path) -> WorktreeKept { + let mut matched: Vec<&KeptWorktree> = found + .keeping() + .iter() + .filter(|kept| kept.path == path) + .collect(); + assert_eq!( + matched.len(), + 1, + "expected exactly one report line for {}: {:?}", + path.display(), + found.keeping() + ); + matched.remove(0).because.clone() +} + +// ======================================================================= +// a clone with nothing in it costs nothing +// ======================================================================= + +#[test] +fn a_clone_with_no_worktrees_directory_is_not_swept_at_all() { + // The answer for nearly every clone, and the reason this is affordable on the + // prune path: no `git worktree list`, no disk walk. + let world = Clone::new(); + + assert_eq!(world.sweep(Insistence::NotInsisted), None); +} + +// ======================================================================= +// the four categories (devlaunch#426) +// ======================================================================= + +#[test] +fn a_worktree_git_still_holds_is_left_alone() { + // The registrations resolve, so git calls this worktree live. This is the arm + // that stops a run inside a container collecting its own worktrees. + let world = Clone::new(); + let live = world.worktree("agent-live"); + + let found = world.plan(); + + assert!(removing(&found).is_empty(), "{:?}", removing(&found)); + assert!(matches!( + kept_because(&found, &live), + WorktreeKept::StillHeld { .. } + )); +} + +#[test] +fn a_registration_git_calls_prunable_is_reclaimed() { + // The host's ordinary case: registered from inside the container, so the path + // git holds resolves to nothing and git itself says the registration can go. + let world = Clone::new(); + let finished = world.worktree("agent-finished"); + world.containerise(); + + let found = world.plan(); + + assert_eq!(removing(&found), [finished]); + assert_eq!(found.removing()[0].seen_as, SeenAs::Prunable); + assert_eq!( + found.removing()[0].promotion, + WorktreePromotion::Unopposed, + "nothing objected, so nothing was insisted past" + ); +} + +#[test] +fn a_directory_git_has_already_forgotten_is_reclaimed() { + // devlaunch#426's category 1: `git worktree prune` has already dropped the + // metadata and the directory is the whole of what is left. + let world = Clone::new(); + let left_behind = world.worktree("agent-forgotten"); + world.containerise(); + run_git(&world.clone, &["worktree", "prune"]); + + let found = world.plan(); + + assert_eq!(removing(&found), [left_behind]); + assert_eq!(found.removing()[0].seen_as, SeenAs::Forgotten); +} + +#[test] +fn a_locked_worktree_is_never_removed_without_being_asked_for() { + // A harness locks a worktree so it is not collected mid-run, so a lock may + // mean in use right now or may be what a killed session left behind. + let world = Clone::new(); + let locked = world.worktree("agent-locked"); + run_git( + &world.clone, + &["worktree", "lock", &locked.display().to_string()], + ); + world.containerise(); + + let found = world.plan(); + + assert!(removing(&found).is_empty(), "{:?}", removing(&found)); + let because = kept_because(&found, &locked); + let WorktreeKept::Objected(objections) = &because else { + panic!("expected an objection, got {because:?}"); + }; + assert!( + objections + .iter() + .any(|it| matches!(it, WorktreeObjection::Locked { .. })), + "{objections:?}" + ); +} + +#[test] +fn insisting_carries_a_locked_worktree_past_its_lock() { + let world = Clone::new(); + let locked = world.worktree("agent-locked"); + run_git( + &world.clone, + &["worktree", "lock", &locked.display().to_string()], + ); + world.containerise(); + + let found = world + .sweep(Insistence::Insisted) + .expect("a sweep of a clone that has worktrees"); + + assert_eq!(removing(&found), [locked]); + assert_eq!(found.removing()[0].seen_as, SeenAs::Locked); + // What was insisted past travels with the directory it was insisted past for, + // so the report can say it on that line. + assert!(matches!( + &found.removing()[0].promotion, + WorktreePromotion::Insisted { .. } + )); +} + +// ======================================================================= +// what a worktree holds (the sharpened Ask 3) +// ======================================================================= + +#[test] +fn an_uncommitted_edit_keeps_a_worktree_that_is_otherwise_collectable() { + // Reachability says nothing about the working tree. A worktree on a + // fully-merged branch with unstaged edits in it reads as safe to delete under + // a commits-only rule, and deleting it loses the edits. + let world = Clone::new(); + let dirty = world.worktree("agent-dirty"); + std::fs::write(dirty.join("README.md"), "an afternoon of edits\n").expect("an edit"); + world.containerise(); + + let found = world.plan(); + + assert!(removing(&found).is_empty(), "{:?}", removing(&found)); + assert!(matches!( + kept_because(&found, &dirty), + WorktreeKept::Objected(_) + )); +} + +#[test] +fn an_untracked_file_keeps_it_too() { + // An agent's scratch notes are not less lost for never having been added. + let world = Clone::new(); + let dirty = world.worktree("agent-notes"); + std::fs::write(dirty.join("notes.md"), "what I was about to do\n").expect("a note"); + world.containerise(); + + let found = world.plan(); + + assert!(removing(&found).is_empty(), "{:?}", removing(&found)); +} + +#[test] +fn a_commit_that_was_never_pushed_keeps_the_worktree() { + let world = Clone::new(); + let ahead = world.worktree("agent-ahead"); + std::fs::write(ahead.join("work.md"), "committed and nowhere else\n").expect("a file"); + commit(&ahead, "ahead"); + world.containerise(); + + let found = world.plan(); + + assert!(removing(&found).is_empty(), "{:?}", removing(&found)); + assert!(matches!( + kept_because(&found, &ahead), + WorktreeKept::Objected(_) + )); +} + +#[test] +fn a_detached_head_that_is_ahead_keeps_the_worktree() { + // An agent worktree need not be on a branch, and a check keyed on a branch + // name finds no branch and therefore nothing to protect. + let world = Clone::new(); + let detached = world.worktree("agent-detached"); + std::fs::write(detached.join("work.md"), "committed on no branch\n").expect("a file"); + commit(&detached, "ahead"); + run_git(&detached, &["checkout", "--detach"]); + world.containerise(); + + let found = world.plan(); + + assert!(removing(&found).is_empty(), "{:?}", removing(&found)); + assert!(matches!( + kept_because(&found, &detached), + WorktreeKept::Objected(_) + )); +} + +#[test] +fn a_detached_head_on_a_commit_the_cache_has_is_collectable() { + // Detached is not by itself a reason to keep anything: the question is whether + // what it points at is anywhere else. + let world = Clone::new(); + let detached = world.worktree("agent-detached"); + run_git(&detached, &["checkout", "--detach"]); + world.containerise(); + + let found = world.plan(); + + assert_eq!(removing(&found), [detached]); +} + +// ======================================================================= +// the stale-ref trap +// ======================================================================= + +#[test] +fn work_pushed_after_the_clone_was_cut_is_found_in_the_cache() { + // The clone's own `refs/remotes/origin/*` is as of clone time and never + // fetched again, so asking it alone reports pushed-and-merged branches as + // unpushed -- which keeps every byte forever and makes the flag the only way + // to reclaim anything. The sibling cache is the thing that gets fetched. + let world = Clone::new(); + let pushed = world.worktree("agent-pushed"); + std::fs::write(pushed.join("work.md"), "pushed after the clone was cut\n").expect("a file"); + commit(&pushed, "pushed"); + run_git(&pushed, &["push", "origin", "agent-pushed"]); + world.fetch(); + // The clone's own view of the remote goes back to what it was when the clone + // was cut. That is the state the module header describes -- a clone that is + // never fetched into, whose `refs/remotes/origin/*` is as of clone time and + // can be absent -- reached here in one step instead of by waiting for a + // container to be rebuilt. + run_git( + &world.clone, + &["update-ref", "-d", "refs/remotes/origin/agent-pushed"], + ); + world.containerise(); + + // The clone alone now cannot see it. This is the trap, asserted so the + // fixture cannot quietly stop reproducing it. + let runner = ProcessRunner::new(); + let git = Git::new(&runner); + let seen_by_the_clone = git + .unpushed_commits(&world.clone, "refs/heads/agent-pushed") + .said() + .expect("the clone answers"); + assert!( + !seen_by_the_clone.trim().is_empty(), + "the clone should still think this branch is unpushed" + ); + + let found = world.plan(); + + assert_eq!(removing(&found), [pushed]); +} + +// ======================================================================= +// nesting (55 GB in one clone) +// ======================================================================= + +#[test] +fn a_worktree_inside_a_kept_worktree_is_reclaimed_on_its_own() { + // An agent session running inside an agent worktree makes its worktrees under + // that one. Scanning only the top level is how one clone reached 55 GB. + let world = Clone::new(); + let outer = world.worktree("agent-outer"); + let inner = world.nested(&outer, "agent-inner"); + // The outer one is dirty for a reason of its own, so it stays and the scan has + // to descend into it to find the inner one. + std::fs::write(outer.join("notes.md"), "keep me\n").expect("a note"); + world.containerise(); + + let found = world.plan(); + + assert_eq!(removing(&found), [inner]); + assert_eq!(keeping(&found), [outer]); +} + +#[test] +fn a_worktree_inside_one_that_is_going_is_not_reported_twice() { + // The outer directory takes everything inside it, so reporting the inner one + // as well would count the same bytes twice and offer a directory that will not + // be there. + let world = Clone::new(); + let outer = world.worktree("agent-outer"); + world.nested(&outer, "agent-inner"); + world.containerise(); + + let found = world.plan(); + + assert_eq!(removing(&found), [outer]); +} + +// ======================================================================= +// what is and is not devlaunch's to delete +// ======================================================================= + +#[test] +fn a_plain_directory_sitting_in_the_worktrees_place_is_not_touched() { + // Confirmed by the `.git` gitfile naming an admin directory, not by the path + // shape: a directory that happens to be here is not devlaunch's to remove. + let world = Clone::new(); + let intruder = worktrees_dir(&world.clone).join("not-a-worktree"); + std::fs::create_dir_all(&intruder).expect("a plain directory"); + std::fs::write(intruder.join("a-file"), "mine\n").expect("a file"); + + let found = world + .sweep(Insistence::Insisted) + .expect("a clone with a `.claude/worktrees/` is swept"); + + assert!( + removing(&found).is_empty() && keeping(&found).is_empty(), + "nothing here is a worktree, so there is nothing to say: {found:?}" + ); + assert!(intruder.exists()); +} + +#[test] +fn a_symlink_in_the_worktrees_place_is_stepped_over() { + // Following one would walk a removal out of the cache directory `--prune` is + // scoped to, and that scoping is what makes a scratch-cache run harmless. + let world = Clone::new(); + let outside = world.tmp().join("somewhere-else"); + std::fs::create_dir_all(&outside).expect("a directory outside the cache"); + std::fs::create_dir_all(worktrees_dir(&world.clone)).expect("the worktrees directory"); + let link = worktrees_dir(&world.clone).join("agent-link"); + std::os::unix::fs::symlink(&outside, &link).expect("a symlink"); + + let found = world + .sweep(Insistence::Insisted) + .expect("a clone with a `.claude/worktrees/` is swept"); + + assert!( + removing(&found).is_empty() && keeping(&found).is_empty(), + "a symlink is not a candidate: {found:?}" + ); + assert!(outside.exists()); +} + +// ======================================================================= +// what the dirty check sees under a `.claude/worktrees/` (devlaunch#442, S4) +// ======================================================================= + +#[test] +fn plain_content_under_a_candidates_worktrees_place_is_work() { + // The dangerous residue of excluding the place instead of the thing. This + // directory is not a linked worktree, so the sweep never reports it -- and + // with `.claude/worktrees/` excluded from the dirty check wholesale, nothing + // protected it either. It went when its parent did, unreported. + let world = Clone::new(); + let finished = world.worktree("agent-finished"); + let scratch = worktrees_dir(&finished).join("scratch"); + std::fs::create_dir_all(&scratch).expect("a plain directory"); + std::fs::write(scratch.join("notes.md"), "an afternoon\n").expect("a note"); + world.containerise(); + + let found = world.plan(); + + assert!(removing(&found).is_empty(), "{:?}", removing(&found)); + assert!( + matches!(kept_because(&found, &finished), WorktreeKept::Objected(_)), + "content nothing else accounts for has to object" + ); +} + +#[test] +fn a_tracked_file_modified_under_the_worktrees_place_is_still_work() { + // Contrived -- the harness's directory is normally ignored -- but it is real + // uncommitted work, and a pathspec that hides a whole path hides this too. + let world = Clone::new(); + let finished = world.worktree("agent-tracked"); + let tracked = worktrees_dir(&finished).join("keep.md"); + std::fs::create_dir_all(tracked.parent().expect("a parent")).expect("the directory"); + std::fs::write(&tracked, "committed\n").expect("the file"); + commit(&finished, "a tracked file where the sweep looks"); + run_git(&finished, &["push", "origin", "agent-tracked"]); + world.fetch(); + std::fs::write(&tracked, "edited, and nowhere else\n").expect("the edit"); + world.containerise(); + + let found = world.plan(); + + assert!(removing(&found).is_empty(), "{:?}", removing(&found)); + assert!( + matches!(kept_because(&found, &finished), WorktreeKept::Objected(_)), + "an edit to a file the repository knows about is work" + ); +} + +// ======================================================================= +// the arm that deletes is the hardest one to reach (devlaunch#442, S1) +// ======================================================================= + +#[test] +fn a_directory_whose_admin_directory_is_here_is_asked_what_it_holds() { + // git has let go of the name -- nothing in the listing joins to this + // directory any more -- but the admin directory it wrote is still here, so + // there is an index and a HEAD to ask through. A classification that lands on + // the deleting arm with a probe available must take the probe: neither git + // dropping a name nor this module's suffix join missing one should cost + // somebody an afternoon. + let world = Clone::new(); + let unsaved = world.worktree("agent-unsaved"); + std::fs::write(unsaved.join("notes.md"), "an afternoon\n").expect("a note"); + let gitdir = world + .clone + .join(".git") + .join("worktrees") + .join("agent-unsaved") + .join("gitdir"); + std::fs::write(&gitdir, "/workspaces/a-container/elsewhere/.git\n") + .expect("a registration that no longer looks like a worktrees path"); + + let found = world.plan(); + + assert!(removing(&found).is_empty(), "{:?}", removing(&found)); + assert!( + matches!(kept_because(&found, &unsaved), WorktreeKept::Objected(_)), + "an unjoinable registration is not a licence to delete" + ); +} + +#[test] +fn a_registration_whose_admin_directory_vanished_is_still_asked_about_its_commits() { + // The window is a concurrent `git worktree prune` between the listing and the + // look. No index and no HEAD, so nothing can be asked of the working tree -- + // but the registration still names a head, and this is the arm that deletes, + // so the commits get asked about. + let world = Clone::new(); + let unpushed = worktrees_dir(&world.clone).join("agent-unpushed"); + std::fs::create_dir_all(worktrees_dir(&world.clone)).expect("the worktrees directory"); + run_git( + &world.clone, + &[ + "worktree", + "add", + "-b", + "agent-unpushed", + &unpushed.display().to_string(), + ], + ); + // A commit that was never pushed anywhere, so nothing else reaches it. + std::fs::write(unpushed.join("work.md"), "nowhere else\n").expect("a file"); + commit(&unpushed, "work nothing else has"); + world.containerise(); + // The listing still names it; the admin directory it named does not exist. + let listing = run_git(&world.clone, &["worktree", "list", "--porcelain"]); + assert!(listing.contains("agent-unpushed"), "{listing}"); + let picture = { + let runner = ProcessRunner::new(); + let git = Git::new(&runner); + ClonePicture::of(&git, &world.clone).expect("git listed the clone") + }; + std::fs::remove_dir_all( + world + .clone + .join(".git") + .join("worktrees") + .join("agent-unpushed"), + ) + .expect("the admin directory, as a concurrent prune leaves it"); + + let runner = ProcessRunner::new(); + let git = Git::new(&runner); + let status = picture + .status_of(&git, &world.clone, Some(&world.bare), &unpushed) + .expect("a linked worktree of this clone"); + + let WorktreeStatus::Forgotten { holds, .. } = &status else { + panic!("expected the forgotten arm, got {status:?}"); + }; + assert!( + matches!(holds, Unsaved::WouldLose(_)), + "the commit is nowhere else and the registration could say so: {holds:?}" + ); + assert!(matches!( + decide(status, Insistence::NotInsisted), + WorktreeDecision::Keep(_) + )); +} + +#[test] +fn a_gitfile_naming_the_clones_own_admin_directory_is_not_a_worktree() { + // The tail of a gitfile is file content, and file content is not trusted to + // be a name: `..` would name the clone's own `.git` and have this module + // probe, and then remove, something that is not one worktree. + let world = Clone::new(); + let liar = worktrees_dir(&world.clone).join("agent-liar"); + std::fs::create_dir_all(&liar).expect("a directory"); + std::fs::write( + liar.join(".git"), + "gitdir: /workspaces/x/.git/worktrees/..\n", + ) + .expect("a gitfile naming no worktree"); + std::fs::write(liar.join("a-file"), "mine\n").expect("a file"); + + let found = world + .sweep(Insistence::Insisted) + .expect("a clone with a `.claude/worktrees/` is swept"); + + assert!( + removing(&found).is_empty() && keeping(&found).is_empty(), + "nothing here names one worktree, so there is nothing to say: {found:?}" + ); + assert!(liar.exists()); +} + +// ======================================================================= +// the prune-metadata guard +// ======================================================================= + +#[test] +fn metadata_is_pruned_when_nothing_was_held_back_for_what_it_holds() { + let world = Clone::new(); + world.worktree("agent-finished"); + world.containerise(); + + let found = world.plan(); + + assert!(found.metadata_gate().open()); +} + +#[test] +fn metadata_is_not_pruned_while_a_worktree_is_kept_for_what_it_holds() { + // `git worktree prune` is all-or-nothing across a clone and would drop the + // registration of the dirty one too -- turning it into a forgotten directory, + // which the next run removes outright. The guard would protect it once and + // hand it over the second time. + let world = Clone::new(); + world.worktree("agent-finished"); + let dirty = world.worktree("agent-dirty"); + std::fs::write(dirty.join("notes.md"), "unsaved\n").expect("a note"); + world.containerise(); + + let found = world.plan(); + + assert_eq!(removing(&found).len(), 1); + assert!(!found.metadata_gate().open()); +} + +#[test] +fn a_lock_does_not_hold_the_metadata_prune_back() { + // git skips locked registrations itself, so a locked worktree keeps its + // registration through a prune and goes on being protected by it. + let world = Clone::new(); + world.worktree("agent-finished"); + let locked = world.worktree("agent-locked"); + run_git( + &world.clone, + &["worktree", "lock", &locked.display().to_string()], + ); + world.containerise(); + + let found = world.plan(); + + assert_eq!(removing(&found).len(), 1); + assert!(found.metadata_gate().open()); +} + +// ======================================================================= +// registrations with nothing behind them +// ======================================================================= + +#[test] +fn a_registration_whose_directory_is_gone_is_counted_and_frees_nothing() { + // Prunable metadata with no host bytes behind it: either a container path + // that never resolved here or a directory somebody removed by hand. + let world = Clone::new(); + let removed_by_hand = world.worktree("agent-gone"); + world.worktree("agent-here"); + world.containerise(); + std::fs::remove_dir_all(&removed_by_hand).expect("a directory removed by hand"); + + let found = world.plan(); + + assert_eq!(found.registrations_with_nothing_here().container_paths(), 1); + assert_eq!( + found.registrations_with_nothing_here().deleted(), + 0, + "the registration named a container path, not a path in this clone" + ); + assert_eq!(removing(&found).len(), 1, "{:?}", removing(&found)); +} + +#[test] +fn a_registration_naming_a_path_in_this_clone_is_told_apart_from_a_container_one() { + // The sharpened spec asks the two apart. Neither has bytes behind it, so the + // metadata prune is the whole of the work either way -- but a container path + // is the ordinary shape of every worktree an agent made inside a + // devcontainer, and a path in this clone with nothing at it is somebody's own + // removal or a run interrupted between the removal and the prune. One number + // said neither (devlaunch#442 review, S5). + let world = Clone::new(); + let deleted = world.worktree("agent-deleted"); + world.worktree("agent-here"); + // No `containerise` for this one: the registration keeps the host path it was + // made with, and the directory it names is gone. + std::fs::remove_dir_all(&deleted).expect("a directory removed by hand"); + + let found = world.plan(); + + let nothing_here = found.registrations_with_nothing_here(); + assert_eq!(nothing_here.deleted(), 1); + assert_eq!(nothing_here.container_paths(), 0); +} + +// ======================================================================= +// the porcelain parse +// ======================================================================= + +#[test] +fn the_clones_own_entry_and_worktrees_elsewhere_are_not_candidates() { + // Only registrations under a `.claude/worktrees/` have a directory here to be + // joined to; the clone's own entry is not one of them. + let parsed = registrations( + "worktree /cache/repos/o/r/ws-one\n\ + HEAD 1111111111111111111111111111111111111111\n\ + branch refs/heads/main\n\ + \n\ + worktree /somewhere/else/entirely\n\ + HEAD 2222222222222222222222222222222222222222\n\ + branch refs/heads/other\n\ + \n\ + worktree /workspaces/ws/.claude/worktrees/agent-a\n\ + HEAD 3333333333333333333333333333333333333333\n\ + branch refs/heads/task\n\ + prunable gitdir file points to non-existent location\n", + ); + + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].inside, ".claude/worktrees/agent-a"); + assert!(parsed[0].prunable); +} + +#[test] +fn a_nested_registration_keeps_its_whole_place_inside_the_clone() { + // The join key has to carry the nesting, or a nested worktree and a top-level + // one of the same leaf name would be joined to each other. + let parsed = registrations( + "worktree /workspaces/ws/.claude/worktrees/outer/.claude/worktrees/inner\n\ + HEAD 4444444444444444444444444444444444444444\n\ + detached\n\ + locked\n", + ); + + assert_eq!(parsed.len(), 1); + assert_eq!( + parsed[0].inside, + ".claude/worktrees/outer/.claude/worktrees/inner" + ); + assert!(matches!(parsed[0].head, WorktreeHead::Detached { .. })); + assert_eq!(parsed[0].locked, Some(Lock { reason: None })); +} + +#[test] +fn a_lock_reason_is_carried_and_an_absent_one_is_absent() { + let parsed = registrations( + "worktree /workspaces/ws/.claude/worktrees/agent-a\n\ + HEAD 5555555555555555555555555555555555555555\n\ + branch refs/heads/task\n\ + locked an agent is working in here\n", + ); + + assert_eq!( + parsed[0].locked, + Some(Lock { + reason: Some("an agent is working in here".to_owned()) + }) + ); +} + +// ======================================================================= +// the decision is total +// ======================================================================= + +#[test] +fn a_worktree_git_holds_is_never_promoted_by_insisting() { + // `--force-worktrees` is not a general override: git saying a worktree is + // live is not a refusal for a person to insist past. + let held = WorktreeStatus::Held { + head: WorktreeHead::Branch { + reference: "refs/heads/task".to_owned(), + commit: "6666666666666666666666666666666666666666".to_owned(), + }, + }; + + assert!(matches!( + decide(held, Insistence::Insisted), + WorktreeDecision::Keep(WorktreeKept::StillHeld { .. }) + )); +} + +#[test] +fn a_lock_and_what_it_holds_are_both_reported() { + // A locked worktree that is also dirty has two things wrong with it, and a + // report naming one would be telling half the truth. + let status = WorktreeStatus::Locked { + lock: Lock { reason: None }, + head: WorktreeHead::Branch { + reference: "refs/heads/task".to_owned(), + commit: "7777777777777777777777777777777777777777".to_owned(), + }, + holds: Unsaved::WouldLose( + Losses::of([Loss::Uncommitted( + NonEmpty::of(["?? notes.md".to_owned()]).expect("one line"), + )]) + .expect("one loss"), + ), + usage: DiskUsage::measured(4096), + }; + + let WorktreeDecision::Keep(WorktreeKept::Objected(objections)) = + decide(status, Insistence::NotInsisted) + else { + panic!("expected two objections"); + }; + + assert_eq!(objections.len(), 2); +} diff --git a/rust/devlaunch-core/src/flows/lifecycle.rs b/rust/devlaunch-core/src/flows/lifecycle.rs index fa617669..dbc923c5 100644 --- a/rust/devlaunch-core/src/flows/lifecycle.rs +++ b/rust/devlaunch-core/src/flows/lifecycle.rs @@ -76,6 +76,7 @@ use crate::domain::locks::{self, LockError}; use crate::domain::metadata::{self, MetadataStorage, RecordUpdate, WorktreeFilter}; use crate::domain::model::WorktreeInfo; use crate::domain::workspace_state::{self, CouldNotTell, Losses, NonEmpty, Unsaved}; +use crate::flows::agent_worktrees::{self, WorktreeReport, WorktreeSweep}; use crate::flows::completion_cache; use crate::flows::disk_usage::{self, DiskUsage}; use crate::flows::listing::{ @@ -760,6 +761,36 @@ 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 @@ -2025,16 +2056,28 @@ pub struct PrunePlan { keeping: Vec, /// Worktree records whose directory is definitively not there any more. stale_records: Vec, + /// The agent git worktrees inside the clones this run is *keeping* + /// (devlaunch#426). Only the kept ones, which is what stops their bytes being + /// counted twice: a clone this run removes already accounts for everything + /// inside it. + worktrees: WorktreeSweep, } impl PrunePlan { /// Whether this run would change nothing at all. pub fn nothing_to_do(&self) -> bool { - self.removing.is_empty() && self.stale_records.is_empty() + self.removing.is_empty() && self.stale_records.is_empty() && self.worktrees.nothing_to_do() } - /// What the whole run would free. - pub fn freed(&self) -> DiskUsage { + /// 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())) } @@ -2057,6 +2100,11 @@ impl PrunePlan { pub fn stale_records(&self) -> &[WorktreeInfo] { &self.stale_records } + + /// The agent git worktrees inside the clones this run is keeping. + pub fn worktrees(&self) -> &WorktreeSweep { + &self.worktrees + } } /// The directory `--prune` scans, canonicalised once. @@ -2137,12 +2185,13 @@ pub fn prune_plan( storage: &MetadataStorage, workspaces: &[Workspace], placement: &ClonePlacement, - insistence: Insistence, + insisted: Insisted, notices: &mut dyn Notices, ) -> Result { let ClonePlacement { root, locations } = placement; let mut removing: Vec = Vec::new(); let mut keeping: Vec = Vec::new(); + let mut worktrees = WorktreeSweep::default(); let mut cache_notices = Vec::new(); let record_for = records_by_directory(clones, storage, &mut cache_notices); let listed_at = sources_by_workspace(workspaces); @@ -2180,7 +2229,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(), @@ -2188,10 +2237,24 @@ pub fn prune_plan( usage, promotion, }), - Decision::Keep(because) => keeping.push(Kept { - path: clone, - because, - }), + Decision::Keep(because) => { + // The agent worktrees inside a clone are swept only where + // the clone itself is staying, and the sweep runs here, + // under the same repository lock the classification was + // taken under. + worktrees.record(agent_worktrees::sweep_clone( + &git, + &clone, + &owner, + &repo, + bare.as_deref(), + insisted.worktrees, + )); + keeping.push(Kept { + path: clone, + because, + }); + } } } } @@ -2210,6 +2273,7 @@ pub fn prune_plan( removing, keeping, stale_records, + worktrees, }) } @@ -2313,18 +2377,26 @@ pub struct PruneReport { /// unfinished, and the clones that *did* go are still gone — which is why this /// is a report and not an abort. pub refused: Vec, + /// What the run did about the agent worktrees inside the clones it kept. + pub worktrees: WorktreeReport, } impl PruneReport { - /// 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.metadata_refused.is_empty() } } @@ -2391,6 +2463,7 @@ pub fn prune_clones( removed: Vec::new(), withheld: Vec::new(), refused: Vec::new(), + worktrees: WorktreeReport::default(), }; let mut forget: Vec = Vec::new(); for ((owner, repo), reclaimables) in by_repo { @@ -2435,6 +2508,24 @@ pub fn prune_clones( } } } + // The agent worktrees inside the clones this run is keeping. A second pass + // over a disjoint set of directories — the sweep only ever covers clones the + // plan keeps, and this loop only ever removes things inside them — so it takes + // each repository's lock again rather than sharing the loop above, which is + // holding a lock for as short a time as the work needs. + for found in plan.worktrees.clones() { + let _lock = clones + .repo_manager() + .hold_repo_lock(found.owner(), found.repo()) + .map_err(PruneError::Lock)?; + let bare = canonical( + &clones + .repo_manager() + .bare_dir(found.owner(), found.repo()) + .to_string_lossy(), + ); + agent_worktrees::reclaim(&git, found, bare.as_deref(), &mut report.worktrees); + } // Outside every repo lock, because the repo lock is what protects the // *directory* work and a record drop touches only `metadata.json`, which has a // lock of its own. Keeping it out means a repository is held for exactly as @@ -3389,6 +3480,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"); @@ -3398,7 +3500,7 @@ mod tests { &world.storage, &workspaces, &placement, - insistence, + insisted, &mut ignoring(), ) .expect("a plan") @@ -6439,7 +6541,7 @@ mod tests { &storage, &workspaces, &placement, - Insistence::NotInsisted, + Insisted::nothing(), &mut ignoring(), ) .expect("a plan"); @@ -6554,7 +6656,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] @@ -6826,6 +6928,323 @@ mod tests { ); } + // ======================================================================= + // agent worktrees inside the clones a prune keeps (devlaunch#426) + // ======================================================================= + + /// One real agent git worktree inside `clone`, on its own pushed branch. + /// + /// The directory the harness makes, made the way the harness makes it, because + /// the classification is git's own `worktree list` and a stub would report no + /// registrations at all -- which reads as "git has forgotten these", the answer + /// that deletes. + fn an_agent_worktree(clone: &Path, leaf: &str) -> PathBuf { + let path = clone.join(".claude").join("worktrees").join(leaf); + std::fs::create_dir_all(path.parent().expect("a parent")).expect("the worktrees directory"); + run_git( + clone, + &["worktree", "add", "-b", leaf, &path.display().to_string()], + ); + run_git(clone, &["push", "-u", "origin", leaf]); + path + } + + /// Rewrite `clone`'s worktree registrations to the container paths they really + /// carry, which is what a host sees and what makes git call them prunable. + fn as_a_host_sees_them(clone: &Path) { + let admin = clone.join(".git").join("worktrees"); + for entry in std::fs::read_dir(&admin).expect("the admin directory") { + let gitdir = entry.expect("an admin entry").path().join("gitdir"); + let registered = std::fs::read_to_string(&gitdir).expect("a gitdir file"); + std::fs::write( + &gitdir, + registered.replace(&clone.display().to_string(), "/workspaces/a-container"), + ) + .expect("the rewritten gitdir"); + } + } + + /// Put one registration back where it resolves, which is what a container + /// running `git worktree add` does to a plan that is already on screen. + fn registered_again(clone: &Path, leaf: &str) { + let gitdir = clone + .join(".git") + .join("worktrees") + .join(leaf) + .join("gitdir"); + let registered = std::fs::read_to_string(&gitdir).expect("a gitdir file"); + std::fs::write( + &gitdir, + registered.replace("/workspaces/a-container", &clone.display().to_string()), + ) + .expect("the restored gitdir"); + } + + /// A live workspace whose clone holds one collectable agent worktree. + fn a_live_clone_with_an_agent_worktree() -> (World, PathBuf, PathBuf) { + let mut world = World::empty(); + let clone = world.clone_at("r-live-aa", "live"); + world.record("r-live-aa", "live", &clone); + let worktree = an_agent_worktree(&clone, "agent-one"); + as_a_host_sees_them(&clone); + world.devpod.lists(&[listed("live", &clone)]); + (world, clone, worktree) + } + + #[test] + fn the_plan_reaches_inside_a_clone_it_is_keeping() { + // The whole of devlaunch#426: every one of the 72 directories measured was + // inside a clone belonging to a *live* workspace, so the orphan rule not + // only missed them, it must never fire on them. + let (world, clone, worktree) = a_live_clone_with_an_agent_worktree(); + + let plan = plan_for(&world, Insistence::NotInsisted); + + assert!( + removing(&plan).is_empty(), + "the clone itself is staying: {:?}", + removing(&plan) + ); + assert!(matches!( + kept_because(&plan, &clone), + KeptBecause::StillOpened { .. } + )); + assert_eq!(plan.worktrees().removing(), 1); + assert_eq!( + plan.worktrees().clones()[0].removing()[0].path, + worktree, + "{:?}", + plan.worktrees() + ); + assert!( + !plan.nothing_to_do(), + "a plan with worktrees to reclaim has something to do" + ); + } + + #[test] + fn a_worktree_inside_a_clone_that_is_going_is_not_swept_separately() { + // Its bytes are already in the clone's own figure, so sweeping it would + // count them twice and offer a directory that will not be there. + let world = World::empty(); + let orphan = world.clone_at("r-orphan-aa", "orphan"); + an_agent_worktree(&orphan, "agent-one"); + as_a_host_sees_them(&orphan); + world.devpod.lists(&[]); + + // `--force` is what carries the clone itself past the objection the + // worktrees put in its way: `.claude/` is untracked, so the clone-level + // guard sees uncommitted work and says so. That guard is right to count + // them -- removing the clone destroys whatever they hold -- so the + // insistence is the fixture, not a workaround. + let plan = plan_for(&world, Insistence::Insisted); + + assert_eq!(removing(&plan), [orphan]); + assert!(plan.worktrees().nothing_to_say(), "{:?}", plan.worktrees()); + } + + #[test] + fn the_acting_pass_removes_the_worktree_and_drops_its_registration() { + let (mut world, clone, worktree) = a_live_clone_with_an_agent_worktree(); + let plan = plan_for(&world, Insistence::NotInsisted); + let clones = clones_for(&world.repos_dir, &world.devpod); + let mut context = CommandContext::new(&world.devpod); + + let outcome = prune_clones( + &mut context, + &clones, + &mut world.storage, + &plan, + &mut ignoring(), + ) + .expect("the pass ran"); + + let PruneOutcome::Acted(report) = &outcome else { + panic!("expected the pass to act, got {outcome:?}"); + }; + assert!(report.finished()); + assert_eq!(report.worktrees.removed.len(), 1); + assert!(!worktree.exists()); + assert!(clone.exists(), "the clone itself is untouched"); + // The metadata went with it, so git does not go on listing a worktree that + // is not there. + let listing = run_git(&clone, &["worktree", "list", "--porcelain"]); + assert!( + !listing.contains("agent-one"), + "git still lists it: {listing}" + ); + } + + #[test] + fn a_worktree_registered_again_while_the_question_was_open_is_left_alone() { + // A container running `git worktree add` is not a participant in + // devlaunch's repository lock, so the plan can be overtaken. The approved + // set has to be able to shrink between the report and the act. + let (mut world, _clone, worktree) = a_live_clone_with_an_agent_worktree(); + let plan = plan_for(&world, Insistence::NotInsisted); + assert_eq!(plan.worktrees().removing(), 1); + registered_again(&world.repo_dir.join("r-live-aa"), "agent-one"); + + let clones = clones_for(&world.repos_dir, &world.devpod); + let mut context = CommandContext::new(&world.devpod); + let outcome = prune_clones( + &mut context, + &clones, + &mut world.storage, + &plan, + &mut ignoring(), + ) + .expect("the pass ran"); + + let PruneOutcome::Acted(report) = &outcome else { + panic!("expected the pass to act, got {outcome:?}"); + }; + assert!(report.worktrees.removed.is_empty()); + assert_eq!(report.worktrees.withheld.len(), 1); + assert!(worktree.exists(), "it is registered and live again"); + } + + #[test] + fn a_worktree_that_went_dirty_while_the_question_was_open_keeps_its_registration() { + // devlaunch#442 review, S1. The window is the `[y/N]` question, and a + // container writing into a worktree is not a participant in devlaunch's + // repository lock. The re-check withholds the worktree, which is the easy + // half. The hard half is `git worktree prune`: it is all-or-nothing across + // a clone, so running it drops the withheld worktree's registration too -- + // and that registration is the only thing telling the next run this is not + // a directory git has forgotten. Forgotten is the arm that deletes. + let mut world = World::empty(); + let clone = world.clone_at("r-live-aa", "live"); + world.record("r-live-aa", "live", &clone); + let finished = an_agent_worktree(&clone, "agent-finished"); + let unsaved = an_agent_worktree(&clone, "agent-unsaved"); + as_a_host_sees_them(&clone); + world.devpod.lists(&[listed("live", &clone)]); + + let plan = plan_for(&world, Insistence::NotInsisted); + assert_eq!( + plan.worktrees().removing(), + 2, + "both read collectable when the question is asked: {:?}", + plan.worktrees() + ); + + // The write the plan on screen could not have known about. + std::fs::write(unsaved.join("notes.md"), "an afternoon\n").expect("a note"); + + let clones = clones_for(&world.repos_dir, &world.devpod); + let mut context = CommandContext::new(&world.devpod); + let outcome = prune_clones( + &mut context, + &clones, + &mut world.storage, + &plan, + &mut ignoring(), + ) + .expect("the pass ran"); + + let PruneOutcome::Acted(report) = &outcome else { + panic!("expected the pass to act, got {outcome:?}"); + }; + assert_eq!(report.worktrees.removed.len(), 1); + assert_eq!(report.worktrees.withheld.len(), 1); + assert!(!finished.exists(), "nothing objected to that one"); + assert!(unsaved.exists(), "it holds a note nowhere else"); + assert_eq!( + report.worktrees.metadata_held_back, + std::slice::from_ref(&clone), + "the prune has to answer to what this pass withheld, not to what the plan predicted" + ); + let listing = run_git(&clone, &["worktree", "list", "--porcelain"]); + assert!( + listing.contains("agent-unsaved"), + "the registration is what stops the next run reading this as forgotten: {listing}" + ); + drop(clones); + + // And the run after it, which is where the loss actually landed: with the + // registration still there this reads prunable-and-dirty rather than + // forgotten, so it is offered to nobody and the note is still on disk. + let again = plan_for(&world, Insistence::NotInsisted); + assert_eq!(again.worktrees().removing(), 0, "{:?}", again.worktrees()); + assert_eq!( + std::fs::read_to_string(unsaved.join("notes.md")).expect("the note is still here"), + "an afternoon\n" + ); + } + + #[test] + fn a_registration_with_nothing_behind_it_is_not_by_itself_something_to_prune() { + // devlaunch#442 review, S3. It frees no bytes, and the metadata prune is + // the whole of the work -- so a run does it, and the run after that has + // nothing to say. Reported as work every time and cleared by none of them, + // `--prune` asked the question and did nothing, forever. + let mut world = World::empty(); + let clone = world.clone_at("r-live-aa", "live"); + world.record("r-live-aa", "live", &clone); + let removed_by_hand = an_agent_worktree(&clone, "agent-gone"); + as_a_host_sees_them(&clone); + std::fs::remove_dir_all(&removed_by_hand).expect("a directory removed by hand"); + world.devpod.lists(&[listed("live", &clone)]); + + let plan = plan_for(&world, Insistence::NotInsisted); + assert_eq!( + plan.worktrees().clones()[0] + .registrations_with_nothing_here() + .container_paths(), + 1 + ); + assert!( + !plan.nothing_to_do(), + "the registration can be cleared, so clearing it is work" + ); + + let clones = clones_for(&world.repos_dir, &world.devpod); + { + let mut context = CommandContext::new(&world.devpod); + prune_clones( + &mut context, + &clones, + &mut world.storage, + &plan, + &mut ignoring(), + ) + .expect("the pass ran"); + } + drop(clones); + + let again = plan_for(&world, Insistence::NotInsisted); + + assert!( + again.nothing_to_do(), + "the prune cleared it, so there is nothing left to offer: {:?}", + again.worktrees() + ); + } + + #[test] + fn what_a_worktree_holds_is_reported_and_kept_until_the_flag_says_otherwise() { + let (world, clone, worktree) = a_live_clone_with_an_agent_worktree(); + std::fs::write(worktree.join("notes.md"), "an afternoon\n").expect("a note"); + + let kept = plan_for(&world, Insistence::Insisted); + assert_eq!( + kept.worktrees().removing(), + 0, + "--force is not --force-worktrees" + ); + assert_eq!(kept.worktrees().keeping(), 1); + + let removed = plan_insisting( + &world, + Insisted { + clones: Insistence::NotInsisted, + worktrees: Insistence::Insisted, + }, + ); + assert_eq!(removed.worktrees().removing(), 1); + assert!(clone.exists()); + } // ======================================================================= // reconcile (devlaunch#88) // ======================================================================= diff --git a/rust/devlaunch-core/src/flows/listing.rs b/rust/devlaunch-core/src/flows/listing.rs index b074b5f6..a7f78e4b 100644 --- a/rust/devlaunch-core/src/flows/listing.rs +++ b/rust/devlaunch-core/src/flows/listing.rs @@ -65,6 +65,7 @@ use crate::domain::metadata::MetadataStorage; use crate::domain::model::WorktreeInfo; use crate::domain::workspace_id; use crate::domain::workspace_state::{self, CloneState, CouldNotTell, NonEmpty, Unsaved}; +use crate::flows::agent_worktrees; use crate::flows::disk_usage::{self, DiskUsage}; use crate::runner::Runner; use crate::timing; @@ -784,7 +785,7 @@ pub(crate) struct DevlaunchClone { pub(crate) enum DiskField { NotAsked, NothingOfOurs, - Freed(DiskUsage), + Freed(CloneDisk), } impl DiskField { @@ -792,7 +793,71 @@ impl DiskField { match (sizes, measurable) { (Sizes::Skip, _) => Self::NotAsked, (Sizes::Measure, None) => Self::NothingOfOurs, - (Sizes::Measure, Some(clone)) => Self::Freed(disk_usage::exclusive_usage(clone)), + (Sizes::Measure, Some(clone)) => Self::Freed(CloneDisk::of(clone)), + } + } +} + +/// What deleting one clone would free, and how much of that is agent git +/// worktrees (devlaunch#426). +/// +/// The second figure is a **part of** the first and never an addition: the +/// worktrees are inside the clone, so their bytes are already in what deleting it +/// would free. It is here because on the reference host they were 82% of a whole +/// cache while being invisible in `--ls --size`, which is how the disk filled. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CloneDisk { + freed: DiskUsage, + /// `None` when the clone has no `.claude/worktrees/` at all, which is nearly + /// every clone — and a different fact from "it has some and they cost + /// nothing", which is a `Some` of zero. + /// + /// Boxed because this value rides inside `WorkspaceTable`, which is one + /// variant against an empty one and is measured for exactly that: a second + /// inline `DiskUsage` here pushes the whole table enum past the size a + /// `Nothing` arm should have to carry. The allocation happens only on a clone + /// that has agent worktrees in it, which is where there is something to say. + in_worktrees: Option>, +} + +impl CloneDisk { + fn of(clone: &Path) -> Self { + Self { + freed: disk_usage::exclusive_usage(clone), + in_worktrees: agent_worktrees::bytes_in(clone).map(Box::new), + } + } + + /// What deleting the whole clone would free. + pub fn freed(&self) -> &DiskUsage { + &self.freed + } + + /// How much of that is agent git worktrees, or nothing when there are none. + pub fn in_worktrees(&self) -> Option<&DiskUsage> { + self.in_worktrees.as_deref() + } + + /// The same figure, but only when it is worth a person's attention: a clone + /// with an empty `.claude/worktrees/` has a measurement and nothing to say, + /// and a table cell that said "0 B in worktrees" would be noise in every row + /// on a host that has ever run one agent. + /// + /// Here rather than in the binary because the comparison needs the bytes, + /// which a usage does not hand out: printing them stripped of which arm they + /// are is what turns a floor into a total. + pub fn worktrees_worth_naming(&self) -> Option<&DiskUsage> { + self.in_worktrees + .as_deref() + .filter(|usage| usage.known_bytes() > 0) + } + + /// `pub` for the binary's rendering tests, which have no reachable + /// measurement to borrow. Binary surface, not part of the frozen `wf` API. + pub fn measured(freed: u64, in_worktrees: Option) -> Self { + Self { + freed: DiskUsage::measured(freed), + in_worktrees: in_worktrees.map(|bytes| Box::new(DiskUsage::measured(bytes))), } } } @@ -1018,7 +1083,27 @@ fn json_row(row: &ListedWorkspace) -> serde_json::Value { // Null where there is no clone of dl's own, the same way `repo` and // `branch` already say "not mine". DiskField::NothingOfOurs => insert(&mut value, "disk", serde_json::Value::Null), - DiskField::Freed(usage) => insert(&mut value, "disk", disk_usage::usage_as_json(usage)), + DiskField::Freed(disk) => insert(&mut value, "disk", disk_as_json(disk)), + } + value +} + +/// The `disk` object: what the clone would free, plus a `worktrees` key when any +/// of it is agent git worktrees. +/// +/// The key is **absent when there are none**, and that is unambiguous here in a +/// way it would not be one level up: `disk` itself is already absent unless +/// `--size` was asked for, so a `disk` object that has no `worktrees` key has +/// been measured and found none. Present-and-zero stays available for the clone +/// that has an empty `.claude/worktrees/`. +fn disk_as_json(disk: &CloneDisk) -> serde_json::Value { + let mut value = disk_usage::usage_as_json(disk.freed()); + if let Some(in_worktrees) = disk.in_worktrees() { + insert( + &mut value, + "worktrees", + disk_usage::usage_as_json(in_worktrees), + ); } value } @@ -1044,7 +1129,7 @@ fn insert(object: &mut serde_json::Value, key: &str, field: serde_json::Value) { pub enum SizeCell { NoColumn, NotOurs, - Measured(DiskUsage), + Measured(CloneDisk), } /// The `LAST USED` cell: devpod's stamp, cut to its date and time, or that there @@ -1123,7 +1208,7 @@ fn size_cell(workspace: &Workspace, cache_dir: &Path, sizes: Sizes) -> SizeCell match DiskField::of(sizes, measurable_clone(workspace, cache_dir).as_deref()) { DiskField::NotAsked => SizeCell::NoColumn, DiskField::NothingOfOurs => SizeCell::NotOurs, - DiskField::Freed(usage) => SizeCell::Measured(usage), + DiskField::Freed(disk) => SizeCell::Measured(disk), } } diff --git a/rust/devlaunch-core/src/flows/mod.rs b/rust/devlaunch-core/src/flows/mod.rs index 3be471d1..c6c33d86 100644 --- a/rust/devlaunch-core/src/flows/mod.rs +++ b/rust/devlaunch-core/src/flows/mod.rs @@ -1,3 +1,5 @@ +// binary surface — not part of the frozen wf API (#251 §7) +pub mod agent_worktrees; // binary surface — not part of the frozen wf API (#251 §7): the branch decision's // refusal travels inside a launch refusal, and the words for it are the binary's. pub mod branch_manager; diff --git a/rust/dl/src/cli.rs b/rust/dl/src/cli.rs index 1da78197..53ab39db 100644 --- a/rust/dl/src/cli.rs +++ b/rust/dl/src/cli.rs @@ -287,8 +287,12 @@ pub(crate) enum Command { Refresh, /// `dl --install []` Install { rc: Option }, - /// `dl --prune [-y] [--force]` - Prune { yes: bool, force: bool }, + /// `dl --prune [-y] [--force] [--force-worktrees]` + Prune { + yes: bool, + force: bool, + force_worktrees: bool, + }, /// `dl --reconcile [-y]` Reconcile { yes: bool }, /// `dl --purge [-y]` @@ -472,6 +476,10 @@ pub(crate) struct Cli { /// regardless of the cache's age (`--update-cache`). #[arg(long)] force: bool, + /// With `--prune`: remove agent git worktrees that are locked, dirty, or hold + /// commits nothing else reaches. Reported and left alone without it. + #[arg(long = "force-worktrees")] + force_worktrees: bool, /// Use a non-default devcontainer.json. A bare name means /// `.devcontainer//devcontainer.json`. Stored with the workspace, so /// pass it once. @@ -700,6 +708,17 @@ fn global_command(cli: &Cli, chosen: Chosen) -> Result { command: name, }); } + // Its own flag rather than a second meaning for `--force`, and refused + // everywhere else for the same reason: `--force` is a word people already + // type, and letting it reach the worktree sweep would widen it from "past a + // clone holding work nowhere else" to "past a worktree somebody may be + // working in". + if cli.force_worktrees && !matches!(chosen, Chosen::Prune) { + return Err(GrammarError::ModifierNotAllowed { + modifier: "--force-worktrees", + command: name, + }); + } Ok(match chosen { Chosen::Ls => Command::List { output: if cli.json { @@ -720,6 +739,7 @@ fn global_command(cli: &Cli, chosen: Chosen) -> Result { Chosen::Prune => Command::Prune { yes: cli.yes, force: cli.force, + force_worktrees: cli.force_worktrees, }, Chosen::Reconcile => Command::Reconcile { yes: cli.yes }, Chosen::Purge => Command::Purge { yes: cli.yes }, @@ -754,6 +774,12 @@ fn workspace_command(cli: Cli, argv: &[String]) -> Result command: "a workspace command", }); } + if cli.force_worktrees { + return Err(GrammarError::ModifierNotAllowed { + modifier: "--force-worktrees", + command: "a workspace command", + }); + } // Before `force_placement`, and that ordering is the whole of the fix it is. // `--force`'s meaning is recovered from its *position* in the word stream, and // `--rm` is a word in that stream — so `dl --rm --force` reads `--force` as @@ -1116,14 +1142,45 @@ mod tests { parse(&["--prune"]), Ok(Command::Prune { yes: false, - force: false + force: false, + force_worktrees: false }) ); assert_eq!( parse(&["--prune", "-y", "--force"]), Ok(Command::Prune { yes: true, - force: true + force: true, + force_worktrees: false + }) + ); + } + + #[test] + fn force_worktrees_is_its_own_flag_and_only_the_prune_takes_it() { + // Not a second meaning for `--force`: that flag already means "past work + // that is nowhere else" and people type it, so letting it reach the agent + // worktrees would widen it into "past a worktree somebody may be in". + assert_eq!( + parse(&["--prune", "--force-worktrees"]), + Ok(Command::Prune { + yes: false, + force: false, + force_worktrees: true + }) + ); + assert_eq!( + parse(&["--ls", "--force-worktrees"]), + Err(GrammarError::ModifierNotAllowed { + modifier: "--force-worktrees", + command: "--ls" + }) + ); + assert_eq!( + parse(&["ws", "--force-worktrees"]), + Err(GrammarError::ModifierNotAllowed { + modifier: "--force-worktrees", + command: "a workspace command" }) ); } diff --git a/rust/dl/src/commands.rs b/rust/dl/src/commands.rs index 992ae17d..fd1248bf 100644 --- a/rust/dl/src/commands.rs +++ b/rust/dl/src/commands.rs @@ -16,8 +16,8 @@ use devlaunch_core::flows::completion::{self, FileState, InstallError, Installed use devlaunch_core::flows::completion_cache::{self, Refreshed}; use devlaunch_core::flows::launch::LaunchNotice; use devlaunch_core::flows::lifecycle::{ - self, ChildWork, DeleteOutcome, Guarded, Insistence, LifecycleNotice, PruneError, PruneOutcome, - Refresh, RefreshReason, StopOutcome, + self, ChildWork, DeleteOutcome, Guarded, Insisted, Insistence, LifecycleNotice, PruneError, + PruneOutcome, Refresh, RefreshReason, StopOutcome, }; use devlaunch_core::flows::listing::{self, CommandContext, DlView, Sizes}; use devlaunch_core::flows::repo_manager::CacheNotice; @@ -104,7 +104,11 @@ pub(crate) fn dispatch( Command::UpdateCache { force } => render_update_cache(runner, &mut context, cache, force), Command::Refresh => render_refresh(&mut context, cache), Command::Install { rc } => render_install(&mut context, cache, rc.as_deref()), - Command::Prune { yes, force } => render_prune(runner, &mut context, yes, force), + Command::Prune { + yes, + force, + force_worktrees, + } => render_prune(runner, &mut context, yes, insisted(force, force_worktrees)), Command::Reconcile { yes } => render_reconcile(runner, &mut context, refresh, yes), Command::Purge { yes } => render_purge(&mut context, cache, yes), Command::Select { verb, devcontainer } => render_select( @@ -858,16 +862,36 @@ fn render_prune( runner: &dyn Runner, context: &mut CommandContext<'_>, yes: bool, - force: bool, + insisted: Insisted, ) -> Ending { - prune_clone_directories(runner, context, yes, force).with_the_boundary() + prune_clone_directories(runner, context, yes, insisted).with_the_boundary() +} + +/// What the two insistence flags mean, in one value. +/// +/// Built here rather than passed as two booleans, so the pair cannot be handed +/// over the wrong way round: they answer different hazards and `--force` reaching +/// the worktree sweep would widen a flag people already type. +fn insisted(force: bool, force_worktrees: bool) -> Insisted { + Insisted { + clones: insistence(force), + worktrees: insistence(force_worktrees), + } +} + +fn insistence(insisted: bool) -> Insistence { + if insisted { + Insistence::Insisted + } else { + Insistence::NotInsisted + } } fn prune_clone_directories( runner: &dyn Runner, context: &mut CommandContext<'_>, yes: bool, - force: bool, + insisted: Insisted, ) -> Cleanup { let mut records = match session::open_records(runner) { Err(refused) => return Cleanup::Raised(refuse_startup(&refused)), @@ -888,18 +912,13 @@ fn prune_clone_directories( )); return Cleanup::Ended(Ending::Refused); } - let insistence = if force { - Insistence::Insisted - } else { - Insistence::NotInsisted - }; let mut notices: Vec = Vec::new(); let plan = match lifecycle::prune_plan( &records.clones, &records.storage, &workspaces, &placement, - insistence, + insisted, &mut notices, ) { Err(refused) => return Cleanup::Raised(refuse_prune(&refused)), diff --git a/rust/dl/src/render.rs b/rust/dl/src/render.rs index 0178f7c2..834d843e 100644 --- a/rust/dl/src/render.rs +++ b/rust/dl/src/render.rs @@ -20,6 +20,9 @@ use devlaunch_core::domain::metadata; use devlaunch_core::domain::workspace_id::{NamePart, UnsafeName}; use devlaunch_core::domain::workspace_state::NonEmpty; use devlaunch_core::domain::xdg; +use devlaunch_core::flows::agent_worktrees::{ + SeenAs, WorktreeKept, WorktreeObjection, WorktreePromotion, WorktreeReport, WorktreeSweep, +}; use devlaunch_core::flows::branch_manager::BranchError; use devlaunch_core::flows::completion_cache::CompletionData; use devlaunch_core::flows::disk_usage::describe_usage; @@ -30,7 +33,9 @@ use devlaunch_core::flows::lifecycle::{ KeptBecause, LifecycleNotice, NotAdopted, Objection, Promotion, PrunePlan, PruneReport, PurgeOutcome, PurgePlan, PurgeStep, ReconcilePlan, RemovalRefused, Unlocatable, VolumeRefusal, }; -use devlaunch_core::flows::listing::{LastUsed, SizeCell, Sizes, TableRow, WorkspaceTable}; +use devlaunch_core::flows::listing::{ + CloneDisk, LastUsed, SizeCell, Sizes, TableRow, WorkspaceTable, +}; use devlaunch_core::flows::migration::{Listing, MigrationReport}; use devlaunch_core::flows::provision::{BundleFailed, FailureLevel, ProvisionEvent}; use devlaunch_core::flows::repo_manager::{ @@ -145,11 +150,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 { @@ -1361,7 +1384,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!( @@ -1397,12 +1420,143 @@ pub(crate) fn prune_plan_lines(plan: &PrunePlan) -> Vec { )); lines.push(String::new()); } + lines.extend(worktree_plan_lines(plan.worktrees())); if plan.nothing_to_do() { lines.push("Nothing to prune.".to_owned()); } lines } +/// The agent git worktrees inside the clones this run is keeping, and what each +/// of them is (devlaunch#426). +/// +/// Its own section under the clone plan rather than rows mixed into it, because +/// these are a different kind of thing: every one of them is inside a clone the +/// run has just said it is *not* touching, and the rules that reach them are +/// their own. Nothing at all is printed when there is nothing to say, which is +/// every host that has never run an agent in a workspace. +fn worktree_plan_lines(sweep: &WorktreeSweep) -> Vec { + if sweep.nothing_to_say() { + return Vec::new(); + } + let mut lines = vec![ + format!( + "Agent git worktrees inside the clones above -- {}:", + describe_usage(&sweep.freed()) + ), + String::new(), + ]; + for found in sweep.clones() { + lines.push(format!(" {}:", found.clone_path().display())); + for worktree in found.removing() { + let mut line = format!( + " - removing {} ({}): {}", + worktree.path.display(), + describe_usage(&worktree.usage), + seen_as(worktree.seen_as) + ); + // What `--force-worktrees` is answering, on the line of the directory + // it answers for. Without it the plan reads the same for a worktree + // holding an afternoon's work as for a finished one. + if let WorktreePromotion::Insisted { despite } = &worktree.promotion { + line = format!("{line}, and {}; removing anyway", objected(despite)); + } + lines.push(line); + } + for kept in found.keeping() { + lines.push(format!( + " - leaving {}: {}", + kept.path.display(), + worktree_kept_because(&kept.because) + )); + } + // Two sentences rather than one number, because the two are different + // facts: a container path is what every worktree an agent made inside a + // devcontainer carries, and a path in this clone with nothing at it is + // somebody's own removal or a run interrupted halfway. Neither frees + // anything. + let nothing_here = found.registrations_with_nothing_here(); + if nothing_here.container_paths() > 0 { + lines.push(format!( + " - {} registration(s) here name a path inside a container, which never \ + resolved on this host, so nothing is freed by forgetting them", + nothing_here.container_paths() + )); + } + if nothing_here.deleted() > 0 { + lines.push(format!( + " - {} registration(s) here name a directory in this clone that is not there \ + any more, so nothing is freed by forgetting them", + nothing_here.deleted() + )); + } + if !found.metadata_gate().open() { + lines.push( + " - git worktree prune is held back here: it is all-or-nothing across a \ + clone, and it would drop the registration that is keeping a worktree above" + .to_owned(), + ); + } + } + lines.push(String::new()); + // Said once, rather than implied by every line above it. `--prune` is a local + // command and deliberately does not fetch, so "nothing else reaches these + // commits" is a statement about the last fetch and not about the forge now. + lines.push( + "Whether a worktree's commits are anywhere else is as of the last fetch into the \ + repository cache; --prune does not fetch." + .to_owned(), + ); + lines.push(String::new()); + lines +} + +/// How git saw a directory that is going. +fn seen_as(seen: SeenAs) -> &'static str { + match seen { + SeenAs::Forgotten => "git has already forgotten it", + SeenAs::Prunable => "git says the registration for it can go", + SeenAs::Locked => "git is holding it locked", + } +} + +/// Why one worktree directory is staying, as the report says it. +/// +/// Every arm names the fact it rests on and none of them claims the worktree is +/// idle, because nothing on a host can establish that: a lock is the agent +/// harness's courtesy, and a killed session leaves one behind. +fn worktree_kept_because(because: &WorktreeKept) -> String { + match because { + WorktreeKept::StillHeld { head } => format!( + "git still holds it and does not offer it up, on {}", + head.named() + ), + WorktreeKept::Objected(objections) => format!( + "{} -- add --force-worktrees to remove it anyway", + objected(objections) + ), + } +} + +/// Everything arguing against removing one worktree, joined as one clause. +fn objected(objections: &NonEmpty) -> String { + objections + .iter() + .map(worktree_objection) + .collect::>() + .join(" and ") +} + +fn worktree_objection(objected: &WorktreeObjection) -> String { + match objected { + WorktreeObjection::Locked { lock } => match &lock.reason { + None => "git is holding it locked".to_owned(), + Some(reason) => format!("git is holding it locked ({reason})"), + }, + WorktreeObjection::Holds(holds) => format!("holds {}", objection(holds)), + } +} + /// Why one clone directory is staying, as the report says it. fn kept_because(because: &KeptBecause) -> String { match because { @@ -1468,7 +1622,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!( @@ -1489,6 +1643,59 @@ pub(crate) fn prune_report_lines(report: &PruneReport) -> Vec { &by_hand, )); } + lines.extend(worktree_report_lines(&report.worktrees)); + lines +} + +/// What the run did about the agent worktrees. +/// +/// The withheld lines say *that this was not so when the plan was printed*, which +/// is the whole of what a second classification has to tell somebody who has +/// already read the first one — and here it is not a rare race: a container is +/// not a participant in devlaunch's repository lock, so it can register a +/// worktree while the plan is on screen. +fn worktree_report_lines(report: &WorktreeReport) -> Vec { + if report.nothing_to_say() { + return Vec::new(); + } + let mut lines = vec![format!( + "Removed {} agent worktree(s) -- {}.", + report.removed.len(), + describe_usage(&report.freed()) + )]; + for withheld in &report.withheld { + lines.push(format!( + "Left {}: {}. That was not so when the plan above was printed.", + withheld.path.display(), + worktree_kept_because(&withheld.because) + )); + } + for clone in &report.metadata_held_back { + lines.push(format!( + "Did not run git worktree prune in {}: a worktree there is being kept, and the \ + registration is what goes on protecting it.", + clone.display() + )); + } + for clone in &report.metadata_refused { + lines.push(format!( + "git worktree prune would not run in {}, so git still lists worktrees that are \ + gone. The next --prune will offer them again.", + clone.display() + )); + } + if !report.refused.is_empty() { + let by_hand: Vec = report + .refused + .iter() + .map(|refusal| refusal.path.clone()) + .collect(); + lines.extend(report_refusals( + report.refused.iter(), + "Some agent worktrees would not come away. These refused:", + &by_hand, + )); + } lines } @@ -2234,7 +2441,6 @@ pub(crate) fn provision_event(event: &ProvisionEvent) -> Option { mod tests { use std::path::PathBuf; - use devlaunch_core::flows::disk_usage::DiskUsage; use devlaunch_core::flows::launch::TerminalTitle; use devlaunch_core::flows::listing::{SourceDescription, SourceKind}; @@ -2471,7 +2677,7 @@ mod tests { "a", SourceKind::Local, "/x", - SizeCell::Measured(DiskUsage::measured(2048)), + SizeCell::Measured(CloneDisk::measured(2048, None)), LastUsed::Never, ), row( @@ -2488,6 +2694,40 @@ mod tests { assert!(lines[3].contains(" - never"), "{:?}", lines[3]); } + #[test] + fn a_size_cell_names_the_part_of_it_that_is_agent_worktrees() { + // On the host devlaunch#426 was found on, the worktrees were 82% of the + // whole cache and no row said so, which is how it reached 100%. + let lines = table_lines( + &table(vec![ + row( + "a", + SourceKind::Local, + "/x", + SizeCell::Measured(CloneDisk::measured(4096, Some(3072))), + LastUsed::Never, + ), + row( + "b", + SourceKind::Local, + "/y", + SizeCell::Measured(CloneDisk::measured(2048, Some(0))), + LastUsed::Never, + ), + ]), + Sizes::Measure, + ); + + assert!( + lines[2].contains("4.0 KiB (3.0 KiB in worktrees)"), + "{:?}", + lines[2] + ); + // A clone with an empty `.claude/worktrees/` has a measurement and nothing + // to say, and a "0 B in worktrees" in every row would be noise. + assert!(lines[3].contains("2.0 KiB never"), "{:?}", lines[3]); + } + #[test] fn the_unknown_source_column_is_the_kind_word_and_the_payload() { let lines = table_lines( diff --git a/rust/dl/tests/completion_tables.rs b/rust/dl/tests/completion_tables.rs index 318172f4..1c59b60b 100644 --- a/rust/dl/tests/completion_tables.rs +++ b/rust/dl/tests/completion_tables.rs @@ -286,7 +286,7 @@ fn aid_flag_list(rewrite: &str, name: &str) -> BTreeSet { /// none of these is one. They modify a line that already named one, and the script /// offers nothing in that position at all (a first word starting with `--` ends /// completion) — a gap worth closing, but a different change than this. -const NOT_OFFERED_FIRST: [(&str, &str); 4] = [ +const NOT_OFFERED_FIRST: [(&str, &str); 5] = [ ( "--json", "only with --ls, which has already been typed by then", @@ -300,6 +300,10 @@ const NOT_OFFERED_FIRST: [(&str, &str); 4] = [ "--force", "modifies rm, --prune or --update-cache, never alone", ), + ( + "--force-worktrees", + "modifies --prune, which has already been typed by then", + ), ]; #[test] diff --git a/rust/dl/tests/lifecycle.rs b/rust/dl/tests/lifecycle.rs index 012a3436..16649481 100644 --- a/rust/dl/tests/lifecycle.rs +++ b/rust/dl/tests/lifecycle.rs @@ -1343,3 +1343,230 @@ fn a_reconcile_that_cannot_write_a_record_says_which_and_exits_one() { "it reported a repair it did not make" ); } + +// --------------------------------------------------------------------------- +// --prune reaches the agent worktrees inside the clones it keeps (devlaunch#426) +// --------------------------------------------------------------------------- + +/// The clone every one of these worktrees lives inside: opened by a live +/// workspace, so `--prune` was never going to touch it. +const AGENT_CLONE: &str = "{ROOT}/cache/devlaunch/repos/blooop/devlaunch/devlaunch-main-legacy"; + +#[test] +fn a_prune_names_the_agent_worktrees_inside_a_clone_it_is_keeping() { + // devlaunch#426: 104.5 GB in 72 of these, every one inside a clone belonging + // to a live workspace. The dry run is the plan that already existed, grown a + // section -- not a second dry-run path. + let world = World::with(&["--agent-worktrees"]); + let run = world.answering("no\n", &["--prune"]); + run.exited(0); + let out = without_sizes(&run.out); + assert!( + out.contains(&format!( + "Agent git worktrees inside the clones above -- :\n\n {AGENT_CLONE}:\n" + )), + "{out}" + ); + assert!( + out.contains(&format!( + " - removing {AGENT_CLONE}/.claude/worktrees/agent-finished (): git says \ + the registration for it can go\n" + )), + "{out}" + ); + assert!( + out.contains(&format!( + " - leaving {AGENT_CLONE}/.claude/worktrees/agent-unsaved: holds 1 uncommitted \ + change(s) (notes.md) -- add --force-worktrees to remove it anyway\n" + )), + "{out}" + ); + // Said once, and it is the honest scope of the answer: `--prune` does not + // fetch, so "nowhere else" is as of the last one. + assert!( + out.contains( + "Whether a worktree's commits are anywhere else is as of the last fetch into the \ + repository cache; --prune does not fetch.\n" + ), + "{out}" + ); + // And the plan still ends in the question, so the dry run is the same one + // dry run it always was. + assert!(out.ends_with(&format!("Are you sure? [y/N] Aborted.\n{DOCKER_BOUNDARY}"))); +} + +#[test] +fn a_prune_removes_the_collectable_agent_worktree_and_keeps_the_clone() { + let world = World::with(&["--agent-worktrees"]); + let clone = world + .root + .join("cache/devlaunch/repos/blooop/devlaunch/devlaunch-main-legacy"); + let trees = clone.join(".claude").join("worktrees"); + + let run = world.dl(&["--prune", "-y"]); + + run.exited(0); + let out = without_sizes(&run.out); + assert!( + out.contains("Removed 1 agent worktree(s) -- ."), + "{out}" + ); + assert!(!trees.join("agent-finished").exists(), "{out}"); + assert!( + trees.join("agent-unsaved").exists(), + "the one holding a note is still here" + ); + assert!(clone.join(".git").exists(), "the clone itself is untouched"); + // `git worktree prune` is held back while a worktree there is kept for what it + // holds: it is all-or-nothing across a clone, and running it would drop the + // registration that is the only reason the next run can tell that directory + // from a forgotten one. + assert!(out.contains("Did not run git worktree prune in"), "{out}"); +} + +#[test] +fn the_flag_is_what_carries_an_agent_worktree_past_what_it_holds() { + let world = World::with(&["--agent-worktrees"]); + let trees = world + .root + .join("cache/devlaunch/repos/blooop/devlaunch/devlaunch-main-legacy") + .join(".claude") + .join("worktrees"); + + let run = world.dl(&["--prune", "-y", "--force-worktrees"]); + + run.exited(0); + let out = without_sizes(&run.out); + assert!( + out.contains("Removed 2 agent worktree(s) -- ."), + "{out}" + ); + assert!(!trees.join("agent-unsaved").exists(), "{out}"); +} + +#[test] +fn plain_force_does_not_reach_the_agent_worktrees() { + // The two flags answer different hazards. `--force` is a word people already + // type at `--prune`, and widening it here would turn it into permission to + // remove a worktree somebody may be working in. + let world = World::with(&["--agent-worktrees"]); + let trees = world + .root + .join("cache/devlaunch/repos/blooop/devlaunch/devlaunch-main-legacy") + .join(".claude") + .join("worktrees"); + + let run = world.dl(&["--prune", "-y", "--force"]); + + run.exited(0); + assert!( + trees.join("agent-unsaved").exists(), + "--force removed a worktree only --force-worktrees answers for: {}", + without_sizes(&run.out) + ); +} + +#[test] +fn the_clone_sentences_count_clone_bytes_and_the_worktree_sentence_counts_worktree_bytes() { + // devlaunch#442 review, S2. The worktree total used to be chained into the + // plan's and the report's figures and then spent on clone sentences, so + // "Removing 1 that nothing references -- 128.0 KiB" sat above a single + // 120.0 KiB row, and "Removed 0 clone director(ies) -- 8.0 KiB." was printed + // over a run that removed no clone at all. Scaled to the host in devlaunch#426 + // that is 104 GB folded into the number somebody says yes to. + let world = World::with(&["--prunable", "--agent-worktrees"]); + let run = world.answering("no\n", &["--prune"]); + run.exited(0); + + let headline = a_size_in(&run.out, "Removing 1 that nothing references -- "); + let only_row = a_size_in(&run.out, "devlaunch-gone-nobody ("); + assert_eq!( + headline, only_row, + "the headline is a total over the clone rows under it, and there is one: {}", + run.out + ); + + // And the same bytes are not stated twice: the worktree section has its own. + let worktrees = a_size_in(&run.out, "Agent git worktrees inside the clones above -- "); + assert!(worktrees > 0.0, "{}", run.out); + assert_ne!( + worktrees, headline, + "these are different claims about different directories: {}", + run.out + ); +} + +/// The size `dl` printed straight after `after`, in whatever unit it chose. +/// +/// The unit is not normalised because nothing here compares across units: every +/// figure in one run of this fixture is KiB, and a comparison that silently +/// succeeded across units would be the bug rather than the check. +fn a_size_in(text: &str, after: &str) -> f64 { + let at = text + .find(after) + .unwrap_or_else(|| panic!("no {after:?} in {text}")) + + after.len(); + let number: String = text[at..] + .chars() + .take_while(|c| c.is_ascii_digit() || *c == '.') + .collect(); + assert!( + text[at + number.len()..].starts_with(" KiB"), + "this fixture's figures are all KiB: {text}" + ); + number.parse().expect("a number") +} + +#[test] +fn the_listing_attributes_the_bytes_that_are_agent_worktrees() { + // The ask this one is on a different surface for: it was invisible in + // `--ls --size` on the host that filled up, which is why it got to 100%. + let world = World::with(&["--agent-worktrees"]); + + let table = world.dl(&["--ls", "--size"]); + table.exited(0); + assert!( + table.out.contains(" in worktrees)"), + "no row named the worktree bytes: {}", + table.out + ); + + let json = world.dl(&["--ls", "--size", "--json"]); + json.exited(0); + let rows: serde_json::Value = + serde_json::from_str(&json.out).expect("--ls --json prints one document"); + let clone_row = rows + .as_array() + .expect("an array") + .iter() + .find(|row| row["id"] == "devlaunch-main-legacy") + .expect("the clone the worktrees are inside") + .clone(); + assert!( + clone_row["disk"]["worktrees"]["exclusiveBytes"] + .as_u64() + .expect("worktree bytes") + > 0, + "{clone_row}" + ); + // Attribution and never an addition: they are inside the clone, so they are + // already part of what deleting it would free. + assert!( + clone_row["disk"]["exclusiveBytes"].as_u64().expect("bytes") + >= clone_row["disk"]["worktrees"]["exclusiveBytes"] + .as_u64() + .expect("worktree bytes"), + "{clone_row}" + ); + // A clone that has never had one says nothing about worktrees at all: `disk` + // is already absent unless `--size` was asked for, so an absent key here has + // been measured and found none. + let plain = rows + .as_array() + .expect("an array") + .iter() + .find(|row| row["id"] == "devlaunch-dirty-dofaraji") + .expect("the other clone") + .clone(); + assert!(plain["disk"].get("worktrees").is_none(), "{plain}"); +} diff --git a/rust/dl/tests/lifecycle_scenario.py b/rust/dl/tests/lifecycle_scenario.py index 92dd22d9..d441817f 100755 --- a/rust/dl/tests/lifecycle_scenario.py +++ b/rust/dl/tests/lifecycle_scenario.py @@ -17,6 +17,7 @@ [--unwritable] [--no-cache] [--no-workspaces] [--devcontainer-volumes] + [--agent-worktrees] The base world, under the root it is given: @@ -69,6 +70,14 @@ PRUNABLE_LEAF = "devlaunch-gone-nobody" PRUNABLE_DIRTY_LEAF = "devlaunch-gone-dirty" +# --agent-worktrees: two real git worktrees an agent harness would have made +# inside the *clean* clone, which a live workspace opens. Registered from inside a +# container, so the paths git holds are container paths and git calls them +# prunable -- the shape devlaunch#426 measured 72 of. One is finished and +# collectable; the other holds an untracked note, so it is reported and kept. +AGENT_GONE = "agent-finished" +AGENT_HELD = "agent-unsaved" + # --stale-record: a record whose directory is definitively not there. STALE_LEAF = "devlaunch-ancient-forgotten" @@ -287,6 +296,27 @@ def build(root: pathlib.Path, shim: pathlib.Path, wanted: set) -> None: dirty=True, ) + if "agent-worktrees" in wanted: + # `git worktree add` from inside the clone, then the registrations rewritten + # to the container paths they would really carry. Both steps matter: the + # classification is git's own `worktree list`, and it is the container path + # that makes a registration prunable on a host. + trees = clean / ".claude" / "worktrees" + trees.mkdir(parents=True, exist_ok=True) + for leaf in (AGENT_GONE, AGENT_HELD): + git(clean, "worktree", "add", "-q", "-b", leaf, str(trees / leaf)) + git(clean, "push", "-q", "origin", leaf) + git(bare, "fetch", "-q", "origin", "+refs/heads/*:refs/heads/*", "--prune") + (trees / AGENT_HELD / "notes.md").write_text("half a thought\n", encoding="utf-8") + for leaf in (AGENT_GONE, AGENT_HELD): + gitdir = clean / ".git" / "worktrees" / leaf / "gitdir" + gitdir.write_text( + gitdir.read_text(encoding="utf-8").replace( + str(clean), "/workspaces/devlaunch-main-legacy" + ), + encoding="utf-8", + ) + if "unpushed" in wanted: # A commit that exists in this clone and nowhere else. Uncommitted work and # unpushed commits are different losses and the refusal names which. @@ -472,6 +502,7 @@ def build(root: pathlib.Path, shim: pathlib.Path, wanted: set) -> None: "[--stale-record] [--orphan] [--unplaceable] [--unwritable] " "[--no-cache] [--no-workspaces] [--not-a-clone] [--unpushed] " "[--sealed-cache] [--symlinked-cache] [--v1-cache] " + "[--agent-worktrees] " "[--devcontainer-volumes]" ) flags = {argument.lstrip("-") for argument in sys.argv[3:]} @@ -489,6 +520,7 @@ def build(root: pathlib.Path, shim: pathlib.Path, wanted: set) -> None: "symlinked-cache", "v1-cache", "devcontainer-volumes", + "agent-worktrees", } if unknown: raise SystemExit(f"lifecycle_scenario.py: unknown fixture(s): {sorted(unknown)}")