Rohan and Aditi are both editing the same file, grades.py, on the same afternoon. Neither knows the other is doing it. Rohan is adding a rule so that students scoring 80 or above get grade "B1". Aditi, working from an older syllabus draft, is adding almost the same rule — except she uses a cutoff of 70. Both of them branch off the project at the exact same commit, edit the exact same spot in the file, and commit their work. Rohan's branch gets merged into main first, without any trouble. Twenty minutes later, Aditi tries to merge her branch in too, and Git stops her cold:
Auto-merging grades.py
CONFLICT (content): Merge conflict in grades.py
Automatic merge failed; fix conflicts and then commit the result.
This is not a crash and it is not a bug in Git. It is Git honestly telling you: "Two humans changed the same spot in different ways, and I refuse to guess which one you want." Learning to read that moment correctly — instead of panicking or randomly deleting lines until the red text goes away — is the actual skill this chapter teaches. We will resolve Rohan and Aditi's conflict by hand, trace exactly what Git writes into the file and why, and then look closely at two ways students commonly get the resolution wrong even after the conflict "looks" fixed.
Why most merges need no conflict at all
Before we can understand a conflict, we need to understand what a normal merge does, because a merge conflict is just the case where Git's normal trick fails.
Every commit in Git points backward to the commit(s) it came from. When Rohan runs git checkout -b rohan-grading, he creates a new branch name pointing at the current commit — call it C0 — and starts adding commits on top of it. If nobody else touches main in the meantime, merging rohan-grading back into main is trivial: main is just moved forward to point at Rohan's latest commit. No content is combined, because there was never a second version to compare against. Git calls this a fast-forward merge, and it is what happens most of the time on a small project:
$ git checkout main
Switched to branch 'main'
$ git merge rohan-grading
Updating 9f8a2c1..3d4e5f6
Fast-forward
grades.py | 2 ++
1 file changed, 2 insertions(+)
Notice the stat line: 2 insertions, 0 deletions. Rohan only added two new lines (an elif and a return); he did not touch anything else, so there is nothing to compare or combine — main simply catches up.
Aditi's situation is different, and this is the part students often miss: her branch, aditi-grading, was also created from C0, but by the time she tries to merge, main has already moved on to Rohan's commit. main and aditi-grading have now diverged — each has commits the other does not have. A fast-forward is impossible, because moving main's pointer forward to Aditi's commit would silently throw away Rohan's work. Git has to actually combine the two histories. This is called a three-way merge, and the "three" refers to the three versions of the file Git looks at for every changed region:
- Base — the version at the last commit both branches share in common (here,
C0, before either student touched the grading function). - Ours — the version on the branch you are currently sitting on when you run
git merge(here,main, which since the fast-forward now equals Rohan's commit). - Theirs — the version on the branch you are merging in (here,
aditi-grading).
Git's merge algorithm walks through the file region by region and applies one simple rule to each region: if only one side changed a region relative to the base, take that side's version automatically — if both sides changed the same region, and changed it to different content, stop and ask a human. That single rule explains everything that follows in this chapter. It is not that Git is bad at guessing; it is that Git refuses to guess when both humans touched the same lines with different intent, because picking wrong there could silently corrupt logic that a human needs to reason about.
Setting up the exact conflict
Here is the base version of grades.py that both students started from, at commit C0:
def grade(marks):
if marks >= 90:
return "A1"
else:
return "C1"
Rohan's branch inserts a new band right after the "A1" check:
def grade(marks):
if marks >= 90:
return "A1"
elif marks >= 80:
return "B1"
else:
return "C1"
Aditi's branch, started from the same C0 before Rohan's change existed anywhere, inserts a band in the same spot but with a different cutoff:
def grade(marks):
if marks >= 90:
return "A1"
elif marks >= 70:
return "B1"
else:
return "C1"
Both insertions land in exactly the same place: after the line return "A1" and before the line else:. In the base file, there was nothing at all between those two lines. Both branches added two lines there — but different two lines. This is precisely the "both sides changed the same region differently" case, so when Aditi runs the merge on main (which is now Rohan's commit), Git cannot silently choose:
$ git checkout main
$ git merge aditi-grading
Auto-merging grades.py
CONFLICT (content): Merge conflict in grades.py
Automatic merge failed; fix conflicts and then commit the result.
Checking the repository's status confirms exactly what is unresolved and what Git expects next:
$ git status
On branch main
You have unmerged paths.
(fix conflicts and run "git commit")
(use "git merge --abort" to abort the merge)
Unmerged paths:
(use "git add <file>..." to mark resolution)
both modified: grades.py
no changes added to commit (use "git add" and "git commit -a")
"Both modified" is the key phrase: it does not mean the file is broken, it means both branches touched it since the base, and Git needs you to say which content survives.
Reading the conflict markers
Open grades.py and this is what you will actually see. Git has not deleted either side's work — it has written both versions into the file, wrapped in markers, so you can decide:
def grade(marks):
if marks >= 90:
return "A1"
<<<<<<< HEAD
elif marks >= 80:
return "B1"
=======
elif marks >= 70:
return "B1"
>>>>>>> aditi-grading
else:
return "C1"
Each marker is exactly seven repeated characters, which matters because it is how Git's own tooling recognizes them:
<<<<<<< HEADopens the block and labels it — this is ours, the content currently on the branch you're standing on.HEADis simply Git's name for "the commit you currently have checked out."=======is the divider between the two sides.>>>>>>> aditi-gradingcloses the block and names the other side — this is theirs, the branch you passed togit merge.
Everything between <<<<<<< HEAD and ======= is Rohan's two lines, unedited. Everything between ======= and >>>>>>> aditi-grading is Aditi's two lines, unedited. The context lines above and below (if marks >= 90:, return "A1", else:, return "C1") are outside the markers because both branches agree on them — Git only wraps the region that actually differs, not the whole file and not even the whole function.
Going one layer deeper: diff3 markers
The default view above answers "what does each side want," but not "what did this region look like before either of them touched it." For that, Git has a second, more detailed conflict style, turned on with:
$ git config --global merge.conflictStyle diff3
With this setting, the same conflict is written with an extra section showing the common ancestor's content in between:
def grade(marks):
if marks >= 90:
return "A1"
<<<<<<< HEAD
elif marks >= 80:
return "B1"
||||||| 1a2b3c4
=======
elif marks >= 70:
return "B1"
>>>>>>> aditi-grading
else:
return "C1"
The ||||||| section (also seven characters, using pipes) shows the base version of this region — and here it is empty, with nothing printed between ||||||| and =======, because in the original C0 file there were zero lines between return "A1" and else:. Both students inserted into empty space rather than editing an existing line. That is genuinely useful information: it tells you this is a "two people added different new things in the same spot" conflict, not a "two people changed the same existing line differently" conflict. When the base section is not empty, diff3 lets you compare all three versions side by side and see precisely which words changed on each branch relative to the original — extremely useful when a conflict spans a long paragraph of code and you can no longer tell, just from HEAD and theirs, what the original intent even was.
Resolving it: deciding, not just deleting
Resolving a conflict means editing the file down to the single version you actually want, then removing all three marker lines completely — the markers are not valid Python and Git will happily let you commit broken code if you leave them in. Here, the team's finalized syllabus uses an 80-mark cutoff, so the correct resolution keeps Rohan's block and discards Aditi's outdated one entirely:
def grade(marks):
if marks >= 90:
return "A1"
elif marks >= 80:
return "B1"
else:
return "C1"
Notice what this resolution required that Git could never have done on its own: knowing that 70 was a draft value and 80 was the final policy. That is exactly the judgment call Git's rule was designed to hand off to a human instead of guessing.
Once the file looks the way you want, tell Git the conflict is resolved and finish the merge:
$ git add grades.py
$ git commit -m "Merge aditi-grading into main; keep 80-mark cutoff per finalized policy"
git add here does not mean "start tracking a new file" — on an already-tracked file mid-merge, it specifically means "I have resolved this file's conflict; stage my resolved version." The commit that results is special: it has two parent commits instead of the usual one — Rohan's commit and Aditi's commit both feed into it. That two-parent shape is literally how Git remembers, forever, that this point in history is where two diverged lines of work rejoined into one.
Two mistaken beliefs, corrected
Misconception 1: "If I just delete the marker lines and keep everything else, I've resolved the conflict safely." This feels safe because no content is thrown away, but it is one of the most common ways students introduce silent bugs. Suppose Aditi, resolving the conflict above, simply deletes the three marker lines and leaves both code blocks in place, in the order Git printed them:
def grade(marks):
if marks >= 90:
return "A1"
elif marks >= 80:
return "B1"
elif marks >= 70:
return "B1"
else:
return "C1"
This looks harmless — surely the first matching branch just wins and the second elif never runs? Trace it for a student who scored 72. Is 72 >= 90? No. Is 72 >= 80? No, 72 is less than 80, so this branch is skipped too. Is 72 >= 70? Yes — this branch does fire, and the function returns "B1". Both elif lines are reachable, not just the first: the second one is exactly the region where the first one's condition is false but the second one's is true, which for marks >= 80 versus marks >= 70 is precisely the range [70, 80). So this "safe-looking" resolution silently reproduces part of Aditi's rejected 70-mark cutoff anyway — every student scoring 70 through 79 gets bumped up to B1, which is almost certainly not what either developer intended once the team had settled on 80 as the real cutoff. "Keep both blocks" is never a substitute for reading what each block actually decides; it just postpones the decision Git was asking you to make, and hides it inside a branch that only fires for a narrow, easy-to-miss range of inputs.
Misconception 2: "HEAD is always my code and the other name is always the code I don't want." This happens to be true during a git merge, but it flips during a git rebase, and students who memorize "HEAD = mine" as a fixed rule get burned the first time they rebase. During git merge, you stay on your current branch and pull another branch's commits in, so HEAD/"ours" is the branch you started on and "theirs" is the one named in the command. During git rebase, Git temporarily replays your commits one at a time on top of the target branch, so the roles reverse: "ours" refers to the branch you are rebasing onto (the target, not your original work), and "theirs" refers to your own commit being replayed. Concretely, if Aditi ran git rebase main from her branch instead of merging, and the same conflict appeared, <<<<<<< HEAD in that conflict would show Rohan's already-integrated main content, and the block near >>>>>>> would show Aditi's own change being replayed — the exact opposite pairing from the merge conflict we just resolved. The shortcut commands git checkout --ours -- grades.py and git checkout --theirs -- grades.py (or their modern equivalents, git restore --ours and git restore --theirs) blindly take one whole side without looking at content, which is exactly why knowing which operation you're in the middle of — merge or rebase — is not a minor detail; it determines which side "ours" actually names.
If a resolution goes badly wrong midway — you've deleted more than you meant to, or you no longer trust your own edits — you are not stuck. git merge --abort throws away the in-progress merge entirely and puts both the working directory and the index back to exactly how they were the instant before you ran git merge, as if the attempt never happened. That is your safety net: conflict resolution is meant to be attempted, checked, and restarted if it goes wrong, not fixed in one irreversible pass.
Visualizing the three-way merge
Practice: trace it yourself
These questions cannot be answered by pattern-matching the examples above — each one changes the setup slightly, so you have to re-apply the three-way rule from scratch.
- Suppose instead of both branches inserting new lines, Rohan and Aditi had both edited the same existing line — the base had
return "C1"for everyone below 90, Rohan changed it toreturn "B2", and Aditi changed it toreturn "C1_revised". Would this still produce a conflict under the three-way rule described in this chapter? Explain which of the three versions (base/ours/theirs) differ from each other and why that forces Git to stop. - If Rohan had instead edited a completely different function in the same file, and Aditi's change to
grade()was untouched by Rohan, would merging Aditi's branch into main still produce a conflict? Walk through the three-way rule region by region to justify your answer. - A classmate resolves a conflict by deleting the
<<<<<<< HEADline and the>>>>>>> branch-nameline, but forgets to delete the middle=======line, then runsgit addandgit commit. What happens when this Python file is executed? Is this a Git problem or a different kind of problem? - During a
git rebase mainrun from Aditi's branch (instead of a merge), a conflict marker shows<<<<<<< HEADcontaining Rohan's 80-cutoff code. Whose commit does "HEAD" refer to in this situation, and how is that different from what "HEAD" meant during the merge conflict earlier in this chapter?
Summary
- A merge only needs to ask you for help when two branches changed the same region relative to their common base differently; anything one side alone changed is applied automatically.
- Git compares three versions per conflicting region: base (last shared commit), ours/HEAD (the branch you're on), and theirs (the branch being merged in) — and this "ours/theirs" naming flips during a rebase.
- Conflict markers are exactly seven repeated characters:
<<<<<<< HEAD,=======, and>>>>>>> branch-name; thediff3style adds a|||||||section showing the original base content. - Resolving means editing down to the one version you actually want and deleting all marker lines — not mechanically keeping every block, which can silently leave old, rejected logic reachable for a range of inputs you never tested.
- A resolved merge is staged with
git addand finished withgit commit, producing a commit with two parents that permanently records where the two histories rejoined. git merge --abortcancels an in-progress merge completely, restoring the state from just before you started — use it freely when a resolution attempt goes wrong.
Think About It
Think about this: How would you explain resolving git merge conflicts to a friend who has never seen a computer? What real-world analogy would you use? Imagine you had to build a system using these concepts — what would be your first step? Try this: before moving on, write down three things you learned and one question you still have.