Skip to content

dl --prune: remove the clone directories nothing opens any more - #170

Merged
blooop merged 2 commits into
mainfrom
perf/159-prune-orphaned-clones
Aug 14, 2026
Merged

blooop merged 2 commits into
mainfrom
perf/159-prune-orphaned-clones

Conversation

@blooop

@blooop blooop commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Closes #159.

Rebased onto main at 856e1b8 — what changed since the last review

The last review's one blocking finding (L-A: holds_unsaved_work let git's
discovery walk up to an ancestor repository and answer about that one, so a
clone with an unpushed commit and an untracked file was deleted with no
--force) was graduated to #171 and fixed on main by #173. This branch's
own unsaved-work sum in devlaunch/workspace_state.py is therefore dropped
entirely
main's is the same three arms plus the --git-dir/--work-tree
pinning this branch did not have. workspace_state.py is now byte-identical to
main; the +153/−36 it used to carry is gone.

The L-A reproduction was re-executed against this head: the clone, its
scratch.md and its later.txt all survive, and the report names why.

Standards 1 (the CouldNotTell arm's producers unheld) is closed by #173's
tests: mutating the git status producer to read as clean is red 11 ways,
mutating the git log producer is red on
test_a_readable_repo_whose_remote_refs_are_broken_is_could_not_tell.

Two further reads of record.local_path_records_by_directory and
_records_for_absent_directories — now go through
WorkspaceCloneManager.resolve_clone_path (#174), which is the resolver the
guard, the --ls --json row and the delete already share. The first was the
review's surviving green mutation and it is a deletion: a record that
misses its clone drops that clone out of Disputed into Orphaned. Both have
a test that is red under exactly that mutation and green under nothing else.

One behaviour changed, and it needs a ruling. main's sum answers
CouldNotTell for a directory git cannot read as a repository at all, so a
stray non-clone directory in the cache is now kept and named, and --force
removes it. This branch used to tell that case apart with a .git probe of its
own and delete it (the reviewer's non-blocking L7). Deleting the second
opinion is deliberate — dl <ws> rm already refuses on that arm — but it is a
real cost: clearing junk out of the cache now takes a flag. README says so.

--git-dir is the only part of the original work that shipped separately; no
other hunk was found to be redundant.

dl --prune removes the clone directories under <cache>/devlaunch/repos that
no live devpod workspace opens any more, and nothing else. It is shaped exactly
like --purge — print the plan, name what is left standing and why, confirm,
-y to skip, --force for the one guarded arm — because that shape is already
learned. What it does is the opposite of --purge's all-or-nothing: on the host
#155 measured, the only way to reclaim 4.00 GB of dead clones was to destroy the
7.86 GB of live ones and every bare cache with them.

Decided on #155; the amendments on #159 from the re-verdict of #165 are what the
Referenced test, the total's arm and the Measured(0) pin below come from.

The classification

A three-constructor sum, and one total function over it:

A non-deletable state is unrepresentable rather than filtered out. There is
no boolean beside a status: decide(status, force) returns Remove(usage) or
Keep(because), it is the only place anything becomes deletable, it is total
over the arms with the codebase's existing _unhandled_* sentinel behind it, and
its result is consumed at exactly one site — which files each directory into
PrunePlan.removing or PrunePlan.keeping. A directory cannot be in both, in
neither, or in removing without a reason having been produced for keeping it.

Two fields sit inside Orphaned rather than beside the classification, and
both for the same reason: unsaved is only actionable on the arm that could be
deleted, so "unsaved work on a clone that is staying anyway" cannot be said; and
usage is only ever wanted for bytes somebody will get back, so the other two
arms are never walked at all. Remove then carries the usage, so "what this run
reclaims" is a total over exactly the set that dies.

--force promotes one arm. Referenced and Disputed are not refusals to
insist past — one says the directory is in use, the other says devlaunch's
records and devpod's disagree, and forcing a deletion is not an answer to a
disagreement.

The two traps #159 named, and one more

Resolve both sides. Referenced is decided by equality of canonical paths,
not by the lexical containment test is_devlaunch_clone uses. That predicate is
right where it lives — refusing there means declining to delete somebody's
workspace — and reusing it here would mean deleting their clone, so the review
of #165 was right that it must not be taken as-is. Candidates are canonical by
construction (root resolved once, real directory names from scandir, symlinked
entries skipped) and workspace sources are resolved individually.

Hold the repository lock. Taken across each repo's classification and again
across its removals. The docstring says plainly which window that closes (a
clone git clone is still filling) and which it does not (devpod only learns
about a clone after the lock is released).

And: a source devlaunch cannot read as a path stops the command. A live
workspace whose source will not resolve could be opening any candidate, so while
one exists there is no directory this command can honestly call unreferenced.
Dropping it from the comparison instead would be a deletion decided by a lookup
that failed. A source that is merely absent deliberately does not land there —
that is most of the workspaces on #88's host.

What it never touches

.bare (never a candidate, never reported), any devpod workspace, container,
image or volume, and anything outside repos_dir. It also drops the
metadata.json records of directories already gone — "gone" being _present's
distinction, so a directory behind a door this process cannot open keeps its
record.

Composition with #153

known_bytes() gets its first production caller here, and it is a sum rather
than a rendering: disk_usage.total_usage() returns Measured or
PartlyUnreadable, so one floor among the orphans makes the whole total a floor
and it renders as . known_bytes is also what orders the report, which is
the comparative question it was exported for. Nothing prints it directly.

Cost

One devpod list and zero devpod status, pinned in
test/test_devpod_spawn_counts.py beside --purge's. Nothing runs on its own,
nothing is on a launch path, and nothing was added to --ls.

Also here

  • report_refusals is extracted from purge_all_data and shared, so the
    "usually a container wrote them as a different user" advice — the only part of
    that report that is a guess — has one copy.
  • README.md loses its dl --prune-worktrees [days] row, which documented a
    command that does not exist and never did on this branch's history.
  • The Measured(0) case dl --prune: remove clone directories no workspace references #159's amendments asked for is pinned on the surface it
    belongs to: a clone removed by hand reads 0 B / {"exclusiveBytes": 0},
    which must stay distinguishable from - / null ("not devlaunch's to
    measure"). Those are exactly the directories this ticket classifies.

Mutation proofs, with the selection stated, are on #159.

Summary by Sourcery

Add a dl --prune command to remove unreferenced clone directories safely and share refusal reporting between prune and purge.

New Features:

  • Introduce dl --prune to scan devlaunch clone directories, classify them by workspace usage, and remove only unreferenced clones while preserving bare caches and live workspaces.

Enhancements:

  • Refactor refusal reporting into a shared report_refusals helper reused by purge and prune.
  • Add disk_usage.total_usage to aggregate per-directory usage while preserving partial-read semantics.
  • Ensure metadata.json worktree records are pruned when their directories are removed or no longer present.
  • Clarify and extend documentation and agent notes around clone pruning, cache layout, and dl --prune behaviour.

Tests:

  • Add an extensive test suite for dl --prune covering clone classification, safety guards, locking, error handling, and devpod interactions.
  • Add tests for disk_usage.total_usage and for handling clones removed by hand in workspace size reporting.
  • Pin that dl --prune invokes devpod list exactly once and never calls devpod status.

@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 have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@sourcery-ai

sourcery-ai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Reviewer's Guide

Adds a new dl --prune command that safely removes unreferenced clone directories under the devlaunch cache, sharing refusal reporting with purge, introduces structured clone classification and planning logic, extends disk-usage aggregation, and backs it all with extensive tests and documentation updates.

Sequence diagram for the new dl_prune command flow

sequenceDiagram
    actor User
    participant dl_cli as _run_cli
    participant prune_cmd as prune_command
    participant CloneMgr as WorkspaceCloneManager
    participant Devpod as list_workspaces
    participant Locs as workspace_locations
    participant Planner as prune_plan
    participant Printer as print_prune_plan
    participant Executor as prune_clones

    User->>dl_cli: dl --prune [flags]
    dl_cli->>prune_cmd: prune_command(flags)
    prune_cmd->>prune_cmd: validate flags
    prune_cmd->>CloneMgr: _get_clone_manager()
    prune_cmd->>Devpod: list_workspaces()
    Devpod-->>prune_cmd: workspaces
    prune_cmd->>Locs: workspace_locations(workspaces)
    alt locations.unlocatable
        prune_cmd-->>User: print unlocatable sources
        prune_cmd-->>dl_cli: return 1
    else all locatable
        prune_cmd->>Planner: prune_plan(CloneMgr, workspaces, locations, force)
        Planner-->>prune_cmd: PrunePlan
        prune_cmd->>Printer: print_prune_plan(plan)
        alt plan.nothing_to_do
            prune_cmd-->>dl_cli: return 0
        else work to do
            prune_cmd->>User: prompt Are you sure?
            alt user declines
                prune_cmd-->>User: print Aborted.
                prune_cmd-->>dl_cli: return 0
            else user confirms or -y
                prune_cmd->>Executor: prune_clones(CloneMgr, plan)
                Executor-->>prune_cmd: exit_code
                prune_cmd-->>dl_cli: return exit_code
            end
        end
    end
Loading

File-Level Changes

Change Details Files
Introduce dl --prune command to classify and prune unreferenced clone directories under <cache>/devlaunch/repos, with a structured plan and safety checks.
  • Add CloneStatus sum types (Referenced, Orphaned, Disputed) and Decision (Remove, Keep) plus _unhandled_status to centralize deletion decisions.
  • Implement workspace source resolution (_canonical, source_paths, workspace_locations) using canonical paths and handling unlocatable sources as a hard-stop condition.
  • Add helper directory scanners (_subdirectories) and clone classification _clone_status, including unsaved-work and disk-usage probes only for deletable arms.
  • Introduce PrunePlan (with Reclaimable/Kept) and prune_plan to scan repos_dir under repo locks, skip .bare, sort removals by known_bytes, and collect stale metadata records.
  • Implement print_prune_plan, prune_clones, _forget_clone, _repo_lock, and prune_command including flag parsing, confirmation flow, unsaved-at-apply-time recheck, refusal reporting reuse, and CLI wiring in _run_cli/help text.
devlaunch/dl.py
Refactor refusal reporting so both purge and prune share a single user-facing report of directories that could not be removed.
  • Extract report_refusals helper from purge_all_data to print refusals plus a single sudo-rm hint over a parametrized set of paths.
  • Update purge_all_data to call report_refusals and adjust its return flow accordingly.
  • Use report_refusals from prune_clones when clone deletions partially fail.
devlaunch/dl.py
Extend disk-usage utilities to support summing multiple DiskUsage values while preserving partial-read semantics, and test them.
  • Introduce _unreadable_in to extract unreadable paths from a DiskUsage.
  • Add total_usage(usages) that sums known_bytes while aggregating unreadable paths, returning Measured or PartlyUnreadable as appropriate.
  • Update tests to cover total_usage behaviour, including floors and propagation of unreadable paths.
devlaunch/disk_usage.py
test/unit/test_disk_usage.py
Ensure clones removed by hand are reported as zero-sized (not unmeasured) in workspace disk reports, and adjust tests accordingly.
  • Extend workspace-state test harness to support a hand_removed scenario and to remove the clone directory before measuring.
  • Add tests that assert dl --ls --json --size reports {</li><li>exclusiveBytes</li><li>: 0} and human-readable 0 B for hand-removed clones, distinguishing them from non-devlaunch workspaces.
test/test_workspace_state.py
Add comprehensive test suite for dl --prune behaviour, including safety invariants, classification correctness, locking, devpod interactions, and metadata maintenance.
  • Introduce test/unit/test_prune_orphaned_clones.py which builds a realistic git-based cache world, stubs devpod via a RecordedDevpod, and verifies all CloneStatus arms (Referenced, Orphaned-clean, Orphaned-dirty, Disputed) and .bare exclusion.
  • Test workspace source variants (localFolder vs gitRepository), symlinked cache paths, unlocatable sources (bad paths vs deleted ones), devpod failures, empty cache behaviour, --force semantics, confirmation and plan printing ordering, mid-flight unsaved work, unknown flag handling, re-runs with nothing to do, metadata pruning rules (including unreadable directories), symlinked caches, refusal handling with unreadable directories, and repository-lock behaviour via a separate lock-holder process.
  • Add a devpod spawn-count test ensuring dl --prune calls devpod list exactly once and never devpod status.
  • Update type-checker exclusions for _unhandled_status in pyproject.toml.
test/unit/test_prune_orphaned_clones.py
test/test_devpod_spawn_counts.py
pyproject.toml
Update documentation and contributor guidance to describe dl --prune and its behaviour, and clean up obsolete command references.
  • Add a detailed README section "Pruning the clones nothing opens" with examples, semantics, safety guarantees, and cost characteristics; wire dl --prune into the CLI command table and remove the non-existent dl --prune-worktrees row.
  • Expand AGENTS.md to explain how dl --prune is scoped by DEVPOD_HOME, what it touches, and that it is always explicit/interactive.
  • Ensure docs describe use of known_bytes/exclusive disk usage, refusal behaviour, and metadata pruning in the context of prune.
README.md
AGENTS.md

Assessment against linked issues

Issue Objective Addressed Explanation
#159 Implement a dl --prune command that scans <cache>/devlaunch/repos, classifies clone directories as Referenced, `Orphaned(unsaved: str None), or Disputed, and removes only Orphaned(unsaved=None)clones usingremove_tree, never touching .bare` repos or devpod workspaces/containers, and dropping metadata records both for removed clones and for worktrees whose directories are already gone, while using the hardlink-aware size functions from #153 for per-clone and total reclaimed bytes.
#159 Ensure safety and race-handling for pruning by: comparing canonical paths (Path.resolve()) for both workspace sources and clone directories to avoid the symlinked-cache total-loss bug; holding repo_manager.lock_path(owner, repo) while classifying and deleting each repo’s clones so a clone being populated by git clone is not misclassified or removed; enforcing that clones with unsaved work are kept unless --force, that Disputed clones are never removable even with --force, and that removal refusals are reported with exit code 1; and covering these behaviors with tests as described (four clone categories, symlinked cache regression, refusal path, unsaved-work behavior, and idempotent runs).
#159 Document and integrate dl --prune consistently with the CLI: wire it into the main CLI dispatcher, ensure it costs exactly one devpod list and zero devpod status calls, update README/AGENTS to describe the command, remove the non-existent dl --prune-worktrees documentation row, and add tests pinning the devpod spawn counts and the user-visible reporting/confirmation behavior.

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 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.13467% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 95.19%. Comparing base (856e1b8) to head (67c576e).
⚠️ Report is 5 commits behind head on main.

Files with missing lines Patch % Lines
devlaunch/dl.py 97.00% 10 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #170      +/-   ##
==========================================
+ Coverage   94.67%   95.19%   +0.51%     
==========================================
  Files          21       21              
  Lines        2591     2932     +341     
==========================================
+ Hits         2453     2791     +338     
- Misses        138      141       +3     
Files with missing lines Coverage Δ
devlaunch/disk_usage.py 100.00% <100.00%> (ø)
devlaunch/dl.py 94.30% <97.00%> (+0.84%) ⬆️

... and 2 files with indirect coverage changes

Impacted file tree graph

🚀 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 head 4a711b8, fixed point 39d4d2a...HEAD. Preflight: 3 workflow runs on the head SHA, MERGEABLE/CLEAN, every check pass or skipping including codecov/patch and codecov/project (gh pr checks 170 --json name,bucket). Baseline reproduced independently in three worktrees — pixi run pytest -q1283 passed, 23 deselected in each.

The two axes below ran as independent subagents in their own working copies and are not merged or reranked. A third section carries the reviewing lead's own attacks, which belong to neither axis.


Standards

Baseline confirmed by execution: 1283 passed, 23 deselected in 111.51s.

Blocking

  1. The unsaved-work probe fails open, and --prune is the first caller that makes that a bulk deletion. devlaunch/dl.py:1580 builds Orphaned(holds_unsaved_work(clone), …); Optional[str] conflates "nothing to lose" with "git could not answer" (devlaunch/workspace_state.py:67-77 returns None on OSError/timeout/non-zero, :128-141 reads that as no losses and skips the unpushed check when branch is also None). devlaunch/dl.py:1386 then reads None as removable. Executed: with git off PATH, the clone holding an unpushed commit + untracked file was deleted; with .git chmod 000, its working tree was destroyed (scratch.md survived: False, later.txt survived: False) and the command printed sudo rm -rf …/orphan-dirty/.git as the remedy. That is the container-wrote-as-another-uid machine AGENTS.md says this command exists for. devlaunch/workspace_state.py:54-58 asserts the opposite ("must never be reported as safe to delete on the strength of a failed command … fails safe explicitly") — pre-existing, false, and load-bearing here. The probe needs a third answer and decide an arm that keeps it.

  2. Remove collapses two domain cases, so the --force confirmation cannot say what it costs. devlaunch/dl.py:1348-1357, 1386-1388: under --force the unsaved description is discarded. Executed — the plan reads - …/orphan-dirty (276.0 KiB) under "Removing 2 that nothing references", indistinguishable from the clean clone. prune_clones:1751 skips the re-probe when plan.force, so there is no later chance.

  3. TestACacheReachedThroughASymlink guards nothing. test/unit/test_prune_orphaned_clones.py:498-517 — "the total-loss regression, and the reason it gets a test of its own" — passes with the .resolve() it names removed (1 passed in 1.98s). The clone survives via Disputed (it holds a metadata record naming a listed workspace), not Referenced. Same defect the builder fixed at :258 by asserting on the reason; not applied here, nor at :216.

Non-blocking

  1. devlaunch/dl.py:1585-1592 — record-path canonicalisation untested (M7 GREEN). If it regressed, a non-canonical record drops a clone from Disputed to Orphaned → deleted.
  2. devlaunch/dl.py:820/1773-1777 — the sudo rm -rf argument is untested (M4 GREEN); pointing it at plan.root prints an instruction to delete every live clone, 114 passed.
  3. devlaunch/dl.py:1590,1613,1640,1648,1692,1792 — six two-hop reaches through clone_mgr.repo_manager / .storage (message chains).
  4. devlaunch/dl.py is 3294 lines, 605 of them this PR; the repo already factors disk_usage.py/workspace_state.py. --prune is a self-contained surface belonging in devlaunch/prune.py (divergent change).
  5. devlaunch/dl.py:1398-1405Reclaimable.owner/.repo duplicate path.parent{,.parent}.name.
  6. test/test_devpod_spawn_counts.py:82-99spawns answers every subprocess.run rc=0/empty, and :211 runs the deletion path under it. Harmless there (not a git repo) but the same shape as the trap.

Answers the axis owed. Classification is total: _clone_status (:1547-1581) always returns one arm, decide (:1369-1396) is total, prune_plan:1655-1663 does one decide and exactly one append per candidate — nothing can be in both or neither sets. _unhandled_status genuinely bites: adding a 4th arm made pixi run ty error Expected 'Never', found 'Quarantined & ~Referenced & ~Orphaned & ~Disputed', and ty is in lintci, so the coverage exclusion is honest (no fail_under configured either). Orphaned(unsaved, usage) is modelling, not a clump — both are consumed in the same arm and usage gates the unbounded walk. No new symbol is reachable outside prune_command/purge_all_data, so nothing lands on the launch path or --ls. The fixture's own boundary is correct (test/unit/test_prune_orphaned_clones.py:103,121-125 passes non-devpod to _real_run); no sibling test stubs subprocess while exercising the git guard.

Mutations the Standards axis authored

Selection for all rows unless stated: pixi run pytest -q test/unit/test_prune_orphaned_clones.py test/unit/test_disk_usage.py test/test_workspace_state.py test/test_devpod_spawn_counts.py (subset baseline 114 passed in 48.79s), each applied and reverted.

# Mutation Verdict Verbatim tail
B1 workspace_locations: _canonical(source)pathlib.Path(source) (builder's) RED 1 failed, 113 passed in 56.73s
B1b same, selection …::TestACacheReachedThroughASymlink GREEN 1 passed in 1.98s
B2 prune_clones: drop the pre-removal re-probe (builder's) RED 1 failed, 113 passed in 59.91s
B3 _subdirectories: is_dir(follow_symlinks=False)is_dir() (builder's) RED 1 failed, 113 passed in 79.89s
M1 _clone_status: consult the record before devpod's listing RED 2 failed, 112 passed in 65.33s
M2 decide: --force widens to Disputed RED 1 failed, 113 passed in 80.78s
M3 decide: --force widens to Referenced RED 1 failed, 113 passed in 75.14s
M4 prune_clones: sudo rm -rf advice names plan.root instead of the refusing paths GREEN 114 passed in 70.81s
M5 prune_clones: withheld clone recorded but not skipped RED 1 failed, 113 passed in 76.75s
M6 prune_command: unlocatable workspaces warn instead of stopping RED 1 failed, 113 passed in 64.14s
M7 _records_by_directory: key by unresolved record.local_path GREEN 114 passed in 76.60s
M8 _repo_lockcontextlib.nullcontext() RED 1 failed, 113 passed in 68.87s
M9 total_usage: always return Measured RED 3 failed, 111 passed in 73.87s
M10 _records_for_absent_directories: _presentPath.exists() RED 1 failed, 113 passed in 92.67s

Behavioural probes (temporary files, since deleted): --force plan names the unsaved clone → FAIL; corrupt .git/HEADFAIL (Removed 2 clone director(ies) -- 2.5 MiB.); git off PATHFAIL; .git chmod 000 → FAIL (scratch.md survived: False); live workspace sourcing repos/o/rFAIL; live workspace sourcing <clone>/subproject → pass, but only via the unsaved guard (the new file was untracked); trailing-slash / relative / deeper-nesting sources → 5 passed.

Verified by execution: full-suite and subset baselines; all 14 mutation rows; all 8 probe rows; ty exhaustiveness on a synthetic 4th arm. Not verified: pixi run -e py310 ci, pylint, ruff, full pixi run ci, any real dl-next invocation, the reference-host figures.

Standards verdict: Request changes — findings 1 and 2 both let a clone holding work that exists nowhere else reach the removal set, and finding 1 is proven to destroy it on the machine class this command was written for.


Spec

Measured against #159, #155 and the amendment comment on #159. Baseline reproduced: pixi run pytest -q1283 passed, 23 deselected in 114.90s.

S1 — blocking. The total-loss symlink regression is present but does not discriminate. #155: "Resolved-path comparison: a cache reached through a symlink still classifies the referenced clone as Referenced (this is the total-loss regression, so it gets its own test)." test/unit/test_prune_orphaned_clones.py:498-514 asserts only that the directory still exists. Breaking the comparison side (keeping _canonical for the unlocatable check, but keying by_path by the unresolved source) left the full suite at 1283 passed, 23 deselected. Probing the report line shows why: the clone survives on the Disputed arm — "devpod lists workspace referenced and sources it at …/link-to-cache/…; see devlaunch#88". Exactly the hollowness the builder found and fixed for the gitRepository test, not carried across to the test #155 names by name. On #155's own host that is not academic: 3 clone dirs had no metadata record, so the Disputed net that rescues the fixture would not have rescued them.

S2 — blocking. Only one of the two sides #155 names is guarded. #155: "Compare resolved paths, not strings. Path.resolve() both sides." The fixture never puts the candidate side behind a link. Removing .resolve() from the scan root (dl.py:1640) and dropping _canonical from the record index (dl.py:1591) and from the .bare path (dl.py:1648) each left the full suite green. The shipped code is correct in all three places; nothing holds it there.

S3 — blocking (primitive is pre-existing). The unsaved guard reads "cannot tell" as "clean" in the case #155 calls normal. #155: "Clones are precisely the directories a container wrote as uid 1000, so partial refusal is the normal case here", and "Refuses Orphaned(unsaved=…) by default, naming what deleting would destroy." Executed, unmutated: a clone with an unpushed commit and an untracked scratch.md, .git chmod 000 → classified Orphaned(None), listed under "Removing", scratch.md destroyed with no --force. The disk walk on the same directory correctly printed ≥24.0 KiB; the git probe silently said "holds nothing". decide (dl.py:1369) has no arm for "could not ask".

S4 — non-blocking. Three "Referenced" tests are vacuous. With the Referenced arm removed entirely, test_a_clone_a_live_workspace_sources_is_kept (:222), test_it_does_not_touch_the_clone_a_live_workspace_opens (:347) and the symlink test all still pass — every fixture clone carries a record, so they land in Disputed. Only the two reason-asserting tests bite.

S5 — non-blocking. #155's boundary-line assertion is not here. "The boundary line is asserted in the --purge and --prune output." Parked to #160, which #155 itself graduated as a separate ticket. #159's own Tested list omits it. Parking is legitimate; the other two parked items are explicitly out of scope per #155.

S6 — non-blocking. XDG_CONFIG_HOME deviation. #159: "scratch XDG_CACHE_HOME only (never XDG_CONFIG_HOME)". test/conftest.py:58-59 scopes both. Pre-existing and strictly safer; the new fixture writes only under devlaunch_cache(). Worth a ruling rather than a change.

S7 — non-blocking. Amendment (d), figures. The explanation is not restated — README links, total_usage's docstring explicitly defers. But each of #155's host figures now sits in three fresh copies (README + dl.py docstring + test docstring): 1017 ms, 0.08 GB, 49 records, 52 clone, 37 stale.

Confirmed holding: Disputed is never deletable including under --force (decide, dl.py:1369, no force branch; mutation RED). Nothing runs automatically — prune_command has exactly one caller, the --prune dispatch at dl.py:2967. --ls gained no orphan hint — the diff touches no --ls path. Amendment (a): is_devlaunch_clone is not called anywhere in the prune path; source_paths (:1461) reads GitRepository too. Amendment (b): known_bytes has a production caller (sort key, dl.py:1670); total_usage renders, and a real headline read ≥2.3 MiB. Amendment (c): pinned at test/test_workspace_state.py:287.

#159 / #155 assertion checklist

Named assertion Status Evidence
only the clean orphan dies tested guard_off_everywhere RED (4 failed)
…its "referenced survives" half vacuous referenced_arm_off did not kill :222
--force promotes only the dirty one tested unsaved_guard_off_in_decide RED
…"does not touch referenced" half vacuous referenced_arm_off did not kill :347
Disputed survives --force tested, bites force_promotes_disputed RED
.bare untouched tested, bites bare_is_a_candidate RED (2 failed)
idempotent second run tested, bites unsaved_guard_off_in_decide RED
symlinked-cache regression vacuous source_side_not_resolved GREEN full suite; probe shows Disputed arm
resolve both sides (candidate / record / bare) missing 3 mutations GREEN full suite
remove_tree refusal exits 1, root-skipped tested ran green in selection; no mutation by this axis
1 devpod list, 0 devpod status tested ran green; asserts removal first, so not vacuous
scratch XDG_CACHE_HOME only deviation see S6
real git repos, local bare as remote tested .bare clone + push -u, real git log --not --remotes
record dropped for removed clone / already-gone dir tested, bites killed by disputed_arm_off (4 failed)
"gone" is FileNotFoundError only tested ran green; no mutation by this axis
lock held across classify+remove tested ran green; no mutation by this axis
Measured(0) for hand-removed clone tested test/test_workspace_state.py:287
floor cannot reach a rendering path tested plus live ≥2.3 MiB headline
Docker boundary line in output missing (parked → #160) see S5
unsaved probe that could not answer missing S3 probe: work destroyed

Mutations the Spec axis authored

Selection A = pixi run pytest -q over the four affected test files (baseline 114 passed). Selection B = pixi run pytest -q (full suite, baseline 1283 passed, 23 deselected).

# Mutation Sel. Verdict Verbatim tail
1 by_path keyed by the unresolved source (dl.py:1503) B GREEN 1283 passed, 23 deselected in 111.87s
2 scan root not resolved (dl.py:1640) B GREEN 1283 passed, 23 deselected in 136.57s
3 record index not canonicalised (dl.py:1591) B GREEN 1283 passed, 23 deselected in 119.45s
4 .bare path not canonicalised (dl.py:1648) B GREEN 1283 passed, 23 deselected in 112.91s
5 Referenced arm removed (dl.py:1547) A RED (2) :222, :347 and the symlink test all passed
6 Disputed arm unreachable A RED (4) 4 failed, 50 passed in 44.82s
7 --force promotes Disputed (dl.py:1369) A RED (1) 1 failed, 113 passed
8 .bare becomes a candidate A RED (2) 2 failed, 112 passed in 72.84s
9 follow_symlinks=True (dl.py:1525) A RED (1) FAILED …test_a_symlink_standing_where_a_clone_would_be_is_left_alone
10 pre-removal re-probe removed (dl.py:1750) A RED (1) FAILED …test_work_written_while_the_question_was_open_is_not_destroyed
11 unlocatable workspace silently skipped A RED (1) FAILED …test_a_source_that_cannot_be_read_as_a_path_stops_the_whole_command
12 unsaved guard off in decide only A RED (2) test_a_clone_holding_work_saved_nowhere_else_is_kept survived, as the builder said
13 combination: 12 + 10 together A RED (4) 4 failed, 110 passed in 70.75s

Mutation 1 is the row the builder's table reports as RED ("the source side compared without resolving it"). Isolating the comparison from the unlocatable check makes it survive the full suite — their mutation was killed by test_a_source_that_cannot_be_read_as_a_path…, not by the symlink regression test.

Fixture boundary check (the builder's reported trap): RecordedDevpod.__call__ (:118-127) captures _real_run before patching and delegates every non-devpod argv to it — git runs for real. Confirmed by mutations 12/13 biting on real git status/git log output.

Verified by execution: the baseline; all 13 mutations with the stated selections; the unmutated .git-unreadable probe; the symlink-arm probe; the floor reaching the total headline. Not verified: the builder's other 11 table rows (neither reproduced nor refuted); pixi run ci, pylint, py310.

Spec verdict: Request changes — on S1/S2 and S3. The design (decide total over three arms, Disputed never deletable including under --force, nothing automatic, --ls untouched, all four amendments honoured in the code) holds up; the gaps are in what the tests actually hold and in the missing "could not ask" arm.


Reviewer's own attacks (lead — not an axis)

These are the reviewing lead's own attacks, run in a third worktree. Selection for every row: pixi run pytest -q in the default environment — the full suite, the same selection the builder reported against. Baseline reproduced first: 1283 passed, 23 deselected in 117.02s. Attack tests were run from a file outside test/ during mutation runs so they could not colour a verdict; the production tree was reverted after every mutation and git status --porcelain shows no tracked file modified.

Can a protected directory reach the removal set? Yes — four ways, each executed

L1 (blocking) — on devlaunch#88's measured host state, --prune keeps the stub and deletes the live workspace's only real checkout. Disputed is reached by listed_at.get(record.workspace_id) (devlaunch/dl.py:_clone_status), i.e. it requires devpod's workspace id and metadata's workspace_id to be the same string. On #88's host they are not: devpod's record is workspaces/devlaunch-main/ (id devlaunch-main, old scheme) while metadata carries the new-scheme id that equals the clone leaf (devlaunch-main-zovomobo). The lookup misses, and the healthy clone falls through to Orphaned. Executed — devpod lists o-r-ref sourcing the config-only stub, the real checkout is referenced:

Removing 3 that nothing references -- 2.7 MiB:
  - .../repos/o/r/orphan-clean (2.2 MiB)
  - .../repos/o/r/disputed (244.0 KiB)
  - .../repos/o/r/referenced (244.0 KiB)

Leaving 2:
  - .../repos/o/r/orphan-dirty: holds 1 uncommitted change(s) (scratch.md) and 1 unpushed commit(s) -- add --force to remove it anyway
  - .../repos/o/r/ref: workspace o-r-ref still opens it

Removed 3 clone director(ies) -- 2.7 MiB.

The stub survives, the live checkout dies — and so does the disputed clone, because its record's id is no longer listed either. #155 justified shipping ahead of #88 on the claim that "the Disputed arm makes --prune safe under exactly the state #88 exists to repair". That claim does not hold: at #88's measured scale it is 36 workspaces of 39. Read from source but not executed: prune_command calls _get_clone_manager(), which runs migrate_cache — the migration that renames clone directories and records "old devpod workspace ids, now orphaned because the id derivation changed" (devlaunch/worktree/migration.py). On an unmigrated cache dl --prune can therefore create this state and then classify against it in the same process.

L2 (blocking) — a workspace registered between the report and the answer is deleted anyway. prune_clones re-probes only unsaved work; it never re-asks whether a directory became referenced, and its docstring justifies that with "it cannot have changed without a second devpod list, and this command is pinned at one" — which is a statement about what the command observes, not about what the world does. The clone path for (owner, repo, branch) is deterministic (storage keys on owner/repo/branch), so a concurrent dl o/r@clean reuses the exact directory in the plan. Executed, with the listing gaining a workspace sourcing the clone at the moment input() is called: Removed 1 clone director(ies) -- 2.2 MiB. The builder's own breadcrumb on #159 promised "the act pass re-classifies under the lock and removes only what it also finds removable"; what shipped re-probes one field. This is not the register window the PR parks — registration has completed here.

L3 (blocking) — a live workspace opening a subdirectory of a clone does not hold the clone. Referenced is path equality, so devpod up <clone>/sub puts <clone>/sub in by_path and leaves <clone> unreferenced. Executed, with the subdirectory committed and pushed so the unsaved guard has nothing to say: Removing 3 ... - .../repos/o/r/referenced (284.0 KiB) / Removed 3 clone director(ies). The amendment said the lexical containment predicate must not be reused as-is; the diff replaced it with equality and lost the containment direction that actually protects — "is any live workspace's source at or under this candidate". (The Standards axis probed the same shape and saw it survive; its subdirectory file was untracked, so the unsaved guard caught it. That is the guard, not the classification.)

L4 (blocking) — a source arm devlaunch cannot read does not stop the command, though the command says it does. prune_command prints "Nothing was removed: no clone is unreferenced while a workspace is unaccounted for" and returns 1 for locations.unlocatable. But unlocatable is only fed by _canonical failing, and source_paths(UnrecognisedSource) returns () — so a live workspace devlaunch cannot place contributes no path and no alarm. parse_workspace_source routes image, container and any malformed localFolder to that arm. Executed with a single {"container": "abc123"} workspace: the run proceeded and removed three directories; "Nothing was removed" never appeared.

Two more, executed

L5 (blocking, same defect the Standards axis found independently) — a clone whose git cannot answer reads as holding nothing. _git returns None on any refusal and _unsaved reads None as "no losses", so holds_unsaved_work is None for a clone git cannot read. Executed with .git/HEAD unlinked and a fresh uncommitted file in the tree: Removing 1 that nothing references -- 2.2 MiB / Removed 1 clone director(ies) -- 2.2 MiB. workspace_state.py:54-58 claims the opposite ("must never be reported as safe to delete on the strength of a failed command … fails safe explicitly"). Pre-existing, but --prune is the first caller that applies it to directories the user never named.

L6 (blocking) — --force is a plan-wide boolean, so it disables the re-probe for clones it did not promote. prune_clones reads plan.force, not a per-directory promotion: Reclaimable does not carry why it was removable. Executed — under --force, a clone that was Orphaned(None) at plan time and had work written into it while the question was open was destroyed: Removed 2 clone director(ies) -- 2.5 MiB. This is precisely the "boolean beside a status that a later caller could read without having answered which arm it is" that decide's docstring says does not exist; it exists, one layer down. Carrying the promotion on Remove/Reclaimable would confine --force to the arm it claims to promote.

Mutation table — inherited vs authored

Three of the builder's rows re-run to check the table reproduces; the rest are mine, aimed at the deletion decision.

# mutation origin verdict
1 the unsaved guard off in decide inherited RED — 2 failed, 1281 passed, 23 deselected in 124.19s (matches the builder's "RED (2 failed)")
2 --force promotes the Disputed arm too inherited RED — 1 failed, 1282 passed, 23 deselected in 131.50s
3 .bare becomes a candidate inherited RED — 2 failed, 1281 passed, 23 deselected in 123.85s (matches "RED (2 failed)")
4 _records_by_directory keys by the unresolved record.local_path mine GREEN — survived, 1283 passed, 23 deselected in 132.19s
5 PrunePlan.nothing_to_do ignores stale_records mine GREEN — survived, 1283 passed, 23 deselected in 115.98s
6 the pre-removal re-probe removed entirely mine RED — 1 failed, 1282 passed, 23 deselected in 124.52s
7 decide: Referenced becomes deletable mine RED — 7 failed, 1276 passed, 23 deselected in 132.19s
8 the "Leaving" section is never printed mine RED — 2 failed, 1281 passed, 23 deselected in 118.46s
9 the act pass takes no repository lock (plan pass keeps its lock) mine GREEN — survived, 1283 passed, 23 deselected in 112.63s
10 total_usage always returns Measured (floor lost) mine RED — 3 failed, 1280 passed, 23 deselected in 112.28s
11 stale_records never dropped in prune_clones mine RED — 1 failed, 1282 passed, 23 deselected in 80.67s

Row 4 is a real guard with zero coverage, and it guards Disputed. The suite's symlink test resolves devpod's side only; the metadata side is resolved in _records_by_directory and nothing pins it. I wrote the missing test — a Disputed clone whose record was written while the cache was reached through a symlink — and it passes on this branch (1 passed in 2.56s) and fails under mutation 4 (FAILED test_I_a_disputed_clone_recorded_through_a_symlink_is_deleted, 1 failed, 8 deselected in 2.16s), with the plan reading Removing 3 ... - .../repos/o/r/disputed (244.0 KiB) under --force. Row 9 says the same about the act-pass lock: the concurrency test only proves the plan pass takes it.

Non-blocking

  • L7 — every directory two levels under repos_dir is a candidate; nothing checks it was ever a devlaunch clone. A stray directory holding notes classifies as a clean orphan and goes (executed: - .../repos/o/r/notes-i-left-here (8.0 KiB)Removed 2 clone director(ies)). This is intended: test/test_devpod_spawn_counts.py:test_prune_reads_the_workspace_list_exactly_once_and_asks_no_status pins it by removing a non-git directory. Worth saying out loud in the README, which calls them "clone directories" throughout.
  • L8 — mutation 5: a cache whose only work is dropping stale records can print "Nothing to prune." and do nothing, unnoticed.
  • L9prune_clones passes every refusing inner path to report_refusals, so the pasted sudo rm -rf grows one argument per refusal rather than naming the directories.

Verified by execution vs not

Verified by execution (all from /tmp/.../scratchpad/review-170, selection stated per row): the full-suite baseline; all 11 mutation rows; attacks L1–L7 and the row-4 missing test, each with the report text quoted above; the tree left clean after every mutation.

Not verified / not run by me: pixi run ci, pixi run -e py310 ci, pylint, ruff, ty (the Standards axis ran ty on a synthetic fourth arm; I did not); any real dl-next invocation — every result above comes from the suite's own isolated XDG_CACHE_HOME; the migration→classification interaction in L1 (read from source, not executed); the reference-host figures quoted in the docstrings (1017 ms, 37 clones, 4.00 GB).


Verdict

Request changes.

Both axes independently returned Request changes, and the lead's own attacks found four distinct inputs that put a directory the spec protects into the removal set. Blocking, one line each:

  1. L1 — on devlaunch#88's measured host state the Disputed arm never fires, and --prune deletes the live workspace's only real checkout while keeping the config-only stub. #155 shipped this ahead of #88 on the claim that "the Disputed arm makes --prune safe under exactly the state #88 exists to repair"; that claim is false, at a measured scale of 36 workspaces of 39.
  2. L2 — the act pass never re-asks whether a directory became referenced, so a launch that completes between the report and the answer has its clone deleted; the builder's own breadcrumb promised the act pass "re-classifies under the lock".
  3. L3 — a live workspace opening <clone>/sub does not hold <clone>: strict path equality dropped the containment direction that actually protects.
  4. L4 — an UnrecognisedSource workspace contributes no path and no alarm, so the command proceeds while printing that it does not.
  5. L5 / Standards 1 / Spec S3 — a clone whose git cannot answer reads as holding nothing and is deleted without --force (found independently by all three).
  6. L6 / Standards 2 — --force is a plan-wide boolean, so it disables the re-probe for clones it did not promote; work written while the question was open is destroyed.
  7. Standards 3 / Spec S1+S2 — the symlink test #155 names by name does not discriminate, and three canonicalisation sites on the deletion path survive mutation under the full suite.

What is not in dispute, and is worth keeping: the classification really is total — decide is exhaustive, _unhandled_status fails the build on a fourth arm (ty verified), a directory cannot be in both the removing and keeping sets or in neither, Disputed has no force branch, nothing runs automatically, and --ls gained no orphan hint. The defects are at the edges of the sum, not in it: the arms are right and the funnel into them is not.

GitHub refuses --approve/--request-changes on a same-account PR, so this is posted with --comment; the written verdict above is the gate.

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

Re-verdict pass. Reviewed at head 0454f7e, fixed point 39d4d2a...HEAD. Preflight confirmed independently: gh api …/actions/runs?head_sha=0454f7e… --jq .total_count3, MERGEABLE/CLEAN, and gh pr checks 170 shows every check pass or skipping including codecov/patch and codecov/project. Baseline reproduced in three separate worktrees, pixi run pytest -q: 1308 passed, 23 deselected in each (155.56s / 168.91s / 153.47s).

The two axes below ran as independent subagents in their own working copies and are not merged or reranked. A third section carries the reviewing lead's own attacks, which belong to neither axis. Neither axis nor the lead wrote this code or the previous review.


Standards

Baseline, pixi run pytest -q at 0454f7e:

1308 passed, 23 deselected in 168.91s (0:02:48)

CI gate, both environments, as one pixi run ci / pixi run -e py310 ci task each (verbatim excerpts):

✨ Pixi task (format in default): ruff format .
77 files left unchanged
✨ Pixi task (ruff-lint in default): ruff check . --fix
All checks passed!
✨ Pixi task (pylint in default): pylint … pylint 4.0.5
Your code has been rated at 10.00/10 (previous run: 10.00/10, +0.00)
✨ Pixi task (ty in default): ty check --respect-ignore-files .
All checks passed!
=============== 1308 passed, 23 deselected in 135.23s (0:02:15) ================
TOTAL                                    2803    169    94%
CI_EXIT=0
##### PY310 CI          (platform linux -- Python 3.10.20)
77 files left unchanged / All checks passed! / 10.00/10 / All checks passed!
=============== 1308 passed, 23 deselected in 149.61s (0:02:29) ================
CI310_EXIT=0

The retry's gate claim is true — this is the first time anyone on this node has run it.

Blocking

  1. The CouldNotAsk arm has three producers and only one of them is held by anything. coverage report -m (the py310 CI run above) names devlaunch/workspace_state.py … 242, 253 as never executed — those two lines are return CouldNotAsk(status.reason) (a refusal from git status --porcelain) and return CouldNotAsk(unpushed.reason) (a refusal from git log --oneline <branch> --not --remotes). Both credited tests (test_a_repository_that_will_not_answer_is_not_reported_as_holding_nothing, test_a_clone_whose_repository_will_not_open_is_kept_with_nothing_typed) unlink .git/HEAD, so both exercise only the rev-parse route at :198. Mutating each of the two uncovered lines to read the refusal as clean leaves the full suite green (M-S1, M-S2 below). Both are reachable on states that are the ordinary case for this command, executed against real git: .git/index chmod 000 → rev-parse --abbrev-ref HEAD returns main while status gives fatal: .git/index: index file open failed: Permission denied; a remote-tracking ref naming a missing object → status rc=0 while log gives fatal: bad object origin/main. The shipped code is correct on both — probed unmutated: Keep(because='holds work git could not be asked about (fatal: .git/index: index file open failed: Permission denied) -- add --force to remove it anyway') and the same for fatal: bad object origin/main. So this is a correct guard with zero coverage on the deletion path, on the round's headline finding (L5 / prior Standards 1 / Spec S3): any future edit to _unsaved silently restores "cannot tell reads as clean", and nothing in pixi run ci notices. Two tests using the two states above close it.

Non-blocking

  1. Three of the eight sums have no _unhandled_* sentinel, and for Decision the missing one guards a deletion. With a synthetic third arm on Decision, pixi run ty reports exactly one diagnostic, and it is on the report side — dl.py:1940 keeping.append(Kept(clone, decision.because)). prune_clones narrows with if isinstance(decision, Keep): … continue and then falls through to remove_tree(reclaimable.path) (dl.py:2073-2076) with no attribute access, so nothing there objects. Exhaustiveness of the sum that decides deletion is currently enforced by an incidental attribute read in a different function. Same shape for SourcePlaces (only diagnostic: dl.py:1737 places.paths) and GitAnswer.
  2. Five more uncovered lines in the new prune code (same coverage run): dl.py:1742-1745 — the TooShallow arm and _unhandled_site, i.e. a workspace sourced at <repos_dir>/<owner> stopping the whole command is never executed; 1765-1766, 1786-1787 (the OSError safe-defaults in _is_populated_clone / _subdirectories); 1990 (_unhandled_promotion); 2113-2114 (_forget_clone's OSError). Plus workspace_state.py:154-156, _git's OSError arm (git absent).
  3. Divergent change, worse than last round. devlaunch/dl.py is now 3610 lines (was 3294); 939 of the diff's lines are in it. ~36 new symbols form a self-contained --prune surface sitting beside CLI dispatch, ssh and the completion cache, in a repo that already factors disk_usage.py and workspace_state.py. devlaunch/prune.py.
  4. Duplicated code on the deletion path. The classify preamble is copied between the two passes: workspace_locations(…) + if locations.unlocatable: report_unlocatable(…); return 1 + listed_at = {…} + record_for = _records_by_directory(clone_mgr) at dl.py:1912-1913, 2148 and dl.py:2058-2062. Two passes seeing different worlds is the design; four lines that must stay identical being copied is not, and a drift between them is a deletion.
  5. Message chains — six two-hop reaches through clone_mgr.repo_manager / .storage at dl.py:1850, 1873, 1894, 1917, 1962, 2112. Unchanged from the prior round.
  6. Speculative generality in SourceSite. InAClone(clone) is built at dl.py:1703 and its clone field is never read anywhere; Outside and InAClone are handled identically at the sum's only consumer (dl.py:1738).
  7. Mysterious name (mild). Placeable / Unplaceable / Misplaced are three "place" words with unrelated meanings — Misplaced is not the negation of Placeable, it is a successfully located workspace at a non-clone. (owner, repo) is a data clump travelling through _repo_lock, _clone_status, Reclaimable, by_repo and misplaced's key.

Answers the axis owed.

Totality — proven by execution. A synthetic arm was added to each of the eight sums one at a time and pixi run ty run (it is in lintci, so a diagnostic is a build failure). All eight break the build; five break it through the sentinel, three by accident (item 2). Verbatim, CloneStatus:

error[invalid-argument-type]: Argument to function `_unhandled_status` is incorrect
    --> devlaunch/dl.py:1466:23
1466 |     _unhandled_status(status)
     |                       ^^^^^^ Expected `Never`, found `Quarantined & ~Referenced & ~Orphaned & ~Disputed`
Found 1 diagnostic

Promotion_unhandled_promotion (1); SourceSite_unhandled_site (1); WorkspaceSource_unhandled_source (4 diagnostics, two sites); UnsavedWork_unhandled_unsaved (1). Decision, SourcePlaces, GitAnswer error only via unresolved-attribute. No arm is reachable in a contradictory combination: _clone_status (dl.py:1791-1841) returns on the first of four ordered questions, decide is total over three arms, and prune_plan (dl.py:1929-1935) makes exactly one decide call and exactly one append per candidate — so no directory is in both the removing and keeping sets or in neither.

--force widens only what it claims — verified. force: bool reaches exactly one function: prune_command:2143prune_plan:1902decide(status, force) at 1929. Inside decide, force is read at exactly one line, 1453, inside the Orphaned branch and only after objection(status.unsaved) is not None; Referenced and Disputed have no force branch. PrunePlan has no force field. The act pass reads the per-directory promotion at dl.py:2071, isinstance(reclaimable.promotion, Insisted). Held by execution: mutating 2071 back to a plan-wide any(…) is RED and names the credited test (R3). One residual, non-blocking: the act pass reads only the promotion's tag, not Insisted.despite, so a directory forced past "git could not be asked" at plan time is still removed if the objection has since become real unpushed work.

Mutations the Standards axis authored

Every mutation applied alone and reverted; git status --porcelain printed empty after each.

# mutation origin selection verdict verbatim tail node id in failure list
M-S1 workspace_state.py:242 — a git status --porcelain refusal reads as clean mine pixi run pytest -q (full) GREEN 1308 passed, 23 deselected in 198.98s (0:03:18) — (no failures)
M-S2 workspace_state.py:253 — a git log --not --remotes refusal reads as clean mine pixi run pytest -q (full) GREEN 1308 passed, 23 deselected in 158.38s (0:02:38) — (no failures)
M-S3 synthetic 4th arm on CloneStatus mine pixi run ty RED (build) Expected 'Never', found 'Quarantined & ~Referenced & ~Orphaned & ~Disputed' / Found 1 diagnostic _unhandled_status, dl.py:1466
M-S4 synthetic 3rd arm on Promotion mine pixi run ty RED (build) Argument to function '_unhandled_promotion' is incorrect / Found 1 diagnostic _unhandled_promotion
M-S5 synthetic 3rd arm on Decision mine pixi run ty RED (build) — report side only error[unresolved-attribute]: Attribute 'because' is not defined … --> devlaunch/dl.py:1940:52 / Found 1 diagnostic none at prune_clones; act pass falls through to remove_tree
M-S6 synthetic 5th arm on SourceSite mine pixi run ty RED (build) Argument to function '_unhandled_site' is incorrect / Found 1 diagnostic dl.py:1745
M-S7 synthetic 3rd arm on SourcePlaces mine pixi run ty RED (build) — accident, no sentinel Attribute 'paths' is not defined on 'Ambiguous & ~Unplaceable' … --> devlaunch/dl.py:1737:23 dl.py:1737
M-S8 synthetic 5th arm on WorkspaceSource mine pixi run ty RED (build) Argument to function '_unhandled_source' is incorrect ×2 / Found 4 diagnostics _unhandled_source
M-S9 synthetic 4th arm on UnsavedWork mine pixi run ty RED (build) Argument to function '_unhandled_unsaved' is incorrect / Found 1 diagnostic _unhandled_unsaved
M-S10 synthetic 3rd arm on GitAnswer mine pixi run ty RED (build) — accident, no sentinel Attribute 'output' is not defined on 'TimedOut & ~Refused' ×3 / Found 5 diagnostics workspace_state.py
R1 (retry row, re-run) read_clone: a rev-parse refusal reads as NothingToLose inherited full suite RED 2 failed, 1306 passed, 23 deselected in 168.72s test_workspace_state.py::TestACloneGitWillNotAnswerAbout::test_a_repository_that_will_not_answer_is_not_reported_as_holding_nothing and test_prune_orphaned_clones.py::TestACloneGitWillNotAnswerAbout::test_a_clone_whose_repository_will_not_open_is_kept_with_nothing_typed
R2 (retry row) _records_by_directory keys by the unresolved record.local_path inherited full suite RED 1 failed, 1307 passed, 23 deselected in 165.72s TestEveryPathThisCommandComparesIsCanonical::test_a_record_written_through_a_symlink_still_disputes_its_clone
R3 (retry row) act pass reads --force plan-wide again inherited full suite RED 1 failed, 1307 passed, 23 deselected in 153.34s TestWhatForceIsAnsweringAndForWhich::test_it_does_not_turn_off_the_re_probe_for_a_clone_it_promoted_nothing_about
R4 (retry row) _is_populated_clone always true inherited full suite RED 3 failed, 1305 passed, 23 deselected in 180.01s TestADevpodRecordThatPointsAtNothing::{…at_a_stub…, …at_a_folder_that_is_gone…, test_only_that_repositorys_clones_are_disputed}

Sample-verification verdict on the retry's table. Four rows re-run; in all four the failure list names exactly the test the retry credits. Its per-row totals sum to 1304 rather than 1308 because that table was produced before the three extra hollow-guard tests (+4) it describes lower down; the failure counts match exactly. No misattribution found.

Prior Standards-relevant findings

  • Standards 1 (unsaved probe fails open) — closed in behaviour, partly open in coverage. UnsavedWork gained CouldNotAsk, decide keeps it, R1 is RED naming both credited tests. Two of three producers are unheld — blocking finding 1.
  • Standards 2 (--force confirmation cannot say what it costs) — closed. Insisted.despite is printed on that directory's own line (dl.py:1984-1986); PrunePlan.force is gone; R3 RED.
  • Standards 3 (TestACacheReachedThroughASymlink guards nothing) — closed. The test now asserts the reason plus a real-run assertion.
  • NB 4 (record-path canonicalisation untested) — closed, R2 RED.
  • NB 5 (sudo rm -rf argument untested) — closed by reading only; not re-mutated by this axis.
  • NB 6 (message chains) — still open. NB 7 (dl.py size) — still open and larger. NB 8 (Reclaimable.owner/.repo) — still open, now partly earned.
  • NB 9 (spawns answers every subprocess.run) — still open as a shape, harmless today: the pinned test removes a plain directory with no .git, so _is_a_repository short-circuits before git is asked.

Verified by execution vs not

Verified by execution: the full-suite baseline; pixi run ci and pixi run -e py310 ci end to end; the coverage line list; all 14 mutation rows with selections and node ids; real-git reachability of both uncovered refusal routes; the unmutated probe showing both reach Keep; a clean tree after every mutation.

Not verified / not run: any real dl-next invocation; the retry's other ~13 table rows; the reference-host figures; the L1/#88 host-state argument (Spec's ground); the residual Insisted.despite window — reasoned from source, not executed.

Standards verdict: Request changes — the classification is genuinely total, --force provably widens one arm per directory with no plan-wide flag left, and CI is green in both environments; but the arm added to fix the round's headline finding has three producers and only one of them is held by anything.


Spec

Baseline, pixi run pytest -q at 0454f7e: 1308 passed, 23 deselected in 153.47s (0:02:33).

S-findings (none blocking)

S-a (non-blocking, mine, executed). #159: "Refuses Orphaned(unsaved=…) by default, naming what deleting would destroy." The CouldNotAsk arm is correct and reachable — an unreadable .git/index (the uid-1000 container case) gives rev-parse success and git status failure, and the shipped tree keeps the clone: - blooop/proj1/stale-1a: holds work git could not be asked about (fatal: .git/index: index file open failed: Permission denied) -- add --force to remove it anyway. But that branch is guarded by nothing: mutation SP3 left the full suite green, and under it the same run prints Removed 15 clone director(ies) and precious.txt is gone with nothing typed. Every "could not ask" test enters through read_clone's rev-parse branch instead. One test closes it.

S-b (non-blocking, mine, executed). #155 named "a hand-moved cache, or a partially-applied #88 reconciliation" as the recurring class. Executed: old-scheme …/devlaunch/main still a real checkout that devpod opens, new-scheme devlaunch-main-zovomobo beside it carrying the record → the new clone is Orphaned and is removed. Neither #155's id-join nor the retry's funnel (recorded folder is a checkout) covers it. Mitigated where it matters: with work in it the unsaved guard kept it. Off #88's measured state and off the migration's rename path; worth a docstring line, not a change.

Ruling on L1 outcome (a): the arm fires on #88's measured state; #155's decision stands and does not re-open.

#88's measured state was rebuilt from the ticket — real git clones, records written through MetadataStorage under the new scheme, a fake devpod on PATH carrying the old-scheme ids and both published shapes (…/blooop/devlaunch/main absent = 35 of 36; …/blooop/bencher/main a 1-file .devcontainer/devcontainer.json stub, no .git = 1 of 36), plus an unaffected repo. Real invocation of this tree, --prune -y --force:

Removing 1 that nothing references -- 276.0 KiB:
  - .../repos/blooop/rockerc/rockerc-dead-aaaaaaaa (276.0 KiB)

Leaving 3:
  - .../blooop/bencher/bencher-main-qqqqqqqq: devpod lists workspace bencher-main and sources it at .../blooop/bencher/main; see devlaunch#88
  - .../blooop/bencher/main: ... see devlaunch#88
  - .../blooop/devlaunch/devlaunch-main-zovomobo: devpod lists workspace devlaunch-main and sources it at .../blooop/devlaunch/main; see devlaunch#88

The live workspaces' real checkouts survive under --force, cited to #88 — the exact inversion of the prior review's L1. Other half, #155's own host shape (5 live workspaces all at healthy clones, misplaced = 0, 15 stale): Removed 15 clone director(ies) -- 4.8 MiB, five still opens it, .bare untouched. The arm does not disable pruning anywhere it should not. --prune answered n on the #88 lab printed the plan and Aborted. with nothing removed.

Test faithfulness: TestADevpodRecordThatPointsAtNothing is built from #88's real recorded state (stub = .devcontainer/devcontainer.json only; gone = repo_dir/"main"), runs under --force, asserts the reason on the directory's own line, and its scoping test asserts an unrelated repo's clone still dies. The ids in the fixture genuinely fail to join, which is #88's discriminator.

Cost deviation (one devpod list → one per pass): justified and honestly declared. Executed round-trip log from a real invocation: acting run list --output json ×2; nothing-to-do run ×1; zero devpod status in both — exactly what the breadcrumb, test_devpod_spawn_counts.py (two tests) and README all say. It is what closes prior L2.

S6 (XDG_CONFIG_HOME) deviation: justified. test/conftest.py is untouched by this PR — pre-existing, strictly safer, and the new symlinked-repos_dir test depends on the scoping. A third, undeclared deviation worth naming: Referenced is now containment ("at or under"), not #155's literal path equality — forced by prior L3, safe-direction, documented.

Never-deletable / nothing-automatic: Disputed has no --force branch, verified live and by test; --prune is reachable only from the dl --prune dispatch (no other caller of prune_plan/prune_clones); --ls help text and output unchanged, executed; the --ls --json hunks are sum-type adapters with no orphan field.

#159 / #155 assertion checklist

named assertion status evidence (selection: pixi run pytest -q, full suite, unless stated)
only the clean orphan dies tested-and-bites SP10 RED 10 failed, 1298 passed, 23 deselected in 120.94s
…"referenced survives" half (prior vacuous) tested-and-bites SP10 kills test_a_clone_a_live_workspace_sources_is_kept
--force promotes only the dirty one tested-and-bites SP5 RED → test_it_does_not_turn_off_the_re_probe_for_a_clone_it_promoted_nothing_about
…"does not touch referenced" half (prior vacuous) tested-and-bites SP10 kills test_it_does_not_touch_the_clone_a_live_workspace_opens
Disputed survives --force tested-and-bites live #88 lab under --force, all three kept
.bare untouched tested-and-bites live labs; SP8 RED on the .bare assertion
idempotent second run tested-and-bites live: second run = 1 devpod list, nothing removed
symlinked-cache regression (prior vacuous) tested-and-bites SP10 kills TestACacheReachedThroughASymlink::…
resolve both sides — candidate / record / bare (prior missing) tested-and-bites ×3 SP6, SP7, SP8 each RED
remove_tree refusal exits 1, root-skipped tested (read) asserts code == 1 + siblings go; no mutation by me
1 devpod list, 0 devpod status tested-and-bites, deviated executed log: 2 / 1 / 0
scratch XDG_CACHE_HOME only deviation, ruled OK conftest untouched by PR
real git repos, local bare as remote tested fixture + my labs both real git
record dropped for removed clone / already-gone dir tested (read) no mutation by me
"gone" is FileNotFoundError only tested (read) _present; no mutation by me
lock held across classify and remove tested (read) two flock-based tests, plan pass and act pass
unsaved probe that could not answer (prior missing) tested-and-bites, one branch unheld SP9 RED ×2; SP3 GREEN — see S-a
Docker boundary line in --purge/--prune output missing (parked → #160) not in devlaunch/; README only

Mutations the Spec axis authored

Selection for every row: pixi run pytest -q — full suite, default env, applied alone to 0454f7e, reverted after. Rows marked ⁽¹⁾ had their summary line overwritten by pixi's lockfile WARN on stderr; their failure lists are verbatim and are the evidence.

# mutation origin verdict tail node id in failure list
SP1 _is_populated_clone: (p/".git").exists()p.exists() mine RED ⁽¹⁾ …::test_a_workspace_recorded_at_a_stub_disputes_that_repositorys_clones (only)
SP2 holder(): containment reversed mine RED ⁽¹⁾ TestAWorkspaceThatOpensPartOfAClone::test_a_live_workspace_opening_a_subdirectory_still_holds_the_clone
SP3 _unsaved: git status refusal → NothingToLose() mine GREEN — survived ⁽¹⁾ no FAILED lines — (see S-a; live run then deletes precious.txt)
SP4 misplaced recorded only when the folder still exists mine RED ⁽¹⁾ …::test_a_workspace_recorded_at_a_folder_that_is_gone_disputes_them_too, …::test_only_that_repositorys_clones_are_disputed
SP5 act pass forces every directory mine RED ⁽¹⁾ …::test_work_written_while_the_question_was_open_is_not_destroyed, …::test_it_does_not_turn_off_the_re_probe…
SP6 clone_root: .resolve() dropped prior GREEN, re-run RED ⁽¹⁾ …::test_a_repos_dir_reached_through_a_symlink_still_matches_its_clones
SP7 _records_by_directory keyed unresolved prior GREEN, re-run RED ⁽¹⁾ …::test_a_record_written_through_a_symlink_still_disputes_its_clone
SP8 .bare path not canonicalised prior GREEN, re-run RED ⁽¹⁾ …::test_a_repos_dir_reached_through_a_symlink_still_matches_its_clones
SP9 objection: CouldNotAskNone mine RED ⁽¹⁾ test_workspace_state.py::…::test_a_repository_that_will_not_answer…, test_prune_orphaned_clones.py::…::test_a_clone_whose_repository_will_not_open_is_kept_with_nothing_typed
SP10 _clone_status: Referenced arm never reached mine RED 10 failed, 1298 passed, 23 deselected in 120.94s incl. all three prior-vacuous tests

Prior findings S1–S7

finding ruling evidence
S1 symlink regression does not discriminate closed test now asserts the reason; SP10 kills it
S2 only one of two sides guarded closed SP6/SP7/SP8 all RED on reason-asserting tests
S3 "cannot tell" read as "clean" closed (one branch unheld → S-a) SP9 RED ×2; live run keeps the clone and names git's own words
S4 three vacuous Referenced tests closed SP10 kills all three by node id
S5 boundary line absent still open, legitimately parked #160; #159's Tested list omits it
S6 XDG_CONFIG_HOME closed as a ruled deviation conftest untouched; new test depends on it
S7 host figures in three copies still open, cosmetic each figure in README + dl.py + a test

Verified by execution vs not

Verified: baseline; ten mutations with the stated selection; #88's measured state reproduced and run twice (--force, and n); #155's host shape; the partial-migration residual; the unreadable-.git/index probe mutated and unmutated; devpod round-trip counts; dl --ls. All by invoking devlaunch.dl:main on that tree with a fake devpod first on PATH and a scratch XDG_CACHE_HOME.

Not run / not verified by me: pixi run ci, py310, pylint, ruff, ty; dev.sh/dl-next (deliberately — it repoints a shared venv other worktrees use); the migration→classification sequence (read from source); the retry's other table rows; the refusal-path, "gone"-is-FileNotFoundError and lock rows carry no mutation of mine.

Spec verdict: Approve — L1 outcome (a) holds under execution on #88's measured state and on #155's, both halves; every prior blocking Spec finding is closed with a mutation that bites by node id, and my two new findings are proportionate to a docstring and a test rather than to a gate.


Reviewer's own attacks (lead — not an axis)

Two further worktrees. Baseline reproduced first, selection: pixi run pytest -q, default environment, full suite: 1308 passed, 23 deselected in 155.56s (0:02:35). Subset selection L = pixi run pytest -q test/unit/test_prune_orphaned_clones.py test/test_devpod_spawn_counts.py test/test_workspace_state.py test/unit/test_disk_usage.py test/unit/test_workspace_source.py, baseline 169 passed in 100.70s (0:01:40).

L1 outcome (a), independently: confirmed on this host's own recorded devpod state

#88's shape is live on the machine this review ran on. #88's published diagnostic, read-only, user path redacted:

BROKEN kinisi-ros-ags-fix-branch-clone-instance-id -> ~/.cache/devlaunch/repos/kinisi-robotics/kinisi_ros/ags-fix-branch-clone-instance-id (exists: False )
BROKEN devlaunch-main -> ~/.cache/devlaunch/repos/blooop/devlaunch/main (exists: False )
BROKEN kinisi-ros-main -> ~/.cache/devlaunch/repos/kinisi-robotics/kinisi_ros/main (exists: False )
BROKEN kinisi-ros-ags-devcontainer-tooling-support -> ~/.cache/devlaunch/repos/kinisi-robotics/kinisi_ros/ags-devcontainer-tooling-support (exists: False )
4 of 5 localFolder workspaces have no .git at the recorded path

I took that real listing, rewrote only its repos root onto a scratch tree (no records invented, no ids invented), built the healthy new-scheme checkout #88 says sits beside the stale record plus an unaffected repository's genuinely dead clone, and ran the working tree — real git, XDG_CACHE_HOME scoped, devpod replaying the host's own records:

Removing 1 that nothing references -- 192.0 KiB:
  - .../repos/other/project/stale-clone (192.0 KiB)

Leaving 1:
  - .../repos/blooop/devlaunch/devlaunch-main-zovomobo: devpod lists workspace devlaunch-main and sources it at .../repos/blooop/devlaunch/main; see devlaunch#88

Removed 1 clone director(ies) -- 192.0 KiB.
healthy new-scheme checkout survived: True
unaffected repo's dead clone removed:  True

There is no metadata record in that cache at all, so the record-keyed arm cannot be what saved it — the path-keyed Misplaced arm is. Both halves of the retry's honest-cost claim hold, from a second direction than Spec's.

Correction, recorded because it nearly became a finding: my first run of this probe reported the checkout deleted. pixi run prepends the project environment's bin, which contains its own devpod, so my shim was shadowed and the command saw the host's real (out-of-tree) paths. I checked command -v devpod under pixi run, found the shadowing, and re-ran. The deletion was my harness, not the product.

L-A (blocking, new) — the CouldNotAsk arm cannot fire when an ancestor directory is a git repository, and the clone is deleted with its work in it

workspace_state._is_a_repository asks "is there a repository here" of the clone; _git then runs git … cwd=clone with no --git-dir and no GIT_CEILING_DIRECTORIES, so when <clone>/.git is present but unusable git's discovery walks up and answers about a different repository. It does not refuse, so RefusedCouldNotAsk is never produced. Isolated at the predicate level on head 0454f7e, verbatim:

_is_a_repository: True
read_clone      : CloneState(branch='main', unsaved=NothingToLose())
holds_unsaved   : NothingToLose()
objection       : None
git -C toplevel : .../lead-home4/home

branch='main' is the enclosing repository's branch; the clone is on work. The two questions are answered about two different directories.

End to end, real dl --prune -y, no --force, against a clone holding an unpushed commit and an untracked file whose .git is incomplete (an interrupted clone, or a container that half-wrote it), inside a home directory that is itself a clean, pushed git repository ignoring .cache/ — the ordinary dotfiles-in-git shape:

Removing 1 that nothing references -- 236.0 KiB:
  - .../home/.cache/devlaunch/repos/blooop/devlaunch/interrupted (236.0 KiB)

Removed 1 clone director(ies) -- 236.0 KiB.
=== survival ===
clone dir survived:  False
scratch.md survived: False
later.txt survived:  False

The same setup with .git chmod 000 destroys the working tree and leaves only .git, printing the sudo rm -rf remedy for it (scratch.md survived: False, later.txt survived: False).

This is prior finding L5 / Standards 1 / Spec S3 surviving its own fix through a door the fix did not close. The arm is real and does bite when git refuses — both axes proved that independently. It is not total over the ways git fails to answer about this clone, because git can succeed while answering about another one. The mechanism (cwd=repo, no ceiling) is pre-existing on main, but _is_a_repository is new here and --prune is the first caller that applies the answer to directories nobody named. Pinning git rev-parse --show-toplevel to the clone, or passing --git-dir, closes it.

I hit this on my very first real invocation, by accident, because the scratch cache sat inside the checkout's own worktree. The suite cannot reach it: tmp_path is never under a repository.

Mutations the lead authored

Each applied alone and reverted; git status --porcelain --untracked-files=no printed 0 after every row.

# mutation origin selection verdict verbatim tail node ids in the failure list
LM1 _is_populated_clone: a .git behind a closed door reads as a populated clone (except OSError: return True) mine L GREEN — survived 169 passed in 135.33s (0:02:15)
LM2 _clone_status: the #88 misplaced arm consulted only for clones with no metadata record mine L RED 4 failed, 165 passed in 104.92s (0:01:44) TestADevpodRecordThatPointsAtNothing::{…at_a_stub…, …at_a_folder_that_is_gone…, …at_the_repository_directory_itself…, test_only_that_repositorys_clones_are_disputed}
LM3 prune_clones: the act pass ignores a workspace that became misplaced since the plan (misplaced={} on the second pass only) mine L GREEN — survived 169 passed in 131.89s (0:02:11)
LM4 decide: --force marks every removal Insisted, not only the ones it promoted mine L RED count not captured (note below) TestWhatForceIsAnsweringAndForWhich::{test_the_plan_names_what_forcing_this_one_destroys, test_it_does_not_turn_off_the_re_probe_for_a_clone_it_promoted_nothing_about}
LM5 _site_of: a recorded folder that is not on disk is read as Outside the clone tree (kills #88's 35-of-36 case) mine L RED 2 failed, 167 passed in 89.91s (0:01:29) …::test_a_workspace_recorded_at_a_folder_that_is_gone_disputes_them_too, …::test_only_that_repositorys_clones_are_disputed
LM6 WorkspaceLocations.holder: containment dropped, equality only (retry's L3 row, re-run) inherited L RED count not captured TestAWorkspaceThatOpensPartOfAClone::test_a_live_workspace_opening_a_subdirectory_still_holds_the_clone
LM7 _is_populated_clone: .git must be a directory (retry's .git-is-a-file row, re-run) inherited L RED count not captured TestACloneWhoseGitIsAFile::test_a_live_workspace_opening_one_still_holds_it
LM8 objection: CouldNotAsk reads as nothing to lose (retry's L5 row, re-run) inherited L RED count not captured test_prune_orphaned_clones.py::TestACloneGitWillNotAnswerAbout::test_a_clone_whose_repository_will_not_open_is_kept_with_nothing_typed, test_workspace_state.py::TestACloneGitWillNotAnswerAbout::test_a_repository_that_will_not_answer_is_not_reported_as_holding_nothing

Note on the four missing counts: my driver's "tail" read the last line of the merged stream, which was pixi's lockfile warning rather than pytest's summary. The FAILED … node ids above are verbatim from the failure list; the summary count line for LM4/6/7/8 is not captured, and I am not reconstructing it.

Sample-verification of the retry's node-id habit (LM6, LM7, LM8): in all three the failure list names exactly the test the retry credits. No misattribution — the same conclusion the Standards axis reached on a different sample of four.

LM1 and LM3 are two correct guards that nothing holds, both on the #88 protection path. LM1: the OSError arm of _is_populated_clone is the difference between "this repository is disputed" and "this repository's clones are prunable", and no test reaches it — the Standards axis's coverage run independently names dl.py:1765-1766 as never executed. LM3: the retry's fix for L2 is credited with "the act pass classifies again … against a fresh listing", and the one arm that fix exists to serve — #88's — has no test on the second pass at all.

Also executed

A real dl --prune -y of the working tree against a scratch XDG_CACHE_HOME with the host's real devpod and real git, on a cache holding a dead clone, a dirty clone, a stray non-git directory and a broken clone: removed the dead clone and the stray directory, kept both clones with their reasons named. The retry's "real invocation" claim reproduces.

Verified by execution vs not (lead)

Verified by execution: the full-suite baseline and the subset baseline; all 8 mutation rows with the stated selection (4 of them without their summary count, as noted); #88's diagnostic on this host; the relocated-real-records end-to-end run of L1 outcome (a), both halves; L-A at the predicate level and end to end, three variants; one real dl --prune invocation against the host's real devpod; a clean tree after every mutation.

Not verified / not run by me: pixi run ci, -e py310 ci, pylint, ruff, ty (the Standards axis ran these; I did not); ./dev.sh (deliberately not run — it rebinds a host-wide dl-next symlink other work shares, so I invoked the tree directly and say so); the reference-host figures (1017 ms, 4.00 GB, 52/37/49 clones); the migration→classification interaction the prior review read from source.


Verdict

Request changes.

The axes split — Standards: Request changes, Spec: Approve — and they are not merged or reranked. The lead's own attacks then found one input that still puts a directory holding work-that-exists-nowhere-else into the removal set, executed on shipped code with no mutation and no --force.

Ruling on L1, outcome (a): it holds. #155's decision survives and does not re-open. The Disputed arm fires on devlaunch#88's measured state, confirmed by execution twice and independently — the Spec axis rebuilt #88's published shapes (35 gone + 1 config-only stub, old-scheme ids) and saw the live workspaces' real checkouts kept under --force and cited to #88; the lead replayed this host's own devpod list records, which carry #88's shape today (4 of 5), and saw the healthy new-scheme checkout kept by the path-keyed arm with no metadata record present at all. Both halves of the honest cost check out: affected repositories become undeletable and named, unaffected repositories are still pruned (Removed 15 clone director(ies) on #155's host shape). #155's reasoning was wrong and is corrected on #159; its conclusion — leave #88 off map #139, one-way pointer only — stands. No park is owed.

Prior findings, one line each:

prior finding ruling
L1Disputed never fires on #88's measured state closed — outcome (a) confirmed by execution, twice, independently
L2 — act pass never re-asks whether a directory became referenced closed in code (SP5, R3 RED); the #88 arm on that pass is unguarded (LM3 GREEN) — non-blocking
L3<clone>/sub does not hold <clone> closedholder() is containment; LM6 and SP2 RED on the named test
L4UnrecognisedSource contributes no path and no alarm closedUnreadableLocalFolder is its own arm and stops the command; image/container are Placeable(())
L5 / Standards 1 / Spec S3 — a clone git cannot answer about reads as holding nothing STILL OPEN — the arm exists and bites on refusals, but two of its three producers are unheld (M-S1, M-S2, SP3 all GREEN on the full suite) and it cannot fire at all when an ancestor is a git repository (L-A, executed deletion)
L6 / Standards 2--force is a plan-wide boolean closedPrunePlan.force deleted, promotion rides on Reclaimable; R3, LM4 RED
Standards 3 / Spec S1+S2 — the symlink test does not discriminate; three canonicalisation sites unheld closed — all three sites now RED under the full suite (SP6/SP7/SP8), and the tests assert the reason

Blocking for this round:

  1. L-A — holds_unsaved_work answers about the wrong repository, and a clone with an unpushed commit and an untracked file is deleted without --force. _is_a_repository says "there is a repository here" while _git lets git's discovery walk up to an ancestor repository and answer about that one instead. Executed end to end on head 0454f7e: scratch.md survived: False, later.txt survived: False. Pin the probe to the clone (--git-dir, or assert rev-parse --show-toplevel), and the CouldNotAsk arm becomes total over the ways git fails to answer.
  2. Standards 1 — the CouldNotAsk arm has three producers and only one is held. workspace_state.py:242 and :253 are uncovered per pixi run ci's own coverage report, and reading each as clean leaves the full suite green. Two tests (unreadable .git/index; a remote ref naming a missing object) close it. This is the same guard as (1), from the coverage side; both axes and the lead found it independently, which is why it is not a style note.

What is not in dispute and is worth keeping. The classification is genuinely total — eight sums, five with a sentinel that provably fails pixi run ty, decide exhaustive over three arms, one decide call and one append per candidate so no directory is in both sets or neither. --force reaches exactly one if in one function on one arm; PrunePlan.force is gone and the act pass reads a per-directory promotion. Disputed is never deletable, including under --force, verified live. Nothing runs automatically and --ls gained no orphan hint. pixi run ci and pixi run -e py310 ci are green end to end — pylint 10.00/10, ruff clean, ty clean, 1308 passed in both — which nobody on this node had run before. Every prior vacuous test named last round now asserts the reason and dies under mutation by node id, and sampling seven of the retry's credited rows across two reviewers found no misattribution. The defect that remains is at the edge of one probe, not in the sum.

GitHub refuses --approve/--request-changes on a same-account PR, so this is posted with --comment; the written verdict above is the gate.

blooop added 2 commits August 14, 2026 07:45
`dl --purge` is all-or-nothing, so the only way to reclaim the 4.00 GB of
dead clone directories measured under one cache was to destroy the 7.86 GB
of live ones and every bare cache with them. `dl --prune` removes exactly
the directories no live devpod workspace opens, in `--purge`'s own shape:
print the plan, name what is left standing and why, confirm, `-y` to skip.

Three constructors and one total function over them. `decide` returns
Remove or Keep, it is the only place a directory becomes deletable, and its
result is consumed once -- so a directory cannot be planned for removal
without a reason for keeping it having been ruled out, and a status nobody
handled stops the build rather than falling through into a deletion.
`unsaved` and `usage` live inside the Orphaned arm, which is what makes
"unsaved work on a clone that is staying" unsayable and keeps the unbounded
walk off the two arms whose bytes nobody gets back.

Referenced is containment, not equality: a workspace opened on a
subdirectory of a clone needs the clone. It is decided by canonical paths
rather than by the lexical containment `is_devlaunch_clone` uses -- refusing
there declines to delete a workspace, and refusing here deletes a clone. A
live workspace whose source cannot be read as a path stops the command
outright, because while one exists no directory can honestly be called
unreferenced.

The pass that acts classifies again, per directory, under the lock, against
a fresh listing -- a launch that completes while the report is on screen
registers a workspace for a directory already in the plan. What `--force`
answered rides on each Reclaimable rather than over the whole plan, so it
cannot switch the re-check off for clones it promoted nothing about.

devlaunch#88's state is joined by path, not by workspace id. The id is what
that ticket's scheme change broke, so a record naming a workspace devpod
still lists could never match on the host the Disputed arm was written for.

known_bytes gets its first production caller, as a sum rather than a
rendering: total_usage returns Measured or PartlyUnreadable, so one floor
among the orphans makes the whole total a floor and it prints as one.

Rebased onto devlaunch#171/#173/#174, which landed while this was parked and
which solved two of this branch's own problems better:

- the unsaved-work sum this branch added to `workspace_state` is dropped
  entirely in favour of the one on `main`. `main`'s is the same three arms
  under different names (`CouldNotTell` for `CouldNotAsk`) plus the half
  this branch did not have: `_git` names its repository with `--git-dir`
  and `--work-tree`, so git's discovery cannot walk up to an ancestor
  repository and answer about that one instead. That was the blocking
  finding on the last review of this branch, executed as a deletion of an
  unpushed commit and an untracked file with no `--force` typed.
- `--prune` keeps its own two-answers-to-one function, `_objection`, but it
  is now total over `main`'s arms with `unhandled_unsaved` behind it.

One behaviour follows from `main`'s sum and is a change from this branch's
earlier shape: a directory git cannot read as a repository at all -- stray
notes somebody left in the cache -- is `CouldNotTell` and is therefore
*kept* and named, with `--force` as the way past it. This branch used to
tell that case apart with a `.git` probe of its own and remove it. Deleting
the second opinion is the point: `dl <ws> rm` already refuses on that arm,
and the probe that separated them answered "empty" for a directory dl never
looked inside.
`--prune` reads metadata.json in two places -- to decide which directory a
record disputes, and to decide which record describes nothing -- and both
read `record.local_path` raw. A record gives *two* answers about where its
clone is, the path written in it and the one derived from owner/repo/branch,
and devlaunch#174 was those two answers disagreeing between the guard and
the delete. `resolve_clone_path` is now the single place that settles it,
and these are the last two callers that were not going through it.

Here the consequence is the dangerous direction rather than the merely
inconsistent one. A record that misses its clone leaves that clone with no
record at all, which drops it out of `Disputed` and into `Orphaned` -- a
deletion, with no `--force` and nothing said. The empty recorded path makes
it concrete: `Path("")` is `Path(".")`, truthy and existing, so it
canonicalised to whatever directory `dl` was run from and the record was
filed under that. On the other reader a record pointing somewhere stale
described "nothing" and was dropped, taking the only note of where a clone
that is still on disk lives.

Both were unheld -- the review's mutation table has the first as a surviving
green -- and both now have a test that fails under exactly the mutation that
reintroduces the fault, one asserting the clone is deleted and one asserting
the record is dropped.

`_is_populated_clone` stops asking `Path.exists()`. It is the third arm of
the same question and it had no test: `exists()` swallows ENOENT, ENOTDIR,
EBADF and ELOOP and re-raises the rest on 3.10-3.13 while 3.14 returns
False, so one expression is two behaviours across the `ci` matrix. `os.stat`
asks it once, a closed door reads as "not a populated clone" -- which
disputes the whole repository and keeps its clones, the direction a refusal
has actually established -- and the test for that arm is red under the
mutation that reads it the other way and green under nothing else.

README says out loud that clearing non-clone junk out of the cache now takes
`--force`, which is what `main`'s unsaved-work sum makes true.
@blooop
blooop force-pushed the perf/159-prune-orphaned-clones branch from 0454f7e to 67c576e Compare August 14, 2026 06:46
@blooop
blooop merged commit 9c9d215 into main Aug 14, 2026
12 checks passed
@blooop
blooop deleted the perf/159-prune-orphaned-clones branch August 14, 2026 06:50
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: remove clone directories no workspace references

1 participant