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.
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 bisectcan 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.
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-1234The 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.
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.
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 -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,?]? nI use only a few of the answers.
ystages the hunk andnleaves it for later.ssplits the hunk into smaller hunks when there are unchanged lines in between.eopens the hunk in the editor so you can pick line by line.qquits 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.
git diff --staged
git commit -m "fix(cart): ignore expired coupons"
git stash push --include-untracked
npm test
git stash popThe 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.
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.
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.
git rebase -i --autosquash mainGit 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.
git config --global rebase.autoSquash true
git config --global rebase.updateRefs truerebase.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.
git rebase --exec "npm test" mainIt 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 -pto split what you did together, and test each commit in isolation withgit stash. - Fix with
git commit --fixupand fold withgit rebase -i --autosquashbefore merging. - Run
git rebase --exec "npm test"to confirm that no intermediate commit is broken.