Skip to content
Go back

Husky and Conventional Commits — How to use Git Hooks Without the Pain

Edit Views

Table of contents

Open Table of contents

Introduction

Have you ever wondered about the meaning of commit messages? Image one day you open someone’s repo when you run git log --oneline and you see this:

a1b2c3d updates
e4f5g6h wip
i7j8k9l more fixes
q4r5s6t FINAL FINAL

That history is useless. You can’t search it, can’t generate a changelog from it, can’t tell what broke or when. Six months later, even the person who wrote those commits won’t remember what more fixes touched.

This is the reason why you need to use git commit message convention

Git hooks exist to prevent exactly this, but knowing how to use them is only half the problem. Write a script, Git runs it before the commit lands and if the message doesn’t pass, the commit doesn’t happen.

The real friction is the setup, and that’s where most teams quietly give up. Hooks live in .git/hooks, which Git doesn’t track and every new clone starts from scratch. Even if you document the setup, teammates probably ignore it, and two weeks later you’re back to wip.

Few npm packages and one Git hook file, and your team never writes a garbage commit message again.

Husky fixes the plumbing. Hooks become regular files in .husky/, committed to the repo, installed automatically on npm i. One setup, the whole team gets it.

This guide wires Husky to Conventional Commits via @commitlint/cli and @commitlint/config-conventional — so bad messages get blocked at the moment of commit.

Conventional Commits

The spec is simple, every commit message follows this structure:

<type>[optional scope]: <description>

In practice, once your team has this enforced, every commit in the repo looks like this:

The type field is what makes the message machine-readable and human-scannable at the same time, below there are the most common values you’ll use day to day:

TypeWhen to use
featNew feature
fixBug fix
refactorCode cleanup, no behavior change
docsDocumentation only
choreBuild process, dependency bumps
testAdding or fixing tests
perfPerformance improvement
ciCI configuration

The optional scope narrows context in bigger codebases. fix is vague. fix(auth) tells you exactly where to look.

Why does any of this matter? Three reasons that show up in daily work:

How Git Hooks Actually Work

Git has 13 client-side hooks and each named after the operation that triggers it, we care about only two:

git commit pre-commit prepare-commit-msg commit-msg post-commit
Git client-side hook lifecycle

commit-msg is the one we care about most, it fires after you write your message but before Git saves the commit, so if the hook rejects the format, nothing gets pushed.

pre-commit runs even earlier, before the message editor opens, which makes it the right place for linting and fast tests.

Both hooks abort the commit on failure; they just guard different things at different moments.

Setup

Commitlint validates the message and Husky fires it automatically via the commit-msg hook. One config file ties the ruleset together, that’s the whole stack.

Yes No git commit -m 'message' commit-msg hook fires Husky runs commitlint Valid format? Commit created Commit aborted Error output shown
How the pieces fit together

Step 1 — Install commitlint

npm i @commitlint/cli @commitlint/config-conventional --save-dev

@commitlint/cli validates and @commitlint/config-conventional is the ruleset — it implements the full Conventional Commits spec. You don’t have to use it, but it’s the right default for almost every project.

Create .commitlintrc in your project root:

{
  "extends": ["@commitlint/config-conventional"]
}.commitlintrc

Test it now. Pipe a bad string straight into commitlint:

echo "qwerty" | npx commitlint

If everything is wired up correctly, the output should look like this:

✖   input: qwerty
✖   subject may not be empty [subject-empty]
✖   type may not be empty [type-empty]

✖   found 2 problems, 0 warnings

Now try the same thing with a properly formatted message and see what happens:

echo "feat: add login page" | npx commitlint

No output. Exit code 0. It’s working.

Step 2 — Initialize Husky

npx husky init

This creates a .husky/ directory and adds a prepare script to package.json that reinstalls hooks on every npm i. You’ll find a .husky/pre-commit file with placeholder content — that’s confirmation it worked. Replace it with something harmless for now:

echo "pre-commit hook".husky/pre-commit

We’ll come back to pre-commit later. The hook we need first is commit-msg.

Step 3 — Create the commit-msg Hook

Create .husky/commit-msg:

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

That’s the whole file with one command, two arguments worth understanding:

On macOS or Linux, make it executable:

chmod +x .husky/commit-msg

Husky usually handles this automatically. If the hook isn’t firing, check this first.

Testing the Full Flow

Start by committing something that deliberately breaks the format, typeless message that should never make it through:

git add .
git commit -m "stuff"

Commitlint won’t be subtle about it and you’ll see something like this:

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

✖   found 2 problems, 0 warnings
husky - commit-msg script failed (code 1)

Husky intercepts it before Git records anything and commit never happened. Now try the same thing with a message that actually follows the convention:

git commit -m "feat: add husky configuration"

That one goes through cleanly the full flow is working end to end.

Customizing commitlint Rules

The default @commitlint/config-conventional ruleset covers most projects out of the box, but you can loosen restrictions or add custom types by extending the config:

{
  "extends": ["@commitlint/config-conventional"],
  "rules": {
    "subject-case": [0],
    "body-max-line-length": [1, "always", 100],
    "type-enum": [
      2,
      "always",
      [
        "feat", 
        "fix", 
        "docs", 
        "refactor", 
        "test", 
        "chore", 
        "perf", 
        "ci", 
        "revert", 
        "wip"
      ]
    ]
  }
}.commitlintrc

The numbers next to each rule aren’t arbitrary, commitlint uses a three-level severity system that controls whether a rule blocks the commit or just warns:

LevelMeaning
0Disabled
1Warning (commit goes through)
2Error (commit blocked)

A few rules are worth adjusting before you share this with the team:

Adding More Hooks

commit-msg validates messages it is a one hook of 13. The pattern for any hook is the same, just create a file in .husky/ named after it.

Lint before every commit:

npx eslint . --ext .js,.ts.husky/pre-commit

Run tests before push:

npm test.husky/pre-push

Block direct pushes to main:

branch=$(git rev-parse --abbrev-ref HEAD)
if [[ "$branch" == "main" ]]; then
  echo "Direct push to main is not allowed"
  exit 1
fi.husky/pre-push

Sharing Hooks With the Team

This is the actual reason Husky exists — not just to make hooks easier, but to make them sharable.

Raw .git/hooks files aren’t tracked by Git. They exist on your machine and nowhere else, which means every new clone starts with no hooks at all. You document the setup, teammates ignore it, and the hooks quietly die.

Husky moves hooks into .husky/, a normal directory that gets committed. When someone clones the repo and runs npm i, the prepare script fires:

{
  "scripts": {
    "prepare": "husky"
  }
}package.json

Husky reads .husky/ and installs everything into .git/hooks automatically. No documentation to read, no script to remember. Clone the repo, run npm i, and the hooks are already in place.

Bypassing Hooks When You Need To

Sometimes you need to skip them — emergency hotfixes, fixup commits before an interactive rebase, committing generated files.

git commit --no-verify -m "chore: generated file update"

--no-verify skips both pre-commit and commit-msg. Use it deliberately, not as a reflex.

Project Structure After Setup

your-project/
├── .husky/
│   ├── commit-msg       ← runs commitlint
│   └── pre-commit       ← optional: linting, etc.
├── .commitlintrc        ← commitlint config
├── package.json         ← prepare script lives here
└── ...

.husky/ and .commitlintrc belong in version control — that’s the whole point. Commit both. That’s what makes this work for everyone, not just you.

What You Actually Get

A month of enforced Conventional Commits looks like this:

git log --oneline

a1b2c3d feat(auth): add OAuth2 integration
e4f5g6h fix(api): handle empty response body
i7j8k9l refactor: extract validation helpers
m1n2o3p docs: update setup instructions
q4r5s6t feat: add dark mode toggle

Every line is readable and searchable it tells you exactly what changed and where to look. Compare that to updates, wip, FINAL FINAL history at the top of this post.

FAQ

Does this work with non-npm projects? Husky needs Node.js for installation, but the hooks themselves are plain shell scripts. If your project uses another language, you can install Husky as a dev dependency just for tooling. Alternatively, pre-commit (Python ecosystem) or Lefthook (language-agnostic) cover the same ground.
What if someone doesn't have Node installed? The prepare script fails silently — nothing breaks. Non-Node contributors just won't have the hooks locally. CI is your safety net for that case.
Does this work with pnpm or yarn? Yes. The setup is identical. Replace npm install with your package manager. The prepare lifecycle script works the same way across npm, pnpm, and yarn.
How do merge commits get handled? Merge commits generated by Git — like Merge branch 'feature' into 'main' — don't match the Conventional Commits format. commitlint detects they're machine-generated and skips validation automatically.
Should I enforce this in CI too? Yes. --no-verify exists, so local hooks aren't airtight. Add a CI step that runs commitlint against the PR's commits. Local hooks give fast feedback. CI gives actual enforcement.

Conclusion

Git hooks have been there the whole time. The friction was never the feature — it was the setup. Files that don’t get committed, scripts copied manually, teammates who never ran the setup because it wasn’t npm i.

Husky removes all of that. Two packages, one config file, one hook. Commit the .husky/ folder, and the whole team has the same rules from day one.

Set it up once. Stop thinking about it. Start reading your git log without closing the terminal in frustration.


Share this post on:

Next Post
Search Google Like a Pro. site:, filetype:, and Other Operators That Actually Work