Lesson 03 / 12
Force Push Discipline
In a thousand-commit history, changing the last commit changes 1 identity, changing the first changes 1000; but in both cases all seven of seven copies have to resync, and the stranded local work stays the same — the cost scales with the number of copies, not the size of the change.
Contents
The previous lesson counted the cost once and it stayed within a single repository: a thousand identities, five thousand six hundred forty objects, zero common ancestor. The number described the local half of the operation. For a rewritten history to be of any use, it has to be written to the other side too — and the other side does not accept it the ordinary way.
This lesson covers why that rejection is placed, how it is overridden, and who loses what once it is overridden. It is not a procedure lesson: below, no force push line is written in runnable form. What is measured is not how the tool is invoked but the cost the invocation leaves on whom.
The Reason for the Rejection
The distinction between fetch, pull, and push was established in the previous course and is not repeated here. One point from it is needed here: a push asks to change where a reference on the other side points. The other side accepts this request under only one condition — the requested new tip contains the current tip. If it does, nothing is lost; every commit on the other side can still be found by walking backward from the new tip.
Rewriting breaks exactly this condition. The new chain does not contain the old chain’s commits; it puts commits with different identities in their place. The other side rejects the request and states its reason.
# example dump — not executed $ git push source metrics ! [rejected] metrics -> metrics (non-fast-forward) error: failed to push some refs to 'source' hint: Updates were rejected because the tip of your current branch is behind hint: its remote counterpart.
The rejection message reports not a defect but a rule doing its job. This rejection is the repository’s one built-in protection. It allows the chain a reference on the other side points to to be changed only by growing; it does not allow shrinking it or replacing it with a different chain.
Overriding the Rejection
The tool has an option that disables this rejection, and its name is force push. What it does can be said in one sentence: it moves the reference on the other side to the tip of the new chain without asking its old value at all. The old chain is not deleted; because no reference points to it anymore it stays unreachable, and the other side’s garbage collection routine cleans it up after a while.
This line is not written in full form here. The reason is that the line itself is copyable: run on a shared branch, it deletes others’ work from that branch’s record and the operation cannot be undone — undoability is provided only by the copies in others’ hands, and what that means is written separately at the end of the lesson.
A second option in the same family adds the other side’s reference’s expected value to the request as well: it writes if the tip is where expected, rejects if not. This option does not make force push safe; it only prevents you from overwriting work you did not see. There is no protection for work you saw and overwrote anyway.
Who Pays the Cost
The cost of rewriting sits in two separate columns. The first is the writer’s column: the rewritten chain’s objects are produced once. The second is the copies’ column: every copy has to re-obtain the same chain. The measurement places the two side by side and changes the position of the changed commit four times.
The measurement’s assumptions:
- RH17 — The setup is the previous lessons’ setup and is unchanged; the measurement is done on a 1000-commit history.
- RH18 — The repository lives in seven copies. The copy count is an input to the measurement, not a choice made by the person doing the rewrite.
- RH19 — The moment the branch tip’s identity changes, every copy has to resync. The measurement counts the copy that has to resync; whether they actually do is outside the measurement.
- RH20 — The writer’s cost is the object count of the chain they rewrote; the copies’ cost is that same count multiplied by the number of copies. The unit is the touched object.
- RH21 — Each copy has some amount of local work written on top of the old tip (drawn from the setup, zero to three commits) and that work has not yet been written to the other side.
- RH22 — After force push, this local work’s foundation disappears. The work left stranded is independent of the position of the changed commit: the moment the tip changes, every local chain loses its foundation.
- RH23 — Identity and copy counts are not a cost, they are the width of the set; the cost is read only in the object column.
- RH24 — The resolution is 1/1000 in commits, 1/7 in copies.
Measurement
"""Force push: the person who rewrites does not pay the cost. Part 1 - four positions: identity changed, objects touched by the writer and the copies. Part 2 - the local work copies wrote on top of the old tip. Part 3 - sharing status: which condition is the one safe one. """ SEED = 20260814 FILES = ("metrics.py", "report.py", "identity.py", "config.py", "document.md") COPIES = 7 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): after = [x for x in t if x["no"] >= position] return len(after), sum(x["objects"] for x in after) def resyncing(copy_count, position, t): """Who has to resync the rewritten history.""" changed, _ = rewrite(t, position) return copy_count if changed else 0 def copies(count=COPIES, seed=SEED): """Each copy's local commit count written on top of the old tip.""" draw = rng(seed) return [{"no": i + 1, "local": draw(4)} for i in range(count)] T = history(1000) K = copies() print(f"{'changed position':>17s} {'identity changed':>17s}" f" {'writer objects':>15s} {'resyncing copies':>17s}" f" {'copies objects':>15s}") for position in (1000, 900, 500, 1): changed, touched = rewrite(T, position) k = resyncing(COPIES, position, T) print(f"{position:17d} {changed:17d} {touched:15d} {k:17d} {k * touched:15d}") print() print("local commits copies wrote on the old tip:", [x["local"] for x in K], "total", sum(x["local"] for x in K)) print() print(f"{'sharing status':<16s} {'copies':>6s} {'resyncing':>10s}" f" {'stranded (last commit)':>24s} {'stranded (first commit)':>25s}") for name, count in (("unshared", 0), ("three copies", 3), ("seven copies", 7)): stranded = sum(x["local"] for x in K[:count]) print(f"{name:<16s} {count:6d} {resyncing(count, 1000, T):10d}" f" {stranded:24d} {stranded:25d}")
changed position identity changed writer objects resyncing copies copies objects
1000 1 2 7 14
900 101 602 7 4214
500 501 3002 7 21014
1 1000 5640 7 39480
local commits copies wrote on the old tip: [0, 0, 3, 2, 2, 2, 0] total 9
sharing status copies resyncing stranded (last commit) stranded (first commit)
unshared 0 0 0 0
three copies 3 3 3 3
seven copies 7 7 9 9
A Thousandfold Difference, a Zerofold Difference
The top table’s left half gives the expected result. Changing the last commit changes 1 identity and costs the writer 2 objects; changing the first commit changes 1000 identities and means 5640 objects. The gap is a thousandfold. Between fixing a typo and transforming the entire repository, in terms of the cost the writer sees, there is a chasm.
The right half does not see this chasm. The number of copies that have to resync is 7 in all four rows. All seven of seven copies, no matter how small the change, have to pick up the branch tip’s new identity and adjust their own state to it. The gap is not a thousandfold, it is zerofold. The chasm the writer sees does not exist at all in the table the copies see.
The copies’ object column gives the absolute counterpart of this: the cost of changing the last commit is 14 objects, of changing the first is 39480. Both numbers are seven times the writer’s column figure — and seven is not a number the writer decides. This is the lesson’s third claim: the cost scales with the number of copies, not the size of the change, and that number is not in the rewriter’s hands. The person doing the rewrite can choose the cheapest line in their own column; the copies’ column is not subject to that choice.
The bottom table states this in its starkest form. The local work the copies wrote on top of the old tip totals 9 commits — three of the seven copies have no local work at all, four do. The work stranded after force push is 9, and this number is the same in both columns: whether you change the last commit or the first, nine commits lose their foundation. Whether the tip changed or not is the entire question; how much it changed concerns no one.
There is exactly one safe row in the table: unshared. When the copy count is zero, resyncing is zero and stranded work is zero. This is the one criterion the measurement produces, and it is the only one.
The middle rows show a detail as well. Rewriting from the nine hundredth commit changes 101 identities but touches 602 objects, not two hundred two. The difference comes from the binary assets that fall in that range — counting identities and counting objects do not give the same answer, and this lesson’s unit is the object. This is why the copies’ column multiplies the object count by seven, not the identity count.
What the Copy Has to Do
“Resyncing” is not a single operation, it is a decision, and the decision is the copy owner’s. There are two paths and neither is free.
The first path is resetting the local branch to the new tip. If there is local work, this path abandons it; the commits stay unreachable and can only be found from that copy’s own reflog. If there is no local work, the path is clean, and that is the case for three of the copies in the measurement.
The second path is rebasing the local work onto the new chain. Nothing is lost, but the local commits also get new identities, and this time the cost from this course’s first lesson is paid on the copy’s side. For four copies, this is what is paid: all nine of nine commits are rewritten.
There is no free third path. Trying to write the local work to the other side as it is brings the same rejection, this time facing the copy — and now the copy’s owner is looking at the force push button too. One force push inviting a second shows why this lesson’s discipline belongs to a team, not a single person.
Without Anyone Knowing
The most irritating part of the operation does not show up in the numbers. Force push is not written to history. In the new history, no commit and no field says someone overwrote something. The previous course’s sixth question showed that the conflict decision was not recorded; here, what is not recorded is that the record itself was changed.
The copies do not learn instantly either. As long as a copy does not look at the other side, it keeps assuming its old chain is valid, writes commits on top of it, and grows its stranded work. The moment of learning is the next fetch; every commit made before that is a commit added onto an overwritten tip.
That moment can itself be misleading. If a pull is called after the fetch, the tool tries to merge the two chains and usually succeeds: because the old and new chains carry the same changes, the merge completes silently. The result is a history in which the same changes are found twice, under two separate identities, and nothing warns of this — only looking at the history reveals it. The overwritten branch being overwritten again often starts this way too: a copy brings the old chain back, the rewriter sees this, and repeats the operation.
Together, these three facts turn the operation from a tool problem into a notification problem. The tool warns no one, history tells no one; the one who has to say something is the rewriter. The notification’s content is fixed too: which branch, which moment, and which path the copies are expected to take.
Discipline
The criteria come out of the measurement and all four can be written briefly.
First: force push is applied only to unshared branches. A branch being unshared is not the assumption “no one is working from it,” it is a condition that can be checked.
Second: the chains the main branch, the release branch, and tags point at are not rewritten. When a change needs to be undone, the path is not to rewrite but to write a new commit that does the reverse — established in the Introduction to Version Control course under the name revert — and because it never changes history, it concerns no copy.
Third: if a shared review branch needs to be rewritten, the operation is announced beforehand and again afterward. The option that adds the expected value to the request is used; that option protects unseen work, not seen work.
Fourth: the protection is placed not on the tool but on the other side. Repository servers have branch protection abilities and can be set to reject updates other than fast-forward. A rule sitting on the server side rather than the tool side stops it from being something that can be forgotten.
Where the four criteria collapse in practice also has to be said. Most of the time the rule is known and applied; the accident comes from a branch being assumed unshared. A branch’s name is personal, no one is assumed to be working from it, yet someone has fetched it during a review. The way to test the assumption is to look at the other side, not your own memory; if the branch’s status on the other side is fetched, the assumption turns into a measurement.
Every lost path has a recovery path, and three can be listed here. When the old chain on the other side is left without a reference, recovery is in the copies: a copy that still points at the old tip can write it back as a new branch; the overwritten side’s backup is the side that pays the cost. A copy’s stranded local work can be found from that copy’s own reflog and moved onto the new chain. The old tip the person doing the rewrite lost is read from a backup branch or from the local reflog. Two of the three paths rest on the same mechanism, and that mechanism has a lifespan.
Summary
- The ordinary push accepts only a chain that contains the other side’s tip; rewriting breaks this condition and is rejected, and the rejection is the repository’s one built-in protection.
- Force push removes the rejection: it moves the reference without asking its old value. The line is not given in full form here; on a shared branch it cannot be undone.
- In a thousand-commit history, changing the last commit means 1 identity and 2 objects, changing the first means 1000 identities and 5640 objects — but all seven of seven copies have to resync in every case.
- What the copies pay ranges between 14 and 39480 objects and is always seven times what the writer pays; the 9 stranded commits are independent of where the change was made. The writer does not pay the cost.
- The one safe row the measurement gives is unshared branches: zero copies, zero stranded work.
- The operation is not written to history and the copies do not learn until the next fetch; notifying is the rewriter’s job, not the tool’s.
Next Step
Two of this lesson’s recovery paths looked at the same place: the local record holding the branch tip’s old value. That record is not unlimited. In a rewritten history, the old chain’s commits stay unreachable, stay recoverable for a while, then get cleaned up. The next lesson measures that window: how many commits stay reachable after a rewrite, how many can only be found from the record, and at what moment the recovery window closes.
To keep your progress and take notes, Log in
My notes
Log in to take notes.