A Git-Hook for Commit Messages Validation - No Husky, Just JS

Matti Bar-Zeev - Oct 14 '22 - - Dev Community

Join me on this week’s post as I seek out an alternative for Husky git-hooks solution and find it sitting right in front of me - Native Git infrastructure and NodeJS based custom hooks.

The need for a commit message standardization

At a certain point in any RnD org evolution the need for enforcing a git commit messages standard arises. The reasons may vary, but typically it’s for gaining better context for each change, perhaps insisting on a ticket reference or maybe other related metadata for each commit.

My reason for enforcing a standardization comes from the Conventional Commits standards and how they are translated to package version bumping, mainly on Monorepos. It is critical that commits will have a message which instructs the publishing mechanism how to bump the package version accordingly, otherwise… it’s a mess.

So how do we enforce conventional commits nowadays?
Well, Husky is the standard go-to tool for enforcing such a thing (along with linting etc.), but something just doesn't feel right with it. I mean, do we really need another NPM package here?

I see that Git already has its own hooks infrastructure to deal with that, both on the server and the client side, so why can’t we use Git’s native solution instead?

I believe that the main reason is that many fear that they won’t be able to distribute the hook configurations with the rest of the organization, since Git’s hooks reside in the '.git/hooks' directory which is not committed into the source control.
Being unable to share these configurations puts us back in the wild-wild-west of commit messages.

But I believe there is still hope -

First let’s define our goals:

  • Have a git message hook which will check if the commit message is a valid conventional commit message
  • Have this git message hook run on the client
  • Do it all using only Git API, no external libs
  • Have it as simple as possible so adding new hooks will be intuitive
  • Make sure that the configuration can and will be aligned with all devs in the org

As the Chemical Brothers used to say - “Here we go!”


I’m taking my Pedalboard monorepo as the project I’m going to apply this on, for the simple reason that conventional commits have a crucial part in bumping its packages version.
The plan is to create a commit-able configuration for a “commit-msg” hook, point Git to “look” at this configuration instead of its default one and then implement the required logic for enforcing the commit messages.

Creating my custom hook

As you might know, under the .git directory of your git-managed project there are several git hook examples ready to be taken and tweaked. Let’s see what we got there. Here is the content under pedalboard/.git/hooks:

applypatch-msg.sample
commit-msg.sample
fsmonitor-watchman.sample
post-update.sample
pre-applypatch.sample
pre-commit.sample
pre-merge-commit.sample
prepare-commit-msg.sample
pre-push.sample
pre-rebase.sample
pre-receive.sample
update.sample
Enter fullscreen mode Exit fullscreen mode

We are interested in the commit-msg.sample. I will copy it to a .git-hooks directory under the project’s root and change its name to “commit-msg” so that Git will stop ignoring it.
I will just add a line of code to make sure it runs when Git is about to perform a commit msg modification, something like this:

echo "My commit-msg hook"
exit 1
Enter fullscreen mode Exit fullscreen mode

Here is its original content with my change:

#!/bin/sh
#
# An example hook script to check the commit log message.
# Called by "git commit" with one argument, the name of the file
# that has the commit message.  The hook should exit with non-zero
# status after issuing an appropriate message if it wants to stop the
# commit.  The hook is allowed to edit the commit message file.
#
# To enable this hook, rename this file to "commit-msg".

# Uncomment the below to add a Signed-off-by line to the message.
# Doing this in a hook is a bad idea in general, but the prepare-commit-msg
# hook is more suited to it.
#
# SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p')
# grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1"

# This example catches duplicate Signed-off-by lines.

echo "My commit-msg hook"
exit 1

test "" = "$(grep '^Signed-off-by: ' "$1" |
    sort | uniq -c | sed -e '/^[   ]*1[    ]/d')" || {
   echo >&2 Duplicate Signed-off-by lines.
   exit 1
}
Enter fullscreen mode Exit fullscreen mode

At this point there is no use in committing something to check it, since Git is not pointing to my “.git-hooks” directory. Let’s make it happen with the following command -

Note that this only applies to the Git config of this project and not the global Git config. Don’t change your global Git config if you don’t have a good reason to do so.

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

You can validate that this was done like this:

git config --get core.hooksPath   

.git-hooks
Enter fullscreen mode Exit fullscreen mode

Now when I try to commit something, the commit does not succeed (remember the “exit 1”?) and logs out

My commit-msg hook
Enter fullscreen mode Exit fullscreen mode

Validating the Commit message

The logic should be straightforward - we get the commit message and match it with some regex which should represent the different conventional commit messages.

What I’m about to show you is pretty cool - you can write your custom hook in no other than JavaScript!

You just need to add to the file the annotation that this hook should run on NodeJS, and there you go - you can start doing your JS magic without messing around with bash scripts and what-have-you.
Here is an example which will yield “Matti” and will terminate the script, making the commit fail:

#!/usr/bin/env node

console.log('Matti');
process.exit(1)
Enter fullscreen mode Exit fullscreen mode

Ok, ok - let’s get back to what we are set to do. First I will get the message and print it out -

#!/usr/bin/env node

const fs = require('fs');

const commitMsgFile = process.argv[2];
const message = fs.readFileSync(commitMsgFile, 'utf8')
console.log('message:', message);
process.exit(1)
Enter fullscreen mode Exit fullscreen mode

In the code above we’re getting the 3rd argument, which is the commit message file, we read it using Node’s FS module, and print out the message.

All that is left to do now is to run some RegExp checks and validate it. Let’s see if I can find something on the web which can assist me with that… Here is a nice one.
My custom hook looks like this now:

#!/usr/bin/env node

const fs = require('fs');

const conventionalCommitMessageRegExp = /^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test){1}(\([\w\-\.]+\))?(!)?: ([\w ])+([\s\S]*)/g;
let exitCode = 0;
const commitMsgFile = process.argv[2];
const message = fs.readFileSync(commitMsgFile, 'utf8');
const isValid = conventionalCommitMessageRegExp.test(message);

if(!isValid) {
   console.log('Cannot commit: the commit message does not comply with conventional commits standards.');
   exitCode = 1;
}

process.exit(exitCode);
Enter fullscreen mode Exit fullscreen mode

Yay! This is really nice, with no need to install Husky or any other solution, just by relying on Git and Node we have a solid solution for enforcing conventional commit messages on the client, but how do we share this configuration with the rest of the team?

Sharing the hook

We need to be able to share this configuration with the rest of the team/org so that nobody falls behind. What it means that we need to make this modification to the git’s core.hooksPath, in other words run this command for all devs:

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

But we cannot run it for all manually, so we need other means to run it for them. You can use the “prepare” or the “postInstall” npm life-cycle scripts in your project to run this script above, and you will get a 99% guarantee that every developer has this configuration in place (after all, I hope they’re installing the project's packages at least once).

Here is an exmaple of what I did - in the "postinstall" script I'm calling another script which sets git hooks path, like this:

"setup:git-hooks": "git config core.hooksPath .git-hooks",
"postinstall": "yarn run setup:git-hooks",
...
Enter fullscreen mode Exit fullscreen mode

BTW, check out the next post in which I take the code shown here and package it, so you can install and use it easily.

As always, if you have any questions, or comments on this topic, be sure to leave them in the comments section below so that we can all learn from it.

Hey! If you liked what you've just read check out @mattibarzeev on Twitter 🍻

Photo by Motoki Tonn on Unsplash

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .