Atomic commits and Conventional Commits

What makes a commit atomic, how to write messages with Conventional Commits, how to split changes with git add -p, and how to fix older commits with fixup and autosquash.

Author
José Morales
Published
Reading
7 min

A PR arrives with a single commit, fix stuff, that touches 30 files. Inside there is a rename, a bug fix, and a dependency upgrade. The reviewer cannot tell which change causes which diff. Two weeks later, git bisect points at that commit as the one that broke something, and reverting it undoes all three changes at once. Git history is a debugging tool, and it only works if each commit tells a single story.

What makes a commit atomic

A commit is atomic when it contains a single logical change and leaves the repository in a state that compiles and passes the tests. Those two conditions have practical consequences.

  • You can revert it on its own, without dragging unrelated changes along.
  • git bisect can stop on it and get a reliable result.
  • The reviewer reads it with a single question in mind.

Atomic does not mean small. Renaming Customer to Account across 40 files is a single logical change and goes in a single commit. Splitting it into 40 commits leaves 39 states that do not compile. The other way around, a ten-line diff that fixes a bug and also reformats a function is two commits.

If I could put only one rule in a CONTRIBUTING.md, it would be this one.

Every commit compiles, passes the tests, and can be reverted on its own. If the message needs an “and” to describe the change, it is probably two commits.

Conventional Commits

Conventional Commits is a convention for the first line of the message. The format is type(scope)!: description, where the scope and the ! are optional.

A fix messagetext
fix(payments): reuse the idempotency key on provider retries

The adapter generated a new key on each retry, so a timeout after a
successful charge could charge the card twice. The key now comes from
the incoming request and is passed through unchanged.

Refs: PAY-1234

The first line says what changes and where. The body explains why, which is the one thing the diff does not show. The footers at the end follow the Git trailer format.

A breaking change is marked with ! and with the BREAKING CHANGE footer.

A breaking change messagetext
feat(api)!: require Idempotency-Key on POST /charges

BREAKING CHANGE: requests without the header now return 400.

The types

The specification defines only feat and fix. The rest comes from the Angular convention, which is the one @commitlint/config-conventional uses.

Type When to use it Version with semantic-release
feat New functionality that users of the system see minor
fix Fixes a bug patch
perf Improves performance without changing behavior patch
refactor Changes structure without changing behavior none
test Adds or fixes tests none
docs Documentation only none
build, ci, chore Build, pipeline, and maintenance none
Any type with ! Breaks compatibility major

The version column is the one from semantic-release’s default preset. The automatic version is the least of it. The type forces you to decide what kind of change you are making, and a commit that does not fit a single type is usually two commits.

To enforce the convention without relying on anyone’s memory, run commitlint in a commit-msg hook and in CI.

.husky/commit-msgsh
npx --no -- commitlint --edit "$1"

Splitting changes with git add -p

You almost never work on one change at a time. You fix the bug, spot a bad name and change it, add a log line. git add -p lets you pick which parts of each file go into the next commit, hunk by hunk.

git add -ptext
$ git add -p src/cart/totals.ts
diff --git a/src/cart/totals.ts b/src/cart/totals.ts
--- a/src/cart/totals.ts
+++ b/src/cart/totals.ts
@@ -12,7 +12,7 @@ export function subtotal(lines: CartLine[]): number {
-  return lines.reduce((sum, l) => sum + l.price * l.qty, 0);
+  return lines.reduce((sum, line) => sum + line.unitPriceCents * line.quantity, 0);
 }
(1/2) Stage this hunk [y,n,q,a,d,j,J,g,/,s,e,?]? y
@@ -30,6 +30,9 @@ export function applyCoupon(total: number, coupon: Coupon): number {
+  if (coupon.expiresAt < Date.now()) {
+    return total;
+  }
(2/2) Stage this hunk [y,n,q,a,d,j,J,g,/,e,?]? n

I use only a few of the answers.

  • y stages the hunk and n leaves it for later.
  • s splits the hunk into smaller hunks when there are unchanged lines in between.
  • e opens the hunk in the editor so you can pick line by line.
  • q quits and keeps what you already staged.

Before you commit, review exactly what is going in. Then test the commit without the rest of your changes.

Commit and test the commit in isolationsh
git diff --staged
git commit -m "fix(cart): ignore expired coupons"
git stash push --include-untracked
npm test
git stash pop

The stash holds everything that stayed out of the commit. That way the tests run against the real commit and not against your working copy with mixed changes. If they fail, you fix the problem and run git commit --amend before you bring the rest back with git stash pop.

Fixing older commits with fixup

The reviewer finds a mistake in the second of four commits. Adding a fifth commit called fix review breaks the atomicity of the second one, which keeps a bug that gets fixed three commits later. The alternative is a fixup commit.

Create a fixupsh
git log --oneline main..HEAD
git add -p
git commit --fixup=3f2a9c1

--fixup creates a commit whose message is fixup! followed by the original commit’s message. While the branch is in review, the history looks like this.

Branch with a pending fixup
gitGraph
mainfeature/checkoutfeat(cart): totalsrefactor(cart): extract Moneyfeat(checkout): apply couponsfixup! refactor(cart): extract Moneytest(checkout): coupon edge cases

The reviewer can look at the fixup on its own, which is exactly what they want on a second pass. Before merging, a rebase with autosquash folds it into its original commit.

Apply the fixupssh
git rebase -i --autosquash main

Git opens the commit list with each fixup! already moved below its target and marked as fixup. You save, close the editor, and the history ends up with three clean commits. To make this the default behavior, turn it on once.

Configurationsh
git config --global rebase.autoSquash true
git config --global rebase.updateRefs true

rebase.updateRefs also moves the branches that point at intermediate commits, which helps if you work with stacked PRs. To also change the original commit’s message, use git commit --fixup=amend:3f2a9c1.

Check every commit, not just the last one

A rebase can leave an intermediate commit that does not compile even when the branch tip is green. --exec runs a command after each commit and stops at the first one that fails.

Run the tests on every commit in the branchsh
git rebase --exec "npm test" main

It is slow on long branches. For a PR of four or five commits it takes as long as running the suite four or five times, and it saves you from a broken bisect months later.

Squash or rebase when merging

If the PR is a single logical change, a squash merge with a Conventional Commits message is enough. If the PR carries several atomic commits, a rebase merge keeps them and main inherits a history you can bisect and revert piece by piece. What you should avoid is squashing a PR with well-separated commits, because it throws away exactly the work you did with add -p and the fixups.

Summary

  • An atomic commit is one logical change that compiles and passes the tests. Size does not matter.
  • The Conventional Commits type forces you to classify the change. If it does not fit one type, it is two commits.
  • Use git add -p to split what you did together, and test each commit in isolation with git stash.
  • Fix with git commit --fixup and fold with git rebase -i --autosquash before merging.
  • Run git rebase --exec "npm test" to confirm that no intermediate commit is broken.

Written by

José Morales

Senior Backend Engineer

Backend and BFFs in production. I write about architecture, distributed systems, and the craft of writing code others can maintain.