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.

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.
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:
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.
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:
- The common ancestor commit (where the branches split)
- The tip of main
- 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
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:
const MAX_RETRIES = 5;
- Between
<<<<<<< HEADand=======→ 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:
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:
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.
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.
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:
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.