Skip to content

Ask git about the clone, not whichever repo it wandered into (#171) - #173

Merged
blooop merged 5 commits into
mainfrom
fix/171-clone-guard-ancestor
Aug 10, 2026
Merged

blooop merged 5 commits into
mainfrom
fix/171-clone-guard-ancestor

Conversation

@blooop

@blooop blooop commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Closes #171.

This revision (dc9c9c1) — the four text corrections, and one test

No behaviour changed. 395e527's review was Spec: approve, Standards: request changes with "No code defect found."

  1. The causal claim that the missing 3.14 leg is why the boundary was wrong is deleted — from workspace_state.py, the test docstring, ci.yml, and the CHANGELOG paragraph that chained the same "so". It is false: Standards ran pixi run ci on 3.14.6 at 88c1b4c, the revision carrying the wrong text, and got 1271 passed, exit 0. The leg stays, on the reason ci.yml already gave: it catches the reversion.
  2. The MD mutation row's conclusion is corrected. It said "green on 3.10 … so that mutation would have merged"; both axes measured py310 exit 1, 1 failed. The narrow claim — the two closed-door tests are red on 3.14 and green on 3.10 — replaces it here, in the table and in the holds_unsaved_work can answer about an ancestor repository and report a dirty clone as safe to delete #171 breadcrumb.
  3. read_clone's ValueError paragraph had its referents inverted ("total over the first and not over the second"); it now names OSError and ValueError rather than counting.
  4. ci.yml cited a CONTRIBUTING file that does not exist; the instruction is at README.md's Development section.

Plus the NITs: the reviewer's surviving green mutation (skip read_clone for an absent clone directory) now has a test row, red under it; README's rm lead says recorded clone; _measurable_clone's docstring describes all four fields it answers; the orphaned wrap in ci.yml is closed; and ci.yml records that default is the only unpinned leg, so the matrix loses 3.14 the day 3.15 solves.

What shipped

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. It was live in merged main (39d4d2a), 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 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 .git is 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:

clone shape plain cwd= (shipped) --git-dir/--work-tree (this PR)
.git dir holding garbage branch=main, status=[] — the ancestor's fatal: not a git repository
empty .git dir branch=main, status=[] fatal: not a git repository
.git with HEAD and nothing else branch=main, status=[] fatal: not a git repository
real clone, .git/objects deleted branch=main, status=[] fatal: not a git repository
truncated gitfile fatal (both) — never part of the bug fatal
healthy clone answers answers — unchanged
linked worktree (.git is a gitfile) answers answers — git follows the 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 under cwd= too. Re-executed here on six gitfile variants (gitdir: , gitdir:, empty, garbage, a gitdir: naming a path that is not there, a gitdir: naming a directory that is not a repository) — all six rc=128 under plain cwd=. 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_DIRECTORIES was 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-dir fails closed.

--work-tree is not decoration beside --git-dir, and there is a test that goes red without it. core.worktree in a clone's own config points the work tree at another directory, and --git-dir alone honours it — so git status --porcelain compares 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 are README.md and feature.txt and which holds an untracked an-hour-of-work.md:

# other work tree EMPTY -- does not hold HEAD's files
$ git --git-dir=<clone>/.git status --porcelain          # --work-tree dropped
 D README.md
 D feature.txt
rc=0

# other work tree MIRRORS HEAD -- a second checkout of the same commit
$ git --git-dir=<clone>/.git status --porcelain          # --work-tree dropped
rc=0                                                     # ...and nothing printed

# either shape, with the flag
$ git --git-dir=<clone>/.git --work-tree=<clone> status --porcelain
?? an-hour-of-work.md
rc=0

The empty shape is a WouldLose naming 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 is NothingToLose() on a clone holding an hour of work that exists nowhere else. The old test built the empty shape, so under the mutation its isinstance(..., 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 = true is the neighbouring shape and is not a third case: --git-dir alone answers fatal: this operation must be run in a work tree at 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 were None, and None meant delete freely. It is now a total sum:

Unsaved = Union[NothingToLose, WouldLose, CouldNotTell]

could-not-tell refuses the delete exactly as would-lose does, and says --force. unhandled_unsaved (the shape disk_usage._unhandled_usage already uses) makes a fourth arm a crash rather than consent.

The existing docstrings are answered rather than ignored. read_clone said "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 refused git status as a clean tree; _git returns GitSaid/GitRefused so empty output and no output stop looking alike).

A clone dl cannot look at is a third refusal, and Path.is_dir() had no way to say so. With the clone's parent at mode 000 it gave a different wrong answer depending on the interpreter: it re-raises PermissionError (so dl <ws> rm failed closed by crashing, and dl --ls --json became a traceback for the whole listing because of one workspace) up to and including 3.13, and on 3.14 returns False, which read as "not there, so nothing to lose" — a clone that may be full of work, reported as free to delete. read_clone now reads the errno: ENOENT and ENOTDIR mean there is no clone there, everything else is a CouldNotTell. ValueError is caught alongside OSError, because os.stat is total over OSError and not over ValueError: 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-000 parent against Path.is_dir():

interpreter Path.is_dir() on a clone behind a mode-000 parent
3.10.20 raises PermissionError
3.11.15 raises PermissionError
3.12.13 raises PermissionError
3.13.14 raises PermissionError
3.14.6 returns False

Patch 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.yml ran ci on py310–py313 only. The default environment resolves the unpinned python = ">=3.10"3.14.6 — and ran only test-e2e. So the leg where is_dir() returns False, the fail-open one this class exists for, was exercised by no GitHub ci job, and pixi 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 ran pixi run ci on 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 calls is_dir(), so a ci (default) leg would have been green on the wrong boundary too. What caught it was a reviewer asking for the mode-000 parent to be executed. Those causal sentences are deleted from workspace_state.py, the test docstring, ci.yml and 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.yml already gave a paragraph earlier: it catches the reversionclone.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.

default rather than a new py314 feature: 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

Breaking, in dl --ls --json: unsaved was a string or null; it is now {"nothingToLose": true} | {"wouldLose": "<what>"} | {"couldNotTell": "<why>"} — the shape disk already 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. null at the JSON surface means one thing (review's S1)

The previous revision of this branch left unsaved gating on the metadata record while its neighbour disk gated on _measurable_clone — the clone directory. A clone under the cache that dl has 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: true and a measured disk are dl saying this is my clone, beside an unsaved: null documented as there is no clone of dl's here to inspect. So null still 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 for disk.

Fixed rather than documented away, on the evidence that this is the same bug as #165 and was answered there the same way: unsaved, checkedOut and path describe _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_clone for devlaunch, and a record-or-clone expression for the directory — and deleting the ownership gate on the record lookup left the whole suite green. _measurable_clone is asked once, mine is defined as clone_path is not None, and a record can only move the row from one directory of dl's to another:

clone_path = _measurable_clone(ws, cache_dir)
mine = clone_path is not None
record = clone_mgr.storage.get_worktree_by_workspace_id(ws.id) if mine else None
if record is not None:
    clone_path = Path(record.local_path)

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 — a config.toml pointing repos_dir outside the cache makes dl's clones read as someone else's, while the records dl wrote for them survive. Under the old form that row printed a wouldLose about a directory the same row called path: null, on a checkout dl has 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_clone for 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 same null carrying the same two meanings. It has a row now: an absent directory is NothingToLose() — which is exactly what lets rm clear away a workspace whose clone was removed by hand — so the listing has to say so too, and TestTheJsonListing asserts it. Mutation MI below is that edit, executed on this head: 1 failed, and the failing line is the unsaved assertion, not the path one.

README and CHANGELOG said "null only where repo and branch are"; that was the false claim and both now say "null exactly where devlaunch is false", with the weaker record-based test named as not the same set.

5. What rm does not cover, said out loud

README.md claimed "dl <workspace> rm refuses 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 under dl's cache with no metadata record, holding work. dl --ls --json now correctly reports wouldLose for it (that is item 4 above). rm does not refuse — the guard reads the record, finds none, and "dl has no record of a clone here" is answered NothingToLose. So the devpod workspace goes, the clone stays, exit 0, no --force asked 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_id returns False without touching the disk. But it is not a refusal either, and dl.py states 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's remove_workspace_by_id teaches both halves of the real one (False and no removal with no record; removal with one) rather than returning a truthy Mock that 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.py at 2fde002), ancestor repo clean and fully pushed — inherited from the first revision of this PR, not re-run here:

ancestor status:   []
ancestor unpushed: []

read_clone         -> CloneState(branch='main', unsaved=None)
holds_unsaved_work -> None
TRUTH: clone dir holds ['.git', 'scratch.md']

On GitHub, this head (dc9c9c1) is green on all eleven checksci (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. ruff All checks passed!, pylint 10.00/10, ty All checks passed! on each. workspace_state.py at 100% statement coverage on both. py311, py312 and py313 were not re-run locally on this head — GitHub's ci (py311), ci (py312) and ci (py313) legs are green on dc9c9c1, and their last local run was on 395e527 at 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 395e527 against 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 since 395e527 is 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.

# Mutation Result Verdict
MB _git drops --work-tree, keeps --git-dir 2 failed, 1274 passed, 23 deselected — exit 1. Fails on isinstance(..., WouldLose) with NothingToLose(), and on the clean clone reported as WouldLose('2 uncommitted change(s) (README.md, feature.txt)') red
MC --ls --json looks up the record regardless of ownership (drops if mine else None) 1 failed, 1275 passed, 23 deselected — exit 1, and it is the new leftover-record test red
MD read_clone goes back to Path.is_dir() inside the existing try default (3.14.6): 3 failed, 1273 passed — exit 1. py310: 1 failed, 1275 passed — exit 1 as well; the two closed-door tests pass on py310 and the failure there is test_a_recorded_path_that_is_not_a_path_at_all_is_could_not_tell red on both, for different tests
ME read_clone goes back to Path.is_dir() with the try removed — the pre-PR expression 3 failed, 1273 passed — exit 1 on both default and py310 red
MF the test double's remove_workspace_by_id reverts to the MagicMock default (truthy, removes nothing) 1 failed, 1275 passed, 23 deselected — exit 1 red
MG _unsaved_work_in answers CouldNotTell for a missing record — i.e. rm refuses instead 4 failed, 1272 passed, 23 deselected — exit 1 red
MH read_clone catches OSError only, not ValueError 1 failed, 1275 passed, 23 deselected — exit 1 red
MI --ls --json skips read_clone when os.stat on the clone path says ENOENT/ENOTDIR — "don't shell out to git for a directory that isn't there" 1 failed, 1276 passed, 23 deselected — exit 1 (default, 3.14.6), and the failure is the new absent-clone test on its unsaved line: AssertionError: assert None == {'nothingToLose': True} red
MA --ls --json's unsaved/path/checkedOut gate on the metadata record again (review S1) 2 failed, 1269 passed (inherited) red
M1 _git drops --git-dir/--work-tree, runs with cwd= alone — the shipped bug 4 failed, 1261 passed (inherited) red
M2 _unsaved answers NothingToLose() when git status refuses 7 failed, 1258 passed (inherited) red
M3 _unsaved swallows a refused git log … --not --remotes (treats it as GitSaid("")) 1 failed, 1264 passed (inherited) red
M4 delete guard drops its CouldNotTell arm — falls through to the delete 2 failed, 1263 passed (inherited) red
M5 _unsaved_work_in answers NothingToLose() when the record cannot be read 1 failed, 1264 passed (inherited) red
M6 unsaved_as_json renders CouldNotTell as {"nothingToLose": true} 2 failed, 1263 passed (inherited) red
M7 an absent directory becomes CouldNotTell — over-refusal 3 failed, 1262 passed (inherited) red

MD'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 failedtest_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's Path.is_dir() catches ValueError and returns False. So ci (py310) would have been red and MD would not have merged. That claim was true of 88c1b4c, which had no NUL test, and false of 395e527, 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 the default leg 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 the try as well. MD keeps the try, and at the closed-door tests it is red on 3.14 only — on 3.10 is_dir() re-raises into except (OSError, ValueError) and the answer is still CouldNotTell. That is the whole of what the default leg 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 status succeeds (it never reads remote-tracking refs), so the repository probe passes and the clone reads clean right up until git log … --not --remotes refuses 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

  • S2 — the guard and the delete can name different directories. _unsaved_work_in reads record.local_path; remove_workspace_by_id falls back to the derived path when local_path is not on disk and rmtrees that. Pre-existing, a different mechanism, and filed as The unsaved-work guard and the delete can name different directories #174. Not widened here.
  • S3 — an empty leftover directory needs --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 (rmtree killed 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.
  • NIT — WouldLose rejects an empty description while CouldNotTell/GitRefused accept one. GitRefused("") is reachable (str(OSError()) == ""), but _git already substitutes f"git {args} exited {rc}" for an empty stderr, and no live defect was found. Left alone.
  • NIT — Path("") is PosixPath('.'), so a record with an empty local_path reports about dl's own working directory. Not taken, with a reason. It needs a hand-edited or truncated metadata.json, and remove_workspace_by_id already falls back to the derived path for an empty local_path — so the guard would read dl'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's rm claim — corrected rather than made true by code. Making rm say 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 every dl ./path workspace, 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.sh was not run — it rebinds a host-wide dl-next symlink that concurrent work on this machine shares. Nothing was exercised against the real ~/.cache/devlaunch; every fixture is built inside the worktree's tmp_path. No devpod workspace, container or clone on this machine was deleted.

Not verified by execution on this head, and said as such:

  • that the boundary holds at patch levels other than the five run (3.10.20, 3.11.15, 3.12.13, 3.13.14, 3.14.6);
  • Standards' green pixi run ci on 3.14.6 at 88c1b4c — reported above as theirs, attributed, and not reproduced here;
  • mutation rows MA–MH and M1–M7, executed on earlier revisions and labelled so in the table. Only MI was executed on this head;
  • the py311, py312 and py313 legs locally — last run here on 395e527, though GitHub's are green on this head. Locally this head was run on default (3.14.6) and py310 (3.10.20), both ends of the boundary this change is about.

🤖 Generated with Claude Code

`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

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Sorry @blooop, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@sourcery-ai

sourcery-ai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Reviewer's Guide

Fixes 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 guard

sequenceDiagram
    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
Loading

File-Level Changes

Change Details Files
Make git repository discovery fail-closed by explicitly binding all git commands to the clone’s .git directory and work tree, and model git’s answers as an explicit success/refusal sum type.
  • Introduce GitSaid/GitRefused and GitAnswer union to distinguish successful git output from refusals instead of using Optional[str].
  • Change _git to resolve the clone path and invoke git with --git-dir=/.git and --work-tree=, keeping cwd but disabling discovery; return GitRefused on subprocess errors and non-zero exit codes with a reason string.
  • Update _unsaved and read_clone to interpret GitRefused as CouldNotTell and GitSaid as normal output, ensuring refusals no longer look like clean trees.
devlaunch/workspace_state.py
Replace the Optional[str]-based unsaved-work representation with a total Unsaved sum type (NothingToLose, WouldLose, CouldNotTell) and propagate it through workspace_state, dl’s delete guard, and JSON rendering.
  • Define dataclasses NothingToLose, WouldLose, CouldNotTell and Unsaved union; enforce non-empty descriptions in WouldLose.post_init and add unhandled_unsaved helper for exhaustiveness checks.
  • Change CloneState.unsaved type from Optional[str] to Unsaved and adjust holds_unsaved_work/read_clone/_unsaved to always return an Unsaved arm, with absent directories mapped to NothingToLose.
  • Add unsaved_as_json to render Unsaved as one-key JSON objects, and use it in workspaces_as_json; update _unsaved_work_in to return NothingToLose for missing records and CouldNotTell for record read failures.
  • Update dl’s rm handler to branch explicitly on WouldLose, CouldNotTell, and NothingToLose, calling unhandled_unsaved for any future unmatched arm.
devlaunch/workspace_state.py
devlaunch/dl.py
Extend and restructure tests to cover broken clone shapes, ancestor-repo discovery, CouldNotTell behavior, JSON shape changes, and delete-guard behavior including force overrides and exhaustiveness checks.
  • Add fixtures for ancestor repos and broken clones under ancestors, plus tests that verify git cannot escape the clone, that branches are not borrowed from ancestors, and that non-repo directories produce CouldNotTell.
  • Update existing tests to assert on Unsaved types (NothingToLose/WouldLose/CouldNotTell) instead of truthiness of strings, and add tests for WouldLose validation and unsaved_as_json output.
  • Add tests that dl --ls --json emits the new unsaved object shape, that broken clones and unreadable records surface CouldNotTell without reporting nothingToLose, and that dl rm refuses on both WouldLose and CouldNotTell, allows --force, and crashes on unknown arms via unhandled_unsaved.
  • Update test module docstring to mention the new discovery bug caught by real git tests.
test/test_workspace_state.py
Document the behavioral and JSON-shape changes for unsaved work reporting and delete-guard semantics, including the new CouldNotTell arm and the fix for git discovery escaping the clone. README.md
CHANGELOG.md

Assessment against linked issues

Issue Objective Addressed Explanation
#171 Ensure git repository discovery cannot escape the clone directory so that an unusable .git yields a refusal instead of answers about an ancestor repository.
#171 Represent a refusal to determine unsaved work explicitly and make destructive operations fail safe by distinguishing nothing-to-lose, would-lose, and could-not-tell instead of conflating them via Optional[str].
#171 Strengthen tests so the guards are proven by mutation (reintroducing specific faults causes test failures), particularly around the could-not-tell paths.

Tips and commands

Interacting with Sourcery

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

Customizing Your Experience

Access your dashboard to:

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

Getting Help

@codecov

codecov Bot commented Aug 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.95%. Comparing base (39d4d2a) to head (b883c4a).
⚠️ Report is 6 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@            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     
Files with missing lines Coverage Δ
devlaunch/dl.py 93.43% <100.00%> (+0.34%) ⬆️
devlaunch/workspace_state.py 100.00% <100.00%> (+6.97%) ⬆️

Impacted file tree graph

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@blooop blooop left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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 39d4d2a and this head. The ticket's repro: shipped gives CloneState(branch='main', unsaved=None) — the ancestor's branch — while this head gives CouldNotTell("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 where status succeeds but log refuses (shipped: unsaved=None).
  • The refusal fails safe end to end. Six rm scenarios through main() 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 --force needed. No path found where a CouldNotTell reaching the guard permits deletion.
  • The exhaustiveness is real, not decorative. Standards: dropping the CouldNotTell arm from either consumer is a ty error (Expected Never, found CouldNotTell & ~NothingToLose & ~WouldLose). Spec's X10 is the headline — an arm that logs the refusal but forgets return 1 is rejected by ty, not merely by a test. That is constructive modelling actually paying.
  • The GIT_CEILING_DIRECTORIES rejection holds. Standards found four near-miss forms that fail open while --git-dir refuses, including GIT_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.

  1. S1 — make --ls --json's unsaved ask _measurable_clone as disk does, or drop the "and only there" claim. As it stands the ticket's own sentinel survives at the JSON surface.
  2. --work-tree — add a test that goes red without it; the core.worktree shape is the one that demonstrates why.
  3. Two docstrings — correct _git's "all five answered about the ancestor" to four of five, and drop CloneState's "every case where unsaved is a CouldNotTell".
  4. 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 blooop left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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 with devlaunch: true and unsaved: 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_git now 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, and core.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 rm cases through main(), all fail-safe, including the pinned one: absent clone, no --force, exit 0, not regressed; and chmod 000 parent 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.

NITsos.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:

  1. s/3.13+/3.14+/ in workspace_state.py:303, test_workspace_state.py:341, CHANGELOG.md:288 — and either add a py314 ci leg or stop saying the suite runs on both.
  2. Rebuild the --work-tree test on the mirrored-work-tree shape so it pins the safety property rather than two filenames.
  3. Restructure dl.py:511 so the equivalence holds by construction and the surviving mutation becomes unwritable.
  4. Correct README.md:481, or make rm say 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 blooop left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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 at test_workspace_state.py:362 with AssertionError: 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: neither Path nor CloneState is ever falsy.
  • README.md:481 — behaviour matches the new text exactly (workspace deleted, exit 0, no --force, clone and its an-hour-of-work.md still on disk), and it is pinned: restoring the old claim gives 4 failed.
  • Version boundary3.14+ in all three places, default in 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 failedtest_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.

blooop added 2 commits August 10, 2026 14:43
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.
@blooop
blooop merged commit bb7f821 into main Aug 10, 2026
12 checks passed
@blooop
blooop deleted the fix/171-clone-guard-ancestor branch August 10, 2026 14:15
blooop added a commit that referenced this pull request Aug 14, 2026
`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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

holds_unsaved_work can answer about an ancestor repository and report a dirty clone as safe to delete

1 participant