AverageDevs
Git

Conventional Commits in Git: Clean History, Automated Releases

A practical guide to the Conventional Commits spec - types, scopes, breaking changes, linting with commitlint + Husky, and automating changelogs and releases.

Conventional Commits in Git: Clean History, Automated Releases

Conventional Commits is a lightweight convention for structuring commit messages so your history is readable by humans and parsable by tools. With it, you can auto‑generate changelogs, version numbers, and even publish releases without manual bookkeeping.

TL;DR

  • Format: type(scope)?: subject + optional body + optional footer.
  • Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert.
  • Breaking changes: add ! after type/scope or include a BREAKING CHANGE: footer.
  • Lint with @commitlint/config-conventional and Husky to enforce the spec.
  • Automate changelogs and versioning with semantic-release (or conventional-changelog).

The Spec (Quick Overview)

Anatomy:

type(scope)?: subject

body (optional, wrapped to ~72 chars)

footer (optional)
BREAKING CHANGE: description (if any)
Refs: #123

Rules that matter:

  • type: one of the conventional types (see cheat sheet below).
  • scope: optional, lower‑case identifier like auth, billing, ui/button.
  • subject: imperative, lower case, no trailing period.
  • breaking: mark with ! (e.g., feat(auth)!:) or a BREAKING CHANGE: footer.

Examples:

feat(search): add debounced SearchInput and result highlighting

fix(blog): correct sitemap lastModified for MDX posts

refactor(ui/card): remove variant prop in favor of intent

perf(image): inline critical hero image to improve LCP

revert: revert "feat(search): add debounced SearchInput and result highlighting"

Breaking Changes

Two ways to signal a breaking change:

  1. Punctuation: feat(api)!: drop legacy v1 endpoints
  2. Footer: add a section in the message body:
feat(api): drop legacy v1 endpoints

BREAKING CHANGE: The /v1 routes are removed. Use /v2 equivalents.

Why it matters: release tools bump major versions automatically when they see breaking changes.

Enforce the Convention Locally (commitlint + Husky)

Install dev dependencies:

pnpm add -D @commitlint/cli @commitlint/config-conventional husky

Add commitlint config (TS or JS). Minimal JS config:

// commitlint.config.cjs
module.exports = { extends: ["@commitlint/config-conventional"] };

Initialize Husky and add a commit-msg hook:

pnpm dlx husky init
echo "pnpm commitlint --edit \"$1\"" > .husky/commit-msg
chmod +x .husky/commit-msg

Optional: add a prepare script so hooks install after pnpm install:

{
  "scripts": {
    "prepare": "husky"
  }
}

Now any commit failing the convention will be rejected with a clear error.

Auto‑Generate Changelogs and Releases (semantic‑release)

semantic-release reads your Conventional Commits and decides the next version:

  • feat → minor bump
  • fix → patch bump
  • BREAKING CHANGE / ! → major bump

Install:

pnpm add -D semantic-release @semantic-release/changelog @semantic-release/git @semantic-release/npm @semantic-release/github

Add a basic config:

// release.config.cjs
module.exports = {
  branches: ["main"],
  plugins: [
    ["@semantic-release/commit-analyzer", { preset: "conventionalcommits" }],
    ["@semantic-release/release-notes-generator", { preset: "conventionalcommits" }],
    ["@semantic-release/changelog", { changelogFile: "CHANGELOG.md" }],
    ["@semantic-release/npm", {}],
    ["@semantic-release/github", {}],
    ["@semantic-release/git", { assets: ["CHANGELOG.md", "package.json", "pnpm-lock.yaml"], message: "chore(release): ${nextRelease.version}\n\n${nextRelease.notes}" }],
  ],
};

Run locally (dry run) or in CI:

pnpm semantic-release --dry-run

In CI, add a job on main that runs semantic-release with a token that can create GitHub releases and push tags.

Conventional Commits in PRs and Squash Merges

  • Prefer squash merges and ensure the final squash message follows the convention.
  • Keep the PR title conventional; many bots validate PR titles (conventional-pr checks).
  • If you must merge multiple commits, ensure each respects the spec for a clean history.

Monorepos and Scopes

  • Use scope to denote package or app: feat(web), fix(api), build(repo).
  • Some release tools can map scopes to independent packages; check semantic-release monorepo setups or changesets if you need per‑package versioning.

Cheat Sheet of Types

  • feat: user‑facing feature
  • fix: bug fix
  • docs: documentation only
  • style: formatting (no code meaning changes)
  • refactor: code change that neither fixes a bug nor adds a feature
  • perf: performance improvements
  • test: add/modify tests
  • build: build system or external dependencies changes
  • ci: CI configuration changes
  • chore: maintenance tasks (no src/test changes)
  • revert: revert a previous commit

Pitfalls and How to Avoid Them

  • Too‑broad scopes: prefer ui/card over ui for clarity.
  • Stuffing multiple concerns into one commit: split refactor vs feature.
  • Forgetting BREAKING markers: if you remove/rename public APIs, mark it.
  • Overusing chore: if it’s user‑facing, it’s probably feat or fix.

Minimal Team Checklist

  1. Add commitlint + Husky and block non‑conforming messages.
  2. Use scopes meaningful to your architecture (app/package/feature).
  3. Enable semantic‑release in CI for automatic versioning and changelogs.
  4. Prefer squash merges; ensure PR titles follow the convention.
  5. Document your team’s examples in docs/commits.md and link from the README.

Adopting Conventional Commits takes an hour and pays off forever. Your history becomes searchable, your changelogs write themselves, and releases are predictable - all while keeping the team aligned on what shipped and why.