---
title: 'Fetch, Pull, and Push'
source: 'https://academia.sh/en/courses/branching-and-collaboration/fetch-pull-and-push'
course: 'Branching and Collaboration'
language: en
updated: '2026-08-17T18:10:44+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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

```text
# 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

```text
# 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

```text
# 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 `metrics` branch, and its
  main branch carries **5 commits**; the other side's branch has taken `report` and
  `identity` and 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

```python
"""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.
