Skip to content

fix: a commit only an unpushed local tag reaches now counts as unpushed - #521

Merged
blooop merged 2 commits into
mainfrom
fix/487-unpushed-local-tag
Aug 29, 2026
Merged

fix: a commit only an unpushed local tag reaches now counts as unpushed#521
blooop merged 2 commits into
mainfrom
fix/487-unpushed-local-tag

Conversation

@blooop

@blooop blooop commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Closes #487.

The lie

dl <ws> rm and dl --prune ask one question before destroying a clone: is there
anything in here that exists nowhere else? Since #486 that question excludes
refs/tags/* wholesale, and it had to: a tag your remote carries on a branch it
no longer has reaches commits no refs/remotes/* reaches, so a repository that
tags releases on branches it then deletes reported hundreds of unpushed commits on
every clone, forever (#485, 265 of them on one real repository, six of eight
workspaces on a host refusing to be deleted, and not one commit of it real).

The exclusion is right about that case and silently wrong about its neighbour:

git tag backup-before-rebase        # never pushed
git reset --hard origin/main
dl <ws> rm                          # deletes it. No refusal, no --force, nothing printed.

The commit backup-before-rebase reaches is now the only copy of itself, and the
guard answers NothingToLose, which flows/lifecycle.rs reads as MayRemove.
That is the backup habit an agent rewrite workflow actively encourages, so this
was the highest-priority data-loss path left in the repo.

The fix

Nothing inside a clone can tell the two cases apart: no remote-tracking ref
carries a tag, so refs/tags/ has no mark saying which name arrived in a fetch.
What knows is dl's own bare mirror at repos/<owner>/<repo>/.bare, which fetches
+refs/tags/*:refs/tags/* forced and pruned (#476) and is what every workspace
clone is made from. So the guard compares the two, offline:

The tag in the clone Counted?
The mirror has it, at the same object No. It came off the remote.
The mirror has not got it Yes.
The mirror has the name at another object Yes. Moved or retyped here.
There is no mirror to ask Yes, every tag.

The tags that come back counted are named as ordinary positive refs in the
existing query, between the --all they were excluded from and the --not that
would flip them:

git log --oneline --exclude=refs/tags/* --all refs/tags/backup-before-rebase --not --remotes

so a local tag pointing at a commit the remote already has still costs nothing.
No fourth Unsaved arm: the three arms are the three documented keys of
dl --ls --json, which wf consumes (#485's lesson). What changed is what counts
as unpushed, not the answer vocabulary.

The refusal says which tag

Naming the tags is the difference between a refusal you can act on and one you
cannot. "Push or commit it" is advice already taken for a commit under a tag, and
where the mirror is merely behind the remote the commit is already pushed too:

devlaunch-local-tag holds 1 unpushed commit(s), 1 reachable only from local tag(s) (backup).
Push or commit it, or run: dl devlaunch-local-tag rm --force

Both counts, because they answer different questions: how much would be lost, and
how much of it pushing cannot clear. A second local query, asked only once a
refusal exists and only when a local tag was named in the one that produced it,
isolates the commits nothing but those tags reach (the same ref algebra with the
sides swapped, so its result is a subset of the first by construction). A commit
on a branch is not blamed on a tag, and a tag reaching nothing a branch does not
reach is named nowhere. ByLocalTags holds both halves as NonEmpty, so "some
tags reached no commits" has no representation. A refusal from that query is not
a CouldNotTell and it is the one place this module bends that rule: by then the
loss is established and the clone is kept either way, so nothing here can read a
failed question as permission, and only the naming half is lost.

This also covers a case worth naming: a tag pushed from a workspace and not yet
swept into the cache, on a commit no remote branch reaches, reads as unpushed.
That is #485's own repository shape, and it is narrow, but "reachable only from
local tag(s) (v0.26.0)" is what tells the reader the cache is stale rather than
leaving them to --force past a refusal they cannot otherwise explain.

Why #486's false positive does not come back

The mirror and the clone agree by construction for everything that came off the
remote: dl fetches tags into the bare and git clone copies them from there
into the workspace, same objects on both sides. #485's own fixture is pinned as a
test in both places, at the seam
(a_tag_no_remote_branch_reaches_any_more_is_not_unsaved_work) and at the binary
boundary (a_clone_whose_last_tag_the_remote_carries_too_is_deleted_like_any_other),
and both still say NothingToLose. a_local_tag_on_a_commit_the_remote_already_has_is_not_a_loss
covers the narrower way it could have come back, where naming a tag makes a clone
refuse for commits the remote does have.

The comparison is exact only up to the last sweep, which is the trade the ticket
picked: a tag pushed straight from a workspace and not yet fetched into the cache
reads as local until the next fetch, and keeps the clone.

Where the bare path comes from

read_clone(&Git, clone) knew only a directory. The seam chosen is the resolver
that already answers "which directory is this record's clone in":
ClonePathResolver gains bare_path(record), and the production implementation
answers resolve_bare_path, beside resolve_clone_path on the clone manager. It
is named off the record's repository, not off the clone directory, so a record
whose local_path was written by an older layout still finds the mirror its
origin points at. --prune gets it from the same bare_dir(owner, repo) it
already computes while walking the cache.

The absent case is a two-armed BareCache rather than an Option<&Path>, because
it is an answer with a direction: Unknown counts every tag as local, and every
caller that cannot name a mirror has to write the word. Principle 1 of map #444: a
check that cannot prove safety fails towards keeping.

Cost

One extra local for-each-ref per clone, and a second only when the clone has a
tag at all (a_clone_with_no_tags_never_asks_the_bare pins that). No network.

Tests

Red before green: with the comparison stubbed back to "no local tags", exactly the
four new behaviour tests fail and the two anti-regression ones stay green.

  • a_commit_only_an_unpushed_local_tag_reaches_is_unsaved (was
    ..._is_given_up, which asserted the bug)
  • a_local_tag_the_bare_holds_at_another_object_is_unsaved
  • with_no_bare_to_compare_against_every_tag_counts
  • a_bare_that_is_not_a_repository_counts_every_tag_too
  • a_local_tag_on_a_commit_the_remote_already_has_is_not_a_loss
  • a_clone_with_no_tags_never_asks_the_bare
  • the_two_tag_queries_ask_the_same_question_of_each_side,
    a_tag_listing_is_read_as_the_pairs_it_is (client layer)
  • a_commit_only_a_local_tag_reaches_stops_the_delete (binary boundary, new
    --local-tag world)
  • the_mirror_is_named_off_the_repository_wherever_the_clone_was_recorded and
    a_record_whose_repository_the_name_rules_refuse_names_no_mirror (the resolver)

For the message (each red without the attribution, green with it):

  • a_branch_commit_is_not_blamed_on_a_tag_that_happens_to_be_there (two unpushed
    commits, one tag's: both counts said separately)
  • a_tag_that_reaches_nothing_of_its_own_is_not_named_in_the_refusal
  • a_long_list_of_tags_is_cut_short_like_every_other_list (one truncation rule,
    shared with the changed-paths list rather than written twice)
  • the_attribution_is_given_up_rather_than_the_refusal_when_git_will_not_say and
    no_local_tags_is_no_question_asked
  • the_attribution_query_is_the_unpushed_one_with_the_sides_swapped
  • a_tag_is_its_object_as_well_as_its_name (pins the equality vouched_for rests
    on, which no fixture where the two sides agree could catch)

On the checked-in public-api snapshot

The rows for ClonePathResolver::bare_path, Loss::Unpushed's new shape and
ByLocalTags are hand-written, because this host carries neither nightly nor the
pinned cargo-public-api. That is not a second hand-maintained copy of a fact:
CI's public-api job runs scripts/public-api-snapshots.sh into a scratch tree
and diffs the regenerated output against the file, so a hand-written row that is
wrong fails the job rather than being trusted. It is green here, which is the
verification.

🤖 Generated with Claude Code

Summary by Sourcery

Prevent cleanup from deleting clones whose only remaining commits are reachable through local tags, while retaining the existing protection against false positives from remote-backed tags.

Bug Fixes:

  • Count commits reachable only from unpushed local tags as unsaved work, preventing clone deletion from discarding the only copy of those commits.
  • Distinguish remote-backed tags from local or moved tags by comparing workspace tags with the repository mirror.
  • Include the relevant local tag names and separately attributed commit counts in deletion refusal messages.

Enhancements:

  • Fail safely by treating missing or unusable repository mirrors as unable to vouch for tags.
  • Preserve the existing three-state workspace safety vocabulary and JSON API while enriching unpushed-loss details.

Documentation:

  • Document tag-aware cleanup behavior, mirror comparison rules, safe handling of unavailable mirrors, and tag attribution in refusal messages.

Tests:

  • Add unit, integration, and lifecycle coverage for local, remote-backed, moved, missing-mirror, and stale-mirror tag scenarios, including attribution and performance-related query behavior.

@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 6 days and 2 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@sourcery-ai

sourcery-ai Bot commented Aug 29, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR fixes tag-only data-loss detection by comparing each workspace tag’s exact object with the repository’s bare mirror, then feeding only unvouched local tags into the existing unpushed-commit guard. It threads repository-specific mirror paths through rm, prune, and listing flows, conservatively keeps clones when comparison is unavailable, and adds unit, domain, and end-to-end regressions without changing the public Unsaved result vocabulary.

Sequence diagram for tag-aware clone deletion guard

sequenceDiagram
    participant Lifecycle as rm_or_prune
    participant State as workspace_state
    participant Clone as WorkspaceClone
    participant Bare as BareMirror
    participant Git as Git

    Lifecycle->>State: holds_unsaved_work(clone, bare)
    State->>Git: tags_in_clone(clone)
    Git-->>State: tag refs with object IDs
    alt clone has tags and bare is available
        State->>Git: tags_in_bare(bare)
        Git-->>State: mirror tag refs with object IDs
        State->>State: filter tags not vouched_for by exact name and object
    else no bare or bare refuses
        State->>State: treat every clone tag as local
    end
    State->>Git: unpushed_commits(clone, local_tags)
    Git-->>State: Unsaved result
    State-->>Lifecycle: keep clone or allow removal
Loading

File-Level Changes

Change Details Files
Compare clone tags with the repository’s bare mirror before deciding whether tag-only commits are unpushed.
  • Query tag names and object IDs from both clone and bare repository using a shared Git format.
  • Reintroduce only tags absent from or differing from the bare into the existing unpushed-commit query.
  • Treat missing or unusable bare caches as unknown and conservatively count all clone tags.
  • Preserve the existing three-state Unsaved vocabulary and fail toward retaining data.
rust/devlaunch-core/src/clients/git.rs
rust/devlaunch-core/src/domain/workspace_state.rs
Propagate the repository-specific bare-cache path through lifecycle and listing flows.
  • Add bare-path resolution to ClonePathResolver and derive it from the recorded repository rather than clone location.
  • Pass bare paths through rm, prune, listing, and unsaved-work checks.
  • Use an explicit BareCache state for known versus unavailable mirrors.
rust/devlaunch-core/src/flows/lifecycle.rs
rust/devlaunch-core/src/flows/listing.rs
rust/devlaunch-core/src/flows/workspace_clone.rs
Add regression coverage for local, remote-backed, moved, and unavailable tags.
  • Cover tag-only local commits, moved/retyped tags, remote-backed tags, absent or invalid mirrors, and clones without tags.
  • Verify Git argument ordering, shared tag-query formatting, parsing, and no unnecessary bare query.
  • Add an end-to-end rm scenario proving a local tag prevents deletion while prior false-positive behavior remains safe.
rust/devlaunch-core/src/clients/git/tests.rs
rust/devlaunch-core/src/domain/workspace_state/tests.rs
rust/dl/tests/lifecycle.rs
rust/dl/tests/lifecycle_scenario.py
Document the refined tag safety rule and its operational tradeoffs.
  • Explain mirror-based tag comparison, conservative behavior when no mirror exists, and local-only execution cost.
  • Record the fix in the unreleased changelog.
docs/cleanup.md
CHANGELOG.md

Assessment against linked issues

Issue Objective Addressed Explanation
#487 Ensure commits reachable only through an unpushed local tag are treated as unsaved work, preventing dl rm and dl --prune from silently deleting the only copy without requiring --force.
#487 Distinguish local-only tags from tags that were fetched from the remote, while preserving the existing behavior that commits reachable only through remote-carried tags are not incorrectly treated as unsaved.
#487 Use the repository's bare cache to make the tag comparison safely and offline, including conservative behavior when the cache is unavailable or invalid, and document and test the resulting behavior.

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

@blooop
blooop force-pushed the fix/487-unpushed-local-tag branch 2 times, most recently from 56e7bc9 to 37242b5 Compare August 29, 2026 19:59
@codecov

codecov Bot commented Aug 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.73913% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 95.78%. Comparing base (adab656) to head (53dd4dc).
⚠️ Report is 5 commits behind head on main.

Files with missing lines Patch % Lines
rust/devlaunch-core/src/domain/workspace_state.rs 92.59% 6 Missing ⚠️
Additional details and impacted files
Flag Coverage Δ
python 42.98% <ø> (ø)
rust 96.08% <96.73%> (-0.01%) ⬇️

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

Components Coverage Δ
shipped code (rust) 96.08% <96.73%> (-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
blooop force-pushed the fix/487-unpushed-local-tag branch from 37242b5 to 149ae23 Compare August 29, 2026 20:05
@blooop
blooop force-pushed the fix/487-unpushed-local-tag branch 2 times, most recently from 6489e36 to 8570653 Compare August 29, 2026 20:28
blooop added 2 commits August 29, 2026 21:42
…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.
The refusal the tag comparison produces was "holds 1 unpushed commit(s). Push or
commit it, or run: dl <ws> rm --force", and for every tag-driven instance of it
that advice cannot be taken: the commit is already committed, and where the
mirror is merely behind the remote it is already pushed too. What is missing is
the tag in dl's cache, which no amount of pushing supplies and which the sentence
did not mention. That is the state docs/cleanup.md argues against two paragraphs
above the one this adds: a guard that has to be --force'd past to clear, until
--force is what gets typed without reading.

So the answer now carries what makes the sentence actionable. A second local
query, asked only once a refusal exists and only when a local tag was named in
the one that produced it, isolates the commits nothing but those tags reach: the
same ref algebra with the sides swapped. Its result is a subset of the first by
construction, so both counts can be said at once without either being a second
measurement:

    holds 2 unpushed commit(s), 1 reachable only from local tag(s) (backup)

Both numbers, because they answer different questions: how much would be lost,
and how much of it pushing cannot clear. A commit on a branch is not blamed on a
tag, and a tag reaching nothing a branch does not reach is named nowhere, so the
plain sentence survives for the case where it was right.

`ByLocalTags` holds the tags and the commits owed to them, both `NonEmpty`, so
"some tags reached no commits" has no representation and the absent attribution
is an absent value. A refusal from this query is *not* a `CouldNotTell` and it is
the one place this module bends that rule: by then the loss is established and
the clone is kept either way, so no failed question here can be read as
permission. Only the naming half is lost.

Also pins `TagRef`'s equality on both name and object, since `vouched_for` is
that equality: derived on the name alone the whole comparison would degrade to
"the mirror has heard of this name", which fails in the deleting direction and
which every fixture where the two agree would pass.
@blooop
blooop force-pushed the fix/487-unpushed-local-tag branch from 8570653 to 53dd4dc Compare August 29, 2026 20:45
@blooop
blooop merged commit 56397db into main Aug 29, 2026
15 checks passed
@blooop
blooop deleted the fix/487-unpushed-local-tag branch August 29, 2026 20:50
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.

A commit only an unpushed local tag reaches now reads as nothing to lose

1 participant