solomonmark.dev · a journal

Standardize Git Commits with Commitizen, Husky, and Commitlint

Standardize Git Commits with Commitizen, Husky, and Commitlint

Messy commit histories are a silent tax on every team. Messages like fix stuff, wip, or update 2 final FINAL make it impossible to understand what changed, why it changed, or when a bug was introduced.

Conventional Commits is a lightweight spec that solves this. Combined with three tools — Commitizen, Commitlint, and Husky — you get an interactive commit workflow that guides developers to write good messages and automatically rejects bad ones.

This guide walks through setting up all three in a Node.js project.


What Each Tool Does

ToolRole
CommitizenInteractive CLI prompt that builds a valid commit message step by step
CommitlintLints commit messages against the Conventional Commits spec
HuskyRuns scripts via Git hooks — used here to trigger Commitlint on every commit

The Conventional Commits Format

Before diving in, here’s the structure you’ll be enforcing:

<type>(<scope>): <short description>

[optional body]

[optional footer]

Examples:

feat(auth): add OAuth login
fix(api): handle null response from payment service
chore: update dependencies
docs(readme): add installation instructions

Valid types: feat, fix, docs, style, refactor, perf, test, chore, ci


Installation

Install all three tools as dev dependencies:

npm install --save-dev commitizen cz-conventional-changelog @commitlint/cli @commitlint/config-conventional husky

Step 1 — Configure Commitizen

Commitizen needs an adapter that defines the prompt style. cz-conventional-changelog implements the Conventional Commits spec.

Add the following to your package.json:

{
  "scripts": {
    "commit": "cz",
    "prepare": "husky"
  },
  "config": {
    "commitizen": {
      "path": "./node_modules/cz-conventional-changelog"
    }
  }
}

The prepare script runs automatically after npm install, ensuring Husky hooks are always registered when a teammate clones the repo.


Step 2 — Configure Commitlint

Create a commitlint.config.js in your project root.

For ESM projects ("type": "module" in package.json):

export default { extends: ['@commitlint/config-conventional'] };

For CommonJS projects:

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

How to tell which you have: Check your package.json. If it has "type": "module", use the ESM syntax. If it’s absent or set to "commonjs", use the CommonJS syntax.


Step 3 — Set Up Husky

Initialize Husky, which creates the .husky/ directory:

npx husky init

This also creates a default .husky/pre-commit file that runs npm test. Update it to something your project actually has — for example, a typecheck:

# .husky/pre-commit
npm run typecheck

Now create the commit-msg hook that runs Commitlint:

echo 'npx --no -- commitlint --edit $1' > .husky/commit-msg

This hook fires after you write a commit message. If the message doesn’t conform to the spec, the commit is aborted with a clear error.


How It Works Together

Here’s the full flow when a developer commits:

git add .
npm run commit         # or: git cz / npx cz

Commitizen launches an interactive prompt:

? Select the type of change:
  feat:     A new feature
  fix:      A bug fix
  docs:     Documentation only changes
  refactor: A code change that neither fixes a bug nor adds a feature
  ...

? What is the scope of this change? (e.g. auth, api, ui): auth

? Write a short description:
  add OAuth login

? Provide a longer description (press enter to skip):

? Are there any breaking changes? No

? Does this change close any open issues? Yes
? Add issue references: #42

Commitizen assembles the final message:

feat(auth): add OAuth login

Closes #42

Husky’s commit-msg hook then passes this to Commitlint for validation. Since it’s well-formed, the commit succeeds.

If someone bypasses Commitizen and runs git commit -m "fix stuff" directly, Commitlint catches it:

⧗   input: fix stuff
✖   subject may not be empty [subject-empty]
✖   type may not be empty [type-empty]

✖   found 2 problems, 0 warnings

Final package.json Structure

{
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "typecheck": "tsc --noEmit",
    "commit": "cz",
    "prepare": "husky"
  },
  "config": {
    "commitizen": {
      "path": "./node_modules/cz-conventional-changelog"
    }
  },
  "devDependencies": {
    "@commitlint/cli": "^20.0.0",
    "@commitlint/config-conventional": "^20.0.0",
    "commitizen": "^4.3.1",
    "cz-conventional-changelog": "^3.3.0",
    "husky": "^9.0.0"
  }
}

Verification Checklist

After setup, confirm everything works:

  • npm run commit — Commitizen prompt appears
  • git commit -m "bad message" — rejected by the commit-msg hook
  • git commit -m "feat: add something" — passes through cleanly
  • Fresh npm install on a clone — Husky hooks are installed automatically via the prepare script

What’s Next

  • Auto-generate changelogs with standard-version or release-it, which parse your commit history to produce a CHANGELOG.md
  • Custom commit types — swap cz-conventional-changelog for cz-customizable to define your own prompt and types
  • CI enforcement — add Commitlint to your CI pipeline to catch any commits that slipped past the hook (e.g., direct pushes to main)

With this setup, every commit in your repo carries a consistent, machine-readable message — making git history actually useful for debugging, reviewing, and releasing.