---
title: 'Pull Request Flow'
source: 'https://academia.sh/en/courses/branching-and-collaboration/pull-request-flow'
course: 'Branching and Collaboration'
language: en
updated: '2026-08-17T18:10:45+00:00'
license: 'CC BY-SA 4.0'
---

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