Lesson 14 / 15
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.
Contents
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.
# 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
"""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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.