Git Branching and Merging

Fast-forward vs. true merge

If the target branch's tip is a direct ancestor of the branch being merged in, Git doesn't need to combine anything — it just moves the target branch's pointer forward.

graph LR subgraph before["Before: fast-forward possible"] m1["main"] --> c1["C1"] f1["feature"] --> c2["C2"] --> c1 end subgraph after["After 'git merge feature' on main"] m2["main"] --> c2b["C2"] f2["feature"] --> c2b end

No new commit is created — main simply now points at the same commit feature does. This is only possible when main hasn't moved forward independently since feature branched off. If both branches have new commits since they diverged, Git creates a merge commit — a commit with two parents, combining both histories:

graph LR c1["C1"] --> c2m["C2 (main)"] c1 --> c3f["C3 (feature)"] c2m --> merge["Merge commit<br/>(parents: C2 AND C3)"] c3f --> merge

The merge commit's tree is computed by a three-way merge: compare feature's tree, main's tree, and their common ancestor's tree; for each file, if only one side changed it, take that side's version; if both changed the same lines, that's a conflict requiring manual resolution.

Rebase: rewriting history to look linear

graph LR subgraph before2["Before rebase"] c1b["C1"] --> c2b["C2 (main)"] c1b --> c3b["C3 (feature)"] end subgraph after2["After 'git rebase main' on feature"] c1a["C1"] --> c2a["C2 (main)"] --> c3a["C3' (feature, NEW commit)"] end

git rebase main (run while on feature) takes each commit unique to feature (just C3 here) and replays it on top of main's current tip — but replaying creates a genuinely new commit object (C3', a different hash from C3, even though the resulting file content is identical) with main's tip as its parent instead of the original C1. The old C3 still exists in the repository (until garbage collected) but feature now points at C3' — the history looks like feature was always built directly on top of main's latest commit, with no merge commit and no visible fork.

When to use which

Rebase before merging a feature branch into main produces a clean, linear history that's easier to read with git log and easier to bisect when hunting a regression — every commit sits in a single, unambiguous line. A true merge preserves the actual history (this work really did happen on a separate branch, in parallel) — valuable when that context matters, but it makes git log visually noisier with merge commits and forked lines.

The golden rule of rebasing

Never rebase a branch that other people have already pulled from. Rebase creates new commit objects with different hashes — if someone else has feature checked out (or has based their own work on it) and the remote feature branch gets rebased and force-pushed, their local history now disagrees with the remote's at the object level. Their next pull either creates a confusing duplicate set of commits or requires them to manually reset to match the rewritten history. Rebase freely on a local, not-yet-pushed branch (or one only you use) — once it's shared, prefer merge, or coordinate explicitly if a rebase of shared history is genuinely necessary.

Common pitfall

Running git rebase main on a feature branch, hitting a conflict, resolving it, and then being surprised the same conflict reappears on the next commit being replayed is actually expected behavior, not a bug — rebase replays commits one at a time, and if multiple commits touch the same conflicting lines, each one triggers its own conflict resolution independently, because each is being individually re-applied against a history it wasn't originally written against. git rebase --abort at any point returns to the pre-rebase state cleanly if the conflict resolution gets too tangled to continue.