Enforcing Conventional Commits

Inconsistent commit messages make Git history hard to read and impossible to automate. Conventional Commits solves this problem with a standardized format. Conventional Commits adopt a simple format like feat: add new feature or fix: correct a bug, which makes it easy to understand the purpose of each commit at a glance. This guide shows how to enforce it using Commitizen and Husky.

#Prerequisites

You can skip this Prerequisites section if you are already familiar with the terminal, Node.js environments and package-managers.

The tools used in this guide require a Unix-like environment. On macOS and Linux you can use the terminal. If you are on Windows ensure you execute the commands in WSL (Windows Subsystem for Linux). You can install WSL by following the instructions on the WSL website.

If you are on macOS install Homebrew Package Manager. You can check if you have Homebrew installed by running brew -v in your terminal. If you don’t have it, you can install it by following the instructions on the Homebrew website.

I will use tools based on Node.js, so you need to have Node.js installed on your machine. You can check if it’s installed by running node -v in your terminal. If you don’t have it, instead of downloading from the official site, I recommend installing NVM (Node Version Manager) to manage multiple versions of Node.js on the same machine. This way you can easily switch between different Node.js versions for different projects. If you are on macOS, install the NVM using Homebrew Package Manager with brew install nvm. If you don’t use macOS check a command like curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh | bash that should install it, but you can always check NVM installing documentation. After installing NVM, you should close and reopen your terminal before using nvm, and then install the latest version of Node.js with nvm install node and set it as the default with nvm use node. If you want to use another version of Node.js, you can install it with nvm install <version> and switch to it with nvm use <version>.

You will need a package manager (npm, yarn, or pnpm). If you already installed Node.js, you have npm. But I recommend pnpm, install it through Homebrew if you are on macOS: brew install pnpm or through Node.js npm install -g pnpm if you are on a different operating system. Check with npm -v, yarn -v, or pnpm -v if they are installed. I will use pnpm in this tutorial, but you can adapt and use npm or yarn if you prefer.

A Git repository for your project. If you don’t have one, you can create it by running git init in your project directory. If you create a new Git repository, make sure to create an initial commit before setting up Husky, because Husky hooks won’t work until there is at least one commit in the repository.

A Node.js environment set up in your project, with a package.json file. If you don’t have one, you can create it by running npm init -y in your project root directory. The -y flag automatically answers “yes” to all prompts, creating a package.json with default values. You can edit the package.json file later to add more information about your project. Make sure to also create a .gitignore file to exclude node_modules/ and other unnecessary files from your Git repository.

Basically we will use in this tutorial:

  • pnpm install to install the necessary packages.
  • pnpm exec to run the CLI tools without installing them globally at system level. You can also install them globally at system level if you prefer.

#What are Commitizen and Husky?

  • Commitizen is an optional CLI tool that guides developers through writing compliant commit messages.
  • Husky enforces them by running commitlint as a Git hook that rejects any non-compliant commit.

Together, they create a robust system for ensuring that all commits in a project adhere to the Conventional Commits specification, improving code quality and maintainability.

The Commitizen software is not mandatory to enforce the specification, but it provides a user-friendly interface to create compliant commit messages. With Commitizen you can start to better understand the specification. Husky, on the other hand, is essential for enforcing the rules by blocking non-compliant commits at the Git level.

#Installation

Install both packages as dev dependencies:

pnpm install --save-dev commitizen husky

#Configure Commitizen

Commitizen is adapter-agnostic, it delegates the prompt UI and message format to an adapter. cz-conventional-changelog is the official adapter for the Conventional Commits format.

Initialize Commitizen with the cz-conventional-changelog adapter. The --pnpm flag tells Commitizen to use pnpm as the package manager, --save-dev saves it as a dev dependency, --save-exact ensures that the exact version is saved in package.json, and --force overwrites any existing configuration if it exists.

pnpm exec commitizen init cz-conventional-changelog --pnpm --save-dev --save-exact --force

This adds the following to your package.json:

{
  "config": {
    "commitizen": {
      "path": "./node_modules/cz-conventional-changelog"
    }
  }
}

You can now create a compliant commit by running pnpm exec cz instead of git commit. You’ll be guided through a series of prompts to build your commit message. This helps you to understand the Conventional Commits specification and ensures that your commit messages are consistent and informative if you commit them through pnpm exec cz. However, if you commit directly with git commit -m "some message", there is no guarantee that the message will be compliant. This is where Husky comes in to enforce the rules.

#Configure Husky

Initialize Husky to set up the .husky directory and install the Git hooks:

pnpm exec husky init

This command, in recent versions of Husky (v9), creates a .husky directory in the root of your project, creates a sample pre-commit hook inside, and adds a prepare script to your package.json to ensure that Husky is set up correctly when other developers clone the repository and run pnpm install. The pre-commit hook is just a sample, you can remove it or replace it with your own hooks. The important part is the prepare script in package.json, which ensures that Husky is properly set up on every machine that clones the repository.

The added pre-commit hook .husky/pre-commit looks like this:

pnpm test

This means that before every commit, the pnpm test command will run. If the tests fail, the commit will be blocked. You can replace this with any command you want to run before commits, but for our purpose of enforcing commit message rules, we will add a commit-msg hook later to run commitlint. So the pre-commit hook is not strictly necessary for enforcing commit message rules, but it’s a common practice to run tests before allowing commits, and it serves as an example of how to use Husky hooks.

The critical part for enforcing commit message rules will be the commit-msg hook that we will set up next. So if you don’t want to run tests before commits, you can comment the command there adding a # before the command like: # pnpm test or even remove the pre-commit hook with rm .husky/pre-commit.

Regarding package.json the added code looks like this:

{
  "scripts": {
    "prepare": "husky"
  }
}

The prepare is a built-in pnpm lifecycle hook that runs automatically after pnpm install completes (and also before pnpm publish). You don’t invoke it manually, the package manager runs it for you.

When the value is “husky”, it runs the husky CLI, which registers all the hook files inside .husky/ with Git by configuring git config core.hooksPath .husky, telling Git to use the .husky/ directory for hooks. Without this registration step, Git doesn’t know the hooks exist, even though the files are in the repo. That’s because, by default, Git looks for hooks in the .git/hooks directory. That folder is not committed to the repository, it’s local to each machine and developer. The .husky/ folder, on the other hand, is committed to the repo, so hook files are shared with everyone.

Next, install the commitlint packages to validate commit messages against the Conventional Commits rules:

pnpm install --save-dev @commitlint/cli @commitlint/config-conventional

Create a commitlint.config.js file in the root of your project to extend the conventional configuration. We need this file because commitlint needs to know which rules to apply when validating commit messages. By extending the @commitlint/config-conventional configuration, we are telling commitlint to use the standard set of rules defined by the Conventional Commits specification.

We are using the modern ES module syntax in this file, so ensure your package.json has "type": "module" in it’s configuration. Add the following content to commitlint.config.js:

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

You can use the older CommonJS syntax if you prefer, and avoid having to edit package.json. The important part is that the configuration file exports an object with the extends property set to ["@commitlint/config-conventional"], which tells commitlint to use the rules defined in that package. For the CommonJS syntax, the file should be named commitlint.config.cjs and would look like this:

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

Finally, add a commit-msg hook to run commitlint against every commit message.

Using pnpm exec commitlint means the hook runs commitlint from the project’s local node_modules. If commitlint is not installed (e.g. the developer forgot to run pnpm install), the hook will fail and block the commit, serving as a reminder to install dependencies first. The $1 is a placeholder for the commit message file that Git provides to the hook. This setup ensures that every commit message is validated against the Conventional Commits rules before the commit is accepted.

echo 'pnpm exec commitlint --edit $1' > .husky/commit-msg
chmod +x .husky/commit-msg

#Testing the Setup

You can pipe a message directly into commitlint without touching Git at all.

Failing example (non-conventional message):

echo "my bad commit message" | pnpm exec commitlint

Passing example (valid conventional message):

echo "feat: add new feature" | pnpm exec commitlint

You can also test the actual Git hook by trying to commit a non-compliant message:

git commit -m "my bad commit message"

This should be blocked by Husky and show an error from commitlint. If you try to commit a compliant message, it should go through without issues and the commit should be created successfully.

If the non-compliant commit goes through without being blocked, you can check if Husky is properly set up by running git config core.hooksPath to see if it points to the .husky/ directory. If it doesn’t, you can run pnpm install to set it up, because the prepare script will automatically configure Husky. Also, make sure that the commit-msg hook file exists in the .husky/ directory and has the correct command to run commitlint.

To go back to the state before that non compliant commit, you can use git reset --soft HEAD~1 to undo the last commit while keeping the changes in your working directory.

#The Workflow

With this setup in place:

  1. A developer runs pnpm exec cz and is guided through the Commitizen prompts.
  2. On a traditional commit, the Husky commit-msg hook fires and passes the message to commitlint. If the message does not conform to the Conventional Commits specification, the commit is rejected and an error is shown.

For other developers that clone the repository, they just need to run pnpm install to set up Husky and the hooks. The prepare script ensures that Husky is properly configured on their machine, so they will also have the commit message validation in place without needing to run any additional setup commands.

git clone <repo> 
  → pnpm install 
    → prepare script runs automatically 
      → husky registers .husky/commit-msg with Git 
        → commits are now automatically validated by commitlint

This tutorial is not a security boundary, it’s a developer experience improvement. It helps maintain a clean commit history and encourages best practices, but it can be bypassed if someone really wants to. For example, they could disable the Git hooks locally or use git commit --no-verify to skip the hooks. However, for most teams and projects, this setup provides a good balance of enforcement and usability to encourage compliance with the Conventional Commits specification.

#Removing Commitizen and Husky

If you want to remove all the tools and configurations you just made, you can follow these steps:

Uninstall the packages:

pnpm remove commitizen husky @commitlint/cli @commitlint/config-conventional cz-conventional-changelog

Remove the Husky hooks:

rm -rf .husky

Remove the commitlint.config.js file:

rm commitlint.config.js

Or the commitlint.config.cjs file if you used the CommonJS syntax:

rm commitlint.config.cjs

Remove the Commitizen configuration from package.json:

{
-  "config": {
-    "commitizen": {
-      "path": "./node_modules/cz-conventional-changelog"
-    }
-  }
}

Remove the prepare script from package.json:

{
  "scripts": {
-    "prepare": "husky"
  }
}

#Conclusion

This two-layer approach means that developers are encouraged to write compliant commit messages with Commitizen, while Husky ensures that any non-compliant messages are blocked at the Git level.

From now on who clones the repository will have the commit message validation set up automatically after running pnpm install, encouraging that all contributors adhere to the same commit message standards without needing to remember to set up Husky manually.

This helps maintain a clean and consistent commit history, which is crucial for project maintainability and automation. By enforcing the Conventional Commits specification, you can improve the readability of your commit history and enable powerful automation tools that rely on structured commit messages.

Tags:
development
Date: