Lesson 01 / 12
Interactive Rebase
Changing a commit changes the identity of every commit after it; rewriting from the middle touches 252, 362, 3002, and 11322 objects in 50-, 200-, 1000-, and 4000-commit histories respectively, and the ratio stays at roughly half at every scale.
Contents
The previous course read history with a single measure: an integration format’s value was not the code it produced but the number of questions the history it left behind could answer. Four formats produced the same code, and the six questions were answered five, three, two, and three times respectively. In that course, history was a given record: questions were asked of it, answers were counted. Once the record was written, changing it was not on the table.
This course treats the same record as an object. History can be rewritten: a commit message can be fixed, two commits can be reduced to one, the order can be changed, a commit can be dropped entirely. The measure changes along with it. There, what was measured was the answered question; here, what is measured is the touched object. This lesson’s question is this: how much of history does going back and changing a commit touch, and does that ratio drop as history grows?
Identity Propagates Through the Chain
A commit’s identity is derived from its content. The fields that go into the identity are fixed: the file tree at that point, the author, the committer, the timestamp, the message, and the parent’s identity. This last field determines this entire course. When a parent’s identity changes, the child’s identity is forced to change too, because the child’s content carries the parent’s identity. This is unavoidable because identity is produced from content: there is no way to keep the same identity while changing the content, because if there were, an identity would not be an identity.
The consequence can be written in one sentence: changing a commit changes the identity of every commit after it. In this respect there is no difference between fixing a single typo and removing an entire file from history; both regenerate everything from that point onward. The size of the change does not determine the cost — its location does.
The old commits are not deleted at this point. They stay in the repository; because no branch or tag points to them anymore, they become unreachable. Being unreachable is not the same as ceasing to exist, and this distinction is the entire subject of this topic’s fourth lesson. For now, this much is enough: rewriting is not a deletion but a duplication — the old chain stays in place, a new chain is written beside it, and the branch tip points to the new one.
Interactive Mode
Rebase was established in the previous course as an integration format: the branch’s commits are turned into patches and applied in order onto a new base. That comparison is not repeated here. What is new is the same family’s interactive mode, which never appeared there.
In interactive mode, the tool does not apply the patches directly. It first puts the commits to be staged in front of you as a list; each line in the list carries a commit and the action to be applied to it. Once the list is saved and closed, the tool executes the lines top to bottom.
# taught command and example dump — not executed $ git rebase -i HEAD~3 pick 9c4d7e1 metrics: print threshold value pick 4a5b6c7 report: fix field width pick 5b6c7d8 report: add summary line # r, reword = keep the commit, edit its message # s, squash = merge the commit into its predecessor # d, drop = drop the commit
Four actions form this lesson’s measurement axis. Reword keeps the commit and changes only its message. Reorder changes the lines’ positions; the order in the list becomes the order in history. Squash merges a commit into its predecessor and reduces the resulting commit count by one. Drop removes the commit entirely, and that too reduces the count by one.
The four do very different jobs: one only fixes a piece of text, another removes a change from history entirely. What the measurement asks is whether this difference is reflected in the cost.
One of these actions was already established on its own in the Introduction to Version Control course: the ability to amend the last commit in place produces a new commit from the staging area’s current state and leaves the old one unreachable. That, too, was a rewrite, only it was the shallowest kind — it sat at the tip of history and had no descendant. Interactive mode does the same job anywhere in history; when it is done on a commit that has descendants, the cost comes along with it.
A patch may fail to apply while the plan is being executed. The mechanics of this case were already established with the rebase format in the previous course and are not repeated here. Two points are specific to interactive mode: on a squash line, the tool puts the merged commits’ messages in front of you in a single text and waits for you to fix it; and if the operation is canceled midway, the branch tip returns to where it was before entering the plan. Canceling is the one intact exit available while the plan is running.
Where the Cost Starts
Interactive mode asks how many commits you will put on the list. If you plan the last three commits, the tool shows three lines and the rewrite starts from the third commit. If you want to fix a commit in the middle of history, you are forced to put everything written since that commit into the list — even if only one line needs fixing.
The cost is therefore measured not by the number of actions in the plan but by where the plan starts. The measurement asks this in two separate ways: four different actions at the same position, and the same action at four different depths.
Two limits of the plan should be stated here. First, commits below the listed lines cannot be touched; once a starting point is chosen, anything before it is outside the plan, and going further back means a new plan. Second, if there is a fork edge in history, a linear plan does not carry it as it is: merge commits are flattened unless requested otherwise, and the two-parent structure collapses to a single parent. The measurement’s setup is linear, so this second limit does not enter the measurement; but it needs to be known before building a plan in a repository whose history has a fork.
The measurement’s assumptions:
- RH1 — The setup is a single history built at four scales: 50, 200, 1000, 4000 commits. Each commit touches one of five files; one in eleven also carries a binary asset, and every version of that asset stays in history as a separate object.
- RH2 — A commit’s object cost is 2 for the tree and blob; if it carries a binary asset, 40 is added to that. The count is the repository’s object count, not its file count.
- RH3 — Changing a commit changes the identity of every commit after it. The touched set is the closed interval from the changed position to the tip of history.
- RH4 — “Rewriting from the middle” means the position
n // 2; the rule does not change even as the scale changes. - RH5 — Interactive mode’s four actions differ only in the length of the result: squash and drop remove one commit, reword and reorder do not. The touched set is the same in all four.
- RH6 — This lesson’s unit of cost is the touched object. The number of commits whose identity changes is not a cost, it is the width of the touched set; the step unit is not used in this lesson.
- RH7 — If the number of commits staged is
k, the rewrite starts at positionn - k + 1. - RH8 — The set’s resolution at the four scales is 1/50, 1/200, 1/1000, 1/4000 respectively.
Measurement
"""Interactive rebase: the cost of rewriting from the middle. Part 1 - rewriting from the middle at four scales: commits, objects, ratio. Part 2 - interactive mode's four actions at the same position. Part 3 - the number of commits staged determines the cost. """ SEED = 20260814 FILES = ("metrics.py", "report.py", "identity.py", "config.py", "document.md") SCALES = (50, 200, 1000, 4000) ACTIONS = ("reword", "reorder", "squash", "drop") def rng(seed): state = seed % 2147483646 + 1 def draw(n): nonlocal state state = (state * 48271) % 2147483647 return state % n return draw def history(n, seed=SEED): """n-commit linear history; each commit touches one file.""" draw, commits = rng(seed), [] for i in range(n): file = FILES[draw(5)] binary = draw(11) == 0 commits.append({"no": i + 1, "file": file, "binary": binary, "objects": 2 + (40 if binary else 0)}) return commits def rewrite(t, position): """Changing a commit changes the identity of every commit after it.""" after = [x for x in t if x["no"] >= position] return len(after), sum(x["objects"] for x in after) def clone_cost(t): return sum(x["objects"] for x in t) def interactive(t, position, action): """Four actions: only the result's length changes, the touched set does not.""" changed, touched = rewrite(t, position) return len(t) - (1 if action in ("squash", "drop") else 0), changed, touched print(f"{'commits':>7s} {'rewritten':>16s} {'touched objects':>16s}" f" {'ratio':>6s} {'object ratio':>12s}") for n in SCALES: t = history(n) changed, touched = rewrite(t, n // 2) print(f"{n:7d} {changed:16d} {touched:16d} {changed / n:6.3f}" f" {touched / clone_cost(t):12.3f}") print() T = history(1000) print(f"{'action':<18s} {'result commits':>14s} {'identity changed':>17s}" f" {'touched objects':>16s}") for a in ACTIONS: remaining, changed, touched = interactive(T, 500, a) print(f"{a:<18s} {remaining:14d} {changed:17d} {touched:16d}") print() print(f"{'staged':>13s} {'starting position':>17s} {'identity changed':>17s}" f" {'touched objects':>16s} {'ratio':>6s}") for k in (3, 10, 50, 500): changed, touched = rewrite(T, len(T) - k + 1) print(f"{k:13d} {len(T) - k + 1:17d} {changed:17d} {touched:16d}" f" {changed / len(T):6.3f}")
commits rewritten touched objects ratio object ratio
50 26 252 0.520 0.663
200 101 362 0.505 0.393
1000 501 3002 0.501 0.532
4000 2001 11322 0.500 0.501
action result commits identity changed touched objects
reword 1000 501 3002
reorder 1000 501 3002
squash 999 501 3002
drop 999 501 3002
staged starting position identity changed touched objects ratio
3 998 3 6 0.003
10 991 10 20 0.010
50 951 50 260 0.050
500 501 500 3000 0.500
Rewriting Does Not Get Proportionally Cheaper
The top table does the same operation at four scales: changing the commit in the middle of history. Touched objects climb from 252 to 11322 — forty-five times. The ratio column on the right does not budge: 0.520 / 0.505 / 0.501 / 0.500. As history grows eightyfold, rewriting from the middle still touches roughly half of the commits.
This is a counterintuitive result. One might expect that fixing a single commit in a growing history would become proportionally smaller work; the opposite happens. Because the ratio stays constant, the absolute cost grows linearly with history. Rewriting does not get proportionally cheaper; the only thing that gets cheaper is staying close to the tip.
The object-ratio column carries a separate warning. While the commit ratio approaches
0.500, the object ratio bounces around as 0.663 / 0.393 / 0.532 / 0.501. At small
scales, where the commits carrying a binary asset happen to fall decides the result: in
the fifty-commit history most of those commits fell in the second half, in the two
hundred-commit one most fell in the first half. Counting commits and counting objects
are not the same thing, and this distinction is itself the measure of the
large-repositories topic. Here only this is recorded: the ratio depends on the unit
it is counted in, and this lesson’s unit is the object.
The middle table answers the second question. When the four actions are applied at the same position, identity changed is 501, touched objects is 3002 — the same in all four. The one column that diverges is the resulting commit count: squash and drop give 999, the other two give 1000. So fixing a typo in a message and removing a commit from history entirely have the same cost. The tool does not care which action you chose; it cares where you started.
The bottom table measures this from the other direction. When 3 commits are staged, touched objects are 6; at 10, 20; at 50, 260; at 500, 3000. A fifty-commit plan’s object cost is thirteen times a ten-commit plan’s, while its commit count is only five times as large — the difference comes from the binary assets that fall within the staged range. Commits near the tip are therefore cheap, and this is exactly where interactive mode’s everyday use lies: tidying up the last few commits that have not yet been shared.
Read together, the three tables give a single rule: keep the plan close to the tip. The rule has both a cost side and a risk side. The cost side has been measured — a three-commit plan touches six objects, a five-hundred-commit plan touches three thousand. The risk side has not been measured yet: the further back the staged range reaches, the higher the chance that the commits in that range have already been picked up by others. Both sides point in the same direction and say the same thing; the second one is measured in this topic’s third lesson.
Before Rewriting
Rewriting is not a reversible operation; the old chain staying unreachable does not mean there is no way to bring it back, but it does mean you will go looking for one. Three habits make that search unnecessary.
Backup branch. Opening a branch that points at the branch’s current tip before the operation is a single line, and it reduces recovery to rolling back one pointer. A backup branch is not a local record; it is an ordinary branch, and it stays until deleted.
Dry run. There are comparison abilities that show how far two chains have diverged before the plan is applied. Placing the rewritten chain next to its backup side by side shows which commit actually changed and which one only changed identity.
Separate clone. If a bulk transformation is going to be tried, it is tried on a separate copy of the repository. This is the next lesson’s subject, and there it becomes a required step.
# taught command and example dump — not executed $ git branch backup/metrics metrics $ git rebase -i --onto main 9c4d7e1 metrics $ git range-diff main...backup/metrics main...metrics
The fourth safeguard 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. The log has two limits — it is local, and it is not kept indefinitely. This topic’s fourth lesson measures exactly that limit: how many commits stay reachable after a rewrite, and when the recovery window closes.
Summary
- A commit’s identity contains its parent’s identity; this is why changing a commit changes the identity of every commit after it. What determines the cost is not the size of the change but its location.
- Interactive mode puts commits into a plan list; the four actions are reword, reorder, squash, and drop.
- All four actions at the same position change the identity of 501 commits and touch 3002 objects; the only thing that diverges is the resulting commit count (999 or 1000).
- Rewriting from the middle touches 252 / 362 / 3002 / 11322 objects at four scales and the ratio stays at 0.520 / 0.505 / 0.501 / 0.500: rewriting does not get proportionally cheaper.
- The number of commits staged directly determines the cost: 3 / 10 / 50 / 500-commit plans touch 6 / 20 / 260 / 3000 objects respectively.
- Rewriting cannot be undone; a backup branch, a dry run, and a separate clone are the three safeguards.
Next Step
In this lesson rewriting stayed within a single branch’s last few commits, and even its deepest attempt started from the middle of history. Sometimes what is needed is broader than that: removing every version of a file from history, fixing an author field across every commit, moving a directory to the root. What these jobs share is that they are forced to start from the first commit of history. The next lesson measures the cost of this expansion: in a thousand-commit history, how many identities does a transformation starting from the first commit change, how many objects does it touch, and what happens to the repository’s copies once the operation completes.
To keep your progress and take notes, Log in
My notes
Log in to take notes.