This is a blog post I’ve refactored from a talk I gave at an engineering brown bag in June 2024.

I’m a developer with lots of bad habits. Those bad habits become a lot more annoying for other devs to have to deal with. Sometimes large code bases have A LOT of code with other people with some bad habits. I think using a small and lean set of git hooks could prevent bad habits making it to remote.

Git hooks

Git hooks allow you to fire off custom scripts when certain important actions occur. There are two groups of these hooks: client-side and server-side. Client-side hooks are triggered by operations such as committing and merging, while server-side hooks run on network operations such as receiving pushed commits.

Husky

Husky allows you to execute local commands before your client-side Git hooks run. If these commands finish with a nonzero exit code then the current git action will fail. A local command can be a JavaScript package manager command or a POSIX shell script. If your command finished with a nonzero exit code then the hook will fail and the current git action will not complete.

Lets install Husky.

bun add -D husky
bunx husky init

The husky init command simplifies setting up husky in a project. It updates the prepare script in package.json as well as creating an example hook script. Running husky on the prepare lifecycle hook means it will be installed for other contributros to the project, and the same hooks and commands will run for them without any further setup required.

Lint-staged

I couldn’t write a better introduction to lint-staged than the authors themselves.

Run linters against staged git files and don’t let 💩 slip into your code base!

Linting and formatting and entire project can be slow and generally not necessary with every time you push code to a remote. Linting and formatting the files we are making changes to is sensible.

If the process is automated and should the linting check or formatting check fail lint-staged will finish with a nonzero exit code.

I think a clear benefit of something like this is that you can adopt it into an large existing codebase that has stood the test of time but might… smell a bit; and start gradually improving the quality of the code knowing that new logic follows the linting standards and is also well formatted.

Lets setup lint-staged

bun install -D lint-staged

add the following to package.json

{
  "scripts": {
    "start": "expo start",
    "android": "expo start --android",
    "ios": "expo start --ios",
    "web": "expo start --web",
    "prepare": "husky",
    "lint-staged": "lint-staged",
    "eslint": "eslint"
  }
}

define the lint-staged command in package.json

{
  "lint-staged": {
    "**/*": ["eslint", "prettier --write --ignore-unknown"]
  }
}

Lets run lint-staged on the pre-commit hook. To do this; add a new commit hook file in .husky called pre-commit. The content of that new file is going to tell bun to run the lint-staged command

touch .husky/pre-commit
# .husky/pre-commit
bun run lint-staged

We now have a higher degree of assurace that code commited to the repository will be well formatted and follow the linting standards. I say higher degree of assurance because these can of course all be bypassed locally using the force.

Commitlint

Its also not a terrible idea to have some linting on our commit messages. Commitlint offers simple no-frills approach that does a lot for you. It comes with a nice way of using the conventional commits spec so you really don’t have to think about it too much.

bun install -D @commitlint/cli @commitlint/config-conventional

add a new commit hook file in .husky called commit-msg. The content of that new file is going to tell bun to run the commitlint command we’re about to setup in our package.json

# .husky/commit-msg
bunx --no -- commitlint --edit "$1"

in your package json

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