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.

published: reading time: 20 min read author: Geek Workbench updated: March 31, 2026

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:

TypeDescriptionSemVer Impact
featNew featureMINOR bump
fixBug fixPATCH bump
docsDocumentation onlyNone
styleFormatting, missing semicolons, etc.None
refactorCode change that neither fixes nor addsNone
perfPerformance improvementPATCH bump
testAdding or correcting testsNone
buildBuild system or external dependenciesNone
ciCI/CD configuration changesNone
choreOther changes that don’t modify src or testsNone

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

ScenarioImpactMitigation
Developer bypasses hook with --no-verifyNon-conventional commits enter historyCI-level commitlint on PRs catches violations
Squash merge destroys commit structureSemantic history lostConfigure merge strategy to preserve commits or use rebase merges
Legacy repo with messy historyMigration painUse git filter-branch or git-filter-repo for history rewrite (carefully)
Type enum too restrictiveDevelopers invent types like wip or hackReview and expand type list quarterly based on team usage
Breaking change not signaledIncorrect version bumpCode review checklist includes verifying ! or BREAKING CHANGE: footer

Trade-off Analysis

AspectStructured CommitsFree-Form Commits
AutomationFull changelog + versioningManual release notes
Learning curveModerate (team training needed)None
Git log readabilityExcellentVariable
Enforcement overheadHusky + CI configNone
Merge conflict riskLow (same as any commit)Low
Historical migrationExpensive for old reposN/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-PatternWhy It’s BadFix
fix: fix the thingNo useful informationDescribe what was fixed and why
Using chore for everythingDefeats the purpose of categorizationUse specific types
Writing novel-length subjectsGets truncated in git logKeep subject under 72 chars, use body for detail
Mixing multiple concerns in one commitCan’t cherry-pick or revert cleanlyOne logical change per commit
Forgetting scope on large codebasesHard to filter by areaAlways use scope when repo has clear modules
Translating commit messagesBreaks automated toolingKeep types and structure in English

Quick Recap Checklist

  • Install @commitlint/cli and @commitlint/config-conventional
  • Configure Husky commit-msg hook
  • Define allowed types in commitlint.config.mjs
  • Add CI-level commit linting for PRs
  • Train team on Conventional Commits spec
  • Set up commitizen for 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

AspectConventional CommitsAngular ConventionSemantic Release
What it isSpecification (language-agnostic)Original implementationAutomated release tool
ScopeCommit message formatCommit message formatFull release pipeline
ToolingAny parser that supports the speccommitlint, Angular toolssemantic-release CLI
AdoptionBroad (any language/framework)Angular ecosystemNode.js/npm focused
Version bumpsDefined by specDefined by AngularAutomated based on commits
ChangelogCan be generated by any toolconventional-changelogBuilt-in generation
EnforcementVia commitlint or similarVia Angular toolingVia CI pipeline
Breaking changes! or BREAKING CHANGE: footerSame as specSame, triggers MAJOR
Best forAny project adopting structured commitsAngular projectsAutomated 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

1. What triggers a MAJOR version bump in Conventional Commits?

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.

2. What's the difference between Conventional Commits and Angular commit conventions?

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.

3. How do you handle a team that keeps bypassing commit hooks with --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.

4. Can you use Conventional Commits with squash merges?

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.

5. What happens when a commit type doesn't match your allowed enum?

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.

6. Why is the 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.

7. How does 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.

8. What is the difference between 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.

9. How do you write a commit message for a breaking change in the body vs the subject line?

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.

10. What is the purpose of the 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.

11. Why should commit messages stay in English even for non-English teams?

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.

12. How do conventional commit types map to semantic version bumps in automated tooling?

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.

13. What is the difference between a squash merge and a rebase merge regarding commit history?

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.

14. How do you handle commits that span multiple concerns (e.g., a refactor that also fixes a bug)?

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).

15. What does the ! 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.

16. Why is 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.

17. Can you use Conventional Commits for infrastructure-as-code changes?

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.

18. How do you migrate an existing repo with messy commit history to Conventional Commits?

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.

19. What is the role of 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.

20. How does Conventional Commits handle zero-changelog scenarios?

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


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

ComponentRecommendedMaximumNotes
Subject line50 chars72 charsTruncated in git log/graph views
Body paragraph72 chars100 charsWraps cleanly in terminal
Footer line72 chars100 charsUsed 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.

#git #version-control #changelog

Automated Releases and Tagging

Automate Git releases with tags, release notes, GitHub Releases, and CI/CD integration for consistent, repeatable software delivery.

#git #version-control #automation

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.

#git #commit #commit-messages