---
title: 'Merge Strategies'
source: 'https://academia.sh/en/courses/branching-and-collaboration/merge-strategies'
course: 'Branching and Collaboration'
language: en
updated: '2026-08-17T18:10:44+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```text
# 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

```python
"""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.
