Basis Lab

How a Repository Remembers

Why a Merge Only Stops to Ask About Some of Your Changes

Last timeThe Three Places a File Lives

A merge reads three versions of every file, not two, and the third one is the commit both lines came from. That reading decides what it can apply for you.

What a join is actually comparing

The repository worked here holds three files, readme.md, settings.ini and

render.py. Its main line has seven commits. A side line, named proofs, has

three, and it starts from the fifth commit on the main line, so the two lines

have been apart for a while and both of them have edited settings.ini.

Every name quoted below is the first seven characters of a real one, and anyone

who builds the same three files with the same commit messages will read the

same names out, because a name here is computed from the contents it names.

That claim is only worth making if you can check it, so here is the script

that produced the part of the history this lesson and the next one argue over.

FIG 1The six commits this lesson and the next are about
bash
cd plotter
export GIT_AUTHOR_NAME='A Writer' GIT_AUTHOR_EMAIL='[email protected]'
export GIT_COMMITTER_NAME='A Writer' GIT_COMMITTER_EMAIL='[email protected]'
git checkout -q main
export GIT_AUTHOR_DATE='2026-02-02T09:00:00 +0000'
export GIT_COMMITTER_DATE='2026-02-02T09:00:00 +0000'
printf '[page]\nwidth = 800\nheight = 480\n\n[render]\nquality = draft\n' > settings.ini
git add -A && git commit -q -m 'Add a render section'
export GIT_AUTHOR_DATE='2026-02-03T09:00:00 +0000'
export GIT_COMMITTER_DATE='2026-02-03T09:00:00 +0000'
printf '[page]\nwidth = 800\nheight = 600\n\n[render]\nquality = draft\n' > settings.ini
git add -A && git commit -q -m 'Raise the page height to 600'
export GIT_AUTHOR_DATE='2026-02-04T09:00:00 +0000'
export GIT_COMMITTER_DATE='2026-02-04T09:00:00 +0000'
printf 'def render(page):\n    if page.blank:\n        return page\n    frame(page)\n    return page\n' > render.py
git add -A && git commit -q -m 'Leave a blank page unframed'
git checkout -q -b proofs main~2
export GIT_AUTHOR_DATE='2026-02-06T09:00:00 +0000'
export GIT_COMMITTER_DATE='2026-02-06T09:00:00 +0000'
printf '[page]\nwidth = 800\nheight = 900\n\n[render]\nquality = draft\n' > settings.ini
git add -A && git commit -q -m 'Raise the page height to 900'
export GIT_AUTHOR_DATE='2026-02-07T09:00:00 +0000'
export GIT_COMMITTER_DATE='2026-02-07T09:00:00 +0000'
printf '[page]\nwidth = 800\nheight = 900\n\n[render]\nquality = proof\n' > settings.ini
git add -A && git commit -q -m 'Set the render quality to proof'
export GIT_AUTHOR_DATE='2026-02-08T09:00:00 +0000'
export GIT_COMMITTER_DATE='2026-02-08T09:00:00 +0000'
printf '# Plotter\n\nA small renderer.\n\nSettings live in settings.ini.\n\nProofs use a taller page.\n' > readme.md
git add -A && git commit -q -m 'Point the readme at the proof settings'
git checkout -q main
Run this inside the repository the first lesson's script left behind and it adds three commits to the main line and three on a side line forking from main~2, which is where every name below comes from. The identity and both dates are pinned on every commit, because an unpinned committer date or a committer name taken from the machine changes the bytes of the commit and so changes its name. And no export line reads a variable it is setting on that same line, because every right hand side is expanded before any of the assignments on the line take effect, which would leave the second value empty.
FIG 2The two lines, their ancestor and the join
Main has moved 7 minus 5, which is 2 commits, since the point the lines parted, and the side has moved 3, so the join is deciding about 5 commits out of the 13 in the repository. The 5 commits at or before the fork are common to both sides and nothing has to be decided about them, and the 3 on the unrelated tint line are in neither reading.

Main's tip, fbdccf5, holds one version of settings.ini. The side's tip,

3f7bf69, holds another. Compare just those two and all you learn is that they

differ. You cannot tell whether main changed the file, or the side changed it,

or both did, and those three situations want three different answers: take the

side's, take main's, and ask a person.

So the missing reading is the version both lines started from. The commit both

tips descend from is the merge base, and asking for it in this repository

reports ea21ea3, the fifth commit on the main line. A join compares three

versions of everything, which is why it is called a three way merge, and the

third version is the entire reason it can decide anything for itself.

FIG 3The join, and the three readings it kept
bash
git merge-base main proofs            # ea21ea3, the fifth commit on the main line
git merge proofs                      # CONFLICT content: Merge conflict in settings.ini
git diff --name-only --diff-filter=U  # settings.ini, and nothing else
git ls-files -u settings.ini          # stage 1 ca72d6e, stage 2 721142e, stage 3 b80b111
git status --short                    # M readme.md, UU settings.ini, render.py not listed
Four lines of evidence for everything claimed below. The merge base is one commit, not a range. One file of three stopped the merge. The three numbered stages are the three readings the rule needs, kept side by side: stage 1 is the ancestor, stage 2 is main, stage 3 is the side. And render.py is absent from the last line because its result is already what is on disk, which is a decision the merge made rather than a file it ignored. The join that follows was committed with the same pinned identity and with both dates set to 2026-02-10T09:00:00 +0000, which is what makes the name of the merge commit itself reproducible.

What equality with the ancestor decides

Write bb for the version held in the merge base, oo for the version at

main's tip, and tt for the version at the side's tip. For any region of any

file the result rr follows from comparisons alone.

If t=bt = b the side never touched this region, so the only change is main's and

r=or = o. If o=bo = b the reverse, so r=tr = t. If o=to = t both sides made the

same change, there is nothing to choose between them, and r=or = o. Otherwise

both sides moved away from the ancestor and moved somewhere different, and

every possible answer silently discards somebody's work, so the merge stops and

reports a conflict.

The lesson stops here

5 more paragraphs to go

You have read the opening. The rest of the argument, the problems that check whether it landed, and the lines worth keeping at the end all come with a plan.

The first lesson of every course in the library reads the whole way through, free, so you can see exactly what the rest of them are.

See the planThe contents

This is the reading half

Starting the course gives you your own copy of it. Every idea on every page has problems standing under it, marked with a reason rather than a tick, and any sentence you do not believe can be opened and argued with. None of that can happen on a page nobody owns.

The contents

The rest of this course

  1. 01What a Git Hash Is Actually a Hash Of
  2. 02Why a Commit Is a Whole Snapshot and Still Costs Almost Nothingopening only
  3. 03Every Commit Names Its Parent, and a Branch Is One Line in a Fileopening only
  4. 04Why the Same File Is Reported as Both Staged and Not Stagedopening only
  5. 05Why a Merge Only Stops to Ask About Some of Your Changesyou are here
  6. 06Why Your Commits Get New Names When You Rebaseopening only
  7. 07A Hard Reset Deletes Nothing, and You Have About Thirty Daysopening only
  8. 08Why Your Push Was Rejected, and the One Push That Destroys Workopening only

Read alongside