The delete guard stops counting tags the remote carries - #486
Merged
Conversation
`dl <ws> rm` asked git for every commit in the clone that no remote-tracking ref contains, over every ref `--all` reaches, `refs/tags` included. Tag a release, merge the branch, delete it, and the tag is the last ref reaching those commits: no `refs/remotes/*` has them, so the guard reads a clean and fully pushed clone as holding work that exists nowhere else. One repository carries 265 such commits, which is what six of the eight workspaces on this host reported, none of it real. The doc comment called that the safe direction to fail in, a clone kept for the price of some disk. The measurement is what makes it the wrong way round here: the guard cannot be satisfied by pushing anything, on every workspace of that repository, forever. What it teaches is `--force`, and a habit of `--force` is the clone that really did hold an unpushed hour of work going the same way. `--exclude=refs/tags/*` binds to the `--all` after it and takes the tags out of that alone, so local branches, every worktree's HEAD including detached ones, and `refs/stash` are all still asked about. #471 and the stash keep their answers, and both are pinned by tests that fail if the ref set is narrowed any further than this. What is given up is a commit reachable only from a local tag, with no branch, worktree HEAD or stash in the clone naming it too. Closes #485
All three copies of the version together, the way 0.17.0's release commit did and 0.18.0's did not: `rust/Cargo.toml` is where it is written, and the conda badge and the `dl --version` transcript in the README restate it. `Cargo.lock` carries the four workspace members' own versions, so it moves too.
Reviewer's GuideThe PR fixes false-positive workspace deletion refusals caused by release tags by excluding Sequence diagram for the tag-aware workspace deletion guardsequenceDiagram
participant User
participant DL
participant Git
participant Remote
User->>DL: rm workspace
DL->>Git: unpushed_commits(clone)
Git->>Remote: git log --oneline --exclude=refs/tags/* --all --not --remotes
Remote-->>Git: refs/remotes/*
Git-->>DL: unpushed commits excluding local tags
alt no unpushed commits
DL-->>User: delete workspace
else unpushed branch, worktree HEAD, or stash
DL-->>User: refuse deletion
end
Flow diagram for preserved and excluded workspace refsflowchart LR
All["--all ref set"] --> Exclude["--exclude=refs/tags/*"]
Exclude --> Check["--not --remotes"]
Branches["Local branches"] --> Check
Worktrees["Worktree HEADs"] --> Check
Stash["refs/stash"] --> Check
Tags["refs/tags/*"] -. excluded .-> Check
Check --> Result["Unpushed work used by delete guard"]
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
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
…est has not got It said that excluding more of the ref set, or narrowing the question to the checked-out branch, would fail this test. Both pass it: the commit lands on the branch the fixture leaves checked out, so a ref set of `--branches` alone still finds it, which was measured rather than argued. No test moves, because the test asserts something true and worth asserting. The comment now says what that is, and names the two clone-state tests that do fail when the ref set narrows, since that is what the next person to narrow it will be looking for.
Four of the six workspaces reported 265 and the other two reported 268 and 269, which is in #485's own listing two paragraphs above the sentence that rounded them all to one number. 265 is the floor, not the figure, and a measurement written down wrong is worse than no measurement: it is the number the next reader checks the code against.
Excluding `refs/tags` buys #485 by giving up one case: a commit only an unpushed local tag reaches now reads as nothing to lose, so `dl rm` deletes it without asking and `--prune` without printing. Tag before a rewrite and move the branch off it and you are there, which is an ordinary enough habit to be worth a ticket rather than a paragraph, so #487 is open for it. No red before green here, because the commit changes no behaviour: the test asserts what the previous commit already does. What it adds is that the cost is declared where it is paid, and that the day #487 closes, a test fails and names itself as the one to delete. Checked the other way instead: with the exclusion removed the assertion fails, so it is pinned to the exclusion rather than passing for its own reasons. The doc comment now says why a smarter query cannot recover the case. Remote tracking refs carry no tags, so nothing inside a clone marks which of its tags arrived in a fetch; the bare cache does know, and is not a path this seam is handed.
This was referenced Aug 26, 2026
Merged
blooop
added a commit
that referenced
this pull request
Aug 29, 2026
…to lose The delete guard excludes `refs/tags/*` from the unpushed question, because a tag the remote carries on a branch it no longer has would otherwise read as hundreds of unpushed commits on every clone of that repository forever (#485, fixed in #486). The exclusion was right about that case and silently wrong about its neighbour: tag before a rewrite, move the branch off the tag, and the commit under it exists in one place on earth while `dl rm` deletes the clone without asking and `--prune` without printing. Nothing inside a clone can tell those two apart, because no remote-tracking ref carries a tag. What can is the bare mirror the clone was made from, which fetches `+refs/tags/*:refs/tags/*` forced and pruned: a tag it holds at the same object is a tag the remote had at the last sweep, and a tag it has not got, or holds at another object, was typed here. Those come back into the query by name, so the commits only they reach are counted like any other unpushed work, and a local tag pointing at a commit the remote already has still costs nothing. The bare path is threaded through the resolver that already answers which directory a record's clone is in: `ClonePathResolver::bare_path`, answered in production by `resolve_bare_path` beside `resolve_clone_path`, and named off the record's repository rather than off the clone directory. `--prune` passes the same mirror it already computes while walking the cache. Where there is none to ask, every tag counts, which keeps the clone: `BareCache` is two arms rather than an `Option<&Path>` so every caller that cannot name one has to write the word. No fourth `Unsaved` arm: its three arms are the three documented keys of `dl --ls --json`. What changed is what counts as unpushed, not the vocabulary. Closes #487.
blooop
added a commit
that referenced
this pull request
Aug 29, 2026
…to lose The delete guard excludes `refs/tags/*` from the unpushed question, because a tag the remote carries on a branch it no longer has would otherwise read as hundreds of unpushed commits on every clone of that repository forever (#485, fixed in #486). The exclusion was right about that case and silently wrong about its neighbour: tag before a rewrite, move the branch off the tag, and the commit under it exists in one place on earth while `dl rm` deletes the clone without asking and `--prune` without printing. Nothing inside a clone can tell those two apart, because no remote-tracking ref carries a tag. What can is the bare mirror the clone was made from, which fetches `+refs/tags/*:refs/tags/*` forced and pruned: a tag it holds at the same object is a tag the remote had at the last sweep, and a tag it has not got, or holds at another object, was typed here. Those come back into the query by name, so the commits only they reach are counted like any other unpushed work, and a local tag pointing at a commit the remote already has still costs nothing. The bare path is threaded through the resolver that already answers which directory a record's clone is in: `ClonePathResolver::bare_path`, answered in production by `resolve_bare_path` beside `resolve_clone_path`, and named off the record's repository rather than off the clone directory. `--prune` passes the same mirror it already computes while walking the cache. Where there is none to ask, every tag counts, which keeps the clone: `BareCache` is two arms rather than an `Option<&Path>` so every caller that cannot name one has to write the word. No fourth `Unsaved` arm: its three arms are the three documented keys of `dl --ls --json`. What changed is what counts as unpushed, not the vocabulary. Closes #487.
blooop
added a commit
that referenced
this pull request
Aug 29, 2026
…to lose The delete guard excludes `refs/tags/*` from the unpushed question, because a tag the remote carries on a branch it no longer has would otherwise read as hundreds of unpushed commits on every clone of that repository forever (#485, fixed in #486). The exclusion was right about that case and silently wrong about its neighbour: tag before a rewrite, move the branch off the tag, and the commit under it exists in one place on earth while `dl rm` deletes the clone without asking and `--prune` without printing. Nothing inside a clone can tell those two apart, because no remote-tracking ref carries a tag. What can is the bare mirror the clone was made from, which fetches `+refs/tags/*:refs/tags/*` forced and pruned: a tag it holds at the same object is a tag the remote had at the last sweep, and a tag it has not got, or holds at another object, was typed here. Those come back into the query by name, so the commits only they reach are counted like any other unpushed work, and a local tag pointing at a commit the remote already has still costs nothing. The bare path is threaded through the resolver that already answers which directory a record's clone is in: `ClonePathResolver::bare_path`, answered in production by `resolve_bare_path` beside `resolve_clone_path`, and named off the record's repository rather than off the clone directory. `--prune` passes the same mirror it already computes while walking the cache. Where there is none to ask, every tag counts, which keeps the clone: `BareCache` is two arms rather than an `Option<&Path>` so every caller that cannot name one has to write the word. No fourth `Unsaved` arm: its three arms are the three documented keys of `dl --ls --json`. What changed is what counts as unpushed, not the vocabulary. Closes #487.
blooop
added a commit
that referenced
this pull request
Aug 29, 2026
…to lose The delete guard excludes `refs/tags/*` from the unpushed question, because a tag the remote carries on a branch it no longer has would otherwise read as hundreds of unpushed commits on every clone of that repository forever (#485, fixed in #486). The exclusion was right about that case and silently wrong about its neighbour: tag before a rewrite, move the branch off the tag, and the commit under it exists in one place on earth while `dl rm` deletes the clone without asking and `--prune` without printing. Nothing inside a clone can tell those two apart, because no remote-tracking ref carries a tag. What can is the bare mirror the clone was made from, which fetches `+refs/tags/*:refs/tags/*` forced and pruned: a tag it holds at the same object is a tag the remote had at the last sweep, and a tag it has not got, or holds at another object, was typed here. Those come back into the query by name, so the commits only they reach are counted like any other unpushed work, and a local tag pointing at a commit the remote already has still costs nothing. The bare path is threaded through the resolver that already answers which directory a record's clone is in: `ClonePathResolver::bare_path`, answered in production by `resolve_bare_path` beside `resolve_clone_path`, and named off the record's repository rather than off the clone directory. `--prune` passes the same mirror it already computes while walking the cache. Where there is none to ask, every tag counts, which keeps the clone: `BareCache` is two arms rather than an `Option<&Path>` so every caller that cannot name one has to write the word. No fourth `Unsaved` arm: its three arms are the three documented keys of `dl --ls --json`. What changed is what counts as unpushed, not the vocabulary. Closes #487.
blooop
added a commit
that referenced
this pull request
Aug 29, 2026
…to lose The delete guard excludes `refs/tags/*` from the unpushed question, because a tag the remote carries on a branch it no longer has would otherwise read as hundreds of unpushed commits on every clone of that repository forever (#485, fixed in #486). The exclusion was right about that case and silently wrong about its neighbour: tag before a rewrite, move the branch off the tag, and the commit under it exists in one place on earth while `dl rm` deletes the clone without asking and `--prune` without printing. Nothing inside a clone can tell those two apart, because no remote-tracking ref carries a tag. What can is the bare mirror the clone was made from, which fetches `+refs/tags/*:refs/tags/*` forced and pruned: a tag it holds at the same object is a tag the remote had at the last sweep, and a tag it has not got, or holds at another object, was typed here. Those come back into the query by name, so the commits only they reach are counted like any other unpushed work, and a local tag pointing at a commit the remote already has still costs nothing. The bare path is threaded through the resolver that already answers which directory a record's clone is in: `ClonePathResolver::bare_path`, answered in production by `resolve_bare_path` beside `resolve_clone_path`, and named off the record's repository rather than off the clone directory. `--prune` passes the same mirror it already computes while walking the cache. Where there is none to ask, every tag counts, which keeps the clone: `BareCache` is two arms rather than an `Option<&Path>` so every caller that cannot name one has to write the word. No fourth `Unsaved` arm: its three arms are the three documented keys of `dl --ls --json`. What changed is what counts as unpushed, not the vocabulary. Closes #487.
blooop
added a commit
that referenced
this pull request
Aug 29, 2026
…to lose The delete guard excludes `refs/tags/*` from the unpushed question, because a tag the remote carries on a branch it no longer has would otherwise read as hundreds of unpushed commits on every clone of that repository forever (#485, fixed in #486). The exclusion was right about that case and silently wrong about its neighbour: tag before a rewrite, move the branch off the tag, and the commit under it exists in one place on earth while `dl rm` deletes the clone without asking and `--prune` without printing. Nothing inside a clone can tell those two apart, because no remote-tracking ref carries a tag. What can is the bare mirror the clone was made from, which fetches `+refs/tags/*:refs/tags/*` forced and pruned: a tag it holds at the same object is a tag the remote had at the last sweep, and a tag it has not got, or holds at another object, was typed here. Those come back into the query by name, so the commits only they reach are counted like any other unpushed work, and a local tag pointing at a commit the remote already has still costs nothing. The bare path is threaded through the resolver that already answers which directory a record's clone is in: `ClonePathResolver::bare_path`, answered in production by `resolve_bare_path` beside `resolve_clone_path`, and named off the record's repository rather than off the clone directory. `--prune` passes the same mirror it already computes while walking the cache. Where there is none to ask, every tag counts, which keeps the clone: `BareCache` is two arms rather than an `Option<&Path>` so every caller that cannot name one has to write the word. No fourth `Unsaved` arm: its three arms are the three documented keys of `dl --ls --json`. What changed is what counts as unpushed, not the vocabulary. Closes #487.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #485.
What was wrong
dl <ws> rmasks git what the clone holds that no remote does:git log --oneline --all --not --remotes.--allspansrefs/tags, so a tag the remote carries on a branch the remote no longer has reads as unpushed work. That is the ordinary shape of a release: tag it, merge the branch, delete the branch.On this host, six of eight workspaces were un-deletable and none of the work was real:
Inside one of them, git says the clone is clean and fully pushed:
git rev-list --count --all --not --remotesgit rev-list --count --exclude=refs/tags/* --all --not --remotesgit rev-list --count feat/bt-with-wheels-on-brake --not --remotesAll 265 hang off tags whose branches the remote deleted:
kaufland_virtual_demo(131),bt-mapping-refresh/bounded-verified-2026-08-11(29),bt-mapping-refresh/verified-2026-08-11(26),DPD_Demo(7),ci-regression-pr-6003(7),live_ford_demo(4). The bare cache fetches--tags, so every clone of that repository inherits them, and every workspace of it is affected for good.Why the documented trade doesn't hold
unpushed_commitsargued this was the safe direction to fail in: a clone kept costs disk, a clone deleted costs the work. That holds for a guard that fires occasionally. This one cannot be satisfied by pushing anything, on every workspace of the repository, forever, so what it actually teaches is--force— and a habit of--forceis the clone that really did hold an unpushed hour of work going the same way, unread.The fix
--exclude=refs/tags/*binds to the--allthat follows it and takes the tags out of that alone. Local branches, every worktree's HEAD including detached ones, andrefs/stashare all still asked about, so #471 and the stash keep their answers.Given up: a commit reachable only from a local tag, with no branch, worktree HEAD or stash in the clone naming it too.
Tests
Red before green, at both seams.
At the clone-state seam (
workspace_state/tests.rs, real git against a local bare):a_tag_no_remote_branch_reaches_any_more_is_not_unsaved_work— pushes a branch and a tag, deletes the branch from both sides, assertsNothingToLose. Fails onmainwithWouldLose(Unpushed("release")).a_commit_on_a_detached_worktree_head_is_still_unsaved— guards the ref set from being narrowed to--branchesinstead. Together with the existing stash test, both fail if it is.At the
dlbinary seam (dl/tests/lifecycle.rs, via a new--tagged-releasefixture inlifecycle_scenario.py):a_clone_whose_last_tag_the_remote_carries_too_is_deleted_like_any_other—dl <ws> rmexits 0 and the clone and its record are gone. Fails onmainwith the 1-commit refusal.a_pushed_tag_does_not_hide_a_commit_that_really_is_nowhere_else— the same clone plus one commit of its own is still refused, and still named as a commit. Fails onmaincounting 2.cargo test --workspacegreen (27 groups),cargo clippy --locked --all-targets -- -D warningsclean,cargo fmt --checkclean, and the doc guards (test_readme_cli_doc.py,test_docs_prose.py,test_docs_links.py,test_review_guard.py) pass — 123 passed.Release
Second commit cuts 0.19.1:
rust/Cargo.toml,Cargo.lock, the README's conda badge anddl --versiontranscript, and a CHANGELOG entry.docs/cleanup.mdstates the new behaviour where it stated the old trade.🤖 Generated with Claude Code
Summary by Sourcery
Exclude tags from unpushed-work detection so clean workspaces with remote-backed releases can be deleted without weakening protection for other local work.
Bug Fixes:
Enhancements:
Build:
Documentation:
Tests: