Writing Good Commit Messages in Git
1. Overview
Commit messages are what you’ll revisit most frequently when debugging, reviewing code, or writing changelogs — more often than the code you just wrote. This guide helps you master standard commit message structure, apply Conventional Commits, and automatically validate messages using commitlint + husky. Once you finish, you’ll write clear, consistent commits, and your team can generate changelogs automatically from git history.
2. Why commit messages matter
A good commit message answers three things: what changed, why it changed, and what the impact is. Without it, you have to git show each commit to guess the author’s intent — far longer than writing a decent message from the start.
Compare two messages for the same change:
# Bad
fix bug
# Good
fix(payment): handle null pointer when PaymentService.charge() receives Order without Customer
The bad message doesn’t say what bug or where. The good message shows the module (payment), the action (fix), and the specific reason — read in 5 seconds and you understand.
3. Standard commit message structure
Git recommends a 3-part structure: subject (title), body (content), footer (footer).
<type>(<scope>): <subject>
<body>
<footer>
subject: brief summary, ≤ 50 characters, write in present tense (“add” not “added”).body: explain why you changed, not what (the diff already shows what).footer: metadata — breaking changes, related issues, co-authors.
Full example:
fix(order): avoid double-charge when retrying failed payment
PaymentService.charge() has no idempotency key, so when
network timeout occurs and the client retries, the system calls the
API gateway twice for the same Order.
Add idempotencyKey = order.getId() + "-" + attemptCount
to request header so gateway auto-dedupes.
Closes #482
3.1. Subject line
The subject line is the most important — it shows up in git log --oneline, on PR titles, and in changelogs. Rules:
# Wrong - capitalized, period at end, past tense
Fixed the bug in order service.
# Correct - lowercase, no period, present tense
fix: order doesn't deduct inventory when paying via e-wallet
Note: The 50 character limit isn’t a hard rule, but exceeding 72 characters will be truncated on many terminals and GitHub UI. Try to be concise.
3.2. Body section
The body explains context — something the code diff can’t convey: why you chose this solution, why alternatives were ruled out, any side effects.
refactor(cart): move CartService from sync to async
Previously CartService.checkout() called InventoryService and
ShippingService sequentially, total latency ~800ms. Moved to
CompletableFuture running both calls in parallel, reduced latency
to ~450ms.
No change to public API, only internal implementation.
Separate the subject and body with a blank line — git and other tools parsing commits rely on this convention.
4. Conventional Commits
Conventional Commits is the most popular standard today, defining fixed type values so tools can automatically categorize and generate changelogs.
<type>[optional scope]: <description>
[optional body]
[optional footer(s)]
Common type values:
| Type | Meaning |
|---|---|
feat |
add new feature |
fix |
fix a bug |
docs |
documentation changes only |
style |
code formatting, no logic change |
refactor |
restructure code, no behavior change |
test |
add/fix tests |
chore |
miscellaneous (build, dependencies, config) |
perf |
performance improvement |
Example with scope as module name:
feat(auth): support Google OAuth2 login
Add GoogleOAuth2Provider implementing OAuth2LoginProvider.
Add client-id, client-secret config in
application.properties.
4.1. Breaking change
When a change breaks backward compatibility, mark it with ! after type/scope or add footer BREAKING CHANGE:.
feat(api)!: change GET /orders/{id} response to OrderDto
BREAKING CHANGE: field "customerName" changed to object
"customer" containing id, name, email. Clients must update parser.
Semantic-release and CI/CD tools use the BREAKING CHANGE: line to auto-bump major version.
5. Automatically validate commit messages
Good conventions only work if your team follows them consistently. Use commitlint to block incorrectly formatted commits locally before pushing.
<!-- Not applicable to Node projects, example only if using Maven wrapper for husky hooks -->
In practice commitlint runs on Node, install via npm:
npm install --save-dev @commitlint/cli @commitlint/config-conventional husky
Create a config file:
// commitlint.config.js
module.exports = { extends: ['@commitlint/config-conventional'] };
Attach the commit-msg hook via husky:
npx husky init
echo 'npx --no -- commitlint --edit "$1"' > .husky/commit-msg
Now try committing with wrong format:
git commit -m "fixed bug"
⧗ input: fixed bug
✖ subject may not be empty [subject-empty]
✖ type must be one of [feat, fix, docs, style, refactor, test, chore, perf]
✖ found 2 problems, 0 warnings
husky - commit-msg script failed (code 1)
The commit is blocked immediately, forcing you to fix the format before committing.
5.1. Using with Java/Spring Boot projects
If your project uses Maven, you can still use husky + commitlint in parallel (they only need Node runtime, they don’t touch Java builds). Or if you prefer pure Java, write a simple validation script and call it via core.hooksPath:
#!/bin/bash
# .githooks/commit-msg
PATTERN="^(feat|fix|docs|style|refactor|test|chore|perf)(\([a-z-]+\))?: .{1,50}"
MSG=$(cat "$1")
if ! [[ "$MSG" =~ $PATTERN ]]; then
echo "Commit message doesn't follow Conventional Commits format" >&2
exit 1
fi
git config core.hooksPath .githooks
chmod +x .githooks/commit-msg
5.2. Writing tests for validation scripts
If you maintain your own validation script (not using commitlint), test that regex. For example, encapsulate the parsing logic into a Java class for reuse in CI:
public class CommitMessageValidator {
private static final Pattern PATTERN =
Pattern.compile("^(feat|fix|docs|style|refactor|test|chore|perf)" +
"(\\([a-z0-9-]+\\))?: .{1,50}");
public boolean isValid(String message) {
String subject = message.lines().findFirst().orElse("");
return PATTERN.matcher(subject).find();
}
}
class CommitMessageValidatorTest {
private final CommitMessageValidator validator = new CommitMessageValidator();
@Test
void shouldAcceptConventionalFormat() {
boolean result = validator.isValid("fix(order): avoid double-charge when retrying");
assertThat(result).isTrue();
}
@Test
void shouldRejectMessageWithoutType() {
boolean result = validator.isValid("fixed the bug");
assertThat(result).isFalse();
}
@Test
void shouldRejectUnknownType() {
boolean result = validator.isValid("update: add something");
assertThat(result).isFalse();
}
}
These tests ensure the validation logic won’t regress when you update the regex later — for example, adding a new type but forgetting to update tests.
6. Common mistakes
Oversized commits with vague messages. “update code” for a commit touching 15 different files signals you should split into smaller commits, each with one clear purpose (atomic commits).
Mixing multiple change types in one commit. Both fix a bug and refactor unrelated code in the same commit makes git revert and git bisect harder — reverting a fix might accidentally remove the refactor.
Body repeating the subject. The body should add context, not rephrase the first line with longer sentences.
# Repeating, pointless
fix(order): fix order total calculation error
Fix the error in calculating order total in OrderService.
Using emoji/decorations instead of content. Emoji isn’t wrong, but if you use it to replace actual description, auto-generated changelog will lack information.
Note: Conventional Commits works best for teams with CI/CD auto-generating changelogs or semantic versioning. For small teams with few commits, overly rigid rules may cause unnecessary friction — prioritize clarity over mechanical compliance.
7. Conclusion
Good commit messages don’t need to be long, just follow the structure: brief subject in present tense, body explaining the reasoning, footer with necessary metadata. Applying Conventional Commits helps your team read git history faster and lets tools automate changelog and versioning. Set up commitlint or custom git hooks to turn conventions into automatic constraints, removing the dependency on individual team members’ discipline.