What a Good Commit Actually Looks Like
by Eric Hanson, Backend Developer at Clean Systems Consulting
The Anatomy of a Real Commit
Your team's code review process is slowing down. PRs sit for days. Reviewers leave comments like "why was this changed?" and "what's the context here?" The diff is there, the code makes sense in isolation, but the reviewer doesn't know what problem it's solving. Half the review time is actually archaeology — reconstructing the intent from the code.
Most of that overhead disappears with well-structured commits. Not just better messages — better commits. The message is one piece. The scope, atomicity, and structure of the change are equally important.
What Atomic Actually Means in Practice
An atomic commit captures one logical unit of change. It's independently understandable, independently revertable, and leaves the codebase in a valid state.
"One logical unit" is not the same as "one file" or "one function." It's the smallest meaningful slice of intent.
Refactoring a class and adding a feature are two logical units. They should be two commits, even if the feature required the refactor. Fixing a bug and writing its regression test are one logical unit. They belong together.
Here's the test: if a future developer runs git revert <sha>, what happens? If reverting one thing also silently removes something unrelated, you bundled two logical changes into one commit.
# Working directory has mixed changes:
# - Refactored UserRepository to use JPA Criteria API
# - Added address validation feature that uses the refactored repo
# Stage and commit them separately:
git add src/repository/UserRepository.java \
src/repository/UserRepositoryImpl.java
git commit -m "refactor: migrate UserRepository to JPA Criteria API
Reduces query complexity and eliminates raw string SQL. No behavior
change — all existing tests pass."
git add src/service/AddressValidationService.java \
src/service/AddressValidationServiceTest.java
git commit -m "feat: add address validation before order submission
UK postcodes were being accepted in US-only address fields, causing
failed shipments. Added format validation per ISO 3166-1 country
codes. Validated against AddressValidationServiceTest."
A Real Commit Message, Annotated
The Conventional Commits specification (conventionalcommits.org) gives the message a consistent, machine-readable structure. Here's a full example:
fix(payment): prevent duplicate charges on gateway timeout
The payment gateway occasionally returns HTTP 504 after successfully
processing a charge. Our retry logic treated the timeout as a failure
and submitted a second charge, resulting in duplicate billing.
Fixed by adding an idempotency key derived from order_id + attempt
number to every charge request. The gateway deduplicates requests
with the same idempotency key within a 24-hour window.
Affected flow: POST /api/v2/orders/:id/pay → PaymentGatewayClient#charge
Refs: incident-2026-03-14, #PRD-1841
Fixes: #2047
Breaking it down:
fix(payment):— type and scope. Enables automated changelogs. Types: feat, fix, perf, refactor, chore, docs, test.- First line — present tense, imperative, under 72 characters, explains the what.
- Blank line — required separator before body.
- Body — explains the why and the context. The diff shows what changed; the body explains why that change was the right fix.
- Footer — ticket references. These enable your issue tracker to cross-link commits automatically.
The Boundary Case: How Big Is Too Big
A commit is too big when:
- The subject line requires "and" to describe what it does
- The changes span multiple unrelated areas of the system
- You can't summarize it in 72 characters without losing something important
# Too big — two separate concerns crammed together
feat: add OAuth login and fix dashboard pagination bug
# Should be two commits:
feat(auth): add OAuth2 login via Google and GitHub
fix(dashboard): correct off-by-one in pagination when total < page_size
A commit is too small when it's a fixup of another commit on the same branch that hasn't been merged yet. Three commits that say "add test", "fix test", "actually fix test" should be one commit via git rebase -i before merging.
Code Examples That Belong in Commit Messages
For complex changes, a short code snippet in the commit body is worth more than paragraphs of prose:
refactor(cache): replace synchronized block with ConcurrentHashMap
The synchronized block on the cache map was a bottleneck under
high read concurrency. Replaced with ConcurrentHashMap#computeIfAbsent
which uses segment-level locking.
Before (under 200 concurrent reads on 32-core machine):
p99 latency: 340ms
After (same conditions):
p99 latency: 12ms
No API changes. Thread safety verified by CacheConcurrencyTest.
You don't need this level of detail for a two-line bug fix. You do need it when the change has non-obvious performance implications or when the alternative approach was deliberately rejected.
What Good Looks Like Across Commit Types
Feature commit:
feat(invoicing): add PDF export for invoice history
Customers on Enterprise plan have been requesting downloadable
invoices for accounting systems. Generates PDF via iText 8
with company branding and line-item breakdown.
New endpoint: GET /api/v1/invoices/:id/pdf
Requires: invoice:export permission
Rate limited: 60 requests/hour per account
Chore/dependency update:
chore(deps): upgrade Spring Boot 3.1.x → 3.3.2
Picks up CVE-2024-22257 fix (Spring Security) and Micrometer 1.13
with virtual thread support. No API changes required.
Tested: full integration test suite passes locally.
Migration notes: none for current usage patterns.
Revert:
revert: revert "perf: increase user cache TTL to 24h"
This reverts commit a3f9d24. The 24h TTL caused stale permission
data in staging — users retained access after role revocation.
Reverting to 15m TTL while we investigate a targeted cache
invalidation approach.
The Workflow That Makes This Stick
Good commits require staging discipline. If you always git add . and commit everything at once, you can't be atomic. The tools that help:
git add -p— stages individual hunks, not files. Lets you separate unrelated changes in the same file.git commit --amend— rewrites the last commit. Use it before pushing to fix messages or add a missed file.git rebase -i origin/main— squash fixup commits, reorder, reword. Run this before every PR.
The goal is not ceremonial rigor. It's a commit history that a developer can navigate two years from now without having to ping anyone for context. That's the bar.