Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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
Expand Down Expand Up @@ -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 |

Expand All @@ -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.

Expand Down
132 changes: 132 additions & 0 deletions docs/cleanup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<clone>/.claude/worktrees/<name>/`, 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/<id>/.claude/worktrees/<name>`, 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:
Expand Down
Loading
Loading