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: 12 min read 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 + Mitigations

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-offs

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

Interview Q&A

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.

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.

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.

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.

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.

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

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.

#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