Lesson 05 / 15
Fast-Forward and Merge Commit
Merging two branches writes either nothing to history or a commit with two parents; a twelve-commit development grows to fifteen and answers five of six questions as a merge commit, or stays at twelve and answers three as a fast-forward.
Contents
Opening a branch was cheap: a single commit ID written to a reference file. Naming brought order: reading a branch name made clear who was developing what. Neither operation grew history — each only added pointers to it.
This lesson’s question picks up from there. At some point after two branches diverge, they merge; what does that merging write to history? The answer is not single. The tool has two distinct behaviors, and which one applies determines how many of the questions asked of history months later can be answered.
Two Cases of Merging
Bringing a branch into main means finding the common ancestor of two commit IDs and applying the difference between them. The tool faces two distinct cases.
The first case: no commit has been added to main since the branch diverged. The branch’s commits already build on top of main’s last commit, and there is nothing to merge. The tool’s only job is to move main’s pointer forward to the branch’s tip. This is called a fast-forward.
The second case: both sides have taken commits since diverging. History has genuinely forked and cannot be closed by moving a single pointer. The tool merges the two sides’ content and writes the result to a new commit with two parents: a merge commit.
# taught command and example dump — not executed $ git merge metrics Updating 3f1a2b0..9c4d7e1 Fast-forward metrics.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) $ git merge report Merge made by the 'ort' strategy. report.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+)
The first call says Fast-forward: no new commit was produced. The second reports that
it merged under a strategy name: a new commit was produced. The same command, two
different results.
Common Ancestor and Three-Way Merge
For the tool to know which case it faces, it must first find the common ancestor: the first commit reached by tracing back from both branch tips. The common ancestor is the point where the two branches diverged. The first case’s definition can be written in terms of the common ancestor: if the common ancestor equals main’s tip, main has not moved since the branch diverged, and a fast-forward applies.
If the common ancestor is not equal to main’s tip, the merge works over three file versions: the version at the common ancestor, the version on main, and the version on the branch. This is called a three-way merge, and the third version — the state at the ancestor — is what supplies the measure. If placing two versions side by side were enough, the tool would count every difference as a conflict; the ancestor version lets the tool know who changed a given line. If only one side changed it, that side’s version is taken; if both sides changed the same line in different ways, no decision can be made and a merge conflict is born.
The common ancestor is the result of a search, not a field written into history. This means the cost of merging grows with branch age: the farther back the ancestor lies, the more lines the two sides have diverged on.
What Fast-Forward Writes
A fast-forward writes nothing to history. Main’s reference points to a new ID, and that is all. The branch’s commits appear in main’s history exactly as they were, but no record remains in history saying they were developed on a branch. Once the branch pointer is deleted, the information that those commits belonged to a separate piece of work is deleted with it.
The choice does not have to be left to the tool. The default behavior fast-forwards when
it can; the --no-ff option forces a two-parent commit even when a fast-forward is
possible; the --ff-only option does the opposite and stops without merging if a
fast-forward cannot be applied. The third looks useless at first glance, but it is a
safety lock: on a team that wants its history to stay linear, it blocks an
unintentionally produced merge commit from getting in. None of the three settings is
more correct than the others; all three are separate answers to the same question, and
this lesson’s measurement asks that question.
What the Merge Commit Writes
The merge commit’s one distinguishing feature is that it has two parents. The first parent is main’s current tip, the second is the tip of the branch being merged. This second edge is the only thing carrying the branch structure of history.
# taught command and example dump — not executed $ git log --oneline --graph * a1b2c3d merge report branch |\ | * 7e8f9a0 report: add summary line | * 4d5e6f1 report: fix field width |/ * 9c4d7e1 metrics: print threshold value
The drawing on the left side is produced from the second parent. The two indented lines
are the report branch’s commits; the line carrying the |/ mark shows the divergence
point. This drawing cannot be produced for a branch taken in by fast-forward, because
there is no edge to draw.
The IDs and file names here are invented. Running the same command in your own repository gives different IDs; what stays fixed is the structure of the output.
Setting Up the Measure
The setup that runs through the course is this: three people start three separate pieces
of work in the same repository — metrics, report, and identity. Each makes four
commits. On the third step, metrics and report both touch the same file,
config.py. The report branch’s second commit introduces a bug. The real development
is twelve commits, and its order is known, because we wrote the setup ourselves.
These twelve commits are brought into main through separate integration formats. All the formats produce the same code; the history they produce differs. The course measures this difference with a single metric: six questions are asked of the history, and how many it can answer is counted. These are called the course’s answerable questions, and their definitions do not change throughout the course.
- Feature grouping — which commits belong to which piece of work?
- Bug isolation — can the commit that introduced the bug be found on its own?
- True order — in what order was the work developed?
- File trail — can every commit that touched a file be found individually?
- Branch integrity — do a branch’s commits stay together?
- Conflict decision — where was the conflict resolved, and which side was chosen?
These six questions are the course’s constant; their definitions do not change in later lessons, only their answers do.
The measurement’s assumptions:
- MR1 — The real development is produced by the setup; its order and buggy commit are known, because we wrote the setup. History is not required to know this.
- MR2 — Fast-forward represents the case where no commit was added to main after the branch diverged: commits are arranged in real time order and no branch record is kept.
- MR3 — A merge commit adds one closing commit per branch; this commit touches no file and carries no bug, it carries only the branch record.
- MR4 — A question being answerable means the record giving that answer is found in history; how much effort the reader spends does not enter the measurement.
- MR5 — The sixth question is measured by searching history for a field carrying the chosen side of a conflict. This lesson does not resolve a conflict; the field does not exist, and in this lesson the question goes unanswered for both formats.
- MR6 — The measurement does not assume it produces code; the two formats produce identical code, and only history is measured.
- MR7 — The set’s resolution is 1/12 at twelve commits and 1/6 at six questions; a difference smaller than this cannot be defended with this setup.
Measurement
"""Two integration formats: merge commit and fast-forward. Part 1 - real development: three branches, four commits per branch. Part 2 - six questions asked of the history the two formats leave behind. """ SEED = 20260813 BRANCHES = ("metrics", "report", "identity") FILES = {"metrics": "metrics.py", "report": "report.py", "identity": "identity.py"} BUGGY = ("report", 2) SHARED_FILE = "config.py" def rng(seed): state = seed % 2147483646 + 1 def draw(n): nonlocal state state = (state * 48271) % 2147483647 return state % n return draw def development(): draw, record, time = rng(SEED), [], 0 for step in range(1, 5): for branch in BRANCHES: time += 1 + draw(3) file = (SHARED_FILE if step == 3 and branch in ("metrics", "report") else FILES[branch]) record.append({"branch": branch, "step": step, "file": file, "buggy": (branch, step) == BUGGY, "time": time}) return record def integrate(record, format): t = [] if format == "merge commit": for branch in BRANCHES: for k in [x for x in record if x["branch"] == branch]: t.append({**k, "branch_record": branch, "time_preserved": True}) t.append({"branch": branch, "step": 0, "file": None, "buggy": False, "time": max(x["time"] for x in record if x["branch"] == branch), "branch_record": branch, "merge_commit": True, "time_preserved": True}) elif format == "fast-forward": for k in sorted(record, key=lambda x: x["time"]): t.append({**k, "branch_record": None, "time_preserved": True}) return t def q1_grouping(t): return all(x.get("branch_record") for x in t if not x.get("merge_commit")) def q2_bug(t): buggy = [x for x in t if x["buggy"]] return len(buggy) == 1 and not buggy[0].get("squashed") def q3_order(t): return all(x.get("time_preserved") for x in t) def q4_file(t): return all(isinstance(x["file"], str) or x.get("merge_commit") for x in t) def q5_integrity(t): positions = {} for i, x in enumerate(t): if x.get("merge_commit"): continue positions.setdefault(x["branch"], []).append(i) return all(y[-1] - y[0] == len(y) - 1 for y in positions.values()) def q6_conflict(t): return any("chosen" in x for x in t) QUESTIONS = (("feature grouping", q1_grouping), ("bug isolation", q2_bug), ("true order", q3_order), ("file trail", q4_file), ("branch integrity", q5_integrity), ("conflict decision", q6_conflict)) FORMATS = ("merge commit", "fast-forward") record = development() T = {f: integrate(record, f) for f in FORMATS} print(f"real development: {len(record)} commits, {len(BRANCHES)} branches, shared file " f"touched by {sum(1 for k in record if k['file'] == SHARED_FILE)}, " f"buggy commit {sum(1 for k in record if k['buggy'])}") print() print(f"{'format':<22s} {'commits':>7s} {'from development':>17s} {'added':>7s}") for f in FORMATS: added = sum(1 for x in T[f] if x.get("merge_commit")) print(f"{f:<22s} {len(T[f]):7d} {len(T[f]) - added:17d} {added:7d}") print() print(f"{'question':<20s} {'merge commit':>14s} {'fast-forward':>13s}") for name, question in QUESTIONS: print(f"{name:<20s} {('yes' if question(T[FORMATS[0]]) else 'no'):>14s}" f" {('yes' if question(T[FORMATS[1]]) else 'no'):>13s}") print(f"{'answered':<20s} {sum(s(T[FORMATS[0]]) for _, s in QUESTIONS):14d}" f" {sum(s(T[FORMATS[1]]) for _, s in QUESTIONS):13d}")
real development: 12 commits, 3 branches, shared file touched by 2, buggy commit 1 format commits from development added merge commit 15 12 3 fast-forward 12 12 0 question merge commit fast-forward feature grouping yes no bug isolation yes yes true order yes yes file trail yes yes branch integrity yes no conflict decision no no answered 5 3
What Three Commits Buy
The top table compares the two formats by commit count. The merge commit grows history to 15 commits: twelve from the real development, 3 closing commits added by the tool. Fast-forward stays at 12 and adds nothing.
Intuition here favors fast-forward: same work, fewer records. The bottom table says the opposite. The merge commit answers 5 of the six questions, fast-forward answers 3. Three extra commits buy two extra answers.
Which two answers are bought? Feature grouping and branch integrity. Both arise
from the same loss: fast-forward keeps no branch record. Commits are arranged in real
time order, so the three pieces of work’s commits interleave — the metrics branch’s
four commits do not sit consecutively on main, the other two branches’ commits fall
between them. History cannot answer “which piece of work did this commit belong to,”
because the record naming the work was never written.
Four are shared: bug isolation, true order, and file trail are yes in both formats. The buggy commit stands alone in both histories; no format merged it with another. Timestamps were preserved too. These three answers need no extra record.
Conflict decision is no in both formats. metrics and report touch the same file
on the third step, and this gives rise to a merge conflict; but neither history
carries a record of how the conflict was resolved. What is worth noting: the merge
commit carries where the conflict happened — the resolution is written in that
commit — but it does not keep which side was chosen as a separate field. The
measurement checks for this distinction by searching for a field, and the field is
absent in both formats. This result holds for all four formats, and the next lesson pays
for it.
What the Choice Depends On
The difference between the two formats is not right versus wrong, it is a trade-off. In the measured set the difference is 2/6: two of six questions. Since the set’s resolution is 1/6, this difference is twice the measurement band and is defensible.
The price paid is 3/12: three commits are added to a twelve-commit development. This price grows linearly as the branch count grows, because every branch adds one closing commit. In a twelve-branch integration, a quarter of history consists of closing commits, and the log output counts closings instead of showing the real work.
The criterion is this: will the branch record be queried later? In a single-commit fix it is not, and the fast-forward loss is zero. In a four-commit feature branch it is; there the three-commit price is paid for the answers to two questions.
The three added commits carry a second benefit as well. The merge commit’s first parent is main’s own line; when the log is called to follow only first parents, history shrinks from the measurement’s twelve commits to three closing commits. The same history becomes readable at two separate resolutions: three lines for the question of what was done, twelve lines for the question of how it was done. Fast-forward has no second resolution, because there is no edge to separate by — every reading gives twelve lines.
# taught command and example dump — not executed $ git log --oneline --first-parent a1b2c3d merge report branch b2c3d4e merge metrics branch 9c4d7e1 initial setup
Both of these resolutions rest on the branch record; without the record, the two collapse into each other and the distinction cannot be established.
Summary
- Fast-forward applies when main has not advanced since the divergence point, and it writes no commit to history; it only moves the branch pointer forward.
- A merge commit is a commit with two parents; the second parent is the only record carrying history’s branch structure, and the log’s drawing is produced from it.
- A twelve-commit development grows to 15 commits under a merge commit and stays at 12 under fast-forward; the 3 added commits are one closing commit per branch.
- The merge commit answers 5 of the six questions, fast-forward answers 3; the two questions that make the difference are feature grouping and branch integrity.
- Bug isolation, true order, and file trail are answered in both formats; conflict decision goes unanswered in both.
Next Step
In the measurement, metrics and report touched the same file, and neither history
recorded how that was resolved. The next lesson looks at the resolution itself: what the
tool writes to the file when it cannot merge the two sides, how conflict markers are
read, how the resolution is verified to be correct — and, once resolved, exactly what is
written to history and what is not.
To keep your progress and take notes, Log in
My notes
Log in to take notes.