Skip to content

rewrite: don't lose content that both sides of a merge contain - #10100

Draft
jefft wants to merge 1 commit into
jj-vcs:mainfrom
jefft:fix-criscross-merges-6369
Draft

rewrite: don't lose content that both sides of a merge contain#10100
jefft wants to merge 1 commit into
jj-vcs:mainfrom
jefft:fix-criscross-merges-6369

Conversation

@jefft

@jefft jefft commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

A merge can silently delete content that both of its sides contain. No
conflict marker is produced, and because a merge commit's diff is taken
against its auto-merged parents, there is no diff either -- nothing records
that the content was ever there. git does not behave this way on the same
history. Reported as #6369.

It takes two fixes that each add the same line, merged together twice, and
those two merges later merged. Only two parents are needed at the end, so
this is not an octopus-merge problem:

       base          a file WITHOUT some line
       /  \
      A    B         two fixes, each independently ADDING that line
      |\  /|
      | \/ |
      | /\ |
 AB_for_X  AB_for_Y  two *separate* merges of A and B; both have the line
       \    /
         XY          the line disappears here, with no conflict

The same-change rule resolves AB_for_X and AB_for_Y to the line being
present, so they and their merge bases A and B all carry it. The base is
then never collapsed to a single tree:
merge_commit_trees_no_resolve_without_repo() asks
find_recursive_merge_commits() for a Merge and flattens it, which
moves each of the base merge's adds onto the remove side:

    adds    [AB_for_X(line), base(no line), AB_for_Y(line)]
    removes [A(line), B(line)]

trivial_merge() counts values as a signed multiset, +1 per add and -1 per
remove. The line nets 0, the version without it nets +1, and exactly one
value at +1 is the "resolved cleanly" case. A value that cancels to zero
disappears silently: the counting cannot tell "someone deleted this" from
"the base assignment put it on the wrong side".

Collapse the base instead, as martinvonz suggested in #6369: recurse over
trees rather than commit ids, and reduce each recursive base to exactly one
tree. The new merge_commit_ids_no_resolve() builds the outer terms
directly, and each recursive base is collapsed by
MergedTree::collapse_conflicts(), a new method next to MergedTree::resolve():
it applies the automatic resolutions resolve() would apply, then keeps the
first side of whatever remains. The merge becomes the ordinary three-way
adds [AB_for_X, AB_for_Y], removes [virtual_base], where the line nets +1
and survives. Merge base selection is unchanged.

Keeping the first side is what git's "resolve" strategy effectively does
when it picks one of several merge bases. The choice of side only affects
which diffs the outer merge sees and which borderline merges auto-resolve
versus conflict, never which content the outer merge can keep: every
parent's own value is still an add. Materializing the leftover conflicts
into markers, as git's "recursive"/"ort" strategies do, is not an option:
git never parses a conflict back, but jj round-trips conflicts through the
working copy, and a base carrying markers can produce an outer conflict
whose materialization no longer parses, leaving the next snapshot to store
the marker text as ordinary content. A resolve() variant that also resolves
down to hunk level would shrink the set of paths needing the first-side
fallback; that belongs with #4152.

Merges with a single, already-resolved merge base are unaffected.
Criss-cross merges get smaller unresolved term lists --
test_merge_criss_cross's goes from five sides to three -- while their
resolved results are unchanged. find_recursive_merge_commits() is no longer
used by the tree merge, but is left in place and still tested since it is
public API.

The merge future is boxed at the merge_commit_trees_no_resolve_without_repo()
boundary so the deeper future type doesn't reach consumers. The
custom-command example already overflowed rustc's default recursion limit
under rustc 1.98.

Fixes #6369.
Part of #7640.

Assisted-by: Opus 5 via Claude Code

Checklist

If applicable:

  • I have updated CHANGELOG.md
  • I have updated the documentation (README.md, docs/, demos/)
  • I have updated the config schema (cli/src/config-schema.json)
  • I have added/updated tests to cover my changes
  • I fully understand the code that I am submitting (what it does,
    how it works, how it's organized), including any code drafted by an LLM.
  • For any prose generated by an LLM, I have proof-read and copy-edited with
    an eye towards deleting anything that is irrelevant, clarifying anything
    that is confusing, and adding details that are relevant. This includes,
    for example, commit descriptions, PR descriptions, and code comments.

@jefft
jefft force-pushed the fix-criscross-merges-6369 branch from 8399425 to 32d8851 Compare September 2, 2026 00:06
@jefft

jefft commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

In my codebase this fix not only made lost lines reappear (the bun@latest dependency), but simplified the other conflicted parts:
image

@jefft
jefft force-pushed the fix-criscross-merges-6369 branch 4 times, most recently from 688b8d0 to 90a50ce Compare September 2, 2026 01:43
@jefft jefft changed the title merge: build a virtual merge base instead of flattening a conflicted one rewrite: don't lose content that both sides of a merge contain Sep 2, 2026
@jefft
jefft force-pushed the fix-criscross-merges-6369 branch 2 times, most recently from 8d5621b to 55db199 Compare September 2, 2026 03:04
Comment thread lib/src/rewrite.rs
.await
// TODO: indexing error shouldn't be a "BackendError"
.map_err(|err| BackendError::Other(err.into()))?;
let base_tree = Box::pin(merge_commit_ids_no_resolve(store, index, &ancestor_ids)).await?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recursion using the machine stack will crash Google's servers. Can you rewrite this code to keep track of the stack on the heap instead (e.g. in a Vec)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recursion using the machine stack will crash Google's servers.

So it did (crash the CI). For this PR though, could we just Box::pin as in the revised code?

Box::pin(merge_commit_ids_no_resolve(store, index, &commit_ids)).await

That fixes the compile-time type depth problem, which is what broke CI.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, I strongly suspect that will still stash Google's servers. You can find many examples of PRs fixing similar problems in this list (but not all of them are fixing stack overflows).

Comment thread lib/src/rewrite.rs Outdated
Comment thread lib/src/rewrite.rs Outdated
Comment on lines +200 to +209
let tree = tree.resolve().await?;
if tree.tree_ids().is_resolved() {
return Ok(tree);
}
let conflicts: Vec<_> = tree.conflicts().collect();
let mut builder = MergedTreeBuilder::new(tree);
for (path, values) in conflicts {
builder.set_or_remove(path, Merge::resolved(values?.first().clone()));
}
builder.write_tree().await

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Arbitrarily picking the first value seems wrong. Perhaps we need a tree.resolve() version that also updates the tree with any automatic resolutions. IIRC, tree.resolve() currently either fully resolves a file conflict or leaves it fully unresolved. FYI, we have talked about going in the opposite direction as well, making tree.resolve() leaving the whole tree either fully resolved or fully unresolved (#4152). I think it seems cleaner to have one method for resolving all the way down to hunk level when possible and one method for not resolving anything at all.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Arbitrarily picking the first value seems wrong.

Agreed.

Please see the revised code, which moves the collapse into a new MergedTree::collapse_conflicts(), right next to resolve(). It applies the automatic resolutions resolve() would apply, then keeps the first side of whatever remains. The policy is now named, documented and unit-tested, instead of implicit in the rewrite logic.

This should play nicely with #4152. E.g. if you implement hunk-level resolving, the change inside collapse_conflicts() is just its first line:

let tree = self.resolve().await?;
// becomes:
let tree = self.resolve_hunks().await?; // #4152 hunk-level

and the first-side fallback stays as the residue policy, firing only on paths even hunk-level merging can't combine.

(Confession: I am a barely sentient PHP developer with a LLM. When I unchecked the "I fully understand the code that I am submitting" checkbox in this PR, I really meant it. I do have an exceedingly merge'y codebase, where every second jj rebase or jj absorb passes through a cursed criss-cross merge, causing conflicts and pain, so I am keen to see this fixed and happy to test follow-ups on real history.)

@jefft
jefft force-pushed the fix-criscross-merges-6369 branch 3 times, most recently from ca53f1e to 3cc2862 Compare September 2, 2026 06:03
@yuja

yuja commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

FWIW, we should probably disable the same-change rule during auto-merging. It seems better to require an explicit jj resolve call to do a lossy merge.

A merge can silently delete content that both of its sides contain. No
conflict marker is produced, and because a merge commit's diff is taken
against its auto-merged parents, there is no diff either -- nothing records
that the content was ever there. git does not behave this way on the same
history. Reported as jj-vcs#6369.

It takes two fixes that each add the same line, merged together twice, and
those two merges later merged. Only two parents are needed at the end, so
this is not an octopus-merge problem:

```
       base          a file WITHOUT some line
       /  \
      A    B         two fixes, each independently ADDING that line
      |\  /|
      | \/ |
      | /\ |
 AB_for_X  AB_for_Y  two *separate* merges of A and B; both have the line
       \    /
         XY          the line disappears here, with no conflict
```

The same-change rule resolves AB_for_X and AB_for_Y to the line being
present, so they and their merge bases A and B all carry it. The base is
then never collapsed to a single tree:
merge_commit_trees_no_resolve_without_repo() asks
find_recursive_merge_commits() for a Merge<CommitId> and flattens it, which
moves each of the base merge's adds onto the remove side:

```
    adds    [AB_for_X(line), base(no line), AB_for_Y(line)]
    removes [A(line), B(line)]
```

trivial_merge() counts values as a signed multiset, +1 per add and -1 per
remove. The line nets 0, the version without it nets +1, and exactly one
value at +1 is the "resolved cleanly" case. A value that cancels to zero
disappears silently: the counting cannot tell "someone deleted this" from
"the base assignment put it on the wrong side".

Collapse the base instead, as martinvonz suggested in jj-vcs#6369: recurse over
trees rather than commit ids, and reduce each recursive base to exactly one
tree. The new merge_commit_ids_no_resolve() builds the outer terms
directly, and each recursive base is collapsed by
MergedTree::collapse_conflicts(), a new method next to MergedTree::resolve():
it applies the automatic resolutions resolve() would apply, then keeps the
first side of whatever remains. The merge becomes the ordinary three-way
`adds [AB_for_X, AB_for_Y], removes [virtual_base]`, where the line nets +1
and survives. Merge base selection is unchanged.

Keeping the first side is what git's "resolve" strategy effectively does
when it picks one of several merge bases. The choice of side only affects
which diffs the outer merge sees and which borderline merges auto-resolve
versus conflict, never which content the outer merge can keep: every
parent's own value is still an add. Materializing the leftover conflicts
into markers, as git's "recursive"/"ort" strategies do, is not an option:
git never parses a conflict back, but jj round-trips conflicts through the
working copy, and a base carrying markers can produce an outer conflict
whose materialization no longer parses, leaving the next snapshot to store
the marker text as ordinary content. A resolve() variant that also resolves
down to hunk level would shrink the set of paths needing the first-side
fallback; that belongs with jj-vcs#4152.

Merges with a single, already-resolved merge base are unaffected.
Criss-cross merges get smaller unresolved term lists --
test_merge_criss_cross's goes from five sides to three -- while their
resolved results are unchanged. find_recursive_merge_commits() is no longer
used by the tree merge, but is left in place and still tested since it is
public API.

The merge future is boxed at the merge_commit_trees_no_resolve_without_repo()
boundary so the deeper future type doesn't reach consumers. The
custom-command example already overflowed rustc's default recursion limit
under rustc 1.98.

Fixes jj-vcs#6369.
Part of jj-vcs#7640.

Assisted-by: Opus 5 via Claude Code
@jefft
jefft force-pushed the fix-criscross-merges-6369 branch from 3cc2862 to d6d2e07 Compare September 2, 2026 13:51
@martinvonz

Copy link
Copy Markdown
Contributor

FWIW, we should probably disable the same-change rule during auto-merging. It seems better to require an explicit jj resolve call to do a lossy merge.

So if I have commits A and B in my stack and commit A gets rebased onto trunk by the remote (perhaps with a different change id), then rebasing onto trunk will result in conflicts in my rebased version of A, right? I agree that it's more correct, but I'm also a bit worried that it will be annoying in many cases. Perhaps it's not so bad now that we detect if the change has been rebased when the change id is preserved.

@yuja

yuja commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

So if I have commits A and B in my stack and commit A gets rebased onto trunk by the remote (perhaps with a different change id), then rebasing onto trunk will result in conflicts in my rebased version of A, right? I agree that it's more correct, but I'm also a bit worried that it will be annoying in many cases.

If we can jj resolve -r REBASED_RANGE in batches (or automate it by jj git sync), I think the outcome would be acceptable.

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 crisscross merge leads to losing changes

3 participants