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

# Rebase

The format that rewrites a branch's commits onto a new base leaves twelve commits as twelve and answers three of six questions; the total matches fast-forward but the loss does not — one preserves branch integrity, the other true order.

In the previous lesson, the conflict was resolved in a single place: the two branch tips
were compared once, the decision was made once, and a merge commit was written. That
format added one fork edge to history.

A format that wants to keep history fork-free, in a single line, follows a different
path: it **rewrites** the branch's commits onto main's tip. The result is identical as
code, not as history. This lesson's question is what this rewriting preserves and what
it erases — and why, while it gives the same total as fast-forward from the previous
lesson, it does not give the same thing.

## Rewriting

**Rebase** works in three steps. First, the two branches' common ancestor is found.
Then, the branch's commits after the common ancestor are each turned into a **patch**:
the change each commit made relative to its parent is extracted separately. Finally,
these patches are applied in order, starting from main's new tip.

The result of this last step sets the whole measure of the lesson: every applied patch
produces a **new commit**. The new commit's parent is different, and even if its content
is identical, its ID is different. The old commits are not deleted; because no branch
points to them anymore, they become unreachable. The branch's tip points to the new
chain's tip.

```text
# taught command and example dump — not executed

$ git rebase main
Successfully rebased and updated refs/heads/report.

$ git log --oneline --graph
* 5b6c7d8 report: add summary line
* 4a5b6c7 report: fix field width
* 9c4d7e1 metrics: print threshold value
```

There is no fork edge in the drawing, because no second parent was produced. The log
flows in a single column. In the same branch's merge-commit form, indented lines and a
`|/` mark appeared; here they do not.

A second ability in this command family lets the target base be specified separately:
instead of the branch's own common ancestor, another commit can be chosen as the base.
The same family also has an interactive mode; there, patches can be reordered, their
messages fixed, or some of them dropped before being applied. Squash, one use of this
mode, is a separate integration format and is measured on its own in a later lesson.

## Conflict Can Be Asked at Every Step

The previous lesson's conflict resolution can be asked here not once but **per patch**.
The tool applies patches in order and stops at the first one that cannot be applied. The
working area is again left with conflict markers, the resolution is again given by hand;
but once the work is done, what is called is not the merge, it is **continue from where
it left off**.

```text
# taught command and example dump — not executed

$ git rebase main
Auto-merging config.py
CONFLICT (content): Merge conflict in config.py
error: could not apply 4d5e6f1... report: fix field width
Resolve all conflicts manually, mark them as resolved with
"git add/rm <conflicted_files>", then run "git rebase --continue".
```

There are three options: resolving and continuing, skipping that patch, or aborting the
whole operation and returning the branch to its starting state. The abort option is the
safe exit here too, and it is always cheaper than trying to clean up a half-finished
rebase by hand.

If a four-commit branch has two commits touching the same file, the same conflict can be
asked twice. The local ability that remembers resolutions reduces this load — as noted
in the previous lesson, it keeps the record not in history but in a local directory of
the repository. This is rebase's hidden cost: a merge commit asks the conflict once,
rebase can ask it as many times as there are patches.

## What Is Preserved, What Is Erased

Because patches are applied branch by branch, a branch's commits sit consecutively in
history. This means **branch integrity** is preserved: the `report` branch's four
commits are adjacent on the line, and no other piece of work's commit falls between
them.

Two things are not preserved. The first is the **branch record**: no field is written
saying that the four adjacent commits belong to one branch. Adjacency is an arrangement,
not a label; a reader can guess from the shared prefix in commit messages, but history
itself does not state it. The second is the **three branches' order relative to each
other**: in the rewritten chain, one branch's commits come entirely first, then the
other's entirely. That the pieces of work were actually developed interleaved, which two
moved together in which week, cannot be read from the line.

The tool carries the author timestamp over to the new commit; what is erased is not the
time, it is the **order**. When history is read as a line, the order it gives is not the
development order but the rewriting order.

The measurement's assumptions:

- **MR15** — Rebase rewrites a branch's commits onto a new base, branch by branch and in
  order; no branch record is kept and true order is not preserved.
- **MR16** — A rewritten commit gets a new ID. The measurement does not count IDs; it
  counts the questions history can answer.
- **MR17** — Fast-forward is the comparison side and is set up as it was in the previous
  lessons: commits are arranged in real time order, no branch record is kept.
- **MR18** — The branch integrity question checks whether a branch's commits sit
  uninterrupted in history. Keeping commits together is not the same as naming them;
  these are two separate questions.
- **MR19** — The true order question checks whether the three pieces of work's
  development order relative to each other can be read from the line.
- **MR20** — Both formats produce the same code and add no commit to history; only
  history is measured.
- **MR21** — The set's resolution is **1/6** at six questions. One question shifting
  place sits exactly on the measurement band and is the smallest defensible difference.

## Measurement

```python
"""Rebase: twelve commits stay twelve, three questions are answered.

Part 1 - two linear formats side by side: commit count and answered questions.
Part 2 - which three questions: the shared set and the two that diverge.
Part 3 - where the branches' commits sit in history.
"""
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 == "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 == "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 = ("rebase", "fast-forward")
record = development()
T = {f: integrate(record, f) for f in FORMATS}
RB, FF = T[FORMATS[0]], T[FORMATS[1]]

print(f"real development: {len(record)} commits, {len(BRANCHES)} branches")
print()
print(f"{'format':<22s} {'commits':>7s} {'added':>7s} {'answered':>10s}")
for f in FORMATS:
    print(f"{f:<22s} {len(T[f]):7d} {len(T[f]) - len(record):7d}"
          f" {sum(s(T[f]) for _, s in QUESTIONS):10d}")
print()
print(f"{'question':<20s} {'rebase':>21s} {'fast-forward':>12s}")
for name, question in QUESTIONS:
    print(f"{name:<20s} {('yes' if question(RB) else 'no'):>21s}"
          f" {('yes' if question(FF) else 'no'):>12s}")
print()
print("yes in both:       ", [a for a, s in QUESTIONS if s(RB) and s(FF)])
print("rebase only:       ",
      [a for a, s in QUESTIONS if s(RB) and not s(FF)])
print("fast-forward only: ", [a for a, s in QUESTIONS if s(FF) and not s(RB)])
print("no in both:        ", [a for a, s in QUESTIONS if not s(RB) and not s(FF)])
print()
print(f"{'format':<22s} {'branch':<10s} {'positions in history':>21s} {'contiguous':>10s}")
for f in FORMATS:
    for branch in BRANCHES:
        positions = [i for i, x in enumerate(T[f]) if x["branch"] == branch]
        contiguous = positions[-1] - positions[0] == len(positions) - 1
        print(f"{f:<22s} {branch:<10s} {str(positions):>21s}"
              f" {('yes' if contiguous else 'no'):>10s}")
```

```
real development: 12 commits, 3 branches

format                 commits   added   answered
rebase                      12       0          3
fast-forward                12       0          3

question                            rebase fast-forward
feature grouping                        no           no
bug isolation                          yes          yes
true order                              no          yes
file trail                             yes          yes
branch integrity                       yes           no
conflict decision                       no           no

yes in both:        ['bug isolation', 'file trail']
rebase only:        ['branch integrity']
fast-forward only:  ['true order']
no in both:         ['feature grouping', 'conflict decision']

format                 branch      positions in history contiguous
rebase                 metrics             [0, 1, 2, 3]        yes
rebase                 report              [4, 5, 6, 7]        yes
rebase                 identity          [8, 9, 10, 11]        yes
fast-forward           metrics             [0, 3, 6, 9]         no
fast-forward           report             [1, 4, 7, 10]         no
fast-forward           identity           [2, 5, 8, 11]         no
```

## Equal Total, Different Loss

The top table compares the two formats by commit count and finds no difference: both
**12** commits, both **0** added, both **3** answered questions. Looking at this row
alone, the two formats could be called equivalent.

The middle table breaks this. The answered triples are not the same triple. In both,
**bug isolation** and **file trail** are yes: since neither format merges commits, the
buggy commit stands alone and every commit touches a single file. The one pair of
questions that diverges is this: **branch integrity** is answered only by rebase, **true
order** only by fast-forward. What goes unanswered in both are **feature grouping** and
**conflict decision**.

The bottom table shows the reason for this split directly. Under rebase, the three
branches' commits sit at positions `[0, 1, 2, 3]`, `[4, 5, 6, 7]`, and `[8, 9, 10, 11]`;
all three are contiguous. Under fast-forward, the same commits sit at positions
`[0, 3, 6, 9]`, `[1, 4, 7, 10]`, and `[2, 5, 8, 11]`; none is contiguous. Rebase ordered
commits **by piece of work** and broke time; fast-forward ordered **by time** and
scattered the pieces of work. A single line cannot do both, because it has one ordering.

The reading that follows is this: **even when the total is equal, the loss is not the
same.** Which three of six questions matters according to what will be asked of history.
In a bug hunt, "which piece of work brought this in" is asked, and the contiguous block
in a rebased line is useful. In an incident review, "which pieces of work were moving
together that week" is asked, and only the fast-forwarded line answers that question.
The same **3** in the same set buys different things in the two cases.

The two questions left unanswered in both are worth noting too. **Feature grouping** is
no in both formats, because neither writes a branch record; the contiguous block rebase
produces does not fix this, it only makes it guessable. **Conflict decision** is no in
both, and this is the previous lesson's result: linearizing not only records no
resolution, it also raises the count of decisions given in conflicts asked one patch at
a time.

The measured difference is **1/6**: one of six questions changes place. Since the set's
resolution is also **1/6**, this is the smallest difference this setup can defend. The
measurement is not sufficient for a finer distinction than this, and no finer
distinction is claimed.

## Rebasing a Shared Branch

Rebase has an unmeasured consequence, and it is this lesson's most important warning.
Rewritten commits' IDs change. If the branch exists only in your repository, this is not
a problem. If the branch has been shared with others, your new chain and the old chain
in their hands share no commit except the common ancestor.

In that case, writing the branch to the other side in the usual way is rejected, because
their tip is not inside your chain. The way around the rejection is to **overwrite**
their branch with your chain. This operation is not written out in full here: once
applied, the old chain on the other side is left without a reference, everyone working
from that branch has their local history diverge, and **the operation cannot be
undone**. What is lost is not only the IDs; other people's work built on top of those
old commits is left without foundation.

The safe path has three parts. **The first is the criterion:** rebase is applied only to
branches that have not yet been shared. When a shared branch needs to catch up with
main, merge is used instead; a merge rewrites no commit. **The second is a backup:**
opening a backup branch pointing at the branch's current tip before the operation is a
one-line step, and it turns rescuing the chain into a simple pointer rollback. **The
third is the reflog:** the tool writes every move of a branch tip to a local log, and
the old tip can be read from there.

```text
# taught command and example dump — not executed

$ git branch backup/report report
$ git reflog report
5b6c7d8 report@{0}: rebase (finish): refs/heads/report onto 9c4d7e1
4d5e6f1 report@{1}: commit: report: fix field width
```

The reflog has two limits, and both must be known: the log is **local**, it does not
travel to the other side; and it is not kept indefinitely, old entries are cleaned up
over time. A backup branch carries neither of these two limits. This is why a backup
branch is a habit, and the reflog is a last resort.

## Summary

- Rebase turns a branch's commits into patches and rewrites them in order onto a new
  base; each patch produces a commit with a new ID, and the old commits become
  unreachable.
- Conflict can be asked not once but per patch; resolving and continuing, skipping, and
  aborting are the three options, and aborting is the safe exit.
- Twelve commits stay **12**, and **3** of the six questions are answered; the ones
  answered are bug isolation, file trail, and branch integrity.
- Fast-forward also answers **3**, but the third is true order: one orders by piece of
  work and breaks time, the other orders by time and scatters the pieces of work. The
  difference is **1/6**, and while the total is equal, the loss is not.
- Rebasing a shared branch invalidates the other side's chain and cannot be undone; the
  criterion is an unshared branch, and the safeguards are a backup branch and the
  reflog.

## Next Step

Rebase moved an entire branch's commits. Sometimes what is needed is much less than
that: a single fix sitting on a published release also needs to enter the development
branch, or a single commit written on one branch needs early use on another. The next
lesson looks at the format that copies a single commit to another branch — and at what
that copy sitting in two separate places with two separate IDs in history does to the
file trail question, which counts the commits that touched a file.
