DEV Community

楊東霖
楊東霖

Posted on • Originally published at devplaybook.cc

Git Commit Message Best Practices + How AI Can Help You Write Better Commits

Every commit message is a note to your future self—and your future teammates. Write them well and git log becomes a searchable history of why your codebase is the way it is. Write them poorly and you're left with a wall of "fix stuff" and "wip" that tells you nothing when a production bug needs tracing at 3 AM.

This guide covers the fundamentals, the industry-standard Conventional Commits specification, practical examples across different change types, and how AI tools are changing the way developers generate commit messages.


Why Commit Messages Matter

Before diving into format, it's worth being concrete about when commit messages actually get read—because understanding the use cases shapes what makes a good message.

git log — Historical Context

git log --oneline --graph --all
Enter fullscreen mode Exit fullscreen mode

A well-written log reads like a project changelog:

a3f9b2c feat(auth): add OAuth2 login with Google provider
e1c8d45 fix(api): handle null response from payment gateway
7b3a019 refactor(db): replace raw SQL with Knex query builder
2f1e883 chore(deps): upgrade Express from 4.18 to 5.0
Enter fullscreen mode Exit fullscreen mode

A poorly maintained log reads like:

a3f9b2c fix
e1c8d45 update
7b3a019 wip
2f1e883 asdf
Enter fullscreen mode Exit fullscreen mode

The second version is actively harmful—it's worse than no history because it creates the illusion of version control while providing none of the benefits.

git blame — Understanding Decisions

When you find a puzzling piece of code, git blame tells you who wrote it and in which commit. The commit message then tells you why. A message like "fix: skip empty arrays to prevent downstream serialization crash" saves 20 minutes of archaeology every time someone touches that code path.

git bisect — Finding Regressions

git bisect lets you binary-search your commit history to find exactly which commit introduced a bug. This is only useful if the commit messages tell you what each commit actually changed. With meaningful messages, you can skip whole ranges of commits confidently.

git bisect start
git bisect bad HEAD
git bisect good v2.1.0
# git bisect tests commits between v2.1.0 and HEAD
# Readable messages let you intelligently mark good/bad
Enter fullscreen mode Exit fullscreen mode

Changelogs and Release Notes

Tools like conventional-changelog and semantic-release generate changelogs automatically from commit messages. This only works if your messages follow a consistent format—and the output is only as useful as the input messages are specific.


The Conventional Commits Specification

Conventional Commits is the most widely adopted commit message standard. It's simple enough to remember, structured enough for tooling, and expressive enough for real-world use.

Format

<type>[optional scope]: <description>

[optional body]

[optional footer(s)]
Enter fullscreen mode Exit fullscreen mode

Types

Type When to Use
feat New feature or capability
fix Bug fix
refactor Code change that neither fixes a bug nor adds a feature
perf Performance improvement
test Adding or updating tests
docs Documentation changes only
style Formatting, whitespace (no logic change)
chore Build process, dependency updates, tooling
ci CI/CD pipeline changes
revert Reverting a previous commit

Scope (Optional)

The scope is a noun describing the part of the codebase affected:

feat(auth): ...
fix(api/users): ...
refactor(database): ...
chore(deps): ...
Enter fullscreen mode Exit fullscreen mode

Keep scopes consistent within your project. Pick from a defined list and add to it as the codebase grows.

Breaking Changes

Breaking changes are indicated with ! after the type/scope, or a BREAKING CHANGE: footer:

feat(api)!: change user endpoint response format

BREAKING CHANGE: The /api/users endpoint now returns `firstName` and `lastName`
instead of the previously used `name` field. Clients must update accordingly.
Enter fullscreen mode Exit fullscreen mode

Good vs. Bad Commit Examples

Bug Fixes

Bad:

fix bug
Enter fullscreen mode Exit fullscreen mode
fixed the thing
Enter fullscreen mode Exit fullscreen mode
update login
Enter fullscreen mode Exit fullscreen mode

Good:

fix(auth): redirect to login when JWT expires instead of showing blank page
Enter fullscreen mode Exit fullscreen mode
fix(cart): prevent duplicate line items when clicking add button rapidly
Enter fullscreen mode Exit fullscreen mode
fix(api): return 404 instead of 500 when user ID does not exist
Enter fullscreen mode Exit fullscreen mode

The good examples tell you: what was broken, where it was broken, and what the correct behavior now is.

New Features

Bad:

add feature
Enter fullscreen mode Exit fullscreen mode
new stuff
Enter fullscreen mode Exit fullscreen mode

Good:

feat(dashboard): add export to CSV button for filtered reports
Enter fullscreen mode Exit fullscreen mode
feat(notifications): send email digest for unread messages after 24h
Enter fullscreen mode Exit fullscreen mode
feat(search): add fuzzy matching for product name queries
Enter fullscreen mode Exit fullscreen mode

Refactors

Refactors are often the worst-documented commits because "nothing changed" externally. But understanding why code was restructured is valuable.

Bad:

refactor
Enter fullscreen mode Exit fullscreen mode
clean up code
Enter fullscreen mode Exit fullscreen mode

Good:

refactor(payment): extract Stripe integration into dedicated service class
Enter fullscreen mode Exit fullscreen mode
refactor(auth): replace custom session logic with express-session middleware
Enter fullscreen mode Exit fullscreen mode
refactor(db): consolidate 4 user query functions into UserRepository pattern
Enter fullscreen mode Exit fullscreen mode

Breaking Changes

Bad:

update API
Enter fullscreen mode Exit fullscreen mode

Good:

feat(api)!: require authentication on all /v2 endpoints

Previously, several v2 endpoints were publicly accessible. This commit
requires a valid Bearer token on all routes under /v2/*.

BREAKING CHANGE: Clients using /v2/products or /v2/categories without
authentication will receive 401 Unauthorized instead of 200 OK.
Migration: add Authorization header with a valid API key.
Enter fullscreen mode Exit fullscreen mode

Writing Commits for Different Contexts

Fixing a Production Bug

Include enough context to understand the incident:

fix(orders): prevent double-charge when payment gateway returns timeout

The payment processor occasionally returns a timeout after successfully
charging the card, causing retry logic to attempt a second charge.

Added idempotency key per Stripe documentation to prevent duplicate charges.
Idempotency key = `order_{id}_{timestamp_created}`.

Fixes #1847
Related to: https://stripe.com/docs/api/idempotent_requests
Enter fullscreen mode Exit fullscreen mode

Implementing a Feature

Reference the ticket, describe the behavior, note any decisions made:

feat(subscriptions): add annual billing option with 20% discount

Users can now select annual billing during checkout and subscription upgrade.
Pricing is calculated as (monthly_price * 12 * 0.80).

- Added annual toggle to PricingCard component
- Updated SubscriptionService to handle ANNUAL billing_period
- Annual subscriptions renew on the same date each year (not 365 days)

Implements VIC-234
Enter fullscreen mode Exit fullscreen mode

Dependency Updates

Don't just write chore(deps): bump packages. Note what changed and why:

chore(deps): upgrade Prisma from 5.x to 6.0

Prisma 6.0 includes breaking changes to relation queries.
Updated all findUnique calls that used include with nested where clauses
to use the new findFirst syntax.

See migration guide: https://pris.ly/d/prisma-6-migration
Enter fullscreen mode Exit fullscreen mode

AI-Assisted Commit Messages

AI tools have become genuinely useful for commit messages—not to replace developer judgment, but to generate a solid first draft from the diff.

Using Claude via CLI

If you use Claude Code or the Claude API:

# Stage your changes first
git add -p  # or git add specific files

# Get Claude to generate a commit message from the diff
git diff --cached | claude -p "Write a conventional commit message for this diff. Use the format: type(scope): description. Be specific about what changed and why."
Enter fullscreen mode Exit fullscreen mode

Claude is particularly good at:

  • Synthesizing multiple file changes into a coherent description
  • Identifying the "why" from context (variable names, comments, test descriptions)
  • Flagging when a set of changes is doing too many things (a sign you should split the commit)

Using GitHub Copilot

In VS Code with Copilot, the Source Control panel includes a "Generate Commit Message" button (sparkle icon) that analyzes your staged diff and generates a message. The quality is good for straightforward changes and improves when you give it context by reviewing the suggestions and editing.

Practical AI Commit Workflow

The best workflow isn't "let AI write all my commits"—it's using AI for the first draft and reviewing it:

  1. Stage your changes (git add -p to stage interactively)
  2. Generate a draft message (AI tool of choice)
  3. Read the draft and ask: Is this specific enough? Does it explain why? Is it one logical change?
  4. Edit as needed and commit

This gives you speed without sacrificing the quality that comes from developer judgment.

Prompt Templates for Better AI Commits

Prompt: "Analyze this git diff and write a Conventional Commits message.
Requirements:
- Use format: type(scope): short description (max 72 chars)
- Add a body explaining why this change was necessary
- Note any breaking changes
- Reference the affected component/module as the scope
- Be specific about behavior changes, not just code changes

Diff:
[paste diff here]"
Enter fullscreen mode Exit fullscreen mode

Pre-Commit Hooks for Linting Commit Messages

Automated linting enforces your team's commit conventions without relying on everyone remembering the rules.

commitlint

# Install
npm install --save-dev @commitlint/cli @commitlint/config-conventional

# Configure
echo "module.exports = { extends: ['@commitlint/config-conventional'] };" > commitlint.config.js
Enter fullscreen mode Exit fullscreen mode

husky Integration

# Install husky
npm install --save-dev husky

# Initialize
npx husky init

# Add commit-msg hook
echo "npx --no -- commitlint --edit \$1" > .husky/commit-msg
chmod +x .husky/commit-msg
Enter fullscreen mode Exit fullscreen mode

Now if you try to commit with a non-conforming message:

git commit -m "fix stuff"
# ⧗   input: fix stuff
# ✖   subject may not be empty [subject-empty]
# ✖   type may not be empty [type-empty]
# ✖   found 2 problems, 0 warnings
Enter fullscreen mode Exit fullscreen mode

You're blocked until you write a proper message. For teams new to Conventional Commits, this immediate feedback loop teaches the format faster than documentation alone.

Custom Rules

// commitlint.config.js
module.exports = {
  extends: ["@commitlint/config-conventional"],
  rules: {
    // Enforce your team's scope list
    "scope-enum": [
      2,
      "always",
      ["auth", "api", "dashboard", "db", "deps", "ci", "docs"],
    ],
    // Subject line max length
    "header-max-length": [2, "always", 100],
    // Require non-empty body for certain types
    "body-min-length": [1, "always", 0],
  },
};
Enter fullscreen mode Exit fullscreen mode

Setting Up a .gitmessage Template

A .gitmessage file is loaded as the default content of your commit message editor, providing a template to fill in:

# Create the template
cat > ~/.gitmessage << 'EOF'
# type(scope): Short description (max 72 chars)
# Types: feat|fix|refactor|perf|test|docs|style|chore|ci|revert
#
# Body: Why was this change necessary? What problem does it solve?
# (Leave blank if the subject line is sufficient)
#
# Footer: References, breaking changes
# BREAKING CHANGE: description
# Fixes #123
# Closes VIC-456
EOF

# Tell git to use it
git config --global commit.template ~/.gitmessage
Enter fullscreen mode Exit fullscreen mode

Now every git commit (without -m) opens your editor with this template. The comments are stripped from the final message.


Team Conventions Checklist

Before shipping your commit message standards to your team:

  • [ ] Defined list of allowed types (standard Conventional Commits + any custom)
  • [ ] Defined list of scopes (or decision to allow free-form scopes)
  • [ ] Subject line length limit (72 characters is standard)
  • [ ] Breaking change convention (! suffix and/or BREAKING CHANGE: footer)
  • [ ] Whether to require issue/ticket references
  • [ ] commitlint or similar tool configured
  • [ ] .gitmessage template shared via dotfiles repo or onboarding docs
  • [ ] CI check that validates PR title (since squash merges use PR titles as commit messages)

Quick Reference

# Perfect commit anatomy
feat(auth): add password reset via email link

Users who forget their password can now request a reset link via email.
The link expires after 1 hour and is single-use.

- Added /auth/reset-password endpoint
- Added PasswordResetToken model with TTL index
- Email template uses existing transactional email service

Implements #234
BREAKING CHANGE: none
Enter fullscreen mode Exit fullscreen mode

The subject line answers: What changed?
The body answers: Why did it change?
The footer answers: What does this connect to?

Get all three right and your git log becomes the best documentation your codebase has.


Use devplaybook.cc tools to streamline your development workflow. The devplaybook.cc/tools/git-commit-generator tool can help generate Conventional Commits-formatted messages from your change descriptions.


Level Up Your Dev Workflow

Found this useful? Explore DevPlaybook — cheat sheets, tool comparisons, and hands-on guides for modern developers.

🛒 Get the DevToolkit Starter Kit on Gumroad — 40+ browser-based dev tools, source code + deployment guide included.

Top comments (0)