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