Commit history as a story
Small commits that each pass their tests, with messages that say what and why, turn your history into evidence.
Predict first
Before you read: predict
An ExamHall take-home: the exam timer. The candidate finished the work, then committed it one file at a time. npm test runs node --test on Node 24, which runs every *.test.js file. Her six commits, oldest first:
Add package.json(thetestscript)Add tests(src/timer.test.jsandsrc/view.test.js)Add exam model(src/exam.js)Add timer(src/timer.js)Add view(src/view.js)Add README(README.md)
timer.test.js imports exam.js and timer.js; view.test.js imports exam.js and view.js; view.js imports timer.js. On the last commit every test passes. The reviewer checks out each commit in turn and runs npm test.
At how many of the six commits does it pass?
The 30-second version
A take-home's history is part of what you hand in. Commit one change at a time, every commit passing its tests, each test arriving with its code. Say what a change does in a short imperative subject and why in the body. Before you submit, fold each fixup! commit into its target and run the tests at every commit.
Small commits that each pass their tests, with messages that say what and why, turn your history into evidence.
How it works
Step 1 of 12
git log and see how you worked, or see one "final" commit.In the kitchen
- an entry reading "v8: lots of fixes" → a "wip" or "final" commit (step 1)
- the publisher's developer reads the log to see how the game got here → the reviewer reads your history (step 1)
Analogy: A board-game designer preparing a game for a publisher
Every Thursday her group plays the latest printed version, start to finish.
| At the designer's table | In the take-home |
|---|---|
| every printed version plays start to finish on Thursday | every commit passes its tests (step 3) |
| one rule change per version, so the playtest shows what it did | one change per commit (step 2) |
| a card naming a token printed only next week stops the game | a commit needing a later commit's file (step 4) |
| the change log: "v7: trading costs 2 grain; one player hoarded every route by turn 5" | subject what, body why (steps 7, 8) |
| an entry reading "v8: lots of fixes" | a "wip" or "final" commit (step 1) |
| the publisher's developer reads the log to see how the game got here | the reviewer reads your history (step 1) |
In real terms: a reviewer should be able to check out any commit you hand in, see it pass, and read why it exists.
Breaks down when: a playtest's verdict depends on who sits at the table, while a test gives the same answer on every run, and git keeps each version exactly as committed.
What the sources support, and what they don't. S1, Conventional Commits 1.0.0, defines the message format and the release each type maps to; S2, Chris Beams' "How to Write a Git Commit Message", gives the seven rules of a message and asks for atomic commits. Both were read on 2026-10-15 and are valid as of that date. Neither says that every commit must pass its tests: steps 3–6, 11 and 12 are this lesson's practice advice, and its items check them by running git and node --test. Step 10 is this lesson's reading of S1, run as a labelled model.
Examples, run for real
4.1 ExamHall EXH-4490: the same files, told as a story
EXH-4490: an exam-timer take-home handed in as one commit. MR !4790 commits the same files in the order they were built:
4.1 EXH-4490 as handed in: 1 commit, "final version" · 9 files · node --test: 6 tests, pass
4.1 MR !4790: the same files as 5 commits, oldest first, node --test at each:
4.1 chore: scaffold the exam timer · 0 tests · pass
4.1 feat(exam): add the exam window model · 1 test · pass
4.1 feat(timer): close the exam at its end time · 4 tests · pass
4.1 feat(view): show the minutes left · 6 tests · pass
4.1 docs: add the README with the test command · 6 tests · pass
4.1 git rebase --exec "npm test" main (main = the scaffold): 4 of 4 commits replayed, the tests run 4 times, finished
The submitted history passes and says nothing: one commit holds everything. MR !4790 cuts the work into the steps it was built in, each piece with its test (steps 2, 5). Every commit passes on its own, so a reviewer can check out any of them (step 3), and --exec shows it in one command (step 6).
4.2 The timer commit's message
4.2 git log -1 --format=%B, the timer commit (subject 43 characters, body lines at most 64):
4.2 feat(timer): close the exam at its end time
4.2
4.2 The task sheet says an exam closes at its end time, so an answer
4.2 saved in that millisecond is late: isOpen is false at endsAt.
4.2 remainingMs stops at 0, so the view can say "Time is up".
The subject says what the commit does, in the imperative and in 43 characters (step 7). The body says why the exam closes at endsAt itself (step 8).
Try it
Answer first, then check. Nothing is saved.
Question 1 of 7
A box office take-home, half-way through. In src/holds.js, holdSeat(seatId, now) holds a seat for 10 minutes; postHold(req, now) in src/api.js calls it. holds.test.js tests holdSeat and api.test.js tests postHold, and both pass. She then makes one change on her disk: each event sets its own hold length. holdSeat gains a third argument, the event, and reads event.holdMinutes; postHold gains the events and passes the request's event; both test files get the new calls and the 15-minute values; the README gets one line. She considers three ways to commit it.
Which one keeps npm test passing at every commit?
Question 2 of 7
A box office take-home, before she commits anything: four finished pieces, each with its own test file. seats.js imports nothing; labels.js imports seats.js; holds.js imports seats.js and labels.js, since its error names the seat by row and number; release.test.js imports holds.js to make its holds. Each commit adds the file its subject names with that file's test, and npm test must pass at every one.
Put the four commits in order, oldest first.
Your order
- 1empty
- 2empty
- 3empty
- 4empty
Tap the lines in the order you expect.
Question 3 of 7
A box office's seat-map library is released from its Conventional Commits. It is at version 2.3.1. The commits since that release, oldest first:
refactor(holds): take the hold length from the event, whose message ends with the footerBREAKING CHANGE: holdSeat needs the event as its third argumentfeat(seats): add wheelchair spaces to the mapfix(holds): release a hold at its end time
By this lesson's reading of the spec (its release model), which version is released next?
Question 4 of 7
A box office branch: five commits on top of main, where npm test passes. Oldest first:
feat(seats): name rows with letters: changesseatNameinseats.js;seats.test.jsis not changedtest(seats): expect lettered rows:seats.test.jsnow expectsRow C, seat 12feat(holds): hold a seat for 10 minutes:holds.js, which importsHOLD_MINUTESfromconfig.js, andholds.test.jschore(config): add the hold length setting:config.jsdocs: describe seat holds in the README
The code shows the two seat files as commit 1 leaves them.
Before she submits, she runs git rebase --exec "npm test" main.
What happens?
// src/seats.js after commit 1const LETTERS = 'ABCDEFGHJK'; export function seatName(row, number) { return `Row ${LETTERS[row - 1]}, seat ${number}`;} // src/seats.test.js after commit 1, as on mainimport { test } from 'node:test';import assert from 'node:assert/strict';import { seatName } from './seats.js'; test('names row 3, seat 12', () => assert.equal(seatName(3, 12), 'Row 3, seat 12'));Question 5 of 7
Her box office's releaseExpired(holds, now) keeps the holds for which h.until >= now, so a hold ending at 10:10:00 still blocks its seat at 10:10:00 and the next buyer is turned away for that second. The task sheet says a hold lasts 10 minutes. Her fix changes >= to > and adds a test that releases a hold at its end time.
Write the commit message, header and body, as a plain text file.
Question 6 of 7
A box office take-home. Her commit feat(holds): let each event set its hold length adds a holdMinutes setting to each event, and holdSeat now reads it. She has three drafts for the body, and each gives a reason for the change.
Which one does what Chris Beams' "How to Write a Git Commit Message" asks of a body?
Question 7 of 7
A box office branch, five commits on top of main, oldest first:
feat(holds): hold a seat for 10 minuteswipfixup! feat(holds): hold a seat for 10 minutesfeat(holds): release expired holdsfix typo
No two of them change the same lines. She runs git rebase -i --autosquash main and accepts the todo list as Git writes it.
How many commits does the branch hold afterwards?
Go deeper
Pro tip
- Write the subject before the code. If the next commit does not fit one imperative line of about 50 characters, it is probably two commits (steps 2, 7; Beams: "you might be committing too many changes at once"). When: before you start each piece of a take-home. Verify (RAN,
tips.mjs):
Status: RAN.tip MR !4790's five subjects: 30, 37, 43, 33, 42 characters · longest 43 tip "feat(timer): close the exam at its end time and show the minutes left" · 69 characters, and "and" joins two changes: two commits
Related lessons
- Related: Merge vs rebase on the commit graph
- Related: Refactoring safely
- Related: Bisection: binary search on commits, input, config and time
- Related: Orienting in an unfamiliar codebase fast
- Related: Scope and time-box a take-home
- Related: The README as a design doc
- Related: Defending the take-home in the review call
Checked against the tools and docs as of 2026-10-15.