Ask git about the clone, not whichever repo it wandered into (#171) - #173
Conversation
`holds_unsaved_work` -- the one judgement dl makes before destroying a directory -- could answer about a *different repository* and report "nothing would be lost" while the clone held unsaved work. Live in merged main, not only in the unmerged --prune work. `workspace_state._git` ran `git ... cwd=clone` with no --git-dir, no --work-tree and no ceiling, so git's repository discovery walked up the parent chain. A clone whose `.git` was unusable -- truncated, half-removed by an interrupted delete -- did not make git refuse; it made git find an *ancestor* repository and answer about that one. With dl's cache under $XDG_CACHE_HOME and a dotfiles repo in $HOME that ancestor is ordinary, and when it was clean and fully pushed `holds_unsaved_work` returned None for a clone holding untracked work. `dl <ws> rm` deleted it without asking for --force. The failure needed a *tidy* host: a dirty ancestor fired the guard for the wrong reason, about the wrong repository, and hid it. Two changes, neither sufficient alone: 1. Every git command names its repository (`--git-dir`/`--work-tree`), which switches discovery off entirely, so an unusable `.git` is a refusal. Verified against real git 2.55.0 on five shapes a broken clone actually takes -- garbage `.git`, empty `.git`, HEAD-only `.git`, a real clone with its object store deleted, a truncated gitfile -- all of which answered about the ancestor before and refuse now, while a healthy clone and a linked worktree (gitfile `.git`) still answer normally. GIT_CEILING_DIRECTORIES was the other candidate and is not used: it bounds discovery rather than switching it off, and when its path does not match what git resolved it fails *open*, back to the ancestor, silently. 2. The refusal has somewhere to go. `Optional[str]` conflated "nothing to lose" with "could not tell" and both were None, which meant delete freely. It is now a total sum -- NothingToLose / WouldLose(what) / CouldNotTell(why) -- every caller names the arm it handles, and could-not-tell refuses the delete exactly as would-lose does. The same sentinel one layer down (`if status:` reading a *refused* `git status` as a clean tree) goes with it: `_git` returns GitSaid/GitRefused, so empty output and no output stop looking alike. A directory that is *not there* still holds nothing, so clearing up after a half-finished delete needs no --force. A directory that *is* there and is not a repository is now a refusal rather than a clean bill of health -- it holds whatever files are in it, and with no repository to consult nothing has established they exist anywhere else. Breaking in `dl --ls --json`: `unsaved` was a string or null and is now an object with one key naming the arm, the shape `disk` already uses. Null keeps only its other meaning -- not dl's clone to inspect. It breaks the safe way: a reader testing the old field for truthiness now sees a truthy object for every arm and leaves workspaces alone. Closes #171
Reviewer's GuideFixes unsafe workspace deletion by making git calls explicitly target the clone, introducing a three-way Unsaved result type that distinguishes "nothing to lose" from "could not tell", updating dl’s delete guard and JSON output to treat refusals as non-deletable, and adding comprehensive tests and docs around the new behavior. Sequence diagram for updated dl rm delete guardsequenceDiagram
actor User
participant CLI as dl_cli
participant WS as workspace_state
participant CM as CloneManager
participant Git
User->>CLI: dl <workspace> rm
CLI->>CM: storage.get_worktree_by_workspace_id(workspace_id)
alt record read fails
CM-->>CLI: raises OSError/RuntimeError
CLI->>WS: CouldNotTell("could not read the workspace record ...") via _unsaved_work_in
else record missing
CM-->>CLI: None
CLI->>WS: NothingToLose() via _unsaved_work_in
else record present
CM-->>CLI: WorktreeRecord(local_path)
CLI->>WS: holds_unsaved_work(Path(local_path))
WS->>WS: read_clone(clone)
WS->>WS: _git(clone, "status", "--porcelain")
WS->>Git: git --git-dir=clone/.git --work-tree=clone status --porcelain
alt git can read repository
Git-->>WS: GitSaid(output)
WS->>WS: _unsaved(clone, branch)
else git refuses
Git-->>WS: GitRefused(reason)
WS-->>CLI: CouldNotTell(f"git could not read {clone}: {reason}")
end
end
alt unsaved is WouldLose
CLI-->>User: error: "<workspace> holds <description>. ... rm --force"
CLI-->>User: delete refused
else unsaved is CouldNotTell
CLI-->>User: error: "<reason>. devlaunch will not delete a clone it cannot check. ... rm --force"
CLI-->>User: delete refused
else unsaved is NothingToLose
CLI->>CLI: workspace_delete(workspace_id)
CLI-->>User: workspace deleted
else any other Unsaved arm
CLI->>WS: unhandled_unsaved(unsaved)
CLI-->>User: AssertionError
end
File-Level Changes
Assessment against linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #173 +/- ##
==========================================
+ Coverage 93.59% 93.95% +0.36%
==========================================
Files 21 21
Lines 2514 2565 +51
==========================================
+ Hits 2353 2410 +57
+ Misses 161 155 -6
🚀 New features to boost your workflow:
|
blooop
left a comment
There was a problem hiding this comment.
Two-axis review of b92c67b. Independent agents, separate worktrees, reported as they arrived — not merged, not reranked. Both Request changes, both narrowly: the core of this change is sound and independently verified twice.
Gate: CI and prek both success on this head; all eleven checks pass; MERGEABLE. Both reviewers reproduced pixi run ci exit 0, 1265 passed / 23 deselected, workspace_state.py at 100% statement coverage.
What both axes confirmed independently
- The bug reproduces exactly, and the fix closes it. Spec built 20 clone shapes against real git 2.55.0 and asked both
39d4d2aand this head. The ticket's repro: shipped givesCloneState(branch='main', unsaved=None)— the ancestor's branch — while this head givesCouldNotTell("fatal: not a git repository"). Two dangerous shapes the PR does not list also flipped: a bare repo directory (shipped:branch='HEAD', unsaved=None, i.e. delete freely) and a broken ref wherestatussucceeds butlogrefuses (shipped:unsaved=None). - The refusal fails safe end to end. Six
rmscenarios throughmain()with real clones: clean → deletes; dirty → exit 1, names the file; dirty +--force→ deletes; broken.git→ exit 1,will not delete a clone it cannot check; broken +--force→ deletes; clone removed by hand → exit 0, no--forceneeded. No path found where aCouldNotTellreaching the guard permits deletion. - The exhaustiveness is real, not decorative. Standards: dropping the
CouldNotTellarm from either consumer is atyerror (Expected Never, found CouldNotTell & ~NothingToLose & ~WouldLose). Spec's X10 is the headline — an arm that logs the refusal but forgetsreturn 1is rejected byty, not merely by a test. That is constructive modelling actually paying. - The
GIT_CEILING_DIRECTORIESrejection holds. Standards found four near-miss forms that fail open while--git-dirrefuses, includingGIT_CEILING_DIRECTORIES=<the clone itself>— a wholly plausible choice. Spec confirmed the relative-ceiling case independently. - Mutation evidence is honest. Six of seven author rows reproduce to the exact counts under Standards, five under Spec; divergences are explained by broader mutations, not contradictions.
- Cohesion is clean — all 5 files, all 11 hunks are #171; no test deleted without a replacement.
Standards
MEDIUM — --work-tree is load-bearing and unguarded (workspace_state.py:220). Dropping it while keeping --git-dir survives the full suite green. It is not redundant: with core.worktree set to another directory in a clone's config, git status --porcelain returns empty at rc=0 on a clone holding an untracked file — "nothing to lose" about a different directory, the exact failure class #171 exists to fix. (With core.bare=true it fails safe instead.) Spec's X6 found this green too but read it as equivalent under cwd=repo; Standards' core.worktree case is the one that shows it is not.
LOW — the _git docstring overstates its own verification (:210-211): "All five refuse here; all five answered about the ancestor under plain cwd=." Second clause false — six truncated-gitfile variants all refused at rc=128 under plain cwd= too, since git treats an invalid gitfile as a hard error rather than continuing upward. That shape was never part of the bug. The PR body's table gets this right; the prose and the docstring do not. Correct claim: four of five.
LOW — CloneState's docstring asserts an invariant the PR's own new test breaks (:164-165): "branch … is None … in every case where unsaved is a CouldNotTell". Falsified using exactly the shape test_a_readable_repo_whose_remote_refs_are_broken_is_could_not_tell builds: CloneState(branch='feature', unsaved=CouldNotTell(...)). The behaviour is right — git status did succeed — so this is a docstring defect written in the same commit as the test disproving it.
NIT: WouldLose rejects an empty description while CouldNotTell/GitRefused accept one, undocumented; GitRefused("") is reachable via str(OSError()) == ''. No live defect.
Standards verdict: Request changes
Spec
S1 (MEDIUM) — dl --ls --json still emits the old null sentinel for a dl-owned clone holding unsaved work, and the docs say it cannot. dl.py:509-511, :529; claims at README.md:474, CHANGELOG.md:269, dl.py:484.
unsaved gates on the metadata record; its sibling disk gates on _measurable_clone(ws, cache_dir). Executed — a clone under the cache with no record, holding a file that exists nowhere else:
{"id":"r-feature-aaa","devlaunch":true,"repo":null,"branch":null,"path":null,
"unsaved":null,"disk":{"exclusiveBytes":200704}}
TRUTH: clone holds ['.git', 'a', 'an-hour-of-work.md']README says "unsaved is null there, and only there" — false. devlaunch: true plus a measured disk means dl says this is its clone. So at the JSON surface null still carries two meanings, one of them "dl's own clone, never examined" — the exact conflation this ticket exists to remove, one layer out. This is the identical divergence PR #165 fixed for disk, whose story is told in a docstring seven lines below the offending line. Fix: ask _measurable_clone, or stop claiming "only there".
S2 (MEDIUM, pre-existing) — the guard and the delete can name different directories. The guard reads record.local_path (dl.py:463); remove_workspace_by_id (worktree/workspace_clone.py:448-468) falls back to the derived path when local_path is not on disk, and rmtrees that. Executed with the real clone manager:
record local_path: /…/moved-away (absent) -> guard saw NothingToLose()
dl r-feature-aaa rm -> EXIT 0
derived clone still on disk: False work file still on disk: False
Unsaved work destroyed, no --force. Same defect class as this ticket — guard answers about one directory, delete destroys another — different mechanism. Not introduced here, and a follow-up is a legitimate answer; silence is not. Filed as #174.
S3 (LOW) — an empty leftover directory now needs --force (workspace_state.py:266-270). The PR's justification holds only when the directory is entirely gone; rmtree killed after emptying but before removing the root leaves exactly this. Message is legible and names --force, so not a blocker.
S4 (LOW, pre-existing) — a residual "could not tell" that is not representable: with the clone's parent at chmod 000, Path.is_dir() raises PermissionError (Python ≤3.12 ignores ENOENT/ENOTDIR/EBADF/ELOOP, not EACCES), uncaught out of holds_unsaved_work. rm fails closed, but --ls --json becomes a traceback for the whole listing.
Spec verdict: Request changes
Verdict
Request changes — comment, because GitHub refuses --request-changes on one's own PR. Everything blocking is small and mechanical; nothing here is a correctness defect in what the fix itself does.
- S1 — make
--ls --json'sunsavedask_measurable_cloneasdiskdoes, or drop the "and only there" claim. As it stands the ticket's own sentinel survives at the JSON surface. --work-tree— add a test that goes red without it; thecore.worktreeshape is the one that demonstrates why.- Two docstrings — correct
_git's "all five answered about the ancestor" to four of five, and dropCloneState's "every case whereunsavedis aCouldNotTell". - S3/S4 — your judgement; both are legible failures and neither is a regression from
main.
One correction to the ticket, confirmed by execution on both revisions: #171's Blast-radius paragraph and map #139's Note both say this ships in dl <ws> rm and --purge. Spec instrumented holds_unsaved_work and ran main(["--purge","-y"]): zero calls. purge_all_data uses workspace_ownership + remove_tree only. --purge is unguarded against unsaved work by design (#131 — one y/N and the cache goes), so the correction is to the documentation, not the code. I wrote that claim; it was wrong, and the map has been corrected.
Four review items on the #171 fix, none of them in the fix itself. `dl --ls --json` gated `unsaved` on the metadata record while `disk` gated on the clone directory, so a clone under the cache with no record printed `devlaunch: true`, a measured `disk`, and `unsaved: null` -- and `null` is documented as "no clone of dl's here". #171's own sentinel, one layer out, and the divergence PR #165 already closed for `disk`. `unsaved`, `path` and `checkedOut` now fall back to `_measurable_clone`, so `unsaved: null` and `devlaunch: false` are the same set by construction. `--work-tree` was load-bearing and unguarded: dropping it left the suite green, but `core.worktree` pointing elsewhere makes `git status --porcelain` print nothing at rc 0 on a clone holding an untracked file. Test added that goes red without the flag. `read_clone` asked `Path.is_dir()`, which cannot express "I was not allowed to look" and answers differently per Python: <=3.12 raised PermissionError out of the listing, 3.13+ returns False and reads as nothing-to-lose. The errno is read directly now. Two docstrings claimed more than was measured: `_git` said all five broken shapes answered about the ancestor under plain `cwd=` (four did; a truncated gitfile is a hard error in git and was never part of the bug), and `CloneState` asserted an invariant its own new test disproves.
blooop
left a comment
There was a problem hiding this comment.
Two-axis re-review of 88c1b4c. Independent agents, separate worktrees, reported as they arrived — not merged, not reranked.
Spec: Approve. Standards: Request changes. Split verdict, so this does not merge yet; the remaining items are mechanical.
Gate: CI and prek both success on this head; all eleven checks pass; MERGEABLE. Both reviewers reproduced pixi run ci exit 0, 1271 passed / 23 deselected, workspace_state.py 100%, and pixi run -e py310 ci likewise. Both ran poison rows, and both replaced git checkout in their harnesses with sha256-pinned snapshot restores after the earlier clobbered-source incident.
Every prior blocking item is closed, verified by execution
- S1 — Spec re-ran the exact counterexample (dl-owned clone under the cache, no record, holding a file that exists nowhere else). It now prints
"unsaved":{"wouldLose":"1 uncommitted change(s) (an-hour-of-work.md)"}beside"devlaunch":true. Eight JSON cases produced no input withdevlaunch: trueandunsaved: null, or the converse. The equivalence claim survived a deliberate falsification attempt. --work-tree— red under mutation, and it is the new test that fails.- Both docstrings —
_gitnow says four of five, re-verified independently at git 2.55.0;CloneState's false invariant is gone. - S4 — verified on seven interpreters, and the author's "worse than reported" is correct.
- Discovery (requirement 1) — Spec built 22 clone shapes against real git on both 3.10 and 3.14, against shipped and head. The two dangerous shapes the last round found still refuse, and three more that were delete-freely on shipped now refuse: truncated gitfile,
gitdir:naming a non-repo, andcore.bare=true. At head only three remain delete-freely, all correct: a healthy clean clone, an absent directory, a regular file at the clone path. - Requirement 2 — nine
rmcases throughmain(), all fail-safe, including the pinned one: absent clone, no--force, exit 0, not regressed; andchmod 000parent now exits 1 without crashing.
Open — Standards
MEDIUM — the version boundary is wrong on the leg CI actually runs. Code, test and CHANGELOG say "≤3.12 raises / 3.13+ returns False". Executed in this repo's own envs: 3.10.20, 3.11.15, 3.12.3, 3.12.13, 3.13.13 all raise; 3.14.5 and 3.14.6 return False. The pivot is 3.14. Compounding it: .github/workflows/ci.yml:43 runs ci on py310–py313 only, and the default (3.14) env runs only test-e2e — so the fail-open leg this class exists for is exercised by no GitHub ci job, while test/test_workspace_state.py:344-346 says "this suite runs on both". Both axes found this independently. Fix is s/3.13+/3.14+/ in three files, plus either a py314 ci leg or an honest sentence.
MEDIUM — --work-tree's justification describes an outcome its own test does not produce. workspace_state.py:232-243 and the test both say --git-dir alone prints "nothing at rc 0". Executed on the exact fixture the test builds, it prints " D README.md\n D feature.txt" at rc 0 — a WouldLose about the wrong files, which is a refusal, not a fail-open. The mutation's only failing assertion is the filename one; the isinstance(..., WouldLose) line above it passes. The real fail-open needs the other work tree to mirror HEAD — built, and it gives NothingToLose() on a clone holding an hour of work. So the flag is load-bearing and the test is red without it, but it pins file names rather than the safety property. Build the mirrored case.
LOW — the new "same set by construction" invariant has one guard and no test. Dropping if mine else None (dl.py:511) is green: 1271 passed, exit 0. is_devlaunch_clone's own docstring names a reachable shape where it breaks (repos_dir moved outside the cache: reads as someone else's, record survives). The constructive fix deletes the argument instead of documenting it: clone_path = _measurable_clone(...) (None iff not ours), mine = clone_path is not None, then overwrite with the record path when there is one — one source, and the mutation becomes unwritable.
LOW — README.md:481 is false for the class the same commit introduced. "dl <workspace> rm refuses when the clone holds unsaved work" — executed with a dl-owned clone, no record, holding work: the listing correctly reports wouldLose, and rm exits 0 and orphans the clone. No work is destroyed (workspace_clone.py:465-466 returns False untouched with no record), so this is not a fail-open — but dl.py:513-516 states the principle "a listing that described a different directory from the guard would be worse than useless", and its own else names a directory the guard never reads.
NITs — os.stat is total over OSError but also raises ValueError on a NUL-containing path, which tracebacks the whole listing again, the exact harm test_one_unreachable_clone_does_not_take_the_whole_listing_down exists to prevent; except (OSError, ValueError) closes it. And Path("") is PosixPath('.'), so a record with an empty local_path reports about dl's cwd — pre-existing, needs a hand-edited metadata.json, inherited by :529.
Worth recording for #159
Spec read PR #170's branch read-only. It does consume this reading, but it carries its own parallel UnsavedWork sum plus an objection() that flattens it back to Optional[str], and its consumers still do if unsaved:. So "it will get the new arm when it rebases" is not automatic — mechanically taking main's side would leave #171's sentinel alive inside #170. Named on #159 so the rebase does not quietly undo this.
Verdict
Request changes — narrowly. Nothing here is a correctness defect in the fix, and Spec could not falsify the central claim after a deliberate attempt. Four mechanical items:
s/3.13+/3.14+/inworkspace_state.py:303,test_workspace_state.py:341,CHANGELOG.md:288— and either add a py314cileg or stop saying the suite runs on both.- Rebuild the
--work-treetest on the mirrored-work-tree shape so it pins the safety property rather than two filenames. - Restructure
dl.py:511so the equivalence holds by construction and the surviving mutation becomes unwritable. - Correct
README.md:481, or makermsay something when it declines a workspace with no record.
The irony is worth naming rather than glossing: the prose overstated its evidence again — a boundary interpolated between two executed endpoints and then stated as fact — in the commit titled "stop overstating the evidence". Two executed endpoints do not establish the point between them.
Four mechanical items from the second two-axis review of #173. No change to what the fix does. The version boundary was wrong and stated as fact. `Path.is_dir()` on a clone behind a mode-000 parent was run on this repo's own environments: 3.10.20, 3.11.15, 3.12.13 and 3.13.14 raise `PermissionError`; 3.14.6 returns `False`. The pivot is 3.14, not 3.13, in `workspace_state.py`, `test_workspace_state.py` and CHANGELOG. The previous claim was interpolated between two executed endpoints, in a commit titled "stop overstating the evidence". It survived because no CI job ran the side of it that fails open: the `ci` matrix stopped at py313 and the default (3.14) environment ran only `test-e2e`, so `pixi run ci` -- the command a developer is told to run -- was the one selection GitHub never ran. `default` is now a matrix leg. Executed: with `present = clone.is_dir()` back inside the existing `try`, the closed-door tests are red on 3.14 and **green on 3.10**, because there `is_dir()` re-raises into the `except OSError` arm. That leg is the only one that catches it. The `--work-tree` test pinned two filenames rather than the safety property. Executed at git 2.55.0: with `core.worktree` pointing at an *empty* directory, `--git-dir` alone prints " D README.md\n D feature.txt" at rc 0 -- a `WouldLose` about the wrong files, which is a refusal. The fail-open needs the other work tree to mirror HEAD, and then it prints nothing at rc 0: `NothingToLose()` on a clone holding an hour of work. The test builds the mirrored shape, so dropping the flag now fails on `isinstance(..., WouldLose)`; a second test pins that a clean clone is not reported dirty by the other tree's absences. The docstring said "nothing at rc 0" for the shape that does not produce it; it now says both. `unsaved: null` iff `devlaunch: false` was two questions that had to agree, and deleting either gate left the suite green. `_measurable_clone` is now asked once, `mine` is `clone_path is not None`, and a record only moves the row from one directory of dl's to another. The input that broke it -- a record that outlives the reason its workspace counts as dl's -- now has a test. README claimed `dl <ws> rm` refuses when the clone holds unsaved work. For a dl-owned clone with no record it does not: the guard reads the record, finds none, and answers `NothingToLose`, so the workspace goes and the clone stays at exit 0. No work is destroyed -- the delete reads the same absent record -- but it is not a refusal, and README now says what happens. Pinned by a test, with the fake's `remove_workspace_by_id` teaching both halves of the real one rather than returning a truthy Mock that removes nothing. `os.stat` is total over `OSError` but raises `ValueError` on a NUL-containing path, which took the whole listing down for one bad record -- the harm the stat guard exists to prevent. Caught, with a test that is red on 3.10 and 3.14 alike. `Path("")` reporting about dl's cwd is left alone: it needs a hand-edited metadata.json, and the delete already falls back to the derived path for an empty `local_path`, so it is the guard-and-delete-disagree family recorded on #174 rather than this one. pixi run ci: 1276 passed, 23 deselected, exit 0, on py310, py311, py312, py313 and default (3.14.6). workspace_state.py 100%.
blooop
left a comment
There was a problem hiding this comment.
Two-axis review of 395e527. Independent agents, separate worktrees.
Spec: Approve (with one required narrative correction, no code change). Standards: Request changes — four text items, and explicitly "No code defect found."
Gate: CI and prek success; eleven checks pass including the new ci (default); MERGEABLE. Both reviewers reproduced pixi run ci exit 0, 1276 passed / 23 deselected on both 3.14.6 and 3.10.20, workspace_state.py 100% on each.
All four prior items closed — verified by execution, not read
--work-tree— dropping it now fails attest_workspace_state.py:362withAssertionError: NothingToLose(), i.e. the safety-property assertion, not the filename one, which is never reached. Both axes.dl.py:511— Standards' surviving M5 is now red (1 failed). Spec ran 26 JSON rows over 13 workspace shapes — 0 violations, including a record that outlives ownership, a git source with a cache-path record, a stale record, an empty record and a NUL record. The iff is now unfalsifiable: neitherPathnorCloneStateis ever falsy.README.md:481— behaviour matches the new text exactly (workspace deleted, exit 0, no--force, clone and itsan-hour-of-work.mdstill on disk), and it is pinned: restoring the old claim gives 4 failed.- Version boundary —
3.14+in all three places,defaultin the matrix, badge updated.
Also re-verified: 31 clone shapes, zero fail-opens, superset of the previous 22. Two new shapes show --work-tree earning its keep beyond core.worktree — a gitfile naming the ancestor's .git, and .git itself being a bare repo: both report the ancestor's branch yet WouldLose about the clone's own files. A 17-case rm battery through the real main() behaves throughout, including the pinned absent clone, no --force, exit 0. Exactly three consumer sites, all arm-matched with unhandled_unsaved. Nine further mutations authored across the two axes, all red — including three novel ones (-uno hiding untracked work, --not flipping the branch, .strip() eating the porcelain status column).
Required corrections — all text
1. The causal claim justifying the CI change is false (Standards S1). Three places now say the missing 3.14 leg is why the boundary was wrong: workspace_state.py:328 ("which is how a boundary off by a whole minor version survived"), test_workspace_state.py:392 ("this suite could not catch that because…"), ci.yml:50 ("because no job ran the side of it that fails open"). Standards checked out 88c1b4c — the revision carrying the wrong text — and ran pixi run ci on 3.14.6: 1271 passed, exit 0, green. A ci (default) leg would have been green on the wrong boundary; the suite asserts version-independent behaviour and never calls is_dir(). What caught it was a reviewer asking for execution. The leg is still worth having — it catches MD-style reversions, which ci.yml already says a paragraph earlier. Delete the "because"/"which is how" sentences.
2. "Only the 3.14 leg catches it" is contradicted by the row's own numbers (Standards S2, Spec, independently). PR body: "green on 3.10 … so that mutation would have merged." Both axes measured py310: exit 1, 1 failed — test_a_recorded_path_that_is_not_a_path_at_all_is_could_not_tell, the NUL-byte test added in this same push, because CPython 3.10's Path.is_dir() catches ValueError and returns False. So ci (py310) would have been red and it would not have merged. True of 88c1b4c, false of this head. The narrow claim — the two closed-door tests are red on 3.14 and green on 3.10 — is true and both verified it.
3. workspace_state.py:336-337 has its referents inverted. "ValueError is caught alongside OSError because os.stat is total over the first and not over the second" — first is ValueError, second is OSError, so it says the opposite of the truth and of its own next clause. The PR body has it right; the docstring copy got flipped.
4. ci.yml:47 cites a CONTRIBUTING file that does not exist. No CONTRIBUTING* anywhere in the repo; the instruction lives at README.md:672.
NIT (Standards S5) — "by construction" is still a shade strong. Spec confirms the invariant is now unfalsifiable and that the restructure is behaviourally a no-op (is_devlaunch_clone and _measurable_clone(...) is not None are the same predicate), so its value is that divergence becomes unwritable — the right reason. But Standards' MG2 (skip read_clone for a genuinely absent clone dir — a plausible "don't shell out to git for a directory that isn't there" edit) is green and yields {"devlaunch": true, "path": "<dir>", "unsaved": null}. An untested input, not a defect. Either soften the three sentences or add the row to TestTheJsonListing.
Minor NITs: an orphaned line-wrap at ci.yml:125-126; README.md:481's lead still enumerates "when the clone holds unsaved work" as a refusal condition that the paragraph 18 lines below corrects; _measurable_clone's docstring still describes itself as only "the directory --ls --size may walk" when it is now the sole oracle for devlaunch, path and unsaved. Also worth recording: default is the only unpinned leg, so pixi update moves it unreviewed and the matrix will silently lose 3.14 when 3.15 solves — frozen: true keeps each commit reproducible, so a maintenance note, not a defect.
Verdict
Request changes — narrowly, and nothing needs re-running afterwards except the gate. Four one-to-three-line edits, of which the first two are the same failure this PR has now hit in four consecutive rounds: a sentence written one step past what was executed. The irony is worth stating rather than glossing — item 1 and item 2 were introduced by the round that fixed the previous instance, and both were caught only because a reviewer ran the older revision rather than reasoning about it.
Also for #174, which Spec confirmed is accurately scoped: it currently covers only the rm face. The listing has a third face of the same record defect — a record with an empty local_path yields devlaunch: true, path: ".", and unsaved computed about dl's own working directory (Spec's run reported its worktree's dirty files as the workspace's unsaved work) while disk is measured on the real clone. Added to that ticket.
Both axes independently re-confirmed the guard/delete divergence is pre-existing and not widened here — reproduced identically against merged main at 39d4d2a.
Four corrections, all text, plus one test. The causal claim that the missing 3.14 leg is why the boundary was wrong is false, and was falsified by running it: `pixi run ci` on 3.14.6 at the revision carrying the wrong text is green. The tests here assert the same answer on every version -- that is the point of them -- so no leg catches a boundary written down wrongly. Somebody executing the mode-`000` parent did. The leg stays: it catches the reversion, which is the reason `ci.yml` already gave a paragraph earlier. Removed from `workspace_state.py`, the test docstring, `ci.yml` and the CHANGELOG entry that chained the same "so". `read_clone`'s ValueError paragraph had its referents inverted: `os.stat` is total over `OSError` and not over `ValueError`, which is what its own next clause says. Named both rather than saying "the first and the second". `ci.yml` cited a CONTRIBUTING file that does not exist; the instruction is in README's Development section. And the mutation the last round left green -- skipping `read_clone` for a clone directory that is genuinely absent -- now has a row. An absent clone is `NothingToLose()`, which is why `rm` can clear away a workspace whose clone was removed by hand, and the listing has to say the same: not asking writes `unsaved: null` onto a row that says `devlaunch: true` and names a `path`, which is devlaunch#171's sentinel one layer out again. Red under that mutation with 1276 others green, and the failing assertion is the `unsaved` one. NITs: README's `rm` lead said "when the clone holds unsaved work" where the guard reads the *recorded* clone, which the paragraph below it corrects; `_measurable_clone`'s docstring still called itself the directory `--size` walks when three more fields read it; an orphaned line-wrap in `ci.yml`; and a note that `default` is the only unpinned leg, so the matrix loses 3.14 the day 3.15 solves.
Two clauses claimed the recorded directory is "the one the delete would remove". It is the one the *guard* reads, which is not the same thing: `remove_workspace_by_id` falls back to a derived path when the recorded one is not on disk, so the two can name different directories. That divergence is older than this guard and is tracked as #174 — which this PR's own body scopes out. Stating it as settled in the README told a reader the opposite of the ticket. Both instances corrected, and the second now points at #174 rather than writing over it. README only; no code, no behaviour.
`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.
Closes #171.
This revision (
dc9c9c1) — the four text corrections, and one testNo behaviour changed.
395e527's review was Spec: approve, Standards: request changes with "No code defect found."workspace_state.py, the test docstring,ci.yml, and the CHANGELOG paragraph that chained the same "so". It is false: Standards ranpixi run cion 3.14.6 at88c1b4c, the revision carrying the wrong text, and got 1271 passed, exit 0. The leg stays, on the reasonci.ymlalready gave: it catches the reversion.read_clone'sValueErrorparagraph had its referents inverted ("total over the first and not over the second"); it now namesOSErrorandValueErrorrather than counting.ci.ymlcited a CONTRIBUTING file that does not exist; the instruction is atREADME.md's Development section.Plus the NITs: the reviewer's surviving green mutation (skip
read_clonefor an absent clone directory) now has a test row, red under it; README'srmlead says recorded clone;_measurable_clone's docstring describes all four fields it answers; the orphaned wrap inci.ymlis closed; andci.ymlrecords thatdefaultis the only unpinned leg, so the matrix loses 3.14 the day 3.15 solves.What shipped
holds_unsaved_work— the one judgementdlmakes before destroying a directory — could answer about a different repository and report "nothing would be lost" while the clone held unsaved work. It was live in mergedmain(39d4d2a), not only in the unmerged--prunework.workspace_state._gitrangit … cwd=clonewith no--git-dir, no--work-treeand no ceiling, so git's repository discovery walked up the parent chain. A clone whose.gitwas unusable did not make git refuse — it made git find an ancestor repository and answer confidently about that one.1. Discovery cannot escape the clone
Every git command now names its repository:
--git-dir=<clone>/.git --work-tree=<clone>. That switches discovery off entirely, so an unusable.gitis a refusal.Verified against real git (2.55.0), not assumed — the five shapes a broken clone actually takes, four of which answered about the ancestor under plain
cwd=, all five refusing now:cwd=(shipped)--git-dir/--work-tree(this PR).gitdir holding garbagebranch=main,status=[]— the ancestor'sfatal: not a git repository.gitdirbranch=main,status=[]fatal: not a git repository.gitwith HEAD and nothing elsebranch=main,status=[]fatal: not a git repository.git/objectsdeletedbranch=main,status=[]fatal: not a git repository.gitis a gitfile)The truncated-gitfile row is the correction review asked for: git treats an unreadable gitfile as a hard error (
fatal: invalid gitfile format) instead of continuing discovery upward, so that shape refused undercwd=too. Re-executed here on six gitfile variants (gitdir:,gitdir:, empty, garbage, agitdir:naming a path that is not there, agitdir:naming a directory that is not a repository) — all six rc=128 under plaincwd=. The table always said this; the prose above it and_git's docstring said "all five", and both now say four of five.GIT_CEILING_DIRECTORIESwas the other candidate named on the ticket and is deliberately not used: it bounds discovery instead of switching it off, so it has to be an absolute path matching what git resolved the clone's parent to — and when it does not match it fails open, back to the ancestor, silently.--git-dirfails closed.--work-treeis not decoration beside--git-dir, and there is a test that goes red without it.core.worktreein a clone's own config points the work tree at another directory, and--git-diralone honours it — sogit status --porcelaincompares the clone's index against that directory. What comes back depends on what is in it, and the previous revision of this PR pinned the wrong one of the two shapes. Both re-executed here, real git 2.55.0, on a clone whose tracked files areREADME.mdandfeature.txtand which holds an untrackedan-hour-of-work.md:The empty shape is a
WouldLosenaming two files that are not missing: wrong, and worth fixing, but a refusal — it destroys nothing. The mirrored shape is the fail-open one: nothing at rc 0 isNothingToLose()on a clone holding an hour of work that exists nowhere else. The old test built the empty shape, so under the mutation itsisinstance(..., WouldLose)line passed and only its filename assertion failed — red for a reason that is not the safety property. The test now builds the mirrored shape and goes red on the arm (AssertionError: NothingToLose()), with a second test pinning that a clean clone is not reported dirty by the other tree's absences. Both_git's docstring and the class docstring said "nothing at rc 0" for the shape that does not produce it; both now state the two outcomes separately.core.bare = trueis the neighbouring shape and is not a third case:--git-diralone answersfatal: this operation must be run in a work treeat rc 128 — re-executed here — which is a refusal and therefore already safe.2. A refusal is representable and fails safe
Optional[str]conflated "nothing to lose" with "could not tell": both wereNone, andNonemeant delete freely. It is now a total sum:could-not-tellrefuses the delete exactly aswould-losedoes, and says--force.unhandled_unsaved(the shapedisk_usage._unhandled_usagealready uses) makes a fourth arm a crash rather than consent.The existing docstrings are answered rather than ignored.
read_clonesaid "a directory that is not there, or is not a repository, holds nothing" — half of that stays true and keeps its stated purpose: a directory that is not there holds nothing, which is what lets a caller clear away a workspace whose clone was removed by hand (pinned by a test, and by mutation M7). The other half was the bug: a directory that is there and is not a repository holds whatever files are in it, and with no repository to consult nothing has established they exist anywhere else._git's docstring said "a refusal is 'cannot tell', never an answer … must never be reported as safe to delete on the strength of a failed command" — that path was unreachable because the command succeeded; it is now reachable, and the sentinel one layer down went with it (if status:read a refusedgit statusas a clean tree;_gitreturnsGitSaid/GitRefusedso empty output and no output stop looking alike).A clone
dlcannot look at is a third refusal, andPath.is_dir()had no way to say so. With the clone's parent at mode000it gave a different wrong answer depending on the interpreter: it re-raisesPermissionError(sodl <ws> rmfailed closed by crashing, anddl --ls --jsonbecame a traceback for the whole listing because of one workspace) up to and including 3.13, and on 3.14 returnsFalse, which read as "not there, so nothing to lose" — a clone that may be full of work, reported as free to delete.read_clonenow reads the errno: ENOENT and ENOTDIR mean there is no clone there, everything else is aCouldNotTell.ValueErroris caught alongsideOSError, becauseos.statis total overOSErrorand not overValueError: a NUL byte in a recorded path is rejected before the syscall — no errno arm can catch it — and took the whole listing down for one bad record. (Review's S4 and its NIT, both pre-existing.)read_clone's docstring stated that pair the wrong way round; it now names both exceptions instead of saying "the first and the second".The boundary is 3.14, and the previous revision said 3.13 — a whole minor version out, in a commit titled "stop overstating the evidence". It was interpolated between two executed endpoints (3.10 raises, 3.14 returns
False) and then written as fact. Executed here on this repo's own environments, the mode-000parent againstPath.is_dir():Path.is_dir()on a clone behind a mode-000 parentPermissionErrorPermissionErrorPermissionErrorPermissionErrorFalsePatch levels between those were not run, so "3.13 and earlier" is what those five interpreters support as a minor-version claim, not a claim about every release.
1b. The interpreter the fail-open leg lives on is now in CI
.github/workflows/ci.ymlrancion py310–py313 only. Thedefaultenvironment resolves the unpinnedpython = ">=3.10"— 3.14.6 — and ran onlytest-e2e. So the leg whereis_dir()returnsFalse, the fail-open one this class exists for, was exercised by no GitHubcijob, andpixi run ci— the command README's Development section tells a developer to run — was the one selection CI never ran. The test's own docstring said "this suite runs on both".The missing leg is not why the boundary was wrong, and the previous revision of this PR said it was. Standards checked out
88c1b4c— the revision carrying the "3.13+" text — and ranpixi run cion 3.14.6: 1271 passed, exit 0, green. These tests assert the same answer on every version, which is the point of them, and none of them callsis_dir(), so aci (default)leg would have been green on the wrong boundary too. What caught it was a reviewer asking for the mode-000parent to be executed. Those causal sentences are deleted fromworkspace_state.py, the test docstring,ci.ymland the CHANGELOG entry that chained the same "so". (That execution is Standards'; it is not re-run here.)The leg stays, for the reason
ci.ymlalready gave a paragraph earlier: it catches the reversion —clone.is_dir()written back in — on the one interpreter where that call fails open rather than raising. Mutation MD below is that reversion, and the two closed-door tests are red on 3.14 and green on 3.10. It is also red on py310 overall, for a different test — see MD's row — so the leg is a second net over that shape rather than the only one.defaultrather than a newpy314feature: it needs no new environment in the lockfile, and it keeps tracking whatever the newest solvable Python is instead of pinning a version somebody has to remember to bump. The README badge now lists 3.14 as well.3. Every caller updated
dl <ws> rm(dl.pydelete guard) — matches all three arms explicitly._unsaved_work_in— a workspace record dl cannot read was alsoNone/delete-freely; it is nowCouldNotTell. A workspace dl has no record of staysNothingToLose, and that is truthful for this caller specifically:remove_workspace_by_idreturnsFalsewithout touching the disk when there is no record, so there is nothing for the guard to protect. What that means at the surface is now written down rather than glossed — see "Whatrmdoes not cover" below.dl --ls --json—unsavedis now an object with one key, and is answered for every workspacedlowns, not only the ones it still holds a record for (below).--purgedoes not consult this reading, and the claim on holds_unsaved_work can answer about an ancestor repository and report a dirty clone as safe to delete #171 and map Launch latency: dl <spec> -- <cmd> to a running command #139 that it does is wrong. Instrumented and executed on this revision:main(["--purge", "-y"])against a cache holding a broken clone with an untracked file →holds_unsaved_workcalled 0 times, exit 0, clone gone.purge_all_datadeletes on the ownership predicate after one y/N, by design (dl --purge exits 1 and leaves the whole cache when a container wrote as another user #131). The ticket and the map have been corrected; this PR changes no--purgebehaviour.--prune(dl --prune: remove clone directories no workspace references #159) readsholds_unsaved_workand will get the new arm when it rebases.Breaking, in
dl --ls --json:unsavedwas a string ornull; it is now{"nothingToLose": true}|{"wouldLose": "<what>"}|{"couldNotTell": "<why>"}— the shapediskalready uses. The break is the safe way round — a reader that tested the old field for truthiness now sees a truthy object for every arm, so it leaves workspaces alone rather than deleting them.4.
nullat the JSON surface means one thing (review's S1)The previous revision of this branch left
unsavedgating on the metadata record while its neighbourdiskgated on_measurable_clone— the clone directory. A clone under the cache thatdlhas no record for (a metadata write that failed, a record pruned, a cache restored without one) therefore printed:{"id":"r-feature-aaa","devlaunch":true,"repo":null,"branch":null,"path":null, "unsaved":null,"disk":{"exclusiveBytes":200704}}devlaunch: trueand a measureddiskaredlsaying this is my clone, beside anunsaved: nulldocumented as there is no clone of dl's here to inspect. Sonullstill carried two meanings — "not mine" and "mine, never examined" — which is #171's own sentinel surviving one layer out, and the identical divergence PR #165 closed fordisk.Fixed rather than documented away, on the evidence that this is the same bug as #165 and was answered there the same way:
unsaved,checkedOutandpathdescribe_measurable_clone(ws, cache_dir)when there is no record.The equivalence is now by construction rather than by agreement, which is this revision's third item. It used to be two questions that had to give matching answers —
is_devlaunch_clonefordevlaunch, and a record-or-clone expression for the directory — and deleting the ownership gate on the record lookup left the whole suite green._measurable_cloneis asked once,mineis defined asclone_path is not None, and a record can only move the row from one directory ofdl's to another:The record is still preferred when there is one, deliberately: that is the directory
dl <ws> rm's guard reads, and a listing that described a third directory would be worse than useless to the caller deciding whether to call it.The input that broke the old form now has a test: a record that outlives the reason its workspace counts as
dl's.is_devlaunch_clone's own docstring names the way in — aconfig.tomlpointingrepos_diroutside the cache makesdl's clones read as someone else's, while the recordsdlwrote for them survive. Under the old form that row printed awouldLoseabout a directory the same row calledpath: null, on a checkoutdlhas no business inspecting. Mutation MC below is red on exactly that test."By construction" is only as strong as the edits it makes unwritable, and one plausible edit was outside them. Review's MG2 — skip
read_clonefor a clone directory that is genuinely absent, a reasonable "don't shell out to git for a directory that isn't there" — was green, and produced{"devlaunch": true, "path": "<dir>", "unsaved": null}. An untested input rather than a defect, but the samenullcarrying the same two meanings. It has a row now: an absent directory isNothingToLose()— which is exactly what letsrmclear away a workspace whose clone was removed by hand — so the listing has to say so too, andTestTheJsonListingasserts it. Mutation MI below is that edit, executed on this head: 1 failed, and the failing line is theunsavedassertion, not thepathone.README and CHANGELOG said "null only where
repoandbranchare"; that was the false claim and both now say "null exactly wheredevlaunchis false", with the weaker record-based test named as not the same set.5. What
rmdoes not cover, said out loudREADME.mdclaimed "dl <workspace> rmrefuses when the clone holds unsaved work". For one shape that is false, and it is a shape this ticket's own fix makes visible: a clone underdl's cache with no metadata record, holding work.dl --ls --jsonnow correctly reportswouldLosefor it (that is item 4 above).rmdoes not refuse — the guard reads the record, finds none, and "dl has no record of a clone here" is answeredNothingToLose. So the devpod workspace goes, the clone stays, exit0, no--forceasked for.No work is destroyed, which is why this is not a fail-open and is not being changed here: the delete reads the same absent record and
remove_workspace_by_idreturnsFalsewithout touching the disk. But it is not a refusal either, anddl.pystates the principle that a listing describing a different directory from the guard "would be worse than useless". README now says what happens instead of what it wished happened, and a test pins it — including that the fake'sremove_workspace_by_idteaches both halves of the real one (Falseand no removal with no record; removal with one) rather than returning a truthyMockthat removes nothing and makes every survival assertion true for free. Mutation MF is red on that half.The lead sentence of that section still enumerated "when the clone holds unsaved work" as a refusal condition, 18 lines above the paragraph correcting it. It now says the recorded clone, which is the directory the guard actually reads, and points at the paragraph below for the case that is neither a refusal nor a delete.
Red, then green
Behavioural red against the shipped module (
origin/main,workspace_state.pyat 2fde002), ancestor repo clean and fully pushed — inherited from the first revision of this PR, not re-run here:On GitHub, this head (
dc9c9c1) is green on all eleven checks —ci (default),ci (py310),ci (py311),ci (py312),ci (py313),e2e,prek,gate, both codecov checks and GitGuardian. So py311–py313 did run on this head; what follows is what was run locally.Locally on this head, both ends of the boundary:
pixi run ci(default, 3.14.6) → exit 0, 1277 passed / 23 deselected;pixi run -e py310 ci(3.10.20) → exit 0, 1277 passed / 23 deselected. ruffAll checks passed!, pylint10.00/10, tyAll checks passed!on each.workspace_state.pyat 100% statement coverage on both. py311, py312 and py313 were not re-run locally on this head — GitHub'sci (py311),ci (py312)andci (py313)legs are green ondc9c9c1, and their last local run was on395e527at 1276.Mutation evidence
Each mutation reintroduces one specific fault and the full CI selection is re-run —
pixi run ci, never a filtered-k. Baseline green on this head is 1277 passed / 23 deselected.Row MI is this revision's and was executed on this head. Rows MB–MH were executed on
395e527against a baseline of 1276, and MA and M1–M7 on revisions before that (baselines 1271 and 1265); all of those are inherited and not re-executed here, since the only change since395e527is one added test and prose.Restores between mutations are snapshot copies verified by sha256, never
git checkout— and that mattered: a different session sharing this scratchpad overwrote a generically-named restore script mid-run, so one row was executed with a stale mutation still applied. It was caught by the checksum check, discarded, and re-run from a verified snapshot; the script and snapshot now carry unique names and the snapshot is verified before every restore._gitdrops--work-tree, keeps--git-dirisinstance(..., WouldLose)withNothingToLose(), and on the clean clone reported asWouldLose('2 uncommitted change(s) (README.md, feature.txt)')--ls --jsonlooks up the record regardless of ownership (dropsif mine else None)read_clonegoes back toPath.is_dir()inside the existingtrytest_a_recorded_path_that_is_not_a_path_at_all_is_could_not_tellread_clonegoes back toPath.is_dir()with thetryremoved — the pre-PR expressionremove_workspace_by_idreverts to theMagicMockdefault (truthy, removes nothing)_unsaved_work_inanswersCouldNotTellfor a missing record — i.e.rmrefuses insteadread_clonecatchesOSErroronly, notValueError--ls --jsonskipsread_clonewhenos.staton the clone path says ENOENT/ENOTDIR — "don't shell out to git for a directory that isn't there"unsavedline:AssertionError: assert None == {'nothingToLose': True}--ls --json'sunsaved/path/checkedOutgate on the metadata record again (review S1)_gitdrops--git-dir/--work-tree, runs withcwd=alone — the shipped bug_unsavedanswersNothingToLose()whengit statusrefuses_unsavedswallows a refusedgit log … --not --remotes(treats it asGitSaid(""))CouldNotTellarm — falls through to the delete_unsaved_work_inanswersNothingToLose()when the record cannot be readunsaved_as_jsonrendersCouldNotTellas{"nothingToLose": true}CouldNotTell— over-refusalMD's earlier reading was wrong, and this is the correction. The previous revision of this PR read the row as "green on 3.10 … so that mutation would have merged". The row's own numbers say otherwise, and both review axes measured the same thing: py310 exit 1, 1 failed —
test_a_recorded_path_that_is_not_a_path_at_all_is_could_not_tell, the NUL-byte test added in that same push, because CPython 3.10'sPath.is_dir()catchesValueErrorand returnsFalse. Soci (py310)would have been red and MD would not have merged. That claim was true of88c1b4c, which had no NUL test, and false of395e527, which added one. The narrow claim, verified by both axes, is the one now stated everywhere: the two closed-door tests are red on 3.14 and green on 3.10, so thedefaultleg is what catches that half of the mutation. ME is MD's blunter sibling: it is red at the closed-door tests themselves on both interpreters, because the pre-PR expression drops thetryas well. MD keeps thetry, and at the closed-door tests it is red on 3.14 only — on 3.10is_dir()re-raises intoexcept (OSError, ValueError)and the answer is stillCouldNotTell. That is the whole of what thedefaultleg adds for this shape. MB is the row the previous revision got half-right: it was red, but on a filename rather than on the arm. M3 is the subtle one:git statussucceeds (it never reads remote-tracking refs), so the repository probe passes and the clone reads clean right up untilgit log … --not --remotesrefuses on a ref pointing at an object that is not there. M7 exists because pinning only the fail-closed direction would let the fix silently make every already-deleted clone need--force.Review items not taken
_unsaved_work_inreadsrecord.local_path;remove_workspace_by_idfalls back to the derived path whenlocal_pathis not on disk andrmtrees that. Pre-existing, a different mechanism, and filed as The unsaved-work guard and the delete can name different directories #174. Not widened here.--force. Left as it is, with a reason rather than by omission: the argument for changing it is that "a directory that is not there holds nothing" extends to "a directory that is there and has nothing in it holds nothing", which is true. The argument against is that it widens the delete-freely set inside the fix for a fail-open bug, on a shape (rmtreekilled after emptying the directory but before removing its root) that fails safe today and whose message names--force. That trade deserves its own change with its own red test, not a rider on this one.WouldLoserejects an empty description whileCouldNotTell/GitRefusedaccept one.GitRefused("")is reachable (str(OSError()) == ""), but_gitalready substitutesf"git {args} exited {rc}"for an empty stderr, and no live defect was found. Left alone.Path("")isPosixPath('.'), so a record with an emptylocal_pathreports aboutdl's own working directory. Not taken, with a reason. It needs a hand-edited or truncatedmetadata.json, andremove_workspace_by_idalready falls back to the derived path for an emptylocal_path— so the guard would readdl's cwd while the delete removed a third directory. That is the guard-and-delete-disagree family, which is The unsaved-work guard and the delete can name different directories #174, and fixing half of it here would put a refusal and a listing on two different directories. The NUL-path half of the same NIT is taken, because it has one obvious right answer (CouldNotTell) and no such coupling.README.md'srmclaim — corrected rather than made true by code. Makingrmsay something when it declines a workspace with no record is a live option and a small one; it is not taken here because the message would also fire for everydl ./pathworkspace, where there is nothing to say, and the choice belongs with The unsaved-work guard and the delete can name different directories #174's work on what the record and the derived path mean.Not done
./dev.shwas not run — it rebinds a host-widedl-nextsymlink that concurrent work on this machine shares. Nothing was exercised against the real~/.cache/devlaunch; every fixture is built inside the worktree'stmp_path. No devpod workspace, container or clone on this machine was deleted.Not verified by execution on this head, and said as such:
pixi run cion 3.14.6 at88c1b4c— reported above as theirs, attributed, and not reproduced here;py311,py312andpy313legs locally — last run here on395e527, though GitHub's are green on this head. Locally this head was run ondefault(3.14.6) andpy310(3.10.20), both ends of the boundary this change is about.🤖 Generated with Claude Code