Back to the blog

How to set up a pre-commit Git hook with Husky and lint-staged

Table of content

Git hooks are a great way to automatically run specific tasks (such as formatting or linting tasks) at key moments in your Git workflow. In this post, we'll explore Git hooks and walk through how to set up a pre-commit hook using Husky and lint-staged to lint and format your staged files automatically. Let’s dive in.

What are Git hooks?

As mentioned in the Git documentation, Git hooks are a way to run scripts at specific moments in your Git workflow. They can help automate repetitive checks and enforce formatting or linting rules that help collaboration.

Inside any Git-tracked project, open the .git folder. You’ll find a series of folders that include a hooks folder.

Structure inside a .git folder showing the hooks directory among others
Structure inside a .git folder showing the hooks directory, among others.

The hooks folder contains example hook scripts (e.g., pre-commit.sample) that you can customize to automate tasks before commits, pushes and more. To make our lives easier, we’ll use husky to set up a Git hook. Husky makes it easy to create shareable Git hooks that you can add to version control.

Setting up the demo project

In this blog post, we’ll work with a simple repository that contains a small React app created with Vite.

To follow along, scaffold a Vite React and Typescript app:

npm create vite@latest demo-git-hooks-husky -- --template react-ts
cd demo-git-hooks-husky && npm install

At this point, you have a basic “Hello World” React app. The Vite boilerplate includes an eslint.config.js file to lint the files. Let’s install prettier to format our files:

npm install --save-dev prettier

Then, create .prettierrc at the root of the project with basic rules:

{
  "arrowParens": "always",
  "bracketSpacing": false,
  "printWidth": 80,
  "semi": false,
  "singleQuote": true,
  "tabWidth": 2,
  "trailingComma": "es5",
  "useTabs": false
}

Installing and configuring Husky

Husky is a development dependency to make it easier to add Git hooks. First, let’s install it:

npm install --save-dev husky

Once Husky is installed, run the following:

npx husky init

As explained in the official documentation, this command adds a “prepare” script to the package.json and creates a pre-commit hook in the .husky folder you should now have at the root of your repository.

The prepare script is one of the NPM lifecycle scripts that run automatically at specific moments. The prepare script runs on local npm install even when no arguments get passed (see the NPM doc for more details on the prepare script). This means that any developer who installs the project dependencies will automatically have the Git hooks correctly set up with Husky.

Let’s configure the pre-commit hook that was automatically created to make it more relevant to our project!

Configuring the pre-commit script with lint-staged

In our demo app, we have linting and formatting rules we want to apply to all our source files. While you could run linting and formatting as a GitHub action on push or pull requests, a pre-commit hook provides faster feedback by catching issues before a commit is created. When we try to commit a series of changes, we can write our Git hook to run a list of checks before a commit gets created.

Update the package.json lint and format scripts

By default, the pre-commit hook in .husky contains:

npm test

A good start is to ensure that all files are checked by our linter and formatted before they are committed to Git. In our package.json, we already have a lint script. Let's add a few more:

{
  "scripts": {
    "lint": "eslint .",
    "lint:fix": "eslint --max-warnings=0 --fix",
    "format": "prettier --write .",
    "format:file": "prettier --write"
  }
}
  • lint runs eslint on all the files.
  • The lint:fix script will try to auto-fix linting issues where possible. With the flag --max-warnings=0, it will fail (exit with an error) if any errors or warnings remain. We’ll use this script in our pre-commit to add an extra layer of protection and make sure to know as soon as possible if we need to fix the code manually.
  • format will format and write the changes.
  • The format:file script formats the files passed to it, which works well with lint-staged since it provides the staged files as input. It is a slight optimization as a way to only run prettier on staged files and not on the whole project.

Setting up lint-staged to lint and format staged files

lint-staged is an NPM package that will help us run our linter or formatter on staged files. We don’t want commits to fail because of issues in unrelated files. lint-staged will help us run the checks we define in our pre-commit hook on the files that are in the Git staging area. The Git staging area is where your tracked files get placed after you use git add the-file-you-modified.

Let’s install lint-staged:

npm install --save-dev lint-staged

There are many options to configure lint-staged. One of them is to create a .lintstagedrc at the root of the project.

touch .lintstagedrc

In the configuration file for lint-staged, we associate file extensions with the npm scripts we want to run for those files. Let’s add the following config to our .lintstagedrc:

{
  "*.{js,jsx,ts,tsx}": ["npm run lint:fix", "npm run format:file"],
  "*.{json,md,mdx,css,scss,html,yml,yaml}": "npm run format:file"
}

You can also add lint-staged configuration directly in the package.json if you do not want to create an extra configuration file:

{
  "lint-staged": {
    "*.{js,jsx,ts,tsx}": ["npm run lint:fix", "npm run format:file"],
    "*.{json,md,mdx,css,scss,html,yml,yaml}": "npm run format:file"
  }
}

Let’s now add an NPM script to the package.json to run lint-staged:

{
  "scripts": {
    "check:precommit": "lint-staged"
  }
}

Use lint-staged with the husky pre-commit hook

Now that we have lint-staged configured, we can modify the .husky/precommit file and use the check:precommit script we have added to the package.json. In .husky/pre-commit, add the following content:

npm run check:precommit

With all those changes, we now have a working pre-commit setup that uses eslint and prettier on the files we want to commit.

Under the hood: how does husky work?

When husky is installed, it runs a script that updates the .git/config file by setting the core.hooksPath to the .husky directory. This tells Git to look in .husky for hook scripts instead of the default .git/hooks directory.

In .git/config:

[core]
  ...
  hooksPath = .husky/_

In the husky source code, in the index.js, there is a statement that sets up the hooksPath:

let {status: s, stderr: e} = c.spawnSync('git', [
  'config',
  'core.hooksPath',
  `${d}/_`,
]);

In the above snippet, c.spawnSync is a way to launch a child process in Node. This is like running git config core.hooksPath <some_path> in the terminal.

Final project setup overview: Putting everything together

Here's a quick overview of the key files and their contents once everything is configured:

In package.json:

{
  "scripts": {
    "lint": "eslint .",
    "format": "prettier --write .",
    "format:file": "prettier --write",
    "lint:fix": "eslint --max-warnings=0 --fix",
    "check:precommit": "lint-staged"
  },
  "devDependencies": {
    "eslint": "...",
    "husky": "...",
    "lint-staged": "...",
    "prettier": "..."
  }
}

In .husky/pre-commit:

npm run check:precommit

In .lintstagedrc:

{
  "*.{js,jsx,ts,tsx}": ["npm run lint:fix", "npm run format:file"],
  "*.{json,md,mdx,css,scss,html,yml,yaml}": "npm run format:file"
}

This provides a fast, local pre-commit workflow to catch linting or formatting issues before they are committed to Git.

You can find the whole demo on GitHub.

Troubleshooting

If the pre-commit does not work:

  • Make sure you've staged the files using git add.
  • Make sure you've staged a file that your lint-staged configuration supports (a tsx, js or whatever extension you have configured).
  • Reinstall the dependencies if you do not see a .husky folder at the root of your project.

Conclusion

In this blog post, we explored how to use husky to create a pre-commit hook and how to use lint-staged to run checks on staged files. Pre-commit hooks help catch problems early and are a fantastic way to improve consistency when working on a shared codebase. I hope you find this blog post helpful. If you have any questions, don’t hesitate to reach out on Bluesky 😀

Resources