A merge conflict isn’t an error. It’s Git telling you that two people changed the same lines and it has no way to know which change, or which combination, is right. Beginners often treat the markers as a puzzle to make disappear: delete the lines between <<<<<<< and =======, or between ======= and >>>>>>>, and move on. That gets rid of the markers and very often loses someone’s work.
The interview question is usually “how do you resolve a merge conflict?” A complete answer covers the mechanics briefly and then focuses on the real job: figuring out what each side was trying to do and writing code that does both, then proving it.
A conflict, from the start
The repository has a lateFee function charging 2 per day late. Two changes happen in parallel. On main, someone adds a grace period: no fee for up to three days. On cap-fee, someone caps the fee at 30. Merging:
$ git merge cap-fee
Auto-merging fees.js
CONFLICT (content): Merge conflict in fees.js
Automatic merge failed; fix conflicts and then commit the result.
$ git status --short
UU fees.jsUU means “unmerged, both modified.” Opening the file:
export function lateFee(daysLate) {
<<<<<<< HEAD
if (daysLate <= 3) return 0;
return daysLate * 2;
=======
return Math.min(daysLate * 2, 30);
>>>>>>> cap-fee
}The top section is HEAD, the branch you’re on (main), often called “ours.” The bottom is the branch you’re merging in, “theirs.” What’s missing is the most useful piece of information: what the code looked like before either change.
See the base version with zdiff3
Git can show the common ancestor too. Set it once:
git config --global merge.conflictStyle zdiff3For a conflict that already exists, git checkout --conflict=zdiff3 fees.js rewrites the markers in the new style:
export function lateFee(daysLate) {
<<<<<<< ours
if (daysLate <= 3) return 0;
return daysLate * 2;
||||||| base
return daysLate * 2;
=======
return Math.min(daysLate * 2, 30);
>>>>>>> theirs
}Now the intent of each side is clear by comparison with the base. Ours added a line, the grace-period check, and left the multiplication alone. Theirs kept the multiplication and wrapped it in Math.min(..., 30). Neither side removed anything the other needs. (zdiff3 was added in Git 2.35; the older diff3 style shows the same base section with slightly less trimming of common lines.)
When the conflict is in code you didn’t write, look at the commits involved. git log --merge --oneline lists only the commits on either side that touched the conflicted files:
$ git log --merge --oneline
898c96b Waive late fee for a 3-day grace period
ffd0798 Cap late fee at 30Reading those two messages, and the pull requests behind them, tells you the product rules. Here: fees are waived for three days, and fees never exceed 30.
The keep-both trap
The tempting quick fix is to delete the marker lines and keep everything:
export function lateFee(daysLate) {
if (daysLate <= 3) return 0;
return daysLate * 2;
return Math.min(daysLate * 2, 30);
}This is valid JavaScript. It has no syntax error, the build passes, and a linter might at most warn about unreachable code. But the cap never runs:
keep-both: lateFee(2)= 0 lateFee(40)= 80A customer 40 days late is charged 80, which the cap was meant to prevent. This is the mistake interviewers most want you to name: a resolution that compiles is not a resolution that’s correct. The same goes for automatically picking a side. git checkout --theirs fees.js would produce the capped version and silently drop the grace period. Those options exist for files where one side should win outright, like a generated lockfile you’ll regenerate anyway, not for logic.
Writing the combined version
Both rules apply, so the result needs the grace check followed by the capped calculation:
export function lateFee(daysLate) {
if (daysLate <= 3) return 0;
return Math.min(daysLate * 2, 30);
}There’s still a question hiding here that neither branch answered: after the grace period, is the fee for all days late, or only the days after the third? Both branches assumed “all days”, so the combined code keeps that. If it weren’t obvious, the right move would be to ask the people who wrote the two changes, not to guess. Resolving conflicts is sometimes a product decision in disguise.
Then test the combined behaviour, including cases that exercise both rules:
import assert from 'node:assert/strict';
import { lateFee } from './fees.js';
assert.equal(lateFee(3), 0); // grace period (main)
assert.equal(lateFee(4), 8);
assert.equal(lateFee(40), 30); // cap (cap-fee)
console.log('fees ok');$ node fees.test.js
fees okMark the file resolved, finish the merge, then commit the test:
$ git add fees.js
$ git commit --no-edit
$ git add fees.test.js
$ git commit -m "Test grace period and cap together"
$ git log --oneline --graph
* 139396a Test grace period and cap together
* 3f1a543 Merge branch 'cap-fee'
|\
| * ffd0798 Cap late fee at 30
* | 898c96b Waive late fee for a 3-day grace period
|/
* bd131a4 Add late feeThe test went into its own commit after the merge on purpose. Keeping a merge commit limited to the conflict resolution makes it easier to review, because git show --cc 3f1a543 then shows only lines that differ from both parents:
@@@ -1,4 -1,3 +1,4 @@@
export function lateFee(daysLate) {
+ if (daysLate <= 3) return 0;
- return daysLate * 2;
+ return Math.min(daysLate * 2, 30);
}Adding unrelated new code inside a merge commit is sometimes called an “evil merge,” because it’s easy to miss in review and confusing when cherry-picking or reverting later.
Habits that make conflicts smaller and safer
- Bail out when you’re unsure.
git merge --abort(orgit rebase --abort,git cherry-pick --abort) puts everything back to how it was before you started. Nothing is lost. - Integrate often. Merging or rebasing on
maindaily turns one large conflict into several small ones, each of which you understand because the change is fresh. - Separate formatting from logic. A reformat of a whole file conflicts with every other change to that file. Land it on its own, quickly, and tell people.
- Let
rerereremember. Withgit config rerere.enabled true, Git records how you resolved a conflict and reapplies the same resolution if it sees the identical conflict again, which helps when you rebase a long branch repeatedly. Check its work: it replays text, not understanding. - Run the whole test suite after resolving, not just the file you edited. Semantic conflicts, where two changes don’t touch the same lines but break each other, never show markers at all.
Interview practice
You’re rebasing your branch onto main and hit a conflict in config/limits.yaml. Your branch raised max_upload_mb from 10 to 50 for a new video feature. On main, someone renamed the key to upload.max_size_mb and moved it under a new upload: section, keeping the value at 10. What does the correct resolution look like, and how do you check it?
Answer
The two intents are independent. Main’s change is structural: the setting now lives at upload.max_size_mb. My change is a value: the limit should be 50. The resolution keeps main’s new structure and applies my value to it, so the file has an upload: section with max_size_mb: 50 and no old max_upload_mb key. Keeping both keys would leave a dead setting that nothing reads, and taking either side wholesale would drop either the rename or my new limit.
I’d confirm with zdiff3 markers, or git log --merge, that the base had max_upload_mb: 10, to be sure I understand both edits. After resolving, I’d search the code on my branch for any reference to max_upload_mb, since my feature code may still read the old key, and update it to the new path. Then git add config/limits.yaml, git rebase --continue, and run the full tests, including a check that an upload of, say, 40 MB is accepted. If anything about the new structure were unclear, I’d ask the author of the rename before finishing.