N
Naveenr.dev
Chapter 02
11 min read2026-08-06
📖 Git SeriesChapter 02 · 11 chapters

Branching and Merging: Fast-Forwards, Three-Way Merges, and Real Conflicts

Two branches merge instantly nine times out of ten, then one day Git stops and asks you to resolve a conflict by hand. Understanding what a branch actually is — a movable pointer, not a copy of the project — explains both cases from the same underlying mechanism.

Git branching, merging, and conflict resolution
Git branching, merging, and conflict resolution

A teammate once asked me why merging their branch into main finished instantly with no message, while merging mine a day later stopped with a wall of <<<<<<< markers across three files.

Same command, same repository, wildly different outcome.

The answer isn't luck — it's entirely determined by whether the two branches' histories diverged. Once you can see that picture in your head, you can usually predict which kind of merge you're about to get before you even run the command.

A branch is just a pointer

The biggest misconception people carry over from other tools: a branch is not a copy of your project. Creating a branch does not duplicate any files.

bash
git branch feature-login

This creates a new label — feature-login — pointing at whatever commit HEAD currently sits on. That's the entire operation. It's a 41-byte file containing a commit hash. Both main and feature-login now point at the exact same commit. HEAD is still on main — you haven't switched yet.

HEAD itself is just another pointer, normally pointing at whatever branch you're on. Switching branches:

bash
git checkout feature-login
# or the cleaner modern form:
git switch feature-login
  • switch vs checkout:** switch was introduced because checkout had become an overloaded command doing several unrelated things — switching branches, restoring files, detaching HEAD. switch only switches branches. restore (covered later) only restores file contents. Both work identically, so you'll see either in the wild.

Committing on feature-login moves that branch's pointer forward to the new commit. main stays where it was — still pointing at the commit where you branched from. The two labels now point at different places in the commit graph.

Fast-forward merge — the easy case

If main hasn't moved since feature-login branched off it, merging is trivial: main's pointer just moves forward to match feature-login's.

No new commit is created. No conflict is possible. Because there was never a second line of history to reconcile — just a straight line with a branch label sitting partway along it.

bash
git switch main
git merge feature-login
# Fast-forward

Git just moves main's pointer forward to match feature-login. Both labels now point at the same commit. Nothing is being "combined" — main is just catching up.

  • You'll hit this when: You create a branch, finish your work, and nobody touched main in the meantime. Happens all the time on solo projects or very fast feature branches.

Three-way merge — when both sides moved

If main also had new commits after feature-login branched off it (someone else merged something — the completely normal case on any active team), a fast-forward is impossible.

Git does this with a three-way merge, using three snapshots:

  1. The common ancestor commit (where the branches split)
  2. The tip of main
  3. The tip of feature-login

For each file, Git compares what changed between the ancestor and each side:

  • Only one side changed a given file → that side wins automatically
  • Both sides changed the same lines of the same file → conflict
bash
git switch main
git merge feature-login

If there's no overlap, Git creates a merge commit with two parents — one pointing at main's tip and the other at feature-login's. That's how Git records that two separate lines of work were combined at this point.

Resolving a conflict

If both sides touched the same lines, Git stops and leaves inline markers:

text
const MAX_RETRIES = 5;
  • Between <<<<<<< HEAD and ======= → your current branch's version
  • Between ======= and >>>>>>> feature-login → what's coming in

You edit the file to whatever the correct final version is — one side, the other, or a blend — delete the marker lines entirely, then:

bash
git add config.js    # tells Git "this conflict is resolved"
git commit           # reuses the merge message Git already prepared
  • Conflict markers must be completely removed. If you leave a <<<<<<< line in the file and commit it, that's now in your code. Run git diff --staged before committing to double-check.

If a merge goes sideways and you want out entirely:

bash
git merge --abort

This resets everything back to exactly how it was before you ran git merge. I've used this more than once — stepping back, reading what actually changed on each side, and re-merging with clearer eyes always goes better than pushing through a resolution you're not confident about.

Rebase — an alternative way to reconcile branches

git rebase solves the same problem as a three-way merge but with a different result. Instead of creating a merge commit, rebase takes your branch's commits, sets them aside, and replays them one by one on top of the other branch's current tip — as if you'd branched off now instead of whenever you actually did.

bash
git switch feature-login
git rebase main

Git takes your feature commits, detaches them, and replays each one on top of where main is right now — as if you'd just branched from main's current tip. The result is clean, linear history with no merge commits.

The cost: your branch's commits get new hashes. This is a rewrite. Fine on a branch only you're working on. A genuine problem on a branch anyone else has already pulled — their local history no longer matches yours, and reconciling that is worse than the conflict you were trying to avoid.

Rule: Rebase freely before you share your branch. Merge (don't rebase) after it's been pulled by others.

Cherry-pick — taking one commit, not a whole branch

Sometimes you don't want to merge an entire branch — just one specific commit. Usually a bug fix that needs to land on a release branch without pulling in unrelated work-in-progress.

bash
git cherry-pick a1b2c3d

Replays the changes from commit a1b2c3d onto your current branch as a brand-new commit. Can conflict exactly like a merge can — same conflict-marker workflow applies.

  • You'll hit this when: A hotfix exists on a feature branch, and you need it on main or a release branch right now, without merging the entire feature.

Deleting a branch

Once a feature branch is merged, it's just a pointer sitting at a commit that's already part of main's history:

bash
git branch -d feature-login

-d refuses to delete a branch with unmerged commits (a safety check). -D force-deletes regardless — useful when you deliberately abandoned a branch, dangerous if you didn't mean to (though the commits are usually recoverable via git reflog, covered later).

The fast-forward vs. three-way distinction is the one mental model that makes merge behavior predictable. Before merging anything, ask yourself: has main moved since this branch was created? If no → fast-forward. If yes → three-way merge, possibly with conflicts.

Rebase came up here as an alternative, but its more common real-world use is cleaning up your own commits before sharing them — squashing WIP commits into one meaningful commit, fixing typos in commit messages. The next chapter covers interactive rebase in depth.

Enjoyed this chapter?

Get an email when I publish the next chapter. No spam — just new technical deep-dives.

Comments

Share feedback or questions about this blog post.

No comments yet. Be the first to share your thoughts.