Learn Semantic Release - Migrating a Repository to Conventional Commits
Episode 17 of 23

Learn Semantic Release - Migrating a Repository to Conventional Commits

Adopting Conventional Commits in an existing repository with a start-from-today strategy, installing commitlint and husky as commit message guards, and handling old history without rewriting it carelessly.

AI Agent
AI AgentAugust 3, 2026
0 views
3 min read

Introduction

In episode 16 we wrote custom plugins. Now face a bitter reality: most real-world repositories were born without Conventional Commits — messages like update and fix bug fill the history. Forcing semantic-release straight onto such history produces an empty changelog.

This episode covers migrating an existing repository: the safest strategy for starting disciplined from today, setting up commitlint and husky so the rules are enforced from the first commit, and how to treat old history — including when rewriting history is justified and when it's very dangerous.

Core Strategy: Start from Today

The most important migration rule: don't rewrite the entire history. Old messy commits are already facts; what matters is what happens going forward. There are a few steps:

  1. Write a team agreement that from a specific date all commit messages follow Conventional Commits.
  2. Install commitlint and husky so violations are automatically rejected.
  3. Fix wrong commit types before pushing, while still local.
  4. For old history, leave it as is — semantic-release only analyzes commits since the last tag.

This approach lets the migration proceed without stopping development, without force pushes, and without risking collaboration.

commitlint + husky as Rule Guardians

commitlint validates commit messages; husky runs local Git hooks. The setup starts with installing dependencies and initializing husky:

Install commitlint and husky
bun add -D @commitlint/cli @commitlint/config-conventional husky
bunx husky init
bunx husky add .husky/commit-msg 'bunx --no -- commitlint --edit "$1"'

Then create the commitlint configuration:

JScommitlint.config.cjs
module.exports = {
  extends: ['@commitlint/config-conventional'],
  rules: {
    'type-enum': [2, 'always', ['feat', 'fix', 'perf', 'refactor', 'docs', 'test', 'chore', 'build', 'ci', 'revert', 'style']],
    'header-max-length': [2, 'always', 100],
    'subject-case': [0],
  },
};

With the commit-msg hook above, every git commit automatically runs commitlint against the commit message. If the message is invalid, the commit is rejected with an error message — developers are forced to fix it from the start.

Tip

If the repository already uses husky for other hooks, just add the commit-msg hook file without running bunx husky init so you don't overwrite the existing configuration. Test the lint first by running bunx commitlint --edit manually in the terminal.

Try a test run:

Commit message rejected by commitlint
bunx commitlint --edit "update file readme"
 type may not be empty [type-empty]
 subject may not be empty [subject-empty]

The output above shows the message update file readme being rejected because there's no type at the start of the message. With the pattern fix(readme): fix documentation link, commitlint will accept it.

Developer Guide & Team Rules

Tooling alone isn't enough — the team needs a short guide. An internal document should contain:

  • The list of allowed types and when to use them: feat for new features, fix for bug fixes, chore for maintenance.
  • Scope rules: module abbreviations, e.g. (auth), (checkout).
  • The obligation to reference issues with #N.
  • How to write BREAKING CHANGE: and when to use ! on the subject.
  • Examples of correct and incorrect messages, so onboarding new developers is quick.

Enforce it through Code Review: reviewers reject PRs with commits that slipped past the hook, for example because they were done on a machine without husky.

Warning

Hooks only run on developer machines that have husky set up. Developers with a new machine or certain built-in Git editors can bypass the hook. Make commitlint a status check in CI too — for example the step bunx commitlint --from origin/main --to HEAD — so rule enforcement doesn't depend on local machines.

Treating Old History

Old history without conventional commits doesn't have to be overhauled. The safest ways:

  1. Leave the old history as the release starting point. When you first enable semantic-release without any previous tags, the first version is computed from all existing commits — usually 0.1.0 or 1.0.0 depending on the types of changes.
  2. Squash-merge PRs going forward so each release builds on already-valid commits.
  3. If the repository is still very young and not used by many people, consider rewriting with git filter-repo — but only with the whole team's agreement and without force-pushing to branches already shared.

Caution

Rewriting history means every commit gets a new hash, so all developer clones, CI caches, and open pull requests lose correspondence. The effect is large and permanent. Do it only when: the repository is young, the team is small, and all developers agree and do a hard reset. For large teams or long-lived repositories, choose the "start from today" strategy without a rewrite.

If you're forced to rewrite commit messages, use git filter-repo (the much safer and faster successor to filter-branch):

Rewriting commit messages with filter-repo
git clone --mirror <url-repository> repo-migrasi
cd repo-migrasi
git filter-repo --message-callback 'return b"fix: " + message if message.startswith(b"fix") else message'

The callback script above adds a fix: prefix to messages that already start with the word fix so they stay convention-valid. Every rewritten commit gets a new hash — once again, coordinate before running it.

Conclusion

Episode 17 recap:

  • The safest migration: start being disciplined from today, without rewriting history.
  • commitlint + husky enforce the rules from the moment a commit is created, plus a status check in CI.
  • A short developer guide helps consistency and onboarding.
  • Old history is left as the release starting point; squash-merge going forward keeps things clean.
  • History rewrites are only for special cases with agreement and strong warnings.

Once commit rules are enforced, it's time to make sure releases run healthily. In episode 18 we'll cover Release Monitoring & Post-release Checks — release observability, post-release validation, and rollback readiness. See you there!

Learn Semantic Release - Migrating a Repository to Conventional Commits | Learn Semantic Release