Skip to content

Reclaim the agent git worktrees stranded inside live workspace clones - #442

Closed
blooop wants to merge 8 commits into
mainfrom
wayfinder/devlaunch-426
Closed

Reclaim the agent git worktrees stranded inside live workspace clones#442
blooop wants to merge 8 commits into
mainfrom
wayfinder/devlaunch-426

Conversation

@blooop

@blooop blooop commented Aug 25, 2026

Copy link
Copy Markdown
Owner

Closes #426

An agent harness working inside a devcontainer makes its own git worktrees under
<clone>/.claude/worktrees/<name>/, one per task, and nothing ever collected them. On the host
in #426 that was 72 directories, 104.5 GB, about 82% of everything under repos/, with one clone
holding 55 GB on its own. Every one of them sat inside a clone belonging to a live devpod
workspace, so --prune's orphan rule not only missed them, it must never fire there: firing
would delete a live workspace's checkout.

So this is a second rule, and it runs only over the clones the first one is keeping. A clone that
is going already accounts for everything inside it, which is what keeps the bytes from being
counted twice.

What it does

  • --prune sweeps .claude/worktrees/ inside the clones it keeps, classifying each directory
    four ways: git has forgotten it, git says the registration can go, git is holding it locked, or
    git still holds it and does not offer it up. The first two go by default. The last is always
    kept, which is what stops a run inside a container from collecting its own live worktrees.
  • --force-worktrees is the one flag that carries a worktree past a lock, past uncommitted work,
    or past commits nothing else reaches. Deliberately not --force: that word already means "past
    a clone holding work nowhere else" and people type it, so widening it would turn it into
    permission to remove a worktree somebody may be working in. There is a test for exactly that.
  • The dry run is the plan that already existed, grown a section, rather than a second dry-run
    path. -y still skips the question.
  • git worktree prune runs in the clone after the directory goes, so a run interrupted
    between the two leaves a registration whose directory is gone, which is precisely the prunable
    state the next run handles. It is held back in a clone where a worktree is kept for what it
    holds, because the prune is all-or-nothing and would drop that worktree's registration too,
    turning it into a forgotten directory the next run removes outright.
  • dl --ls --size names how much of a clone's figure is worktrees, in the table and in
    disk.worktrees in the JSON. It was invisible there on the host that filled up.

The things that took the work

  • Uncommitted work, not just commits. A worktree on a fully-merged branch with an afternoon
    of unstaged edits reads as finished if you only ask about commits. The dirty check goes through
    --git-dir=<clone>/.git/worktrees/<name> with --work-tree=<directory>, because the
    worktree's own .git gitfile names a container path that resolves to nothing on a host, and
    .claude/worktrees/ is excluded from it so a worktree holding a nested one does not read dirty
    forever.
  • The stale-ref trap. A workspace clone is cut from the sibling .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. Asking it alone reports pushed-and-merged branches as unpushed, which would keep
    every byte forever. The bare is asked first, and the report says the answer is as of the last
    fetch. No network call was added.
  • Nesting. An agent session running inside a worktree makes its worktrees under that one, and
    that is how one clone reached 55 GB. The scan recurses, but only into worktrees it is keeping.
  • It is confirmed to be a worktree, by its own .git gitfile naming a .git/worktrees/<name>
    admin directory, rather than by the path shape. A plain directory sitting there is not
    devlaunch's to delete, and symlinks are stepped over so a removal cannot walk out of the cache.
  • The race is real and unclosable from here, because a container running git worktree add
    is not a participant in devlaunch's repository lock. Every directory is classified again
    immediately before it goes, so the approved set can shrink between the report and the act and
    can never grow. There is a test that re-registers a worktree between the two.
  • Nothing claims a worktree is idle. A lock is the harness's courtesy and a killed session
    leaves one behind, so every line of the report says the fact it rests on and no more.

Not in scope

The 18 duplicated .pixi/envs/default copies are why the figure is 104 GB rather than about 10,
and they cannot be pointed at the shared cache: only the pixi download cache is shared, because
installed environments bake absolute paths. Removing the worktree is how those bytes come back,
which is what this does. Recorded in docs/cleanup.md so nobody chases it.

Checks

cargo test --workspace, cargo clippy --locked --all-targets -- -D warnings, cargo fmt --check and the Python doc guards are green locally. The public-api.rest.txt snapshot is
regenerated: two rows moved, prune_plan taking one Insisted instead of a bare Insistence,
and SizeCell::Measured carrying the clone's disk with its worktree share attributed. The
promised api.txt tier is byte-identical.

🤖 Generated with Claude Code

Summary by Sourcery

Reclaim finished agent Git worktrees from live workspace clones while retaining protected work and reporting their disk usage.

New Features:

  • Extend --prune to reclaim collectable agent Git worktrees nested inside clones that remain in use.
  • Add a dedicated --force-worktrees option for removing worktrees protected by locks, local changes, or unreachable commits.
  • Expose worktree disk usage as an attribution in dl --ls --size table and JSON output.

Bug Fixes:

  • Prevent live, locked, dirty, or otherwise protected worktrees from being removed during pruning.
  • Avoid misclassifying worktrees using stale clone refs by checking commit reachability against the fetched bare repository cache.
  • Handle nested worktrees, container-specific registration paths, symlinks, and races between planning and deletion safely.

Enhancements:

  • Integrate worktree discovery, safety classification, reporting, removal, and Git metadata cleanup into the existing prune plan and execution flow.
  • Preserve Git registrations when a kept worktree would otherwise be lost by all-or-nothing metadata pruning.

Documentation:

  • Document the agent worktree cleanup rules, safety guarantees, force behavior, and disk-usage attribution.

Tests:

  • Add comprehensive Git and filesystem coverage for worktree classification, nested worktrees, safety guards, stale refs, race handling, metadata pruning, CLI behavior, and size reporting.

blooop added 4 commits August 25, 2026 11:43
git worktree list is the authority for locked and prunable; the join to a
directory on disk is by its place inside the clone, because the registered
path is a container path that resolves to nothing on a host.

The dirty check goes through the clone's admin directory for the worktree,
which is the only side that resolves here, and excludes .claude/worktrees so
a worktree holding a nested one does not read dirty forever. Reachability
asks the sibling bare cache first: the clone is never fetched into, so its
remote-tracking refs are as of clone time and asking them alone reports
pushed-and-merged branches as unpushed.
The sweep goes into the plan the user already answers, so there is one dry
run and not two. Its bytes are counted only for clones the run is keeping,
because a clone that is going already accounts for everything inside it.

--force-worktrees is its own flag. --force already means 'past a clone
holding work nowhere else' and people type it; letting it reach a locked or
dirty worktree would widen it into permission to remove one somebody may be
working in.

The acting pass removes the directory and only then runs git worktree prune,
so an interrupted run leaves the prunable state the next run already handles.
It re-classifies every directory immediately before removing it: a container
running git worktree add is not a participant in devlaunch's repo lock.
The figure was invisible on the host that filled up, and it was 82% of the
cache. A part of the clone's number rather than an addition: the worktrees
are inside it. The object store is not in the attribution, because a linked
worktree shares the clone's and the clone's objects are hardlinked from the
bare next door.
The README names --force-worktrees because every flag dl offers has to appear
there; the rules and the two things the report is careful about live in
docs/cleanup.md, which is where depth goes.

Only the tripwire file moved. prune_plan takes one Insisted instead of a bare
Insistence, and SizeCell::Measured carries the clone's disk with its worktree
share attributed; the promised api tier is byte-identical.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @blooop, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 3 days and 9 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@sourcery-ai

sourcery-ai Bot commented Aug 25, 2026

Copy link
Copy Markdown

Reviewer's Guide

This PR extends --prune with a safety-oriented, recursive sweep of agent Git worktrees inside clones that remain in use, using Git registration state plus host-side content and reachability checks to reclaim only defensible candidates. It adds an independent --force-worktrees override, race-resistant reclassification and metadata cleanup, worktree-specific reporting and size attribution, broad integration tests, and documentation.

Sequence diagram for race-resistant worktree pruning

sequenceDiagram
    participant User
    participant Prune as dl --prune
    participant Git
    participant FS as Host filesystem
    participant Container

    User->>Prune: Confirm prune plan
    Prune->>Git: worktree list --porcelain
    Git-->>Prune: Registration state
    Prune->>FS: Scan .claude/worktrees recursively
    Prune->>Git: status and commit reachability checks
    Prune-->>User: Report removable and kept worktrees
    Container->>Git: Re-register worktree
    Prune->>Git: worktree list --porcelain again
    Git-->>Prune: Updated registration state
    alt Still removable
        Prune->>FS: Remove worktree directory
        Prune->>Git: worktree prune
    else Registration or objection changed
        Prune-->>User: Withhold worktree
    end
Loading

Flow diagram for safe agent worktree reclamation

flowchart TD
    A["dl --prune"] --> B["Classify clone"]
    B -->|clone being removed| C["Remove clone"]
    B -->|clone being kept| D["Recursively scan .claude/worktrees"]
    D --> E["Confirm linked Git worktree"]
    E -->|plain directory or symlink| F["Leave untouched"]
    E -->|linked worktree| G["Read Git registration"]
    G --> H{"Forgotten or prunable?"}
    H -->|no: held| F
    H -->|yes| I["Check lock, dirt, and commit reachability"]
    I --> J{"Objection and no --force-worktrees?"}
    J -->|yes| F
    J -->|no| K["Reclassify immediately before removal"]
    K --> L{"Still removable?"}
    L -->|no| F
    L -->|yes| M["Remove directory"]
    M --> N["Run git worktree prune when safe"]
Loading

File-Level Changes

Change Details Files
Add a dedicated agent-worktree reclamation flow to prune only retained live workspace clones.
  • Scan recursively under .claude/worktrees/ and match directories to Git registrations without resolving container paths.
  • Classify forgotten, prunable, locked, and still-held worktrees; protect dirty, untracked, unpushed, and unreadable content.
  • Use the sibling bare cache for last-fetch commit reachability and exclude nested worktrees from dirt checks.
  • Recheck each candidate before deletion, remove directories before running repository-level metadata pruning, and guard metadata pruning when kept worktrees depend on registrations.
  • Expose structured sweep/report data and comprehensive real-Git race, nesting, safety, and classification tests.
rust/devlaunch-core/src/flows/agent_worktrees.rs
rust/devlaunch-core/src/flows/agent_worktrees/tests.rs
rust/devlaunch-core/src/clients/git.rs
rust/devlaunch-core/src/flows/lifecycle.rs
rust/devlaunch-core/src/flows/mod.rs
rust/devlaunch-core/src/flows/lifecycle.rs
rust/dl/tests/lifecycle.rs
rust/dl/tests/lifecycle_scenario.py
Integrate worktree reclamation into the existing prune plan and execution paths with separate force semantics.
  • Sweep only clones classified as retained, preserving clone-level byte accounting and avoiding double counting.
  • Add --force-worktrees as an independent insistence from --force, including command validation and completion coverage.
  • Extend prune plans and reports with worktree removals, withheld candidates, metadata-prune outcomes, and finished-state handling.
  • Keep the existing dry-run confirmation and -y behavior while rendering worktree-specific reasons and race results.
rust/devlaunch-core/src/flows/lifecycle.rs
rust/dl/src/cli.rs
rust/dl/src/commands.rs
rust/dl/src/render.rs
rust/dl/tests/completion_tables.rs
rust/dl/tests/lifecycle.rs
rust/devlaunch-core/public-api.rest.txt
Attribute agent-worktree disk usage in workspace size listings and JSON output.
  • Represent clone size alongside an optional worktree share without treating the share as additional bytes.
  • Render nonzero worktree usage in table output and add disk.worktrees to JSON measurements.
  • Add coverage for measured, zero, and absent worktree-size cases.
rust/devlaunch-core/src/flows/agent_worktrees.rs
rust/devlaunch-core/src/flows/listing.rs
rust/dl/src/render.rs
rust/dl/tests/lifecycle.rs
rust/devlaunch-core/public-api.rest.txt
Document the new cleanup rule, safety boundaries, flag behavior, and disk-attribution semantics.
  • Update CLI summaries and cleanup guidance for worktrees inside retained clones.
  • Document stale-ref handling, nested scans, locks, dirty content, registration ordering, and pixi environment scope.
README.md
docs/cleanup.md

Assessment against linked issues

Issue Objective Addressed Explanation
#426 Have dl --prune reclaim unregistered and prunable .claude/worktrees/ git worktrees inside live workspace clones, without applying the existing orphan-clone deletion rule to those live clones.
#426 Protect locked, dirty, or otherwise potentially lossy worktrees by leaving them alone by default, requiring the explicit --force-worktrees opt-in to remove them, and reporting the reasons they were skipped, including commit reachability concerns.
#426 Make worktree reclamation observable and safe through dry-run planning, accurate size attribution in dl --ls --size, race-aware reclassification before deletion, and cleanup of git worktree metadata via git worktree prune.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@codecov

codecov Bot commented Aug 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.31963% with 106 lines in your changes missing coverage. Please review.
✅ Project coverage is 95.05%. Comparing base (2aa9602) to head (9b6180b).
⚠️ Report is 4 commits behind head on main.

Files with missing lines Patch % Lines
rust/devlaunch-core/src/flows/agent_worktrees.rs 89.49% 60 Missing ⚠️
rust/dl/src/render.rs 75.62% 39 Missing ⚠️
rust/devlaunch-core/src/flows/lifecycle.rs 97.26% 7 Missing ⚠️
Additional details and impacted files
Flag Coverage Δ
python 42.98% <ø> (ø)
rust 95.39% <90.31%> (-0.19%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Components Coverage Δ
shipped code (rust) 95.39% <90.31%> (-0.19%) ⬇️
harness and tooling (python) 42.98% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@blooop blooop left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was generated by AI during review.

Reviewed at b812411f8593cfc70dbdb907881dfccaabab90bb against merge-base 449cae2f. Preflight: all 14 checks green, diff non-empty. Spec taken from #426's body and the sharpening comment (id 5409723748), which is the governing one.

Two axes below, run independently and not merged.

Spec

Checked against the sharpened spec, in the code. Verified: reachability asks the sibling .bare first with the clone as fallback and no fetch anywhere (agent_worktrees.rs:337, git.rs:445), and the "as of the last fetch" sentence is printed once per sweep including under -y. Removal order is directory-then-prune (:1041-1053). The per-candidate re-check exists, iterates only clone.removing, so the approved set can shrink and never grow. Discovery recurses, confirms via the .git gitfile tail rather than the path shape (linked_worktree_name:255), and skips symlinks; the removal itself reuses the symlink-refusing remove_tree_as_far_as_it_goes. Sizes are an attribution and never an addition, and exclusive_usage bills a file only when every link to it is inside the tree, so the hardlinked object store is out. Nothing derives a path from anything but clone, so a scratch XDG_CACHE_HOME run stays harmless. Dirty, untracked, and detached-and-ahead are all protected and tested. --force-worktrees is refused outside --prune.

S1. The git worktree prune holdback reads the plan, not the re-check, and uncommitted work is destroyed on the next run. (blocker)

The guard is the right guard. It is asked of the wrong data. reclaim gates on clone.metadata_may_be_pruned() (agent_worktrees.rs:1049), and that method inspects only self.keeping from the plan (:672). A candidate the second pass demotes to Keep goes into report.withheld (:1032) and is never consulted.

Reproduced against rust/target/release/dl on lifecycle_scenario.py --agent-worktrees, with the container's write landing while the [y/N] question was on screen, which is the window the module itself calls "not a rare race":

Are you sure? [y/N] Removed 0 clone director(ies) -- 8.0 KiB.
Removed 1 agent worktree(s) -- 8.0 KiB.
Left .../.claude/worktrees/agent-unsaved: holds 1 uncommitted change(s) (notes.md)
  -- add --force-worktrees to remove it anyway. That was not so when the plan above was printed.

The re-check did its job. But no "Did not run git worktree prune" line followed, because the plan's keeping was empty, so the prune ran and took agent-unsaved's registration with it. .git/worktrees/ is then gone entirely. The very next run:

- removing .../.claude/worktrees/agent-unsaved (12.0 KiB): git has already forgotten it
Removed 1 agent worktree(s) -- 12.0 KiB.

notes.md is gone. No flag was typed at either run. Forgotten is the one arm that carries no probe at all (:296-306) and an Unopposed promotion, which is what turns a demotion into a deletion.

Preconditions are ordinary rather than exotic: two prunable worktrees in one clone, one of which the container touches while the question is open. On a host every container-registered worktree reads prunable, so dirt is the main protection there, and this is the path that discards it.

The gate needs to see what this pass withheld, not only what the plan kept.

S2. The clone-removal figures now include bytes from clones that are being kept. (major)

PrunePlan::freed() and PruneReport::freed() chain the worktree total in (lifecycle.rs:2073, :2386), but render still spends them on clone-directory sentences (render.rs:1386, :1611). Observed on --agent-worktrees --prunable:

Removing 1 that nothing references -- 128.0 KiB:
  - .../devlaunch-gone-nobody (120.0 KiB)

and, on a run that removed no clone at all, Removed 0 clone director(ies) -- 8.0 KiB. followed by Removed 1 agent worktree(s) -- 8.0 KiB. The same bytes are stated twice and the first statement attributes them to directories nothing references. Scaled to the reference host this is 104 GB folded into the headline number somebody says yes to.

S3. --prune never reaches "Nothing to prune" again once a registration lingers. (minor)

Correct behaviour, reported as work. After a held-back prune, the removed worktree's registration stays, so registrations_with_nothing_here is 1 forever, so nothing_to_do() is false forever. Verified: runs 2 and 3 print the full section, ask [y/N], and do nothing. Worth making a bytes-free registration count not by itself constitute something to do.

S4. The dirty-check pathspec masks tracked work, and non-worktree content under .claude/worktrees/ is neither seen nor protected. (minor)

The exclusion is justified and I confirmed the motivating case: without it, a worktree holding a nested one reads ?? .claude/ forever; with it the nested worktree's own dirt is still seen when it is the candidate. Two residues. A tracked file modified under .claude/worktrees/ is silent ( M .claude/worktrees/keep.md without the exclusion, nothing with it) — contrived, since the harness's directory is normally ignored, but it is real uncommitted work. More reachable: a plain directory sitting under a worktree's .claude/worktrees/ is excluded from the dirty check and skipped by the sweep, since it is not a linked worktree, so it neither blocks nor is reported, and it goes when the parent worktree does. The doc comment's justification ("those nested directories are what this sweep reasons about separately") is true only of confirmed worktrees.

S5. The container-path vs deleted distinction the spec asks for is not made. (minor)

"Distinguish directory absent because it was a container path that does not resolve here from directory absent because it was deleted."

Both collapse into registrations_with_nothing_here (:648). Substantively fine, since the suffix join is what finds the bytes behind a container path, and the doc comment argues the case. Flagged because the ask is literally unmet.

S6. Nits

  • linked_worktree_name accepts a gitfile tail of .git/worktrees/.., which makes admin the clone's own .git. Fails safe (reads dirty, kept), but it is trust placed in file content.
  • docs/cleanup.md is worth one sentence saying that reclaiming an orphan clone containing agent worktrees takes two runs: run 1 keeps it (the clone-level probe has no pathspec exclusion, so the worktrees read as uncommitted work) and sweeps the worktrees, run 2 reclaims the clone with no flag.

On the changed test premise

No existing test was altered: git diff shows zero deletions in rust/dl/tests/lifecycle.rs. The Insistence::Insisted sits in a new test, a_worktree_inside_a_clone_that_is_going_is_not_swept_separately, whose comment states why. The premise is accurate and the call is right: removing the clone really would destroy what those worktrees hold, so the clone-level probe is honest to count them, and the situation converges over two runs. Nothing is buried. Excluding .claude/worktrees/ at clone level too would have been the defect.

Standards

Repo standards: test_docs_prose.py, test_readme_cli_doc.py, test_public_api_snapshots_doc.py and test_bench_doc.py all pass (62 tests). --force-worktrees is named in the README. docs/cleanup.md pre-dates the PR and is already in the Docs table. cargo clippy --locked --all-targets -- -D warnings and cargo fmt --check are clean.

Snapshot discipline holds. git diff -- '*api.txt' is empty, so the frozen tier is byte-identical (#251 §7). public-api.rest.txt is +200/-2 and the two removed rows are exactly the claimed pair: prune_plan taking Insisted in place of a bare Insistence, and SizeCell::Measured carrying CloneDisk. No other removals, no reordering, and the additions sit in generator order. Both moves are intended consequences of this change.

N1. The README carries rationale the repo says belongs in docs/. (minor)

CLAUDE.md: "A new paragraph of design rationale belongs in the docs page for its topic, not in the README." README.md:235-238 argues why it is not --force, and :338-343 restates the 82% / 104 GB measurement. Both are near-verbatim in docs/cleanup.md. Naming the flag is guard-required; the argument is not.

N2. Dead and over-wide public surface. (minor, Speculative Generality)

CloneWorktrees::freed() (agent_worktrees.rs:653) has no caller anywhere: core, dl, unit tests, or dl/tests/. Four more rows would be covered by pub(crate): agent_worktrees::bytes_in (sole caller listing.rs:827, same crate), WorktreeSweep::removing()/keeping() (in-crate tests only; dl uses the CloneWorktrees versions), and Insisted::nothing(). CloneDisk::measured is the one that genuinely needs pub and says so.

N3. Nits

  • Shotgun Surgery with no guard: WORKTREES_DIR (agent_worktrees.rs:91) and the literal ":!.claude/worktrees" pathspec (git.rs:420), whose own comment says "The two have to move together". The repo's habit elsewhere is to pin such a pair with a test (flows::provision::lending_contract); nothing binds these.
  • commands.rs extracts insistence(bool), but the identical inline ternary still stands at commands.rs:527-531.
  • canonical(&clones.repo_manager().bare_dir(o, r).to_string_lossy()) now appears twice (lifecycle.rs:2196, :2513).
  • README says "104 GB"; docs/cleanup.md and the module header say "104.5 GB".

The read_side.rs flake

Nothing in this diff can plausibly cause it. The file is untouched, and the only read-path surface it changes is the disk object, whose new worktrees key appears solely when a clone has a .claude/worktrees/ — which scenario.py never builds, and which would fail that byte-for-byte JSON pin deterministically rather than one run in eight. The read path added here is a read_dir and a walk, both deterministic.

The plausible mechanism is load, not logic: the diff adds five binary-boundary tests that each build a python world and run real git worktree add plus git push, and #401 documents exactly that shape making the timing-sensitive binary tests flake on a loaded cargo test --workspace while passing alone. Same family as lock_wait.rs. Worth a line on #401 rather than a change here.

Verdict

Request changes.

Blocking:

  • S1 — the prune holdback is computed from the plan's keeping rather than from what the acting pass withheld, so a candidate the re-check correctly refuses is demoted to Forgotten and deleted, with its uncommitted work, by the next run. Reproduced end to end with no flag typed. This is the hazard the guard was written for, reached by the race the module documents.

Should fix before merge, not strictly blocking:

  • S2 — worktree bytes are folded into the clone-directory totals, so the plan's headline figure describes directories that are not going.

Everything else above is minor or a nit. The design work here is genuinely good: the four-arm classification, the Insisted pair instead of two booleans, the bare-first reachability, the ordering, and the honesty about what a lock does and does not mean are all right, and the test suite reaches most of the hard cases. S1 is one line of data flow away from the guarantee the rest of the module already earns.

The review on #442 reproduced a data loss with no flag typed at either run.
`reclaim` asked `clone.metadata_may_be_pruned()`, which folds the *plan's*
`keeping` and nothing else. A candidate the acting pass re-classified and
withheld -- a worktree written into while the `[y/N]` question was on screen,
which is the window this module already documents as not rare -- landed in
`report.withheld`, which the gate never saw. So no holdback fired, `git worktree
prune` ran, and it took the withheld worktree's registration with it. Next run
the directory read `Forgotten`, the one arm with no probe, and went outright.

Of the two directions the review offered, the first is what decided it: **the
holdback has to be computed from what the acting pass did, not from what the plan
predicted.** A prediction and an outcome that can disagree is the same shape as
the plan-wide `force` boolean `PrunePlan`'s doc comment records as having caused a
bug here already, and fixing the one call site would have left the shape. So the
answer is a value that gets folded, `MetadataGate`, seeded from the plan's keeps
and fed every outcome the acting pass reaches; `CloneWorktrees::metadata_gate` is
the plan's fold of the same rule and reads as the forecast it is. There is one
rule and two folds of it, where there were two rules that could disagree.

The second direction is taken as far as it goes, because fixing only the gate
leaves the blind deletion one bug away from returning. `Forgotten` now carries an
`Unsaved` and is probed wherever there is an admin directory to probe through:
reaching that arm with one present means git dropped the name or this module's
suffix join missed, and no wrong classification should cost somebody an
afternoon. With the admin directory gone there is genuinely no index and no HEAD,
so nothing can be asked, and that stays the limit rather than becoming a claim.
The `..` tail in a gitfile went the same way: without a guard it did not fail
safe as the review supposed, it removed the directory as forgotten.

The rest of the review, in one pass because it is all the same code:

- **S2.** Worktree bytes were chained into `PrunePlan::freed`/`PruneReport::freed`
  and then spent on clone sentences: 128.0 KiB claimed over a 120.0 KiB
  directory, and `Removed 0 clone director(ies) -- 8.0 KiB.` over a run that
  removed no clone. Both are now `clones_freed`, the worktree section keeps its
  own figure, and a boundary test holds the headline to the rows under it.
- **S3.** A registration with nothing behind it no longer constitutes work by
  itself. It frees nothing, so a run clears it and the run after has nothing to
  say -- where before `--prune` asked the question and no-opped forever.
- **S4.** The `:!.claude/worktrees` pathspec excluded the place rather than the
  thing, hiding a tracked file modified there and, worse, plain content under a
  candidate's `.claude/worktrees/` that the sweep also skips: neither reported nor
  protected, and gone with its parent. git is now asked without a pathspec and
  untracked entries are dropped only where everything under them is a confirmed
  worktree. The motivating case -- a worktree holding a nested one reading dirty
  forever -- is still covered, by its own test.
- **S5.** `registrations_with_nothing_here` tells a container path that never
  resolved here apart from a path in this clone with nothing at it, which is what
  the sharpened spec asked for and one number said neither of.
- **N1.** The README's two paragraphs of rationale go to `docs/cleanup.md`, which
  already carried both nearly verbatim. The flag stays named.
- **N2.** `CloneWorktrees::freed` had no caller and is gone; `bytes_in` is
  `pub(crate)`; `WorktreeSweep::removing`/`keeping` and `Insisted::nothing` are
  `#[cfg(test)]`, which is what they were for.

`public-api.api.txt` is byte-identical. `public-api.rest.txt` removes ten rows --
`CloneWorktrees::{freed, metadata_may_be_pruned}`, the old `usize` signature of
`registrations_with_nothing_here`, `PrunePlan::freed`, `PruneReport::freed`,
`bytes_in`, `WorktreeSweep::{removing, keeping}`, and `Insisted::nothing` with its
impl header -- and adds `MetadataGate`, `RegistrationsWithNothingHere` and the two
`clones_freed` renames.

Left alone: `dl/tests/read_side.rs`, which the review established this diff cannot
flake, and #401 documents the load shape that does.

Closes #426
@blooop

blooop commented Aug 25, 2026

Copy link
Copy Markdown
Owner Author

This was generated by AI while fixing the review.

Fixed at 913df8c. Per finding, and the S1 reasoning first because it is the one that had a choice in it.

S1 (blocker) — fixed, and it was real

Reproduced as a failing test before anything was changed: a_worktree_that_went_dirty_while_the_question_was_open_keeps_its_registration in flows::lifecycle::tests. Two prunable worktrees in one clone, a write into one of them after the plan is taken, then prune_clones. Red at exactly the assertion you named:

assertion `left == right` failed: the prune has to answer to what this pass withheld,
not to what the plan predicted
  left: []
 right: ["…/repos/o/r/r-live-aa"]

Direction 1 decided it. The bug is not one wrong call site, it is that a prediction and an outcome could disagree and only one of them was reachable from the gate — the same shape PrunePlan's doc comment records the plan-wide force boolean causing here once already. So the answer is a value that gets folded rather than a method that reads a field: MetadataGate, seeded from the plan's keeps and then fed every WorktreeKept the acting pass produces. CloneWorktrees::metadata_gate() is the plan's fold of the same rule, and its doc says it is a forecast. One rule, two folds of it, and reclaim has nothing to reach for that could be the wrong data.

Direction 2 is taken as far as it can go, because you are right that fixing only the gate leaves the second run's blind deletion one bug away. WorktreeStatus::Forgotten now carries an Unsaved and is probed wherever there is an admin directory to probe through — pinned by a_directory_whose_admin_directory_is_here_is_asked_what_it_holds, verified red first (the directory was removed). Reaching that arm with an admin directory present means git dropped the name or this module's suffix join missed, and neither should cost an afternoon. Where the admin directory is genuinely gone there is no index and no HEAD and nothing can be asked; that stays a documented limit rather than becoming a claim.

The second run is pinned too, in the same test: with the registration intact the worktree reads prunable-and-dirty rather than forgotten, and notes.md is read back off disk.

One correction to S6 while I was there. It does not fail safe. A gitfile tail of .git/worktrees/.. makes admin the clone's own .git, nothing joins to it, so it landed on Forgotten — and I have the failing run showing the directory in removing with seen_as: Forgotten, removed outright. linked_worktree_name now rejects ., .. and an empty tail, with a test.

S2 (major) — fixed

PrunePlan::freed/PruneReport::freed are now clones_freed and no longer chain the sweep in; the worktree section keeps WorktreeSweep::freed/WorktreeReport::freed. Pinned at the binary boundary by the_clone_sentences_count_clone_bytes_and_the_worktree_sentence_counts_worktree_bytes, which parses the two figures out of a real --prunable --agent-worktrees run and asserts the headline equals the one row under it. Verified red on the old chaining: left: 128.0, right: 120.0.

Minors

  • S3 — fixed. A registration with nothing behind it no longer counts as work on its own, and reclaim now runs the prune for a clone whose only outstanding work is such a registration, so the state converges instead of being reported forever and cleared by nothing. a_registration_with_nothing_behind_it_is_not_by_itself_something_to_prune runs the pass and asserts the next plan has nothing to do; red before.
  • S4 — fixed, both residues. The pathspec is gone from Git::worktree_dirt, and dirt_in drops an untracked entry only when everything under it is a confirmed linked worktree. So a tracked file modified under that path is work again, and so is plain content under a candidate's .claude/worktrees/ — which was the dangerous one, since the sweep skips it too. Two tests, both verified red with the pathspec restored. Your motivating case still holds: a_worktree_inside_a_kept_worktree_is_reclaimed_on_its_own is untouched and green. The walk is guarded by a cheap spine check so an untracked build/ is not walked, stops at each worktree (which is where the .pixi bytes are), and refuses symlinks so it terminates.
  • S5 — fixed. registrations_with_nothing_here returns RegistrationsWithNothingHere, telling a container path that never resolved here apart from a path in this clone with nothing at it, and the plan prints a sentence for each. Registration keeps the path git printed for that one comparison, as a prefix test, never handed to the filesystem.
  • N1 — fixed. README's two paragraphs of rationale removed; docs/cleanup.md already carried both nearly verbatim. --force-worktrees stays named, so test_readme_cli_doc is satisfied.
  • N2 — fixed. CloneWorktrees::freed deleted. bytes_in is pub(crate). WorktreeSweep::removing/keeping and Insisted::nothing are #[cfg(test)] pub(crate) rather than plain pub(crate), because plain would have been dead code in a non-test build — they exist for the tests and now say so.
  • N3, partly and incidentally. The WORKTREES_DIR / ":!.claude/worktrees" pair you flagged as unguarded Shotgun Surgery no longer exists: S4 moved the decision into the module that owns the name, so there is one place. The other three N3 nits (the duplicated ternary in commands.rs, the repeated canonical(bare_dir(…)), the 104 vs 104.5 GB wording) I did not touch — they are outside this diff's blast radius and the README paragraph that said "104 GB" is gone with N1 anyway.
  • S6 second bullet — fixed. docs/cleanup.md now says the two-run orphan case explicitly, along with the holdback's new basis, the Forgotten probe, and the registration kinds.

Checks

cargo test --workspace, cargo clippy --locked --all-targets -- -D warnings, cargo fmt --check, and pytest test/ (413 passed) all green locally. Doc guards specifically: 62 passed across test_docs_prose, test_readme_cli_doc, test_public_api_snapshots_doc, test_bench_doc.

Snapshot. public-api.api.txt is byte-identical — git diff -- '*api.txt' is empty. public-api.rest.txt is +34/-10. The ten removed rows, exhaustively:

Row Why
CloneWorktrees::freed dead, no caller (N2)
CloneWorktrees::metadata_may_be_pruned replaced by metadata_gate (S1)
CloneWorktrees::registrations_with_nothing_here(&self) -> usize now returns RegistrationsWithNothingHere (S5)
PrunePlan::freed renamed clones_freed (S2)
PruneReport::freed renamed clones_freed (S2)
agent_worktrees::bytes_in pub(crate) (N2)
WorktreeSweep::removing #[cfg(test)] (N2)
WorktreeSweep::keeping #[cfg(test)] (N2)
Insisted::nothing #[cfg(test)] (N2)
impl Insisted the header goes with its only member

Added: MetadataGate and RegistrationsWithNothingHere with their derives, metadata_gate, the new registrations_with_nothing_here signature, and the two clones_freed. No reordering, no other removal.

dl/tests/read_side.rs untouched, per your finding on #401.

@blooop

blooop commented Aug 25, 2026

Copy link
Copy Markdown
Owner Author

This was generated by AI while fixing the review.

Closing the loop on S1 with your own reproduction rather than only the unit test, since your report ended at "reproduced end to end". Built lifecycle_scenario.py --agent-worktrees, removed the fixture's note so both worktrees read collectable, then wrote into agent-unsaved with the [y/N] question actually on screen and answered y.

Run 1 — the holdback fires, which is the line that was missing:

Are you sure? [y/N] Removed 0 clone director(ies) -- 0 B.
Removed 1 agent worktree(s) -- 8.0 KiB.
Left .../.claude/worktrees/agent-unsaved: holds 1 uncommitted change(s) (notes.md)
  -- add --force-worktrees to remove it anyway. That was not so when the plan above was printed.
Did not run git worktree prune in .../devlaunch-main-legacy: a worktree there is being kept,
  and the registration is what goes on protecting it.

Note the first line as well: Removed 0 clone director(ies) -- 0 B. where it used to say -- 8.0 KiB over a run that removed no clone. That is S2 in the same output.

Run 2 — the run that took the work. It no longer sees a forgotten directory, because the registration is still there:

Agent git worktrees inside the clones above -- 0 B:

  .../devlaunch-main-legacy:
    - leaving .../.claude/worktrees/agent-unsaved: holds 1 uncommitted change(s) (notes.md) -- add --force-worktrees to remove it anyway
    - 1 registration(s) here name a path inside a container, which never resolved on this host, so nothing is freed by forgetting them
    - git worktree prune is held back here: ...

Nothing to prune.
$ cat .../agent-unsaved/notes.md
an afternoon nobody else has

Three of the fixes show up in those seven lines: the holdback answering to the acting pass (S1), the clone sentence carrying clone bytes (S2), and run 2 reaching Nothing to prune. instead of prompting and no-opping forever (S3). The registration sentence is S5.

And the plan for the same world, for S2's other half — the headline is a total over the row under it again, where it read 128.0 KiB over 120.0 KiB:

Removing 1 that nothing references -- 120.0 KiB:
  - .../devlaunch-gone-nobody (120.0 KiB)
...
Agent git worktrees inside the clones above -- 8.0 KiB:

# Conflicts:
#	rust/devlaunch-core/public-api.rest.txt
@blooop

blooop commented Aug 25, 2026

Copy link
Copy Markdown
Owner Author

This was generated by AI while fixing the review.

One thing that was not a review finding but was blocking the review from being re-run: the branch had gone conflicting with main, so GitHub could not compute the merge ref and the pull_request-triggered CI and prek workflows did not fire at all for 913df8c — not pending, not failed, absent. Only GitGuardian and the push-triggered Auto-publish reported, which reads as "2 checks, green" and is not the same thing as green.

main had moved 19 commits, including the 0.14.0 release. Merged at 7596524. The only conflict was public-api.rest.txt, which is generated, so it was resolved by taking main's and regenerating rather than by hand-merging two snapshots. public-api.api.txt is byte-identical to main's. Everything else auto-merged; render.rs, commands.rs and lifecycle.rs all took content from both sides and the full suite is green over the result.

Re-ran after the merge: cargo test --workspace, cargo clippy --locked --all-targets -- -D warnings, cargo fmt --check, pytest test/ (413 passed).

One thing I deliberately did not do: add a CHANGELOG.md entry under [Unreleased]. This PR never had one, your Standards pass checked repo standards and did not call for it, and inventing release notes for somebody else's feature in a review-fix pass seemed like the wrong call to make unasked. Flagging it rather than deciding it, since 0.14.0 shipped from main while this branch was open and the section is currently empty.

blooop added 2 commits August 25, 2026 13:04
The plan's clone total and its worktree total are two claims about two sets of
directories, which is the point of separating them; docs/cleanup.md described the
--ls attribution and not this.
The forgotten arm now has a probe wherever one is possible, and `worktree_status`
enumerates the four cases instead of catching two of them in a wildcard. The last
one to gain a probe is a registration whose admin directory a concurrent prune
took between the listing and the look: no index and no HEAD, so nothing can be
asked of the working tree, but the registration still names a head and the
commits can be asked about. Narrow, and the arm it lands on is the arm that
deletes, which is the whole reason to bother.

No public surface moves: both new functions are private.
@blooop

blooop commented Aug 25, 2026

Copy link
Copy Markdown
Owner Author

This was generated by AI while fixing the review.

One more push, 9b6180b, finishing S1's second direction properly rather than most of the way.

worktree_status was destructuring (registered, admin) with a wildcard, which meant three different situations shared one answer of "nothing can be asked". Two of them can be asked something. It now enumerates all four:

registration admin dir what is asked
yes yes the working tree, and the commits
no yes the working tree — a name git dropped, or a suffix join that missed
yes no the commits — a concurrent prune took the admin directory between the listing and the look
no no nothing, and that is genuinely all there is

The third row is new since my earlier comment. It is a narrow window, since git worktree list reads the admin directories, so a registration means one existed a moment ago. But it lands on the arm that deletes, and the registration still names a head, so unsaved_without_an_admin_dir asks the cache and then the clone whether anything else reaches the commits. Pinned by a_registration_whose_admin_directory_vanished_is_still_asked_about_its_commits, which makes a commit that was never pushed, removes the admin directory the way a concurrent prune does, and asserts the worktree is kept.

The only refactor in it is pulling the reachability half of unsaved_in into unreachable_commits_in, so the two callers ask it in the same words — the same reason dirt_in exists.

public-api.api.txt and public-api.rest.txt are both unchanged by this commit: git diff -- '*api*.txt' is empty, because both new functions are private. cargo test --workspace, cargo clippy --locked --all-targets -- -D warnings, cargo fmt --check and pytest test/ are green.

Net effect on the finding: the arm the review identified as "the one arm with no probe at all" now has a probe in every case where a probe is possible, and the one remaining case is documented as a limit with the reason it is one, rather than being a wildcard nobody reads.

@blooop

blooop commented Aug 25, 2026

Copy link
Copy Markdown
Owner Author

This was generated by AI while fixing the review.

All checks green at 9b6180b, and the pull request is mergeable again. Fourteen passing: ci, rust, e2e, rust-coverage, packaging, public-api, prek, review, gate, GitGuardian, and the four codecov contexts. publish-job skips as always.

Summary of the four commits on top of the review's b812411:

Commit What
913df8c S1 blocker, S2, S3, S4, S5, N1, N2
7596524 merge of main, which had gone conflicting and was blocking CI from running at all
c631646 one docs sentence for S2's two figures
9b6180b S1's second direction finished: a probe in every case where one is possible

Both blocking findings are fixed with a failing test written first and verified red, and S1 is additionally reproduced end to end through the binary with your own lifecycle_scenario.py --agent-worktrees recipe, in the comment above. Every minor is fixed. Nothing on the list was skipped except three of the N3 nits, named in my first comment, and a CHANGELOG.md entry that I flagged rather than wrote.

@blooop blooop left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was generated by AI during review.

Second review, fresh context. Reviewed at 9b6180b4 against merge-base 2aa9602b. Spec taken from #426's body and the sharpening comment (id 5409723748). Preflight: 15 check runs green at the full head SHA, diff non-empty. I wrote neither the code nor the first review and took neither account on trust: every claim below is either reproduced or refuted here.

Locally: cargo test --workspace green, cargo clippy --locked --all-targets -- -D warnings clean, cargo fmt --check clean, pytest test/ 413 passed.

Spec

The blocker is closed, by reproduction

Ran the first review's own recipe: lifecycle_scenario.py --agent-worktrees --prunable, the fixture note removed so both worktrees read collectable at plan time, then the write into agent-unsaved landing while Are you sure? [y/N] was actually on screen, answered y. Two consecutive runs against target/release/dl at this SHA.

Run 1 withholds and, this time, says so:

Removed 1 clone director(ies) -- 120.0 KiB.
Removed 1 agent worktree(s) -- 8.0 KiB.
Left .../.claude/worktrees/agent-unsaved: holds 1 uncommitted change(s) (notes.md)
  -- add --force-worktrees to remove it anyway. That was not so when the plan above was printed.
Did not run git worktree prune in .../devlaunch-main-legacy: a worktree there is being kept,
  and the registration is what goes on protecting it.

git worktree list still names agent-unsaved after run 1. Run 2 reaches Nothing to prune., and notes.md reads back an afternoon nobody else has. No flag typed at either run. S1 is fixed.

The "verified red first" claim holds. I reverted one line in a scratch checkout -- the gate = gate.and_keeping(&because) fold in reclaim -- and a_worktree_that_went_dirty_while_the_question_was_open_keeps_its_registration fails at exactly the quoted assertion, with the same message:

assertion `left == right` failed: the prune has to answer to what this pass withheld,
not to what the plan predicted
  left: []
 right: [".../repos/o/r/r-live-aa"]

The seed is sound. MetadataGate::default() is open and and_keeping only ever ORs true in, so the fold is monotone: neither pass can reopen a gate the other closed, and the plan's seed can only be more conservative than the outcomes, never less. A plan keep the act would have removed costs a held-back prune, which is the harmless direction.

worktree_status is genuinely exhaustive. Four tuple arms over (Option<&Registration>, Option<&Path>), no catch-all -- a fifth situation would stop the build. And each arm's probe is the right one, not merely present: (Some, Some) asks dirt and reachability; (Some, None) asks the commits alone, which is all a vanished admin directory leaves askable; (None, Some) asks dirt, which is correct because a branch ref lives in the shared repository and survives the directory; (None, None) is #426's category 1 verbatim.

The S6 gitfile guard is complete, not just complete for the tail it was found with. I drove linked_worktree_name over eight shapes:

/workspaces/x/.git/worktrees/.              -> None
/workspaces/x/.git/worktrees/..             -> None
/workspaces/x/.git/worktrees/../name        -> None
/workspaces/x/.git/worktrees/name/../other  -> None
/workspaces/x/.git//worktrees//name         -> Some("name")
/workspaces/x/.git/worktrees/name/          -> Some("name")
/workspaces/x/.git/worktrees/./name         -> Some("name")
.git/worktrees/name                         -> Some("name")

The four rejections are right and the four acceptances are right: Path::components folds ., doubled separators and a trailing separator away before the tail is read, so those are the same name and not a bypass. A tail naming a different worktree's admin directory in the same clone also fails safe -- the borrowed index reports the whole directory as changes, so it lands in keeping as Objected, which I confirmed. A symlinked admin directory either resolves to the right repository or makes git refuse, and a refusal is CouldNotTell, which objects.

Who was right about S6: the fixer

The first review said the .git/worktrees/.. tail "fails safe (reads dirty, kept)". It does not, and the first review's own S1 paragraph says why without noticing: at b812411 the Forgotten arm carried no probe at all. let (Some(registration), Some(admin)) = (registered, admin) else { return Forgotten { usage } } -- so an unregistered directory with a .. tail (which is the natural shape for a hand-written or clobbered gitfile) took the else, landed on the deleting arm, and went. The "reads dirty, kept" analysis holds only for the sub-case where the directory is also genuinely registered, and that is not the sub-case the tail produces. The fixer is right, its failing run was real, and 9b6180b's guard closes it.

Confirmed closed

S2 -- verified in the binary's own output: Removing 1 that nothing references -- 120.0 KiB over a single 120.0 KiB row, Agent git worktrees inside the clones above -- 16.0 KiB as its own figure, and Removed 1 clone director(ies) -- 120.0 KiB. / Removed 1 agent worktree(s) -- 8.0 KiB. as two sentences about two things. S3 -- run 2 reaches Nothing to prune. rather than prompting and no-opping. S4 -- both residues; the pathspec is gone from Git::worktree_dirt and dirt_in drops an untracked entry only when everything under it is a confirmed worktree, with the motivating nested-in-kept case still green. S5 -- the container-path sentence prints. N1, N2 -- closed (see Standards).

T1. A worktree nested inside one that is going is destroyed, unreported, with its work. (blocker)

#426, Ask 2: "locked worktrees are never removed implicitly. Require an explicit opt-in flag, and say how many were skipped and why."
#426, Ask 3: "Refuse -- or require the same explicit flag -- for any worktree whose branch has commits not reachable from the default branch or any remote ref."

sweep_clone descends into a worktree only when it is keeping it (agent_worktrees.rs:1145), and dirt_in drops an untracked entry when everything under it is a confirmed linked worktree. Put together, a nested worktree inside a directory that is going is invisible to both halves: it does not object through its parent's dirty check, and it is never classified on its own. It is removed with the parent, unreported, with no flag typed. Three probes, all against this SHA, in the module's own test harness (outer worktree finished, pushed and clean; container paths, so both read prunable, which is what every worktree on a host reads):

nested holds an uncommitted note   -> removing: [agent-outer]   keeping: []
nested holds an unpushed commit    -> removing: [agent-outer]
nested is `git worktree lock`ed    -> removing: [agent-outer]

The third line is Ask 2 broken outright: a locked worktree removed implicitly, not reported, not counted, no flag. The first two are Ask 3 and the sharpened spec's "Uncommitted work" paragraph. This is not exotic on the host in the ticket: nesting is how one clone reached 55 GB, on a host every container-registered worktree reads prunable, so an outer worktree's removability turns entirely on whether its own working tree is clean and its own commits are pushed. A finished, pushed outer task with a live nested session inside it is the ordinary shape, and the parent's byte figure includes the child's, so it presents as the biggest win in the plan.

I am raising this against the fix rather than against the body because the fix is what rests its case on it. docs/cleanup.md:283-286 now argues the exclusion is safe because nested worktrees "are what this sweep reasons about separately", and dirt_in's doc comment says the dropped set "is exactly the set this sweep reasons about separately". For a nested worktree inside a going one, neither sentence is true. S4 in the first review flagged the sibling case -- non-worktree content under a candidate's .claude/worktrees/ that "goes when the parent worktree does" -- and the fix closed the smaller half of it while the half holding the .pixi gigabytes and the live sessions stayed open. a_worktree_inside_one_that_is_going_is_not_reported_twice pins the behaviour with a deliberately clean inner worktree, so the suite does not see it.

Shape of a fix, since the naive one is wrong: descend into a worktree that is going as well, classify children first, and make a parent removable only if every nested worktree under it is. Reporting stays parent-only, so nothing is counted twice; what changes is that the parent's decision becomes conjunctive over its children.

T2. git worktree prune's blast radius is wider than the gate's domain. (major)

The gate is fed from two places and only two: the plan's fold over clone.keeping, and reclaim's fold over the outcomes for clone.removing. Both range over directories the plan enumerated. git worktree prune ranges over the whole clone. A worktree the container registers after the plan is taken is in neither set, so nothing closes the gate, the prune drops its registration and its admin directory, and the next run meets (None, None) -- the arm with no probe -- and removes it outright.

Reproduced at the reclaim seam at this SHA. Plan taken with one collectable worktree; the container then runs git worktree add and writes a note, as it would while the question is on screen:

run 1 report: removed [agent-finished], withheld [], metadata_held_back []
git worktree list after run 1: (agent-fresh is gone)
run 2 removing: [agent-fresh]   seen_as: Forgotten
fresh dir still there: false
note still there:     false

Same loss as S1, same "no flag typed at either run", reached through the neighbouring door. The module's guarantee that "the approved set can shrink and never grow" is true of the removals and does not cover the prune, and MetadataGate's "answers to outcomes rather than to predictions" is true only over the enumerated candidates. I do not think this is unclosable from here: reclaim already re-reads ClonePicture, so before pruning it can ask whether every registration whose directory is still present in the clone is accounted for by the removed, withheld or plan-kept sets, and close the gate when one is not. Nested registrations inside a removed worktree do not block that test, because their directories are gone by then.

T3. A refused removal is the one acting-pass outcome fed to neither the removal nor the gate. (minor)

agent_worktrees.rs:1352: Removal::WhatItCould | Removal::Nothing pushes to report.refused and folds nothing. The directory is still there and its registration is still there, and if a sibling removal in the same clone succeeded, or the clone has a dangling registration, the prune runs and takes it. Live loss needs work to arrive in a directory the filesystem would not let dl delete, so this is narrower than T2 -- but reclaim's doc and 913df8c's message both say the gate is fed every outcome, and this is the one that is not.

T4. The (None, Some(admin)) arm could ask the commits too. (nit)

dirt_only is right for the branch case, because the branch ref outlives the directory. It is not right for a detached head: the commit is reachable only from the admin directory's HEAD and reflog, which is exactly what a later prune removes. The admin directory is there and holds a HEAD, so it can be asked. Contrived to reach -- it needs a registration whose gitdir no longer names a .claude/worktrees/ path -- and it is the same arm 9b6180b has just been through.

Not defects

The a_worktree_inside_a_clone_that_is_going_is_not_swept_separately premise is right and the two-run convergence is honest, as the first review found. --prune stays inside repos_dir; nothing here derives a path from anything but clone. Sizes are an attribution and exclusive_usage bills a file only when every link to it is inside the tree, so the hardlinked object store stays out.

Standards

Repo standards are met. cargo clippy --locked --all-targets -- -D warnings and cargo fmt --check clean; pytest test/ 413 passed, including test_docs_prose, test_readme_cli_doc, test_public_api_snapshots_doc and test_bench_doc. No em or en dash in README.md or any page under docs/.

N1 closed. Both rationale paragraphs are out of README.md; --force-worktrees is still named at README.md:235, so test_readme_cli_doc is satisfied, and the argument now lives at docs/cleanup.md:283-288, 333-360.

N2 closed, and the new surface is tight. CloneWorktrees::freed is gone. bytes_in is pub(crate) with one caller; WorktreeSweep::removing/keeping and Insisted::nothing are #[cfg(test)] pub(crate), which is the better call than plain pub(crate) since plain would have been dead in a shipping build. Every symbol 913df8c/9b6180b add has a non-test caller: metadata_gate/MetadataGate::open and RegistrationsWithNothingHere's accessors all land in render.rs, and all seven new private functions are called. The WORKTREES_DIR / ":!.claude/worktrees" pair the first review flagged as unguarded Shotgun Surgery is genuinely gone rather than moved: the pathspec exists nowhere in Rust except a doc comment in git.rs:535 explaining its removal, and WORKTREES_DIR is now the sole spelling, used four times inside the module that owns it.

The snapshot resolution lost nothing. I checked this as a three-way rather than a two-way, because "took main's copy and regenerated" is the shape that drops rows silently. Main's delta across the merge window (449cae2..2aa9602) is 26 removals and 62 additions; the merge (913df8c -> 7596524) applied exactly those 26 and 62, and the sets are identical. All 62 of main's added rows are present at HEAD; none of its 26 removed rows came back. The four rows removed relative to current origin/main are all branch-side and all deliberate: SizeCell::Measured now carries CloneDisk, PrunePlan::freed and PruneReport::freed are renamed clones_freed (S2), and prune_plan takes Insisted in place of a bare Insistence. Nothing unexplained. Five further rows that differ are main-only additions from PR #433, which landed after this branch's merge and are not ancestors of HEAD -- not losses. I also ran CI's own generator (scripts/public-api-snapshots.sh) in the container: all three snapshots byte-identical to what is checked in. git diff origin/main HEAD -- '*api.txt' is empty, so the promised tier is untouched, as claimed.

One presentational note: the fix comment's table lists ten removed rows, which is the count against the pre-merge branch tip and not against main. Against main it is four. The claim is not wrong, but a reader checking it will not get ten.

The CHANGELOG omission is right for this repo. Established from evidence, not assumption: of the last 25 first-parent merges to main, two touched CHANGELOG.md, and none of the eight wayfinder/devlaunch-* PRs did. No test, hook, CI job, CONTRIBUTING or CLAUDE.md rule requires an entry; grep -il changelog test/ .github/ docs/ CLAUDE.md pyproject.toml hits only publish.yml and one archival document. publish.yml:72-85 extracts ## [$version] at release time and fails only there, and b878d42 ("Release 0.14.0") is where [Unreleased] gets curated. So the file is maintained per release, not per PR, and declining to invent release notes for somebody else's feature in a review-fix pass was the correct call. Worth passing on rather than acting on: history holds two retroactive-backfill commits (3982989, 4e200b7) whose messages regret a miss, and a whole new module plus a new flag is an ### Added somebody will want at 0.15.0.

N4. An unreachable arm pointing the deleting way. (minor)

agent_worktrees.rs:475 and :524: Losses::of([commits]) over a one-element array can never be None, since NonEmpty::of returns None only for an empty iterator -- so None => Unsaved::NothingToLose is dead in both. In a module whose stated discipline is that every uncertainty fails towards keeping, the dead arm to leave lying around is not the one that reads as a licence to delete. NonEmpty::one (workspace_state.rs:270) already exists and says the intent.

N5. Three copies of one collapse. (nit, Duplicated Code)

unsaved_in (:402), unsaved_without_an_admin_dir (:465) and dirt_only (:520) are the same Result<Option<Loss>, Unsaved> -> Unsaved fold three times over, differing only in which probes they ask. One helper taking the probes as an iterator would leave one rule where there are now three that have to agree.

N6. The skipped N3 nits

Still present, as stated: the duplicated ternary at commands.rs:527-531, the repeated canonical(bare_dir(..)) in lifecycle.rs, and docs/cleanup.md saying 104.5 GB at :252 and 104 GB at :373 (the README paragraph that disagreed is gone with N1, but the docs page now disagrees with itself). Fine to leave; the call to leave them was reasonable.

Verdict

Request changes. (Posted as a comment: GitHub refuses --request-changes on a PR authored by the same account.)

  • Standards: pass. Every claimed Standards fix is genuinely closed, the snapshot resolution lost nothing and the generator agrees byte-for-byte, and the CHANGELOG omission is correct for this repo's actual convention. N4 to N6 are minors and nits.
  • Spec: fail. T1 blocks.

Blocking:

  • T1 -- a worktree nested inside one that is going is removed with it, unreported and with no flag typed, taking uncommitted work, unpushed commits, or a lock. #426 Ask 2 says a locked worktree is never removed implicitly; this removes one and does not even print it. Reproduced three ways. The fix's own justification, in dirt_in's doc comment and at docs/cleanup.md:283-286, is the sentence that is false here.

Should fix before merge, not strictly blocking:

  • T2 -- the metadata prune reaches registrations neither pass enumerated, so a worktree the container registers after the plan loses its registration and is deleted outright by the next run. Reproduced end to end. Same loss as S1 through the neighbouring door, and closable with the picture reclaim already re-reads.
  • T3 -- a refused removal feeds neither the removal nor the gate, which is one hop from T2 and contradicts the stated invariant.

Everything the first review raised is closed, and closed properly: the blocker with a test verified red at the quoted assertion and reproduced through the binary, the minors each with their own pin, and the one place the first review was wrong -- S6 -- corrected with evidence rather than argued around. The MetadataGate fold is the right shape and the four-arm worktree_status is a real improvement over the wildcard. What T1 and T2 have in common is the boundary of the enumeration: both passes reason about the directories the sweep listed, and both the parent-child relation and the metadata prune reach past that list. That is one idea to fix, not two.

@blooop

blooop commented Aug 29, 2026

Copy link
Copy Markdown
Owner Author

Superseded by #525, and closed unmerged.

Not because the work was wrong. Three review rounds produced three correct fixes and the same
defect shape each time, which is a seam in the wrong place rather than a defect list getting
shorter. Map #444 was charted to find where the seam belongs, and #454 is the rebuild on what it
decided. Landing this first would have shipped T1 unfixed, which is an unflagged data-loss path,
so there is no version of it that goes in as a floor.

What #525 carries over from here, unchanged in substance:

  • The four-way classification's content — what each arm asks git, and why exactly one of them
    could take git's word.
  • The dirty check through the clone's admin directory (--git-dir=<clone>/.git/worktrees/<name>
    with --work-tree=<directory>), which is the only way to ask a container-registered worktree
    anything from a host. It gained --ignored, because git worktree remove deletes a worktree
    whose only content is gitignored, exit 0 and silent.
  • The .bare-first reachability with the stale-ref argument behind it, and the report saying the
    answer is as of the last fetch. No network call was added here and none is added there.
  • --force-worktrees as a flag distinct from --force, with the parse test that --force alone
    does not reach a worktree.
  • The --ls --size attribution and the disk.worktrees JSON key.
  • S4's rule, in the words this PR's review gave it: exclude by what a thing is, not by where it
    sits
    .
  • S6's gitfile normalisation guard, which the second review confirmed the fixer was right about.

What was replaced, and why:

  • MetadataGate, metadata_gate(), registration_goes_on_protecting and
    metadata_held_back go with the operation they gated.
    The S1 fix was correct, and its rule
    generalises: a guard read from the plan rather than from the outcome protects once and then
    hands the thing over. What What unit does the agent-worktree sweep reason about #445 established is that the operation itself has no plan-time
    domain — git worktree prune's blast radius is a readdir over $GIT_DIR/worktrees at act
    time, so a registration created after the plan was printed is inside it and no gate can
    contain that. So it is deleted rather than gated better, and the metadata work becomes
    git worktree remove <the path git printed>, per registration, by name. T2 stops being a
    gate that has to hold and becomes an invocation that cannot be constructed
    : the argument is
    a Recorded, and Recorded's only constructor parses a listing.
  • The unit. A directory does not know what contains it, and both surviving operations have
    subtree radius, so the unit is a site plus everything nested inside it, decided bottom-up and
    conjunctively. T1 has no representation under it: the recursion that produces a collectable
    verdict visits every child itself and the caller passes no child list.
  • The predicate. A boolean has to call could not be proved either safe or unsafe, and
    calling it safe is what S1 and S6 both reduce to. It is now
    Collectable(Proof) | Stands(NonEmpty<Reason>), where the proof is mintable only from a probe
    that answered and reasons accumulate up the subtree. N4 closes with it: there is no fallthrough
    arm left to fall through to.
  • Held is gone, and nothing replaces it, because it was never the cover it was described
    as: measured from inside a real devcontainer, a container's own clone is bind-mounted outside
    its own cache_dir, so it is never in the enumerated domain and Held never runs there.
    Locality and ordering carry that instead, and both were already decided.
  • Ownership stops being the gitfile tail. A tail says a directory is a worktree of some
    repository. This build read it as this clone's, and the consequence was measured on this tree:
    a live worktree of another repository, nested in one of ours and holding uncommitted work,
    offered for removal unopposed under the printed reason "git has already forgotten it", which
    was false. That is the one item here that was a live deletion rather than a shape.

T3 needs no fix in the new shape: a refused or partial removal forgets nothing, because
forgetting is a consequence of a completed subtree removal.

The measurements on #426 and its sharpened spec stay the record everything is checked against,
and #525 closes it.

@blooop blooop closed this Aug 29, 2026
blooop added a commit that referenced this pull request Aug 29, 2026
The unit is a site and everything nested inside it, decided bottom-up and
conjunctively, so a worktree nested inside one being removed cannot be lost
with it: the collectable arm of a verdict is reachable only when every nested
site handed one back, and the caller passes no child list. That is T1 from
PR #442's second review, made unrepresentable rather than guarded.

What decides a site is a verdict rather than a boolean:
Collectable(Proof) | Stands(NonEmpty<Reason>), where the proof is a
private-field witness only a probe that answered can mint, and reasons
accumulate up the subtree, so a site that is both dirty and locked reports
both and a parent's line names the child. A lock is an unproved, never a loss.

The metadata operation is `git worktree remove <the path git printed>`, per
registration by name, and the clone-wide `git worktree prune` is deleted
rather than gated: its domain is a readdir at act time, so a registration
created after the plan was printed was inside its blast radius and no plan
could name it. `Recorded`'s only constructor parses a listing, so a name the
pass did not read cannot be an argument. That is T2.

Ownership is a join against this clone's own listing, never the gitfile tail:
a live worktree of another repository, nested in one of ours, used to be
offered for removal unopposed under a reason that was false.

The clone is the root of the same forest, which closes the clone-level guard's
blindness to nested worktrees. `Unsaved` survives as the wire flattening.
blooop added a commit that referenced this pull request Aug 29, 2026
The unit is a site and everything nested inside it, decided bottom-up and
conjunctively, so a worktree nested inside one being removed cannot be lost
with it: the collectable arm of a verdict is reachable only when every nested
site handed one back, and the caller passes no child list. That is T1 from
PR #442's second review, made unrepresentable rather than guarded.

What decides a site is a verdict rather than a boolean:
Collectable(Proof) | Stands(NonEmpty<Reason>), where the proof is a
private-field witness only a probe that answered can mint, and reasons
accumulate up the subtree, so a site that is both dirty and locked reports
both and a parent's line names the child. A lock is an unproved, never a loss.

The metadata operation is `git worktree remove <the path git printed>`, per
registration by name, and the clone-wide `git worktree prune` is deleted
rather than gated: its domain is a readdir at act time, so a registration
created after the plan was printed was inside its blast radius and no plan
could name it. `Recorded`'s only constructor parses a listing, so a name the
pass did not read cannot be an argument. That is T2.

Ownership is a join against this clone's own listing, never the gitfile tail:
a live worktree of another repository, nested in one of ours, used to be
offered for removal unopposed under a reason that was false.

The clone is the root of the same forest, which closes the clone-level guard's
blindness to nested worktrees. `Unsaved` survives as the wire flattening.
blooop added a commit that referenced this pull request Aug 29, 2026
The unit is a site and everything nested inside it, decided bottom-up and
conjunctively, so a worktree nested inside one being removed cannot be lost
with it: the collectable arm of a verdict is reachable only when every nested
site handed one back, and the caller passes no child list. That is T1 from
PR #442's second review, made unrepresentable rather than guarded.

What decides a site is a verdict rather than a boolean:
Collectable(Proof) | Stands(NonEmpty<Reason>), where the proof is a
private-field witness only a probe that answered can mint, and reasons
accumulate up the subtree, so a site that is both dirty and locked reports
both and a parent's line names the child. A lock is an unproved, never a loss.

The metadata operation is `git worktree remove <the path git printed>`, per
registration by name, and the clone-wide `git worktree prune` is deleted
rather than gated: its domain is a readdir at act time, so a registration
created after the plan was printed was inside its blast radius and no plan
could name it. `Recorded`'s only constructor parses a listing, so a name the
pass did not read cannot be an argument. That is T2.

Ownership is a join against this clone's own listing, never the gitfile tail:
a live worktree of another repository, nested in one of ours, used to be
offered for removal unopposed under a reason that was false.

The clone is the root of the same forest, which closes the clone-level guard's
blindness to nested worktrees. `Unsaved` survives as the wire flattening.
blooop added a commit that referenced this pull request Aug 29, 2026
The unit is a site and everything nested inside it, decided bottom-up and
conjunctively, so a worktree nested inside one being removed cannot be lost
with it: the collectable arm of a verdict is reachable only when every nested
site handed one back, and the caller passes no child list. That is T1 from
PR #442's second review, made unrepresentable rather than guarded.

What decides a site is a verdict rather than a boolean:
Collectable(Proof) | Stands(NonEmpty<Reason>), where the proof is a
private-field witness only a probe that answered can mint, and reasons
accumulate up the subtree, so a site that is both dirty and locked reports
both and a parent's line names the child. A lock is an unproved, never a loss.

The metadata operation is `git worktree remove <the path git printed>`, per
registration by name, and the clone-wide `git worktree prune` is deleted
rather than gated: its domain is a readdir at act time, so a registration
created after the plan was printed was inside its blast radius and no plan
could name it.

Ownership is a join against this clone's own listing, never the gitfile tail:
a live worktree of another repository, nested in one of ours, used to be
offered for removal unopposed under a reason that was false.

The clone is the root of the same forest, which closes the clone-level guard's
blindness to nested worktrees, and it takes #522's `BareCache` and passes it
to the probe underneath rather than deciding the tag question for itself. A
site's own reachability probe gives no tag account: it names one revision and
asks the clone about it, where the tag question is about which of a clone's
refs the mirror does not have, so `by_tags: None` is the honest answer.

`Unsaved` survives as the wire flattening.
blooop added a commit that referenced this pull request Aug 29, 2026
The unit is a site and everything nested inside it, decided bottom-up and
conjunctively, so a worktree nested inside one being removed cannot be lost
with it: the collectable arm of a verdict is reachable only when every nested
site handed one back, and the caller passes no child list. That is T1 from
PR #442's second review, made unrepresentable rather than guarded.

What decides a site is a verdict rather than a boolean:
Collectable(Proof) | Stands(NonEmpty<Reason>), where the proof is a
private-field witness only a probe that answered can mint, and reasons
accumulate up the subtree, so a site that is both dirty and locked reports
both and a parent's line names the child. A lock is an unproved, never a loss.

The metadata operation is `git worktree remove <the path git printed>`, per
registration by name, and the clone-wide `git worktree prune` is deleted
rather than gated: its domain is a readdir at act time, so a registration
created after the plan was printed was inside its blast radius and no plan
could name it.

Ownership is a join against this clone's own listing, never the gitfile tail:
a live worktree of another repository, nested in one of ours, used to be
offered for removal unopposed under a reason that was false.

The clone is the root of the same forest, which closes the clone-level guard's
blindness to nested worktrees, and it takes #522's `BareCache` and passes it
to the probe underneath rather than deciding the tag question for itself. A
site's own reachability probe gives no tag account: it names one revision and
asks the clone about it, where the tag question is about which of a clone's
refs the mirror does not have, so `by_tags: None` is the honest answer.

`Unsaved` survives as the wire flattening.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

dl --prune cannot reclaim agent worktrees inside live workspaces' clones (104 GB measured)

1 participant