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
| 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-off Analysis
| 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 Considerations
- 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
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
Interview Questions
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.
scope field optional in Conventional Commits?The scope is optional because not all projects need it. In small or single-module repositories, the scope adds noise without value. However, in monorepos or multi-package projects (e.g., a repo with packages/api, packages/web, packages/shared), scope becomes essential for filtering commits by module in changelogs and release notes.
revert type work in Conventional Commits?The revert type follows the pattern revert: where the body references the commit hash being reverted. Example: revert: feat(auth): add OAuth2 login followed by This reverts commit abc1234. This allows semantic-release to generate reversal entries in changelogs and correctly handle reverts in the release pipeline.
chore and build commit types?chore covers general maintenance tasks that don't modify src/ or test/ files — updating dependencies, configuring CI, rotating secrets. build specifically targets changes to the build system or external dependencies — webpack config changes, package manager swaps, build tool upgrades. Using them correctly ensures cleaner changelog categorization.
Subject line: Append ! after type/scope: feat(api)!: change authentication flow — triggers MAJOR bump. Body: Add BREAKING CHANGE: footer with explanation: BREAKING CHANGE: The /auth/token endpoint now requires a client certificate. Both methods are valid; the footer approach allows more detailed explanation of the incompatibility.
body section in a commit message?The body provides detailed explanation beyond the 72-character subject. Use it to describe why the change was made (not just what), reference related issues or design docs, and explain migration steps for breaking changes. Unlike the subject, the body has no character limit and supports multiple paragraphs with bullet points.
Because automated tooling expects English. Tools like commitlint, semantic-release, and conventional-changelog have parsers and regex patterns designed for English type names. Using non-English types like corregir (Spanish for "fix") breaks parsing entirely, causing automated changelogs and version bumps to fail silently or reject commits.
Standard mapping is: fix → PATCH bump, feat → MINOR bump, and any commit with BREAKING CHANGE in the footer → MAJOR bump regardless of type. Types like docs, chore, test, refactor do not trigger a version bump by default. Some tools allow custom type-to-bump mappings through configuration. This mapping is what enables fully automated versioning.
Squash merge: All commits in a PR are combined into one commit on the target branch — individual commit messages are lost unless manually preserved. Rebase merge: Each commit is replayed one-by-one onto the target branch, preserving full history and all commit messages. For Conventional Commits + semantic-release, rebase merges are preferred to maintain accurate commit categorization for changelog generation.
Split them into multiple commits, one per logical change. The rule is one logical change per commit. In the example, you'd have refactor: restructure auth module and fix: resolve null pointer in token validation. This enables precise git bisect, clean cherry-picking, and accurate changelog entries. If you must batch them, use the most significant type (prefer fix over refactor when both occur).
! suffix achieve that a BREAKING CHANGE: footer cannot?Nothing functionally different — both trigger a MAJOR version bump. The ! suffix in the subject line is a concise visual signal that reads naturally in git log output: feat!: remove deprecated endpoint. The footer approach provides more space for explanation but is less visible at a glance. Teams often use both: feat!: remove deprecated endpoint with a BREAKING CHANGE: footer explaining migration steps.
subject-full-stop set to never (no period) in commitlint rules?Because commit subjects are not sentences — they are imperative commands describing what the commit does (e.g., "add feature", "fix bug", not "added feature" or "fixes bug"). Periods add visual noise and slightly increase character count. Since subject lines should stay under 72 characters, every character matters, and a trailing period serves no purpose.
Yes, but use the feat / fix / chore types with descriptive scopes. Examples: feat(k8s): add HorizontalPodAutoscaler to api-deployment, fix(terraform): correct S3 bucket policy IAM permissions, chore(gitops): update cluster-config to v1.2.0. Treat infrastructure changes with the same rigor as code changes — they affect production environments and deserve clear, searchable commit history.
You have two options: Don't rewrite history for established repos — the pain outweighs the benefit. Instead, enforce conventions going forward and configure semantic-release to be lenient with legacy commits. Or Rewrite history using git filter-repo or git filter-branch if the repo is young and has few contributors. In both cases, update CONTRIBUTING.md, add commitizen, and use CI-level enforcement to prevent new violations.
footer-max-line-length rule in commitlint?It enforces that each line in the commit footer (e.g., BREAKING CHANGE:, references to issues) stays under the specified length (commonly 100). This prevents ugly wrapping in terminal output and ensures footers remain readable in git log, GitHub PR views, and generated changelogs. It's a warning-level rule (level 1) by default since it doesn't break parsing, but it's good practice to follow.
Types like docs, style, refactor, build, ci, and chore explicitly indicate no version bump per SemVer rules. semantic-release skips these types when calculating the next version. If only such commits exist since the last tag, no release occurs — which is correct behavior since no user-facing code changed. This is a feature, not a bug.
Further Reading
Additional Resources
- Conventional Commits Specification — Official specification document
- Angular Commit Guidelines — Original inspiration for Conventional Commits
- @commitlint/config-conventional — Reference configuration with all standard rules
- semantic-release — Automated versioning and changelog generation based on Conventional Commits
- commitizen/cz-cli — Interactive CLI for guided commit message creation
- gitmoji — Emoji codes for commit messages (can be combined with Conventional Commits as
feat: :sparkles: add new feature) - Keep a Changelog — Changelog best practices independent of commit conventions
Quick Reference: Commit Type Decision Tree
flowchart TD
A[What type of change?] --> B{Does it add something new?}
B -->|Yes| C[feat]
B -->|No| D{Does it fix a bug?}
D -->|Yes| E[fix]
D -->|No| F{Does it change docs only?}
F -->|Yes| G[docs]
F -->|No| H{Does it improve performance?}
H -->|Yes| I[perf]
H -->|No| J{Does it refactor without behavior change?}
J -->|Yes| K[refactor]
J -->|No| L{Does it change build/CI config?}
L -->|Yes| M[build or ci]
L -->|No| N[chore]
Commit Message Length Guidelines
| Component | Recommended | Maximum | Notes |
|---|---|---|---|
| Subject line | 50 chars | 72 chars | Truncated in git log/graph views |
| Body paragraph | 72 chars | 100 chars | Wraps cleanly in terminal |
| Footer line | 72 chars | 100 chars | Used for BREAKING CHANGE and refs |
Why 72 characters? Git wraps commit messages at 80 chars by default, leaving 8 chars for indentation. Staying under 72 ensures no unexpected wrapping in standard terminal widths.
Semantic Version Bump Logic
flowchart LR
A[New commits] --> B{Any BREAKING CHANGE?}
B -->|Yes| C[MAJOR: X.0.0]
B -->|No| D{Any feat type commits?}
D -->|Yes| E[MINOR: X.Y.0]
D -->|No| F{Any fix or perf commits?}
F -->|Yes| G[PATCH: X.Y.Z]
F -->|No| H[No version bump]
Conclusion
Commit message conventions turn version control history into a communication channel. The Conventional Commits specification (feat:, fix:, breaking:) is the de facto standard because it’s machine-parseable for changelogs and human-readable for code reviews — a rare intersection of automation and clarity.
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.