You will deliberately create a merge conflict, read the conflict markers, and resolve it correctly. By the end conflicts will feel routine rather than scary, and you will know how to back out safely.

A merge conflict is not an error and it is not Git being broken. It is Git refusing to guess. When two branches change the same lines of the same file in different ways, Git has no rule for which version is "right", only a human knows that. So Git stops the merge, marks the disputed region in the file, and waits for you to decide. Every professional developer hits conflicts weekly; the difference between a beginner and a professional is that the professional has a calm, repeatable procedure. This lab gives you that procedure: reproduce, inspect, read, resolve, verify, and (when needed) abort.

Learning Objectives

  • Understand why and when conflicts occur.
  • Read and resolve conflict markers.
  • Verify the merge and abort safely if needed.
  • Reproduce a conflict on demand in a disposable sandbox repository.
  • Inspect the conflicted state with git status and git diff.
  • Configure and use a visual merge tool for larger conflicts.
  • Time: ~2 hours · Difficulty: Intermediate · Prereqs: the Git fundamentals lab.

Architecture Overview

Every conflict has the same shape: both branches descend from one common ancestor commit, and each branch modified the same region of a file after that split. During a three-way merge Git compares the ancestor against both branch tips. Regions that only one side touched are merged automatically; regions that both sides touched differently become conflicts.

The practical consequence: conflicts are a function of overlap, not of how long a branch lives or how many commits it has. Two one-commit branches that touch the same line will conflict; two fifty-commit branches that touch different files will merge cleanly.

How It Works

How It Works

Git merges with three inputs, not two: the merge base (common ancestor), our side, and their side. Changes are computed as diffs from the base, so edits to different lines combine automatically and only overlapping edits to the same lines need a human.

Conflict markers are Git showing its work: everything between <<<<<<< HEAD and ======= is your version, everything between ======= and >>>>>>> is theirs. Your job is to produce the correct final text, which may keep one side, the other, or a blend of both.

The same three-way logic applies during rebases and cherry-picks; only the labels change. And git merge --abort exists precisely so that starting a merge is safe: if the conflict looks wrong, bail out and regroup with zero damage.

How Git Decides: The Three-Way Merge

When you run git merge feature/api from main, Git does not compare the two branch tips directly. It finds the merge base, the most recent commit reachable from both branches, and performs two diffs: base→ours and base→theirs. You can find the base yourself:

FIND THE MERGE BASE

$ git merge-base main feature/api
3b1f9c2a1e8d0f4b6c7a9e2d5f8b1c3a6e9d0f2a

Three outcomes are then possible per file region:

  • Only one side changed it → Git takes that side's version automatically. Most of any merge is this.
  • Both sides changed it identically → Git takes one copy; no conflict.
  • Both sides changed it differently → conflict; Git writes both versions into the file with markers and waits for you.

A fourth case confuses beginners: if main has not moved since the branch was created, there is nothing to reconcile, and Git just moves the main pointer forward. That is a fast-forward merge, no merge commit, and no possibility of a conflict. Conflicts require both sides to have new commits since the base.

Prevention beats resolution on real teams. Four habits eliminate most conflicts before they happen:

  • Keep branches short-lived, days, not weeks. Every day a branch lives, the drift from main grows.
  • Rebase or merge from main frequently, resolving five small overlaps over a week beats resolving one giant one at the end.
  • Agree on file ownership, if two people are reformatting config.js the same week, that is a communication bug, not a Git problem.
  • Avoid drive-by reformatting, a whitespace-only change to a file someone else is editing manufactures conflicts out of nothing.

Environment Setup

Prerequisites

  • Git 2.4x and an empty sandbox directory for the practice repo
  • Comfort with commit, branch, and merge basics
  • Optional: a visual merge tool (meld or VS Code) for the tooling section
  • Assumed skill: reading diffs

You will need: Git and an optional merge tool (VS Code, meld).

Before you begin: conflicts happen when two branches change the same lines; that is normal.

Confirm Git is installed and new enough (anything from the last several years is fine; the commands here need Git 2.23+):

CHECK GIT VERSION

$ git --version
git version 2.46.0

We will work inside a brand-new throwaway repository so nothing real is at risk. That is the right way to practice: make the conflict happen on purpose, in a repo you can delete afterward.

mkdir merge-lab && cd merge-lab
A scratch directory you can rm -rf when the lab is done.

Step-by-Step Execution

01
Build the sandbox repository

Initialize a repo and commit a small JavaScript config file. This commit becomes the common ancestor, the shared starting point both branches will diverge from. We use a config.js with a single port setting because it is the smallest possible file that can still conflict.

INITIALIZE AND MAKE THE BASE COMMIT

git init -b main
git config user.name "Lab User"
git config user.email "[email protected]"

cat > config.js <<'EOF'
// App configuration
const port = 5000;
module.exports = { port };
EOF

git add config.js
git commit -m "Add config.js with default port 5000"

Expected output:

Initialized empty Git repository in /home/you/merge-lab/.git/
[main (root-commit) 3b1f9c2] Add config.js with default port 5000
 1 file changed, 3 insertions(+)
 create mode 100644 config.js

Common mistake: editing the file before the first commit. The base commit must exist before you branch, or there is no shared ancestor and the exercise does not demonstrate a real three-way merge. Verify with git log --oneline, you should see exactly one commit.

What this teaches: A disposable sandbox makes conflict practice risk-free; nothing here can hurt real work.

02
Create two branches that disagree

Now manufacture the overlap. Create feature/api, change the port to 3000 there, and commit. Then switch back to main and change the same line to 8080, and commit that too. Both sides have now rewritten line 2 differently since the ancestor, which is exactly the condition that forces Git to ask for help.

BRANCH AND EDIT ON feature/api

git switch -c feature/api
sed -i.bak 's/port = 5000/port = 3000/' config.js && rm config.js.bak
git commit -am "Use port 3000 for the API"

EDIT THE SAME LINE ON main

git switch main
sed -i.bak 's/port = 5000/port = 8080/' config.js && rm config.js.bak
git commit -am "Use port 8080 on main"

Verify the divergence before merging:

$ git log --oneline --graph --all
* 9d2e4a1 (main) Use port 8080 on main
| * 71c0b8f (feature/api) Use port 3000 for the API
|/
* 3b1f9c2 Add config.js with default port 5000

How to verify: the graph must show two tips sharing one parent (the |/ join). If you see a straight line, you committed both changes on the same branch, reset and redo this step.

What this teaches: Conflicts need divergence: two branches editing the same lines is the deliberate setup.

03
Trigger the conflict

From main, merge the feature branch. Git starts a three-way merge, finds that both sides changed line 2 of config.js, and pauses in a special conflicted merge state. Nothing is committed yet and nothing is lost, Git has simply stopped and is waiting.

git merge feature/api
If both branches changed the same lines, Git pauses with a conflict.

Expected output:

Auto-merging config.js
CONFLICT (content): Merge conflict in config.js
Automatic merge failed; fix conflicts and then commit the result.

Read that message literally: "fix conflicts and then commit the result" is the entire rest of the lab. Also note what it does not say, it does not say your repository is corrupted, and it does not say you lost work.

Common mistake: panicking and closing the terminal, or worse, deleting the repo. The conflicted state is fully recoverable. If you want out at any point before the merge commit, git merge --abort returns you to exactly where you were (Step 08 practices this).

What this teaches: Merging the second branch surfaces the conflict; Git stops and asks a human to decide.

04
Inspect the conflicted state

Before touching any file, learn to ask Git two questions: what state am I in? and which files are conflicted? On a real project there may be dozens of conflicted files, and git status is your map.

CHECK STATUS

$ 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:   config.js

no changes added to commit (use "git add" to track changes)

"both modified" is the key phrase: it confirms both branches changed this file. For a quick machine-readable list of just the conflicted files, use:

git diff --name-only --diff-filter=U
U = unmerged. Prints one conflicted path per line, nothing else.

How to verify: config.js appears under "Unmerged paths" and not under "Changes to be committed". A file only moves out of the unmerged list when you git add it after resolving.

What this teaches: git status names the conflicted files first; knowing the state before editing prevents panic.

05
Read the conflict markers

Open config.js in any editor. Git has rewritten the disputed region with conflict markers. Everything between <<<<<<< HEAD and ======= is what your current branch (main, a.k.a. HEAD) has; everything between ======= and >>>>>>> feature/api is what the incoming branch has.

<<<<<<< HEAD
const port = 8080;   // your change
=======
const port = 3000;   // their change
>>>>>>> feature/api

Three things to internalize:

  • The markers are ordinary text in an ordinary file. There is no hidden state to unlock, you edit the file like any other edit.
  • Only the marked hunks are disputed. Git already merged everything else in the file silently and correctly.
  • You are not limited to picking one side. The correct resolution might be 8080, or 3000, or a rewritten line that reads the port from an environment variable. Git does not care what you write, only that the markers are gone.

Common mistake: assuming the markers are in a temporary file somewhere. They are in your working tree, in the real config.js. If you commit without editing them out, the marker text lands in your repository history, Step 07 shows how to catch that.

What this teaches: The <<<<<<<, =======, >>>>>>> markers show both versions; reading them is the whole diagnosis.

06
Resolve by editing the file

Decide the correct outcome and edit the file so the markers disappear. For this lab, imagine the team agreed the API should run on port 8080 but should be overridable, so we write a resolution that is better than either side alone:

RESOLVED config.js

// App configuration
const port = process.env.PORT || 8080;
module.exports = { port };

Then tell Git the dispute is settled by staging the file. Staging is the signal: git add on a conflicted path means "this file is resolved."

git add config.js
Staging a conflicted file marks it resolved; it does not finish the merge.

Confirm the unmerged list is now empty:

$ git status
On branch main
All conflicts fixed but you are still merging.
  (use "git commit" to conclude merge)

Changes to be committed:
        modified:   config.js

"All conflicts fixed but you are still merging" is the green light: resolutions staged, merge not yet concluded.

What this teaches: Resolution is an editorial decision, not a Git command: keep one side, the other, or combine them.

07
Complete and verify the merge

Commit to conclude the merge. Git has already prepared a default merge message, so --no-edit accepts it without opening an editor. A merge commit is special: it has two parents, which is what stitches the two branch histories together.

$ git add config.js && git commit --no-edit
[main 7f3ac91] Merge branch 'feature/api'

Now verify. First, no marker text may survive anywhere in the tree; second, the history must show the merge commit joining both lines of development:

VERIFY THE RESULT

$ git diff --check        # silent = no markers or whitespace errors in uncommitted changes
$ grep -rn '<<<<<<<' . --exclude-dir=.git   # must print nothing
$ git log --oneline --graph
*   7f3ac91 (HEAD -> main) Merge branch 'feature/api'
|\
| * 71c0b8f (feature/api) Use port 3000 for the API
* | 9d2e4a1 Use port 8080 on main
|/
* 3b1f9c2 Add config.js with default port 5000

The |\ shape with two parents on the merge commit is the visual proof of a completed merge.

How to verify: if grep prints any line, a marker survived, reopen that file, resolve properly, git add it, and amend (git commit --amend --no-edit) if you already committed.

What this teaches: Staging the resolved file and committing finishes the merge; verifying afterwards proves nothing was lost.

08
Practice the escape hatch: git merge --abort

Knowing how to walk away is what removes the fear. Reset the merge commit (safe here because it was never pushed), redo the merge, and this time abort mid-conflict to prove the repo comes back untouched:

REDO THE CONFLICT AND ABORT IT

git reset --hard HEAD~1        # drop the merge commit (never do this if pushed!)
git merge feature/api          # conflict again, on purpose
git status                     # "You have unmerged paths." - we are mid-merge
git merge --abort              # back out cleanly
git status                     # "nothing to commit, working tree clean"

After the abort, config.js contains exactly what it had before the merge attempt: const port = 8080;, no markers, no half-merged state. --abort works because Git keeps the pre-merge state in ORIG_HEAD and the conflict is still uncommitted.

Common mistake: trying to abort after the merge commit exists. git merge --abort only works mid-merge. To undo a completed merge that was never pushed, use git reset --hard ORIG_HEAD; to undo one that was pushed, use git revert -m 1 <merge-commit> instead.

When you are done practicing, merge once more and resolve it properly so the lab repo ends in a merged state, or just delete the directory; it was always disposable.

What this teaches: Abort restores the pre-merge state, so attempting a merge is never a commitment.

Using a Visual Merge Tool

Hand-editing markers is fine for one hunk in one file. For a conflict spanning many files, a three-pane merge tool (yours / base / theirs, with the result at the bottom) is far faster and far less error-prone. Configure one once:

CONFIGURE VS CODE AS MERGETOOL

git config --global merge.tool vscode
git config --global mergetool.vscode.cmd 'code --wait --merge "$LOCAL" "$REMOTE" "$BASE" "$MERGED"'

OR CONFIGURE meld

git config --global merge.tool meld

Then, while a merge is conflicted, launch it with git mergetool. Git opens each unmerged file in turn; you save and close each one, and Git stages the resolved versions automatically.

LAUNCH THE MERGE TOOL

$ git mergetool
Merging:
config.js

Normal merge conflict for 'config.js':
  {local}: modified file
  {remote}: modified file
Hit return to start merge resolution tool (vscode):

Common mistake: meld and some other tools leave config.js.orig backup files behind. Disable that with git config --global mergetool.keepBackup false, or at least add *.orig to your .gitignore so backups never get committed.

Conflicts During Rebase and Cherry-Pick

Everything above applies verbatim to two other operations that stop on conflicts: git rebase and git cherry-pick. The mechanics are identical, edit the markers, git add, but two details differ and regularly trip people up.

First, the sides feel backwards. During git rebase main, your commits are being replayed on top of main, so HEAD (the "ours" side of the markers) is main's code, and "theirs" is your own commit being applied. If you instinctively keep the "ours" side thinking it is your work, you will throw your own changes away. Read the actual code, not the labels.

Second, the conflict can strike once per commit. A merge conflicts at most once per run; a rebase of ten overlapping commits can stop ten times. The loop is:

THE REBASE CONFLICT LOOP

git rebase main
# ... CONFLICT: fix markers in the file ...
git add config.js
git rebase --continue        # move on to the next commit; repeat if it stops again
# or give up on the whole rebase:
git rebase --abort           # back to exactly where you started

If you find yourself resolving the same hunk over and over during a long rebase, that is exactly what rerere (from the Troubleshooting section) exists for, enable it once and Git replays your previous resolutions automatically.

Cherry-pick behaves like a single-step rebase: resolve, git add, then git cherry-pick --continue, or bail out with git cherry-pick --abort. Same markers, same tools, same calm.

Progress So Far

Testing & Validation

git status && git diff --check

Status should show a clean tree. diff --check should be silent on uncommitted changes and the grep for marker text should print nothing; together they prove no conflict markers survive anywhere in the tree. Run your tests to confirm the merge is correct, not just conflict-free.

That last point deserves emphasis: a clean merge is not the same as a correct merge. Git only guarantees that no markers remain. It cannot know that keeping port 8080 breaks the integration tests that expect 3000, or that two individually-valid changes contradict each other semantically. This is called a semantic conflict, and the only defense is running the test suite after every merge:

FULL POST-MERGE CHECKLIST

git status                          # 1. working tree clean
git diff --check                      # 2. zero leftover markers in uncommitted changes
git log --graph --oneline -5          # 3. merge commit has two parents
node -e "console.log(require('./config.js').port)"   # 4. file parses & value is sane
npm test                              # 5. behavior is actually correct

Step 4 is a smoke test that the resolved file at least parses as valid JavaScript; step 5 is the real proof. On a team, step 5 is exactly what CI runs on the merge result before anyone is allowed to deploy it.

Troubleshooting
  • Leftover markers committed: git diff --check catches <<<< markers before commit. If one already slipped into a commit you have not pushed, fix the file and run git commit --amend --no-edit. To catch it automatically forever, install a pre-commit hook that runs git diff --cached --check.
  • Resolved the wrong way: the merge is just a commit; revert and redo. If unpushed: git reset --hard ORIG_HEAD and merge again. If pushed: git revert -m 1 HEAD creates a commit that undoes the merge while preserving history for everyone else.
  • Overwhelmed: back out cleanly with git merge --abort and try again. There is no limit on attempts, and each attempt costs nothing.
  • Markers look different, with a third section: if you see ||||||| merged common ancestors between the two sides, someone enabled merge.conflictStyle diff3 (or zdiff3). That middle block shows what the ancestor had, which actually makes resolving easier, enable it yourself with git config --global merge.conflictStyle zdiff3.
  • "CONFLICT (modify/delete)": one branch edited a file the other branch deleted, so there are no markers to edit. Decide explicitly: keep the file with git add <file>, or accept the deletion with git rm <file>, then commit.
  • The same conflict keeps coming back on every rebase: enable git config --global rerere.enabled true ("reuse recorded resolution"). Git will remember how you resolved a conflict and auto-apply that resolution the next time it sees the same one.
  • "error: you have not concluded your merge (MERGE_HEAD exists)": you tried to start a new merge (or pull) while an old conflicted merge is still open. Git stores the in-progress merge in the MERGE_HEAD file. Either finish the old one (resolve, git add, commit) or discard it with git merge --abort before starting anything new.
  • Conflicted file disappears from the editor after resolving: you probably ran git checkout --ours or --theirs on it earlier and forgot. Nothing is lost, the resolved content is whatever you staged; inspect it with git diff --cached before committing.

Troubleshooting

SymptomLikely causeFix
conflict markers committed to the fileresolved by staging without removing markerssearch for <<<<<<< before committing; a pre-commit hook can catch it
wrong side chosen in a hurrypicked ours/theirs wholesale without readinguse git checkout -m to redo the conflict and choose carefully
merge --abort says no merge in progressthe merge already completed or was never startedcheck git status; if committed, revert or reset instead
binary file conflictGit cannot show markers inside binariesuse git checkout --ours/--theirs to pick a whole version, then commit
same conflict on every rebasea long branch conflicts repeatedlyconsider git rerere to reuse recorded resolutions, or merge main in once

Extension Ideas

  • Configure a visual mergetool for big conflicts.
  • Reduce conflicts by keeping branches short (see Branching).
  • Catch issues earlier with CI in GitHub Actions.
  • Enable merge.conflictStyle zdiff3 and re-run this lab to see the ancestor shown inside the markers.
  • Try resolving with one-shot flags: git checkout --ours config.js or git checkout --theirs config.js when you know one side should win entirely.

Practice Challenges

Challenge 1: Rebase conflict

Goal: Create the same divergence, then rebase instead of merging, and resolve the conflict mid-rebase.

Hint: During a rebase, git rebase --continue resumes after each resolved commit; --abort still bails out.

Challenge 2: Rerere drill

Goal: Enable git rerere, resolve a conflict, then replay the scenario and watch Git auto-resolve it.

Hint: rerere records your resolution and replays it when the same conflict recurs.

Key Results

  • Resolved a real conflict by reading the markers.
  • Confirmed zero leftover markers with diff --check and a tree-wide marker grep.
  • Validated correctness with tests, not just a clean status.
  • Learned to abort and retry without panic.

Key Takeaways

  • A conflict is Git refusing to guess, it is a request for a human decision, not a failure.
  • Conflicts only happen when both branches change the same lines since their common ancestor.
  • The procedure never changes: status → read markers → edit → add → commit → verify.
  • git merge --abort works any time before the merge commit; a completed merge is undone with reset (unpushed) or revert -m 1 (pushed).
  • git diff --check plus a tree-wide marker grep proves the markers are gone; only the test suite proves the merge is correct.
  • Short-lived branches and frequent merges are the real prevention, conflict resolution skill is the safety net.