How to set up Husky hooks in a monorepo? Why should you? How to use them to enforce linting policies among your team?
It's another day of work. It's a good day at work - you finish stuff! You commit your changes and push them, and go to make yourself some caffeinated beverage while you wait for the CI to do its thing.
You come back and spill your beverage in shock as you see the CI failed! If only you could've prevented it!
You remember your friend talking about git hooks, and setting up something for themselves, but their idea for sharing was to send you a file. "It's hard to enforce hooks in a monorepo", they said.
After googling a bit, though, you find a nice article that explains how to do that (this one right here!). This is how it can be done:
Very TL;DR
Husky hook -> Main monorepo script with scope -> Individual package scripts
TL;DR
- Set up Husky & hooks in your main monorepo directory
- Your hooks should use your monorepo tool to run a script of a specific name by running it with scope.
- Each package that you'd like to have this hook work on should have a script of that same name defined in its
package.json
file.
What's this about?
Git hooks are scripts that run on different stages of the git workflow. They can be used for side effect, or to fail a flow.
For example, the pre-commit
hook runs when you try to commit something. If it fails (exits with code 1), the commit will not be made.
This is a perfect mechanism to enforce linting and tests before a developer's code is added to the repository.
However, there's one problem with this approach - git hooks are a git mechanism - they are local, user-specific and live in your project's .git
folder. How can you enforce that nice pre-commit script for all users?
Husky is a wonderful package that allows you to define hooks for your package, then install them. But...
The Monorepo Problem
In monorepos, your repository is managed outside of your individual packages.
When you try to use husky with an individual package, it just won't work.
# Works okay
> npx husky add .husky/pre-commit "<something to execute>"
# Will not add your pre-commit hook to the right directory...
> npx husky install
# ...so it'll not run when it's supposed to :(
> git commit -m "Blah"
The Solution
I'm using pnpm & pnpm workspaces for my project, so I'll use those for the code examples. They are easily interchangeable with your package manager & monorepo tools of choice.
Also, I'm demonstrating this with adding a pre-commit hook for linting. The same method should work with any other hook as well.
1. Install Husky in your main monorepo
Go to your main monorepo directory, and run:
pnpm i -D husky
2. Set a main pre-commit script
In your main monorepo's package.json
file, set a pre-commit script that runs a specific script in all projects.
Most (or all) monorepo tools have an option to run scripts for all, or some of the packages, by defining a scope.
In my current project, I make sure to use npm's scope notation (not related to the previous scope I mentioned) for all package names (@product/ui
, @product/api
etc.). This allows me to run a script on all packages easily:
> pnpm run --filter "@product/*" myscript
// main package.json
{
scripts: {
"pre-commit": "pnpm run --filter \"@product/*\" pre-commit"
}
}
At this opportunity, you could also add a prepare
script that runs husky install
, so every user that sets up your project will have those hooks added to their .git directory automatically:
// main package.json
{
scripts: {
"pre-commit": "pnpm run --filter \"@product/*\" pre-commit",
"prepare": "husky install"
}
}
3. Add a husky pre-commit hook:
Still in your main directory, create a husky pre-commit hook that runs your new pre-commit script:
> npx husky add .husky/pre-commit "pnpm pre-commit"
4. Add pre-commit scripts for individual packages
The last things you'll have to add are the individual pre-commit scripts for each package.
I won't get into the actual linter configuration here, but I recommend using lint-staged with whatever linters you're already using. Lint-staged will only check staged files, so you'll save time and noise by only getting errors that are relevant to your current commit.
// Individual package's package.json
{
scripts: {
"pre-commit": "lint-staged"
}
}
If you don't add a pre-commit script to a project, nothing will break. This is good for many cases, but beware not to miss adding a hook script for a new project because of this.
5. And that's that!
The next time you'll commit something, all of your package's pre-commit
scripts will run.
Conclusion
Git hooks & Husky are powerful tools that allow you to enforce running local scripts and keeping coding standards. By using this approach, you can enjoy the advantages of hooks and of proper script scoping (each project has its own script inside it) in a monorepo.
What do you think? Would you approach this differently?
Thanks to Yonatan Kra for his thoughtful and thorough review. Check out his blog for many good articles of an experienced developer.
The cover picture was made with DALL-E 2. I don't know a lot about fishing, so I'm trusting its judgement about how fishing rods look like.
Top comments (1)
Сongratulations 🥳! Your article hit the top posts for the week - dev.to/fruntend/top-10-posts-for-f...
Keep it up 👍