Lesson 09 / 15
Merge Strategies
Four integration formats bring the same twelve-commit development into main and leave 15, 12, 3, and 12 commits in history; the number of questions they answer is 5, 3, 2, and 3, and squash, the cleanest history, makes all twelve commits unreadable individually.
Contents
Three of the course’s integration formats are now built: merge commit, fast-forward, and rebase. Cherry-pick was also measured in between, but it was not an integration format — it was a way of moving a single change.
This lesson adds the fourth format — squash, which collects a branch’s entire set of commits into a single commit — and puts all four side by side. What is being asked is no longer what one format does, but how the four rank on the same development — and that the ranking comes out in opposite directions on two measures is this course’s second claim.
Two Separate Decisions, One Word
The word “strategy” points to two separate things here, and confusing them is common.
The first is the content strategy: which method the tool will use to merge the two sides’ files. The default method is the three-way merge set up in the first lesson; it looks at the ancestor version, takes one-sided changes, and cannot decide on two-sided ones. The tool also has a separate method that merges more than two branches at once, and that method stops without doing anything if a conflict arises. Strategy options, meanwhile, let one side be automatically preferred in a conflict.
The second is the format decision: what gets written to history once the merge is done. A closing commit, a rewritten chain, a single collected commit, or nothing at all.
The two decisions are independent. The same content strategy can end in four different formats; the same format can be produced with different content strategies. What this course measures is only the second, because the code is the same across all four.
One warning applies to the first decision. A strategy option that automatically prefers one side in a conflict is the integration-scale version of the second lesson’s shortcut of taking a whole file from one side: it does not resolve the conflict, it counts it as resolved. The other side’s change to those lines drops silently and leaves no sign in history. It is fitting for generated files; it is not fitting for hand-written code.
The Fourth Format: Squash
Squash never brings the branch’s commits into main at all. The tool’s job is to apply the branch’s total change to the working area and stop there; you write the commit, and a single commit is written.
# taught command and example dump — not executed $ git merge --squash report Squash commit -- not updating HEAD Automatic merge went well; stopped before committing as requested $ git commit -m "report: summary line and field width" [main 6f7a8b9] report: summary line and field width 2 files changed, 37 insertions(+), 4 deletions(-)
Two lines matter. The not updating HEAD line reports that no merge commit is written:
the produced commit has a single parent and there is no edge leading to the branch. The
file count in the second command’s output is the sum of the branch’s four commits —
four separate changes are combined into a single patch.
This has an unseen consequence: since the branch’s commits are not main’s ancestor, if the same branch is later merged in the usual way, the tool thinks it was never taken in at all and tries to bring in all its commits again. A branch taken in through squash must be closed once it is taken in.
The reason usually given for choosing squash is this: interim commits written during development — half-finished experiments, self-correcting steps — fill up main’s log. The reason is real, but the measurement shows what is given up in exchange. There are two ways to reduce interim commits’ noise: deleting them or writing them properly. The first also deletes history’s resolution; the second does not. Squash is a choice between these two paths, not a necessity.
The measurement’s assumptions:
- MR29 — All four formats bring the same twelve-commit development into main and produce the same code; only the history they leave is measured.
- MR30 — Squash collects each branch’s commits into a single commit: the file field becomes a list of names rather than a single name, and the bug field is true if any commit in the group has a bug.
- MR31 — A “separately readable commit” is one that stands on its own in history and has not gone into a squash. The closing commits a merge commit adds are not counted in this number.
- MR32 — “Lost” is the count of the twelve real commits that cannot be read individually in history; nothing is lost as code.
- MR33 — The six questions’ definitions are the course’s constant and are not changed in this lesson; they are only run separately for the four formats.
- MR34 — The conflict decision question is unanswered in all four formats, as established in the second lesson. This lesson does not change that result, it shows it in the table.
- MR35 — The set’s resolution is 1/12 at twelve commits and 1/6 at six questions.
Measurement
"""Four integration formats side by side: commit count and answered questions. Part 1 - all four formats bring the same twelve commits into main. Part 2 - the six questions are asked of all four histories. Part 3 - what the squashed history's three commits carry. """ 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 == "rebase": for branch in BRANCHES: for k in [x for x in record if x["branch"] == branch]: t.append({**k, "branch_record": None, "time_preserved": False}) elif format == "squash": for branch in BRANCHES: group = [x for x in record if x["branch"] == branch] t.append({"branch": branch, "step": 0, "file": sorted({x["file"] for x in group}), "buggy": any(x["buggy"] for x in group), "time": max(x["time"] for x in group), "branch_record": branch, "time_preserved": False, "squashed": len(group)}) 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", "rebase", "squash", "fast-forward") record = development() T = {f: integrate(record, f) for f in FORMATS} print(f"real development: {len(record)} commits, {len(BRANCHES)} branches, buggy " f"{sum(1 for k in record if k['buggy'])}") print("questions:", ", ".join(f"q{i}={name}" for i, (name, _) in enumerate(QUESTIONS, 1))) print() print(f"{'format':<22s} {'commits':>7s} {'separate':>8s} {'lost':>6s}" f" {'q1 q2 q3 q4 q5 q6':<17s} {'answered':>8s}") for f in FORMATS: separate = sum(1 for x in T[f] if not x.get("merge_commit") and not x.get("squashed")) marks = " ".join("+" if question(T[f]) else "-" for _, question in QUESTIONS) print(f"{f:<22s} {len(T[f]):7d} {separate:8d} {len(record) - separate:6d}" f" {marks:<17s} {sum(s(T[f]) for _, s in QUESTIONS):8d}") print() print("commits in the squashed history:") for x in T["squash"]: print(f" {x['branch']:<7s} {x['squashed']} commits -> 1 | files " f"{','.join(x['file'])} | bug " f"{'yes' if x['buggy'] else 'no'}")
real development: 12 commits, 3 branches, buggy 1 questions: q1=feature grouping, q2=bug isolation, q3=true order, q4=file trail, q5=branch integrity, q6=conflict decision format commits separate lost q1 q2 q3 q4 q5 q6 answered merge commit 15 12 0 + + + + + - 5 rebase 12 12 0 - + - + + - 3 squash 3 0 12 + - - - + - 2 fast-forward 12 12 0 - + + + - - 3 commits in the squashed history: metrics 4 commits -> 1 | files config.py,metrics.py | bug no report 4 commits -> 1 | files config.py,report.py | bug yes identity 4 commits -> 1 | files identity.py | bug no
Readability versus Answerability
The commit column ranks the four formats this way: 15, 12, 3, 12. The shortest history is squash’s — three branches, three commits. The longest is the merge commit’s; three closings are added on top of twelve commits. If readability is the measure, the ranking is clear: squash first, merge commit last.
The answered-question column gives the exact opposite: 5, 3, 2, 3. The shortest history answers the fewest questions, the longest answers the most. The two measures move in opposite directions, and this is the course’s second claim.
The lost column shows why. In three formats, lost commits are 0: all twelve commits stand individually in history, only their arrangement differs. Under squash, lost is 12, separately readable is 0. This means history entirely gives up its one-in-twelve resolution: the smallest readable unit is no longer a commit, it is an entire branch.
The dump below gives the concrete shape of this loss. The report branch’s four
commits reduce to a single commit; that commit touches both config.py and
report.py, and it carries a bug. That the bug is present can be read; which step
it came from cannot be. File trail drops the same way: it can be seen that a commit
touches two files at once, but finding the commit that touched config.py on its own is
no longer possible.
The 2 questions squash answers are instructive too: feature grouping and branch integrity. Both are answered by squash’s structure alone — every commit is already an entire piece of work, so grouping is flawless and integrity holds by definition. Squash is the format that best answers the coarse questions; it cannot answer any of the fine ones.
The two sides of the trade-off are not equally weighted, and seeing this makes the choice easier. The excess of a 15-commit history is a reading cost: the log output is long. A reading cost can be reduced afterward — there are ways of reading that filter history, collapse it to a single line, or narrow it by date or file, and all of them work on the same record. Squash’s 12-commit loss is a recording loss and cannot be brought back afterward; no reading option can show a commit that was never written. One side can be filtered; the other cannot be produced.
The sentence that follows is this: cleaning up history does not make it cheaper, it erases information. What is erased is not code — all four produce the same code. What is erased is how the code was arrived at.
A Criterion-Based Choice
There is no correct one among the four formats; there is one that fits the question that will be asked. The criterion is a single question: what will this history be asked months later? If the question is not known, the default is the format that answers the most questions — because an extra record can later be ignored, a missing record cannot later be written.
If a bug hunt will be asked, bug isolation and file trail are needed. Three formats answer these, squash does not. Tools that find a bug by binary search also rest on the same resolution: search in a twelve-commit range finishes in four steps, in a three-commit range it finishes in two steps but what it finds contains four changes at once.
If the scope of the work will be asked, feature grouping is needed. Only merge commit and squash answer this; both write the branch record in some form. Rebase and fast-forward are equally silent on this question.
If time will be asked, true order is needed, and only two formats answer it: merge commit and fast-forward. Rebase and squash arrange the line by piece of work and give up time.
The choice does not have to commit to one format either. Using a different format by branch type is a consistent rule: single-commit fixes are taken in by fast-forward, because the branch record is not expected to be queried there later; multi-commit feature branches are taken in by merge commit, because the scope of the work will be asked there; external contributions with unvetted interim commits can be taken in by squash. The rule itself has to be written down — if the format decision is left every time to whoever is doing the merge that day, history becomes a mixture of four separate resolutions and gives no consistent answer to any question.
The tool also has settings that keep this decision in place. The fast-forward locks from the first lesson are the narrowest form of this: a repository can be set to automatically reject any merge other than a fast-forward, or the reverse. The setting does not replace the rule; it prevents the rule from being forgotten.
The difference range runs from 2/6 to 5/6; since the set’s resolution is 1/6, the 3/6 gap between the ends is three times the measurement band and is comfortably defensible. The cost column, meanwhile, is 3/12: what the merge commit pays to answer the most questions is three closings added to twelve commits.
Summary
- The tool’s content strategy and the integration format are separate decisions; the first determines how files are merged, the second determines what is written to history, and this course measures the second.
- Squash never brings the branch’s commits into main; it writes a single commit with one parent. Since the branch’s commits are not main’s ancestor, that branch should not be merged again later; it should be closed.
- The four formats leave 15, 12, 3, and 12 commits in history; the number of questions they answer is 5, 3, 2, and 3 respectively.
- Under squash, lost commits are 12 and separately readable commits are 0; that a bug is present can be read, which step it came from cannot. Cleaning up history erases information.
- A long history’s excess is a reading cost and can be reduced by filtering; squash’s loss is a recording loss and cannot be brought back by any reading option.
- The choice criterion is a single question: what will this history be asked later. Three formats suffice for a bug hunt, two for the scope of the work, two for true time; the rule can be split by branch type, but it must be written down.
Next Step
In this lesson, the format choice was made for a single merge: how this branch should be taken into main. Teams do not make this decision anew each time; they make it once and turn it into a rule, and the rule determines not only the moment of merge but how long branches will live, how many there will be, and how often they will return to main. The next lesson compares these rules — long-lived and short-lived branch patterns — by what they leave in history.
To keep your progress and take notes, Log in
My notes
Log in to take notes.