Skip to content

Let a commit land when it already carries the branch latest - #4

Closed
jochenhz wants to merge 1 commit into
mainfrom
fix/commit-gate-reachability
Closed

jochenhz wants to merge 1 commit into
mainfrom
fix/commit-gate-reachability

Conversation

@jochenhz

@jochenhz jochenhz commented Sep 16, 2026

Copy link
Copy Markdown

A sync that has to merge cannot finish. It stages a merge of the remote target with the local revision, commits it — and the commit is refused for the very divergence the merge resolves:

Remote and local branch have diverged, performing merge
Calculating 3-way diff between
  base 18 -> d4cd8c40   source 22 -> 891b6b92   target 19 -> 0e98220d
[Error] Synchronizing with local changes failed to merge with remote revision:
        Branch has been advanced by another instance, sync and re-stage to commit

It tells the user to sync, inside a sync. The merge itself is computed, staged and correct; only landing it is blocked, and the only way out is lore commit --force by hand.

The rule the check stands for

if !globals.force() && !branch_latest.is_zero() && branch_latest != current_revision {
    return Err(BranchAdvanced.into());
}

That is a pointer comparison, but the rule it exists for is "do not drop what another instance added". An anchor equal to the latest is the cheapest way to satisfy it, not the only one — a staged state that already has the latest in its ancestry publishes that work rather than replacing it.

Git draws the same line in its push path: a non-fast-forward is rejected, but a merge commit that contains the remote tip is accepted, because reachability is the safety property, not pointer equality.

What this changes

When the cheap comparison fails, ask the real question before refusing. The two parents answer it outright; otherwise walk first parents back from the staged state's own parent, stopping once revision numbers fall below the latest's.

  • The shape this exists for — sync staging a merge of a target that descends from the latest — resolves in one or two steps. In the measured case the target's parent is the latest.
  • A plain commit on a stale anchor aborts on the first step.
  • A commit whose anchor is the latest is untouched: the walk is only reached where the code previously returned an error.

It fails closed. An unreadable state, a walk past its bound, a history this clone only partly holds — all answer "no" and the commit is refused. A gap costs a refusal the user resolves by syncing; the opposite mistake costs somebody's work. The first-parent walk is part of that: a latest reachable only through a second parent is not found, and refusing is the right answer to not knowing.

Tests

Two tests in lore-revision/tests/commit.rs define the boundary, with identical pointers and only the staged state differing:

staged state ancestry reaches the latest
parent_self two steps above the latest, parent_other the anchor — the sync-merge shape yes now commits
built on the stale anchor no still refused

Reverting the check turns the first red and leaves the second green, so the pair measures this change and not the fixture.

They set the anchor and the latest directly rather than racing two clones: the check only ever reads those pointers, and how they came to differ — a server-side fast-forward merge, a forced commit — is not something it can see.

Verification

Beyond the unit tests, on a repository that was genuinely stuck this way:

before:  sync errors, merge left staged, only `commit --force` clears it
after:   revision 3f3e2af6 == revision_local 3f3e2af6,  revision_staged 00000000
         revision_merged 0e98220d          ← the local side kept as second parent
         working copy carries both sides' files

The sync completes on its own, with no --force anywhere.

  • cargo test -p lore-revision — 1061 passed, 0 failed
  • Smoke suite as CI runs it (-m smoke -n 4, release binaries) — 1009 passed, 0 failed
  • cargo clippy --all-targets -- -D warnings and cargo +nightly fmt --all --check clean

Not covered

  • The bound is a judgement call. 512 revisions of first-parent walk, reached only on a path that is currently a hard error. I have not constructed a history where that bound is the binding constraint; if you would rather key it off something else, say so.
  • Reachability through second parents is deliberately not searched — a latest merged in as someone's parent_other will refuse rather than allow. Safe, but it means the check is stricter than the rule it states.
  • Independent of the two open PRs (lore-revision: Only follow a parent the server renumbered #2 push re-parent, Let a push ask the server to rebase instead of merge #3 server rebase). It shares a subject with them, not a code path — this one is reachable whenever the anchor and the branch latest disagree, which today happens after a forced commit.

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

A sync that has to merge cannot finish: it stages a merge of the remote
target with the local revision, commits it, and the commit is refused for
the very divergence the merge resolves. The message tells the user to sync
while they are inside a sync:

    Synchronizing with local changes failed to merge with remote revision:
    Branch has been advanced by another instance, sync and re-stage to commit

The merge itself is fine — computed, staged, correct. Only landing it is
blocked, and the only way out is `lore commit --force` by hand.

The check is written as a pointer comparison:

    if !globals.force() && !branch_latest.is_zero()
        && branch_latest != current_revision
    {
        return Err(BranchAdvanced.into());
    }

but the rule it stands for is "do not drop what another instance added".
An anchor equal to the latest is the cheapest way to satisfy that, not the
only one: a staged state that already has the latest in its ancestry
publishes that work rather than replacing it. Git draws the same line in
its push path — a merge commit containing the remote tip is accepted,
because reachability, not pointer equality, is the safety property.

So when the cheap comparison fails, ask the real question before refusing.
The two parents answer it outright; otherwise walk first parents back from
the staged state's own parent, stopping once revision numbers fall below
the latest's. The shape this exists for — sync staging a merge of a target
that descends from the latest — resolves in one or two steps, and a plain
commit on a stale anchor aborts immediately. Nothing changes for a commit
whose anchor is the latest: the walk is reached only where the code
previously returned an error.

It fails closed. An unreadable state, a walk past its bound, a history
this clone only partly holds — all answer "no" and the commit is refused.
A gap costs a refusal the user resolves by syncing; the opposite mistake
costs somebody's work.

Two tests define the boundary, with identical pointers and only the staged
state differing: one whose ancestry reaches the latest two steps back (the
sync-merge shape) now commits, one built on the stale anchor is still
refused. Reverting the check turns the first red and leaves the second
green.

Verified end to end on a repository that was actually stuck this way: the
sync now completes on its own, anchor and branch latest meet on the new
merge revision, nothing staged is left behind, and both sides' files are
present — without `--force`.

cargo test -p lore-revision: 1061 passed. Smoke suite against release
binaries: 1009 passed, 0 failed. clippy and nightly fmt clean.

Signed-off-by: Jochen Hunz <j.hunz@anchorpoint.app>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant