Lesson 7 of 15

Merging & Conflicts

Two Kinds of Merge

Merging is how work on a branch gets folded back into the branch it came from. You stand on the branch that should receive the changes and name the branch you want to bring in. That direction catches everyone once: git merge feature/cart run while on main brings the cart work into main. Running it the other way round does the opposite, and there is no warning, because both are legitimate operations.

Git performs one of two kinds of merge depending on what happened while you were away. If the receiving branch has not moved at all since you branched off — nobody committed to main while you built the cart — then main is simply behind your branch on the same line of history. Git does not need to combine anything; it just slides the main label forward. This is a fast-forward, it creates no new commit, and the history stays a straight line.

If both branches have new commits, history has genuinely forked and Git must build a combination. It finds the last commit the two branches had in common, compares each side against it, and produces a merge commit — the one kind of commit with two parents. That commit is what records that these two lines of work joined here.

Some teams prefer a merge commit even when a fast-forward would do, because it preserves the visible fact that a feature branch existed. git merge --no-ff forces one. Others prefer the straight line. Neither is wrong; agree on one per project so your history is consistent.

Example
# Bring feature/cart into main
git switch main
git pull                       # make sure main is current first
git merge feature/cart

# Fast-forward: main had no new commits
# Updating 2c6b8e1..4b8e0c3
# Fast-forward
#  cart.html | 34 ++++++++++++++++
#  1 file changed, 34 insertions(+)

# Three-way merge: both branches moved
# Merge made by the 'ort' strategy.
#  cart.html | 34 ++++++++++++++++

# Force a merge commit even when fast-forward is possible
git merge --no-ff feature/cart

# See the join in the history graph
git log --oneline --graph --all
Notes
  • Merging never changes the branch you merged from. After git merge feature/cart on main, the feature/cart branch still exists and still points where it did. Merging is a one-way copy into the branch you are standing on.

What a Conflict Actually Looks Like

Git merges by file and, within a file, by region. Two people editing completely different files merge silently. Two people editing different parts of the same file usually merge silently too. A conflict happens only when both branches changed the same lines, or when one branch edited a file the other deleted. Git cannot know which version you meant, so it stops and asks.

When that happens, Git pauses the merge and edits the conflicting file in your working directory, inserting markers around the disagreement. This is the part that alarms people, because opening the file shows what looks like corrupted code. It is not corrupted — it is a question written in text.

Read it like this. Everything between <<<<<<< HEAD and ======= is the version already on your current branch. Everything between ======= and >>>>>>> feature/cart is the version coming in from the branch you are merging. Your job is to produce the correct final text and delete all three marker lines.

"Correct" does not mean picking a side. Quite often the right answer is a combination — your teammate's new function plus your new parameter. Sometimes it is neither, and you rewrite the region entirely. Git is not asking you to choose; it is telling you it cannot decide and handing you the pen.

Once the file reads the way it should, stage it. Staging a conflicted file is how you tell Git the conflict is resolved — there is no separate "mark as resolved" command. When every conflicted file is staged, commit to complete the merge.

Example
git merge feature/cart
# Auto-merging src/cart.js
# CONFLICT (content): Merge conflict in src/cart.js
# Automatic merge failed; fix conflicts and then commit the result.

# src/cart.js now contains:

function getTotal(items) {
<<<<<<< HEAD
  return items.reduce((sum, i) => sum + i.price, 0);
=======
  return items.reduce((sum, i) => sum + i.price * i.qty, 0);
>>>>>>> feature/cart
}

# You decide the correct result and delete ALL marker lines:

function getTotal(items) {
  return items.reduce((sum, i) => sum + i.price * i.qty, 0);
}

# Then stage the resolved file and finish the merge
git add src/cart.js
git status                 # confirms nothing else is unresolved
git commit                 # Git pre-fills a sensible merge message
Notes
  • git status during a conflict is your checklist. It lists every file still unmerged, so you can be certain you have not missed one buried further down a long file. Search your files for <<<<<<< before committing too — committing the marker lines by accident is a rite of passage nobody enjoys.

Tools, Shortcuts and the Escape Hatch

You do not have to edit markers by hand. VS Code detects a conflicted file and shows the two versions side by side with buttons: Accept Current Change, Accept Incoming Change, Accept Both Changes, and Compare. Under the hood it is doing exactly what you would do manually, so learning the manual form first means the buttons make sense rather than being magic.

When one whole file should simply take one side, git checkout --ours <file> and git checkout --theirs <file> do it in one command. During a merge, ours means the branch you are on and theirs means the branch being merged in. Be careful: during a rebase those two words swap meaning, because a rebase replays your commits on top of the other branch. If you are unsure which is which, look at the file instead of guessing.

The most important command in this lesson is the escape hatch. git merge --abort cancels the whole merge and returns your branch to exactly the state it was in before you started. Nothing from either branch is lost. If a merge opens up thirty conflicts across files you do not understand, aborting, asking your teammate what they changed, and trying again with a clear head is a completely reasonable engineering decision — not a defeat.

One more setting worth knowing: merge.conflictStyle diff3 adds a third block to the markers showing the common ancestor — what the code looked like before either side touched it. Seeing the original makes it far easier to work out what each side was trying to do, and therefore what the combined version should be.

Example
# Cancel the merge entirely, exactly as it was before
git merge --abort

# Take one whole file from one side (during a MERGE)
git checkout --ours   src/config.js      # the branch you are on
git checkout --theirs src/config.js      # the branch coming in
git add src/config.js

# Show the common ancestor inside the markers — much easier to reason about
git config --global merge.conflictStyle diff3

# <<<<<<< HEAD
#   return sum + i.price;
# ||||||| common ancestor
#   return sum;
# =======
#   return sum + i.price * i.qty;
# >>>>>>> feature/cart

# Which files are still unresolved?
git status
git diff --name-only --diff-filter=U
  • Conflicts live only in your working directory — nothing is broken in the history, and nothing has been pushed
  • Git never picks a side for you; if it could tell which was right, there would be no conflict
  • Staging a file is how you declare it resolved — there is no separate resolve command
  • git merge --abort always gets you back to safety before the merge is committed
  • After resolving, actually run the project. A conflict resolved into code that compiles but is wrong is worse than one left visible

Merge or Rebase?

There is a second way to combine branches, and it produces a very different-looking history. Merging joins two lines of development and records the join. Rebasing takes your commits, sets them aside, moves your branch to the tip of the other branch, and replays your commits on top one by one — as if you had started your work from there in the first place. The result is a straight line with no merge commit.

Rebasing produces a tidier history, which is why many teams like it, especially for updating a feature branch with the latest main. But understand what it is doing: replaying a commit creates a new commit with the same changes and a different hash. Your original commits are replaced by copies.

That leads to the one rule in this lesson you should treat as absolute: never rebase commits that other people already have. If your branch is pushed and a teammate has pulled it, rebasing rewrites the commits they are holding. Their Git now sees two histories claiming to be the same branch, and the usual outcome is a mess of duplicated commits, a force-push argument, and somebody's work getting lost. Rebase your own local, unpushed commits freely. Do not rebase main, and do not rebase a shared feature branch that others are working on.

A practical policy that many student teams adopt: rebase your feature branch onto main while it is still yours alone, then merge it into main with a normal merge. You get a clean branch and a safe integration. If you are ever unsure, merge — the worst outcome of a merge is a slightly noisier graph, whereas the worst outcome of a rebase on shared work is lost commits.

Example
# Update your feature branch with the latest main

# Option A — merge main into your branch (always safe)
git switch feature/cart
git merge main

# Option B — rebase your branch onto main (tidier, only if unpushed/private)
git switch feature/cart
git rebase main

# A rebase can hit conflicts too, one commit at a time:
#   fix the file, then
git add src/cart.js
git rebase --continue
#   or give up entirely and go back to how things were
git rebase --abort

# NEVER do this to a branch other people are using:
# git switch main && git rebase feature/cart
Notes
  • After rebasing a branch you had already pushed, a normal git push is rejected, because the remote's version and yours no longer share history. That rejection is Git telling you that you have rewritten published commits. Force-pushing past it is exactly the situation the Remote Repositories lesson covers — read that before you reach for --force.
Ask AI