Skip to content

The delete guard stops counting tags the remote carries - #486

Merged
blooop merged 5 commits into
mainfrom
fix/unpushed-guard-counts-tags
Aug 26, 2026
Merged

The delete guard stops counting tags the remote carries#486
blooop merged 5 commits into
mainfrom
fix/unpushed-guard-counts-tags

Conversation

@blooop

@blooop blooop commented Aug 26, 2026

Copy link
Copy Markdown
Owner

Closes #485.

What was wrong

dl <ws> rm asks git what the clone holds that no remote does: git log --oneline --all --not --remotes. --all spans refs/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:

kinisi-ros-feat-bt-with-wheels-on-brake-4ixn: 265 unpushed commit(s)
kinisi-ros-feat-bt-transform-pose-stamped-w0yq: 265 unpushed commit(s)
kinisi-ros-fix-bt-mtpwo-goal-generation-bomq: 265 unpushed commit(s)
kinisi-ros-feat-bt-compute-spread-target-94p1: 265 unpushed commit(s)
kinisi-ros-bugfix-interpolator-position-cl-cf9e: 268 unpushed commit(s)
kinisi-ros-rerun-tf-frame-list-and-recordi-p4vl: 269 unpushed commit(s)

Inside one of them, git says the clone is clean and fully pushed:

query count
git rev-list --count --all --not --remotes 265
git rev-list --count --exclude=refs/tags/* --all --not --remotes 0
git rev-list --count feat/bt-with-wheels-on-brake --not --remotes 0

All 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_commits argued 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 --force is the clone that really did hold an unpushed hour of work going the same way, unread.

The fix

--exclude=refs/tags/* binds to the --all that follows it and takes the tags out of that alone. Local branches, every worktree's HEAD including detached ones, and refs/stash are 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, asserts NothingToLose. Fails on main with WouldLose(Unpushed("release")).
  • a_commit_on_a_detached_worktree_head_is_still_unsaved — guards the ref set from being narrowed to --branches instead. Together with the existing stash test, both fail if it is.

At the dl binary seam (dl/tests/lifecycle.rs, via a new --tagged-release fixture in lifecycle_scenario.py):

  • a_clone_whose_last_tag_the_remote_carries_too_is_deleted_like_any_otherdl <ws> rm exits 0 and the clone and its record are gone. Fails on main with 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 on main counting 2.

cargo test --workspace green (27 groups), cargo clippy --locked --all-targets -- -D warnings clean, cargo fmt --check clean, 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 and dl --version transcript, and a CHANGELOG entry. docs/cleanup.md states 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:

  • Prevent the workspace deletion guard from treating remote-carried release tags as unpushed work.
  • Continue refusing deletion when commits are reachable from local branches, detached worktree heads, or the stash but not from remotes.

Enhancements:

  • Clarify cleanup behavior and the trade-off for commits reachable only from local tags.

Build:

  • Bump the project version to 0.19.1.

Documentation:

  • Update the cleanup documentation, changelog, README version badge, and version output for the corrected deletion behavior.

Tests:

  • Add clone-state and lifecycle coverage for remote-carried tags, genuinely unpushed commits, detached worktree heads, and tag-only local commits.

blooop added 2 commits August 26, 2026 10:53
`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.

@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've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 2 days and 11 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@sourcery-ai

sourcery-ai Bot commented Aug 26, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR fixes false-positive workspace deletion refusals caused by release tags by excluding refs/tags/* only from the --all ref set used by the unpushed-work query. It preserves detection of local branches, detached worktree HEADs, and stashes, adds clone-state and end-to-end regressions for both safe tagged-release deletion and real unpushed work, and releases the change as 0.19.1.

Sequence diagram for the tag-aware workspace deletion guard

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

Flow diagram for preserved and excluded workspace refs

flowchart 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"]
Loading

File-Level Changes

Change Details Files
Exclude local tags from the delete guard’s unpushed-work ref set while preserving all other work-bearing refs.
  • Add --exclude=refs/tags/* immediately before --all in the Git query.
  • Update the command-contract test and document the intentional loss of tag-only protection.
rust/devlaunch-core/src/clients/git.rs
rust/devlaunch-core/src/clients/git/tests.rs
docs/cleanup.md
CHANGELOG.md
Add regression coverage for both safe deletion of pushed tagged releases and continued protection of genuinely local work.
  • Test a remote-carried tag whose branch was deleted as safe to delete.
  • Test detached worktree commits and existing unsaved commits remain detected.
  • Add an end-to-end tagged-release lifecycle fixture and verify deletion/refusal behavior at the dl boundary.
rust/devlaunch-core/src/domain/workspace_state/tests.rs
rust/dl/tests/lifecycle.rs
rust/dl/tests/lifecycle_scenario.py
Release the fix as version 0.19.1 and synchronize user-facing version references.
  • Bump the workspace version and lockfile metadata.
  • Update the README badge and version transcript.
  • Record the behavioral change and trade-off in the changelog and cleanup documentation.
rust/Cargo.toml
rust/Cargo.lock
README.md
CHANGELOG.md
docs/cleanup.md

Assessment against linked issues

Issue Objective Addressed Explanation
#485 Prevent the workspace deletion guard from counting commits reachable only through local tags when determining whether a clone contains unpushed work.
#485 Continue counting genuinely unsaved commits reachable through local branches, detached worktree HEADs, or the stash, while documenting the intentional exception for tag-only commits.
#485 Verify the corrected deletion behavior with regression tests and update user-facing documentation and release metadata.

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 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.39%. Comparing base (d449710) to head (6ab79dd).

Additional details and impacted files
Flag Coverage Δ
python 42.98% <ø> (ø)
rust 95.73% <100.00%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Components Coverage Δ
shipped code (rust) 95.73% <100.00%> (+<0.01%) ⬆️
harness and tooling (python) 42.98% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

blooop added 3 commits August 26, 2026 11:05
…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.
@blooop
blooop merged commit cdd31a2 into main Aug 26, 2026
15 checks passed
@blooop
blooop deleted the fix/unpushed-guard-counts-tags branch August 26, 2026 11:13
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.
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.

The delete guard counts tags, so every kinisi_ros workspace reports 265 unpushed commits

1 participant