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:

  1. Changes you’ve staged with git add live in the index (a staging file at .git/index), not yet in a permanent snapshot.
  2. git commit writes a new tree object representing the full directory snapshot implied by the index (reusing unchanged sub-trees and blobs where possible).
  3. 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 -m flags joined into paragraphs, or from whatever you typed and saved in your editor if you ran git commit with no -m at all (lines starting with # in that editor buffer are stripped before saving).
  4. Git hashes the full object content to produce the commit’s SHA, then writes the object into .git/objects.
  5. The ref your HEAD points to (typically refs/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

  1. You fixed a typo in README.md and 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~1 will unstage the commit’s changes so you can re-add and commit each concern separately.
  2. 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 a BREAKING CHANGE footer and reference issue #217.
  3. You just ran git commit -m "fix bug" on a commit you have not pushed yet. Write the git commit --amend command that replaces it with a properly formatted message describing a fix for a null-pointer exception in the checkout module’s applyDiscount function.

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, or chore, optionally scoped, enabling automated changelogs and version bumps.
  • Multiple -m flags on git commit build 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-lease over bare --force whenever you must update history that’s already been pushed.