Repository navigation
holds_unsaved_work can answer about an ancestor repository and report a dirty clone as safe to delete #171
Description
Type: build
Map: #139
holds_unsaved_work — the one judgement dl makes before destroying a directory — can answer about a different repository and report "nothing would be lost" while the clone holds unsaved work. This is live in merged main (39d4d2a), not only in the unmerged --prune work.
The mechanism
workspace_state._git runs git … cwd=repo with no --git-dir, no --work-tree, and no ceiling. Git's repository discovery therefore walks up the parent chain. When the clone directory exists but its .git is unusable — broken, truncated, half-removed by an interrupted delete — git does not refuse. It finds an ancestor repository and confidently answers about that one.
read_clone's docstring says "a directory that is not there, or is not a repository, holds nothing", but the code never asks whether it is a repository; it asks git a question and trusts the reply. So the "cannot tell" path that _git's docstring carefully describes — "a refusal is 'cannot tell', never an answer … must never be reported as safe to delete on the strength of a failed command" — is unreachable here, because the command succeeds.
Reproduction (executed against origin/main:devlaunch/workspace_state.py)
An ancestor repo that is clean and fully pushed (the dotfiles-in-$HOME case, which is common), with a workspace clone nested under it whose .git is garbage and which holds an untracked scratch file:
ancestor repo: clean and fully pushed (git status --porcelain and
git log main --not --remotes both empty)
read_clone -> CloneState(branch='main', unsaved=None)
holds_unsaved_work -> None
TRUTH: clone dir holds ['.git', 'scratch.md'] <- scratch.md would be destroyed
branch='main' is the ancestor's branch, not the clone's. With unsaved=None, dl <ws> rm does not refuse and does not require --force.
Note the near-miss that hides this: if the ancestor has any unpushed commit or dirty file, the guard fires — for entirely the wrong reason, describing the wrong repository — and the delete is refused. So the failure only shows itself on a tidy host, which is the host most likely to be trusted.
Independently found from the coverage side during review of PR #170: two of the three CouldNotAsk producers in the --prune work (workspace_state.py:242, :253) are uncovered, and mutating each to report "clean" leaves the full suite green.
What the fix has to achieve
- Discovery cannot escape the clone. Ask git explicitly about this directory —
--git-dir/--work-tree, orGIT_CEILING_DIRECTORIES— so an unusable.gitproduces a refusal rather than an answer about somebody else's repository. - A refusal must be representable and must fail safe. Today
Optional[str]conflates "nothing to lose" with "could not tell": both areNone, andholds_unsaved_workreturningNonemeans delete freely. That is the sentinel principle 3 forbids, and it is load-bearing for a destructive operation. A total sum — nothing-to-lose / would-lose(description) / could-not-tell — makes the dangerous conflation unrepresentable, andcould-not-tellmust refuse the delete exactly aswould-losedoes. - Guards proven by mutation, not by green. The existing tests pass with the bug present. Each new guard must be shown red under a mutation that reintroduces the fault.
Blast radius
holds_unsaved_work is the guard for dl <ws> rm; --purge and the unmerged --prune (#159) stand on the same reading. #159 is parked on this finding — but this ticket is deliberately not gated behind that PR, because the bug ships today.
Activity
blooop commentedon Aug 10, 2026
breadcrumb: built test-first in PR #173.
Reproduced the ancestor walk against
origin/mainfirst (clean, fully pushed ancestor →read_clone -> CloneState(branch='main', unsaved=None)while the clone heldscratch.md), then wrote the failing test, then fixed it.--git-dir=<clone>/.git --work-tree=<clone>, which switches discovery off rather than bounding it. Verified against real git 2.55.0 on five broken-clone shapes (garbage.git, empty.git, HEAD-only.git, real clone with.git/objectsdeleted, truncated gitfile) — all four of the first answered about the ancestor before and refuse now; a healthy clone and a linked worktree (gitfile.git) still answer normally.GIT_CEILING_DIRECTORIESwas tried and rejected: it fails open back to the ancestor when its path does not match what git resolved, where--git-dirfails closed.Unsaved = NothingToLose | WouldLose(what) | CouldNotTell(why), every caller names its arm, and could-not-tell refusesrmexactly as would-lose does. The same sentinel one layer down went too —if status:was reading a refusedgit statusas a clean tree, so_gitnow returnsGitSaid/GitRefused.dl <ws> rm,dl --ls --json(breaking:unsavedis now an object with one key naming the arm), and_unsaved_work_in, where an unreadable record was the same conflation one level up.--purgewas checked and does not consult this reading —purge_all_datadeletes on the ownership predicate after confirmation. The docstring claim that it does is inherited and wrong; dl --prune: remove clone directories no workspace references #159's--prunedoes read it and gets the new arm when it rebases.-k; baseline 1265 passed): discovery restored → 4 failed; refusedstatusread as clean → 7 failed; refusedgit logswallowed → 1 failed; guard drops theCouldNotTellarm → 2 failed; unreadable record → 1 failed; JSON renders could-not-tell as nothing-to-lose → 2 failed; absent directory over-refused → 3 failed. All red.workspace_state.pyis at 100% statement coverage — the two uncoveredCouldNotTellproducers were the shape flagged in review of #170, so they were covered rather than left.pixi run ciandpixi run -e py310 ciboth green (pylint 10.00/10, ty clean)../dev.shwas not run: it rebinds a host-widedl-nextsymlink that concurrent work shares.blooop commentedon Aug 10, 2026
breadcrumb — correction to this ticket. Its Blast-radius paragraph says the bug ships in
dl <ws> rmand--purge. The second half is wrong, and I wrote it. Verified by execution on both mergedmainand the fix branch: instrumentingholds_unsaved_workand runningmain(["--purge","-y"])produces zero calls —purge_all_datausesworkspace_ownership+remove_treeonly.--purgeis unguarded against unsaved work by design (#131: one y/N and the cache goes), so this is a documentation error rather than a second hole. The real consumers aredl <ws> rm,dl --ls --json, and the unmerged--prune(#159).I inherited "almost certainly" from a reviewer and passed it on as fact without checking. The bug itself was reproduced by execution; its reach was not.
Say null once, pin the work tree, and stop overstating the evidence
blooop commentedon Aug 10, 2026
breadcrumb — two-axis review of
b92c67baddressed in PR #173, head88c1b4c. Both axes said the fix itself is sound; nothing in it was rewritten. Four bounded items, each shown red under a mutation re-running the full CI selection (pixi run ci, never-k).S1 —
nullstill meant two things at the JSON surface.dl --ls --jsongatedunsavedon the metadata record while its neighbourdiskgated on_measurable_clone— the clone directory. A clone under the cache with no record printeddevlaunch: truewith a measureddiskbesideunsaved: null, andnullis documented as no clone of dl's here to inspect. That is this ticket's own sentinel surviving one layer out, and the same divergence PR #165 closed fordisk. Fixed rather than documented away:unsaved,pathandcheckedOutfall back to_measurable_clonewhen there is no record, sounsaved: nullanddevlaunch: falseare the same set by construction (is_devlaunch_clonetrue ⇒LocalFoldersource ⇒_measurable_clonereturns a path). The record is still preferred when there is one, because that is the directorydl <ws> rm's guard reads and the listing must not name a third. README and CHANGELOG said "null only whererepoandbranchare"; both now say "null exactly wheredevlaunchis false".--work-treewas load-bearing and unguarded. Dropping it while keeping--git-dirleft the whole suite green — reviewer's finding, reproduced here from the other side as MB below failing exactly one test, the new one. It is not redundant: withcore.worktreeset to another directory in the clone's own config, real git 2.55.0 givesgit --git-dir=<clone>/.git status --porcelain→ empty at rc 0 on a clone holding an untracked file, i.e. "nothing to lose" about a directory nobody asked about. Its neighbourcore.bare = truefails closed (fatal: this operation must be run in a work tree), which is why only thecore.worktreeshape demonstrates the flag matters. Both executed here; a test now builds thecore.worktreeshape and goes red without the flag.Two docstrings overstated their own evidence, and both are corrected.
_gitsaid "all five refuse here; all five answered about the ancestor under plaincwd=" — the second clause is false. Re-executed on six truncated/invalid gitfile variants: all six rc=128 under plaincwd=as well, because git treats an unreadable gitfile as a hard error rather than continuing discovery upward. That shape was never part of the bug. The PR body's table always said this; the prose and the docstring did not, and now both say four of five.CloneStateassertedbranchisNone"in every case whereunsavedis aCouldNotTell" — falsified by executing the shape its own new test builds:CloneState(branch='feature', unsaved=CouldNotTell('… bad object origin/bogus')). The behaviour is right; the invariant was written in the same commit as the test disproving it, and is gone.S4 (pre-existing) fixed, and it was worse than reported.
read_cloneaskedPath.is_dir(), which cannot express "I was not allowed to look" — and gives a different wrong answer per Python, both confirmed by execution on this machine: 3.10.20 raisesPermissionError(sodl <ws> rmfails closed by crashing anddl --ls --jsonbecomes a traceback for the whole listing because of one workspace), 3.14.6 returnsFalse, which reads as "not there, so nothing to lose" — a clone that may be full of work, reported as free to delete. Those are the two Pythons CI runs. The errno is now read directly: ENOENT/ENOTDIR mean no clone there, everything else is aCouldNotTell.S3 left, with a reason rather than by omission. An empty leftover directory still needs
--force. Changing it would widen the delete-freely set inside the fix for a fail-open bug, on a shape that fails safe today and whose message names--force. That deserves its own change with its own red test.S2 not widened — the guard and the delete naming different directories is pre-existing, a different mechanism, and is #174.
The
--purgecorrection re-verified on the new head, not taken on trust. Instrumentedholds_unsaved_workand ranmain(["--purge","-y"])against a cache holding a broken clone with an untracked file: 0 calls, exit 0, clone gone.purge_all_datadeletes on the ownership predicate after one y/N, by design (#131). This ticket and map #139 were already corrected; nothing in the PR changes--purge.Mutation evidence, each re-running the full
pixi run ci(baseline on the new head: 1271 passed / 23 deselected, exit 0):--ls --json'sunsaved/path/checkedOutgate on the metadata record again_gitdrops--work-tree, keeps--git-dirread_clonegoes back toPath.is_dir()MB failing exactly one test — the new one — is the reviewer's "survives the full suite green" reproduced from the other side.
pixi run ciandpixi run -e py310 ciboth exit 0 on the new head../dev.shwas not run, nothing touched the real~/.cache/devlaunch, and no devpod workspace, container or clone on this machine was deleted.blooop commentedon Aug 10, 2026
breadcrumb: PR #173, head
395e527(was88c1b4c). All four Standards items closed; no change to what the fix does.1. The boundary is 3.14, not 3.13 — and the leg it lives on is now in CI. Re-executed independently on this repo's own environments,
Path.is_dir()on a clone behind a mode-000parent: 3.10.20, 3.11.15, 3.12.13, 3.13.14 raisePermissionError; 3.14.6 returnsFalse. Fixed inworkspace_state.py,test_workspace_state.pyand CHANGELOG. Patch levels between those were not run and the text says so..github/workflows/ci.ymlnow runsciondefault(3.14.6) as well as py310–py313.defaultrather than a newpy314feature: no lockfile change, and it tracks whatever the newest solvable Python is. Executed proof the leg is load-bearing — withpresent = clone.is_dir()put back inside the existingtry: on 3.14 3 failed / 1273 passed, on 3.10 1 failed / 1275 passed. Both exit 1. What differs is which tests: the two closed-door tests are red on 3.14 and green on 3.10, because on 3.10is_dir()re-raises into theexcept (OSError, ValueError)arm — while 3.10's one failure is the NUL-path test added in this same push, since CPython 3.10'sPath.is_dir()catchesValueErrorand returnsFalse. Soci (py310)would have been red and that mutation would not have merged.2. The
--work-treetest now pins the safety property. Re-executed at git 2.55.0 on the exact fixture: with the other work tree empty,--git-diralone prints" D README.md\n D feature.txt"at rc 0 — aWouldLoseabout the wrong files, a refusal. The fail-open needs the other 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 the mutation now fails onisinstance(..., WouldLose)(AssertionError: NothingToLose()); a second test pins that a clean clone is not reported dirty by the other tree's absences. Both go red: 2 failed / 1274 passed. Both docstrings corrected to state the two outcomes separately.3. "Same set by construction" is now actually by construction, and the mutation is red.
clone_path = _measurable_clone(...),mine = clone_path is not None, record overwrites only when there is one. The input that broke the two-gate form has a test — a record that outlives the reason its workspace counts as dl's (repos_dirmoved outside the cache). Droppingif mine else None: 1 failed / 1275 passed, and it is that test.4.
README.mdcorrected. A dl-owned clone with no record: the listing reportswouldLose,rmexits 0 and orphans the clone. README now says that instead of "refuses". Pinned by a test, and the test double'sremove_workspace_by_idnow teaches both halves of the real one instead of returning a truthyMock— reverting it to theMagicMockdefault is 1 failed / 1275 passed. Not fixed in code: the message would also fire for everydl ./pathworkspace, and the choice belongs with #174.NITs:
except (OSError, ValueError)taken — a NUL byte in a recorded path is aValueErrorbefore the syscall and took the whole listing down for one bad record; test red on 3.10 and 3.14 alike (1 failed / 1275 passed).Path("")not taken:remove_workspace_by_idalready falls back to the derived path for an emptylocal_path, so the guard would read dl's cwd while the delete removed a third directory — that is #174's family, and fixing half of it here would put the refusal and the listing on two different directories.Green:
pixi run ciexit 0, 1276 passed / 23 deselected, on all five environments the matrix now runs (py310, py311, py312, py313, default 3.14.6).pixi run prekexit 0.workspace_state.py100% on 3.10 and 3.14.One process note, because it nearly cost a row. Restores between mutations were sha256-verified snapshot copies, never
git checkout. This scratchpad is shared with other sessions, and one of them overwrote a generically-namedrestore.shmid-run — so one mutation was executed with the previous mutation still applied. The checksum check caught it, that row was discarded and re-run from a verified snapshot, and the script and snapshot now carry unique names with the snapshot verified before every restore. Worth knowing for anyone else running a mutation harness out of this directory.Not widened to #174 (guard and delete naming different directories) or #159's rebase hazard.
blooop commentedon Aug 10, 2026
breadcrumb: PR #173, head
dc9c9c1(was395e527). Text only — no behaviour changed, and the review that asked for these said so explicitly ("No code defect found").1. Deleted the causal claim that the missing 3.14 leg is why the boundary was wrong. It is false, and it was falsified by execution: Standards checked out
88c1b4c— the revision carrying the "3.13+" text — and ranpixi run cion 3.14.6: 1271 passed, exit 0, green. Aci (default)leg would have been green on the wrong boundary, because these tests assert version-independent behaviour and none of them callsis_dir(). What caught it was a reviewer asking for it to be run. Removed fromworkspace_state.py,test_workspace_state.py,ci.ymland the CHANGELOG paragraph that chained the same "so". The leg stays — it catches the reversion, which is the reasonci.ymlalready gave a paragraph earlier.2. Corrected the MD mutation row. It read "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 that same push, because CPython 3.10'sPath.is_dir()catchesValueErrorand returnsFalse.ci (py310)would have been red; it would not have merged. True of88c1b4c, false of395e527. The narrow claim now stated everywhere — PR body, table, and the corrected comment above: the two closed-door tests are red on 3.14 and green on 3.10.3.
read_clone'sValueErrorparagraph had its referents inverted — "total over the first and not over the second" said the opposite of the truth and of its own next clause. It now namesOSErrorandValueErrorinstead of counting.4.
ci.ymlcited aCONTRIBUTINGfile that does not exist. There is noCONTRIBUTING*in the repo; the instruction is in README's Development section, which is what it now cites.The surviving green mutation now has a test. Skipping
read_clonefor a clone directory that is genuinely absent — a plausible "don't shell out to git for a directory that isn't there" — was green and produced{"devlaunch": true, "path": "<dir>", "unsaved": null}.TestTheJsonListinggained a row: an absent directory isNothingToLose(), which is what letsrmclear away a workspace whose clone was removed by hand, so the listing has to say so too. Under that mutation, executed on this head: 1 failed / 1276 passed, exit 1, and the failing line is theunsavedassertion (assert None == {'nothingToLose': True}), not thepathone.Other NITs taken: README's
rmlead now says the recorded clone, which is the directory the guard reads (the paragraph 18 lines below already corrected it);_measurable_clone's docstring describes all four fields it now answers rather than only--size; the orphaned line-wrap inci.ymlis closed; andci.ymlrecords thatdefaultis the only unpinned leg, sopixi updatemoves it unreviewed and the matrix loses 3.14 the day 3.15 solves —frozen: truekeeps each commit reproducible, so it is a lockfile-review note, not a hole.Gate 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 / pylint 10.00 / ty clean on each.workspace_state.py100% on both.On GitHub: all eleven checks pass on
dc9c9c1, including all fivecilegs andgate.Not executed locally on this head, and said so: py311/py312/py313 (green on GitHub for this head; last local run was
395e527); Standards' green run at88c1b4c, attributed to them above and not reproduced here; every mutation row except the new one. Snapshot restores were sha256-verified and uniquely named, per the note above../dev.shwas not run and no devpod workspace, container or clone was deleted.The pattern is worth naming, since this is the fourth round of it. Items 1 and 2 were both introduced by the round that fixed the previous instance — a sentence written one step past what was executed, each time in a commit about not doing that. This round the check applied to every "because", "only", "would have" and "cannot" was: was the thing it asserts actually run, and by whom. Where it was not, the narrower claim is written, and the attributions above are part of that.
Merge pull request #173 from blooop/fix/171-clone-guard-ancestor