Learning Objectives
By the end of this module, you will be able to:
- Explain the difference between fast-forward merges and three-way merges
- Read and manually resolve conflict markers in files
- Use
git merge --abortto safely back out of a failed merge - Configure and use visual merge tools (VS Code, vimdiff, etc.)
- Understand merge strategies and when each applies
1. What Is a Merge?
A merge brings the work from one branch into another. You're on branch A and you say "bring in the changes from branch B." Git examines the two branches, finds their common ancestor (the commit where they diverged), and combines the changes.
git switch main
git merge feature-loginThis means: "Take the changes on feature-login and integrate them into main."
Git decides how to merge based on the shape of the commit graph. There are two fundamentally different cases.
2. Fast-Forward Merges
A fast-forward merge happens when the branch you're merging into hasn't diverged — there's a straight line from it to the branch you're merging.
Before
○ ◄── ○ ◄── ○ ◄── ○ ◄── ○
C1 C2 C3 C4 C5
▲ ▲
main feature
▲
HEAD
main is at C3. feature is at C5. There are no commits on main after C3 — feature is simply "ahead." The history is a straight line.
The Merge
git switch main
git merge feature
# Updating abc1234..def5678
# Fast-forward
# login.py | 45 +++++++++++++++++++++++
# 1 file changed, 45 insertions(+)Git says "Fast-forward" — it just moved the main pointer to where feature points. No new commit was created.
After
○ ◄── ○ ◄── ○ ◄── ○ ◄── ○
C1 C2 C3 C4 C5
▲
main, feature
▲
HEAD
The pointer moved. That's it.
When Fast-Forward Happens
Only when the target branch has no commits that the source branch doesn't have — meaning the source is a direct descendant. There can never be conflicts in a fast-forward merge because there's nothing to reconcile.
Why You Might NOT Want Fast-Forward
Fast-forward merges erase the visual record that a branch existed. In a team setting, you often want to preserve the fact that a set of commits came from a feature branch. Use --no-ff to force a merge commit:
git merge --no-ff feature -m "Merge feature-login into main"Before (same starting point)
○ ◄── ○ ◄── ○ ◄── ○ ◄── ○
C1 C2 C3 C4 C5
▲ ▲
main feature
After --no-ff
○ ◄── ○ ◄── ○ ◄── ○ ◄── ○
C1 C2 C3 C4 C5
╲ ╱
○ ◄──────── ○ ← merge commit C6
▲
main
▲
HEAD
Wait — that diagram is misleading for --no-ff with a straight line. Let me show it properly:
○ ◄── ○ feature
C4 C5
╱ ╲
○ ◄── ○ ◄── ○ ○ ← merge commit C6 (--no-ff)
C1 C2 C3 ▲
main
Even though Git could have fast-forwarded, --no-ff created a merge commit with two parents. Now git log --graph will show that C4 and C5 came from a side branch.
Convention: Many teams and platforms (GitHub, GitLab) use
--no-fffor all PR/MR merges. GitHub's "Merge pull request" button creates a--no-ffmerge by default. This preserves branch history in the graph.
3. Three-Way Merges (True Merges)
When both branches have diverged — each has commits the other doesn't — Git must perform a three-way merge. This is the "real" merge.
The Setup
○ ◄── ○ feature
C4 C5
╱
○ ◄── ○ ◄── ○
C1 C2 C3 ◄── ○ main ← HEAD
C6
main has C6. feature has C4 and C5. They diverged after C3. Git can't just move a pointer — it needs to combine the changes.
The Three-Way Comparison
Git uses three inputs to compute the merge:
Common Ancestor (C3)
╱ ╲
Ours (C6) Theirs (C5)
(main) (feature)
| File | Ancestor (C3) | Ours (main/C6) | Theirs (feature/C5) | Result |
|---|---|---|---|---|
README.md | v1 | v1 | v1 | v1 (no change) |
app.py | v1 | v2 | v1 | v2 (only we changed it) |
login.py | — | — | NEW | NEW (only they added it) |
config.py | v1 | v2 | v3 | CONFLICT (both changed it) |
The rules:
- If only one side changed a file → take that change (auto-resolved)
- If neither side changed a file → keep as-is
- If both sides changed a file in different places → Git can often auto-merge the changes
- If both sides changed a file in the same place → conflict — human must decide
The Merge Commit
After resolution, Git creates a merge commit with two parents:
○ ◄── ○
C4 C5
╱ ╲
○ ◄── ○ ◄── ○ ○ ← merge commit C7 (two parents)
C1 C2 C3 ◄── ○ ▲
C6 main
The merge commit's tree is the resolved result. Its first parent is C6 (the branch you were on), its second parent is C5 (the branch you merged).
4. Merge Conflicts
A conflict occurs when both branches modified the same lines of the same file and Git can't determine which version should win.
What Triggers a Conflict
Ancestor (line 5): result = calculate(x)
Ours (main): result = calculate(x, precision=2)
Theirs (feature): result = compute(x)
Both sides changed line 5. Git doesn't know which change is correct, so it stops and asks you.
What Does NOT Trigger a Conflict
Ancestor: Ours: Theirs:
line 1: aaa line 1: AAA line 1: aaa
line 2: bbb line 2: bbb line 2: bbb
line 3: ccc line 3: ccc line 3: CCC
Ours changed line 1. Theirs changed line 3. Git auto-merges this — no conflict.
Conflict Markers
When Git encounters a conflict, it writes special markers directly into the file:
Some clean code above the conflict...
<<<<<<< HEAD
result = calculate(x, precision=2)
=======
result = compute(x)
>>>>>>> feature
Some clean code below the conflict...
| Marker | Meaning |
|---|---|
<<<<<<< HEAD | Start of YOUR version (the branch you're on) |
======= | Separator between the two versions |
>>>>>>> feature | End of THEIR version (the branch being merged) |
Everything between <<<<<<< and ======= is your current branch's version.
Everything between ======= and >>>>>>> is the incoming branch's version.
Resolving a Conflict
You must:
- Open the file and find the conflict markers
- Decide which version to keep (yours, theirs, both, or a manual combination)
- Remove all conflict markers (
<<<<<<<,=======,>>>>>>>) - Stage the resolved file with
git add - Commit to finalize the merge
After editing, the file should contain only the final correct code — no markers.
# Resolved: kept the function name from feature, added precision from main
result = compute(x, precision=2)Then:
git add config.py
git commit -m "Merge feature into main"If Git opens your editor for the merge commit message, it pre-fills a default message like Merge branch 'feature' into main. You can accept it or customize it.
5. Aborting a Merge
If you start a merge and things go wrong — or you just want to back out — you can abort cleanly:
git merge --abortThis resets your working directory, staging area, and HEAD to the state before the merge began. No changes are lost (your branches remain as they were).
Important:
git merge --abortworks cleanly when your working directory was clean before starting the merge. If you had uncommitted changes, the abort may not restore them perfectly. Always commit or stash before merging.
Checking if You're Mid-Merge
git status
# On branch main
# You have unmerged paths.
# (fix conflicts and run "git commit")
# (use "git merge --abort" to abort the merge)The .git/MERGE_HEAD file also exists during a merge — its presence tells Git (and your tools) that a merge is in progress.
6. Resolving Conflicts with Visual Tools
While you can resolve conflicts in any text editor by editing the markers manually, visual tools make it much easier — especially with complex conflicts spanning many lines.
VS Code (Built-In)
VS Code detects conflict markers and adds clickable buttons above each conflict:
<<<<<<< HEAD
Accept Current Change | Accept Incoming Change | Accept Both Changes | Compare Changes
result = calculate(x, precision=2)
=======
result = compute(x)
>>>>>>> feature
- Accept Current Change — keep your version (HEAD)
- Accept Incoming Change — keep their version (feature)
- Accept Both Changes — keep both (stacked, yours on top)
- Compare Changes — open a side-by-side diff
After clicking, VS Code removes the markers and applies your choice. You still need to git add and git commit.
VS Code Merge Editor (3-Way View)
VS Code also has a dedicated merge editor. When you have conflicts:
- Open the Source Control panel (Ctrl+Shift+G)
- Under "Merge Changes," click on a conflicted file
- Click "Open in Merge Editor"
This opens a three-panel view:
┌─────────────────┬─────────────────┐
│ Incoming │ Current │
│ (theirs) │ (yours/HEAD) │
├─────────────────┴─────────────────┤
│ Result │
│ (what will be committed) │
└───────────────────────────────────┘
You can check/uncheck changes from each side, and the result updates in real-time.
git mergetool (Command-Line Interface to External Tools)
Git can launch an external merge tool for each conflicted file:
git mergetoolGit will open each conflicted file in the configured tool one by one.
Configuring a Merge Tool
# VS Code
git config --global merge.tool vscode
git config --global mergetool.vscode.cmd 'code --wait --merge $REMOTE $LOCAL $BASE $MERGED'
# vimdiff (built-in)
git config --global merge.tool vimdiff
# meld (Linux — graphical)
git config --global merge.tool meld
# kdiff3
git config --global merge.tool kdiff3
# Beyond Compare
git config --global merge.tool bc
git config --global mergetool.bc.path '/usr/local/bin/bcomp'Preventing .orig Backup Files
By default, git mergetool creates .orig backup files. To disable:
git config --global mergetool.keepBackup falsevimdiff Layout
When using vimdiff, the default layout shows three windows:
┌──────────────┬──────────────┬──────────────┐
│ LOCAL │ BASE │ REMOTE │
│ (yours) │ (ancestor) │ (theirs) │
├──────────────┴──────────────┴──────────────┤
│ MERGED │
│ (result you'll commit) │
└──────────────────────────────────────────────┘
Navigate between windows with Ctrl-W + arrow keys. Edit the MERGED buffer. Save and quit all with :wqa.
7. Merge Strategies
Git uses different algorithms depending on the situation. You rarely need to specify these explicitly, but understanding them helps when things go wrong.
recursive (default for two-branch merges)
The standard three-way merge. Handles renames, can deal with criss-cross merge histories (multiple common ancestors) by recursively merging the ancestors first.
git merge feature # uses recursive by default
git merge -s recursive feature # explicit
git merge -s recursive -X patience feature # with strategy optionStrategy options for recursive:
| Option | Effect |
|---|---|
-X ours | On conflict, auto-pick our version |
-X theirs | On conflict, auto-pick their version |
-X patience | Use patience diff algorithm (better for moved blocks) |
-X ignore-space-change | Ignore whitespace conflicts |
Note:
-X oursand-X theirsonly affect conflicts. Non-conflicting changes from both sides are still merged normally. This is different from-s ours(see below).
ort (default in Git 2.34+)
A rewrite of recursive that is faster and handles edge cases better. It's the new default and behaves the same as recursive in most cases. You don't need to do anything different.
ours (strategy, not option)
Takes everything from our side and ignores the other branch entirely. The merge commit is created (preserving the graph history) but no changes from the other branch are incorporated.
git merge -s ours obsolete-branchUse case: recording that you intentionally discarded another branch's changes.
Warning:
-s ours(strategy) ignores everything from the other branch.-X ours(strategy option) only resolves conflicts in your favor but still merges non-conflicting changes. They are very different.
octopus
Merges more than two branches at once. Used internally by Git when you specify multiple branches:
git merge feature-a feature-b feature-cOctopus merges can't handle conflicts — they abort if any conflict is detected. Useful for bringing together several non-conflicting feature branches simultaneously.
8. Common Merge Scenarios
Scenario 1: Clean Merge (No Conflicts)
git switch main
git merge feature
# Auto-merging app.py
# Merge made by the 'ort' strategy.
# app.py | 10 ++++++++++
# 1 file changed, 10 insertions(+)Git auto-resolved everything. A merge commit was created. Done.
Scenario 2: Conflicts in One File
git switch main
git merge feature
# Auto-merging app.py
# CONFLICT (content): Merge conflict in app.py
# Automatic merge failed; fix conflicts and then commit the result.Fix the conflict in app.py, then:
git add app.py
git commitScenario 3: Conflicts in Multiple Files
git merge feature
# CONFLICT (content): Merge conflict in app.py
# CONFLICT (content): Merge conflict in config.py
# CONFLICT (content): Merge conflict in tests.pyResolve each file, stage each one, then commit once:
# Resolve all files...
git add app.py config.py tests.py
git commit -m "Merge feature: resolve 3 conflicts"Or resolve one at a time with git mergetool, which iterates through conflicted files.
Scenario 4: Merge with Auto-Resolve Using Theirs
When you know the other branch is correct for all conflicts:
git merge -X theirs featureConflicts are auto-resolved in favor of the incoming branch. Non-conflicting changes from both sides are still merged.
Command Reference
| Command | Description |
|---|---|
git merge <branch> | Merge a branch into the current branch |
git merge --no-ff <branch> | Force a merge commit even if fast-forward is possible |
git merge --ff-only <branch> | Only merge if fast-forward is possible; abort otherwise |
git merge --abort | Abort a merge in progress, return to pre-merge state |
git merge --continue | Continue merge after resolving conflicts (same as git commit) |
git merge -X ours <branch> | Auto-resolve conflicts in favor of current branch |
git merge -X theirs <branch> | Auto-resolve conflicts in favor of incoming branch |
git merge -s ours <branch> | Merge but discard all changes from the other branch |
git merge --no-commit <branch> | Merge but don't auto-commit (lets you inspect the result) |
git merge --squash <branch> | Squash all commits into a single change set (no merge commit) |
git mergetool | Open configured merge tool for each conflicted file |
git log --merge | Show commits that caused conflicts |
git diff | During merge: show combined diff of conflicted files |
git merge-base main feature | Find the common ancestor of two branches |
Hands-On Lab: Merging and Conflict Resolution
This lab creates merge conflicts deliberately, then resolves them using multiple techniques.
Setup
mkdir ~/git-merge-lab
cd ~/git-merge-lab
git initCreate a base file:
cat > app.py << 'EOF'
# Calculator App v1.0
def add(a, b):
return a + b
def subtract(a, b):
return a - b
def main():
print(f"2 + 3 = {add(2, 3)}")
print(f"5 - 2 = {subtract(5, 2)}")
if __name__ == "__main__":
main()
EOF
git add app.py
git commit -m "Base version: add and subtract"Checkpoint:
git log --oneline
# abc1234 Base version: add and subtractStep 1: Fast-Forward Merge
Create a branch and add a feature:
git switch -c feature/multiply
cat > app.py << 'EOF'
# Calculator App v1.0
def add(a, b):
return a + b
def subtract(a, b):
return a - b
def multiply(a, b):
return a * b
def main():
print(f"2 + 3 = {add(2, 3)}")
print(f"5 - 2 = {subtract(5, 2)}")
print(f"4 * 3 = {multiply(4, 3)}")
if __name__ == "__main__":
main()
EOF
git add app.py
git commit -m "Add multiply function"Merge back into main:
git switch main
git merge feature/multiplyCheckpoint:
git log --oneline
# def5678 Add multiply function ← no merge commit, just pointer move
# abc1234 Base version: add and subtract
# Check for merge commit? There isn't one:
git cat-file -p HEAD
# Only one parent line — this is NOT a merge commitThe message says "Fast-forward." No merge commit was created.
Step 2: --no-ff Merge
Create another feature:
git switch -c feature/divide
cat >> app.py << 'EOF'
def divide(a, b):
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
EOF
# Also add to main():
# (We'll use sed-like approach by rewriting the file)
cat > app.py << 'EOF'
# Calculator App v1.0
def add(a, b):
return a + b
def subtract(a, b):
return a - b
def multiply(a, b):
return a * b
def divide(a, b):
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
def main():
print(f"2 + 3 = {add(2, 3)}")
print(f"5 - 2 = {subtract(5, 2)}")
print(f"4 * 3 = {multiply(4, 3)}")
print(f"10 / 3 = {divide(10, 3):.2f}")
if __name__ == "__main__":
main()
EOF
git add app.py
git commit -m "Add divide function with zero-division guard"Now merge with --no-ff:
git switch main
git merge --no-ff feature/divide -m "Merge feature/divide into main"Checkpoint:
git log --oneline --graph
# * 111aaa Merge feature/divide into main
# |\
# | * 222bbb Add divide function with zero-division guard
# |/
# * def5678 Add multiply function
# * abc1234 Base version: add and subtract
git cat-file -p HEAD
# TWO parent lines — this IS a merge commitThe graph shows the branch and merge structure.
Step 3: Create a Real Conflict
Now we'll create a situation where both branches modify the same lines.
# Create the conflicting branch
git switch -c feature/power
# Modify the comment and main() on the feature branch
cat > app.py << 'EOF'
# Calculator App v2.0 - Power Edition
def add(a, b):
return a + b
def subtract(a, b):
return a - b
def multiply(a, b):
return a * b
def divide(a, b):
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
def power(a, b):
return a ** b
def main():
print("=== Power Calculator ===")
print(f"2 + 3 = {add(2, 3)}")
print(f"5 - 2 = {subtract(5, 2)}")
print(f"4 * 3 = {multiply(4, 3)}")
print(f"10 / 3 = {divide(10, 3):.2f}")
print(f"2 ^ 8 = {power(2, 8)}")
if __name__ == "__main__":
main()
EOF
git add app.py
git commit -m "Add power function, update header"Now go back to main and make a conflicting change:
git switch main
# Modify the SAME lines (comment and main()) differently
cat > app.py << 'EOF'
# Calculator App v2.0 - Scientific Edition
def add(a, b):
return a + b
def subtract(a, b):
return a - b
def multiply(a, b):
return a * b
def divide(a, b):
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
def modulo(a, b):
return a % b
def main():
print("=== Scientific Calculator ===")
print(f"2 + 3 = {add(2, 3)}")
print(f"5 - 2 = {subtract(5, 2)}")
print(f"4 * 3 = {multiply(4, 3)}")
print(f"10 / 3 = {divide(10, 3):.2f}")
print(f"10 % 3 = {modulo(10, 3)}")
if __name__ == "__main__":
main()
EOF
git add app.py
git commit -m "Add modulo function, update header"Checkpoint:
git log --oneline --graph --all
# * 444ddd (HEAD -> main) Add modulo function, update header
# | * 333ccc (feature/power) Add power function, update header
# |/
# * 111aaa Merge feature/divide into main
# ...The branches have diverged. Both changed the header comment and the main() function.
Step 4: Attempt the Merge
git merge feature/powerOutput:
Auto-merging app.py
CONFLICT (content): Merge conflict in app.py
Automatic merge failed; fix conflicts and then commit the result.
Checkpoint:
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:
# both modified: app.pyStep 5: Read the Conflict Markers
cat app.pyLook for sections like:
<<<<<<< HEAD
# Calculator App v2.0 - Scientific Edition
=======
# Calculator App v2.0 - Power Edition
>>>>>>> feature/power
There will be multiple conflict blocks — one for the header, one for the function definitions area, and one for the main() function.
Step 6: Resolve Manually
Open app.py in your editor. For each conflict:
- Decide what the final version should be
- Remove all
<<<<<<<,=======, and>>>>>>>markers - Write the correct code
For example, combine both features:
# Calculator App v2.0 - Full Edition
# ... (keep all functions: add, subtract, multiply, divide, power, modulo) ...
def main():
print("=== Full Calculator ===")
print(f"2 + 3 = {add(2, 3)}")
print(f"5 - 2 = {subtract(5, 2)}")
print(f"4 * 3 = {multiply(4, 3)}")
print(f"10 / 3 = {divide(10, 3):.2f}")
print(f"2 ^ 8 = {power(2, 8)}")
print(f"10 % 3 = {modulo(10, 3)}")Step 7: Stage and Commit
git add app.py
git commit -m "Merge feature/power: combine power and modulo functions"Checkpoint:
git log --oneline --graph --all
# * 555eee (HEAD -> main) Merge feature/power: combine power and modulo functions
# |\
# | * 333ccc (feature/power) Add power function, update header
# * | 444ddd Add modulo function, update header
# |/
# * 111aaa Merge feature/divide into main
# ...Step 8: Practice Aborting
Let's redo the conflict to practice aborting. First, undo the merge:
git reset --hard HEAD~1 # go back to before the mergeNow merge again:
git merge feature/power
# CONFLICT...
git status
# You have unmerged paths...
git merge --abort
git status
# On branch main — clean, as if nothing happenedStep 9: Merge with -X theirs
When you know the incoming branch should win all conflicts:
git merge -X theirs feature/powerGit auto-resolves every conflict in favor of feature/power. Check the result:
cat app.py | head -1
# # Calculator App v2.0 - Power Edition ← theirs won
git log --oneline --graph -5
# Merge commit created automaticallyChallenge
-
Reset back to before the merge (
git reset --hard HEAD~1). Merge again without-X, and this time usegit mergetoolto resolve. If you have VS Code configured, use:git config merge.tool vscode && git config mergetool.vscode.cmd 'code --wait --merge $REMOTE $LOCAL $BASE $MERGED' -
Create a scenario with three branches diverging from the same point, each modifying the same file. Merge them one at a time into
mainand resolve the cascading conflicts. -
Try
git merge --no-commit feature/powerto merge without auto-committing. Inspect the result withgit diff --staged, then decide whether togit commitorgit merge --abort.
Cleanup
rm -rf ~/git-merge-labCommon Pitfalls & Troubleshooting
| Pitfall | Explanation |
|---|---|
| Conflict markers left in committed code | Always search for <<<<<<< in your files before committing a merge. Accidentally committing conflict markers is a common mistake. Some teams add a pre-commit hook to check for this. |
| Merging with a dirty working directory | If your working directory has uncommitted changes when you start a merge, aborting may not fully restore. Always commit or stash before merging. |
Confusing -X ours with -s ours | -X ours (strategy option) resolves only conflicts in your favor, still merging non-conflicting changes. -s ours (strategy) discards the other branch's changes entirely. |
| "Already up to date" | The branch you're merging has already been merged (or is an ancestor). There's nothing new to bring in. |
| Fear of merge conflicts | Conflicts are normal and expected. They just mean two people edited the same lines. The resolution is always the same: read both versions, pick the right one, remove markers, stage, commit. |
Forgetting to git add after resolving | Editing the file removes markers, but Git still considers it unmerged until you git add it. Staging marks the conflict as resolved. |
.orig files cluttering the repo | git mergetool creates backup .orig files. Add *.orig to .gitignore or disable with git config --global mergetool.keepBackup false. |
Pro Tips
-
Always check
git statusduring a merge. It tells you which files are conflicted, which are resolved, and reminds you of your options (--abort,--continue). -
Preview before merging. See what will happen before you commit to it:
git log main..feature --oneline # commits that will be merged git diff main...feature # changes that will be merged (three-dot) git merge --no-commit feature # merge without committing — inspect, then abort or commit -
Use
git merge-baseto find the common ancestor.git merge-base main feature # Returns the commit hash where the branches diverged git show $(git merge-base main feature) -
Small, frequent merges beat large, infrequent ones. The more two branches diverge, the worse the conflicts. Merge (or rebase) regularly.
-
After a conflict, verify the result works. Run tests, compile, or at least read through the merged file. Git's auto-merge is textual, not semantic — it can combine two changes that individually make sense but together introduce a bug.
-
Configure
merge.conflictStylefor more context.git config --global merge.conflictStyle diff3This adds a third section showing the common ancestor version:
<<<<<<< HEAD result = calculate(x, precision=2) ||||||| merged common ancestors result = calculate(x) ======= result = compute(x) >>>>>>> featureThe middle section shows what the line looked like before either branch changed it. This is invaluable for understanding what each side intended.
Quiz / Self-Assessment
1. What is a fast-forward merge?
Answer
--no-ff is used.
2. What triggers a merge conflict?
Answer
3. What are the three inputs Git uses for a three-way merge?
Answer
4. How do you resolve a merge conflict?
Answer
<<<<<<<, =======, >>>>>>>). 3) Edit the file to contain the correct final version. 4) Remove all conflict markers. 5) Stage the file with git add. 6) Commit with git commit.
5. What does git merge --abort do?
Answer
6. What's the difference between --no-ff and a regular merge?
Answer
--no-ff forces a merge commit even when fast-forward is possible, preserving the visual record of the branch in the commit graph.
7. What does -X theirs do?
Answer
8. How is -X ours different from -s ours?
Answer
-X ours (strategy option) resolves only conflicts in your favor — non-conflicting changes from both sides still merge. -s ours (strategy) discards ALL changes from the other branch — the merge commit's tree is identical to HEAD.
9. What does merge.conflictStyle diff3 show you?
Answer
10. Can a fast-forward merge ever have conflicts?
Answer