Skip to content

fix: rm no longer counts a commit and the commit that reverts it - #664

Merged
blooop merged 8 commits into
mainfrom
fix/rm-revert-pair
Oct 1, 2026
Merged

blooop merged 8 commits into
mainfrom
fix/rm-revert-pair

Conversation

@blooop

@blooop blooop commented Oct 1, 2026 •

Copy link
Copy Markdown
Owner

dl kinisi-ros-sim-gate-all-other-nothing-kged rm refused on 2026-10-01. It said "holds 6 unpushed commit(s)". The 6 commits were on local branches that are not checked out. Their PR, kinisi_ros#11898, was squashed into main, and GitHub deleted the remote branches.

2 of the 6 are a commit and its exact revert, next to each other on sim-gate-backup:

  • a7d877c077 "kinisi_benchmarks: Probe the sim selection with a docstring edit (revert before merge)"
  • 0bf0560c5b "Revert ..."

0bf0560c5b^ is a7d877c077, and the tree of 0bf0560c5b is the tree of a7d877c077^. Together they change nothing, so deleting them loses nothing. The guard counted them anyway. #653 listed "A commit and its revert" as a known limit of the copy rule. Each commit has no copy on its own. The squash rule from #659 does not clear them either, because a branch that changes nothing proves nothing to it.

The other 4 are older drafts of commits that were edited later. Replay each one on the final version's parent and the tree is not the final tree (9 to 16 lines differ). They are real work that exists nowhere else, and they still count. So this change does not make that rm succeed. It makes the count 4, not 6.

What this changes

A fourth step, the revert rule, runs after the squash rule. Like the other two, it can only remove commits from the count.

  • A pair is two counted commits, R on top of C. R has one parent, C. C has one parent of its own. The tree of R is the tree under C, and the tree of C is not. Trees, not patches, so R must take back the whole change of C, byte for byte. Both drop out.

  • These are never a pair:

    • An empty C. An empty commit holds only its message, and nothing compares a message.
    • A root C. There is no tree under it.
    • A merge, as C or as R. Its change is not one commit's.
    • A C that the copy rule or the squash rule already cleared. Then R is the only record of taking that change out again.
  • A commit is in one pair at most, and the pairs are taken from the bottom. A revert of a revert puts the change back. So in C, R, R2 the first two drop out and R2 still counts. C, R, R2, R3 is two pairs.

  • A pair drops out only when nothing else holds the state of C. Each of these holds C, and then both commits still count:

    • a branch, a tag, or a worktree's HEAD on C;
    • a second commit that grew from C, for example another branch or the stash.

    So every ref that reaches C reaches it through R.

  • Two new git verbs. unpushed_graph is one git log --boundary --all --not --remotes with each commit's tree and parents. The boundary lines give the tree under a commit made on top of the remote. ref_tips is one git rev-list --no-walk --all: the commit that each ref and each worktree's HEAD names, with tags peeled. It runs only when a pair is found.

  • Every failure clears nothing. A refusal on either verb clears nothing, and so does a graph line in a shape the parser does not know.

The rule is reverted_in_pairs in rust/devlaunch-core/src/domain/workspace_state.rs. It runs inside unsaved(), so dl --ls --json and rm still give the same answer. It runs only when the other rules left a commit counted. docs/cleanup.md and CHANGELOG.md describe the rule and drop "a commit and its revert" from the limits.

What the revert rule does not find, so these commits still count:

  • A revert that is not the next commit, for example C, then other work, then the revert of C.
  • A revert that takes back only part of the change.
  • An earlier draft of a commit that was edited later. This is not a revert, and the rule does not look at subjects.
  • A revert with a merge on either side.

How I checked it

  • Tests first. The new tests in workspace_state/tests.rs failed on a stub that cleared nothing, then passed with the rule:

    • a commit and its revert give NothingToLose, in a SHA-1 and in a SHA-256 repository;
    • the commits under and over a pair still count;
    • a commit that undoes only part of the one under it keeps 2;
    • two empty commits keep 2;
    • a root commit and its revert keep 2;
    • a revert of a revert keeps 1, and one more revert gives NothingToLose;
    • a local tag, a branch and a detached worktree HEAD on C each keep 2;
    • a stash made on C keeps 4, and a second branch that grew from C keeps 3;
    • a merge whose tree is the tree under C keeps 3, and a merge with an edit of its own plus its revert keep 3;
    • the revert of a commit that origin/main holds as a cherry-pick still counts;
    • a replace ref that makes the pair a cycle gets an answer, and the cycle is no pair;
    • ScriptedRunner tests: a refused graph, refused ref tips, a graph line in another shape, and a pair where either commit is no longer counted each clear nothing. A control with the same answers clears the pair.
  • The squash-rule test that had a commit and its revert keep 2 now asks the squash rule on its own. It still clears nothing there, and the whole guard now gives NothingToLose.

  • A review pass by a separate agent tried to make the rule clear work that exists nowhere else. It found one defect, and I fixed it with a test that failed first:

    • A refs/replace entry or a graft can make git log show a commit and its revert as each other's parent. The walk down the pairs then never ended, and its memory grew without limit, so rm and dl --ls --json hung. Now a walk stops at a commit it has seen, and nothing on a cycle is a pair.

    It also found that the doc comment over-promised: a linked worktree's own refs/worktree/* do not hold a pair, because --all does not read them. The comment now says so. Things it tried that held: revert chains of 2 to 6 commits, nested annotated tags, tags on trees, the stash, shallow clones, short hashes, and git log config such as format.pretty, core.abbrev and color.ui=always.

  • Mutants. I removed each guard in turn: the empty-C check, the tree check, one parent for R, one parent for C, C still counted, R still counted, pairs from the bottom, the ref-tip check, the second-child check, the two refusals, the two parser refusals and --boundary. A test failed each time.

  • In the container:

    • pixi run cargo test --workspace --no-fail-fast: no failures. On the first run, one test that does not reach this code (the_json_listing_migrates_the_cache_and_the_table_does_not) failed under load. It passed on 3 reruns and on the run after the review fix.
    • HOME=/tmp/noid GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 pixi run cargo test -p devlaunch-core --lib: 2078 passed.
    • pixi run cargo clippy --locked --all-targets -- -D warnings, pixi run cargo fmt --check and prek run --from-ref origin/main --to-ref HEAD: clean.
    • pixi run test: 839 passed.
  • The real commits. A bundle holds the three branches from that workspace. I made a scratch copy of a kinisi_ros clone, made local branches from the bundle, and ran the guard through a test binary that is not committed. With the PR branch at its last head (aaf7a57c0b) as origin/sim-gate-all-other-nothing and checked out, as the workspace had it:

    • by hash: 21 commits;
    • after the copy and squash rules (what the released dl reports): 6;
    • after the revert rule: 4. Only a7d877c077 and 0bf0560c5b drop out. The 4 drafts (f859476c75, 3c1997bb68, c01b0fde20, 7587798c79) still count.

    With only the bundle's branches and no PR branch, the count goes from 29 to 27, and again only the pair drops out.

  • A second self-review (Defects, Types, Tests and one mutant) found no correctness defect. The mutant broke "one pair per commit, from the bottom", and a_revert_of_a_revert_stays_counted killed it. It led to these commits:

    • 92c80a86: a test with a held pair beside a pair that clears. An all-or-nothing hold passed every test before it.
    • cdc917b3: a Pair { reverted, revert } struct replaces two (String, String) tuples in opposite orders, and the walk keeps the pair instead of a flag it worked out again later.
    • fd4de7b0: a test with log.showSignature=true and a signed commit pins --no-show-signature on the graph verb.
    • 8211895f: the revert-of-a-revert test now checks which commit stays counted, not only the count. Pairing from the top gave the same count.
    • 24d1fe34: the graph verb's doc comment said a commit that only a tag reaches is never counted. A local tag is counted.
  • Not fixed here: log.showSignature on the count itself. The revert rule reads its graph correctly with log.showSignature=true, but unpushed_commits, which runs before every rule, has no --no-show-signature. Its lines then cannot be read, so every commit stays counted. That is on main too. It refuses more, never less, so it is left for its own PR.

Review notes

  • The holding test is local, not per ref. The squash rule asks which refs reach a commit. This rule asks two smaller questions: does a ref or a HEAD name C itself, and does any unpushed commit other than R have C as a parent? If neither, every path from a ref to C goes through R. The graph comes from --all with nothing excluded, so a commit that only a tag or refs/original reaches can still hold a pair back. That is the safe direction.
  • A revert meant for a remote. If a remote holds C in another form that no rule matches, a local revert R could be work meant for that remote. C and R still clear as a pair, because together they change nothing on top of C's parent. When a rule does match C's copy, C is no longer counted and R stays counted.
  • Refs that --all does not see. A linked worktree's refs/worktree/* and refs/bisect/*, and the stash's older reflog entries, are not seen by --all. The count had this gap before this change. I did not change it.

🤖 Generated with Claude Code

A probe commit and its exact revert, left on a backup branch after the PR
was squashed (kinisi_ros#11898), kept rm refusing. Neither has a copy on a
remote, and a branch that changes nothing proves nothing to the squash rule.

A fourth rule clears a pair: two counted single-parent commits, the second
on the first, where the second tree is the tree under the first and the
first tree is not. Pairs are taken from the bottom, one per commit, so a
revert of a revert stays counted. A ref or worktree HEAD on the reverted
commit, or a second commit that grew from it, holds the pair back. Every
git refusal clears nothing.

@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 9 hours and 4 minutes by commenting @sourcery-ai review. Upgrade to get a review now.

@sourcery-ai

sourcery-ai Bot commented Oct 1, 2026

Copy link
Copy Markdown

Reviewer's Guide

Adds a conservative fourth cleanup rule that clears only adjacent counted commit/revert pairs which provably cancel byte-for-byte and are not retained by another ref or child commit. It obtains the required graph and ref-tip metadata from Git, fails closed on incomplete data, integrates after copy/squash rules, and includes extensive behavioral and safety regression tests plus documentation.

Sequence diagram for conservative reverted-pair cleanup

sequenceDiagram
    participant Unsaved
    participant Git
    participant Graph
    participant RefTips

    Unsaved->>Git: unpushed_graph()
    Git-->>Unsaved: CommitGraph or refusal
    alt graph unavailable or malformed
        Unsaved-->>Unsaved: Clear nothing
    else counted adjacent pair found
        Unsaved->>Git: ref_tips()
        Git-->>Unsaved: Ref and worktree HEAD tips or refusal
        alt ref tips unavailable
            Unsaved-->>Unsaved: Clear nothing
        else pair not held by a tip or second child
            Unsaved->>Graph: Verify tree and parent structure
            Graph-->>Unsaved: Reverted pair
            Unsaved-->>Unsaved: Clear C and R
        else pair is held
            Unsaved-->>Unsaved: Keep both counted
        end
    end
Loading

Flow diagram for bottom-up revert-pair matching

flowchart TD
    A[Counted commits remain after copy and squash rules] --> B[Read unpushed graph with trees and parents]
    B --> C{Adjacent counted C then R?}
    C -- No --> D[Keep commits counted]
    C -- Yes --> E{R tree equals tree under C, and C changes it?}
    E -- No --> D
    E -- Yes --> F{C has no ref tip and exactly one child?}
    F -- No --> D
    F -- Yes --> G[Clear C and R]
    G --> H{Another revert follows?}
    H -- Yes --> I[Bottom-up pairing leaves the later revert counted]
    H -- No --> J[Pair is removed]
Loading

File-Level Changes

Change Details Files
Add a conservative revert-pair analysis to remove adjacent counted commits whose net tree change is empty.
  • Identify counted single-parent commit/revert pairs using commit trees and parent relationships.
  • Exclude empty, root, merge, partially reverted, and already-cleared commits.
  • Process chains bottom-up so revert-of-revert sequences retain the restored change.
  • Require no ref tip or additional child commit to hold the reverted state.
  • Run the rule after copy and squash analysis, preserving no-clear behavior on Git or parser failures.
rust/devlaunch-core/src/domain/workspace_state.rs
Expose Git graph and reference-tip data needed to safely validate revert pairs.
  • Add an unpushed_graph query using git log --boundary --all --not --remotes.
  • Add a ref_tips query using git rev-list --no-walk --all.
  • Parse unpushed commits and boundary trees into reusable graph structures.
rust/devlaunch-core/src/clients/git.rs
Expand regression coverage for revert detection and safety constraints.
  • Test ordinary pairs, SHA-256 repositories, revert chains, partial reversions, and surrounding commits.
  • Verify tags, branches, worktrees, stashes, extra children, merges, roots, empty commits, and remote copies prevent unsafe clearing.
  • Add scripted tests for command failures, malformed graph output, and interactions with earlier rules.
rust/devlaunch-core/src/domain/workspace_state/tests.rs
Document the new cleanup behavior and its remaining limitations.
  • Describe revert-pair semantics, holding references, and conservative failure behavior.
  • Update the changelog and cleanup rule count.
docs/cleanup.md
CHANGELOG.md

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 added 2 commits October 1, 2026 09:50
refs/replace or a graft can make git log show a commit and its revert as
each other's parent. The walk down the pairs never ended and grew without
limit. A walk now stops at a commit it has seen, and nothing on a cycle is
a pair. The doc comments also say that a linked worktree's own refs are
not read, as the count does not read them.
@codecov

codecov Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 94.45%. Comparing base (c8c12ce) to head (24d1fe3).

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

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

Components Coverage Δ
shipped code (rust) 94.67% <100.00%> (+0.03%) ⬆️
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 5 commits October 1, 2026 10:12
… top flag it re-derives

A Pair names its reverted and revert commits, and the walk memo carries the pair it took, so the held-back check reads the pairs the walk found with no second reverts() call.
…ver counted

Local tags are counted: unpushed_commits names them on the positive side. Only a tag from the remote, or refs/original, leaves a commit uncounted.
@blooop
blooop merged commit 5471bb6 into main Oct 1, 2026
15 checks passed
@blooop
blooop deleted the fix/rm-revert-pair branch October 1, 2026 10:26
@blooop blooop mentioned this pull request Oct 1, 2026
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.

1 participant