---
title: 'Branching and Collaboration'
source: 'https://academia.sh/en/courses/branching-and-collaboration'
language: en
updated: '2026-08-17T04:42:45+00:00'
license: 'CC BY-SA 4.0'
lessons: 15
outcomes:
    - 'Chooses a branching strategy and justifies it'
    - 'Resolves merge conflicts'
    - 'Works in sync with remote repositories and runs the contribution flow'
---

# Branching and Collaboration

Four ways of integrating produce the same code and leave different histories; the course measures each by how many of six questions the history it leaves can answer.

## Branches

The branch as pointer and how cheap branching is, adapting the workspace through branch creation and switching, a readable naming convention within a team, and how detached HEAD arises and how to get out of it.

### The Concept of a Branch

Source: https://academia.sh/en/courses/branching-and-collaboration/concept-of-a-branch

A branch is a pointer: in a twelve-commit setup, opening three branches writes zero new commits and 123 bytes of reference, and while the copy model copies twelve thousand files for the same tree at four thousand files, the pointer model's cost stays constant.

The Introduction to Version Control course built history as a single line. Working
directory, staging area, and commit followed one another; every new commit pointed to
the one before it, and the order stayed the same however the history was read — a
single path from newest to oldest. In that course, history was **linear and
single-author**: one person, one order, one line.

When more than one person works in the same repository, this assumption breaks down.
Two people start from the same commit and move in separate directions, and history is
no longer a line but a **fork**. This course's question is not how a fork opens —
opening one is cheap, and this lesson measures exactly how cheap. The real question is
how a fork **closes**, because the way it closes determines which questions the history
can answer afterward.

#### The Course's Measure

The course uses a single setup throughout. Three people start three separate pieces of
work in the same repository: `metrics`, `report`, and `identity`. Each makes four
commits, and each commit touches its own file; on the third step, two of them both touch
the `config.py` file. The `report` work's second commit introduces a bug. The real
development is **twelve commits**, and its order is known, because we wrote the setup
ourselves.

These twelve commits can be brought into the main branch in four separate **integration
formats**: merge commit, rebase, squash, and fast-forward. All four produce the same
**code**. The **history** they produce differs, and the course measures this difference
with a single thing: six questions are asked of the history, and how many it can answer
is counted. These are called the course's **answerable questions**, and their
definitions do not change throughout the course.

1. **Feature grouping** — which commits belong to which piece of work?
2. **Bug isolation** — can the commit that introduced the bug be found on its own?
3. **True order** — in what order was the work developed?
4. **File trail** — can every commit that touched a file be found individually?
5. **Branch integrity** — do a branch's commits stay together?
6. **Conflict decision** — where was the conflict resolved, and which side was chosen?

This lesson answers none of the questions. What it measures is an earlier step: the
cost of opening a fork.

#### A Branch Is a Pointer

The **reference (ref)** was introduced in the Creating and Configuring a Repository
lesson: a commit ID bound to a name. A branch is nothing more than this. The content of
a reference under `refs/heads` is a forty-hexadecimal-digit ID and a line ending;
forty-one bytes in total.

```text
# taught command and example dump — not executed

$ git branch metrics
$ git branch
* main
  metrics
$ git rev-parse metrics
3f1a9c4e6b2d8057a41c9e3b7d604f2a8c15be93
$ git symbolic-ref HEAD
refs/heads/main
```

References can be kept either as loose files or in packed form, so it is the output of
`git rev-parse` that is trusted, not the existence of the `refs/heads/metrics` file. The
ID itself is specific to the repository; the value here is from the setup, and you will
see a different value in your own repository. What stays fixed is the reference's
**structure**: one name, one ID.

`HEAD`, meanwhile, points not to a commit but to a **reference**; this is called a
symbolic reference. The chain has three links: `HEAD` points to a branch name, the
branch name points to a commit ID, and the commit points to its own parent. Committing
on a branch changes only the middle link — the ID in the branch reference is replaced
with the new commit's ID, and forty-one bytes are rewritten. `HEAD` stays in place,
because it still points to the same branch.

The lightweight tag from the Tagging lesson also keeps an ID under `refs`; the
difference comes down to one word: **mobility**. A tag binds to a commit once and never
moves on its own; a branch reference is rewritten on every commit and tracks the
branch's tip. They are two uses of the same data structure: one for fixing a point, the
other for dragging one along. This is why work does not happen on top of a tag, it
happens on top of a branch — and as long as `HEAD` points to a branch name, every commit
made **attaches** to something. The case where `HEAD` points directly to a commit ID
instead of a branch name has a separate name, and it is covered in this topic's fourth
lesson.

This has a direct consequence: **a branch does not contain commits.** A branch only
points to one tip commit, and what we call "the branch's commits" is the set reachable
from that tip by following parent edges. The definition of **reachability** from the
Reading History lesson applies here exactly as it was. Two branches' common ancestors
are found in both sets at once, and they have not been copied anywhere.

#### The Shape of the Fork

In the Reading History lesson, the output of `git log --graph` gave a single-column
drawing; that history had no branching. The setup's history has three columns. It is
clearer to draw the shape independent of the command's output:

```text
# the shape of the history — a diagram, not command output

                 metrics  ->  4 commits
                /
  base   ------+------->  report   ->  4 commits
                \
                 identity ->  4 commits
```

The first commit reached when all three branches are traced backward is called the
**merge base**. The merge base is a commit, not a branch; it is the same commit
regardless of which branch's tip you are standing on, and it determines where the
difference starts when two branches are compared. The rest of this course works on top
of the merge base: merging, rebasing, and the question "what is in this branch that is
not in the other" are all computed starting from the base.

Branch tips can also be listed on a single line:

```text
# taught command and example dump — not executed

$ git branch -v
* main     0a5f3c9 Add metrics scaffold
  identity 9d4c1a7 Complete identity verification step
  metrics  6e2d70b Move metrics threshold into configuration
  report   7c3f9d2 Format report heading
```

The asterisk marks which branch `HEAD` points to. Every line in the list is nothing more
than a name and an ID; there is no "content" column for a branch, because no such thing
is stored.

#### The Copy Model and the Pointer Model

The habit before version control was to copy the entire working tree to separate out a
piece of work. This is the **copy model**, and its cost is obvious: as many copies as
there are files in the tree. The cost grows directly proportional to the tree's size,
because what gets set aside is the tree itself.

The copy model has a heavier cost still, and this cost is not measured in bytes. The
copied tree sits **outside** history: no parent edge is attached to the copy, it has no
commit ID, and it is reachable from no reference. Merging two copies afterward means
comparing them with no knowledge of what the common base was — which change is new and
which already exists in the other can only be worked out by hand. None of this course's
six questions can be asked of the copy model, because there is no history there to
query.

In the **pointer model**, what gets set aside is not the tree but a name given to a
snapshot of the tree. The files already sit in the object database, and because it is
content-addressed, the same content is never stored twice. A new branch is nothing more
than giving a second name to an existing commit. The measurement puts these two models
side by side over the same setup.

The measurement's assumptions:

- **BR1** — The setup is three pieces of work and twelve commits; the oracle is known
  because we produced the setup ourselves, and it tells which piece of work every commit
  belongs to and which file it touches.
- **BR2** — The three branches diverge from a shared base commit. The base is outside
  the twelve commits and is the only commit all three branches can reach.
- **BR3** — A branch reference consists of a forty-hexadecimal-digit ID and a line
  ending: forty-one bytes. The measurement is not a disk measurement, it is a count of
  the content written.
- **BR4** — The copy model counts a branch as a full copy of the working tree, and its
  cost is the number of files copied. The pointer model counts a branch as a single
  reference, and its cost is the bytes written.
- **BR5** — The working tree is tried at four sizes: the number of files the setup
  touches, and three enlargements. The setup's commit count does not change across
  these rows; only the tree's width changes.
- **BR6** — Reachability only follows parent edges. A commit reachable from no
  reference cannot be queried in history; sitting in the object database does not put
  it back into history.

#### Measurement

```python
"""A branch is a pointer: branching's cost is not files copied, but the reference written."""
SEED = 20260813
BRANCHES = ("metrics", "report", "identity")
FILES = {"metrics": "metrics.py", "report": "report.py", "identity": "identity.py"}
BUGGY = ("report", 2)
SHARED_FILE = "config.py"
REF_BYTES = 40 + 1        # forty hex digits + line ending


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: (branch, step, file, buggy, time) tuples."""
    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


record = development()
tree = sorted({k["file"] for k in record})
print(f"setup: {len(record)} commits, {len(BRANCHES)} branches, "
      f"{len(record) // len(BRANCHES)} commits per branch, files touched {len(tree)} {tree}")
print(f"opening three branches: new commits 0, references written {len(BRANCHES)}, "
      f"bytes written {len(BRANCHES) * REF_BYTES}")

print()
print("working tree  copy model: files copied  pointer model: bytes written")
for n in (len(tree), 40, 400, 4000):
    print(f"{n:13d} {n * len(BRANCHES):27d} {len(BRANCHES) * REF_BYTES:26d}")

print()
print("branch    tip commit  reachable commits  unreachable if ref deleted")
for branch in BRANCHES:
    group = [k for k in record if k["branch"] == branch]
    print(f"  {branch:8s} {group[-1]['step']:10d} {1 + len(group):18d} {len(group):27d}")
print(f"union reachable from three refs: {1 + len(record)} commits "
      f"(base + {len(record)}), references written {len(BRANCHES)}")
```

```
setup: 12 commits, 3 branches, 4 commits per branch, files touched 4 ['config.py', 'identity.py', 'metrics.py', 'report.py']
opening three branches: new commits 0, references written 3, bytes written 123

working tree  copy model: files copied  pointer model: bytes written
            4                          12                        123
           40                         120                        123
          400                        1200                        123
         4000                       12000                        123

branch    tip commit  reachable commits  unreachable if ref deleted
  metrics           4                  5                           4
  report            4                  5                           4
  identity          4                  5                           4
union reachable from three refs: 13 commits (base + 12), references written 3
```

#### Reading the Numbers

The first line gives the setup's size: **12** commits, **3** branches, **4** files
touched. The second line is this lesson's main measure. Opening three branches writes
**0** new commits and **123** bytes. Zero means branching adds nothing to history;
opening a branch is not a record, it is a name.

The middle table shows how the cost behaves. In the setup's four-file tree, the copy
model copies **12** files, the pointer model writes **123** bytes. When the tree grows
to forty files, the copy model climbs to **120**; at four hundred files, to **1200**; at
four thousand, to **12000**. The pointer column stays **123** on all four rows. The
copy model's cost depends on the size of the tree; the pointer model's cost depends
**only on the number of branches**. The gap between them opens a thousandfold on a
thousandfold-larger tree, and this is the single reason branching could become a habit
instead of an exception.

The bottom table gives a second result. **5** commits are reachable from every branch
tip: the branch's own four, plus the shared base. The three tips do not add up to
fifteen but to **13**, because the base is found in all three sets and is counted once.
**Sharing** a branch is subject to the same economy: writing a third name pointing at
the same base is, again, forty-one bytes.

The setup's resolution is also read off from here. In a twelve-commit set, the smallest
measurable difference is **1/12**; in a six-question set, the smallest difference is
**1/6**. A branch's four commits are a third of the set, a single buggy commit is a
twelfth. Differences below this resolution cannot be defended with this setup, and the
numbers written throughout the course are kept above it.

The last column looks ahead in the course. If a branch reference is deleted, the **4**
commits reachable from that branch become reachable from no reference at all. The
commits keep sitting in the object database, but none of the six questions asked of
history can reach them — because the questions are asked of history, and history is the
set reachable from references. The pointer's cheapness and its fragility are two faces
of the same fact: if writing forty-one bytes is easy, losing forty-one bytes is easy
too.

#### The Main Branch Has No Privilege

There is a result the measurement does not say directly but that follows from its
numbers. Opening three branches writes **123** bytes, and this byte count is the same
for every branch; the setup's `main` branch, too, is a forty-one-byte reference under
`refs/heads`. As far as the tool is concerned, the main branch has no privilege: it is
not read faster, kept more safely, or protected from deletion. Privilege is the team's
decision, not the data structure's.

This has two practical consequences. First, the name "main branch" is itself a
convention and can differ from repository to repository; commands look not at a default
name but at the reference `HEAD` points to. Second, every protection that makes the
main branch privileged is set up **outside the repository**: who can write to which
branch, which branch cannot be deleted, which branch cannot be committed to
directly — none of this is written into the reference itself. The repository does not
ask who wrote the forty-one bytes.

What matters for this lesson is this: **which** commit a branch tip points to is a data
question; what that branch **means** is a convention question. The answer to the second
is naming, and this topic's third lesson measures it.

#### Summary

- A branch is a commit ID bound to a name under `refs/heads`; its content is forty hex
  digits and a line ending, forty-one bytes in total.
- `HEAD` points not to a commit but to a branch reference; committing changes only the
  ID in the branch reference, and `HEAD` stays in place.
- A branch does not contain commits; "the branch's commits" is the set reachable from
  the branch tip by following parent edges, and shared ancestors are found in both sets
  without being copied.
- Opening three branches writes **0** new commits and **123** bytes; the copy model
  does the same job by copying **12** files in a four-file tree and **12000** files in
  a four-thousand-file tree.
- **5** commits are reachable from one branch tip, **13** from the union of three tips;
  if a reference is deleted, the **4** commits reachable from that branch fall outside
  history.

#### Next Step

Opening a branch is a name, and its cost has been measured. But the moment the name is
written, the working tree still carries the old branch's content; **switching** to the
branch is a separate operation and requires the working directory and staging area to
be adapted to the new branch. If there is an uncommitted change in hand at that moment,
a decision has to be made: does the change move along with the switch, and if it cannot,
is the switch blocked? The next lesson separates branch creation from switching and
counts, over twelve cases, which change moves, which one blocks, and which one is left
behind.

### Branch Creation and Switching

Source: https://academia.sh/en/courses/branching-and-collaboration/branch-creation-and-switching

Opening a branch and switching to it are separate operations: the switch only rewrites files that differ between the two branches, and of the twelve remaining-change cases, 3 move along, 5 block the switch, 4 are left behind; none is silently lost.

The previous lesson measured branching's cost: opening three branches writes **0** new
commits and **123** bytes. The measurement deliberately left one thing out. Once a
branch name is written, there is a new reference under `refs/heads`, but not a single
file in the working directory has changed. `HEAD` still points to the old branch. The
name has been written; it has not been switched to.

This lesson's question is the switch itself. Switching is not just writing a reference;
it has to bring the working directory and staging area to what the target branch sees.
Two questions arise from this: how many files does the switch touch, and if there is an
uncommitted change in hand at that moment, what happens to it? The second question has
no single answer: the outcome depends on the change's type and whether the file differs
between the two branches.

#### Two Separate Commands

Creating a branch and switching to it are two separate invocations of the tool. The
first only writes the reference; the second updates `HEAD`, the staging area, and the
working directory together.

```text
# taught command and example dump — not executed

$ git branch metrics
$ git status --short --branch
## main

$ git switch metrics
Switched to branch 'metrics'
$ git status --short --branch
## metrics
```

After the first call, `git status` still says `main`: the branch exists, but has not
been switched to. After the second call, the same line says `metrics`. No file name
appears in between, because there is no difference between the two branches yet; the
measurement will shortly count the case where there is a difference.

There is a shortcut that does both jobs in one call: `git switch -c metrics` first
writes the reference, then switches to it; the older form is `git checkout -b metrics`.
`git checkout` is an overloaded name — the same command both switches branches and
restores a single file, and the distinction can only be read from the shape of the
call. `git switch` and `git restore` split these two jobs into two names. This course
uses the separate names throughout.

#### The Switch's Three Steps

Switching to a branch is three separate write operations, and the three behave as a
single operation: if one fails, none of them are applied.

The first is rewriting `HEAD`. The previous lesson established that `HEAD` points not
to a commit but to a branch name; switching changes this symbolic reference's content,
and its cost is a single line. The second is rebuilding the staging area to match the
target branch's tree. The third is the working directory: files that are **different**
on the target branch are replaced with the target's version.

The scope of the third step is critical. The switch does not rewrite all of the
branch's files, only the ones that are **different** between the two branches. The
difference is of two kinds: content difference (the file exists on both branches, but
their versions differ) and existence difference (the file exists on one branch, not on
the other). In the second case, the switch either creates a file or deletes one. This
is why the switch's cost depends not on the branch's size but on the difference — and
the top table counts exactly this.

#### The Change Left in Hand: Three Rules

If there is an uncommitted change in the working tree at the moment of switching, the
tool's behavior is determined by three rules.

**First rule.** If the file is **identical** between the two branches, the switch does
not touch it. Every uncommitted change on it — whether in the working directory or
already staged — appears unchanged on the new branch. This is usually the wanted
behavior: a small fix started on one branch comes along with you if that file has not
changed on the other branch.

**Second rule.** If the file is **different** between the two branches, the switch has
to rewrite it. If there is an uncommitted change on it, the tool **stops** the switch
instead of overwriting that change. Nothing is written, `HEAD` stays in place, and the
error message says which file is blocking it.

**Third rule.** An untracked file sits outside history and is in no branch's tree. If
the target branch has no file by the same name, it stays put through the switch. If a
tracked file by the same name exists, the switch would write over it, and it is
**stopped** again. The subtle point here is that the file blocks the switch even though
it sits outside history, because the block's cause is not the file's record but the
clash of its name.

```text
# taught command and example dump — not executed

$ git switch report
error: Your local changes to the following files would be overwritten by checkout:
	report.py
Please commit your changes or stash them before you switch branches.
Aborting

$ git switch report
error: The following untracked working tree files would be overwritten by checkout:
	config.py
Please move or remove them before you switch branches.
Aborting
```

The two messages come from two separate rules, and both report the same outcome: the
switch did not happen. The second message's suggested fix is also different — an
untracked file cannot be committed or stashed, so it asks for it to be moved or
removed.

#### Three Ways Out

A blocked switch has four options in front of it, and three of them preserve the
change.

The first is **committing** the change. The change is written to the old branch's tip,
becomes reachable from a reference, and the switch is freed. The change does not come
along with the switch; it stays on the old branch and is found there when you return to
it.

The second is taking it to the **stash**. `git stash push` takes the uncommitted change
out of the working tree and writes it to a separate reference under `refs/stash`; the
tree is cleaned, and the switch is freed. `git stash list` lists the stack, `git stash
pop` reapplies the top one. A stash is not tied to a branch — opening it on a different
one can produce a conflict if the file differs there. This behavior belongs to the
Merging topic and is covered there.

The third is **forcing the change to move**: the `git switch -m` option tries to merge
the uncommitted change with the target branch's version. When it succeeds, the change
appears on the new branch; when it does not, it leaves a conflict in the working tree
that has to be resolved. This lesson does not resolve conflicts and mentions the option
only by name.

The fourth is **discarding** the change. The `git switch` command has an option for
this, and it deletes the uncommitted change irrecoverably; the change was never written
to a commit or a reference, so there is nowhere to recover it from. This lesson does
not write that option in executable form. The safe habit: stash before discarding — a
stash can be undone, a deletion cannot.

The measurement's assumptions:

- **BR7** — The setup is the shared definition's twelve commits and is not changed.
  The base tree has three files: `metrics.py`, `report.py`, `identity.py`. `config.py`
  is **not** in the base; on the third step, `metrics` and `report` add it, so it is not
  found in the `identity` branch's tree.
- **BR8** — A branch's working tree is the file versions seen from that branch's tip:
  the version left by the branch's last commit, or the base version if untouched.
- **BR9** — The switch only rewrites the file that differs between the two branches.
  The difference covers both content difference and existence difference.
- **BR10** — Three rules apply: a change on an identical file moves along; an
  uncommitted change on a different file blocks the switch; an untracked file moves
  along if the target has no file by the same name, and blocks the switch if it does.
- **BR11** — Twelve cases are tried in a single switch, from the `identity` branch to
  the `report` branch. Five kinds of change are counted: modified in the working
  directory, staged, untracked, committed, stashed.
- **BR12** — A committed or stashed change is of no concern to the switch: both are
  already written to a reference. The switch does not move them, it leaves them where
  they are; the measurement counts this outcome as **left behind**.
- **BR13** — A silent loss is a change disappearing without the user being told, and
  it is counted separately. A blocked switch is not a loss; nothing was written.
- **BR14** — The measurement is not a filesystem measurement. Two things are counted:
  the file the switch has to rewrite, and where the state left in hand at the moment of
  switching ends up.

#### Measurement

```python
"""Creating a branch and switching to it are separate operations.

Part 1 - the file difference between two branches: which files the switch rewrites.
Part 2 - twelve cases: where the change left in hand ends up after the switch.
"""
SEED = 20260813
BRANCHES = ("metrics", "report", "identity")
FILES = {"metrics": "metrics.py", "report": "report.py", "identity": "identity.py"}
BUGGY = ("report", 2)
SHARED_FILE = "config.py"
BASE = ("metrics.py", "report.py", "identity.py")   # config.py is not in the base


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 tree(record, branch):
    """Working tree at a branch's tip: file -> version."""
    s = {d: "base" for d in BASE}
    for k in record:
        if k["branch"] == branch:
            s[k["file"]] = f"{branch}{k['step']}"
    return s


def diff(record, source, target):
    """Files the switch has to rewrite."""
    a, b = tree(record, source), tree(record, target)
    return sorted(d for d in set(a) | set(b) if a.get(d) != b.get(d))


SOURCE, TARGET = "identity", "report"
CASES = (("metrics.py", "working directory"), ("report.py", "working directory"),
         ("identity.py", "working directory"), ("metrics.py", "staging area"),
         ("report.py", "staging area"), ("identity.py", "staging area"),
         ("note.txt", "untracked"), ("config.py", "untracked"),
         ("report.py", "committed"), ("identity.py", "committed"),
         ("report.py", "stashed"), ("identity.py", "stashed"))
LOCATION = {"moves": "in the working tree", "blocked": "on the old branch, switch did not happen",
            "committed": "at the old branch's tip", "stashed": "on the stash stack"}


def outcome(file, kind, differing, target_tree):
    if kind in ("committed", "stashed"):
        return "left behind"
    if kind == "untracked":
        return "blocked" if file in target_tree else "moves"
    return "blocked" if file in differing else "moves"


record = development()
print(f"setup: {len(record)} commits, {len(BRANCHES)} branches, base tree "
      f"{len(BASE)} files, files branches touch "
      f"{len({k['file'] for k in record})}")
print()
print(f"{'switch':<20s}{'files rewritten':>19s}   which ones")
for a, b in [("base", d) for d in BRANCHES] + [("identity", "report"),
                                               ("metrics", "report")]:
    f = diff(record, a, b)
    print(f"  {a + ' -> ' + b:<18s}{len(f):19d}   {', '.join(f)}")

differing, target_tree = set(diff(record, SOURCE, TARGET)), tree(record, TARGET)
print()
print(f"switch {SOURCE} -> {TARGET}: twelve remaining cases")
print(f"{'  what is in hand':<34s}{'on target':<11s}{'outcome':<14s}where the change goes")
counts = {}
for file, kind in CASES:
    if kind in ("committed", "stashed"):
        on_target = "-"
    elif file not in target_tree:
        on_target = "absent"
    elif kind == "untracked":
        on_target = "present"
    else:
        on_target = "different" if file in differing else "identical"
    s = outcome(file, kind, differing, target_tree)
    counts[s] = counts.get(s, 0) + 1
    print(f"  {file + ' (' + kind + ')':<32s}{on_target:<11s}{s:<14s}"
          f"{LOCATION[kind] if s == 'left behind' else LOCATION[s]}")
print()
for s in ("moves", "blocked", "left behind"):
    print(f"{s:<14s}{counts.get(s, 0):3d} / {len(CASES)}")
print(f"{'silent loss':<14s}{0:3d} / {len(CASES)}")
```

```
setup: 12 commits, 3 branches, base tree 3 files, files branches touch 4

switch                  files rewritten   which ones
  base -> metrics                     2   config.py, metrics.py
  base -> report                      2   config.py, report.py
  base -> identity                    1   identity.py
  identity -> report                  3   config.py, identity.py, report.py
  metrics -> report                   3   config.py, metrics.py, report.py

switch identity -> report: twelve remaining cases
  what is in hand                 on target  outcome       where the change goes
  metrics.py (working directory)  identical  moves         in the working tree
  report.py (working directory)   different  blocked       on the old branch, switch did not happen
  identity.py (working directory) different  blocked       on the old branch, switch did not happen
  metrics.py (staging area)       identical  moves         in the working tree
  report.py (staging area)        different  blocked       on the old branch, switch did not happen
  identity.py (staging area)      different  blocked       on the old branch, switch did not happen
  note.txt (untracked)            absent     moves         in the working tree
  config.py (untracked)           present    blocked       on the old branch, switch did not happen
  report.py (committed)           -          left behind   at the old branch's tip
  identity.py (committed)         -          left behind   at the old branch's tip
  report.py (stashed)             -          left behind   on the stash stack
  identity.py (stashed)           -          left behind   on the stash stack

moves           3 / 12
blocked         5 / 12
left behind     4 / 12
silent loss     0 / 12
```

#### Reading the Numbers

The top table gives the switch's cost. Switching from the base to the `identity` branch
writes **1** file; switching to the `metrics` and `report` branches writes **2**.
Switches between two branches write **3** files, because each branch's own file and the
shared `config.py` file both differ. The tree's fourth file stays put on every row.

This number completes the previous lesson's measure. Opening a branch wrote **41**
bytes and was independent of the tree's size. Switching to a branch touches the tree,
but not the whole tree: in a four-thousand-file tree, if two branches diverge on three
files, the switch writes **3** files. In the copy model, the same job would require
copying all four thousand files. This is the pointer model's counterpart at switch
time — the cost is not the size of the tree, but the size of the **difference**.

The bottom table is the lesson's main measure. **3** of the twelve cases move along,
**5** block the switch, **4** are left behind. Silent loss is **0**.

What the three that move along have in common is that the switch never touches that
file at all. `metrics.py` is at the base version on both branches, so it brings along
the change whether it is in the working directory or the staging area. `note.txt` is in
no branch's tree and the switch does not care about it. In both cases, moving along is
not a capability, it is the result of **not colliding**.

The blocked five all have a single cause: the file is different between the two
branches, and the switch is going to rewrite it. The working directory and staging area
make no difference here — all four come from the same two files, `report.py` and
`identity.py`. The fifth, `config.py`, reaches the same outcome by a different path: it
is untracked and appears in no commit, but the target branch has a tracked file by the
same name. Sitting outside history does not mean not blocking the switch.

Zero silent loss is a design decision. The tool never deletes an uncommitted change
without reporting it; it blocks instead. The **5/12** stopping rate is not a defect, it
is the cost of that decision: when a switch gets blocked in your workflow, nothing has
been lost, there is only something not yet recorded.

The four left behind are the user's decision, not the tool's, and there is a difference
between the two. A committed change sits at the old branch's tip; it is reachable from
a branch reference and shows up when the branch name is listed. A stashed change sits
under `refs/stash`; it is reachable but shows up on no branch, and once forgotten it is
harder to find than searching branches. The case where a commit is reachable from no
branch reference at all is this topic's fourth lesson's subject.

In a twelve-case set, the smallest measurable difference is **1/12**. The **2/12** gap
between moved and blocked is twice this band and defensible; finer distinctions cannot
be defended with this setup.

#### Summary

- Creating a branch only writes a reference; switching to it updates `HEAD`, the
  staging area, and the working directory together. `git switch -c` does both jobs in
  one call.
- The switch does not rewrite all of a branch's files, only the ones that are
  **different** between the two branches; in the setup, this count is **1** to **2**
  files from the base to a branch, and **3** between branches.
- An uncommitted change on an identical file moves along; a change on a different file
  stops the switch; an untracked file only stops the switch if the target has a file by
  the same name.
- **3** of the twelve cases move along, **5** are blocked, **4** are left behind;
  silent loss is **0** — the tool stops the switch instead of deleting an uncommitted
  change.
- A blocked switch has three safe ways out: committing, stashing, and forcing the move
  with a merge; discarding is irreversible, and the stash is its safe counterpart.

#### Next Step

Which file a switch touches is read off the difference between two branches, computed
without looking at their names at all. The name, though, serves a different question.
In the measurement, the names `identity` and `report` never changed the switch's
outcome; but with thirty branches in the same repository, which one belongs to which
piece of work, which one can be closed, which one is long-lived — all of this is read
from names alone. The next lesson measures a naming convention: feature grouping, the
first of history's questions, answers yes once a branch record is kept, but if the
names are disorderly, the group itself still cannot be read — the measurement shows the
two conditions do not substitute for each other.

### Branch Naming

Source: https://academia.sh/en/courses/branching-and-collaboration/branch-naming

A branch naming convention adds to history's first question what the branch record alone cannot: of the eight combinations of two integration formats and four naming conventions, the group is actually readable in only 3, because keeping a record and having a readable name are two separate conditions.

The previous lesson computed which file a switch touches from the difference between
two branches, and this computation never looked at the branches' names. Switching from
the `identity` branch to the `report` branch wrote three files; if the branches had been
named `new` and `trial`, it would have written the same three files. As far as the tool
is concerned, a name is a label and changes no behavior.

A name serves a different question. With thirty branches in the same repository, which
one belongs to which piece of work, which one can be closed, which one will live for a
month — all of this is read from names alone. In the course's measure, this corresponds
to the **first question**: which commits belong to which piece of work? This lesson
shows that this question has two separate conditions. History has to keep a branch
record — and the name that record carries has to say something. Neither substitutes for
the other, and the measurement counts this.

#### A Name's Two Jobs

A branch name is, first, a **path**. The name of the `refs/heads/metrics` reference is
`metrics`, and the name determines where the reference is looked for. The slash is a
separator in this path: the name `feature/metrics` corresponds to the
`refs/heads/feature/metrics` reference, and `feature` becomes a hierarchy level. The
tool recognizes this level — any command that matches patterns can use the slash as a
boundary.

Second, a name is a **declaration**. It is a field the tool does not read, but a human
does. Like a commit message, it is written — or not — to carry answers to questions
that will be asked later. A **branch naming convention** makes this second job orderly:
it determines which fields the name carries, and in what order.

The fields are typically three. **Type** gives the work's class (feature, fix,
maintenance) and sits before the slash. **Ticket** ties the name to an external record;
it is a number or a short code. **Short name** names the work itself in a word or two.
The measurement counts these three fields separately, because each answers a different
question, and a convention is not obliged to carry all three at once.

#### A Name's Validity

Because a name is written as a file path, its shape is not free. The tool tests
validity with a separate subcommand.

```text
# taught command and example dump — not executed

$ git check-ref-format --branch 'feature/metrics'
feature/metrics
$ git check-ref-format --branch 'feature metrics'
fatal: 'feature metrics' is not a valid branch name

$ git branch --list 'feature/*'
  feature/214-metrics
  feature/215-report
  feature/216-identity
```

Most of the rules come from two concerns. The first is the shell and the command line:
space, `*`, `[`, `?`, and the backslash carry meaning in the shell and turn into
something else when written unquoted. The second is the tool's own reference syntax:
the marks `~`, `^`, `:`, `..`, and `@{` are operators used when resolving a commit by
name. If `feature/metrics~1` were a valid branch name, the expression "this branch's
previous commit" would become unreadable. The `.lock` extension, meanwhile, clashes
with the lock files used when writing a reference.

There is one more rule, and it comes from a different place than the others. A name
under `refs/heads` cannot be both a file and a directory; this is why a branch named
`feature` cannot be opened while a branch named `feature/metrics` exists.

```text
# taught command and example dump — not executed

$ git branch feature
fatal: cannot lock ref 'refs/heads/feature': 'refs/heads/feature/metrics' exists

$ git branch --merged main
  feature/214-metrics
$ git branch -d feature/214-metrics
Deleted branch feature/214-metrics (was 6e2d70b).
```

This is a direct consequence of the structure: using a prefix as a branch name too
blocks every name under that prefix. A prefix has to stay only a prefix.

#### The Jobs a Convention Pays For

There are three everyday jobs a naming convention pays back, and all three are done
with listing commands.

The first is **filtering**. `git branch --list 'feature/*'` lists only the branches
carrying that prefix. With disorderly names, this pattern selects nothing; in a
repository of thirty branches, you would only learn which branch belongs to which type
by reading them one by one.

The second is **closing**. `git branch --merged main` lists branches that have been
brought into main; these are the ones that can be deleted. `git branch -d` deletes only
a merged branch and refuses if it is not merged. The forcing form skips this check, and
the previous lesson's measurement gave its cost: when a branch reference is deleted,
the four commits reachable from that branch become reachable from no reference at all.
A naming convention makes an indirect but real contribution here — if which branch is
short-lived can be read from its name, the list of branches to delete stops being a
guess.

The third is **distinguishing lifespan**. A **long-lived branch** stands for as long as
the repository lives, is never deleted, and does not belong to a single piece of work;
its name is fixed and carries no type prefix. A **short-lived branch** is opened for a
single piece of work and deleted once that work is integrated; its name carries the
type prefix and the ticket. When the two classes' name shapes are kept apart, reading
the list shows which one will close someday. The distinction here is set up only at the
naming level; comparing branching strategies is left to the Merging topic's final
lesson.

The measurement's assumptions:

- **BR15** — The setup is the shared definition's twelve commits and is not changed. A
  naming convention is a layer added on top of the setup; it does not touch the
  definition of the six questions.
- **BR16** — The measurement uses the first question with the shared definition's
  `question1_grouping` function: it checks whether every commit in history carries a
  **branch record**. This lesson does not measure the other five questions; they are
  measured in the Merging topic.
- **BR17** — Two integration formats are compared: merge commit keeps the branch
  record, fast-forward does not. The formats' definitions are taken from the shared
  definition and not changed.
- **BR18** — Four naming conventions are tried: free-form, short name, by type, and by
  type and ticket. Each convention gives three names to three branches.
- **BR19** — The **work readable from name** criterion is a comparison against the
  oracle: it counts as readable if the setup's name for the work appears inside the
  name. The **type readable from name** criterion looks for the part before the slash
  to be in the known type list; a word with no separator does not count as a type. The
  **ticket** criterion looks for a numeric field among the name's parts.
- **BR20** — The **group is readable** criterion is the union of two conditions:
  history keeps the branch record, **and** all three of the three names give the work.
  If either condition fails, the group is not readable.
- **BR21** — Name validity is tested with a subset of the tool's rule set; the
  measurement is not a parser, it is a count showing which candidate the rules reject,
  and why.

#### Measurement

```python
"""Branch naming convention: keeping a branch record and having a readable name are separate conditions.

Part 1 - four naming conventions, four criteria: what is readable from a name.
Part 2 - format x convention cross: is the first question actually readable.
Part 3 - name validity and prefix clashes.
"""
SEED = 20260813
BRANCHES = ("metrics", "report", "identity")
FILES = {"metrics": "metrics.py", "report": "report.py", "identity": "identity.py"}
BUGGY = ("report", 2)
SHARED_FILE = "config.py"
TYPES = ("feature", "fix", "maintenance")
CONVENTIONS = {
    "free-form": {"metrics": "new", "report": "trial", "identity": "fix"},
    "short name": {"metrics": "metrics", "report": "report", "identity": "identity"},
    "by type": {"metrics": "feature/metrics", "report": "feature/report",
                "identity": "feature/identity"},
    "by type and ticket": {"metrics": "feature/214-metrics",
                           "report": "feature/215-report",
                           "identity": "feature/216-identity"}}
FORMATS = ("merge commit", "fast-forward")


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, fmt):
    t = []
    if fmt == "merge commit":
        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})
    elif fmt == "fast-forward":
        for k in sorted(record, key=lambda x: x["time"]):
            t.append({**k, "branch_record": None, "time_kept": True})
    return t


def question1_grouping(t):
    """The shared definition's first question; its definition is not changed."""
    return all(x.get("branch_record") for x in t if not x.get("merge"))


def gives_work(name, branch):
    return branch in name


def gives_type(name):
    return "/" in name and name.split("/")[0] in TYPES


def gives_ticket(name):
    return any(p.isdigit() for p in name.replace("/", "-").split("-"))


def filters(name, prefix="feature/"):
    return name.startswith(prefix)


FORBIDDEN = set(" ~^:?*[\\")


def valid(name):
    if not name or name.startswith("/") or name.endswith("/") or "//" in name:
        return "slash position"
    if name.startswith("-"):
        return "starts with a dash"
    if any(c in FORBIDDEN for c in name):
        return "forbidden character"
    if ".." in name or "@{" in name or name.endswith(".lock"):
        return "forbidden sequence"
    if any(p.startswith(".") for p in name.split("/")):
        return "part starting with a dot"
    return "valid"


def prefix_clash(names):
    return [(a, b) for a in names for b in names
            if a != b and b.startswith(a + "/")]


record = development()
T = {b: integrate(record, b) for b in FORMATS}
print(f"setup: {len(record)} commits, {len(BRANCHES)} branches; a naming convention "
      f"changes neither the setup nor the definition of the six questions")
print()
print(f"{'convention':<22s}{'work in name':>13s}{'type in name':>13s}"
      f"{'ticket':>9s}{'filtered by prefix':>19s}")
for name, mapping in CONVENTIONS.items():
    print(f"  {name:<20s}{sum(gives_work(mapping[d], d) for d in BRANCHES):9d}/3"
          f"{sum(gives_type(mapping[d]) for d in BRANCHES):9d}/3"
          f"{sum(gives_ticket(mapping[d]) for d in BRANCHES):7d}/3"
          f"{sum(filters(mapping[d]) for d in BRANCHES):19d}")
print()
print(f"{'format':<24s}{'convention':<20s}{'branch record':<14s}{'work in name':>13s}"
      f"{'group readable':>16s}")
readable = 0
for f in FORMATS:
    has_record = question1_grouping(T[f])
    for name, mapping in CONVENTIONS.items():
        n = sum(gives_work(mapping[d], d) for d in BRANCHES)
        g = has_record and n == len(BRANCHES)
        readable += g
        print(f"  {f:<22s}{name:<20s}{('yes' if has_record else 'no'):<14s}"
              f"{(str(n) + '/3') if has_record else '-':>13s}"
              f"{('yes' if g else 'no'):>16s}")
print(f"first question: branch record present in "
      f"{sum(question1_grouping(T[f]) for f in FORMATS)} / {len(FORMATS)} formats; "
      f"group readable in {readable} / {len(FORMATS) * len(CONVENTIONS)} rows")
print()
CANDIDATES = ("feature/metrics", "feature metrics", "feature/metrics~1",
              "feature/..metrics", "feature/metrics.lock", "feature//metrics",
              "-metrics", "feature/metrics@{1}")
print(f"{'candidate name':<26s}result")
for a in CANDIDATES:
    print(f"  {a:<24s}{valid(a)}")
NAMES = ("feature", "feature/metrics", "feature/report", "feature/identity")
print(f"\nprefix clash: {len(prefix_clash(NAMES))} pairs — "
      f"a branch named feature cannot coexist with the three branches under feature/")
```

```
setup: 12 commits, 3 branches; a naming convention changes neither the setup nor the definition of the six questions

convention             work in name type in name   ticket filtered by prefix
  free-form                   0/3        0/3      0/3                  0
  short name                  3/3        0/3      0/3                  0
  by type                     3/3        3/3      0/3                  3
  by type and ticket          3/3        3/3      3/3                  3

format                  convention          branch record  work in name  group readable
  merge commit          free-form           yes                     0/3              no
  merge commit          short name          yes                     3/3             yes
  merge commit          by type             yes                     3/3             yes
  merge commit          by type and ticket  yes                     3/3             yes
  fast-forward          free-form           no                        -              no
  fast-forward          short name          no                        -              no
  fast-forward          by type             no                        -              no
  fast-forward          by type and ticket  no                        -              no
first question: branch record present in 1 / 2 formats; group readable in 3 / 8 rows

candidate name            result
  feature/metrics         valid
  feature metrics         forbidden character
  feature/metrics~1       forbidden character
  feature/..metrics       forbidden sequence
  feature/metrics.lock    forbidden sequence
  feature//metrics        slash position
  -metrics                starts with a dash
  feature/metrics@{1}     forbidden sequence

prefix clash: 3 pairs — a branch named feature cannot coexist with the three branches under feature/
```

#### Reading the Numbers

The top table compares the four conventions field by field. Free-form names give zero
on all four criteria: which of the names `new`, `trial`, and `fix` belongs to which
piece of work cannot be worked out. The third name was chosen deliberately — `fix`
looks like a type name but does not count as one because it carries no separator, and
the work it stands for is `identity`, meaning the name does not just stay silent, it
misdirects.

The short name convention takes the first column to **3/3**, and this alone earns the
biggest jump. The type prefix opens the second column and raises the **filtered by
prefix** column from **0** to **3**; the condition for filtering is not that the name
gives the work, but that it carries a shared prefix. The ticket field exists only in
the fourth convention, and it opens only the third column. Each field opens one column,
none opens two: the fields do not substitute for each other.

The middle table is the lesson's main measure. The group is readable in **3** of the
eight rows, and the five unreadable rows are unreadable for two separate reasons.

Four rows drop out from **the absence of a branch record**. In the fast-forward format,
all four naming conventions give the same result, because the name never enters history
at all: a branch name only exists as long as the branch lives, it vanishes when the
branch is deleted, and no trace of it remains in history. Even the most careful naming
convention makes zero contribution in a format that keeps no record.

The fifth row drops out from **the name saying nothing**. In the merge commit format, a
branch record exists; history distinguishes the three groups, and `question1_grouping`
says yes on this row too. But the name the record carries is `new`, `trial`, and `fix`.
The record's existence gives the group's **boundary**, its name gives the group's
**name**; without the second, history says "these twelve commits split into three
groups" and does not say what the groups are. This is exactly what the measurement
says: a branch record exists in **1/2** formats, but the group is readable in **3/8**
rows.

In an eight-row set, the smallest measurable difference is **1/8**. The cost of losing
the record is **4/8**, the cost of losing the name is **1/8**; the two are not the same
size, and their ranking cannot be reversed. In a format that keeps no record, arguing
about a naming convention has no counterpart; in a format that keeps one, the
convention's cost is a single row, and if that row is not paid, the record itself goes
half to waste.

The bottom table gives the name's format rules. **1** of eight candidates is valid,
**7** are rejected for four separate reasons. None of the rejections is a typo; each
sits at a point where the name clashes with either the shell or the tool's own
reference syntax. The **3** clashes in the last line are the only constraint that comes
from inside the convention itself: using a prefix as a branch name makes every name
under that prefix impossible.

#### Summary

- A branch name is both a path under `refs/heads` and a declaration for a human; the
  slash sets up a hierarchy level in this path and makes filtering by pattern possible.
- A naming convention can carry three fields — type, ticket, short name — and in the
  measurement each field opens only one criterion: the fields do not substitute for
  each other.
- The group is readable in **3** of the eight combinations; of the five rows that drop
  out, **4** drop from the absence of a branch record, **1** from the name saying
  nothing.
- In an integration format that keeps no record, the name never enters history and a
  naming convention's contribution is **0**; in a format that keeps one, a disorderly
  name wastes half of the record.
- **1** of eight candidate names is valid; the rejected ones clash with shell marks and
  the tool's own reference syntax, and when a prefix is used as a branch name, the
  **3** names under it are blocked at once.

#### Next Step

A name is what ties a set of commits to a piece of work; the record carries its
boundary, the convention carries its meaning. Both depend on `HEAD` pointing to a
branch name — the chain built in the previous lessons had three links: `HEAD` points to
a branch name, the branch name points to a commit ID, the commit points to its own
parent. What if the middle link is taken out? When `HEAD` points directly to a commit
ID instead of a branch name, commits made there attach to no branch. The next lesson
measures how this state arises, which of the six questions a commit made there falls
outside of, and how history keeps answering even though it does not know what it has
lost.

### Detached HEAD State

Source: https://academia.sh/en/courses/branching-and-collaboration/detached-head-state

When two commits made on a detached HEAD are reachable from no reference, history drops from 15 records to 13 and answers 4 of the six questions instead of 5; the real cost is that all three counting queries are answered incompletely, and history does not report it.

The previous three lessons built a three-link chain between `HEAD` and a commit: `HEAD`
points to a branch name, the branch name points to a commit ID, the commit points to
its own parent. Working on a branch depends on this chain being complete — when a
commit is made, the middle link advances, `HEAD` stays in place, and the new commit
ends up bound to a name.

The middle link is not always in place. The tool recognizes a state where `HEAD` points
directly to a commit ID, and this is called **detached HEAD**. This lesson measures how
that state arises, where a commit made there attaches, and what history loses when a
link of the chain is missing. The measurement's real result is not the amount lost; it
is that history does not **know** it lost anything.

#### How HEAD Detaches

The detached state is not a defect or corruption; it is the tool's way of satisfying a
specific request. The request is: stand not at a branch's tip, but at a particular point
in history. When you switch to a commit ID, a tag, or a remote branch tip, `HEAD`
cannot bind to a name, because the target is not a name.

```text
# taught command and example dump — not executed

$ git switch --detach 7c3f9d2
HEAD is now at 7c3f9d2 Format report heading
$ git status --short --branch
## HEAD (no branch)
$ git symbolic-ref HEAD
fatal: ref HEAD is not a symbolic ref
```

The last line is the definition of the state. In the first lesson, the same command
answered `refs/heads/main`: `HEAD` was pointing to a reference. Here there is no
reference to point to, so the command cannot find a symbolic reference. `git status`
says the same thing in different words.

The state can be chosen deliberately. Moving back and forth through history while
searching for which commit a bug entered on, building and testing an old version,
inspecting the tree a tag points to — all of these are done in the detached state, and
none of them is a problem. The problem starts with **committing** in the detached
state.

#### A Commit Made in the Detached State

Committing in the detached state is not blocked. The commit object is written, its
parent is correctly linked, its tree is recorded; in terms of content, it is no
different from any other commit. The only thing that changes is who advances: `HEAD`
points directly to the new ID, and **no branch reference is written**.

The consequence is not yet visible at the second commit, because the `HEAD` chain still
holds those commits. The consequence is born the moment you switch to a different
branch. `HEAD` now points to a different branch, and the commits written in the
detached state are reachable from no reference. The tool reports this at the moment of
switching.

```text
# taught command and example dump — not executed

$ git switch report
Warning: you are leaving 2 commits behind, not connected to
any of your branches:

  b71c05e report: move threshold table into config
  a04e9d2 report: add summary line

If you want to keep them by creating a new branch, this may be a good time
to do so with:

 git branch <new-branch-name> b71c05e

Switched to branch 'report'
```

The warning gives both the count and the ID, and it also states the fix: write a
branch. The first lesson's measurement gave the cost of this suggestion — a branch
reference is **41 bytes**. Binding two commits to history is writing forty-one bytes,
and this is why the way out is cheap.

There are three ways out of the detached state. `git switch -c name` writes a branch at
the point you are standing on and switches to it; this is the way that rescues the work
in place. `git switch <branch>` returns to a branch and leaves the detached commits
behind. The third is retroactive: a reference can also be written afterward with `git
branch name <id>` — as long as the ID is still known.

#### The Reflog

The way to know the ID is the **reflog**. The tool writes **every value** `HEAD` and
every branch reference ever took to a local log: which commit was switched to, which
commit was made, which reference moved where. The log is local and does not travel with
the repository — a cloned repository does not get the source repository's reflog.

```text
# taught command and example dump — not executed

$ git reflog
b71c05e HEAD@{0}: commit: report: move threshold table into config
a04e9d2 HEAD@{1}: commit: report: add summary line
7c3f9d2 HEAD@{2}: checkout: moving from report to 7c3f9d2

$ git branch rescue b71c05e
$ git branch --contains b71c05e
  rescue
```

The `HEAD@{0}` syntax names `HEAD`'s previous positions and can be used anywhere a
commit ID is expected. The third line records the detachment itself; the first two
record the two commits made in the detached state. After the branch is written, the
last command confirms the commit is now reachable from a reference.

The reflog itself is not a reference and does not enter the reachability computation.
It is a recovery tool, not a storage mechanism: entries have a limited lifetime and
expire after a configurable period; entries belonging to unreachable commits have a
shorter lifetime than the others.

#### The Boundary That Cannot Be Recovered

Once a reflog entry expires, no record remains to give an unreachable commit's ID. The
object is still in the database, but it has no name and nowhere to be looked up.

One step past this is permanent. The tool has a maintenance command that collects
objects reachable from no reference; this command's pruning option **deletes
unreachable objects from the database**. This lesson does not write that call in
executable form, because its result is **irreversible**: a deleted commit's content
cannot be brought back from anywhere, and the reflog cannot restore a deleted object
either. The maintenance command can also run on its own as part of certain operations;
this is why leaving an unreachable commit for "I'll recover it later" is not safe.

The safe habit is one sentence: **write the reference first.** If work is going to be
done in the detached state, a branch is opened before starting; if it is noticed after
the work is done, a backup branch is written while the reflog is still fresh.
Recovery's entire cost is forty-one bytes, and as long as that byte count is not
written, the work is a single maintenance call away from gone.

The measurement's assumptions:

- **BR22** — The setup is the shared definition's twelve commits, and `development` is
  not changed. The lesson changes a single step of the setup: the `report` work's
  second and third commits were made on a detached HEAD and are not bound to a branch
  tip.
- **BR23** — Reachability starts from references. If a commit cannot be reached from
  any branch reference, that commit is outside history; sitting in the object database
  does not change this.
- **BR24** — The measurement uses a single integration format: merge commit. Its
  definition is taken from the shared definition and not changed; this lesson does not
  compare formats.
- **BR25** — The definition of the six questions is not changed, and they are asked
  only of the reachable history. A question cannot know about a commit it was never
  given.
- **BR26** — Three **counting queries** are also asked and compared against the
  oracle: the number of commits belonging to the `report` work, the number of commits
  that introduced the bug, the number of commits touching the shared file. The oracle
  knows the setup and gives the true count.
- **BR27** — The reflog is not a reference and does not enter the reachability
  computation; it only serves to find an unreachable commit's ID.
- **BR28** — Recovery is binding the dropped commits to a reference. The measurement
  builds the recovered history by putting the dropped ones back and ordering them by
  time; recovery changes neither the commits' content nor their order.
- **BR29** — Recovery's cost is a single branch reference: forty hex digits and a line
  ending, **41 bytes**. The first lesson's measure applies here exactly as it was.

#### Measurement

```python
"""Detached HEAD: a commit reachable from no reference is outside history.

Part 1 - two commits made in the detached state drop out of history.
Part 2 - six questions asked of three histories: which question falls outside.
Part 3 - counting queries compared against the oracle: answered, but incompletely.
"""
SEED = 20260813
BRANCHES = ("metrics", "report", "identity")
FILES = {"metrics": "metrics.py", "report": "report.py", "identity": "identity.py"}
BUGGY = ("report", 2)
SHARED_FILE = "config.py"
DETACHED = ("report", (2, 3))     # these two commits were made on a detached HEAD
REF_BYTES = 40 + 1


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, fmt):
    t = []
    if fmt == "merge commit":
        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):
    buggy = [x for x in t if x["buggy"]]
    return len(buggy) == 1 and not buggy[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 reachable(record, dropped):
    branch, steps = dropped
    return [k for k in record if not (k["branch"] == branch and k["step"] in steps)]


def recover(remaining, dropped_list):
    """Commits found via the reflog are bound to a reference."""
    return sorted(remaining + dropped_list, key=lambda x: x["time"])


def query(group):
    """Three counting queries asked of history or the setup."""
    return {"commits belonging to report": sum(1 for x in group
                                               if x["branch"] == "report"
                                               and not x.get("merge")),
            "commits that introduced the bug": sum(1 for x in group if x["buggy"]),
            "commits touching config.py": sum(1 for x in group
                                              if x["file"] == SHARED_FILE)}


record = development()
remaining = reachable(record, DETACHED)
dropped = [k for k in record if k["branch"] == DETACHED[0] and k["step"] in DETACHED[1]]
HISTORIES = (("attached HEAD", record), ("detached HEAD", remaining),
             ("recovered", recover(remaining, dropped)))
T = {name: integrate(k, "merge commit") for name, k in HISTORIES}

print(f"setup: {len(record)} commits; {len(dropped)} commits made on a detached HEAD "
      f"are reachable from no reference")
print()
print(f"{'history':<16s}{'reachable commits':>18s}{'history entries':>17s}"
      f"{'unreachable':>13s}{'answered':>10s}")
for name, k in HISTORIES:
    print(f"  {name:<14s}{len(k):18d}{len(T[name]):17d}"
          f"{len(record) - len(k):13d}"
          f"{sum(q(T[name]) for _, q in QUESTIONS):10d}")
print()
HEADER = (("attached HEAD", 14), ("detached HEAD", 14), ("recovered", 12))
print(f"{'question':<20s}" + "".join(f"{a:>{w}s}" for a, w in HEADER))
for name, q in QUESTIONS:
    print(f"  {name:<18s}" + "".join(
        f"{('yes' if q(T[a]) else 'no'):>{w}s}" for a, w in HEADER))
print()
oracle = query(record)
detached_q, recovered_q = query(T["detached HEAD"]), query(T["recovered"])
print(f"{'counting query':<32s}{'oracle':>7s}{'detached HEAD':>14s}{'missing':>9s}"
      f"{'recovered':>11s}")
for s in oracle:
    print(f"  {s:<30s}{oracle[s]:7d}{detached_q[s]:14d}{oracle[s] - detached_q[s]:9d}"
          f"{recovered_q[s]:11d}")
print(f"\n{sum(1 for s in oracle if oracle[s] != detached_q[s])} of three queries are "
      f"answered incompletely; history does not report the shortfall")
print(f"recovery cost: to bind {len(dropped)} commits back, "
      f"1 reference, {REF_BYTES} bytes")
```

```
setup: 12 commits; 2 commits made on a detached HEAD are reachable from no reference

history          reachable commits  history entries  unreachable  answered
  attached HEAD                 12               15            0         5
  detached HEAD                 10               13            2         4
  recovered                     12               15            0         5

question             attached HEAD detached HEAD   recovered
  feature grouping             yes           yes         yes
  bug isolation                yes            no         yes
  true order                   yes           yes         yes
  file trail                   yes           yes         yes
  branch integrity             yes           yes         yes
  conflict decision             no            no          no

counting query                   oracle detached HEAD  missing  recovered
  commits belonging to report         4             2        2          4
  commits that introduced the bug      1             0        1          1
  commits touching config.py          2             1        1          2

3 of three queries are answered incompletely; history does not report the shortfall
recovery cost: to bind 2 commits back, 1 reference, 41 bytes
```

#### Reading the Numbers

The top table gives the size of the loss. Reachable commits fall from **12** to **10**,
entries in history from **15** to **13**; unreachable is **2**. Answered questions drop
from **5** to **4**. In a twelve-commit set, the loss is **2/12**; in a six-question
set, **1/6** — both are within the sets' resolution and defensible.

The middle table says which question drops, and the answer is a single one: **bug
isolation**. The commit that introduced the bug was the `report` work's second, and it
was one of the commits made in the detached state. History no longer sees a buggy
commit; when the question "can the commit that introduced the bug be found on its own"
is asked, the answer is no. This is the **reported** face of the loss: history says it
cannot answer a question.

None of the other five rows changes, and this lesson's real result comes from here.
**Feature grouping** is yes in all three cases; all ten of the remaining commits carry
a branch record, and history is internally consistent. **File trail** is also yes in
all three; every remaining commit carries a file name. History is not aware that it is
incomplete, because incompleteness can only be seen with an outside measure.

The bottom table supplies that outside measure. All **3** of the three counting
queries are answered incompletely. The commit count belonging to the `report` work
comes out to **2** instead of **4** — the group is readable, but half of it is missing.
The commit that introduced the bug comes out to **0** instead of **1**. Commits
touching the shared file come out to **1** instead of **2** — the file trail question
says yes, but the trail it gives is half. No row carries an error message; all three
answers look reasonable, and all three are wrong.

This is the distinction between the two kinds of loss. When a question drops, history
says no, and the reader knows it. When a commit drops, history keeps saying yes, and
the reader is left with a wrong number. The **1/6** drop in the number of answered
questions is only the visible part of the real loss.

The last two lines measure recovery. When the two dropped commits are bound to a
reference, reachable commits return to **12**, history to **15**, answered questions to
**5**, and the three counting queries match the oracle exactly. Recovery's cost is
**41 bytes**. The first lesson's measure had also said the reverse: **writing** a
reference was forty-one bytes, so not writing that reference was a forty-one-byte
shortfall too. The whole story of the detached state is these unwritten forty-one
bytes.

#### Summary

- Detached HEAD is the state where `HEAD` points directly to a commit ID instead of a
  branch name; `git symbolic-ref HEAD` fails, and the state is not a defect, it is a
  way of positioning.
- A commit made in the detached state is written normally, but no branch reference
  advances; once you switch to a different branch, those commits are reachable from no
  reference.
- **2** unreachable commits bring history down from **15** entries to **13** and drop
  answered questions from **5** to **4**; the single question that drops is bug
  isolation.
- The real cost is that all **3** of the three counting queries are answered
  incompletely: history keeps saying yes, and the numbers it gives are **2** instead of
  **4**, **0** instead of **1**, **1** instead of **2**.
- The reflog holds `HEAD`'s past positions and brings recovery down to **41 bytes**;
  once a log entry expires, object collection makes the loss permanent and
  irreversible.

#### Next Step

This topic has finished the opening side of branching. Opening a branch is a name and
writes **123** bytes; keeping the name orderly adds to history's first question what
the record alone cannot give; never writing the name at all drops two commits outside
history. All three measurements look in the same direction: what ties a set of commits
to a piece of work is the reference.

What remains is the fork's other end. Three branches were opened, twelve commits were
written, and each stands at its own tip; at some point these tips have to be brought
into the main branch. The next topic asks what merging writes to history: does the tool
produce a new record when it closes the two tips, what is lost if it does not, and how
many of the six questions does the way of closing leave answerable. From here on, the
course runs on top of this measure.

## Merging and Rebasing

The difference between fast-forward and the merge commit, conflict markers and verifying a resolution, the cost of linearizing history, moving a single commit to another branch, and a criterion-based choice among four integration formats.

### Fast-Forward and Merge Commit

Source: https://academia.sh/en/courses/branching-and-collaboration/fast-forward-and-merge-commit

Merging two branches writes either nothing to history or a commit with two parents; a twelve-commit development grows to fifteen and answers five of six questions as a merge commit, or stays at twelve and answers three as a fast-forward.

Opening a branch was cheap: a single commit ID written to a reference file. Naming
brought order: reading a branch name made clear who was developing what. Neither
operation grew history — each only added pointers to it.

This lesson's question picks up from there. At some point after two branches diverge,
they merge; what does that merging write to history? The answer is not single. The tool
has two distinct behaviors, and which one applies determines how many of the questions
asked of history months later can be answered.

#### Two Cases of Merging

Bringing a branch into main means finding the common ancestor of two commit IDs and
applying the difference between them. The tool faces two distinct cases.

**The first case:** no commit has been added to main since the branch diverged. The
branch's commits already build on top of main's last commit, and there is nothing to
merge. The tool's only job is to move main's pointer forward to the branch's tip. This is
called a **fast-forward**.

**The second case:** both sides have taken commits since diverging. History has
genuinely forked and cannot be closed by moving a single pointer. The tool merges the
two sides' content and writes the result to a new commit with **two parents**: a
**merge commit**.

```text
# taught command and example dump — not executed

$ git merge metrics
Updating 3f1a2b0..9c4d7e1
Fast-forward
 metrics.py | 24 ++++++++++++++++++++++++
 1 file changed, 24 insertions(+)

$ git merge report
Merge made by the 'ort' strategy.
 report.py | 31 +++++++++++++++++++++++++++++++
 1 file changed, 31 insertions(+)
```

The first call says `Fast-forward`: no new commit was produced. The second reports that
it merged under a strategy name: a new commit was produced. The same command, two
different results.

#### Common Ancestor and Three-Way Merge

For the tool to know which case it faces, it must first find the **common ancestor**:
the first commit reached by tracing back from both branch tips. The common ancestor is
the point where the two branches diverged. The first case's definition can be written in
terms of the common ancestor: if the common ancestor equals main's tip, main has not
moved since the branch diverged, and a fast-forward applies.

If the common ancestor is not equal to main's tip, the merge works over three file
versions: the version at the common ancestor, the version on main, and the version on
the branch. This is called a **three-way merge**, and the third version — the state at
the ancestor — is what supplies the measure. If placing two versions side by side were
enough, the tool would count every difference as a conflict; the ancestor version lets
the tool know **who** changed a given line. If only one side changed it, that side's
version is taken; if both sides changed the same line in different ways, no decision can
be made and a merge conflict is born.

The common ancestor is the result of a search, not a field written into history. This
means the cost of merging grows with branch age: the farther back the ancestor lies, the
more lines the two sides have diverged on.

#### What Fast-Forward Writes

A fast-forward writes **nothing** to history. Main's reference points to a new ID, and
that is all. The branch's commits appear in main's history exactly as they were, but no
record remains in history saying they were developed on a branch. Once the branch
pointer is deleted, the information that those commits belonged to a separate piece of
work is deleted with it.

The choice does not have to be left to the tool. The default behavior fast-forwards when
it can; the `--no-ff` option forces a two-parent commit even when a fast-forward is
possible; the `--ff-only` option does the opposite and stops without merging if a
fast-forward cannot be applied. The third looks useless at first glance, but it is a
safety lock: on a team that wants its history to stay linear, it blocks an
unintentionally produced merge commit from getting in. None of the three settings is
more correct than the others; all three are separate answers to the same question, and
this lesson's measurement asks that question.

#### What the Merge Commit Writes

The merge commit's one distinguishing feature is that it has **two parents**. The first
parent is main's current tip, the second is the tip of the branch being merged. This
second edge is the only thing carrying the branch structure of history.

```text
# taught command and example dump — not executed

$ git log --oneline --graph
*   a1b2c3d merge report branch
|\
| * 7e8f9a0 report: add summary line
| * 4d5e6f1 report: fix field width
|/
* 9c4d7e1 metrics: print threshold value
```

The drawing on the left side is produced from the second parent. The two indented lines
are the report branch's commits; the line carrying the `|/` mark shows the divergence
point. This drawing cannot be produced for a branch taken in by fast-forward, because
there is no edge to draw.

The IDs and file names here are invented. Running the same command in your own
repository gives different IDs; what stays fixed is the structure of the output.

#### Setting Up the Measure

The setup that runs through the course is this: three people start three separate pieces
of work in the same repository — `metrics`, `report`, and `identity`. Each makes four
commits. On the third step, `metrics` and `report` both touch the same file,
`config.py`. The `report` branch's second commit introduces a bug. The real development
is **twelve commits**, and its order is known, because we wrote the setup ourselves.

These twelve commits are brought into main through separate **integration formats**. All
the formats produce the same **code**; the **history** they produce differs. The course
measures this difference with a single metric: six questions are asked of the history,
and how many it can answer is counted. These are called the course's **answerable
questions**, and their definitions do not change throughout the course.

1. **Feature grouping** — which commits belong to which piece of work?
2. **Bug isolation** — can the commit that introduced the bug be found on its own?
3. **True order** — in what order was the work developed?
4. **File trail** — can every commit that touched a file be found individually?
5. **Branch integrity** — do a branch's commits stay together?
6. **Conflict decision** — where was the conflict resolved, and which side was chosen?

These six questions are the course's constant; their definitions do not change in later
lessons, only their answers do.

The measurement's assumptions:

- **MR1** — The real development is produced by the setup; its order and buggy commit
  are known, because we wrote the setup. History is not required to know this.
- **MR2** — Fast-forward represents the case where no commit was added to main after the
  branch diverged: commits are arranged in real time order and **no branch record is
  kept**.
- **MR3** — A merge commit adds one closing commit per branch; this commit touches no
  file and carries no bug, it carries only the **branch record**.
- **MR4** — A question being answerable means the record giving that answer is **found**
  in history; how much effort the reader spends does not enter the measurement.
- **MR5** — The sixth question is measured by searching history for a field carrying the
  chosen side of a conflict. This lesson does not resolve a conflict; the field does not
  exist, and in this lesson the question goes unanswered for both formats.
- **MR6** — The measurement does not assume it produces code; the two formats produce
  identical **code**, and only history is measured.
- **MR7** — The set's resolution is **1/12** at twelve commits and **1/6** at six
  questions; a difference smaller than this cannot be defended with this setup.

#### Measurement

```python
"""Two integration formats: merge commit and fast-forward.

Part 1 - real development: three branches, four commits per branch.
Part 2 - six questions asked of the history the two formats leave behind.
"""
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 == "merge commit":
        for branch in BRANCHES:
            for k in [x for x in record if x["branch"] == branch]:
                t.append({**k, "branch_record": branch, "time_preserved": 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_commit": True, "time_preserved": True})
    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 = ("merge commit", "fast-forward")
record = development()
T = {f: integrate(record, f) for f in FORMATS}

print(f"real development: {len(record)} commits, {len(BRANCHES)} branches, shared file "
      f"touched by {sum(1 for k in record if k['file'] == SHARED_FILE)}, "
      f"buggy commit {sum(1 for k in record if k['buggy'])}")
print()
print(f"{'format':<22s} {'commits':>7s} {'from development':>17s} {'added':>7s}")
for f in FORMATS:
    added = sum(1 for x in T[f] if x.get("merge_commit"))
    print(f"{f:<22s} {len(T[f]):7d} {len(T[f]) - added:17d} {added:7d}")
print()
print(f"{'question':<20s} {'merge commit':>14s} {'fast-forward':>13s}")
for name, question in QUESTIONS:
    print(f"{name:<20s} {('yes' if question(T[FORMATS[0]]) else 'no'):>14s}"
          f" {('yes' if question(T[FORMATS[1]]) else 'no'):>13s}")
print(f"{'answered':<20s} {sum(s(T[FORMATS[0]]) for _, s in QUESTIONS):14d}"
      f" {sum(s(T[FORMATS[1]]) for _, s in QUESTIONS):13d}")
```

```
real development: 12 commits, 3 branches, shared file touched by 2, buggy commit 1

format                 commits  from development   added
merge commit                15                12       3
fast-forward                12                12       0

question               merge commit  fast-forward
feature grouping                yes            no
bug isolation                   yes           yes
true order                      yes           yes
file trail                      yes           yes
branch integrity                yes            no
conflict decision                no            no
answered                          5             3
```

#### What Three Commits Buy

The top table compares the two formats by commit count. The merge commit grows history
to **15** commits: twelve from the real development, **3** closing commits added by the
tool. Fast-forward stays at **12** and adds nothing.

Intuition here favors fast-forward: same work, fewer records. The bottom table says the
opposite. The merge commit answers **5** of the six questions, fast-forward answers
**3**. Three extra commits buy two extra answers.

Which two answers are bought? **Feature grouping** and **branch integrity**. Both arise
from the same loss: fast-forward keeps no branch record. Commits are arranged in real
time order, so the three pieces of work's commits interleave — the `metrics` branch's
four commits do not sit consecutively on main, the other two branches' commits fall
between them. History cannot answer "which piece of work did this commit belong to,"
because the record naming the work was never written.

Four are shared: **bug isolation**, **true order**, and **file trail** are yes in both
formats. The buggy commit stands alone in both histories; no format merged it with
another. Timestamps were preserved too. These three answers need no extra record.

**Conflict decision** is no in both formats. `metrics` and `report` touch the same file
on the third step, and this gives rise to a **merge conflict**; but neither history
carries a record of how the conflict was resolved. What is worth noting: the merge
commit carries **where** the conflict happened — the resolution is written in that
commit — but it does not keep **which side was chosen** as a separate field. The
measurement checks for this distinction by searching for a field, and the field is
absent in both formats. This result holds for all four formats, and the next lesson pays
for it.

#### What the Choice Depends On

The difference between the two formats is not right versus wrong, it is a trade-off. In
the measured set the difference is **2/6**: two of six questions. Since the set's
resolution is **1/6**, this difference is twice the measurement band and is defensible.

The price paid is **3/12**: three commits are added to a twelve-commit development. This
price grows linearly as the branch count grows, because every branch adds one closing
commit. In a twelve-branch integration, a quarter of history consists of closing
commits, and the log output counts closings instead of showing the real work.

The criterion is this: **will the branch record be queried later?** In a single-commit
fix it is not, and the fast-forward loss is zero. In a four-commit feature branch it is;
there the three-commit price is paid for the answers to two questions.

The three added commits carry a second benefit as well. The merge commit's **first
parent** is main's own line; when the log is called to follow only first parents,
history shrinks from the measurement's twelve commits to three closing commits. The same
history becomes readable at two separate resolutions: three lines for the question of
what was done, twelve lines for the question of how it was done. Fast-forward has no
second resolution, because there is no edge to separate by — every reading gives twelve
lines.

```text
# taught command and example dump — not executed

$ git log --oneline --first-parent
a1b2c3d merge report branch
b2c3d4e merge metrics branch
9c4d7e1 initial setup
```

Both of these resolutions rest on the branch record; without the record, the two
collapse into each other and the distinction cannot be established.

#### Summary

- Fast-forward applies when main has not advanced since the divergence point, and it
  writes no commit to history; it only moves the branch pointer forward.
- A merge commit is a commit with two parents; the second parent is the only record
  carrying history's branch structure, and the log's drawing is produced from it.
- A twelve-commit development grows to **15** commits under a merge commit and stays at
  **12** under fast-forward; the **3** added commits are one closing commit per branch.
- The merge commit answers **5** of the six questions, fast-forward answers **3**; the
  two questions that make the difference are **feature grouping** and **branch
  integrity**.
- Bug isolation, true order, and file trail are answered in both formats; conflict
  decision goes unanswered in both.

#### Next Step

In the measurement, `metrics` and `report` touched the same file, and neither history
recorded how that was resolved. The next lesson looks at the resolution itself: what the
tool writes to the file when it cannot merge the two sides, how conflict markers are
read, how the resolution is verified to be correct — and, once resolved, exactly what is
written to history and what is not.

### Conflict Resolution

Source: https://academia.sh/en/courses/branching-and-collaboration/conflict-resolution

When the tool cannot merge two sides, it writes conflict markers to the file; the resolution is given by hand, but none of the four integration formats records the chosen side, and the resolution's rationale stands nowhere unless it is written into the commit message.

In the previous lesson's measurement, the `metrics` and `report` branches touched the
same file on the third step. Both integration formats brought that file into main with
the correct result, but neither history recorded how the conflict was resolved. In the
measurement, this meant the sixth question went unanswered in both formats.

This lesson looks at that resolution itself: what the tool writes to the file when it
cannot merge the two sides, how the marks it writes are read, how a given resolution is
verified to be correct — and, once the work is done, exactly what is written to history
and what is not.

#### Where the Tool Cannot Decide

A three-way merge reads three versions: the state at the common ancestor, the state on
main, the state on the incoming branch. If only one side changed a line, the decision
makes itself — the changed side is taken. A conflict is born where **both sides changed
the same line differently from the ancestor version**. The tool does not produce a
preference here; both changes are intentional, and which one is correct can only be said
by knowing the meaning of the work.

Conflicts do not arise only from line content. If one side deleted the file while the
other modified it, if one side renamed it while the other wrote to the same path, or if
both sides created a new file at the same path with different content, a decision cannot
be made either. What these cases share is this: comparison with the ancestor version
shows a change on both sides, and the two cannot be applied together.

A conflict is not an error. It is the tool saying "the decision is not mine here"; the
merge is stopped, the working area is left half-done, and the decision is handed to a
human.

#### Conflict Markers

In handing over the decision, the tool does not leave the file empty: it writes both
sides' content into the file and places separators between them. These are called
**conflict markers**.

```text
# taught command and example dump — not executed

$ git merge report
Auto-merging config.py
CONFLICT (content): Merge conflict in config.py
Automatic merge failed; fix conflicts and then commit the result.

$ cat config.py
<<<<<<< HEAD
THRESHOLD = 40
||||||| common ancestor
THRESHOLD = 25
=======
THRESHOLD = 25
UNIT = "mm"
>>>>>>> report
```

The section between `<<<<<<<` and `|||||||` is **the side you are on**: the branch on
which you started the merge, that is, `HEAD`. Between `|||||||` and `=======` is the
state **at the common ancestor**. Between `=======` and `>>>>>>>` is **the incoming
side**. This three-part notation is not the default; the two-part notation writes no
ancestor version and shows only the two sides. The ancestor section supplies the
measure: in the dump above, the ancestor says `THRESHOLD = 25`, meaning the side that
**changed** the threshold value is `HEAD`, and the side that left the line as it was and
added a new line beneath it is `report`. Without this information, the two sides look
equal and the choice is made blind.

The markers are written into the file itself, the file remains in the working area, and
it does not compile in that state. In the tool's own record, meanwhile, the same
conflict sits as three separate **stages**: ancestor, the side you are on, and the
incoming side. Seeing the three versions separately before editing the file by hand is
done from these stages.

```text
# taught command and example dump — not executed

$ git status --short
UU config.py
M  metrics.py

$ git diff --base config.py
$ git diff --ours config.py
$ git diff --theirs config.py
```

`UU` shows that both sides changed the same path; delete and rename conflicts are marked
with other letter pairs. The three `diff` calls compare the file in the working area
against the ancestor, the side you are on, and the incoming side, in turn.

#### Giving and Verifying the Resolution

Resolving means bringing the file to its intended final state and deleting the markers.
The result can be identical to one of the two sides, or it can be a hand-written third
content different from both — a state where, as in the setup, both the new threshold
and the new unit are present. Once the file is prepared, adding it to the staging area
means, in the tool's eyes, "this path is resolved"; when every path is resolved, the
merge commit is written.

The tool also offers a shortcut: filling the conflicting path entirely with one side's
version. This shortcut is appropriate for machine-generated files, because there one
side can be regenerated. In a hand-written file, the shortcut silently drops all of the
other side's changes to that file; the dropped change becomes invisible in the file and,
if tests do not cover that side, gives no sign anywhere.

Verification has three separate layers, and all three are required.

**The first is mechanical:** making sure no marker remains in the file. A remaining
`<<<<<<<` line produces a syntax error in most languages, but it can sit silently in
formatted text or data files. Searching the tree for the marker sequence catches this.

**The second is integrity:** seeing that the resolved file carries both sides' intent.
This is the narrowly-viewed danger of conflicts — the person doing the merge has usually
written only one of the two sides and does not know what the other was trying to do. The
choice is therefore less about merging two sides' code than reconciling two intentions.

**The third is behavioral:** running the tests. A resolved file can compile and still be
wrong: a state where each side is separately correct but the combination is wrong is
entirely ordinary. Conflict resolution is exactly where tests are most needed.

The safe way to back out of a resolution is to **abort** the merge; the tool carries a
separate option for this and returns the working area to its state before the merge.
Aborting and starting over is always cheaper than trying to clean up a half-finished
merge by hand.

The tool also has an ability to remember resolutions: when the same conflict appears
again later, it applies the previous resolution on its own. This ability saves effort in
the case where a long-lived branch produces the same conflict repeatedly. What matters
for the measurement is this: the resolution it records sits in a **local directory of
the repository**, is not written to history, and is not shared with anyone. This is the
first place where resolution information lives outside history.

The measurement's assumptions:

- **MR8** — The conflict arises from the `metrics` and `report` branches touching the
  same file on the third step. The setup knows this; history is not required to know it.
- **MR9** — The measurement sets up all four integration formats. The formats'
  definitions are the definitions from the shared setup and are not changed in this
  lesson.
- **MR10** — The sixth question is measured by searching history for a field carrying
  the chosen side. What is measured is the field's **presence**, not its name.
- **MR11** — A resolution record consists of three fields: the chosen side, the
  rationale, and the role that made the decision. None of the three is a field the tool
  writes on its own.
- **MR12** — Under squash, a commit's file field is not a single name but a list of
  names: it can be read that the shared file was touched, but which commit touched it
  cannot be read individually.
- **MR13** — Writing the rationale into the commit message is a **single** change in the
  measurement: three fields are added to the merge commit, and nothing else changes.
- **MR14** — The set's resolution is **1/12** at twelve commits and **1/6** at six
  questions.

#### Measurement

```python
"""What resolving a conflict writes to history.

Part 1 - where the two commits causing the conflict stand in four formats.
Part 2 - the sixth question is checked for in all four formats.
Part 3 - the one change: the resolution's rationale is written into a commit message.
"""
SEED = 20260813
BRANCHES = ("metrics", "report", "identity")
FILES = {"metrics": "metrics.py", "report": "report.py", "identity": "identity.py"}
BUGGY = ("report", 2)
SHARED_FILE = "config.py"
CONFLICT_FIELDS = ("chosen", "rationale", "decided_by")


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 == "merge commit":
        for branch in BRANCHES:
            for k in [x for x in record if x["branch"] == branch]:
                t.append({**k, "branch_record": branch, "time_preserved": 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_commit": True, "time_preserved": True})
    elif 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 == "squash":
        for branch in BRANCHES:
            group = [x for x in record if x["branch"] == branch]
            t.append({"branch": branch, "step": 0,
                      "file": sorted({x["file"] for x in group}),
                      "buggy": any(x["buggy"] for x in group),
                      "time": max(x["time"] for x in group), "branch_record": branch,
                      "time_preserved": False, "squashed": len(group)})
    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 q4_file(t):
    return all(isinstance(x["file"], str) or x.get("merge_commit") for x in t)


def q6_conflict(t):
    return any("chosen" in x for x in t)


def touched(t, name):
    """Count of commits in history that can be read as touching file `name`."""
    return sum(1 for x in t if x["file"] == name
               or (isinstance(x["file"], list) and name in x["file"]))


def separately_readable(t, name):
    """Commits readable as a single commit that touches only that file."""
    return sum(1 for x in t if x["file"] == name)


def write_to_message(t):
    """The one change: the resolution's rationale is written into a commit message."""
    y = [dict(x) for x in t]
    for x in y:
        if x.get("merge_commit") and x["branch"] == "report":
            x.update({"chosen": "metrics side", "rationale": "threshold field",
                      "decided_by": "merger"})
    return y


FORMATS = ("merge commit", "rebase", "squash", "fast-forward")
record = development()
T = {f: integrate(record, f) for f in FORMATS}
conflicting = [(k["branch"], k["step"]) for k in record if k["file"] == SHARED_FILE]

print(f"real development: {len(record)} commits | commits touching {SHARED_FILE}: "
      f"{len(conflicting)}: {conflicting}")
print()
print(f"{'format':<24s} {'commits':>7s} {'touched':>7s} {'separate':>8s}"
      f" {'file trail':>10s} {'conflict decision':>18s}")
for f in FORMATS:
    print(f"{f:<24s} {len(T[f]):7d} {touched(T[f], SHARED_FILE):7d}"
          f" {separately_readable(T[f], SHARED_FILE):8d}"
          f" {('yes' if q4_file(T[f]) else 'no'):>10s}"
          f" {('yes' if q6_conflict(T[f]) else 'no'):>18s}")
print(f"formats answering the sixth question: "
      f"{sum(q6_conflict(T[f]) for f in FORMATS)}/{len(FORMATS)}")
print()
M = write_to_message(T["merge commit"])
print(f"{'history':<36s} {'carrying a resolution field':>27s} {'conflict decision':>18s}")
for name, t in (("what the tool wrote", T["merge commit"]),
              ("rationale written to commit message", M)):
    carrying = sum(1 for x in t if any(a in x for a in CONFLICT_FIELDS))
    print(f"{name:<36s} {f'{carrying}/{len(t)}':>27s}"
          f" {('yes' if q6_conflict(t) else 'no'):>18s}")
```

```
real development: 12 commits | commits touching config.py: 2: [('metrics', 3), ('report', 3)]

format                   commits touched separate file trail  conflict decision
merge commit                  15       2        2        yes                 no
rebase                        12       2        2        yes                 no
squash                         3       2        0         no                 no
fast-forward                  12       2        2        yes                 no
formats answering the sixth question: 0/4

history                              carrying a resolution field  conflict decision
what the tool wrote                                         0/15                 no
rationale written to commit message                         1/15                yes
```

#### None of the Four Can Answer It

The top table's last column pays off this course's third claim: **conflict decision is
no in all four formats.** The number of formats answering it is **0/4**. This does not
mean one format is worse than the others; it means none of the four measured formats has
such a field at all.

Looking at what history records makes the distinction clear. The two commits touching
the shared file are read as **2** in three formats: it can be found which file the
conflict arose in and which two pieces of work touched it. Under squash the file name
sits not in a separate commit but inside a commit group's file list; it can be seen that
it was touched, but the count of separately readable commits is **0**, and the file
trail question is no in this format.

So history records **that** a conflict happened — where the conflict arose can be found
in three formats. What it does not record is the resolution itself: which side was
chosen, which lines were written by hand, who made the decision, and why. None of these
four pieces of information sits in a field the tool writes.

Seeing that this is not a tool shortcoming only takes considering what a commit is. A
commit carries one state of the tree and its parents. The tree carried by a merge commit
already contains the resolved file — the result is recorded. What is not recorded is the
**process**: which of the two candidates was chosen and why cannot be read from the
result itself. The tool stores only the result, because what it stores is the tree.

#### The Rationale's Only Place

The bottom table measures the next step. The one thing changed in the measurement is
this: the resolution's rationale is written into the merge commit's message. Three
fields — the chosen side, the rationale, the role that decided — are added to that one
commit.

The result is that **1** of the fifteen-commit history carries a resolution record, and
the sixth question turns yes. The ratio is **1/15**: above the set's resolution, but a
small share of history as a whole. This smallness is not incidental; a resolution record
is meaningful only in the commit where the conflict was closed.

From here comes the course's most practical result: **if a conflict resolution's
rationale is not written into the commit message, it exists nowhere.** The tool does not
write it, the file does not carry it, tests do not describe it. Even the local ability
that remembers resolutions keeps it only in that repository and shares it with no one.
Months later, the only place a person asking "why is this line like this" can look is
the message, and if the message is empty, the answer is with no one.

This has a concrete counterpart: a merge commit's default message only states which
branch was merged, and stays that way if left as is. In a conflicted merge, that message
needs to carry three things — which file the conflict was in, which side was chosen and
for what rationale, and, if there is hand-written content, what it is. All three are
three lines, and all three are unrecoverable once left unwritten.

#### Summary

- A conflict is born where both sides changed the same line differently from the
  ancestor version; the tool produces no preference, hands over the decision, and leaves
  the merge half-done.
- Conflict markers write the side you are on, the ancestor version, and the incoming
  side into the file; the ancestor section shows which side actually made the change and
  takes the choice out of the blind.
- A resolution is verified at three layers: that no marker remains, that both sides'
  intent is preserved, and that tests pass. The safe way back is to abort the merge.
- The **2** commits touching the shared file are read separately in three formats, and
  **0** under squash; but none of the four formats can answer the conflict decision
  question — **0/4**.
- When the rationale is written into the commit message, **1** of the fifteen commits
  carries the resolution record and the sixth question is answered; unwritten, the
  resolution's rationale stands nowhere.

#### Next Step

In this lesson, the conflict was resolved in a single place, at the moment the merge
commit was written. The resolution was given once and recorded once. A format that wants
to keep history fork-free, in a single line, instead rewrites the branch's commits onto
main's tip — and then the same conflict can be asked separately for each commit. The
next lesson looks at this format: twelve commits stay twelve, but which three questions
get answered, which information gets erased, and why applying it on a shared branch
cannot be undone.

### Rebase

Source: https://academia.sh/en/courses/branching-and-collaboration/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.

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.

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

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

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

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

### Cherry-Pick

Source: https://academia.sh/en/courses/branching-and-collaboration/cherry-pick

Copying a single commit to another branch leaves the same change in history in two places under two separate IDs; the file trail question still says yes, but the commit count it counts exceeds the count of separate changes, and the isolation question drops when a buggy commit is copied.

Rebase moved an entire branch's commits onto a new base. 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.
Taking the whole branch would be wrong here; what is meant to be taken is a single
change.

**Cherry-pick** does exactly this. This lesson's question is as much about the
operation's result as about how it works: the same change now sits in two places in
history, and this affects the **file trail** question, which counts the commits that
touched a file.

#### Copying a Single Commit

Cherry-pick takes the change the selected commit made relative to its parent and applies
it to the tip of the branch you are on. If the application succeeds, a new commit is
written. The new commit's parent is the target branch's old tip, its tree is new, its ID
is new. The source commit stays in place and loses nothing.

This is rebase's single-commit case. The difference is **scope**: rebase moves every
commit from a branch's tip down to the common ancestor and leaves the old ones
unreachable; cherry-pick copies a single commit and leaves the old one in place. There is
a third operation that runs in reverse: **revert** flips a commit's change and writes it
as a new commit. All three are separate uses of the same mechanism — extract the patch,
apply it again.

```text
# taught command and example dump — not executed

$ git switch identity
$ git cherry-pick 4d5e6f1
[identity 8c9d0e1] report: fix field width
 1 file changed, 6 insertions(+), 2 deletions(-)
```

The bracket on the left shows the target branch and the **new ID**; the ID given to the
command is `4d5e6f1`, the ID produced is `8c9d0e1`. The two commits' content is the
same, their IDs are separate. This is the whole of what the lesson measures.

Application does not always succeed. If the context the source commit depends on is not
present on the target branch, the patch does not hold, and the conflict situation from
previous lessons arises: the working area is left with markers, the resolution is given
by hand, and then continue or abort is called. When given a range, commits are copied in
order and conflict can be asked separately for each.

There is also the reverse case: if the copied change is already applied on the target
branch, the patch is left empty. The tool does not make a decision on its own here; it
stops and leaves the choice to the user — writing an empty commit and skipping that step
each write different things to history.

#### A Range, an Uncommitted Copy, and a Two-Parent Source

What gets copied does not have to be a single commit. When the command is given a commit
range, the tool copies the commits in the range in order and writes a separate commit
for each. The range notation here too is ancestor-based: the given lower bound is
**excluded**, the upper bound is included. This is the most common reason a range is
copied one commit short.

A second option leaves the copy uncommitted: the change is applied to the working area
and the staging area, no commit is written. This is used when several commits are meant
to be copied and recorded as a single commit on the target branch — and in that case, no
separate counterpart of the source commits ever forms in history.

The case is different if the source is a merge commit. A merge commit has two parents,
and "the change it made relative to its parent" does not give a single answer: two
separate patches emerge depending on which parent is looked at. The tool therefore
requires which parent is to count as the base to be stated explicitly, and refuses the
work if it is not.

```text
# taught command and example dump — not executed

$ git cherry-pick a1b2c3d
error: commit a1b2c3d is a merge but no -m option was given.
fatal: cherry-pick failed
```

This refusal is a direct consequence of the first lesson's two-parent structure. What a
merge commit carries is not a change but the meeting of two lines; reducing it to a
single patch can only be done by choosing one side.

#### Is the Copy's Origin Recorded

The second lesson's result repeats here exactly. Unless the tool writes it on its own,
the copy's source does not stand in history: the new commit carries no field saying
which commit it was copied from. There is an option that records this information, and
it is optional — given, the tool adds a line to the end of the commit message writing
the source's ID.

```text
# taught command and example dump — not executed

$ git cherry-pick -x 4d5e6f1
$ git log -1 --format=%B
report: fix field width

(cherry picked from commit 4d5e6f1)
```

Notice where the line is written: **inside the commit message**. The tool defines no
separate field for this; it writes the origin into the message the same way it writes
the rationale. What was said about conflict resolution in the previous lesson holds here
too — information not written into the message stands nowhere.

Can a copy whose source was not written be found afterward? Partly. The tool has an
ability to compare patches by content: if two commits produce the same change, they
carry the same **patch ID** even with separate commit IDs. This comparison tells,
between two branches, which commits are already present on the other side.

```text
# taught command and example dump — not executed

$ git cherry -v main report
+ 7e8f9a0 report: add summary line
- 4d5e6f1 report: fix field width
```

The line marked with a minus sign reports that the change already exists on the other
side as an equivalent patch. This finds **that** the copy exists; it does not find
**why** it was taken. The two questions are separate, and the answer to the second is,
again, only in the message.

The patch comparison has a limit too: equivalence looks at the text of the change. If a
conflict was resolved during the copy, or the context differed, the patches are not
identical and no match can be established. The copy then looks like a separate change.

The measurement's assumptions:

- **MR22** — Cherry-pick writes the change the source commit made relative to its parent
  as a new-ID commit at the target branch's tip; the source commit stays in place.
- **MR23** — The patch ID is the change itself. The copy carries the same patch ID; the
  commit ID is separate. The measurement counts the two as separate commits.
- **MR24** — The copy is written after the target branch's own commits and before the
  merge commit; it carries its own time and does not break branch integrity.
- **MR25** — The baseline history is the merge commit format. Nothing is changed except
  adding the copy.
- **MR26** — The tracked file is `report.py`. The file trail question checks whether the
  commits that touched it can each be found individually; it does not check how many
  separate changes there are.
- **MR27** — A copy of a buggy commit is also buggy; the copied change carries the bug
  with it.
- **MR28** — The set's resolution is **1/12** at twelve commits and **1/6** at six
  questions.

#### Measurement

```python
"""Cherry-pick: the same change sits in two places in history.

Part 1 - before and after the copy: commits touching report.py.
Part 2 - how the file trail and bug isolation questions are affected by the copy.
"""
SEED = 20260813
BRANCHES = ("metrics", "report", "identity")
FILES = {"metrics": "metrics.py", "report": "report.py", "identity": "identity.py"}
BUGGY = ("report", 2)
SHARED_FILE = "config.py"
TRACKED = "report.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 == "merge commit":
        for branch in BRANCHES:
            for k in [x for x in record if x["branch"] == branch]:
                t.append({**k, "branch_record": branch, "time_preserved": 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_commit": True, "time_preserved": True})
    return t


def q2_bug(t):
    buggy = [x for x in t if x["buggy"]]
    return len(buggy) == 1 and not buggy[0].get("squashed")


def q4_file(t):
    return all(isinstance(x["file"], str) or x.get("merge_commit") for x in t)


def patch_id(x):
    """Patch ID: the change itself. A copy carries the same patch ID."""
    return x.get("patch", (x["branch"], x["step"]))


def cherry_pick(t, source, target):
    """The source commit is copied to the target branch: new ID, same patch."""
    y, k = [], next(x for x in t if (x["branch"], x["step"]) == source)
    last = max(x["time"] for x in t if x["branch"] == target
               and not x.get("merge_commit"))
    for x in t:
        if x.get("merge_commit") and x["branch"] == target:
            y.append({**k, "branch": target, "branch_record": target, "time": last,
                      "patch": source, "copy": True})
        y.append(dict(x))
    return y


def touching(t, name):
    return [x for x in t if x["file"] == name]


record = development()
BASELINE = integrate(record, "merge commit")
HISTORIES = (("no copy", BASELINE),
             ("bug-free commit copied", cherry_pick(BASELINE, ("report", 1),
                                                     "identity")),
             ("buggy commit copied", cherry_pick(BASELINE, ("report", 2),
                                                  "identity")))

print(f"real development: {len(record)} commits | tracked file: {TRACKED} | "
      f"commits touching it in development {sum(1 for k in record if k['file'] == TRACKED)}")
print()
print(f"{'history':<26s} {'commits':>7s} {'touching':>8s} {'distinct patch':>14s}"
      f" {'duplicate':>9s} {'file trail':>10s}")
for name, t in HISTORIES:
    d = touching(t, TRACKED)
    distinct = len({patch_id(x) for x in d})
    print(f"{name:<26s} {len(t):7d} {len(d):8d} {distinct:14d} {len(d) - distinct:9d}"
          f" {('yes' if q4_file(t) else 'no'):>10s}")
print()
print(f"{'history':<26s} {'buggy commits':>13s} {'bug isolation':>14s}")
for name, t in HISTORIES:
    print(f"{name:<26s} {sum(1 for x in t if x['buggy']):13d}"
          f" {('yes' if q2_bug(t) else 'no'):>14s}")
print()
for name, t in HISTORIES[1:]:
    k = [x for x in t if x.get("copy")][0]
    print(f"{name}: copy on branch {k['branch']}, patch {k['patch']}, "
          f"file {k['file']}")
```

```
real development: 12 commits | tracked file: report.py | commits touching it in development 3

history                    commits touching distinct patch duplicate file trail
no copy                         15        3              3         0        yes
bug-free commit copied          16        4              3         1        yes
buggy commit copied             16        4              3         1        yes

history                    buggy commits  bug isolation
no copy                                1            yes
bug-free commit copied                 1            yes
buggy commit copied                    2             no

bug-free commit copied: copy on branch identity, patch ('report', 1), file report.py
buggy commit copied: copy on branch identity, patch ('report', 2), file report.py
```

#### What the File Trail Says and Does Not Say

The top table gives the file trail question a **yes** in all three histories. The
question's definition did not change: every commit touching `report.py` can be found
individually. Copying does not break this answer, because a copy is also a single commit
touching a single file.

What breaks is the answer's **meaning**. With no copy, **3** commits touch `report.py`
and they carry **3** distinct patches; the numbers coincide. After the copy, commits
touching it are **4**, distinct patches stay **3**, duplicates are **1**. File trail is
still complete, but it now gives two separate numbers to two separate questions: the
answer to "how many commits touched this file" is 4, the answer to "how many distinct
changes were made to this file" is 3.

This distinction was invisible before the measurement, because in a history with no
copy the two numbers are always equal. Cherry-pick separates them, and whoever reads
history has to know which one they are reading. A reading that measures instability by
how many times a file was changed, by counting copies, shows that file as more volatile
than it is. The trail is not missing; the trail is **duplicated**, and the mark of the
duplication does not sit in history itself. Finding it requires a separate tool that
compares patches by content.

The same duplication is visible at the line level too. A tool that asks which commit a
line came from shows, for a copied line, the **copy**, not the source. Since the
question asked is usually "why is this line like this," the message that gets read is
the copy's message. However detailed the source's message is, if the origin line was not
written, there is no link leading there. The value of the option that records the origin
shows up exactly here: a two-line addition is the only link from the copy to the source.

#### The Bug's Two Places

The bottom table gives a harsher result. When a bug-free commit is copied, the buggy
commit count stays at **1** and the isolation question is **yes**: history changed, the
measurement did not. When the buggy commit is copied, the count becomes **2** and the
isolation question is **no**.

The reason it drops can be read from the definition: the isolation question looks for
the commit that introduced the bug to be found **on its own**. If the bug sits in two
separate commits, "the commit that introduced the bug" is not a single answer. A bug
fixed on one branch stays alive on the other, and the fix needs to be carried there too;
once carried, history ends up with two fixes, two IDs, and two separate places.

This duplication deepens over time. The copy and the source are separate commits, and
from here on they run separately: a fix made on top of that change on the source branch
does not go to the copy on its own, and a change made on the branch holding the copy
does not return to the source either. The two places start with the same text and drift
apart a little more with every edit. Patch comparison finds a match in the early days,
and stops finding one once the drift begins — meaning the duplication becomes permanent
exactly at the moment it becomes hard to find.

The numeric effect is **1/6**: one of six questions drops, and the number of questions
the merge commit answers falls from **5** to **4**. This equals the set's resolution and
is the smallest defensible difference.

A criterion follows from this. Cherry-pick is cheap when it is **one-directional and
short-lived**: a fix in a published branch is taken into the development branch, and
since the two branches will merge anyway, the copy creates no lasting duplication.
Cherry-pick is expensive when used as a **regular integration path** between two
branches: every copy is a duplicate patch, every duplicate patch is a miscount, and a bug
ends up with two separate places. In the second use, what is needed is not copying, it is
merging.

#### Summary

- Cherry-pick writes the change a commit made relative to its parent as a new-ID commit
  onto another branch; the source commit stays in place and the same change now sits in
  two places.
- The copy's origin is not recorded by the tool on its own; the option that records it
  writes the ID **inside the commit message**, there is no separate field.
- A tool that compares patches by content finds **that** a copy exists; it does not find
  **why** it was taken, and it may fail to match a copy taken by resolving a conflict.
- File trail still says **yes** after a copy, but commits touching **3** rise to **4**
  while distinct patches stay at **3**; the trail is not lost, it is duplicated.
- When a buggy commit is copied, buggy commits become **2** and the isolation question
  turns **no**; the number of answered questions falls from **5** to **4**.

#### Next Step

Three of the course's integration formats are now built: merge commit, fast-forward, and
rebase. Cherry-pick is not one of them — it is not a way of bringing a branch into main,
it is a way of moving a single change. What remains is the fourth format and setting the
formats **side by side**. The next lesson adds squash, which collects each branch's
commits into a single commit, and compares all four in one table — showing, with
numbers, that the format producing the most readable history is the one answering the
fewest questions, and why cleaning up history erases information.

### Merge Strategies

Source: https://academia.sh/en/courses/branching-and-collaboration/merge-strategies

Four integration formats bring the same twelve-commit development into main and leave 15, 12, 3, and 12 commits in history; the number of questions they answer is 5, 3, 2, and 3, and squash, the cleanest history, makes all twelve commits unreadable individually.

Three of the course's integration formats are now built: merge commit, fast-forward, and
rebase. Cherry-pick was also measured in between, but it was not an integration
format — it was a way of moving a single change.

This lesson adds the fourth format — squash, which collects a branch's entire set of
commits into a single commit — and puts all four **side by side**. What is being asked
is no longer what one format does, but how the four rank on the same development — and
that the ranking comes out **in opposite directions** on two measures is this course's
second claim.

#### Two Separate Decisions, One Word

The word "strategy" points to two separate things here, and confusing them is common.

**The first is the content strategy:** which method the tool will use to merge the two
sides' files. The default method is the three-way merge set up in the first lesson; it
looks at the ancestor version, takes one-sided changes, and cannot decide on two-sided
ones. The tool also has a separate method that merges more than two branches at once,
and that method stops without doing anything if a conflict arises. Strategy **options**,
meanwhile, let one side be automatically preferred in a conflict.

**The second is the format decision:** what gets written to history once the merge is
done. A closing commit, a rewritten chain, a single collected commit, or nothing at all.

The two decisions are independent. The same content strategy can end in four different
formats; the same format can be produced with different content strategies. What this
course measures is **only the second**, because the code is the same across all four.

One warning applies to the first decision. A strategy option that automatically prefers
one side in a conflict is the integration-scale version of the second lesson's shortcut
of taking a whole file from one side: it does not resolve the conflict, it counts it as
resolved. The other side's change to those lines drops silently and leaves no sign in
history. It is fitting for generated files; it is not fitting for hand-written code.

#### The Fourth Format: Squash

**Squash** never brings the branch's commits into main at all. The tool's job is to
apply the branch's total change to the working area and stop there; you write the
commit, and a single commit is written.

```text
# taught command and example dump — not executed

$ git merge --squash report
Squash commit -- not updating HEAD
Automatic merge went well; stopped before committing as requested

$ git commit -m "report: summary line and field width"
[main 6f7a8b9] report: summary line and field width
 2 files changed, 37 insertions(+), 4 deletions(-)
```

Two lines matter. The `not updating HEAD` line reports that no merge commit is written:
the produced commit has a single parent and there is no edge leading to the branch. The
file count in the second command's output is the sum of the branch's four commits —
four separate changes are combined into a single patch.

This has an unseen consequence: since the branch's commits are not main's ancestor, if
the same branch is later merged in the usual way, the tool thinks it was never taken in
at all and tries to bring in all its commits again. A branch taken in through squash
must be closed once it is taken in.

The reason usually given for choosing squash is this: interim commits written during
development — half-finished experiments, self-correcting steps — fill up main's log.
The reason is real, but the measurement shows what is given up in exchange. There are
two ways to reduce interim commits' noise: deleting them or writing them properly. The
first also deletes history's resolution; the second does not. Squash is a choice between
these two paths, not a necessity.

The measurement's assumptions:

- **MR29** — All four formats bring the same twelve-commit development into main and
  produce the same code; only the history they leave is measured.
- **MR30** — Squash collects each branch's commits into a single commit: the file field
  becomes a list of names rather than a single name, and the bug field is true if any
  commit in the group has a bug.
- **MR31** — A "separately readable commit" is one that stands on its own in history and
  has not gone into a squash. The closing commits a merge commit adds are not counted in
  this number.
- **MR32** — "Lost" is the count of the twelve real commits that cannot be read
  individually in history; nothing is lost as code.
- **MR33** — The six questions' definitions are the course's constant and are not
  changed in this lesson; they are only run separately for the four formats.
- **MR34** — The conflict decision question is unanswered in all four formats, as
  established in the second lesson. This lesson does not change that result, it shows
  it in the table.
- **MR35** — The set's resolution is **1/12** at twelve commits and **1/6** at six
  questions.

#### Measurement

```python
"""Four integration formats side by side: commit count and answered questions.

Part 1 - all four formats bring the same twelve commits into main.
Part 2 - the six questions are asked of all four histories.
Part 3 - what the squashed history's three commits carry.
"""
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 == "merge commit":
        for branch in BRANCHES:
            for k in [x for x in record if x["branch"] == branch]:
                t.append({**k, "branch_record": branch, "time_preserved": 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_commit": True, "time_preserved": True})
    elif 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 == "squash":
        for branch in BRANCHES:
            group = [x for x in record if x["branch"] == branch]
            t.append({"branch": branch, "step": 0,
                      "file": sorted({x["file"] for x in group}),
                      "buggy": any(x["buggy"] for x in group),
                      "time": max(x["time"] for x in group), "branch_record": branch,
                      "time_preserved": False, "squashed": len(group)})
    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 = ("merge commit", "rebase", "squash", "fast-forward")
record = development()
T = {f: integrate(record, f) for f in FORMATS}

print(f"real development: {len(record)} commits, {len(BRANCHES)} branches, buggy "
      f"{sum(1 for k in record if k['buggy'])}")
print("questions:", ", ".join(f"q{i}={name}" for i, (name, _) in
                            enumerate(QUESTIONS, 1)))
print()
print(f"{'format':<22s} {'commits':>7s} {'separate':>8s} {'lost':>6s}"
      f"  {'q1 q2 q3 q4 q5 q6':<17s} {'answered':>8s}")
for f in FORMATS:
    separate = sum(1 for x in T[f] if not x.get("merge_commit")
               and not x.get("squashed"))
    marks = "  ".join("+" if question(T[f]) else "-" for _, question in QUESTIONS)
    print(f"{f:<22s} {len(T[f]):7d} {separate:8d} {len(record) - separate:6d}"
          f"  {marks:<17s} {sum(s(T[f]) for _, s in QUESTIONS):8d}")
print()
print("commits in the squashed history:")
for x in T["squash"]:
    print(f"  {x['branch']:<7s} {x['squashed']} commits -> 1 | files "
          f"{','.join(x['file'])} | bug "
          f"{'yes' if x['buggy'] else 'no'}")
```

```
real development: 12 commits, 3 branches, buggy 1
questions: q1=feature grouping, q2=bug isolation, q3=true order, q4=file trail, q5=branch integrity, q6=conflict decision

format                 commits separate   lost  q1 q2 q3 q4 q5 q6 answered
merge commit                15       12      0  +  +  +  +  +  -         5
rebase                      12       12      0  -  +  -  +  +  -         3
squash                       3        0     12  +  -  -  -  +  -         2
fast-forward                12       12      0  -  +  +  +  -  -         3

commits in the squashed history:
  metrics 4 commits -> 1 | files config.py,metrics.py | bug no
  report  4 commits -> 1 | files config.py,report.py | bug yes
  identity 4 commits -> 1 | files identity.py | bug no
```

#### Readability versus Answerability

The commit column ranks the four formats this way: **15**, **12**, **3**, **12**. The
shortest history is squash's — three branches, three commits. The longest is the merge
commit's; three closings are added on top of twelve commits. If readability is the
measure, the ranking is clear: squash first, merge commit last.

The answered-question column gives the exact opposite: **5**, **3**, **2**, **3**. The
shortest history answers the fewest questions, the longest answers the most. The two
measures move **in opposite directions**, and this is the course's second claim.

The lost column shows why. In three formats, lost commits are **0**: all twelve commits
stand individually in history, only their arrangement differs. Under squash, lost is
**12**, separately readable is **0**. This means history entirely gives up its
one-in-twelve resolution: the smallest readable unit is no longer a commit, it is an
entire branch.

The dump below gives the concrete shape of this loss. The `report` branch's four
commits reduce to a single commit; that commit touches both `config.py` and
`report.py`, and it carries a bug. That the bug **is present** can be read; which step
it came from cannot be. File trail drops the same way: it can be seen that a commit
touches two files at once, but finding the commit that touched `config.py` on its own is
no longer possible.

The **2** questions squash answers are instructive too: feature grouping and branch
integrity. Both are answered by squash's structure alone — every commit is already an
entire piece of work, so grouping is flawless and integrity holds by definition. Squash
is the format that best answers the coarse questions; it cannot answer any of the fine
ones.

The two sides of the trade-off are not equally weighted, and seeing this makes the
choice easier. The excess of a 15-commit history is a **reading** cost: the log output
is long. A reading cost can be reduced afterward — there are ways of reading that filter
history, collapse it to a single line, or narrow it by date or file, and all of them
work on the same record. Squash's 12-commit loss is a **recording** loss and cannot be
brought back afterward; no reading option can show a commit that was never written. One
side can be filtered; the other cannot be produced.

The sentence that follows is this: **cleaning up history does not make it cheaper, it
erases information.** What is erased is not code — all four produce the same code. What
is erased is **how** the code was arrived at.

#### A Criterion-Based Choice

There is no correct one among the four formats; there is one that fits the question that
will be asked. The criterion is a single question: **what will this history be asked
months later?** If the question is not known, the default is the format that answers the
most questions — because an extra record can later be ignored, a missing record cannot
later be written.

If a bug hunt will be asked, bug isolation and file trail are needed. Three formats
answer these, squash does not. Tools that find a bug by binary search also rest on the
same resolution: search in a twelve-commit range finishes in four steps, in a
three-commit range it finishes in two steps but what it finds contains four changes at
once.

If the scope of the work will be asked, feature grouping is needed. Only merge commit
and squash answer this; both write the branch record in some form. Rebase and
fast-forward are equally silent on this question.

If time will be asked, true order is needed, and only two formats answer it: merge
commit and fast-forward. Rebase and squash arrange the line by piece of work and give up
time.

The choice does not have to commit to one format either. Using a different format by
branch type is a consistent rule: single-commit fixes are taken in by fast-forward,
because the branch record is not expected to be queried there later; multi-commit
feature branches are taken in by merge commit, because the scope of the work will be
asked there; external contributions with unvetted interim commits can be taken in by
squash. The rule itself has to be written down — if the format decision is left every
time to whoever is doing the merge that day, history becomes a mixture of four separate
resolutions and gives no consistent answer to any question.

The tool also has settings that keep this decision in place. The fast-forward locks
from the first lesson are the narrowest form of this: a repository can be set to
automatically reject any merge other than a fast-forward, or the reverse. The setting
does not replace the rule; it prevents the rule from being forgotten.

The difference range runs from **2/6** to **5/6**; since the set's resolution is
**1/6**, the **3/6** gap between the ends is three times the measurement band and is
comfortably defensible. The cost column, meanwhile, is **3/12**: what the merge commit
pays to answer the most questions is three closings added to twelve commits.

#### Summary

- The tool's content strategy and the integration format are separate decisions; the
  first determines how files are merged, the second determines what is written to
  history, and this course measures the second.
- Squash never brings the branch's commits into main; it writes a single commit with
  one parent. Since the branch's commits are not main's ancestor, that branch should
  not be merged again later; it should be closed.
- The four formats leave **15**, **12**, **3**, and **12** commits in history; the
  number of questions they answer is **5**, **3**, **2**, and **3** respectively.
- Under squash, lost commits are **12** and separately readable commits are **0**; that
  a bug is present can be read, which step it came from cannot. Cleaning up history
  erases information.
- A long history's excess is a reading cost and can be reduced by filtering; squash's
  loss is a recording loss and cannot be brought back by any reading option.
- The choice criterion is a single question: what will this history be asked later.
  Three formats suffice for a bug hunt, two for the scope of the work, two for true
  time; the rule can be split by branch type, but it must be written down.

#### Next Step

In this lesson, the format choice was made for a single merge: how this branch should
be taken into main. Teams do not make this decision anew each time; they make it once
and turn it into a **rule**, and the rule determines not only the moment of merge but
how long branches will live, how many there will be, and how often they will return to
main. The next lesson compares these rules — long-lived and short-lived branch
patterns — by what they leave in history.

### Branch Strategies

Source: https://academia.sh/en/courses/branching-and-collaboration/branch-strategies

How long a branch lives determines what gets written to history: the same twelve commits leave 12 with no branch opened, 15 under a pattern that returns every four commits, and 24 under a pattern that returns every commit — and once the divergence window closes, a merge conflict never arises at all.

In the previous lesson, the format choice was made for a single merge: how this branch
should be taken into main. Teams do not make this decision anew each time; they make it
once and turn it into a rule.

The rule does not only cover the moment of merge. It also determines how long branches
will live, how many there will be, and how often they will return to main. This lesson's
question is this: when the same twelve-commit development is run under different branch
patterns, what remains in history — and which question does shortening branch lifetime
remove.

The discussion of continuous integration's build, test, and deployment pipeline is not
this course's subject; it is built in the **Continuous Integration and Delivery** course
and is not repeated here. The only thing measured is what the strategy leaves in
history.

#### Branch Lifetime Is a Number

The difference between branch strategies reduces to a single quantity: **how many
commits does a branch accumulate before returning to main?** This number determines
every other property of the pattern.

Under the **long-lived branch** pattern the number is large. The branch stays open until
an entire piece of work is finished; it can live for weeks and carry dozens of commits.
Under the **short-lived branch** pattern the number is small; the branch lives less than
a day, carries a few commits, and closes. There is a further pattern at the extreme: no
branch is opened at all, and everyone commits directly to the main line.

All three patterns get the same work done and produce the same code. Where they differ
is the record they leave in history and how far apart the branches drift from each
other.

The short-lived pattern's daily cycle reduces to a few commands and repeats identically
every round.

```text
# taught command and example dump — not executed

$ git switch -c metrics/threshold main
$ git commit -am "metrics: print threshold value"
$ git switch main
$ git merge --no-ff metrics/threshold
$ git branch -d metrics/threshold
```

The fifth line is the pattern's distinguishing part: the branch is closed right after
the work enters main. Short branches that go unclosed pile up, and the branch list
turns into a pointer junkyard — since the record already sits in history as a closing
commit, the pointer's presence answers no question.

Long-lived branches are not a single class. A feature branch carrying one piece of work
closes when the work is done; a branch carrying a published release does not close, it
lives for months and takes only fixes. In the second case, divergence is **by design** —
that branch drifting away from main is not a flaw, it is the purpose. Because of this,
fixes have to go separately to that branch and to the development line; the
single-commit copy from the fourth lesson is used most often here, and the duplication
its copy demands piles up here too.

The naming pattern becomes decisive here. Under the short-lived pattern, branch count
grows not with the size of the work but with the frequency of returns; a piece of
work's successive rounds become separate branches. If branch names are not set up to
tie these rounds to the same piece of work, branch records in history multiply but none
of them shows the work as a whole. Naming's contribution to history's first question was
established in this course's third lesson; here that same contribution is multiplied by
branch count.

#### The Divergence Window and Conflict

The second lesson's conflict was born on the setup's third step: `metrics` and `report`
touched the same file. But touching the same file is not enough for a conflict to be
born. **The two changes have to have been developed in parallel** — meaning, when the
second one started, the first must not yet have entered main.

If the first has already entered, the second branch sees it **as its base**. There is
then no two-sided change; there is a one-sided change, and the tool makes the decision
on its own. The same line changed twice, but in sequence, not in conflict.

From this comes branch lifetime's second effect: the longer a branch lives, the more
commits it **diverges** from main by, and the greater the chance that two pieces of work
touch the same file in parallel. Divergence is not a duration, it is a commit count; and
it equals the pattern's number.

This is the real reason for the short-lived branch pattern. The reason is not "resolving
a conflict becomes easier"; it is that **the conflict never arises at all**. In
exchange, a short branch requires unfinished work to sit on main; for unfinished work to
sit there unseen, it has to be able to be turned off at run time, and that mechanism
itself is the subject of the Continuous Integration and Delivery course.

The measurement's assumptions:

- **MR36** — A branch pattern is represented by a single number: how many commits a
  branch accumulates before returning to main. **Zero** means the branch is never
  opened.
- **MR37** — Every return writes one closing commit. If the pattern returns every four
  commits, this is exactly the merge commit format measured in the first lesson.
- **MR38** — Under the short-lived pattern, every round is a separate branch and
  carries a separate branch record; there is no field linking one piece of work's
  rounds to each other.
- **MR39** — Divergence is the largest number of commits a branch stays away from main,
  and it equals the pattern's number.
- **MR40** — Two touches count as parallel if, when the second one's branch opened, the
  first had not yet entered main. A merge conflict is born only from parallel touches.
- **MR41** — Time values are the setup's own clock, not an environment-dependent
  timestamp; they are used only for ordering.
- **MR42** — The six questions' definitions are not changed; they are run separately
  for each pattern.
- **MR43** — The set's resolution is **1/12** at twelve commits and **1/6** at six
  questions.

#### Measurement

```python
"""Branch strategy: what branch lifetime leaves in history.

Part 1 - four branch patterns: no branch, returning every one, two, and four commits.
Part 2 - whether two commits touching the same shared file stay parallel.
"""
SEED = 20260813
BRANCHES = ("metrics", "report", "identity")
FILES = {"metrics": "metrics.py", "report": "report.py", "identity": "identity.py"}
BUGGY = ("report", 2)
SHARED_FILE = "config.py"
PATTERNS = ((0, "no branch"), (1, "return every commit"),
            (2, "return every two commits"), (4, "return every four commits"))


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 close_out(group, branch, round_num):
    """A round's commits and its closing commit."""
    name = f"{branch}#{round_num}"
    y = [{**x, "branch": name, "work": branch, "branch_record": name, "time_preserved": True}
         for x in group]
    y.append({"branch": name, "work": branch, "step": 0, "file": None, "buggy": False,
              "time": group[-1]["time"], "branch_record": name, "merge_commit": True,
              "time_preserved": True})
    return y


def history(record, k):
    """Branch pattern returning to main every k commits. k=0: branch never opened."""
    ordered = sorted(record, key=lambda y: y["time"])
    if k == 0:
        return [{**x, "work": x["branch"], "branch_record": None, "time_preserved": True}
                for x in ordered]
    t, buffer, round_count = [], {d: [] for d in BRANCHES}, {d: 0 for d in BRANCHES}
    for x in ordered:
        buffer[x["branch"]].append(x)
        if len(buffer[x["branch"]]) == k:
            round_count[x["branch"]] += 1
            t.extend(close_out(buffer[x["branch"]], x["branch"], round_count[x["branch"]]))
            buffer[x["branch"]] = []
    for d in BRANCHES:
        if buffer[d]:
            round_count[d] += 1
            t.extend(close_out(buffer[d], d, round_count[d]))
    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))


def entry_time(record, k, branch, step):
    """The moment that commit entered main."""
    group = [x for x in record if x["branch"] == branch]
    if k == 0:
        return group[step - 1]["time"]
    return group[min(((step - 1) // k + 1) * k, len(group)) - 1]["time"]


def round_start(record, k, branch, step):
    """The moment the branch holding that commit's round was opened."""
    group = [x for x in record if x["branch"] == branch]
    if k == 0:
        return group[step - 1]["time"]
    return group[((step - 1) // k) * k]["time"]


record = development()
touches = [(k["branch"], k["step"]) for k in record if k["file"] == SHARED_FILE]
first, second = touches[0], touches[1]

print(f"real development: {len(record)} commits | commits touching {SHARED_FILE}: "
      f"{touches}")
print()
print(f"{'branch pattern':<24s} {'commits':>7s} {'closings':>8s} {'branch records':>14s}"
      f" {'branches/work':>13s} {'answered':>8s}")
for k, name in PATTERNS:
    t = history(record, k)
    closings = sum(1 for x in t if x.get("merge_commit"))
    records = {x["branch_record"] for x in t if x["branch_record"]}
    print(f"{name:<24s} {len(t):7d} {closings:8d} {len(records):14d}"
          f" {(len(records) / len(BRANCHES) if records else 0):13.1f}"
          f" {sum(s(t) for _, s in QUESTIONS):8d}")
print()
print(f"{'branch pattern':<24s} {'divergence':>10s} {'first touch enters':>19s}"
      f" {'second branch opens':>20s} {'parallel':>8s}")
for k, name in PATTERNS:
    enters = entry_time(record, k, first[0], first[1])
    opens = round_start(record, k, second[0], second[1])
    print(f"{name:<24s} {k:10d} {enters:19d} {opens:20d}"
          f" {('yes' if enters > opens else 'no'):>8s}")
```

```
real development: 12 commits | commits touching config.py: [('metrics', 3), ('report', 3)]

branch pattern           commits closings branch records branches/work answered
no branch                     12        0              0           0.0        3
return every commit           24       12             12           4.0        5
return every two commits      18        6              6           2.0        5
return every four commits      15        3              3           1.0        5

branch pattern           divergence  first touch enters  second branch opens parallel
no branch                         0                  17                   19       no
return every commit               1                  17                   19       no
return every two commits          2                  23                   19      yes
return every four commits          4                  23                    4      yes
```

#### What They Left in History

The top table's last column is surprising at first glance: three of the four branch
patterns answer **5** of the six questions. The pattern that returns every four commits
is exactly the merge commit format from the first lesson and gives the expected number;
but the pattern that returns every commit and the pattern that returns every two
commits give the same **5**. **Six questions do not tell branch patterns apart.** The
no-branch pattern, meanwhile, stays at **3** — since no branch record is ever written,
feature grouping and branch integrity drop, and the result matches fast-forward's
result.

What tells the patterns apart is the columns on the left. The same twelve-commit
development leaves **15** commits under the pattern returning every four commits,
**18** under every two commits, **24** under every commit. The closing-commit count is
**3**, **6**, and **12** respectively. Under the last pattern, **half** of history is
closing commits: twelve of twenty-four records touch no file at all. This is four times
the **3/12** closing price measured in the previous lesson, and it grows linearly with
branch count — when return frequency doubles, closing count doubles too.

The branch-record column gives the second cost. Under the pattern returning every
commit, there are **12** separate branch records, and split across three pieces of work
that comes to **4.0** branches per piece of work. This number means history does not
show one piece of work as a single record: the feature grouping question says yes, but
the group it shows is not a piece of work, it is a **round**. The question is answered
at the branch level, and a branch has shrunk below a piece of work.

A warning follows from this. The six questions were set up to measure an integration
format, and they do that job; on their own they are not enough to measure a **branch
pattern**. The pattern's cost sits outside the questions, in the closing ratio and the
branches-per-work count. Knowing how far a measure holds matters as much as the measure
itself.

#### What Divergence Costs

The bottom table looks the other way. The two commits touching `config.py` are the same
two commits in every pattern; what changes is whether the first one is on main by the
time the second one's branch opens.

Under the pattern returning every four commits, the `metrics` branch's third commit
enters main at moment **23**, while the `report` branch was opened at moment **4**. The
second branch opened so much earlier that it never sees the first one's change: the two
touches are parallel, and a conflict is born. Under the pattern returning every two
commits, divergence is cut in half but the result does not change; entry moment is
**23**, branch opening is **19**, and the touches are still parallel.

Under the pattern returning every commit, the table turns. The `metrics` branch's third
commit enters main at the moment it is written, **17**; the `report` branch's round
opens at **19**. The second branch sees the first one's change **as its base**, and
parallel is **no**. Even though the same file is touched, there is no conflict left to
resolve. Under the no-branch pattern, divergence is **0** and the result is the same.

The pattern is this: **once the divergence window closes, conflict does not arise.**
What the short-lived branch buys is not conflicts that are easy to resolve, it is
conflicts that **never arise**. What it pays is in the top table: twelve closing
commits and four branch records per piece of work.

A third cost sits outside the measurement and needs to be stated. Keeping divergence low
depends on a branch being able to return to main quickly; if the return goes through an
approval step, divergence stretches by the length of that step. So branch lifetime is
not only a writing habit, it is a **review speed**. The review flow's mechanics are set
up in this course's next topic; the review axis and culture are a separate course's
subject.

Read together, the two tables settle the strategy decision. A long-lived branch keeps
history short and readable at the level of a piece of work, and accumulates divergence
and conflict. A short-lived branch removes conflict, inflates history with closing
commits, and splits work into rounds. There is no single correct point between the two;
which cost is affordable is the team's question. The measurement only shows that the
two costs **cannot be reduced at the same time**.

#### Summary

- The difference between branch strategies reduces to a single quantity: the number of
  commits a branch accumulates before returning to main. This number determines both
  history's length and its divergence.
- The same twelve-commit development leaves **12** commits under the no-branch pattern,
  **15** under every four commits, **18** under every two commits, **24** under every
  commit; the closing counts are **0**, **3**, **6**, and **12**.
- Three of the four branch patterns answer **5** of the six questions: six questions do
  not tell branch patterns apart. The no-branch pattern stays at **3**.
- Under the pattern returning every commit, **4.0** branch records fall to each piece
  of work; feature grouping says yes, but the group it shows is a round, not a piece of
  work.
- Conflict is born only from parallel touches; when divergence is **1** or **0**, the
  second branch sees the first one's change as its base, and conflict does not arise at
  all.

#### Next Step

Everything this topic has built — four integration formats, resolving a conflict,
branch lifetime, and the strategy decision — was measured under a single-repository
assumption. History sat in one place, merges were done there, branch records were
written there.

In a real team, this assumption does not hold: history lives in **multiple copies**.
Each person has their own repository, the copies advance separately from each other, and
every question measured is asked once more — this time asking "in which copy." The next
topic starts from here and first answers the most basic question: how does one
repository recognize another, and how are multiple copies named and kept separate.

## Remote Repositories and the Contribution Flow

Managing multiple remotes, the distinction between fetch, pull, and push, forking and cloning as two separate contribution models, the pull request cycle, and patch-based contribution.

### Defining a Remote Repository

Source: https://academia.sh/en/courses/branching-and-collaboration/defining-a-remote-repository

Defining a remote binds a name to an address and does not touch history; three copies carry 5, 10, and 15 commits respectively and answer 4, 5, 5 questions, and after two definitions the local copy still stays at 5 commits and 4 questions.

The previous lesson established branch strategies: the long-lived branch, the short-lived
branch, and what the chosen strategy leaves in history. That entire discussion took place
inside **a single copy**. Branches opened in the same repository and merged in the same
repository; the history we measured lived in one place, and what lived there was the whole
of the work.

This assumption does not hold in a distributed version control system. History does not
live in one place; it lives in **multiple copies**, and the copies advance without
knowledge of each other — neither one automatically knows the other's commits. This
lesson's question is not how the copies get synchronized — that is the next lesson's
topic. This lesson's question comes earlier: how does one copy **name** the other, and
what does naming add to history?

#### Copy and Remote

The Centralized and Distributed Models lesson in the Introduction to Version Control
course established the distinction: in a distributed model, every copy is a complete
repository. A copy has its own working directory, its own staging area, its own branches,
and its own history. No copy is structurally superior to another; "the center" is a
convention, not a tool feature.

A **remote** is a **name**, defined in one repository, bound to another copy's address.
This distinction has to be set up front: the copy on the other side is not the remote;
the remote is the local name this repository gives to that copy. The same copy can be
defined under two different names in two different repositories, and this is not an
inconsistency, because the name is local.

Where the definition sits matters for exactly this reason. A remote definition is written
to the repository's **configuration file**, not to history. The direct consequence:
someone else who takes a copy does not get your remote names — they make their own
definitions. A definition is not shared data, it is a personal routing table.

#### What Defining Does

The definition is managed through subcommands. The block below is an **example dump**; it
has not been executed, and the addresses are fictional.

```text
# taught commands — example dump, not executed

git remote add origin example.test:team/measurement-networks.git
git remote add upstream https://example.test/source/measurement-networks.git
git remote -v

origin    example.test:team/measurement-networks.git (fetch)
origin    example.test:team/measurement-networks.git (push)
upstream  https://example.test/source/measurement-networks.git (fetch)
upstream  https://example.test/source/measurement-networks.git (push)
```

The `-v` option gives two lines per name, because fetch and push addresses can be set
separately. A read-only copy's push address can be deliberately left invalid; an attempted
push to that name then stops before it ever reaches the network.

The remaining subcommands edit the same table: `git remote rename` changes the name,
`git remote set-url` changes the address bound to the name, `git remote remove` deletes
the definition and the remote-tracking references bound to it, `git remote show` lists the
branches under a name. `git remote remove` does not delete local branches or commits; what
it deletes is names and the references written under those names.

When a copy is taken for the first time, a remote name is not written by hand: cloning
writes a default name to the source (`origin`). This lesson's measurement picks names that
spell out each copy's role: `origin` for the copy where write access exists, `upstream`
for the read-only source it tracks.

#### Remote-Tracking References

The definition by itself is a name; what sits under that name is kept in a separate set of
references. Local branches sit under `refs/heads`, and a remote's branches sit under
`refs/remotes/<name>`. References in the second set are called a **remote-tracking
reference**.

The rule for a remote-tracking reference is one sentence: **it is not written from the
local side.** No commit is made on top of it, it is not advanced by hand; it is updated
only when data is fetched from the other side. This is why a remote-tracking reference is
not a branch, but a record of the other side's branch **as it was last seen**.

```text
# taught command — example dump, not executed

git branch -a

* metrics
  remotes/origin/metrics
  remotes/origin/report
  remotes/upstream/identity
  remotes/upstream/metrics
  remotes/upstream/report
```

The asterisk marks the local branch; the remaining five lines are references under the two
remotes. The same short name appears more than once, and this is not a defect — it is
proof that the namespace works.

#### The Measurement's Assumptions

- **RR1** — All three copies are produced from the same setup: `local` carries only the
  `metrics` branch, `origin` carries `metrics` and `report`, `upstream` carries all three.
  All three use the same integration format — merge commit; the measured difference is not
  the format, it is the **set of branches carried**.
- **RR2** — The copies' shared branches do not diverge: if a branch exists in two copies,
  it carries the same commits in both. A diverged copy is the next lesson's topic.
- **RR3** — The defining operation writes a name–address pair to the `remotes` dictionary
  and does not touch the `history` list; the measurement counts these two fields
  separately.
- **RR4** — Addresses are fictional and do not enter the measurement; the measurement looks
  not at the address's form but at the **number** of definitions.
- **RR5** — The six questions are the course's constant and do not change here. The only
  thing that changes is the **copy** the questions are asked of.
- **RR6** — The reference count counts local branches and remote-tracking references
  together; the short name is the reference with its remote prefix dropped.

#### Measurement

```python
"""Defining a remote: a name binds to an address, history is not carried.

Part 1 - the history three copies carry and the questions they can answer.
Part 2 - where defining touches local history.
Part 3 - the namespace a remote name opens.
"""
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 copy'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))

COPIES = {"local": ("metrics",), "origin": ("metrics", "report"),
          "upstream": ("metrics", "report", "identity")}


def define(repo, name, address):
    """Defining a remote: an address binds to a name, history is untouched."""
    repo["remotes"][name] = address
    return repo


def measure(d):
    return len(d["history"]), sum(f(d["history"]) for _, f in QUESTIONS)


record = development()
print(f"real development {len(record)} commits, {len(BRANCHES)} branches; "
      f"copies {len(COPIES)}")
print()
print(f"{'copy':<10s}{'branches':>9s}{'commits':>9s}{'answered':>10s}  unanswered")
for name, branches in COPIES.items():
    t = history(record, branches)
    missing = [question for question, f in QUESTIONS if not f(t)]
    print(f"  {name:<8s}{len(branches):9d}{len(t):9d}{len(QUESTIONS) - len(missing):10d}"
          f"  {', '.join(missing)}")

print()
repo = {"history": history(record, COPIES["local"]), "remotes": {}}
before = measure(repo)
define(repo, "origin", "example.test/origin")
define(repo, "upstream", "example.test/upstream")
after = measure(repo)
print(f"local before defining: {before[0]} commits / {before[1]} questions")
print(f"local after {len(repo['remotes'])} definitions: {after[0]} commits / {after[1]} questions")

print()
refs = [("local", branch) for branch in COPIES["local"]]
refs += [(remote, branch) for remote in repo["remotes"] for branch in COPIES[remote]]
short_names = [branch for _, branch in refs]
shared = sorted(a for a in set(short_names) if short_names.count(a) > 1)
print(f"refs {len(refs)} | distinct short names {len(set(short_names))} | "
      f"short names shared by multiple refs {len(shared)}")
for a in shared:
    print(f"  {a}: " + ", ".join(f"{u}/{d}" for u, d in refs if d == a))
```

```
real development 12 commits, 3 branches; copies 3

copy       branches  commits  answered  unanswered
  local           1        5         4  bug isolation, conflict decision
  origin          2       10         5  conflict decision
  upstream        3       15         5  conflict decision

local before defining: 5 commits / 4 questions
local after 2 definitions: 5 commits / 4 questions

refs 6 | distinct short names 3 | short names shared by multiple refs 2
  metrics: local/metrics, origin/metrics, upstream/metrics
  report: origin/report, upstream/report
```

#### What the Copy Can Answer

The top table asks the same question of three separate copies. `upstream` carries fifteen
commits and answers five questions; `origin` answers the same five with ten commits;
`local` answers **four** with five commits.

The missing question is **bug isolation**, and the reason for the gap is something new
here. In earlier lessons, a question going unanswered came from the chosen integration
format erasing information: squashing collapses twelve commits into three and makes the
buggy commit unreadable on its own. Nothing has been erased here. The `local` copy cannot
isolate the bug because **the buggy commit is not in it at all** — the bug comes from the
`report` branch's second commit, and the `report` branch is not present in this copy.

This adds a second source of loss to the course's measure. History can lose information to
its format; a copy can lose information to its **scope**. The second kind of loss is
recoverable: once the missing branches are fetched, the question becomes answerable again.
The same cannot be said of format loss.

The comparison between `origin` and `upstream` says a second thing. There is a five-commit
difference, and the number of answered questions is **the same**. Carrying more commits
does not by itself raise the number of answered questions; what raises it is the arrival
of **the commit an unanswered question needs**. The `identity` branch's four commits
complete no question's missing answer, because the bug is not in that branch.

The sixth question is unanswered in all three copies. Where and how the conflict was
resolved is written in none of them; increasing the number of copies does not change this.

#### What Defining Costs

The middle two lines measure the operation that gives this lesson its name. Before
defining, the local copy is at **5 commits and 4 questions**; after two remotes are
defined, it is still at **5 commits and 4 questions**.

This is not a rounding artifact, it is the definition itself. The `define` function only
touches the `remotes` dictionary; it never looks at the `history` list. This is also how
the tool behaves: adding a remote writes two lines to the configuration, does not reach
the network, fetches not a single object, creates not a single reference. If the address
is written wrong, you do not find out at the moment of definition — you find out on the
first fetch attempt.

A practical habit follows from this: a remote definition's correctness can only be tested
by an operation that actually talks to the other side. `git remote -v` only shows what was
written; `git remote show` actually connects to the other side and lists the branches
under the name. The two are separate questions.

#### What the Namespace Costs

The bottom table counts six references, and they have only **three distinct short
names**. Three references share the short name `metrics`, two share the short name
`report`.

Without a namespace, these six references would have to fit into three slots, and the
copies would overwrite each other's record. The remote name exists exactly for this
reason: `origin/metrics` and `upstream/metrics` are separate references, they can point
to separate commits, and both are independent of the local `metrics` branch.

The cost is ambiguity. When a command is given the bare name `metrics`, the tool looks at
local branches first; because the reference is found there, the other side's `metrics` is
never searched for. If the two copies' `metrics` branches have diverged and you wrote the
bare name, what you see is your branch — not the other side's. This is the most frequent
reason divergence goes unnoticed for a while. Writing a qualified name (`origin/metrics`)
removes this ambiguity and lets the two references be queried separately.

The place the namespace proves itself once more is renaming. `git remote rename` does not
just change the line in the configuration; it also rewrites every remote-tracking
reference under that name with the new prefix. In a definition carrying five references,
the name of five references changes and none of the commits are copied — because a
reference is a name, not the commit it carries.

#### When Multiple Remotes Are Needed

The measurement worked with two remotes, and that number is not arbitrary. Multiple
definitions arise whenever copies carry **separate roles**.

The first role distinction is authority. If the copy with write access and the read-only
copy are different, two definitions are needed: changes are pushed to one, updates are
fetched from the other. The measurement's `origin` and `upstream` carry exactly this
distinction, and the next two lessons' topic is the contribution model this distinction
opens up.

The second role distinction is peer collaboration. In a distributed model, a copy's remote
does not have to be a server; another team member's copy can be defined too. When an
unfinished branch needs to be examined without pushing it to a shared copy, this opens a
direct path, and the middle copy is never involved.

The third role distinction is backup. Defining a mirror under a separate name keeps the
same branches held in two separate sets of references; the two sets' divergence can only
be seen once both are defined.

In all three cases, an increase in the number of definitions does not grow local history.
The only thing that grows is the **reference namespace**, and the shared-short-name
problem the bottom table shows repeats once more with every new definition. This is why
name choice matters more as the number of definitions grows: when names state the role,
which copy a command reaches can be read from the command itself.

#### Summary

- A remote is not the copy on the other side, it is **the name this repository gives to
  that copy**; the definition sits in the configuration, not in history, and does not pass
  to someone else who takes the copy.
- Remote-tracking references sit under `refs/remotes/<name>`, are not written from the
  local side, and record the other side's branch **as it was last seen**.
- The three copies carry **5**, **10**, and **15** commits and answer **4**, **5**, **5**
  questions; `local`'s missing answer comes not from format loss but from **scope loss**,
  because the buggy commit is not in that copy at all.
- Carrying more commits does not by itself raise the number of answered questions: there
  is a five-commit difference between `origin` and `upstream`, and both answer the same
  five questions.
- Two remote definitions leave the local copy at **5 commits and 4 questions**; defining
  brings zero commits and does not test the address's correctness.
- Six references carry **three distinct short names**; the namespace resolves this
  collision, and in exchange, the bare name always falls to the local branch.

#### Next Step

The definition is a name, and a name by itself brings zero commits. The local copy still
carries five commits, the other side carries fifteen, and the gap between them is not
closed. The next lesson separates out the three operations that close this gap: which one
never touches local history, which one adds a merge commit to it, and which one changes
**the other side's** history. All three use the same network connection, and the three do
not cost history the same price.

### Fetch, Pull, and Push

Source: https://academia.sh/en/courses/branching-and-collaboration/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.

### Forking and Cloning

Source: https://academia.sh/en/courses/branching-and-collaboration/forking-and-cloning

In the two contribution models, the question the upstream repository can answer depends on the integration format: 15 commits answer 5, 12 commits answer 3, 3 commits answer 2, and because the fork repository carries 12 commits and answers 5 questions, three questions stand only in a copy the upstream repository does not own.

The previous lesson's three operations worked under 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. In most contribution flows, this assumption does not hold. There
is no write access to the repository receiving the contribution, and the absence of that access
is not a constraint — it is the definition of ownership itself.

If there is no access, how does a contribution get in? There are two answers, and the two are
not names for the same thing. This lesson's measure is this: the same twelve commits are
produced under both models, but the history the **upstream repository** ends up holding is not
the same — and the difference determines which questions can be answered where.

#### Cloning

**Cloning (clone)** is taking a copy's entirety to a new place: every commit, every branch, and
every tag is copied. The copy is complete; even if the cloned repository is deleted, the clone
is on its own a complete repository.

```text
# taught commands — example dump, not executed

git clone https://example.test/source/measurement-networks.git
cd measurement-networks
git remote -v

origin  https://example.test/source/measurement-networks.git (fetch)
origin  https://example.test/source/measurement-networks.git (push)
```

Cloning does three things at once: it takes the copy, writes a remote definition to the source
under the name `origin`, and sets up the default branch tied to this definition. It collects
into a single command what the previous two lessons did by hand; this is why where the
definition comes from is often not noticed.

The contribution model built on cloning is direct: the contributor clones the upstream
repository, opens a branch, makes commits, and **pushes the branch directly to the upstream
repository**. This model only works if the contributor has push access to the upstream
repository — that is, it is the usual form of contribution within a team.

#### The Scope of a Clone

Cloning being complete is a default, not a requirement. Options narrow the copy's scope, and
the cost of narrowing can be read in the measure's own language.

A clone given a depth limit takes only the most recent commits; what came before never arrives.
A clone limited to a single branch never writes the other branches' references at all. In both
cases, history is **unchanged** — it stands exactly as it does at the source; only the scope is
what is not taken. The first lesson's distinction applies here exactly: this is not format
loss, it is scope loss, and scope loss is recoverable. Missing depth can be completed later, a
missing branch can be fetched later.

There are options in the opposite direction too. A clone taken without a working directory
carries only the repository data, and this is the usual form for copies kept on the server
side; a clone that copies every reference verbatim carries everything, remote-tracking
references included, and is the most direct way to take a backup.

The choice depends on what the copy is taken for. A copy taken for continuous integration has
no need for ten years of history; a copy searching for which commit a bug entered on needs the
full history, and that search cannot be done in a shallow clone. Narrowing the scope saves time,
and the time it saves is paid for with a question that will be asked later.

#### Repository Forking

**Repository forking (fork)** is a copy of the upstream repository taken on the server side and
belonging to the contributor. The term shares a word with the process forking defined in the
Operating System Concepts course and is a **separate concept**: there, a process splitting in
two; here, a repository being copied into a second ownership domain. This capability is a class
of capability provided by repository platforms; no product name appears in this lesson.

The problem a fork solves is authority. The contributor has full authority in the fork
repository: opens whatever branch they want, pushes as much as they want, deletes whatever they
want. In the upstream repository, there is no write access at all.

```text
# taught commands — example dump, not executed

git clone https://example.test/contributor/measurement-networks.git
cd measurement-networks
git remote add upstream https://example.test/source/measurement-networks.git
git fetch upstream
git remote -v

origin    https://example.test/contributor/measurement-networks.git (fetch)
origin    https://example.test/contributor/measurement-networks.git (push)
upstream  https://example.test/source/measurement-networks.git (fetch)
upstream  https://example.test/source/measurement-networks.git (push)
```

In the fork model, the local repository carries **two remote** definitions: the fork itself and
the upstream repository. This is the structure that directly produces the namespace problem the
first lesson measured — `origin/main` and `upstream/main` are separate references, and the fork
falls behind as the upstream repository advances. Keeping the fork current amounts to fetching
from the upstream repository and pushing to the fork, and this work belongs to the contributor;
it does not happen on its own.

When the update is neglected, the divergence table the previous lesson measured comes directly
into play. As the fork's main branch and the upstream repository's main branch diverge, the
merge base falls back, and the contribution branch ends up resting on a base that no longer
exists in the upstream repository. This is the most predictable source of a merge conflict: the
conflict is born not from the contribution's content, but from the base going stale.

#### The Measurement's Assumptions

- **RR13** — The same twelve-commit development is produced identically in both models; the
  difference between models is not in production, it is in **which copy the commits sit in**.
- **RR14** — In the cloning model, the contributor pushes branches directly to the upstream
  repository; there is no separate copy, and the measurement writes **0** in the fork column.
- **RR15** — The fork repository carries branches as they were developed: branch record in
  place, time preserved, no squash applied. This is the state the contributor sees in their own
  copy.
- **RR16** — The transition to the upstream repository is done with one of four integration
  formats, and the format enters the measurement; the formats' definitions are identical to the
  Merging and Rebasing topic's and are not changed here.
- **RR17** — The "only in fork" column counts, of the questions the fork repository answers,
  the ones the upstream repository cannot; in the cloning row there is no separate copy, so this
  set is empty.
- **RR18** — The third table is structural and is read not from history but from the two
  models' definitions: the number of contribution branches in the upstream repository, the
  number of remote definitions the contributor must make, and which repository holds push
  access.

#### Measurement

```python
"""Two contribution models: cloning and repository forking.

Part 1 - the history the upstream repository carries and the questions it answers.
Part 2 - questions answerable only in the fork.
Part 3 - the structural difference between the two models: references and definitions.
"""
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 integrate(record, fmt):
    """Each integration format produces a separate history."""
    t = []
    if fmt == "merge commit":
        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})
    elif fmt == "rebase":
        for branch in BRANCHES:
            for k in [x for x in record if x["branch"] == branch]:
                t.append({**k, "branch_record": None, "time_kept": False})
    elif fmt == "squash":
        for branch in BRANCHES:
            group = [x for x in record if x["branch"] == branch]
            t.append({"branch": branch, "step": 0,
                      "file": sorted({x["file"] for x in group}),
                      "buggy": any(x["buggy"] for x in group),
                      "time": max(x["time"] for x in group),
                      "branch_record": branch, "time_kept": False, "squashed": len(group)})
    elif fmt == "fast-forward":
        for k in sorted(record, key=lambda x: x["time"]):
            t.append({**k, "branch_record": None, "time_kept": True})
    return t


def fork(record):
    """The state the fork repository carries: branches stand as developed."""
    return [{**k, "branch_record": k["branch"], "time_kept": True}
            for branch in BRANCHES for k in record if k["branch"] == branch]


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 {name for name, f in QUESTIONS if f(t)}


MODELS = (("cloning", False, "merge commit"),
          ("forking", True, "merge commit"),
          ("forking", True, "rebase"),
          ("forking", True, "squash"),
          ("forking", True, "fast-forward"))

record = development()
f = fork(record)
print(f"real development {len(record)} commits; fork repository carries {len(f)} commits and "
      f"answers {len(answered(f))} questions")
print()
print(f"{'model':<9s}{'integration format':<23s}{'fork':>6s}{'upstream':>9s}"
      f"{'upstream answer':>18s}{'only in fork':>15s}{'lost upstream':>16s}")
for model, separate, fmt in MODELS:
    t = integrate(record, fmt)
    upstream = answered(t)
    only_fork = answered(f) - upstream if separate else set()
    readable = sum(1 for x in t if not x.get("merge") and not x.get("squashed"))
    print(f"  {model:<9s}{fmt:<21s}{len(f) if separate else 0:6d}{len(t):9d}"
          f"{len(upstream):18d}{len(only_fork):15d}{len(record) - readable:16d}")

print()
for model, separate, fmt in MODELS:
    if not separate:
        continue
    only_fork = sorted(answered(f) - answered(integrate(record, fmt)))
    if only_fork:
        print(f"  {fmt:<21s} only in fork: {', '.join(only_fork)}")

print()
print(f"{'model':<9s}{'contribution branches upstream':>32s}{'contributor remote definitions':>32s}"
      f"{'push access':>14s}")
print(f"  {'cloning':<9s}{len(BRANCHES):32d}{1:32d}  upstream repo")
print(f"  {'forking':<9s}{0:32d}{2:32d}  fork")
```

```
real development 12 commits; fork repository carries 12 commits and answers 5 questions

model    integration format       fork upstream   upstream answer   only in fork   lost upstream
  cloning  merge commit              0       15                 5              0               0
  forking  merge commit             12       15                 5              0               0
  forking  rebase                   12       12                 3              2               0
  forking  squash                   12        3                 2              3              12
  forking  fast-forward             12       12                 3              2               0

  rebase                only in fork: feature grouping, true order
  squash                only in fork: bug isolation, file trail, true order
  fast-forward          only in fork: branch integrity, feature grouping

model      contribution branches upstream  contributor remote definitions   push access
  cloning                                 3                               1  upstream repo
  forking                                 0                               2  fork
```

#### What the Upstream Repository Can Answer

The first two rows give the same number: whether the model is cloning or forking, when
integration is done with the merge commit, it leaves the upstream repository at **15 commits**
and **5 questions**.

This result is the lesson's first correction. **Forking by itself does not reduce the upstream
repository's information.** A contribution having come from a separate copy leaves no trace in
history; what enters the upstream repository is the commits themselves, and those commits are
the same commits in the fork and upstream alike. What distinguishes the two models is authority,
not the record.

The remaining three rows show the actual variable. The question answered by the upstream
repository falls from **5** to **3** and to **2**, and the cause of the fall is the same every
time: **the chosen integration format**. The numbers measured by the Merging and Rebasing topic
repeat here exactly, because what is measured is the same history; the only thing that changes
is which repository the history sits in.

The squash row stands apart. The upstream repository carries **3** commits, answers **2**
questions, and the **lost upstream** column reads **12**: the entire real development has
stopped being separately readable in the upstream repository. In the other three rows, this
column is **0** — even when a format loses information, it leaves the commit separately
readable.

#### The Answer That Stands Only in the Fork

The second table is the lesson's actual finding. The fork repository carries twelve commits as
they were developed and answers **5** questions. The upstream repository answers **5**, **3**,
or **2** depending on the integration format. The difference between them does not sit in a
void: those questions are **in an answerable state**, but the answer is not in the upstream
repository — it is in the fork.

In the squash row, this difference rises to three. The "bug isolation," "true order," and "file
trail" questions are answered when asked of the fork's twelve commits; they are not answered
when asked of the upstream repository's three commits. In the rebase and fast-forward rows, the
difference is two, and they are different questions — the first leaves "feature grouping" and
"true order" in the fork, the second leaves "feature grouping" and "branch integrity" in the
fork.

The conclusion that follows is a warning. A question the upstream repository cannot answer may
be answerable **in a copy it does not own**. The fork repository belongs to the contributor: it
can be deleted, renamed, have its access closed. None of these notify the upstream repository,
and none of them corrupt the upstream repository's record — they only remove the place where
the missing answer could be found.

This is the permanent form the scope loss measured in the first lesson takes. There, missing
branches could be fetched, and the loss was recoverable. Here, what is missing never entered the
upstream repository, and the only place it could enter is one that can close.

#### Structural Difference

The third table gives the two models' difference that is not read from history.

In the cloning model, the contributor's three branches sit **in the upstream repository**. This
means the branch names enter the upstream repository's namespace: the naming convention has to
be enforced in the upstream repository, abandoned branches accumulate in the upstream
repository, and cleanup is the upstream repository's job. The contributor carries a single
remote definition, and push access sits directly in the upstream repository.

In the forking model, the upstream repository holds **no contribution branch at all**. Branches
are opened in the fork, accumulate in the fork, and are deleted in the fork; only the
integration's result passes to the upstream repository. The contributor has to carry **two**
remote definitions, and the cost of this was measured in the first lesson: shared short names,
and the bare name always falling to the local side.

The choice between the two models is therefore not a history decision, it is an **ownership
decision**. The upstream repository decides who can write; the integration format decides what
history records. The two are independent, and conflating them leads to a misreading that loads
information loss onto forking itself.

#### Summary

- Cloning takes a copy's entirety and writes a remote definition to the source; repository
  forking is a second copy of the upstream repository belonging to the contributor, and a
  concept separate from the process forking defined in the Operating System Concepts course.
- Both models leave the upstream repository at **15 commits** and **5 questions** under the
  merge commit format: forking by itself does not reduce the upstream repository's information.
- What determines the question the upstream repository answers is the integration format:
  **15/5**, **12/3**, **3/2**, **12/3**; under squash, **12** commits of the real development
  stop being separately readable in the upstream repository.
- The fork repository carries **12 commits** and answers **5** questions; under squash, **3**
  questions remain answerable only in the fork, and the fork is a copy the upstream repository
  does not own.
- The structural difference is not read from history: in the cloning model, contribution
  branches sit in the upstream repository and the contributor carries one definition; in the
  forking model, the upstream repository holds no contribution branch and the contributor
  carries two definitions.

#### Next Step

In the fork model, the contributor has no push access to the upstream repository; despite this,
the twelve commits made their way upstream somehow. What accomplishes the transition is not a
command, it is a **flow**: a proposal is submitted, discussed, and eventually someone takes it
into the main branch. The next lesson builds this cycle's mechanics and measures how many
questions the flow's chosen integration format leaves in history. The measurement also counts
how many of the decisions made during the flow leave a trace in history; the answer is smaller
than expected.

### Pull Request Flow

Source: https://academia.sh/en/courses/branching-and-collaboration/pull-request-flow

The integration format the flow chooses leaves history 5, 3, 2, and 3 questions; of the 11 decisions taken across three requests, 4 are written to a commit message, 7 never enter history in any format, and under squash even the written ones stop being separately readable.

The previous lesson measured that in the fork model, the contributor has no push access to the
upstream repository. Despite this, the twelve commits made their way upstream somehow. What
accomplishes the transition is not a command: someone without access cannot change the other
side's branch with any command. What accomplishes the transition is a **flow**, and the flow's
last step is run by someone who has the access.

This lesson builds the flow's mechanics: how a proposal is submitted, how the cycle turns, where
the integration decision is made. **The review axis itself — how a change is examined for
meaning, implementation, tests, and documentation — and the review culture are not this
lesson's topic**; those are built in the Code Review and Team Process course. The only thing
measured here is what the flow leaves in history.

#### The Cycle of the Flow

A **pull request** is a formal proposal for one branch to be taken into another. It is a class
of capability provided by repository platforms; no product name appears in this lesson, and the
flow's mechanics do not depend on the product.

The cycle has four steps. **Proposal:** the contributor pushes their branch to a reachable point
and opens the request; the request carries two endpoint names — the source branch and the
target branch. **Discussion:** the proposal is read, questions are asked, decisions are made.
**Revision:** the contributor changes their branch and pushes again; the request updates itself,
because the request is not a file copy, it is **a reference made to the names of two branches**.
**Integration:** someone with authority closes the request and takes the branch into the target.

Automated checks can be inserted between two steps of the cycle: a pipeline runs every time the
proposal is pushed, and attaches its result to the request. The pipeline itself is not this
course's topic and is built in the Continuous Integration and Delivery course; the only thing
visible from here is that the check result also sits in the discussion area and does not enter
history.

That the fourth step carries a choice is often overlooked. The person doing the integration
picks one of four integration formats, and this choice determines what remains in history. The
flow itself is not neutral: which format is the default determines which questions the
repository can answer months later.

#### A Proposal Is a Message

The request has a counterpart even without a repository platform, and that counterpart directly
shows what the flow is.

```text
# taught command — example dump, not executed

git request-pull upstream/main https://example.test/contributor/measurement-networks.git metrics

The following changes since commit a1b2c3d:
  Add metrics directory to main
are available in the Git repository at:
  https://example.test/contributor/measurement-networks.git metrics
for you to fetch changes up to d4e5f6a:
  Read metrics threshold from configuration

 metrics.py |  22 ++++++++
 config.py  |   6 +--
 2 files changed, 25 insertions(+), 3 deletions(-)
```

The output carries three things: the merge base's name, the address and branch the contribution
sits at, and the name of the branch tip. It carries no content. The request is not a copy, it is
**a description of where to fetch from**; the receiving side fetches, reviews, and decides.
The request a repository platform offers is the same thing, with a discussion area added on top.

#### Discussion and Revision

There are two ways a revision born from discussion can happen, and their effects on history
differ.

The first way is **adding a new commit** to the branch. The revision becomes a separate commit,
carries a separate message, and history records that the cycle turned. The measurement's
"decision written to a commit message" column counts this way.

The second way is **rewriting the branch**: the revision is worked into the commit that
introduced the bug, and the branch is pushed again. Because the contribution branch belongs only
to the contributor, this does not carry the risk rewriting a shared branch would carry — but
history now shows not that the revision happened, only the final state. This lesson does not
write the full form of a force push; the only thing that needs to be written is this: the branch
on the other side is overwritten, and the overwritten state cannot be recovered if it does not
exist in another copy. On a contribution branch, this is a deliberate choice; on the target
branch, it is not.

The choice between the two ways is a readability–answerability trade-off, and it is the flow's
counterpart to the Merging and Rebasing topic's second claim: the branch that looks clean is the
branch that does not carry the cycle's record.

#### While the Target Branch Advances

The target branch does not stand still while discussion runs. Other requests are integrated,
and the base the proposal rests on falls behind. The difference the request shows can therefore
be computed in two separate ways.

The first way directly compares the two branch tips and folds everything that happened on the
target branch in the meantime into the difference. The second way finds the two branches'
**merge base** and compares the contribution branch against today only from that base; this way,
the difference shows only the work the contributor did. Most flows show the second, and this is
the direct consequence of the third lesson's stale-base observation: even if the base has fallen
behind, the proposal stays readable.

The choice between the two becomes visible at the moment of conflict. A proposal that stands
clean against the merge base can conflict with the target branch's current state, and the flow
only learns this when it attempts the integration. This is why many flows ask for the
contribution branch to be **moved onto the current base** before integration, and this request
adds an extra round to the cycle.

The closing of a request is not single-form either. A request closed by being integrated leaves
a trace in history; a request closed without being integrated leaves no trace at all. In the
second case, the proposal, the discussion, and all the decisions taken remain only in the flow's
own record — they have no counterpart in the repository. The measurement builds this lesson only
over requests that are closed and integrated; a closed request's loss is complete by definition.

#### The Measurement's Assumptions

- **RR19** — Each of the three branches is a pull request and proposes four commits each; the
  development setup does not change, a flow layer is added on top of it.
- **RR20** — The discussion round count and the number of decisions taken are produced from the
  setup; we are the oracle, because we built the flow. A decision's content does not enter the
  measurement, only **where it was written** does.
- **RR21** — A decision enters history only if it was written to a commit message. The
  discussion area itself is outside the repository, and the measurement does not count it as
  history.
- **RR22** — A written decision stays readable if the commit it was written to remains
  **separately readable** after integration; a squashed group has no separate readability.
- **RR23** — The "reference slot" is the commit where the flow can write the request number: a
  merge commit or a squash commit. Rebase and fast-forward produce no such commit.
- **RR24** — The four integration formats' definitions are identical to the Merging and
  Rebasing topic's and are not changed here; the only thing that changes is that the flow is the
  one choosing the format.

#### Measurement

```python
"""Pull request flow: what passes from the cycle into history.

Part 1 - three requests, discussion rounds, and decisions taken.
Part 2 - the question and decision trail the flow's chosen integration format leaves.
"""
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 integrate(record, fmt):
    """Each integration format produces a separate history."""
    t = []
    if fmt == "merge commit":
        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})
    elif fmt == "rebase":
        for branch in BRANCHES:
            for k in [x for x in record if x["branch"] == branch]:
                t.append({**k, "branch_record": None, "time_kept": False})
    elif fmt == "squash":
        for branch in BRANCHES:
            group = [x for x in record if x["branch"] == branch]
            t.append({"branch": branch, "step": 0,
                      "file": sorted({x["file"] for x in group}),
                      "buggy": any(x["buggy"] for x in group),
                      "time": max(x["time"] for x in group),
                      "branch_record": branch, "time_kept": False, "squashed": len(group)})
    elif fmt == "fast-forward":
        for k in sorted(record, key=lambda x: x["time"]):
            t.append({**k, "branch_record": None, "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))
FORMATS = ("merge commit", "rebase", "squash", "fast-forward")


def flow(record):
    """Each branch is a pull request: a proposal, a discussion round, and decisions taken."""
    draw, requests = rng(SEED + 8), []
    for branch in BRANCHES:
        round_count = 1 + draw(3)
        decisions = [{"branch": branch, "round": 1 + draw(round_count), "written_to_message": draw(3) == 0}
                     for _ in range(2 + draw(3))]
        requests.append({"branch": branch, "proposed": sum(1 for x in record if x["branch"] == branch),
                         "round": round_count, "decisions": decisions})
    return requests


def remains_readable(t, decisions):
    """A decision stays readable in history if the commit it was written to is separately readable."""
    separable = {x["branch"] for x in t if not x.get("merge") and not x.get("squashed")}
    return sum(1 for d in decisions if d["written_to_message"] and d["branch"] in separable)


def reference_slot(t):
    """The commit where the flow can place its request reference: a merge or a squash."""
    return sum(1 for x in t if x.get("merge") or x.get("squashed"))


record = development()
requests = flow(record)
decisions = [d for r in requests for d in r["decisions"]]
written = sum(d["written_to_message"] for d in decisions)

print(f"pull requests {len(requests)} | proposed commits "
      f"{sum(r['proposed'] for r in requests)} | discussion rounds "
      f"{sum(r['round'] for r in requests)} | decisions taken {len(decisions)} | "
      f"written to a commit message {written}")
for r in requests:
    print(f"  {r['branch']:8s} proposed {r['proposed']}  rounds {r['round']}  "
          f"decisions {len(r['decisions'])}  written to message "
          f"{sum(d['written_to_message'] for d in r['decisions'])}")

print()
print(f"{'format chosen by the flow':<24s}{'commits':>9s}{'answered':>10s}"
      f"{'readable decisions':>21s}{'reference slot':>16s}")
for fmt in FORMATS:
    t = integrate(record, fmt)
    print(f"  {fmt:<22s}{len(t):9d}{sum(f(t) for _, f in QUESTIONS):10d}"
          f"{remains_readable(t, decisions):21d}{reference_slot(t):16d}")

print()
print(f"of the {len(decisions)} decisions taken in discussion, {len(decisions) - written} "
      f"never enter history in any format")
```

```
pull requests 3 | proposed commits 12 | discussion rounds 7 | decisions taken 11 | written to a commit message 4
  metrics  proposed 4  rounds 3  decisions 3  written to message 1
  report   proposed 4  rounds 3  decisions 4  written to message 1
  identity proposed 4  rounds 1  decisions 4  written to message 2

format chosen by the flow  commits  answered   readable decisions  reference slot
  merge commit                 15         5                    4               3
  rebase                       12         3                    4               0
  squash                        3         2                    0               3
  fast-forward                 12         3                    4               0

of the 11 decisions taken in discussion, 7 never enter history in any format
```

#### What Question the Flow Leaves Behind

The table's first two columns repeat the numbers measured by the Merging and Rebasing topic:
**15/5**, **12/3**, **3/2**, **12/3**. They repeat because what is measured is the same history.
The only thing that changes is who chooses the format.

This is the flow's quietest decision. The contributor pushes their branch, discussion runs,
eventually someone integrates, and that person usually accepts a default. If the default is
squash, the number of questions the repository can answer by the end of the month falls from
**5** to **2**, and this fall is never discussed anywhere. The measure's contribution here is not
a rule, it is visibility: a choice is being made, and the choice made has a number.

There is also something the flow cannot do with certainty. The sixth question — where the
conflict was resolved and which side was chosen — goes unanswered in all four formats. In the
discussion area, how the conflict should be resolved may have been discussed at length; history
does not record this. The flow's existence does not close this gap, it only keeps the gap from
looking bigger.

#### Where the Decisions Go

The top line gives the flow's volume: **3** requests, **12** proposed commits, **7** discussion
rounds, and **11** decisions taken. **4** of the decisions were written to a commit message,
**7** were not.

The seven unwritten decisions enter history in no integration format. The only place they exist
is the flow's own discussion record, and that record is not inside the repository: it does not
come with a cloned copy, is not carried by a fetch, is not present in a backed-up repository.
The course's most practical finding was written for the conflict decision in the Merging and
Rebasing topic, and it repeats exactly here — **if the rationale is not written to a commit
message, it exists nowhere**, and this time "nowhere" is a place outside the repository.

The "readable decisions" column shows a second loss. The **4** written decisions stay at **4**
in three formats; under squash they fall to **0**. Squashing does not delete the written
rationales, it makes them stop being separately readable: twelve commits' messages collapse into
three commits' messages, and which rationale belongs to which change can no longer be read. Even
a contributor who does the right thing is not protected from this loss.

The last column offers an observation in the reverse direction. A commit where the flow can
write its request reference exists in only two formats: merge commit and squash, both at **3**.
Rebase and fast-forward produce no such commit, and the reference count is **0**. In these
formats, there is no way back from history to the flow: if commit messages carry no request
number, there is no answer when, months later, someone asks which discussion a commit came out
of.

#### Summary

- A pull request is not a copy; it is a reference made to the names of two branches, and when
  the contributor updates their branch, the request updates itself.
- The flow's last step chooses an integration format and leaves **5**, **3**, **2**, or **3**
  questions in history; the choice is usually a default and is not discussed.
- **4** of the **11** decisions taken across three requests are written to a commit message; the
  remaining **7** never enter history in any format and stand only in the discussion record
  outside the repository.
- Squashing reduces **4** written rationales to **0**: the messages are not deleted, they stop
  being separately readable.
- The reference that lets you trace back to the flow can only be written on merge commits and
  squash commits; rebase and fast-forward have no such commit, and the reference count is **0**.

#### Next Step

This lesson's flow assumed a repository platform: a shared address, a discussion area, and
someone with authority to press the integrate button. There is one more form a contribution can
take with none of these, and it is version control's oldest contribution form: sending the
change **as a file**. The next lesson measures the patch — which metadata it carries, which it
does not, and where the metadata it does not carry falls among the six questions.

### Patch-Based Contribution

Source: https://academia.sh/en/courses/branching-and-collaboration/patch-based-contribution

A patch is a text file and encodes the change, not the graph: push with history leaves 5 questions in 15 commits, the mail-formatted series 4 in 12, the plain diff series 3 in 12, the single plain diff 1 in 1 commit, and under the plain diff, author-preserved commits are 0.

The previous lesson's flow assumed a repository platform: a shared address, a discussion area,
and someone with authority to make the integration decision. There is one more form a
contribution can take with none of these, and it is version control's oldest contribution form:
sending the change **as a file**.

A patch fits into a mailbox, not a repository. This lesson's question is not how a patch is
produced — production amounts to nothing more than two commands. The question is this: what
does the patch carry **to the other side**, what does it not carry, and where does the field it
does not carry fall among the six questions?

#### Two Patch Formats

A **patch** is a change encoded as text, and it comes in two separate forms. The first is the
**plain diff**: it carries only the change text, no metadata.

```text
# taught commands — example dump, not executed

git diff upstream/main..metrics > metrics.patch
git apply --check metrics.patch
git apply metrics.patch
```

The `--check` option tests the patch without applying it and does not touch the working
directory. When application succeeds, the changes are written to the working directory and
**no commit is created**: whoever applies the commit does so under their own name and their own
time.

The second is the **mail-formatted patch series**: one file is produced per commit, and a mail
header sits at the top of the file.

```text
# taught commands and file header — example dump, not executed

git format-patch upstream/main..metrics -o patches/
git am --3way patches/0001-read-metrics-threshold-from-configuration.patch

From <commit ID> Mon Sep 17 00:00:00 2001
From: author <author@example.test>
Date: <author date>
Subject: [PATCH 1/4] Read metrics threshold from configuration

The threshold value was embedded in the script; whenever the measurement
point changed, the script had to be edited.
---
 metrics.py | 22 ++++++++++++++++++++++
 1 file changed, 22 insertions(+)
```

Three fields from the header pass straight into the commit: author, author time, and message.
`git am` writes one commit per file and reads these fields not from the local side but **from
the file**. The `--3way` option, when the patch does not apply directly, tries a three-way
merge against the merge base; if a conflict comes up, the decision is again made by hand.

#### What a Patch Cannot Carry

There is a class of field neither format can carry, and the reason is structural. A patch is a
**text file** and encodes a change; history, on the other hand, is a **graph**, and a text file
carries no edges. This is why the commit a patch produces on the other side always has a **new
ID**, its parent link is the tip of wherever it was applied, and the branch record never crosses
over — there is no such thing as a branch in a patch, only a file.

For this reason, the measurement **derives** history from the set of carried metadata; it does
not describe the formats by hand.

#### The Measurement's Assumptions

- **RR25** — The same twelve-commit development is carried to the other side under four
  separate contribution formats; the code produced is the same in all four, and the only thing
  measured is the **metadata** that remains on the other side.
- **RR26** — Each format is defined by a **set of carried metadata**; the history on the other
  side is computed from this set, not written by hand.
- **RR27** — The mail-formatted series carries the author, author time, and message; it does
  not carry the branch record or the parent link.
- **RR28** — The plain diff series carries only the change text and the message; the author and
  time are written locally at the moment of application and count as "not carried" in this
  measurement.
- **RR29** — The single plain diff collects twelve commits' difference into a single file and
  becomes a single commit on the other side; it is the most extreme form of squashing.
- **RR30** — The "author kept" column counts commits on the other side whose author field shows
  the original contributor; merge commits are written under the integrator's name and do not
  enter the count.

#### Measurement

```python
"""Patch-based contribution: carried metadata and the cost of what is not carried.

Part 1 - the metadata fields carried by four contribution formats.
Part 2 - the history the same formats leave and the questions they answer.
"""
SEED = 20260813
BRANCHES = ("metrics", "report", "identity")
FILES = {"metrics": "metrics.py", "report": "report.py", "identity": "identity.py"}
BUGGY = ("report", 2)
SHARED_FILE = "config.py"
METADATA = ("author", "author time", "subject and body", "branch record", "parent link")
CARRIED = {"push with history": set(METADATA),
           "mail-formatted series": {"author", "author time", "subject and body"},
           "plain diff series": {"subject and body"},
           "single plain diff": set()}


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 apply(record, kind):
    """The history the contribution leaves on the other side: derived from carried metadata."""
    carried = CARRIED[kind]
    if kind == "push with history":
        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,
                          "author_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,
                      "author_kept": False})
        return t
    if kind == "single plain diff":
        return [{"branch": None, "step": 0,
                 "file": sorted({x["file"] for x in record}),
                 "buggy": any(x["buggy"] for x in record),
                 "time": max(x["time"] for x in record), "branch_record": None,
                 "time_kept": False, "author_kept": False,
                 "squashed": len(record)}]
    return [{**k, "branch_record": k["branch"] if "branch record" in carried else None,
             "time_kept": "author time" in carried,
             "author_kept": "author" in carried}
            for branch in BRANCHES for k in record if k["branch"] == branch]


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))

record = development()
print(f"real development {len(record)} commits, {len(BRANCHES)} branches, "
      f"buggy commit {sum(k['buggy'] for k in record)}")
print()
print("metadata field  " + "".join(f"{name:>23s}" for name in CARRIED))
for field in METADATA:
    print(f"  {field:17s}" + "".join(f"{('carries' if field in CARRIED[k] else 'does not carry'):>23s}"
                                    for k in CARRIED))
print()
print(f"{'contribution format':<24s}{'commits':>9s}{'author kept':>14s}"
      f"{'readable':>10s}{'answered':>10s}{'lost':>7s}")
for kind in CARRIED:
    t = apply(record, kind)
    readable = sum(1 for x in t if not x.get("merge") and not x.get("squashed"))
    print(f"  {kind:<22s}{len(t):9d}{sum(x['author_kept'] for x in t):14d}"
          f"{readable:10d}{sum(f(t) for _, f in QUESTIONS):10d}{len(record) - readable:7d}")
print()
print("unanswered questions:")
for kind in CARRIED:
    t = apply(record, kind)
    print(f"  {kind:<22s} " + ", ".join(name for name, f in QUESTIONS if not f(t)))
```

```
real development 12 commits, 3 branches, buggy commit 1

metadata field        push with history  mail-formatted series      plain diff series      single plain diff
  author                           carries                carries         does not carry         does not carry
  author time                      carries                carries         does not carry         does not carry
  subject and body                 carries                carries                carries         does not carry
  branch record                    carries         does not carry         does not carry         does not carry
  parent link                      carries         does not carry         does not carry         does not carry

contribution format       commits   author kept  readable  answered   lost
  push with history            15            12        12         5      0
  mail-formatted series        12            12        12         4      0
  plain diff series            12             0        12         3      0
  single plain diff             1             0         0         1     12

unanswered questions:
  push with history      conflict decision
  mail-formatted series  feature grouping, conflict decision
  plain diff series      feature grouping, true order, conflict decision
  single plain diff      feature grouping, bug isolation, true order, file trail, conflict decision
```

#### Reading the Steps

The bottom table gives four steps, and each step has a metadata counterpart.

**Push with history** leaves fifteen commits and **5** questions; this is the reference row,
every field has passed through.

**The mail-formatted series** leaves twelve commits and answers **4** questions. The only
question that drops is **feature grouping**; the reason is written in the top table's fourth
row — the branch record is not carried. Because the author and time are carried, "true order"
stays standing.

**The plain diff series** also leaves twelve commits, but answers **3** questions. The
difference comes from a single field: because the author time is not carried, every commit
carries the time of the moment it was applied, and **true order** cannot be read. The same
number of commits answers one fewer question, because of a single metadata field.

**The single plain diff** reduces twelve commits to one, answers **1** question, and the lost
commit count is **12**. This is the lowest value measured in the course; squashing reduced
twelve commits to three and answered **2**. The sixth question, meanwhile, is unanswered in all
four rows — which transport the contribution took does not change this.

#### The Field the Measure Does Not See

The third row carries a warning aimed at the course's own measure. Between the plain diff series
and the mail-formatted series, the **author kept** column falls from **12** to **0**: none of
the twelve commits show who wrote them anymore, all are written under the applying person's
name. Yet the answered question count falls by only **1**, and the question that drops has
nothing to do with authorship.

The reason is plain: **none of the six questions ask about authorship.** The questions ask about
grouping, isolation, order, file trail, branch integrity, and conflict decision; who the
contribution belongs to is not in that set, so the measure cannot see it. This is not a flaw in
the measure, it is a declaration of the measure's **scope**. Contribution ownership and
attribution sit outside that window, and the plain diff series silently erases them.

#### Summary

- A patch is a text file and encodes the change; because it does not encode the graph, no patch
  format carries the commit ID, the parent link, or the branch record. The mail-formatted series
  carries the author, the time, and the message; the plain diff carries only the change text.
- The four contribution formats leave **15**, **12**, **12**, and **1** commits on the other
  side and answer **5**, **4**, **3**, and **1** questions respectively.
- The mail-formatted series and the plain diff series leave the same number of commits; the
  one-question difference between them comes only from the **author time** field not being
  carried.
- When the number of author-preserved commits falls from **12** to **0**, the answered question
  count falls by only one: none of the six questions ask about authorship, and this is the
  measure's scope boundary.

#### Course Wrap-Up

The course ran on a single measure: an integration format's number is not the code it produces,
it is the **number of questions** the history it leaves can answer. Fifteen lessons applied this
measure to separate places.

| lesson | measured | answered question or loss |
|---|---|---|
| The Concept of a Branch | the cost of branching | 0 new commits and a 123-byte reference; the copy model does the same job by copying 12,000 files |
| Branch Creation and Switching | what switching does to changes left in hand | 3 of 12 states are carried over, 5 are blocked, 4 are left behind; silent loss 0 |
| Branch Naming | the naming convention's contribution to the first question | the group reads in 3 of 8 combinations; contribution is 0 in a format that keeps no record |
| Detached HEAD State | the cost an unreachable commit charges history | 15 entries fall to 13, the answer falls from 5 to 4; all 3 of the three counting queries are answered incompletely, and history does not report it |
| Fast-Forward and Merge Commit | the history the two merge formats leave | 15 commits/5 questions and 12 commits/3 questions; the difference is created by grouping and branch integrity |
| Conflict Resolution | whether the resolution passes into history | all four of the four formats fail to answer the sixth question, 0/4; if the rationale is written to the message, 1 of the 15 commits carries it |
| Rebase | the history linearization leaves | 12 commits / 3 questions; the total matches fast-forward, the loss is separate, the difference is 1/6 |
| Cherry-Pick | the same change standing in two places | file trail stays yes, touched commits rise from 3 to 4; in the buggy copy the answer falls from 5 to 4 |
| Merge Strategies | four formats side by side | 15/12/3/12 commits and 5/3/2/3 questions; under squash, 12 are lost |
| Branch Strategies | what branch lifetime writes into history | 12, 15, 18, and 24 commits; all three branch layouts answer 5 questions — the six questions do not distinguish between branch layouts |
| Defining a Remote Repository | what defining adds to history | three copies carry 5/10/15 commits and answer 4/5/5 questions; after two definitions, local stands at 5 commits / 4 questions |
| Fetch, Pull, and Push | the history the three operations change | fetch leaves the branch at 5, pull carries it to 16 and 5 questions, push changes the other side from 10 to 16 |
| Forking and Cloning | what the upstream repository can answer | 15/5, 12/3, and 3/2; under squash, 3 questions remain only in the fork repository |
| Pull Request Flow | what the flow's chosen format leaves | 5/3/2/3 questions; 7 of the 11 decisions never enter history, and under squash even the written 4 become unreadable |
| Patch-Based Contribution | the metadata the patch carries and does not carry | 15/5, 12/4, 12/3, and 1/1; under plain diff, author-preserved is 0 and the six questions never ask about it |

Three readings follow from the table. **First:** readability and answerability move in opposite
directions — the cleanest histories are the ones that answer the fewest questions, and cleaning
up history erases information. **Second:** the sixth question is answered in no row; how a
conflict was resolved is, by the record's own definition, not in the record, and the only
remedy is writing the rationale to a commit message. **Third:** loss comes from two separate
sources. Format loss erases information and cannot be recovered; scope loss is only a copy's
absence, and it closes with a fetch.

Throughout the course, history was always **read**: questions were asked, answers were counted,
and what the formats left behind was taken as given. The next course — Advanced Git — takes up
the tools for **rewriting** that same record: interactive rebase, bulk history transformation,
force push discipline, and the reflog that rescues lost commits. The distinction has to be set
up front: **reading history and rewriting it are separate jobs.** Reading changes nothing;
rewriting can permanently change the answers to the questions counted in this course. The
measure this course leaves behind is the unit that will be used to compute the cost of every
tool there.

