Git Merge Conflict Resolution Case Study
Key takeaways
Resolving hundreds of merge conflicts when merging a long-lived refactor into main: strategies, categorization, automation, tests, and safe integration.
Introduction
After three months on refactor/new-architecture, merging into main caused conflicts in hundreds of files. This post explains how we resolved them systematically.
What you will learn
- Strategies for large branch merges
- How to reduce conflict volume
- Phased resolution techniques
- Testing strategy after integration
Context
Refactor scope
- Branch:
refactor/new-architecture - Duration: Dec 2025 – Mar 2026 (~3 months)
- Changes:
- Directory layout (
src/→app/) - Renames (
UserManager→UserService) - Dependency injection
- Tests rewritten
- Directory layout (
main kept moving
- ~20 new features
- ~50 bug fixes
- ~10 dependency bumps
First merge attempt
$ git checkout refactor/new-architecture
$ git merge main
CONFLICT (content): Merge conflict in src/user_manager.cpp
...
Automatic merge failed; fix conflicts and then commit the result.
$ git status | grep "both modified" | wc -l
247
Problem: resolving 247 files in one sitting is not realistic.
It helps to understand why a refactor produces so many conflicts even when “nobody touched the same logic”. Git merges line by line against the common ancestor. A rename of UserManager to UserService changes every line that mentions the class; a directory move changes every #include; a formatter run changes whitespace across whole files. Any bug fix on main that touches one of those lines now conflicts, even if it is semantically unrelated. The number of conflicting files therefore says more about how wide the refactor’s diff is than about how hard the conflicts are — most of them turn out to be mechanical.
Before going further, we turned on two settings that pay for themselves on any large merge:
$ git config rerere.enabled true # record and replay resolutions
$ git config merge.conflictStyle zdiff3 # show the common ancestor in markers (Git 2.35+)
rerere (“reuse recorded resolution”) stores each resolved conflict hunk and applies it automatically when the same conflict appears again. That matters here because the first attempt was aborted (git merge --abort) and repeated several times while we worked out the approach; without rerere, every retry would mean redoing all completed resolutions. zdiff3 adds a ||||||| section with the original text, so you can see what each side changed instead of guessing from two versions.
Strategy
- Merge main into the refactor branch first (not the reverse on a dirty main)
- Categorize conflicts
- Trivial / mechanical first
- Logic conflicts one by one
- Green tests before merging to main
Why merge into the feature branch?
# Preferred: on refactor branch
$ git checkout refactor/new-architecture
$ git merge main
# Fix here; main stays healthy until the end
# Risky: merge huge branch straight into main first
$ git checkout main
$ git merge refactor/new-architecture
# main can be broken for a long time during resolution
Strictly, a merge that is in progress never “breaks” main for other people — nothing is pushed until you commit. The real risk of the second form is the temptation to push a half-verified merge commit to main because people are waiting. Doing the integration on the feature branch keeps the unfinished state private, lets CI run on the result, and turns the final step into a normal PR whose diff reviewers can inspect. It also produces a merge commit on the feature branch that records exactly how each conflict was resolved, which is valuable later when git blame points at a line that changed during the merge.
Step 1: merge main in
$ git checkout refactor/new-architecture
$ git merge main --no-commit --no-ff
$ git status > conflicts.txt
Counts
$ grep "both modified" conflicts.txt | wc -l
189
$ grep "deleted by us" conflicts.txt | wc -l
34
$ grep "added by them" conflicts.txt | wc -l
24
The three categories mean different things. both modified is the classic content conflict. deleted by us means the refactor branch deleted (or moved) a file that main modified — Git cannot know where main’s change should go. added by them in a conflicted state usually means main added a file at a path that collides with something on the refactor branch, or that Git’s rename detection could not match it.
Git’s default merge strategy (ort, the default since Git 2.34) does directory rename detection: if the refactor moved nearly everything from src/ to app/, a brand-new file that main added under src/ is placed under app/ automatically, with a CONFLICT (file location) notice asking you to confirm. Rename detection is similarity-based, though; a file that was moved and heavily rewritten on the refactor branch drops below the default 50% similarity threshold and shows up as a delete/modify conflict instead. git merge -X find-renames=30% main lowers the threshold, at the risk of pairing unrelated files — check the reported renames before trusting them.
Step 2: prioritize
Example classifier:
import subprocess
conflicts = subprocess.check_output(
['git', 'diff', '--name-only', '--diff-filter=U']
).decode().splitlines()
categories = {
'rename': [],
'trivial': [],
'logic': [],
'delete': [],
}
for file in conflicts:
if 'test' in file:
categories['trivial'].append(file)
elif file.endswith('.h') or file.endswith('.hpp'):
categories['rename'].append(file)
else:
categories['logic'].append(file)
Rough outcome: renames, trivial (imports/tests), logic, delete/modify.
The path-based heuristic is only a first cut — “the file name contains test” does not make a conflict trivial, and headers are not automatically rename conflicts. A more reliable classification uses the two-letter status codes from git status --porcelain: UU (both modified), UD / DU (modified on one side, deleted on the other), AA (both added). The delete/modify cases (UD, DU) always need a human, because someone must decide where the other side’s change now belongs. For the UU files, counting conflict hunks per file (grep -c '^<<<<<<<') and looking at a sample of hunks is what actually tells you whether a file is mechanical (import lines, renamed identifiers) or contains a logic conflict.
Step 3: mechanical fixes
Import path conflicts
Prefer the new layout from the refactor:
$ git checkout --ours src/some_file.cpp
Tests fully rewritten on refactor
$ git checkout --ours tests/*.cpp
Batch
$ for file in $(cat trivial_conflicts.txt); do
git checkout --ours "$file"
git add "$file"
done
These commands need a loud warning, because this is where large merges most often lose work. git checkout --ours <file> takes the whole file from the current branch, discarding every change main made to it — not just the conflicting hunks. If main fixed a bug in some_file.cpp and the only conflict was an #include line, --ours silently throws the bug fix away, and nothing in the build or the merge output tells you. The same applies to tests/*.cpp: tests that main added for its ~50 bug fixes disappear, which removes exactly the safety net you need after the merge.
Two safer alternatives exist. git merge -X ours main resolves only the conflicting hunks in favor of the current branch and still takes main’s non-conflicting changes in the same file (note the difference: -X ours is a strategy option; -s ours is a different strategy that ignores the other branch entirely). And for truly mechanical conflicts such as renamed identifiers, taking “ours” and then re-applying main’s diff (git diff <merge-base> main -- <file>) to the new code preserves both sides. Before bulk-resolving anything, list what main changed in those files with git log --oneline <merge-base>..main -- <files> — if the list contains fixes, the files are not trivial.
Also keep in mind that “ours” and “theirs” flip during a rebase: when rebasing the refactor branch onto main, “ours” is main (the branch being rebased onto) and “theirs” is the commit being replayed. A script written for a merge does the opposite of what you want if it is reused during a rebase.
Step 4: manual merges
Both sides changed behavior
Merge refactor structure + main features (e.g. caching):
class UserService {
std::shared_ptr<Database> db_;
std::unordered_map<int, User> cache_;
public:
User getUser(int id) {
if (auto it = cache_.find(id); it != cache_.end()) {
return it->second;
}
auto user = db_->query("SELECT * FROM users WHERE id = ?", id);
cache_[id] = user;
return user;
}
};
This is the kind of conflict that cannot be automated: the refactor renamed the class and introduced an injected Database, while main added a cache to the old UserManager::getUser. Neither side’s version is correct alone; the resolution is new code that neither branch contained. That is also why these resolutions deserve review — a merge commit that invents code is as risky as any other change, but reviewers often skim merge commits.
Resolving a feature into a new structure also forces questions the original author never faced. Main’s cache was presumably fine as a member of a singleton manager; is UserService still a single instance under dependency injection, or does each consumer get its own copy with its own cache? Does any code path that updates a user now need to invalidate cache_? Is getUser called from multiple threads, where an unguarded unordered_map is a data race? When a merged behavior looks right but tests fail in odd ways afterwards, these interactions are the usual cause, and it pays to ask the author of the main change rather than guess.
Step 5: verify
$ cmake --build build
$ cd build && ctest
$ ./integration_tests.sh
$ ./benchmark.sh
A clean build proves less than it seems after a merge like this. Conflicts that Git resolved automatically can still be wrong: if main added a call to UserManager::load() in a file the refactor never touched, the merge succeeds without a conflict, and the build fails (the lucky case) — or, with dynamic languages or reflection-based wiring, it fails only at runtime. That is why the full test suite, including the tests main added, has to pass on the merge result, and why git diff --check plus a search for leftover markers (git grep -n '^<<<<<<<\|^>>>>>>>') belong in the same step.
Commit the integration
$ git add .
$ git commit -m "Merge branch 'main' into refactor/new-architecture
Resolved conflicts; preserved main features; tests green."
Step 6: merge to main
Open PR, review conflict resolutions, then:
$ git checkout main
$ git merge refactor/new-architecture --no-ff
$ git push origin main
Lessons
Takeaways
- Sync often—merge main weekly (or rebase if policy allows)
- Split work—multiple smaller PRs when possible
- Classify conflicts (trivial vs logic)
- Never skip tests after resolution
Long-running branches
$ git checkout refactor/new-architecture
$ git merge main
# small, frequent integrations beat rare huge ones
Tips
- Separate rename-only commits from logic commits
.gitattributesfor lockfiles/generated assetsgit diff --checkandgrep '<<<<<<< HEAD'before commit
Conflict patterns
Same function, both edited
Combine validation from main with new names/types from refactor.
File moved on refactor, edited on main
$ git show main:src/user_manager.cpp > /tmp/main_version.cpp
# Port new methods into app/user_service.cpp
$ git rm src/user_manager.cpp
$ git add app/user_service.cpp
Include paths
Unify on new paths; re-home any new headers from main.
Tools
VS Code merge editor, vimdiff, merge.conflictstyle diff3 (or zdiff3), and git rerere for repeated merges.
Two more commands are useful when reviewing someone else’s large merge. git show --remerge-diff <merge-commit> (Git 2.36+) shows how the committed merge differs from what Git would have produced automatically — in effect, exactly the conflict resolutions and any extra edits hidden in the merge commit. And git log --merge during an unfinished merge lists the commits on both sides that touched the conflicted files, which tells you whose intent you are trying to reconcile. The bottom line from this project is that the tooling is rarely the bottleneck; the time goes into understanding what each side meant, which is why frequent small syncs, when each side’s change is still fresh in someone’s memory, beat any clever resolution technique.
Closing thoughts
- Merge main into the feature branch to protect main
- Classify for throughput
- Resolve in phases to reduce mistakes
- Test to prevent regressions
Don’t try to resolve everything in one undifferentiated batch.
FAQ
Q1. merge vs rebase on long branches? Often merge for shared long branches; rebase rewrites history.
Q2. Too many conflicts? Split the branch: structure first, renames next, behavior last—multiple PRs.
Q3. New files on main? Port them into the new layout on the refactor branch.
Related Articles
Checklists
Large merge
- Backup branch
- Count conflicts
- Classify
- Trivial first
- Manual one by one
- Build
- Tests
- Review
- Merge
- Monitor
Per-file resolution
- No conflict markers left
- Both sides’ intent preserved where needed
- Build + targeted tests
- Final
git diffsanity check