What makes a good commit
A good commit is atomic — one logical change that stands on its own — with a clear message: an imperative-mood subject under ~50 chars, a blank line, then a body explaining why. Learn Conventional Commits (type(scope): subject) and how to split noisy work into clean commits.
Two developers ship the same feature. One leaves a single commit: git commit -m "stuff" bundling a bug fix, a rename, and a new endpoint. The other leaves three commits, each one thing, each with a sentence saying why. Six months later someone hits a regression and runs git bisect or git log. The first history is useless — every commit is a tangled blob. The second reads like a changelog. Same code, wildly different maintainability.
By the end of this lesson you will be able to write atomic commits with messages a future engineer can actually use, and split a messy editing session into clean commits.
After this lesson you can explain what makes a commit atomic, write a commit message with an imperative-mood subject and a why-focused body, apply the Conventional Commits format, and split one noisy change into several clean commits.
A good commit is atomic: it captures exactly one logical change, and the project still builds and passes tests at that commit. Atomic does not mean “small” — it means self-contained. A bug fix is one commit; a variable rename is another; a new feature is a third. The test is: could you revert this single commit cleanly without dragging unrelated work with it? If reverting “fix login” also undoes a rename, the commit was not atomic. Atomic commits make git revert, git bisect, git cherry-pick, and code review all work the way they are designed to.
The subject line is a one-line summary in the imperative mood, under about 50 characters. Write it as a command — what applying the commit will do — not as a past-tense report:
Add rate limiting to login endpoint ← imperative, good
Added rate limiting ← past tense, avoid
Fixes the thing where login breaks ← vague, avoidThe imperative reads naturally with git’s own phrasing: “if applied, this commit will Add rate limiting…”. Keep it under ~50 chars so git log --oneline and GitHub do not truncate it. Capitalise the first word, no trailing period — it is a title, not a sentence.
After the subject, leave a blank line, then a body that explains WHY — not what. The diff already shows what changed; the body’s job is the reasoning the diff cannot convey:
Add rate limiting to login endpoint
Brute-force attempts were spiking auth CPU and occasionally
locking out real users. Cap to 5 attempts per IP per minute;
chose a sliding window over a fixed one to avoid burst gaming
at minute boundaries.
Closes #482Wrap the body at about 72 characters so it reads well in terminals and git log. The blank line between subject and body is mandatory — git and its tools rely on it to separate the title from the description. Months later, the “why” is the only thing that explains a non-obvious line.
Conventional Commits give the subject a machine-readable structure: type(scope): subject. A small, fixed vocabulary of types prefixes the message:
feat(auth): add rate limiting to login
fix(api): correct off-by-one in pagination
refactor(db): extract query builder
docs(readme): document env vars
test(cart): cover empty-basket checkout
chore(deps): bump eslint to 9.xCommon types are feat, fix, refactor, docs, test, and chore; the optional (scope) names the affected area. Teams adopt this because it is parseable: tools auto-generate changelogs, and a feat vs fix vs a !/BREAKING CHANGE marker drives semantic-versioning bumps automatically. The format turns commit messages into release infrastructure.
▸Why this works
Why does atomicity make git bisect magical? Bisect binary-searches your history to find the commit that introduced a bug — it checks out a commit, you mark it good or bad, and it halves the range. This only works if each commit builds and represents one change: a “bad” commit then points at one logical change, not a tangle of five. A single mega-commit that mixes a fix, a rename, and a feature makes bisect land on a blob you still have to untangle by hand — you have thrown away the very precision the tool exists to give you.
Splitting a tangled afternoon into clean history.
You spent an afternoon and your working tree now has three unrelated things: a fix to auth.js, a rename of util.js → helpers.js across the codebase, and a new /health endpoint. Committing all at once would produce one un-revertable blob. Instead you stage by purpose:
git add auth.js
git commit -m "fix(auth): reject expired session tokens"
git add util.js helpers.js src/ # the rename and its call sites
git commit -m "refactor: rename util to helpers"
git add routes/health.js app.js
git commit -m "feat(api): add /health liveness endpoint"For the bug fix you add a body explaining the security reasoning (git commit with no -m opens your editor). Now git log --oneline reads as three intentional steps. If the /health endpoint later causes trouble, git revert removes just that commit — the fix and the rename are untouched. The afternoon’s mess became history a teammate can read and bisect.
▸Common mistake
A common anti-pattern is the “WIP dump”: coding all day, then git add . && git commit -m "wip" at the end. It is fast, but it destroys every benefit of history — you cannot revert one part, bisect lands on a wall of unrelated changes, and review is hopeless. If you genuinely committed messy work-in-progress locally, clean it up before sharing it (with interactive rebase, covered later) so the published history is atomic. The rule of thumb: commit messages are written for the person debugging your code at 2 a.m. six months from now — often you.
Which commit best follows the conventions for an atomic commit with a good message?
A good commit is atomic — one logical change that builds and passes on its own, so git revert, git bisect, and review work cleanly. Its message has an imperative-mood subject under ~50 chars (“Add”, not “Added”), a mandatory blank line, then a body explaining why wrapped at ~72 chars. Conventional Commits structure the subject as type(scope): subject using feat, fix, refactor, docs, test, chore, which lets tooling auto-generate changelogs and drive semantic-version bumps. Split noisy work into single-purpose commits by staging subsets — avoid the “wip” dump that throws away every benefit of history. You have now completed the recording-changes loop: stage precisely, read what you changed, ignore what does not belong, and record it as clean, atomic history.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.