Commit Message Conventions
A commit message is the label you attach to a snapshot of your project’s history, and it is often the only context a teammate — or you, six months from now — will have for why a change was made. Git does not enforce any particular format on this text beyond storing whatever string you give it, so the quality of a project’s history depends entirely on the discipline of the people committing to it. This lesson covers the traditional structure for a good commit message, the widely used Conventional Commits specification, and exactly how Git stores the message inside the commit object itself.
Overview: Why Commit Message Conventions Matter
Every time you run git commit, Git creates a new commit object in its object database. That object is a small block of text containing a pointer to a tree (the snapshot of every file at that moment), a pointer to the previous commit or commits (its parents), the author’s name, email, and timestamp, the committer’s name, email, and timestamp, and — critically — the commit message you typed. All of that text is fed through a hash function (SHA-1 in classic Git, with SHA-256 supported in newer repositories) to produce the commit’s unique ID. The message is not a side note stored elsewhere; it is baked into the object’s identity. Change even one character of the message and you get a completely different commit hash.
Because the message lives inside the object forever, sloppy messages compound. A history full of commits named fix, wip, and update stuff is nearly useless when you’re trying to find which change introduced a bug with git bisect, generating a changelog, or onboarding a new teammate who is trying to understand why a line of code exists. Two conventions have become the de facto standard for solving this: the classic 50/72 rule (a short imperative subject line, a blank line, then a wrapped explanatory body), and the more structured Conventional Commits specification, which prefixes the subject with a machine-readable type such as feat or fix. Many teams use both together, and tools like semantic-release or GitHub’s auto-generated release notes can parse Conventional Commits to bump version numbers and build changelogs automatically.
Syntax: The Anatomy of a Commit Message
A well-formed commit message has up to three parts, separated by blank lines:
| Part | Rule |
|---|---|
| Subject line | ~50 characters, imperative mood, no trailing period, capitalized |
| Body (optional) | Blank line after the subject, then prose wrapped at ~72 characters explaining what and why |
| Footer (optional) | Blank line after the body, then metadata like Closes #123 or BREAKING CHANGE: ... |
The Conventional Commits specification structures the subject line itself:
<type>[optional scope]: <short summary>
[optional body]
[optional footer(s)]
Common type values:
| Type | Meaning |
|---|---|
feat |
A new feature |
fix |
A bug fix |
docs |
Documentation only changes |
style |
Formatting, whitespace, no logic change |
refactor |
Code change that neither fixes a bug nor adds a feature |
perf |
A performance improvement |
test |
Adding or correcting tests |
build |
Build system or dependency changes |
ci |
Continuous integration configuration changes |
chore |
Maintenance work with no production code change |
revert |
Reverts a previous commit |
The optional scope in parentheses names the affected area (auth, api, checkout). A ! after the type/scope, or a BREAKING CHANGE: footer, flags a breaking API change.
Examples
Example 1: A simple imperative subject line
git add navbar.js
git commit -m "Add login button to navbar"
Output:
[main a1b2c3d] Add login button to navbar
1 file changed, 12 insertions(+), 1 deletion(-)
This is the minimum viable commit message: a short, capitalized, imperative sentence. Notice it reads naturally after “If applied, this commit will …” — that’s the imperative-mood test.
Example 2: A Conventional Commit with a scope
git add src/auth/tokenRefresh.js
git commit -m "feat(auth): add JWT token refresh logic"
Output:
[main 6e2f9a4] feat(auth): add JWT token refresh logic
1 file changed, 34 insertions(+)
The feat type tells anyone scanning the log (or a changelog generator) that this commit adds new user-facing functionality, and (auth) narrows it to the authentication module.
Example 3: A full message with body and footer
git commit -m "feat(api): add rate limiting to public endpoints" \
-m "Add a token bucket limiter to the /api/v1/* routes to prevent abuse from unauthenticated clients. The limit is configurable via the RATE_LIMIT_RPM environment variable." \
-m "Closes #482" \
-m "BREAKING CHANGE: unauthenticated requests are now capped at 60 requests per minute; previously there was no limit."
Output (from git log -1):
commit 7f3e9d2a1b8c6f5e4d3c2b1a0f9e8d7c6b5a4f3e
Author: Priya Shah <priya@example.com>
Date: Mon Aug 3 10:15:00 2026 -0500
feat(api): add rate limiting to public endpoints
Add a token bucket limiter to the /api/v1/* routes to prevent abuse
from unauthenticated clients. The limit is configurable via the
RATE_LIMIT_RPM environment variable.
Closes #482
BREAKING CHANGE: unauthenticated requests are now capped at 60
requests per minute; previously there was no limit.
Passing -m multiple times tells Git to treat each string as its own paragraph, separated by a blank line — a quick way to build a subject, body, and footer without opening an editor. The Closes #482 footer will automatically close issue #482 on GitHub when this commit is merged into the default branch, and the BREAKING CHANGE: footer signals tooling (and human readers) that this is not a backward-compatible change.
Example 4: Amending a commit message
git commit --amend -m "fix(auth): correct off-by-one error in token expiry check"
Output:
[main 9c4f2e1] fix(auth): correct off-by-one error in token expiry check
Date: Mon Aug 3 10:22:11 2026 -0500
1 file changed, 2 insertions(+), 2 deletions(-)
--amend replaces the most recent commit with a new one that has the corrected message (and the same or updated content if you also staged changes first). Because the message is part of what gets hashed, this produces a brand-new commit hash — it does not edit the old commit in place. Only amend commits that are still local; amending a commit that has already been pushed and rewriting shared history requires a force push, which is discussed in Common Mistakes below.
How It Works Step by Step
Understanding what git commit -m "…" actually does demystifies why the message matters so much:
- Changes you’ve staged with
git addlive in the index (a staging file at.git/index), not yet in a permanent snapshot. git commitwrites a new tree object representing the full directory snapshot implied by the index (reusing unchanged sub-trees and blobs where possible).- Git assembles a commit object: the new tree’s hash, the current commit’s hash as the
parent(two parents for a merge commit), the author line, the committer line, and the message text you supplied — from-m, from multiple-mflags joined into paragraphs, or from whatever you typed and saved in your editor if you rangit commitwith no-mat all (lines starting with#in that editor buffer are stripped before saving). - Git hashes the full object content to produce the commit’s SHA, then writes the object into
.git/objects. - The ref your
HEADpoints to (typicallyrefs/heads/main) is updated to the new commit hash — this is literally what people mean when they say “the branch pointer moves forward.” A branch is nothing more than a file containing a commit hash.
You can see the raw object Git just created with git cat-file:
git cat-file -p HEAD
Output:
tree 9d5a1c4e8f2b3a6d7c8e9f0a1b2c3d4e5f6a7b8c
parent 3f8e2a1b9c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f
author Priya Shah <priya@example.com> 1785000000 -0500
committer Priya Shah <priya@example.com> 1785000000 -0500
feat(auth): add JWT token refresh logic
Add a background refresh call that renews the JWT five minutes
before expiry so users are not logged out mid-session.
This is exactly what lives inside the commit object: the tree pointer, the parent pointer, author and committer metadata, a blank line, and then your message verbatim. There is no separate “commit message database” — the message is the object.
Common Mistakes
Mistake 1: Vague, non-descriptive messages
git commit -m "fix stuff"
This tells a future reader (including you) nothing about what broke, what changed, or why. When you’re bisecting a regression six months later, fix stuff is indistinguishable from every other fix stuff in the log.
Fix — name the bug and the area it lives in:
git commit -m "fix(cart): prevent quantity field from accepting negative numbers"
Mistake 2: One giant commit mixing unrelated concerns
git commit -m "Updated checkout page to fix coupon total bug and also cleaned up some unrelated CSS"
This subject line is too long to read in git log --oneline, and it bundles a bug fix with an unrelated style cleanup. If the CSS change ever needs to be reverted independently of the bug fix, it can’t be — they’re welded together in one commit.
Fix — split the work into two focused commits, each with its own clear message:
git add src/checkout/total.js
git commit -m "fix(checkout): recalculate total after coupon is applied"
git add src/checkout/checkout.css
git commit -m "style(checkout): remove unused CSS classes"
Mistake 3: Amending or rebasing a commit that’s already shared
git commit --amend -m "fix(auth): correct token validation"
git push --force origin main
Amending rewrites the commit’s hash. If anyone else has already pulled the original commit, this force-push replaces the shared history under them; a bare --force will silently overwrite any commits they’ve pushed in the meantime that you haven’t seen. This is the rebase “golden rule” violated: never rewrite history that others have already built on.
Fix — only amend commits that are still local and unpushed, and if you must update shared history, use the safer force-push variant that refuses to overwrite commits you haven’t seen:
git push --force-with-lease origin main
Best Practices
- Write the subject line in the imperative mood (“Add”, “Fix”, “Remove”) as if finishing the sentence “If applied, this commit will …” — this matches Git’s own auto-generated messages like “Merge branch ‘feature/login-page'”.
- Keep the subject to roughly 50 characters, capitalize the first word, and don’t end it with a period.
- Always leave a blank line between the subject and the body —
git log --oneline, GitHub’s PR list, and other tooling rely on that blank line to know where the subject ends. - Wrap the body at about 72 characters so it displays cleanly in terminals,
git log, and diff tools that don’t auto-wrap. - Explain what changed and why in the body — the diff itself already shows how.
- Reference related issues or pull requests in the footer (
Closes #123,Refs #456) so GitHub auto-links them and closes them on merge. - Adopt Conventional Commits if your team wants automated changelogs or semantic-version bumps from tools like
semantic-release. - Keep each commit focused on one logical change so its message can stay short and specific.
- Set up a reusable commit template so the format is always in front of you when you write a message without
-m:
git config --global commit.template ~/.gitmessage
# <type>(<scope>): <short summary, imperative, ~50 chars>
#
# <body: explain what and why, wrap at 72 chars>
#
# <footer: Closes #123, BREAKING CHANGE: ...>
- Use commit-linting hooks (
commitlint,husky) to enforce the convention automatically before a commit is even created.
Practice Exercises
- You fixed a typo in
README.mdand separately added contact-form validation, but committed both together with the message"updates". Describe (or, if it’s still local, actually run) the commands you’d use to split this into two commits with proper Conventional Commits messages. Hint: if unpushed,git reset HEAD~1will unstage the commit’s changes so you can re-add and commit each concern separately. - Write a full Conventional Commits message (subject, body, and footer) for a change that removes the deprecated
getUserLegacy()function and breaks any caller that still uses it. It should include aBREAKING CHANGEfooter and reference issue #217. - You just ran
git commit -m "fix bug"on a commit you have not pushed yet. Write thegit commit --amendcommand that replaces it with a properly formatted message describing a fix for a null-pointer exception in the checkout module’sapplyDiscountfunction.
Summary
- A commit message is stored inside the commit object itself, alongside the tree pointer, parent pointer, and author/committer metadata — it’s part of what gets hashed into the commit’s SHA.
- The 50/72 rule: a short imperative subject (~50 chars), a blank line, then a body wrapped at ~72 characters explaining what and why.
- Conventional Commits prefix the subject with a type like
feat,fix, orchore, optionally scoped, enabling automated changelogs and version bumps. - Multiple
-mflags ongit commitbuild a subject, body, and footer without opening an editor. git cat-file -p <commit>lets you see the raw commit object, message included, exactly as Git stores it.- Avoid vague messages, mixing unrelated changes into one commit, and amending or force-pushing commits that others may already have.
- Prefer
git push --force-with-leaseover bare--forcewhenever you must update history that’s already been pushed.
