Home Posts X GitHub

Why husky has dropped conventional JS config

A short explanation about my favorite new husky feature and what led to it.

Context

I’ve been working on husky for seven years and closed 600 issues related to Git hooks in JavaScript projects. In January, ~100 issues were closed along with the new release.

Since v0, husky was configuring Git hooks using JavaScript, either with .huskyrc.js files or fields in package.json.

Now Git hooks are configured using individual files for hooks in a .husky/ directory.

Why the sudden change?

Everything changes and evolves. The JS ecosystem has changed (yarn has been introduced, monorepos became a trend, new tools and best practices arised, even npm works slightly differently, …) and Git has introduced a new exciting feature.

But before speaking about the new config and its benefits, let’s see the approach taken by husky till now.

How husky worked

In a very Linux way, to configure a Git hook, you simply put an executable text file in .git/hooks/.

To be able to run any Git hook created by the user in .huskyrc.js, husky was installing all possible hooks in .git/hooks/.

For example, when a commit was made, each Git hook would check if there’s a corresponding hook definition in .huskyrc.js:

$ git commit

pre-commit (native) → husky/runner.js (node)
  → is a pre-commit defined in `.huskyrc.js`? → YES, run it

prepare-commit-msg (native) → husky/runner.js (node)
  → is a prepare-commit-msg defined in `.huskyrc.js`? → NO, do nothing

commit-msg (native) → husky/runner.js (node)
  → is a commit-msg defined in `.huskyrc.js`? → NO, do nothing

post-commit (native) → husky/runner.js (node)
  → is a post-commit defined in `.huskyrc.js`? → NO, do nothing

The benefit of it: users can add, update and remove hooks from .huskyrc.js and changes will be picked automatically.

The downside, node is started even when there’s nothing to run.

But couldn’t we install only the needed hooks instead, you may ask? This was envisioned three years ago (#260) but husky wouldn’t “just work” automatically anymore.

For example, let’s consider the following config:

// .huskyrc.js
module.exports = {
  hooks: {
    'pre-commit': 'cmd'
  }
}
.git/hooks/pre-commit    ← is somehow created

Then you modify it:

// .huskyrc.js
module.exports = {
  hooks: {
    // 'pre-commit': 'cmd', ← removed
    'commit-msg': 'cmd'      added
  }
}

Your .git/hooks directory is now inconsistent:

.git/hooks/pre-commit    ← still exists
.git/hooks/commit-msg    ← doesn't exist

Since your hooks definition is not in one place anymore but in two (.huskyrc.js and .git/hooks/), suddenly you need boilerplate to keep JS world in sync with Git world.

You (and your collaborators) need to re-generate hooks every time there’s a change in .huskyrc.js. Re-generation could be bound to some events, but there’s no reliable way to cover all possible cases and unexpected behaviors would appear. That’s why this approach was dismissed.

Husky new approach

When your abstractions have flaws, it’s often a sign to take a step back.

What do we know so far?

  1. By design, Git hooks configuration is done via executable scripts.
  2. Husky initial approach was an indirection and a bit of magic.
  3. Generating Git hooks from a JS config can get out of sync.

Can we do better with Git?

In 2016, Git 2.9 introduced core.hooksPath. It lets you tell Git to not use .git/hooks/ but another directory.

This is what the new husky is built upon:

It solves the first problem (unnecessary Git hooks) and the second one (have your hooks definition in a single place).

In other words, when you create a hook with the new husky, it’s pure shell and directly accessible. There’s nothing anymore between Git and you ❤️. I find it beautiful.

But…

“It’s not common to have a directory."

It depends, other tools use directories to store config. For example, a repo can have the following directories .vscode/, .github/, .storybook/, …

Technically, it’s just another entry in your tree hierarchy. A .husky/ directory doesn’t add more visual clutter than a .huskyrc.js file.

This is also how Git hooks were designed and why husky follows this approach.

You can however choose where to put .husky/. For example, it could be in config/ grouped with other config files for those who’d wish to.

“JS tools like Jest, ESLint, Prettier, … are configured with JS config and husky is a JS tool."

You can have .jestrc.js, .eslintrc.js, .prettierrc.js and it makes sense since they’re entirely written in JS.

But husky is a special case, it’s not pure JS, it’s “hybrid”. It interacts with a non-JS tool that already has a way to be configured.

“It’s harder to setup."

Husky comes with an init command (recommended), you’re good to go in seconds.

$ npx @husky/init # done!

That said, manually setting up husky is done only once. You have to change one line in package.json and run a command to add a hook (that’s all).

That’s the same number of steps for jest, eslint, … add the command to package.json and create a file .jestrc.js, .eslintrc.js, …

(There’s only one exception though, if you’re publishing a package and at the same time using Yarn v2, it’s three lines)

“Why still use husky if there’s core.hooksPath?"

Husky provides some safe guards based on previous versions feedbacks, user-friendly error messages and some additional features. It’s a balance between going full native and a bit of user-friendliness.

“Migrating will be complicated."

There’s husky-4-to-6 CLI that will do it for you automatically.

…but I still want to define hooks in package.json

Good news! Nothing prevents you to do so :) Actually, it’s quite simple, run the following command:

$ npx husky add .husky/pre-commit "npm run pre-commit"

Create a pre-commit script in your package.json:

// package.json
{
  "scripts": {
    "pre-commit": "npm test && eslint"
  }
}

You’re done.

Conclusion

I hope it makes things clearer. This whole release has been carefully thought for months to provide the best approach to Git hooks, the most flexibility while keeping husky 4 main features.

Overall, it better adheres to Git philosophy and package managers recommandations.

It makes sense to configure pure JS tools with JS format config. It’s the same language after all.

However when there’s something native, using JS as an intermediate just to define or run Git hooks feels weird to me now. Like holding a hammer with a plier 🛠️ to drive a nail… you only need the hammer 🔨.

Thanks for reading. I understand it’s pretty new and if you’re still unsure, consider giving it 5 minutes.


I’ve left my job to be able to work on Open Source. Many thanks to the people and companies who have sponsored this release and the awesome projects which have started using it!