DEV Community

Cover image for Git hook is the excellent alternative to Husky
Krzysztof Kaczyński
Krzysztof Kaczyński

Posted on • Updated on

Git hook is the excellent alternative to Husky

Backstory

Some time ago I was asked to introduce an automatization which would check if committed files fit linter rules responsible for uniform code formatting and code quality (e.g.: eslint, prettier, stylelint e.t.c.)

After I did some research it came out that the most common way to do that is to use husky with lint-staged. I installed and configured those tools. Everything worked as expected. If the file contained any errors which couldn't be auto-fixed by linter, committing process was interrupted and the error message was shown in the terminal. Unfortunately, this solution has one problem. Running husky and lint-staged takes much more time than I expected. Sometimes it even took more time than the committing process itself (including checking the files for any errors).

Git-hooks

As I had some time left after I completed this task I decided that I will look for another solution. I searched a little more and I found git-hooks. I read a bit more about git-hooks and it came out that git offer native solution to do some custom actions at certain points in git execution for example committing changes. pre-commit caught my attention, which is briefly described like this:

"This hook is invoked by git-commit[1], and can be bypassed with the --no-verify option. It takes no parameters, and is invoked before obtaining the proposed commit log message and making a commit. ..."

From the above text it follows, that before commit becomes submitted we have some time to execute custom operations like linting and auto-fixing staged files. All files changed in this phase can be added and included in the same commit (it means that we do not have to create a separated commit to apply changes from linters auto-fixes). After I read some about shell scripting I was ready to create my first git-hook

pre-commit

#!/bin/sh
RED="\033[1;31m"
GREEN="\033[1;32m"
NC="\033[0m"
linter_exit_code=1
all_ts_files=$(git diff --cached --diff-filter=d --name-only | grep .ts$)
all_scss_files=$(git diff --cached --diff-filter=d --name-only | grep .scss$)
./node_modules/.bin/eslint $all_ts_files --quiet --fix && ./node_modules/.bin/stylelint $all_scss_files --stdin --quiet --fix
linter_exit_code=$?
git add -f $all_ts_files $all_scss_files
if [ $linter_exit_code -ne 0 ]
then
  echo "${RED} ❌ Linter errors have occurred ( ͡ಥ ͜ʖ ͡ಥ)${NC}"
  exit 1
else
  echo "${GREEN} ✔ Eslint and Stylelint did not find any errors [̲̅̅(̲̅ ͡° ͜ʖ ͡°̲̅)̲̅̅]${NC}"
  exit 0
fi
Enter fullscreen mode Exit fullscreen mode

What is going on in above code:

  • git diff --cached --diff-filter=d --name-only | grep .ts$ → we are collecting all staged files, then we are filtering out deleted ones (if you do not do that your linter will throw an error for those files because this linter won't be able to resolve paths for deleted files) then I am using grep to take only files which I am interested in. In my case, I am collecting .ts files for eslint and .scss for stylelint,
  • linter_exit_code=$? → save exit code of last executed action(0 in case no errors or errors that can be auto-fixed by linter or 1 in case of errors not fixable by linters)
  • git add -f $all_ts_files $all_scss_files → add files auto-fixed by linters. We need to use -f flag to force git add in case of $all_ts_files and $all_scss_files are empty
  • At the end of this script I am displaying proper information basing on exit code value

After we create a git-hook we have to remember to update git configuration or create a symlink between git configuration and created git-hook:

  • git command (should work for every operating system)

    git config core.hooksPath ./git-hooks
    
  • symlink (Linux)

    ln -s -f ../../git-hooks/pre-commit .git/hooks/pre-commit
    

It is worth to add above scripts to npm postinstall, because of that every developer which will clone our repository and run npm install script will also configure git-hooks

Summary

Using git-hooks instead of husky and lint-staged came out to be an excellent idea because committing time was sped up about twice. In addition, I got rid of two additional dependencies in the project, what can become very useful especially in case of husky because from Husky 5 documentation we can find out that Husky 5 will be free only for open-source projects.

Seven steps to set up git-hooks

  1. In project directory create git-hooks directory
  2. Go to .git/hooks directory
  3. From the name of hook which you want to use remove .sample
  4. Move this hook into created git-hooks directory
  5. Create your git-hook body
  6. Update git configuration or create a symlink from git-hooks to .git/hooks directory
  7. Add the appropriate script to npm postinstall command

Simple example

I prepared a simple repository git-hooks-example to prove that those git-hooks will work on Linux / Windows / Mac. In Redme.md I wrote how you can test this pre-commit hook.

Top comments (32)

Collapse
 
tomdavidson profile image
Tom Davidson • Edited

husky uses githooks and just uses the convention of using like-named npm run scripts as the hook script. For example, this local repo with husky has the following pre-commit script (as does all the husky installed hooks):

.git/hooks$ cat pre-commit
#!/bin/sh
# husky

# Created by Husky v4.3.5 (https://github.com/typicode/husky#readme)

. "$(dirname "$0")/husky.sh"
Enter fullscreen mode Exit fullscreen mode

husky.sh then checks for config files which package manager you're using and what not but eventually just executes the script of the same hook name you have defined. husky is ux sugar on top of git hooks.

Collapse
 
krzysztofkaczy9 profile image
Krzysztof Kaczyński

Yes, but it is an additional layer of abstraction which maybe you do not need. Another difference is that Husky in version < 5 (I do not know how it will work in version 5 but for this version you have to pay unless your project is open-source) without lint-staged will, lint all files not only staged files and I think it is not that good because in my opinion if you are committing 6 files you want to run your linter only on those 6 files no whole repo (it will also make committing process much longer if your project is big)

Collapse
 
tomdavidson profile image
Tom Davidson

Seems like there are several things getting conflated. You can run your shell script in an npm run script named, 'pre-commit' and not use lint-staged at all. lint-staged and husky are two different tools.

The temporary patron license window is kinda funny but I'm glad there are experimentation with open source funding models. After the funding drive / early access 5.x also will be licensed as MIT (according to the top of the project readme). The important thing is that we do not overreact to FUD. But this is a completely different issue than 'git-hook is the new husky'.

As for the unnecessary abstraction concern. Does husky, lefthook, overcommit, etc add value over using git-hooks directly? I would say in nearly all cases yes but each team/project should prob do what works best for them. git-hooks were not designed to be part of the collaborative workflow - .git/hooks/* are not nodes in the git DAG. Using the git-hook manager abstraction to hook into git but run scripts that are in other workflow tools and part of the versioned code helps the work flow code be more manageable, visible, and consistent.

In expressions-calculator you attempt to make git hooks collaborative with:

"postinstall": "ln -s -f ../../git-hooks/pre-commit .git/hooks/pre-commit",

I understand its an opinion, but I dont think this is cleaner than husky. Also, what is this doing, forcing symlinks to scripts from a grandparent dir from outside the repo?

On one had I dig a git-hooks dir in the repo root idea. If I wanted an alternative to husky I might have a deps module that on install symlinked the scripts from git-hooks/ to .git/hooks BUT, git hooks are just hooks. Not a special classification of script, why should they have their own folder in the root? Perhaps a ci dir could have a pre-commit script that gets symlinked .... or perhaps we just use husky and be done with it.

The reason I am spending this much time replying is that the rejection of husky seems to be an over reacting to the licensing FUD which hurts us all. Rather than dog the project we should help end the funding drive early, and get an MIT GA release out.

Thread Thread
 
krzysztofkaczy9 profile image
Krzysztof Kaczyński • Edited

"You can run your shell script in an npm run script named, 'pre-commit'" → yes I can but then I will not have access to only staged files and husky will lint all files in your project what is probably not something what you want. Probably you can try to create a script which will pull out all staged files but then why do not use git-hooks?

"it-hooks were not designed to be part of the collaborative workflow" → I agree in 100%, but husky 5 will also usegit-hooks under the hood, so not the best argument not to use git-hooks. git-hook is the best way to control commits and ultimately perform custom operations that I know of. If you have a better idea, how can I control commits in JS projects let me know?

"I understand its an opinion, but I dont think this is cleaner than husky. Also, what is this doing, forcing symlinks to scripts from a grandparent dir from outside the repo?" → as I mentioned in my article you do not have to use symlinks there is an alternative which I will paste below:

git config core.hooksPath ./git-hooks
Enter fullscreen mode Exit fullscreen mode

And that's all, paste this line into postinstall script and this will work the same as symlink which I am using in my repository.

"If I wanted an alternative to husky I might have a deps module that on install symlinked the scripts from git-hooks/ to .git/hooks BUT, git hooks are just hooks. Not a special classification of script, why should they have their own folder in the root?" →I think that code splitting is always better than keeping everything in one file. If there is a possibility to move those hooks into a separated folder which will keep only those git-hooks rather than put everything into package.json than I think this is the way to go.

"The reason I am spending this much time replying is that the rejection of husky seems to be an over reacting to the licensing FUD which hurts us all" → I didn't say: "Do not use husky it is bad". I show an alternative to husky. I think many people don't know about the existence of git-hooks because husky + lint-staged is always the first result in the search engine if you are looking for information on controlling commits in JavaScript projects, so I wanted to shear my experience with git-hooks and show an alternative for this duo.

Collapse
 
isaachagoel profile image
Isaac Hagoel

I think the whole point of using husky is for anyone who clones the repo to have the hooks auto created (and stay up to date with whatever is confgured in package.json) without having to take the extra steps of creating links/scripts under .git.

Collapse
 
krzysztofkaczy9 profile image
Krzysztof Kaczyński • Edited

It works the same for git-hooks after you clone the repo and run npm install your hooks will be ready for you. You can check this repository for example: git-hooks-example. After you clone this repo and run npm install and try to commit sth which doesn't fit my linter rules you will get an error if linter won't be able to auto-fix errors

Check my custom git-hooks directory and npm postinstall in packages.json.

Collapse
 
isaachagoel profile image
Isaac Hagoel

Very nice! I didn't know you could do that

Collapse
 
cfuehrmann profile image
Carsten Führmann • Edited

Husky is a convenient way of auto-adding Git hooks during npm install or yarn install. But more importantly, it is a way of providing platform independence for hook scripts. This blog post uses #!/bin/bash. But what if the developer does not have sh? (As is the case for most Windows users.) By contrast, we do have the guarantee that every user of husky has NodeJs.

Collapse
 
krzysztofkaczy9 profile image
Krzysztof Kaczyński • Edited

This is * #!/bin/sh not #!/bin/bash. I added an example to this blog post. You can download it and check that this will work on Windows too: git-hooks-example

Collapse
 
cfuehrmann profile image
Carsten Führmann

I didn't claim it was bash. The Windows command line interpreter definitely cannot run arbitrary sh scripts, which are a Unix standard. Let alone certain basic commands - for example cp is for copying files on Unixoids, but copy must be used on Windows. It's interesting that your script runs on Windows. I see two possible explanations: Either your script is so parsimonious that its syntax is valid both in sh and on Windows. Or somehow Git, or your Git installation, brings sh with it. It would be interesting to figure out if the second explanation is true. It would also be interesting to know if, for example, you could use the cp command on Windows from a sh script like yours above.

Thread Thread
 
airtonix profile image
Zenobius Jiricek

you can if you install winbash, which is different from msysgit bash in that winbash actually treats windows paths as windows paths, where as msysgit bash mutates them to some virtual-path-that-like-linux-but-not-really-linux.

Collapse
 
tcarrio profile image
Tom

This is great as it brings awareness of the native git functionality around commit hooks. Love it 🤓

It might be worth checking this: it looks like you aren't checking for extensions, just end of strings with your TS and SCSS file grep (/ts$ vs /\.ts$/). Thus you could have commit hooks failing because a file called "starts" or "boots" for example would be linted and likely fail if there's any non-JS code in there.

Collapse
 
krzysztofkaczy9 profile image
Krzysztof Kaczyński

I am glad to hear that 😃.

You are right I should look for .ts$ (For grep you do not have to escape the . character 😉)

Collapse
 
codefinity profile image
Manav Misra

This is good info...but, it sure is simple to do: npx mrm lint-staged

Collapse
 
krzysztofkaczy9 profile image
Krzysztof Kaczyński

Hmm but if I use this npx mrm lint-staged, then I have to remember to run this script every time before I commit any changes. git-hoos will do it automatically. Correct me if I am wrong

Collapse
 
codefinity profile image
Manav Misra

Well, not necessarily. npx mrm lint-staged will add that to 'package.json' so it's all included with npm i. I use it for 'student repos' in my classes.
As you say though, we are adding dependencies. So, maybe convenience for some additional package.
So, it adds something like this one.
If it's 'new project' - not 'template repo,' then, yes, you add it manually each time.

Thread Thread
 
krzysztofkaczy9 profile image
Krzysztof Kaczyński • Edited

Yes, but now you are adding husky and lint-staged and this article is about that maybe you do not need it because there is actually no so much work to set up git hooks in my opinion.

This solution with husky and lint-staged was my first attempt to solve this task. I wrote about that in Backstory section

Collapse
 
zuzusik profile image
Viktor Zozuliak

There is one limitation which comes with this solution - all git hooks become source-controlled.

The whole idea having git-hooks out of source control under .git/hooks folder is to give flexibility for users/tools to install their own hooks without interfering with other repository contributors.

While this is definitely a corner case and in most cases users don't install their own hooks and don't use dev tools which do so, still it can be an issue. And Husky solution doesn't have this limitation.

Collapse
 
slackerzz profile image
Lorenzo

As someone else already said, husky uses git-hooks under the hood.
The main point is that when working in a team husky let you share the pre-commit hook with your team since it's in the repo,v defined in the package.json.
Going with your solution will require to share an additional file. I've used that in the past but I can assure you that not every team member will set up the pre-commit hook manually.
With husky it's automatic.

Collapse
 
krzysztofkaczy9 profile image
Krzysztof Kaczyński

Lorenzo, please check an example which I added to this article. The team member only has to run npm install this will trigger npm postisntall which will set up git-gooks automatically. I think that every developer after downloading JavaScript project with npm will run npm install before he starts to work with this code

Collapse
 
slackerzz profile image
Lorenzo

Sorry, i missed that part 😅 (i read the post and replied with my little son climbing on me)

Collapse
 
christiankozalla profile image
Christian Kozalla

Thank you for this remarkable post about git-hooks!

I was looking for a tool to run a script to generate a new site map on my blogs repo every time I added a new post (new md-file in posts directory). I stumbled upon git-hooks then, but didn't dive into the shell scripting.. With your article I am going to set it up this week 😄

Collapse
 
krzysztofkaczy9 profile image
Krzysztof Kaczyński

I'm glad to hear that 😄

Collapse
 
alevosia profile image
Ålex • Edited

"Git hook is the new Husky" makes it sound like Husky isn't git hook under the hood and no mention of that either in your article. Hmm.. JS is the new React when? I'm sure that would speed some apps up "about twice".

Collapse
 
jonrandy profile image
Jon Randy 🎖️ • Edited

So, going straight through Githooks instead of using Githooks through Husky is faster! Mind blown 😂

Collapse
 
cpmech profile image
Dorival Pedroso

Good idea!

I also run tsc to check my TypeScript code. Here you go my git pre-commit hook (based on yours; thanks):

#!/bin/sh

staged_files=$(git diff --cached --diff-filter=d --name-only | grep  -E '\.(js|jsx|ts|tsx)$')

# skip if there are no js or ts files
if [ -z "$staged_files" ]; then
    exit 0
fi

# run type-check
yarn run --silent tsc
tsc_exit_code=$?

# check the tsc exit code
if [ $tsc_exit_code -ne 0 ]; then
    echo "🥵 tsc failed"
    exit 1
else
    echo "👍 tsc"
fi

# run linter on staged files => save exit code for later
yarn run --silent eslint $staged_files --quiet --fix
linter_exit_code=$?

# add files auto-fixed by the linter
git add $staged_files

# check linter exit code
if [ $linter_exit_code -ne 0 ]; then
    echo "🥵 lint failed"
    exit 1
else
    echo "👍 lint"
fi

# return 0-exit code
echo "🎉 all good to go"
exit 0
Enter fullscreen mode Exit fullscreen mode
Collapse
 
airtonix profile image
Zenobius Jiricek • Edited

I think this will fail if you're using isolatedModules

Collapse
 
edo78 profile image
Federico "Edo" Granata

Well git hook can't be the new husky because husky use git hooks ... (BTW After the early access, husky v5 will be MIT again.)

Husky is a simple way to have the hooks under versioning and shared between all the devs ... sure you can reinvente the wheel again but the whole post looks like a big misunderstanding of husky.

There's nothing wrong in manually create your link to emulate what husky already does but "Git hook SURELY ISN'T the new Husky"

Collapse
 
ambroseus profile image
Eugene Samonenko

If you can't sponsor Husky, that's okay, husky v4 is free to use in any project. During the early access, v4 will continue to receive maintenance updates.

After the early access, husky v5 will be MIT again.

Collapse
 
elninoisback profile image
Ε Γ И І И О

"Husky 5 will be free only for open-source projects." Is this still valid? It looks like they had second thoughts.

Collapse
 
krzysztofkaczy9 profile image
Krzysztof Kaczyński

Thank you for the information :D I have updated my post

Collapse
 
milesq profile image
Miłosz Wiśniewski

Husky was created AFTER git (and hooks) and is to facilitate work with git hooks