Husky: How to automatically format, lint and test before you commit or push

Feature image: Husky: How to automatically format, lint and test before you commit or push

When a team works on a project, keeping the codebase up to coding standards might be challenging. Perhaps a team member submits code that deviates from the established coding style, or you make a commit fixing a component but unintentionally breaking another. What can we do to mitigate these risks?

We can leverage several command-line tools to safeguard the quality of the codebase even before changes are merged into the repository:

  • Formatters automatically correct the coding style, ensuring consistency across the entire codebase.
  • Linters analyze the code to detect and fix issues, enforce best practices, and prevent common errors. (formatters vs. linters)
  • Testing frameworks validate the application's functionality against expected outcomes.

These tools are fantastic, but there's a catch: you and your teammates must remember to run them before pushing code to the repository. And, being humans, sometimes you might forget to do so.

That's why today we'll talk about Husky, a tool that automatically runs any number of commands whenever you commit or push. You'll never have to worry about forgetting to format, lint, or test before uploading code to the repo — Husky does it for you every time you run git commit or git push. Let's get started.

Setting up the scene

Suppose we are working on a Laravel project containing PHP and JavaScript code. Don't worry, these instructions will work even if you're not in this exact tech stack.

For PHP, let's use:

  • Tighten's Duster, a formatting and linting toolset for Laravel apps: ./vendor/bin/duster fix
  • Pest as a testing framework: php artisan test

And for JavaScript:

  • Prettier as a formatter: npm run format
  • ESLint as a linter: npm run lint
  • Vitest as a testing framework: npm run test

Husky in action

Husky is a modern native solution for managing your Git hooks, custom scripts you can define to be fired when certain Git events take place. Created by typicode (the developer behind json-server and jsonplaceholder) and counting over 10 million weekly downloads on npm, this tool allows you to automate command execution upon Git events like committing or pushing.

To start, install Husky as a development dependency:

npm install --save-dev husky

After installation, initialize Husky by running the following command:

npx husky init

Please note that, to run this command successfully, you'll need to first initialize your Git repository.

This simple command does two essential things:

  • Adds the prepare script to your package.json.
  • Creates a .husky folder in your project's root directory containing a pre-commit file.

This pre-commit file is a bash script that will run before every commit; by default, it contains just one line:

npm test

We can edit this file to include additional commands. Let's adapt it to run the commands we need for our specific project. Modify the pre-commit file as follows:

npm run format
npm run lint
npm run test
./vendor/bin/duster fix
php artisan test

Now, when you execute a commit using:

git add -A && git commit -m "My commit message"

Husky will automatically run the specified commands before allowing the commit to proceed.

  • The commit will be successful if the commands execute without encountering errors.
  • However, if any of the commands encounter an error during execution, the commit process stops, and the error output will be displayed in the terminal so you can take care of it, and then try the commit again.

For example, imagine we have an undefined method in a JavaScript file. Husky will execute the first command, npm run format, without issues. It will then proceed to the second command, npm run lint, where it will encounter the error:

/dev/laravel-app/resources/js/bootstrap.js
4:1 error 'translate' is not defined no-undef
 
1 problem (1 error, 0 warnings)
 
husky - pre-commit script failed (code 1)

Husky will halt the execution of subsequent commands and cancel the commit, allowing us to rectify the issue before attempting to commit again. There's nothing like catching a bug before it flies!

Once you've completed your Husky setup, add the .husky folder to your repository to ensure that the pre-commit hook we created will run before anyone commits to the codebase.

You can create another hook by creating more files in the .husky folder. The filename must be a valid Git hook name; for example, pre-push.

How to format and lint only the staged files

This strategy is excellent, but you might have noted a downside: running the formatter and linter across the entire codebase may seem excessive when modifying only a few files. Let's address that.

In PHP

With Duster, we can utilize the --dirty flag, which instructs the tool to run linters or fixers only on files that have been staged but not yet committed.

In our example, the command would be:

./vendor/bin/duster fix --dirty

We can proceed to edit the .husky/pre-commit file:

npm run format
npm run lint
npm run test
-./vendor/bin/duster fix
+./vendor/bin/duster fix --dirty
php artisan test

And we are done! Now, let's move to JavaScript.

In JavaScript

lint-staged is a tool that enables us to perform checks selectively on just the files we've edited and staged for commit, resulting in a significantly quicker check run.

To use it, let's install it as a development dependency:

npm install --save-dev lint-staged

Add this script to package.json:

{
"scripts": {
// other scripts
"lint-staged": "lint-staged"
}
}

Now, let's update the Husky pre-commit hook. Open .husky/pre-commit and replace the multiple npm commands with the one we just added:

-npm run format
-npm run lint
-npm run test
+npm run lint-staged
./vendor/bin/duster fix --dirty
php artisan test

Finally, let's create a configuration file named .lintstagedrc.json in the root of the project. This file specifies which commands to run based on file extensions. The precise set of commands may vary depending on your setup, but in our example, it will be:

{
"*.css": [
"prettier --write"
],
"*.{js,vue}": [
"prettier --write",
"eslint --ignore-path .gitignore --fix",
"vitest related --run --environment=jsdom"
]
}

We are instructing lint-staged to perform the following tasks:

When CSS files are staged, then:

  • Run Prettier only on those files

When JavaScript files are staged, then:

  • Run Prettier only on those files
  • Run ESLint only on those files
  • And finally, run Vitest

So, if we only have three files staged, lint-staged will check just those three, skipping the rest of the codebase. This way, we save time by focusing only on what's new or changed.

$ git add -A && git commit -am "Add a cool feature"
 
> demo-husky@0.0.0 lint-staged
> lint-staged
 
Preparing lint-staged...
Running tasks for staged files...
Applying modifications from tasks...
Cleaning up temporary files...
[main b57de91] Test lint-staged
2 files changed, 4 insertions(+), 7 deletions(-)

We run the whole test suite whenever we stage JavaScript code, because our changes might have unexpected side effects on other parts of the app. However, if we only edited CSS files, we can safely assume it's okay to skip JavaScript testing.

And if we did not stage JavaScript or CSS files, none of these checks will run. For example, if we stage app/Models/User.php, Husky will run Duster and Pest, but none of the commands included in lint-staged (Prettier, ESLint, and Vitest).

In Closing

That's it! We have covered how to format, lint, and test our front-end and back-end code.

This setup will allow you and your team to comply with the project's code styling and quality standards and catch bugs before they get merged into the repo's main branches.

Yes, initially, it might be frustrating to have a commit halted because of an error. But the payoff greatly exceeds that initial discomfort. And here's a little secret: if you want to bypass Husky, add --no-verify at the end of your commit command. But don't tell anyone I told you!

You can access the complete codebase for this article on this public GitHub repo.

I hope you can implement one or two tips from this guide. If you want to see more content like this, let us know. Until next time!

Get our latest insights in your inbox:

By submitting this form, you acknowledge our Privacy Notice.

Hey, let’s talk.

By submitting this form, you acknowledge our Privacy Notice.

This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.

Thank you!

We appreciate your interest. We will get right back to you.