Merging, Rebasing, and Reading History

The moment two people (or two agents) commit to the same repo, history stops being a straight line and git has to combine work. This page covers what history actually is (a DAG), what a merge really does, what a conflict really is (git refusing to guess, not git breaking), and when rebase is the better tool — plus the one rule that keeps rebase safe.

Sanitized — no real hosts, paths, or project names. Continues the sensor-logger example. Assumes Git-from-First-Principles (commits are snapshots with parent pointers) and the daily loop from Pull-Commit-Push-on-a-Shared-Repo.


History is a DAG

Git-from-First-Principles left history as a linked list: each commit points at its parent. Two things upgrade the list to a directed acyclic graph:

  • A commit can have two parents (a merge commit — it joins two lines of work).
  • Two commits can share one parent (a fork — two people started from the same snapshot).

You read the graph with:

$ git log --oneline --graph --all
* 9c4e2d1 (fix-42) fix: clamp negative sensor readings
| * 5a81f03 (main) docs: update calibration table
|/
* 3d1c07a chore: release v1.2

Read it bottom-up (git prints newest first): both branches forked from 3d1c07a. That fork point matters more than anything else on this page — git calls it the merge base, and every merge decision flows from it.

Worth aliasing on day one:

git config --global alias.lg "log --oneline --graph --all --decorate"

Two range notations you’ll use constantly:

  • git log main..fix-42 — commits on fix-42 that aren’t on main (“what does this branch add?”).
  • git diff main...fix-42 (three dots) — the diff from the merge base to fix-42 (“what did this branch change?”). This is what a merge request shows, and it’s how you review an agent’s branch without their diff being polluted by unrelated main movement.

Merge, case 1: fast-forward

If main hasn’t moved since fix-42 forked, there is nothing to combine:

$ git switch main
$ git merge fix-42
Updating 3d1c07a..9c4e2d1
Fast-forward

Remember what a branch is — a pointer. main simply slides forward to 9c4e2d1. No new commit, no merging of content, nothing could conflict. History stays a straight line, as if the work had been done directly on main.

Merge, case 2: three-way merge

If both branches moved since the fork, git combines them with three inputs — hence “three-way”:

        base (merge base — the fork point)
       /    \
   ours      theirs
  (main)    (fix-42)

For every file, git compares each side against the base:

  • Only one side changed a region relative to base → take that side. No conflict.
  • Both sides changed the same lines relative to base → git refuses to guess. That’s a conflict.

The result is a merge commit — an ordinary snapshot, except with two parents. The DAG joins back together.

The key reframe: a conflict is not an error and not damage. It is git stopping at exactly the spots where two humans made contradictory decisions, and asking a human to make the call. Everything else in the merge — usually 99% of it — was combined automatically.

Resolving a conflict without fear

In the lab repo from Start-Here-What-Git-Is this is a real, waiting exercise — fix-42 and main edited the same clamp on purpose, so run the merge below in your fork and follow along against actual output rather than the printed kind.

$ git merge fix-42
Auto-merging sensor.c
CONFLICT (content): Merge conflict in sensor.c
Automatic merge failed; fix conflicts and then commit the result.

Three things to know before touching anything:

  1. You can always back out. git merge --abort returns the tree to exactly where it was before the merge. Nothing is at risk while you decide.
  2. git status is the worklist. It lists every unresolved file under “unmerged paths”; resolved-and-staged files drop off the list.
  3. The markers show three versions, not two (set git config --global merge.conflictStyle zdiff3 to include the base):
<<<<<<< HEAD
    if (reading < MIN_VALID) reading = MIN_VALID;
||||||| base
    if (reading < 0) reading = 0;
=======
    if (reading < 0) return SENSOR_ERR_RANGE;
>>>>>>> fix-42

With the base visible you can see what each side intended: ours changed the clamp floor, theirs changed clamping into an error return. Your job is to write the line(s) that honor both intentions (or decide one supersedes the other), delete all the markers, then:

git add sensor.c      # "add" here means "I declare this file resolved"
git merge --continue

The loop is: edit → git add → repeat per file → --continue. That’s the whole procedure. A conflicted merge never destroys either side’s work — both full snapshots still sit in the object database; the conflict is only about what the new snapshot should say.

Rebase: replaying instead of joining

A merge joins two lines with a two-parent commit. A rebase does something different: it replays your commits, one at a time, on top of a new base.

$ git switch fix-42
$ git rebase main

Git finds the commits unique to fix-42, then applies each one’s changes onto the tip of main, creating new commits as it goes. Afterward, history is a straight line — as if you’d started your work from today’s main instead of last week’s. (Conflicts can happen here too, per replayed commit; same marker format, resolve the same way, then git rebase --continue. And git rebase --abort fully backs out, same as merge.)

The critical mechanical fact: the replayed commits are new commits. A commit’s hash covers its content, its parent, and its metadata — change the parent and you’ve changed the hash. Rebase doesn’t move your commits; it makes copies with new IDs and moves your branch pointer to the copies. The originals still exist, unreachable (recoverable via the reflog — Undoing-Things-in-Git).

Which is exactly why the one rule exists:

Never rebase commits that others already have. Rebase your private, unpushed branches freely. Once a branch is pushed and someone may have built on it, its commits are shared history — rewriting them means your copies and their originals both exist, and git sees two divergent lines that then have to be merged anyway, with duplicated commits. (This is also why a bare git push gets rejected after you rebase a pushed branch — and why force-pushing over it is a decision, not a fix.)

Merge or rebase?

Both produce the same final content. They produce different history:

MergeRebase
History shapePreserves the true DAG — you can see the fork and the joinLinear — reads as if work happened sequentially
Commit IDsUnchangedRewritten
Safe on shared branchesYesNo
Typical useLanding a finished branch into mainUpdating your in-progress branch onto latest main before review

A serviceable house default: rebase your own unpushed work to keep it current; merge to land it. Teams also commonly use squash merges (the whole branch lands as one commit on main) — that’s a platform-level choice in GitLab/GitHub merge settings, and it makes the branch’s internal commit hygiene matter less.

Where this connects to the rest of the series: in the parallel-agent pattern from Git-Worktrees-and-Parallel-Agents, each agent branch is reviewed with git diff main...issue-N, then landed by merge in risk order — and if two agent branches made logically conflicting changes, this page is the machinery that catches it, at merge time.

Next: Undoing-Things-in-Git — reset, revert, and the reflog, or why no committed work is ever actually lost.