How-toVerified · versioning 0.6.2· 2026-08-19beginner~15 min
@hyperfrontend/versioning

How to replace commitizen and commitlint with one package

You will drop four packages and both of their configs, and keep the same workflow: a guided prompt for writing conventional commits, and a hook that refuses the ones that do not conform.

commitizen, cz-conventional-changelog, @commitlint/cli, and @commitlint/config-conventional split one concern across two toolchains that have to be kept agreeing with each other. @hyperfrontend/versioning ships both halves as the cz and cl bins, reading one config file, with no runtime dependencies of its own.

1. Swap the packages

npm uninstall commitizen cz-conventional-changelog @commitlint/cli @commitlint/config-conventional
npm install --save-dev @hyperfrontend/versioning

Delete commitlint.config.js, and the .czrc or config.commitizen block that pointed commitizen at its adapter. The "commit": "cz" script can go too, because npx cz runs on its own.

2. Put both configs in one file

commit.config.js at the repo root feeds the prompt and the validator from a single source, so a type you offer is always a type you accept:

/** @type {import('@hyperfrontend/versioning/commits/author').PartialSessionConfig} */
module.exports = {
  types: [
    { name: 'feat', description: 'A new feature' },
    { name: 'fix', description: 'A bug fix' },
    { name: 'docs', description: 'Documentation only' },
    { name: 'chore', description: 'Tooling and housekeeping' },
  ],
  scopeOptional: true,
  headerMaxLength: 72,
  validateRuleset: {
    'type-enum': ['error', { types: ['feat', 'fix', 'docs', 'chore'] }],
    'subject-empty': ['error'],
    'header-max-length': ['error', { maxLength: 72 }],
    'imperative-mood': ['warn'],
  },
}

validateRuleset maps a rule name to a [level] or [level, options] tuple, where the level is error, warn, or off and anything unlisted is off. The rules are type-enum, scope-enum, subject-empty, subject-case, header-max-length, and imperative-mood. .mjs and .cjs work the same way, and --config <path> overrides discovery.

The rest of PartialSessionConfig shapes the session: scopeMulti to collect several scopes, and scopeFilter to drop candidates by { path, name }.

3. Author commits with cz

git add .
npx cz

The session walks type, scope, subject, body, breaking change, issue references, then a preview you confirm before it runs git commit. The subject step counts down the characters left in the header budget as you type, and the same ruleset warns inline, so a message that would fail the hook is visible before you reach it.

Scope choices come from what you staged: each staged path resolves to its nearest project, and those project names become the list. Stage inside one package and that package is the only offer; stage nothing and the session stops and tells you to stage first. Cancelling with Ctrl+C exits 130 and writes nothing.

4. Enforce it in the commit-msg hook

cl takes the path git hands the hook and exits non-zero on any error-level violation, which is exactly the contract husky and lefthook already expect.

.husky/commit-msg:

npx cl "$1"

Or in lefthook.yml:

commit-msg:
  commands:
    validate:
      run: npx cl {1}

Warnings print and let the commit through; errors print and stop it:

✖ type-enum: type must be one of [feat, fix, docs, chore] but was "added"

5. Run the same check on a pull request

A hook only protects the machines that installed it. Validate every commit on the branch in CI, using the same config file:

- run: |
    status=0
    for sha in $(git rev-list ${{ github.event.pull_request.base.sha }}..HEAD); do
      git log -1 --format=%B "$sha" > "$RUNNER_TEMP/commit-msg"
      npx cl "$RUNNER_TEMP/commit-msg" || status=1
    done
    exit $status

Check it worked

Commit a message that breaks a rule you set to error and watch the hook reject it by name. Commit a conforming one and watch it land. Then run npx cz on a staged change: the type list is the one from your config, the scope offered matches the package you staged in, and the header countdown starts at your headerMaxLength.

Reference and background