Skip to content

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:

  1. Add package.json (the test script)
  2. Add tests (src/timer.test.js and src/view.test.js)
  3. Add exam model (src/exam.js)
  4. Add timer (src/timer.js)
  5. Add view (src/view.js)
  6. 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?

Pick one

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

The log is read. A repository carries its history: a reviewer can run 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?

Pick one

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

  1. 1empty
  2. 2empty
  3. 3empty
  4. 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:

  1. refactor(holds): take the hold length from the event, whose message ends with the footer BREAKING CHANGE: holdSeat needs the event as its third argument
  2. feat(seats): add wheelchair spaces to the map
  3. fix(holds): release a hold at its end time

By this lesson's reading of the spec (its release model), which version is released next?

Pick one

Question 4 of 7

A box office branch: five commits on top of main, where npm test passes. Oldest first:

  1. feat(seats): name rows with letters: changes seatName in seats.js; seats.test.js is not changed
  2. test(seats): expect lettered rows: seats.test.js now expects Row C, seat 12
  3. feat(holds): hold a seat for 10 minutes: holds.js, which imports HOLD_MINUTES from config.js, and holds.test.js
  4. chore(config): add the hold length setting: config.js
  5. docs: 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?

Code for this question
// 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'));
Pick one

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?

Pick one

Question 7 of 7

A box office branch, five commits on top of main, oldest first:

  1. feat(holds): hold a seat for 10 minutes
  2. wip
  3. fixup! feat(holds): hold a seat for 10 minutes
  4. feat(holds): release expired holds
  5. fix 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?

Pick one

Go deeper

Pro tip
  1. 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):
    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
    Status: RAN.

Checked against the tools and docs as of 2026-10-15.