Commit Message Conventions: Conventional Commits, Angular Style, and Semantic Commits
Master commit message conventions including Conventional Commits, Angular style, and semantic commits. Learn automated changelog generation, linting enforcement, and team-wide standards.
Introduction
A commit message is the smallest unit of documentation in your codebase. Yet most teams treat it as an afterthought — a hasty git commit -m "fix stuff" that tells future maintainers absolutely nothing. Good commit message conventions change that. They turn your git log into a readable narrative, enable automated changelog generation, and make git bisect actually useful.
The Conventional Commits specification, inspired by Angular’s commit guidelines, has become the de facto standard for structured commit messages. It defines a lightweight syntax for commits that machines can parse and humans can read. When combined with tools like commitlint, husky, and semantic-release, it creates a fully automated release pipeline.
This post covers the Conventional Commits specification, Angular-style conventions, semantic commit practices, and how to enforce them across your team. If you’re managing a monorepo or publishing packages, this is foundational infrastructure.
When to Use / When Not to Use
Use Conventional Commits when:
- You want automated changelog generation
- You’re using semantic versioning with automated releases
- Your team has more than 2 contributors
- You need to filter commits by type for release notes
- You’re building a public-facing library or SDK
Skip them when:
- You’re the sole contributor on a personal project
- Your team actively resists the discipline (culture first, tooling second)
- You’re doing rapid prototyping where commit granularity doesn’t matter
Core Concepts
Conventional Commits defines a structured format:
<type>[optional scope]: <description>
[optional body]
[optional footer(s)]
The type is the most important field. It categorizes the change:
| Type | Description | SemVer Impact |
|---|---|---|
feat | New feature | MINOR bump |
fix | Bug fix | PATCH bump |
docs | Documentation only | None |
style | Formatting, missing semicolons, etc. | None |
refactor | Code change that neither fixes nor adds | None |
perf | Performance improvement | PATCH bump |
test | Adding or correcting tests | None |
build | Build system or external dependencies | None |
ci | CI/CD configuration changes | None |
chore | Other changes that don’t modify src or tests | None |
The scope is an optional noun describing the section of the codebase (e.g., feat(auth): add OAuth2 support).
The breaking change is signaled with ! after the type/scope or in the footer as BREAKING CHANGE:. This triggers a MAJOR version bump.
flowchart TD
A[Developer writes commit] --> B{Follows Conventional Commits?}
B -->|Yes| C[commitlint passes]
B -->|No| D[commitlint rejects]
C --> E[Commit accepted]
D --> F[Developer fixes message]
F --> B
E --> G[Push to remote]
G --> H[CI triggers release pipeline]
H --> I{Any feat commits?}
I -->|Yes| J[MINOR version bump]
I -->|No| K{Any fix commits?}
K -->|Yes| L[PATCH version bump]
K -->|No| M[No version bump]
J --> N[Generate changelog]
L --> N
N --> O[Create git tag]
O --> P[Publish release]
Architecture and Flow Diagram
sequenceDiagram
participant Dev as Developer
participant Git as Git Hook
participant Lint as commitlint
participant Repo as Repository
participant CI as CI Pipeline
participant Rel as semantic-release
Dev->>Git: git commit -m "feat(api): add rate limiting"
Git->>Lint: Run commit-msg hook
Lint->>Lint: Parse and validate message
Lint-->>Git: ✓ Valid
Git->>Repo: Create commit
Dev->>Repo: git push
Repo->>CI: Webhook trigger
CI->>Rel: Analyze commits since last tag
Rel->>Rel: Determine version bump
Rel->>Rel: Generate changelog
Rel->>Repo: Create tag + release
Rel-->>CI: Release published
Step-by-Step Guide
1. Install the Tooling
npm install --save-dev @commitlint/cli @commitlint/config-conventional
npm install --save-dev husky
2. Configure commitlint
Create commitlint.config.mjs:
export default {
extends: ["@commitlint/config-conventional"],
rules: {
"type-enum": [
2,
"always",
[
"feat",
"fix",
"docs",
"style",
"refactor",
"perf",
"test",
"build",
"ci",
"chore",
"revert",
],
],
"subject-case": [2, "never", ["start-case", "pascal-case", "upper-case"]],
"body-max-line-length": [1, "always", 100],
"footer-max-line-length": [1, "always", 100],
},
};
3. Set Up Husky Hooks
npx husky init
npx husky add .husky/commit-msg 'npx --no -- commitlint --edit ${1}'
4. Add a Commit Helper (Optional)
Install commitizen for interactive prompts:
npm install --save-dev commitizen cz-conventional-changelog
Add to package.json:
{
"config": {
"commitizen": {
"path": "cz-conventional-changelog"
}
}
}
Now run npx cz instead of git commit for guided commits.
5. Enforce in CI
Add a commit lint step to your CI pipeline:
# .github/workflows/lint-commits.yml
name: Lint Commits
on: [pull_request]
jobs:
commitlint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: wagoid/commitlint-github-action@v6
Production Failure Scenarios + Mitigations
| Scenario | Impact | Mitigation |
|---|---|---|
Developer bypasses hook with --no-verify | Non-conventional commits enter history | CI-level commitlint on PRs catches violations |
| Squash merge destroys commit structure | Semantic history lost | Configure merge strategy to preserve commits or use rebase merges |
| Legacy repo with messy history | Migration pain | Use git filter-branch or git-filter-repo for history rewrite (carefully) |
| Type enum too restrictive | Developers invent types like wip or hack | Review and expand type list quarterly based on team usage |
| Breaking change not signaled | Incorrect version bump | Code review checklist includes verifying ! or BREAKING CHANGE: footer |
Trade-offs
| Aspect | Structured Commits | Free-Form Commits |
|---|---|---|
| Automation | Full changelog + versioning | Manual release notes |
| Learning curve | Moderate (team training needed) | None |
| Git log readability | Excellent | Variable |
| Enforcement overhead | Husky + CI config | None |
| Merge conflict risk | Low (same as any commit) | Low |
| Historical migration | Expensive for old repos | N/A |
Implementation Snippets
Example conventional commits:
# Feature with scope
git commit -m "feat(auth): add JWT refresh token rotation"
# Bug fix
git commit -m "fix(api): handle null pointer in user lookup"
# Breaking change with body and footer
git commit -m "refactor(database)!: migrate from MongoDB to PostgreSQL
Dropped all Mongoose models and replaced with Prisma ORM.
Migration script included in /scripts/migrate-v2.sql.
BREAKING CHANGE: User model _id field replaced with UUID primary key"
# Revert
git commit -m "revert: feat(auth): add JWT refresh token rotation
This reverts commit abc1234. The implementation caused
race conditions during concurrent token refresh."
Programmatic parsing with Node.js:
import { parse } from "@commitlint/parse";
const message = "feat(api): add rate limiting";
const parsed = await parse(message);
console.log(parsed.type); // 'feat'
console.log(parsed.scope); // 'api'
console.log(parsed.subject); // 'add rate limiting'
Observability Checklist
- Logs: Log commit type distribution in CI (
feat: 40%, fix: 35%, docs: 15%, other: 10%) - Metrics: Track percentage of commits that pass linting on first attempt
- Alerts: Alert when PR contains commits that fail conventional commit rules
- Dashboards: Weekly report of commit types by team member
- Traces: Trace commit → CI → release pipeline for each tagged release
Security/Compliance Notes
- Commit messages are stored in plaintext in git history — never include secrets, API keys, or PII
- Breaking change footers are public; don’t disclose internal security details
- For regulated environments, ensure commit messages link to ticket IDs for audit trails:
feat(auth): add MFA support [SEC-1234] - Signed commits (
git commit -S) provide cryptographic proof of authorship independent of message format
Common Pitfalls / Anti-Patterns
| Anti-Pattern | Why It’s Bad | Fix |
|---|---|---|
fix: fix the thing | No useful information | Describe what was fixed and why |
Using chore for everything | Defeats the purpose of categorization | Use specific types |
| Writing novel-length subjects | Gets truncated in git log | Keep subject under 72 chars, use body for detail |
| Mixing multiple concerns in one commit | Can’t cherry-pick or revert cleanly | One logical change per commit |
| Forgetting scope on large codebases | Hard to filter by area | Always use scope when repo has clear modules |
| Translating commit messages | Breaks automated tooling | Keep types and structure in English |
Quick Recap Checklist
- Install
@commitlint/cliand@commitlint/config-conventional - Configure Husky
commit-msghook - Define allowed types in
commitlint.config.mjs - Add CI-level commit linting for PRs
- Train team on Conventional Commits spec
- Set up
commitizenfor interactive commits (optional) - Configure merge strategy to preserve commit history
- Link commit types to semantic version bumps in release pipeline
Interview Q&A
A BREAKING CHANGE footer in the commit message, or an exclamation mark ! after the type/scope (e.g., feat(api)!: change response format). This signals an incompatible API change that requires consumers to update their code.
Conventional Commits is a formalized specification inspired by Angular's commit guidelines. Angular's convention is the original implementation; Conventional Commits generalizes it into a tool-agnostic standard that any project can adopt. The type names and structure are nearly identical.
--no-verify?Enforce conventions at the CI level, not just locally. Add a commitlint step to your PR pipeline that rejects non-conventional commits. Make the local hook a convenience, not the gate. Additionally, use git push --no-verify restrictions server-side with a pre-receive hook if you control the remote.
Yes, but you lose the individual commit history. The squash commit message should follow the convention and summarize all changes. For automated versioning tools like semantic-release, consider using rebase merges or merge commits to preserve the full commit history for accurate changelog generation.
The commitlint hook rejects the commit and the developer must amend the message. This is a hard gate — the commit is not created until the message is valid. In CI, the PR check fails. The fix is to run git commit --amend with a corrected message.
Production Failure: Broken Automated Changelogs
Scenario: Inconsistent commit messages breaking release pipeline
# What happened:
# 1. Team adopted Conventional Commits but didn't enforce it
# 2. Some devs use "fix: ...", others use "bugfix: ...", "WIP: ..."
# 3. semantic-release can't parse non-conventional commits
# 4. Automated changelog is incomplete, version bumps are wrong
# Symptoms
$ npx semantic-release --dry-run
[semantic-release] › ℹ Found 0 commits since last release
# Expected: 15 commits, found: 0 (because types don't match config)
$ cat CHANGELOG.md
# Only shows commits that matched the type enum
# Missing: "bugfix: fix login", "WIP: add feature", "misc: cleanup"
# Root cause: No enforcement — commitlint was installed but not enforced
# Recovery steps:
# 1. Identify non-conventional commits
git log --oneline main..HEAD | grep -vE "^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?: "
# 2. For future: enforce with commitlint + husky
npx husky add .husky/commit-msg 'npx --no -- commitlint --edit ${1}'
# 3. For existing history: DON'T rewrite (too disruptive)
# Instead, configure semantic-release to be more lenient:
# In release config, add custom parser:
# parserOpts: {
# headerPattern: /^(\w*)(?:\((.*)\))?: (.*)$/,
# headerCorrespondence: ['type', 'scope', 'subject']
# }
# 4. Train the team
# - Add commit message guide to CONTRIBUTING.md
# - Use commitizen for guided commits
# - Show examples in PR templates
Trade-offs: Conventional Commits vs Angular vs Semantic Release
| Aspect | Conventional Commits | Angular Convention | Semantic Release |
|---|---|---|---|
| What it is | Specification (language-agnostic) | Original implementation | Automated release tool |
| Scope | Commit message format | Commit message format | Full release pipeline |
| Tooling | Any parser that supports the spec | commitlint, Angular tools | semantic-release CLI |
| Adoption | Broad (any language/framework) | Angular ecosystem | Node.js/npm focused |
| Version bumps | Defined by spec | Defined by Angular | Automated based on commits |
| Changelog | Can be generated by any tool | conventional-changelog | Built-in generation |
| Enforcement | Via commitlint or similar | Via Angular tooling | Via CI pipeline |
| Breaking changes | ! or BREAKING CHANGE: footer | Same as spec | Same, triggers MAJOR |
| Best for | Any project adopting structured commits | Angular projects | Automated npm releases |
Key insight: Conventional Commits is the format, commitlint is the enforcer, semantic-release is the consumer. They work together but serve different purposes.
Implementation: Commitlint + Husky Configuration
# === 1. Install dependencies ===
npm install --save-dev @commitlint/cli @commitlint/config-conventional husky
# === 2. Initialize Husky ===
npx husky init
# === 3. Configure commitlint ===
# commitlint.config.mjs
export default {
extends: ['@commitlint/config-conventional'],
rules: {
// Enforce allowed types
'type-enum': [
2,
'always',
['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'build', 'ci', 'chore', 'revert']
],
// Type must be lowercase
'type-case': [2, 'always', 'lower-case'],
// Subject must not be empty
'subject-empty': [2, 'never'],
// Subject must not end with period
'subject-full-stop': [2, 'never', '.'],
// Subject max length (warning, not error)
'subject-max-length': [1, 'always', 72],
// Body max line length (warning)
'body-max-line-length': [1, 'always', 100],
// Scope case
'scope-case': [2, 'always', 'lower-case'],
},
};
# === 4. Add Husky hook ===
npx husky add .husky/commit-msg 'npx --no -- commitlint --edit ${1}'
# === 5. Test it ===
# This should fail:
git commit -m "fixed the thing"
# Error: subject must not be empty
# Error: type must be one of [feat, fix, docs, ...]
# This should pass:
git commit -m "fix(auth): resolve token refresh race condition"
# === 6. CI enforcement ===
# .github/workflows/commitlint.yml
name: Commitlint
on: [pull_request]
jobs:
commitlint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: wagoid/commitlint-github-action@v6
with:
configFile: commitlint.config.mjs
Resources
Category
Related Posts
Automated Changelog Generation: From Commit History to Release Notes
Build automated changelog pipelines from git commit history using conventional commits, conventional-changelog, and semantic-release. Learn parsing, templating, and production patterns.
Automated Releases and Tagging
Automate Git releases with tags, release notes, GitHub Releases, and CI/CD integration for consistent, repeatable software delivery.
git commit: Writing Effective Commit Messages and Best Practices
Deep dive into git commit, writing effective commit messages, conventional commits, signed commits, and commit best practices for clean version control history.