Skip to content

lore-revision: Only follow a parent the server renumbered - #2

Open
jochenhz wants to merge 1 commit into
mainfrom
fix/push-reparent-only-on-rewrite
Open

jochenhz wants to merge 1 commit into
mainfrom
fix/push-reparent-only-on-rewrite

Conversation

@jochenhz

@jochenhz jochenhz commented Sep 15, 2026

Copy link
Copy Markdown

A push that carries more than one local revision into a branch head that has moved silently drops the other side's work. The push succeeds, reports a fast-forward merge, raises no conflict — and the overwritten commit is still in the history, so nothing looks wrong to anyone reading it afterwards.

Two ordinary local commits are enough, no --force anywhere. Reproducible with the released 0.9.0 CLI.

A publishes from-a.txt                          → the head moves
B, still on the old head, commits two files
  and pushes once with --fast-forward-merge     → both land

files in a fresh clone:
  from-b1.txt
  from-b2.txt
  keep.txt          ← from-a.txt is gone

history (A's commit is still in it):
  4  B two
  3  B one
  2  A publishes
  1  seed

Cause

collect_fragments_and_push bent a revision's parent pointer onto whatever the previous iteration left in current_latest, keeping its tree:

if !current_latest.is_zero() && state.parent_self() != current_latest {
    // Rebase on new latest revision
    // TODO(mjansson): This only handles revision number rewrite for now, implement proper
    //                 automatic rebase if the push resulted in a clean rebase
    state.set_parent_self(current_latest);
}

That is correct for the case the TODO names: the server accepts the revision as it stands and only rewrites its revision number, so the same content is stored under a new signature and the next revision has to follow it.

It is wrong after a server-side fast-forward merge. current_latest is then a revision the server built, carrying the other side's work. Re-pointing at it makes this revision's stale tree look like a direct descendant of it, so branch_push takes the ordinary fast-forward path — current_head == state.parent_self() holds legitimately — with no merge, no three-way diff and no conflict check. The branch simply becomes that tree, and everything the other side added is absent from it.

The guard !current_latest.is_zero() is why this has not shown up with a single-revision push: the block cannot fire for the first revision of a push, only from the second on.

Fix

Track the rename instead of the head. Remember the pushed revision as (signature in local history, signature on the server) and follow a parent only when it is exactly that revision under a new signature. After a server-side merge the recorded signature is the one the server stored, which is what the merge holds as its other parent — not necessarily the one the revision has in local history, since this loop may have re-serialized it.

Any other mismatch is now left alone, which sends the revision as it stands and lets the server three-way merge it against a base it already holds — the revision's own parent, which reached the server as the previous merge's second parent. So a chain of N local revisions pushes correctly, one server merge each, rather than the last one winning outright.

Verification

  • New smoke test scripts/test/test_push.py::test_push_fast_forward_merge_two_local_revisions, next to the existing fast-forward-merge tests. Against main it fails with Missing: {'from_other.txt'}; with this change it passes. test_push_fast_forward_merge still passes either way.
  • cargo test -p lore-revision clean, cargo clippy -p lore-revision --all-targets -- -D warnings --no-deps clean, cargo +nightly fmt --check clean.
  • Independently cross-checked through a downstream client (Anchorpoint 2), where the same three scenarios flip as expected: a two-commit submit stops losing the other side's work, a forced-commit chain stops losing it, and the single-revision push is unaffected.

Not covered

  • Links and layers go through the same loop. The change treats them no differently, but the test does not exercise them.
  • One case the test cannot set up deterministically: the server renumbers a revision, and a concurrent push lands before the next revision of the same push goes up. The fix handles it by recording the signature the server stored rather than the one in local history, but that path is reasoned about, not covered.

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

@jochenhz
jochenhz force-pushed the fix/push-reparent-only-on-rewrite branch from 259b7a1 to 300cce1 Compare September 15, 2026 20:47
@jochenhz
jochenhz force-pushed the fix/push-reparent-only-on-rewrite branch from 300cce1 to a6eec66 Compare September 16, 2026 06:43
@jochenhz
jochenhz force-pushed the fix/push-reparent-only-on-rewrite branch from a6eec66 to abe7c41 Compare September 16, 2026 18:29
@jochenhz jochenhz changed the title Only follow a parent the server renumbered, never a moved head lore-revision: Only follow a parent the server renumbered Sep 16, 2026
A push carrying more than one local revision into a branch head that has
moved drops the other side's work. The push succeeds, reports no
conflict, and leaves the overwritten commit in the history, so nothing
looks wrong afterwards.

collect_fragments_and_push moved a revision's parent pointer onto the
value the previous iteration left in current_latest, keeping its tree.
That is correct for the case the TODO describes, where the server only
rewrote the revision number. After a server-side fast-forward merge
current_latest is a revision the server built from the other side's
work, and the reparented revision then looks like a direct descendant:
the server takes the ordinary fast-forward path with no three-way diff,
and the branch becomes a tree that never held the other side's changes.

Track the rename instead. Remember the pushed revision as (signature in
local history, signature on the server) and follow the parent only to
that revision under a new signature. Everything else is pushed as it
stands, so the server merges it against a base it already holds.

The guard !current_latest.is_zero() is why a single-revision push never
hit this: the block cannot fire on the first revision.

Regression test in scripts/test/test_push.py. Against 0.9.0 the second
revision's tree replaces the branch, and the file the other client
pushed is missing from a fresh clone.

Signed-off-by: Jochen Hunz <j.hunz@anchorpoint.app>
@jochenhz
jochenhz force-pushed the fix/push-reparent-only-on-rewrite branch from abe7c41 to 212b90e Compare September 16, 2026 19:12
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