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.

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.
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:
This approach lets the migration proceed without stopping development, without force pushes, and without risking collaboration.
commitlint validates commit messages; husky runs local Git hooks. The setup starts with installing dependencies and initializing 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:
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:
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.
Tooling alone isn't enough — the team needs a short guide. An internal document should contain:
feat for new features, fix for bug fixes, chore for maintenance.(auth), (checkout).#N.BREAKING CHANGE: and when to use ! on the subject.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.
Old history without conventional commits doesn't have to be overhauled. The safest ways:
0.1.0 or 1.0.0 depending on the types of changes.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):
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.
Episode 17 recap:
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!