Lesson 07 / 15
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.
Contents
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.
# 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.
# 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
"""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.
# 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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.