Resolving criss-cross merges Jump to heading
A criss-cross merge happens when two branches merge each other: release merges main to pick up a fix, and around the same time main merges release to pick up a patch. Each branch now contains a merge whose parents include the otherβs commits, and when they are merged again there is no single best common ancestor β there are two, each equally close. Git handles this by merging the two bases together first, creating a virtual ancestor, and then using that as the base. Usually the result is fine. Sometimes the virtual ancestor itself contains conflict markers, and the conflicts you see make no sense. This page explains what is happening and how to resolve and prevent it, within 3-way merge fundamentals.
When to use this approach Jump to heading
git merge-base --allprints more than one commit for the branches you are merging.- Conflict markers contain nested markers, or the base section contains text neither side ever had.
- Two long-lived branches β main and a release branch, or two integration branches β merge in both directions.
- You are designing a branching model and want to avoid the pattern, as covered in GitFlow vs GitHub Flow comparison.
Step 1 β Confirm there is more than one base Jump to heading
The defining symptom is several merge bases.
git merge-base --all main release/2.4
# a1b2c3d...
# e4f5a6b...
git log --oneline --graph --boundary main...release/2.4 | head -30 Step 2 β See the virtual ancestor Git will use Jump to heading
The default ort strategy merges the bases recursively into a temporary commit and uses that as the base. You can reproduce it to understand the conflicts it causes.
# Merge the two bases yourself, exactly as Git would, without touching any branch
bases=$(git merge-base --all main release/2.4 | tr '\n' ' ')
set -- $bases
git merge-tree --write-tree "$1" "$2"
# The first line is the tree ID of the virtual ancestor; following lines list conflicts in it If the bases merge cleanly, the virtual ancestor is a normal tree and the final merge behaves normally. If they conflict, the virtual ancestor contains conflict markers as ordinary text, and that text then appears in the base section of your final conflicts.
# Verification: inspect a file from the virtual ancestor
tree=$(git merge-tree --write-tree "$1" "$2" | head -1)
git show "$tree:config/app.yml" | grep -n '<<<<<<<' || echo "virtual base is clean for this file" Step 3 β Read nested conflicts calmly Jump to heading
When the virtual ancestor itself had a conflict, your final conflict shows that inner conflict inside the base section, with longer markers to distinguish the levels.
<<<<<<< HEAD
max_connections = 200
||||||| merged common ancestors
<<<<<<<<< Temporary merge branch 1
max_connections = 120
=========
max_connections = 150
>>>>>>>>> Temporary merge branch 2
=======
max_connections = 150
>>>>>>> release/2.4 Read it the usual way, treating the inner conflict as βthe base was either 120 or 150β. Theirs is 150, identical to one base, so theirs probably did not change this line. Ours changed it to 200. The likely resolution is 200 β but because the history was contested, confirm with whoever made the release-branch merge.
Step 4 β Resolve, then record why Jump to heading
Resolve file by file. For each conflict, compare ours and theirs against both candidate bases; a side that matches either base made no change. Record anything non-obvious in the merge commit message, because the next person to look at this history will face the same puzzle.
git merge release/2.4
# resolve each file
git add config/app.yml
git commit -e # add a note: "criss-cross: bases a1b2c3d and e4f5a6b disagreed on max_connections" If you want to see how a resolution differs from what Git would have produced on its own, git show --remerge-diff on the merge commit shows exactly that; see reviewing conflict resolutions with remerge-diff.
Step 5 β Prevent criss-crosses with one-way flow Jump to heading
Criss-crosses come from merges in both directions between long-lived branches. A one-way rule removes them: fixes flow in one direction only, typically from the oldest supported branch forward, or from main to release branches by cherry-pick.
# A cheap guard: fail if a branch has more than one merge base with main
n=$(git merge-base --all origin/main HEAD | wc -l)
[ "$n" -le 1 ] || { echo "criss-cross history with main ($n bases) β check merge direction"; exit 1; } The forward-only patterns are covered in cherry-picking hotfixes across release branches.
Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Is a criss-cross merge a problem if it merges cleanly? Jump to heading
Not by itself. Gitβs recursive handling of multiple bases works well most of the time. It becomes a problem when the bases conflict with each other, which is more likely the longer both branches live and the more they diverge.
Would the resolve strategy avoid this? Jump to heading
The resolve strategy picks one of the bases instead of merging them. That avoids nested markers but can produce worse results, because it ignores changes captured only in the other base. The default is the better choice.
Can rebasing remove a criss-cross? Jump to heading
Rebasing one branch onto the other rewrites its history so it no longer contains the cross-merge, leaving a single base. That is only appropriate for branches nobody else has based work on; for shared long-lived branches, prevention is the practical fix.
Related Jump to heading
- 3-Way Merge Fundamentals β the parent topic.
- Finding the Merge Base and Why It Matters β the single-base case this extends.
- Supporting Multiple Maintained Versions β branch models where criss-crosses usually arise.
- Choosing Between the ORT and Recursive Strategies β how the default strategy builds the virtual ancestor.