dl --prune: remove the clone directories nothing opens any more - #170
Conversation
Reviewer's GuideAdds a new Sequence diagram for the new dl_prune command flowsequenceDiagram
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
File-Level Changes
Assessment against linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ 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
🚀 New features to boost your workflow:
|
blooop
left a comment
There was a problem hiding this comment.
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 -q → 1283 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
-
The unsaved-work probe fails open, and
--pruneis the first caller that makes that a bulk deletion.devlaunch/dl.py:1580buildsOrphaned(holds_unsaved_work(clone), …);Optional[str]conflates "nothing to lose" with "git could not answer" (devlaunch/workspace_state.py:67-77returnsNoneon OSError/timeout/non-zero,:128-141reads that as no losses and skips the unpushed check whenbranchis alsoNone).devlaunch/dl.py:1386then readsNoneas removable. Executed: withgitoffPATH, the clone holding an unpushed commit + untracked file was deleted; with.gitchmod 000, its working tree was destroyed (scratch.md survived: False,later.txt survived: False) and the command printedsudo rm -rf …/orphan-dirty/.gitas the remedy. That is the container-wrote-as-another-uid machine AGENTS.md says this command exists for.devlaunch/workspace_state.py:54-58asserts 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 anddecidean arm that keeps it. -
Removecollapses two domain cases, so the--forceconfirmation cannot say what it costs.devlaunch/dl.py:1348-1357,1386-1388: under--forcetheunsaveddescription is discarded. Executed — the plan reads- …/orphan-dirty (276.0 KiB)under "Removing 2 that nothing references", indistinguishable from the clean clone.prune_clones:1751skips the re-probe whenplan.force, so there is no later chance. -
TestACacheReachedThroughASymlinkguards 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 viaDisputed(it holds a metadata record naming a listed workspace), notReferenced. Same defect the builder fixed at:258by asserting on the reason; not applied here, nor at:216.
Non-blocking
devlaunch/dl.py:1585-1592— record-path canonicalisation untested (M7 GREEN). If it regressed, a non-canonical record drops a clone fromDisputedtoOrphaned→ deleted.devlaunch/dl.py:820/1773-1777— thesudo rm -rfargument is untested (M4 GREEN); pointing it atplan.rootprints an instruction to delete every live clone, 114 passed.devlaunch/dl.py:1590,1613,1640,1648,1692,1792— six two-hop reaches throughclone_mgr.repo_manager/.storage(message chains).devlaunch/dl.pyis 3294 lines, 605 of them this PR; the repo already factorsdisk_usage.py/workspace_state.py.--pruneis a self-contained surface belonging indevlaunch/prune.py(divergent change).devlaunch/dl.py:1398-1405—Reclaimable.owner/.repoduplicatepath.parent{,.parent}.name.test/test_devpod_spawn_counts.py:82-99—spawnsanswers everysubprocess.runrc=0/empty, and:211runs 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 lint→ci, 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_lock → contextlib.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: _present → Path.exists() |
RED | 1 failed, 113 passed in 92.67s |
Behavioural probes (temporary files, since deleted): --force plan names the unsaved clone → FAIL; corrupt .git/HEAD → FAIL (Removed 2 clone director(ies) -- 2.5 MiB.); git off PATH → FAIL; .git chmod 000 → FAIL (scratch.md survived: False); live workspace sourcing repos/o/r → FAIL; 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 -q → 1283 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_diris 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_statuspins 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.
- L9 —
prune_clonespasses every refusing inner path toreport_refusals, so the pastedsudo rm -rfgrows 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:
- L1 — on devlaunch#88's measured host state the
Disputedarm never fires, and--prunedeletes the live workspace's only real checkout while keeping the config-only stub. #155 shipped this ahead of #88 on the claim that "theDisputedarm makes--prunesafe under exactly the state #88 exists to repair"; that claim is false, at a measured scale of 36 workspaces of 39. - 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".
- L3 — a live workspace opening
<clone>/subdoes not hold<clone>: strict path equality dropped the containment direction that actually protects. - L4 — an
UnrecognisedSourceworkspace contributes no path and no alarm, so the command proceeds while printing that it does not. - 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). - L6 / Standards 2 —
--forceis 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. - 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
left a comment
There was a problem hiding this comment.
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_count → 3, 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
- The
CouldNotAskarm has three producers and only one of them is held by anything.coverage report -m(the py310 CI run above) namesdevlaunch/workspace_state.py … 242, 253as never executed — those two lines arereturn CouldNotAsk(status.reason)(a refusal fromgit status --porcelain) andreturn CouldNotAsk(unpushed.reason)(a refusal fromgit 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 therev-parseroute 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/indexchmod 000 →rev-parse --abbrev-ref HEADreturnsmainwhilestatusgivesfatal: .git/index: index file open failed: Permission denied; a remote-tracking ref naming a missing object →statusrc=0 while log givesfatal: 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 forfatal: 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_unsavedsilently restores "cannot tell reads as clean", and nothing inpixi run cinotices. Two tests using the two states above close it.
Non-blocking
- Three of the eight sums have no
_unhandled_*sentinel, and forDecisionthe missing one guards a deletion. With a synthetic third arm onDecision,pixi run tyreports exactly one diagnostic, and it is on the report side —dl.py:1940 keeping.append(Kept(clone, decision.because)).prune_clonesnarrows withif isinstance(decision, Keep): … continueand then falls through toremove_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 forSourcePlaces(only diagnostic:dl.py:1737 places.paths) andGitAnswer. - Five more uncovered lines in the new prune code (same coverage run):
dl.py:1742-1745— theTooShallowarm 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). Plusworkspace_state.py:154-156,_git's OSError arm (git absent). - Divergent change, worse than last round.
devlaunch/dl.pyis now 3610 lines (was 3294); 939 of the diff's lines are in it. ~36 new symbols form a self-contained--prunesurface sitting beside CLI dispatch, ssh and the completion cache, in a repo that already factorsdisk_usage.pyandworkspace_state.py.devlaunch/prune.py. - 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)atdl.py:1912-1913, 2148anddl.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. - Message chains — six two-hop reaches through
clone_mgr.repo_manager/.storageatdl.py:1850, 1873, 1894, 1917, 1962, 2112. Unchanged from the prior round. - Speculative generality in
SourceSite.InAClone(clone)is built atdl.py:1703and itsclonefield is never read anywhere;OutsideandInACloneare handled identically at the sum's only consumer (dl.py:1738). - Mysterious name (mild).
Placeable/Unplaceable/Misplacedare three "place" words with unrelated meanings —Misplacedis not the negation ofPlaceable, it is a successfully located workspace at a non-clone.(owner, repo)is a data clump travelling through_repo_lock,_clone_status,Reclaimable,by_repoandmisplaced'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 lint → ci, 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:2143 → prune_plan:1902 → decide(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.
UnsavedWorkgainedCouldNotAsk,decidekeeps it, R1 is RED naming both credited tests. Two of three producers are unheld — blocking finding 1. - Standards 2 (
--forceconfirmation cannot say what it costs) — closed.Insisted.despiteis printed on that directory's own line (dl.py:1984-1986);PrunePlan.forceis gone; R3 RED. - Standards 3 (
TestACacheReachedThroughASymlinkguards 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 -rfargument 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 (
spawnsanswers everysubprocess.run) — still open as a shape, harmless today: the pinned test removes a plain directory with no.git, so_is_a_repositoryshort-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: CouldNotAsk → None |
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 Refused → CouldNotAsk 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 |
|---|---|
L1 — Disputed 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> |
closed — holder() is containment; LM6 and SP2 RED on the named test |
L4 — UnrecognisedSource contributes no path and no alarm |
closed — UnreadableLocalFolder 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 |
closed — PrunePlan.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:
- L-A —
holds_unsaved_workanswers about the wrong repository, and a clone with an unpushed commit and an untracked file is deleted without--force._is_a_repositorysays "there is a repository here" while_gitlets git's discovery walk up to an ancestor repository and answer about that one instead. Executed end to end on head0454f7e:scratch.md survived: False,later.txt survived: False. Pin the probe to the clone (--git-dir, or assertrev-parse --show-toplevel), and theCouldNotAskarm becomes total over the ways git fails to answer. - Standards 1 — the
CouldNotAskarm has three producers and only one is held.workspace_state.py:242and:253are uncovered perpixi 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.
`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.
0454f7e to
67c576e
Compare
Closes #159.
dl --pruneremoves the clone directories under<cache>/devlaunch/reposthatno 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,-yto skip,--forcefor the one guarded arm — because that shape is alreadylearned. 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:
Referenced(workspace_id)— a live workspace's source resolves to this exactdirectory.
Orphaned(unsaved, usage)— nothing opens it, and no record ties it to a liveworkspace.
Disputed(workspace_id, sourced_at)— its record names a workspace devpodstill lists and sources somewhere else. Build: persist the devpod workspace id instead of re-deriving it, and reconcile the orphans #88's shape.
A non-deletable state is unrepresentable rather than filtered out. There is
no boolean beside a status:
decide(status, force)returnsRemove(usage)orKeep(because), it is the only place anything becomes deletable, it is totalover the arms with the codebase's existing
_unhandled_*sentinel behind it, andits result is consumed at exactly one site — which files each directory into
PrunePlan.removingorPrunePlan.keeping. A directory cannot be in both, inneither, or in
removingwithout a reason having been produced for keeping it.Two fields sit inside
Orphanedrather than beside the classification, andboth for the same reason:
unsavedis only actionable on the arm that could bedeleted, so "unsaved work on a clone that is staying anyway" cannot be said; and
usageis only ever wanted for bytes somebody will get back, so the other twoarms are never walked at all.
Removethen carries the usage, so "what this runreclaims" is a total over exactly the set that dies.
--forcepromotes one arm.ReferencedandDisputedare not refusals toinsist 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.
Referencedis decided by equality of canonical paths,not by the lexical containment test
is_devlaunch_cloneuses. That predicate isright 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, symlinkedentries 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 cloneis still filling) and which it does not (devpod only learnsabout 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 themetadata.jsonrecords of directories already gone — "gone" being_present'sdistinction, 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 ratherthan a rendering:
disk_usage.total_usage()returnsMeasuredorPartlyUnreadable, so one floor among the orphans makes the whole total a floorand it renders as
≥.known_bytesis also what orders the report, which isthe comparative question it was exported for. Nothing prints it directly.
Cost
One
devpod listand zerodevpod status, pinned intest/test_devpod_spawn_counts.pybeside--purge's. Nothing runs on its own,nothing is on a launch path, and nothing was added to
--ls.Also here
report_refusalsis extracted frompurge_all_dataand 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.mdloses itsdl --prune-worktrees [days]row, which documented acommand that does not exist and never did on this branch's history.
Measured(0)case dl --prune: remove clone directories no workspace references #159's amendments asked for is pinned on the surface itbelongs to: a clone removed by hand reads
0 B/{"exclusiveBytes": 0},which must stay distinguishable from
-/null("not devlaunch's tomeasure"). 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:
Enhancements:
Tests: