Lesson 12 / 15
Fetch, Pull, and Push
The three synchronization operations touch local history at different points: fetch leaves the local branch at 5 commits and raises local objects to 15, pull carries the branch to 16 commits and 5 questions, push changes the other side's branch from 10 to 16.
Contents
The previous lesson measured that a remote definition is nothing more than a name: two definitions left the local copy at five commits and four questions, because defining does not reach the network and fetches not a single object. The gap between the two keeps standing unclosed — the local copy carries five commits, the other side ten.
There are three operations that close this gap, and the three are often taken for names of the same thing. They are not. This lesson’s measure runs on a single axis: how much does each operation change local history? The answer is three separate numbers, and in the third, the history that changes is not even local.
Three Operations, Three Separate Effects
The three can be told apart in one sentence each.
Fetch brings the other side’s references and the missing objects into the local copy. It touches none of the local branches, does not change the working directory, and does not merge. The only thing it writes is remote-tracking references.
Pull is a compound operation: it fetches first, then merges what it fetched into the local branch. Its first step is identical to fetch; its difference is the second step, and that step adds a commit to local history.
Push reverses the direction. It sends the local branch’s commits to the other side and equalizes the other side’s reference with the local one. It does not touch local history; it changes the other side’s history. Of the three operations, this is the only one whose effect reaches outside its own repository.
Fetch
# taught commands — example dump, not executed git fetch origin From example.test/origin * [new branch] report -> origin/report * [new branch] identity -> origin/identity a1b2c3d..d4e5f6a metrics -> origin/metrics
Every line of the output is a reference update, not a branch change. The names on the
right are written with the origin/ prefix: what is being updated is remote-tracking
references. The local metrics branch carries exactly as many commits after this command
as it did before.
Fetch is the operation that cannot by itself break anything. There is no need to put the
working directory at risk to see what is on the other side: after git fetch, the
incoming commits are read with git log origin/metrics, the difference is taken with
git diff metrics origin/metrics, and only then is what comes next decided.
The Fetch Rule and Pruning
What fetch takes and where it writes it is read from a refspec. The rule’s default
form is a pair like +refs/heads/*:refs/remotes/origin/*: the left side gives the
references on the other side, the right side gives the names they are written under
locally. The leading plus sign allows non-fast-forward updates to be written too — this is
safe for a remote-tracking reference, because none of your own commits sit there.
The rule can be narrowed. Fetching a single branch saves measurable time in large
repositories; fetching every defined remote at once is done with git fetch --all, and
each name writes to its own set of references.
The most frequently overlooked consequence of the rule runs in the deletion direction.
When a branch is deleted on the other side, the corresponding remote-tracking reference
does not fall away on its own; the refspec only writes references that exist, it does not
delete ones that do not. git fetch --prune performs this cleanup. In a repository where
pruning is not done, git branch -a’s output keeps showing branches that no longer exist
on the other side, and this is the most concrete proof that a remote-tracking reference is
not a branch but a record of the last-seen state.
Pull
# taught commands — example dump, not executed git pull origin main git fetch origin && git merge origin/main
The two lines do the same job; the second is the first written out in full. This expansion shows why pull is not a “harmless update”: its second step is a merge, and every result measured in the Merging and Rebasing topic applies here as well. A merge conflict is born during a pull too, and a merge commit is written during a pull too.
Pull’s second step can be replaced by configuration, and rebase can be done instead of merge. This means choosing which integration format the second step applies; what the formats leave in history was measured in the Merging and Rebasing topic and is not repeated here. This lesson’s measurement sets up pull’s merge-based form.
Push
# taught commands — example dump, not executed git push origin main To example.test/origin d4e5f6a..b7c8d9e main -> main
The remote name and branch name do not have to be written to the command every time. A
push done with the -u option sets up a tracking branch relationship between the
local branch and the branch on the other side; subsequent fetch, pull, and push calls read
this relationship and run without the names being written. The relationship sits in the
configuration, and the “ahead by this many commits, behind by that many” line in
git status’s output is also computed from it — that line compares the local branch with
the remote-tracking reference, not with the other side.
A push is accepted only when it is fast-forwardable: every commit on the other side’s branch must also be present in the local branch. If the condition is not met, the operation is rejected, and this rejection is not a defect, it is the record being protected. The rejection says that commits you have not seen exist on the other side.
There is a form of push that goes past this condition, and this lesson does not write it out in its full form. A force push replaces the other side’s branch with the local branch and drops from the references any commits that were on the other side but not local. Dropped commits cannot be recovered if they are not present in another copy; your repository’s reflog does not rescue the other side’s loss, because that log is local. The safe path is three steps: fetch, read the incoming commits, reconcile, and then do an ordinary push. The lease-guarded form of forcing — the form that rejects the operation if the other side has moved since the last fetch — reduces the risk but does not eliminate it; the discipline for it is built in the Advanced Git course’s “Force Push Discipline” lesson.
The Measurement’s Assumptions
- RR7 — The local copy is a repository that has taken the
metricsbranch, and its main branch carries 5 commits; the other side’s branch has takenreportandidentityand carries 10 commits. Both sides use the merge commit format. - RR8 — Fetch does not touch the local branch list; the only thing it does is equalize the remote-tracking reference with the other side’s branch and bring the missing commits locally.
- RR9 — Pull adds one merge commit to the local branch after fetching. The measurement does not set up pull’s rebase-based form.
- RR10 — Push does not touch the local branch; it replaces the other side’s branch with a copy of the local branch. The measurement applies only a fast-forwardable push.
- RR11 — The “local objects” count counts commits present locally whether or not they are in the local branch; commits shown by the remote-tracking reference also enter this count.
- RR12 — Divergence means commits exist on both sides outside the merge base; the measurement sets up divergence by splitting the set of branches the two copies carry.
Measurement
"""Fetch, pull, and push: the history each operation changes. Part 1 - the local branch, local object, and other-side branch after each operation. Part 2 - fast-forwardability of a push in two diverged copies. """ 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(): """Real development: three branches, four commits each, one carries the bug.""" 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 history(record, branches): """A branch's history from having taken the given branches with a merge commit.""" t = [] for branch in branches: for k in [x for x in record if x["branch"] == branch]: t.append({**k, "branch_record": branch, "time_kept": 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": True, "time_kept": True}) return t def question1_grouping(t): return all(x.get("branch_record") for x in t if not x.get("merge")) def question2_bug(t): k = [x for x in t if x["buggy"]] return len(k) == 1 and not k[0].get("squashed") def question3_order(t): return all(x.get("time_kept") for x in t) def question4_file(t): return all(isinstance(x["file"], str) or x.get("merge") for x in t) def question5_integrity(t): spots = {} for i, x in enumerate(t): if x.get("merge"): continue spots.setdefault(x["branch"], []).append(i) return all(y[-1] - y[0] == len(y) - 1 for y in spots.values()) def question6_conflict(t): return any("chosen" in x for x in t) QUESTIONS = (("feature grouping", question1_grouping), ("bug isolation", question2_bug), ("true order", question3_order), ("file trail", question4_file), ("branch integrity", question5_integrity), ("conflict decision", question6_conflict)) def answered(t): return sum(f(t) for _, f in QUESTIONS) def fetch(local, remote): """Fetch: the remote-tracking reference and commits arrive, the local branch does not move.""" return {"branch": list(local["branch"]), "object": local["object"] + [x for x in remote["branch"] if x not in local["object"]], "tracking": list(remote["branch"])} def pull(local, remote): """Pull: fetch, then a merge commit on top of the local branch.""" local = fetch(local, remote) incoming = [x for x in local["tracking"] if x not in local["branch"]] if not incoming: return local merge = {"branch": "main", "step": 0, "file": None, "buggy": False, "time": max(x["time"] for x in local["tracking"]), "branch_record": "main", "merge": True, "time_kept": True} local["branch"] = local["branch"] + incoming + [merge] local["object"] = local["object"] + [merge] return local def push(local, remote): """Push: the local branch does not move, the other side's branch is replaced with the local one.""" return local, {"branch": list(local["branch"])} record = development() local = {"branch": history(record, ("metrics",)), "object": history(record, ("metrics",)), "tracking": []} remote = {"branch": history(record, ("report", "identity"))} print(f"real development {len(record)} commits; local branch {len(local['branch'])}, " f"the other side's branch {len(remote['branch'])}") print() print(f"{'step':<10s}{'local branch':>13s}{'local answered':>16s}{'local objects':>15s}" f"{'other branch':>14s}{'other answered':>16s}") def write(name, local, remote): print(f" {name:<8s}{len(local['branch']):13d}{answered(local['branch']):16d}" f"{len(local['object']):15d}{len(remote['branch']):14d}{answered(remote['branch']):16d}") write("start", local, remote) write("fetch", fetch(local, remote), remote) pulled = pull(local, remote) write("pull", pulled, remote) pushed, remote_after = push(pulled, remote) write("push", pushed, remote_after) print() print(f"{'copy pair':<14s}{'local':>6s}{'other':>6s}{'merge base':>12s}" f"{'only local':>13s}{'only other':>13s}{'fast-forward':>15s}") PAIRS = {"not diverged": (pulled["branch"], remote["branch"]), "diverged": (history(record, ("metrics", "report")), history(record, ("metrics", "identity")))} for name, (ld, od) in PAIRS.items(): common = [x for x in ld if x in od] print(f" {name:<12s}{len(ld):6d}{len(od):6d}{len(common):12d}" f"{len(ld) - len(common):13d}{len(od) - len(common):13d}" f"{'yes' if all(x in ld for x in od) else 'no':>15s}") print() for name, (ld, od) in PAIRS.items(): dropped = [x for x in od if x not in ld] print(f" {name} pair: force push would drop {len(dropped)} commits from the other side")
real development 12 commits; local branch 5, the other side's branch 10 step local branch local answered local objects other branch other answered start 5 4 5 10 5 fetch 5 4 15 10 5 pull 16 5 16 10 5 push 16 5 16 16 5 copy pair local other merge base only local only other fast-forward not diverged 16 10 10 6 0 yes diverged 10 10 5 5 5 no not diverged pair: force push would drop 0 commits from the other side diverged pair: force push would drop 5 commits from the other side
What the Three Operations Cost
The top table separates the three operations across four rows, and the difference between rows shows up in a single column each time.
The fetch row leaves the local branch at 5, and the answered questions stay at
4. The only column that changes is “local objects”: it climbs from 5 to 15.
These fifteen commits sit locally, exist on disk, can be read — but none of them are in
the local branch’s history. This is fetch’s definition: it increases reachability, it
does not change history. The “bug isolation” question that stays unanswered is still
unanswered after fetch, because the question is asked of the local branch, and the local
branch has not moved. The buggy commit is now present locally; it only becomes visible
when asked of the origin/report reference.
The pull row carries the local branch from 5 to 16 and raises the answered questions from 4 to 5. The number sixteen is composite: the local branch’s own 5 commits, the 10 commits arriving from the other side, and pull’s own 1 written merge commit. On top of the twelve-commit real development, four merge commits accumulate — three merges that bring the branches into the main branch, and a fourth merge that reconciles the two copies.
The question gained is “bug isolation”. The previous lesson measured that this gap came from scope loss; pull closes the scope, and the question becomes answerable. This is direct proof that scope loss is recoverable.
In the push row, none of the three local columns move: 16, 5, 16. The column that changes is the other side’s branch — it climbs from 10 to 16. Push’s measure is read not in its own repository but in the other one, and of the three operations this holds true only for push.
The other side’s answered questions go from 5 to 5; the number does not change, but the commits carried increase by six. This is the reverse reading of the previous lesson’s finding: an increase in the number of commits does not by itself raise the answered questions. The other side was already carrying the buggy commit.
Divergence and the Limit of Push
The bottom table places two copy pairs side by side. In the not diverged pair, all ten of the other side’s commits are also present locally: the merge base is 10, and only-on-the-other-side is 0. The push is a fast-forward, and nothing is dropped.
In the diverged pair, the merge base drops to 5. There are five commits locally that are not on the other side, and five commits on the other side that are not local. The fast-forward condition is not met, and the ordinary push is rejected. The last line says what the rejection protects: if a force push had been applied, 5 commits would have been dropped from the other side — roughly 5/12 of the real development, and the person who wrote those five would only notice once their own copy moved.
The difference between the two numbers shows why push’s rejection behavior is a safety feature. The rejection is not an error message, it is a notification of divergence. The correct response is not to force the push but to close the divergence: fetch makes the divergence visible, pull or rebase integrates it, the subsequent push becomes a fast-forward, and dropped commits fall to zero.
Summary
- Fetch leaves the local branch at 5 commits and raises local commits to 15; the answered questions stay at 4 because the question is asked of the local branch and the local branch does not move.
- Pull adds a merge commit on top of fetch: the local branch climbs to 16, the answered questions to 5; one of the sixteen is the commit pull itself wrote.
- Scope loss is a recoverable loss: once the missing branch arrives, the “bug isolation” question becomes answerable again.
- Push does not touch local history and carries the other side’s branch from 10 to 16; its measure is read not in its own repository but in the other one.
- Push is accepted only when it is fast-forwardable; in the diverged pair the merge base drops to 5, and a force push would drop 5 commits from the other side, irreversibly.
Next Step
This lesson’s three operations worked with a single assumption: push access existed. The local copy could change the other side’s branch, and the only obstacle was the fast-forwardability condition. This assumption does not hold for most contribution flows — there is no write access to the repository receiving the contribution. The next lesson compares the two models this situation opens up: cloning the copy directly and pushing a branch, versus opening a separate copy and carrying a proposal from there. The measure is which questions the upstream repository can answer in the two models.
To keep your progress and take notes, Log in
My notes
Log in to take notes.