Using javaScript in your git hooks
Git hooks really come in handy to automatically perform a lot of repetitive tasks when working with git, and also can be used to ensure that some requirements are met before you can commit or push in a client repository, or before a remote repository can accept new commits. For instance, is really common to execute git hooks to check wether a test suite ran successfully, and with a certain coverage threshold.
Now, to do so, you just need to edit your hook scripts, which lie within the .git/hooks/
directory in any git repo. Such scripts are run before or after certain events, and git will use their output to make some changes (i.e., append a signature automatically to every commit message) or to prevent things for happening (for instance, prevent you to make a push if the linter output has errors or warnings).
By default, in any git repository, this folder is populated with a lot of sample hook scripts, that have names like pre-push.sample
. If you inspect such sample files, you will see that they are just shell scripts that usually exit
with 1 or 0 based on conditions. Sometimes, they print
something to get the job done: for instance, in the prepare-commit-msg
hook you need to print to stdout
in order to indicate the desired commit message.
Exit with an 1 code will prevent the related action for happening: if you exit
with 1 from the pre-commit
hook, the commit will be rejected. On the other hand, 0 means that everything is ok. This is the standard in the shell scripts world.
As you would see if you inspected the default hooks, they are all written in bash. And one of the best sources of documentation for git, the pro git book, uses ruby as the scripting language for its examples.
Using javaScript
Now, you can use any scripting language for your shell scripts or git hooks, would it be python, php, javaScript or any other, you just need to know some little things and adhere to certain criteria.
(this article really will help you a lot if you never used javaScript as a cli language).
In order to use javaScript code to be run in the shell, we will use node.js. In any shell script, to tell your OS that the script is intended to be run by node, you just need to add this line to the first line of the script:
#!/usr/bin/env node
Now, if you put this in any file, toss some javaScript at it, and execute it, you should see the output in your terminal. For instance, let’s say we create this file:
Then we make it executable:
sudo chmod 774 ./test.js
Now, if we just run the file:
./test.js
We will see the ‘Hello World’ output in terminal.
Now notice how this is different from executing test.js
with node. The command:
node ./test.js
would have the same output. However, by appending the #!/usr/bin/env node to the first line, we’re telling the OS that it should use node to execute the file by default: there is no need to invoke node
explicitly. This is important in the git hook context because git hooks are just executed as a shell script: the OS will use node
to do so only if we explicitly mark the file to be executed by node
.
Stdout and exit codes
As we just see, console.log just writes to the standard output, so it works like print
in a bash script. Should we need to output some specific message in a git hook, we just can use the old good console.log
to get the job done.
Now, how to exit
in a node script? Well, if you never done cli node.js before, you may think that return
will do the trick. However, that won’t work. In order to send an exit code from a node shell script, we must use the process module. The process.exit()
method will just work like a bash exit
statement.
So, for our use case, we will just process.exit(1)
whenever we want to abort some git event, or process.exit(0)
when things are OK.
Checking commit messages
Let’s say we want to check if a commit message adheres to a certain structure. For instance, in my job, we use Jira to manage our issues, and Jira issue tickets have an id like: PN-123, where PN are just the initials of the project name. So, when we create a branch or add a commit to a repository, they should reference a Jira issue: branches usually reference user stories or bugs, and commits usually reference tasks or subtasks.
So, we prepend the special feature/ or hotfix/ string and an user story ID in every branch we create, like:
feature/PN-301-allow-user-logout
And a plain ID in every commit:
PN-440-added-generic-error-notifications-styles
With most repository managers, like github, gitlab or bitbucket, is easy to enforce such policies just from the repository manager UI. So, for instance, we got our remote repositories to reject commits that don’t adhere to this naming policy.
Now, the problem is that this doesn’t prevent developers to mistakenly create wrong branch names or commit messages in their local repositories. And when they will push their work, it will be rejected, and they will be forced to rewrite history and do some git nasty tricks to fix those mistakes.
So, one good thing we could do in such a scenario is to setup a git hook to prevent bad commit messages to be committed directly in our local repository: now, whenever we make such a mistake, we can fix it at the right time.
When we want to check the commit message, the right hook is the commit-msg
hook. We should add a commit-msg file to the hooks directory. In the hook, we will perform the following tasks:
- Check wether the branch name is correct. If not, we will print an useful error message end
exit
with 1. - Check wether the commit message is correct. If not, we will print an useful error message and
exit
with 1. - If everything is ok, we
exit
with 0.
To check the branch name in every commit is a bit redundant, but as far as I know there is not a dedicated hook that runs when a new branch is created. Checking both the branch name and the commit message will ensure that the problem will be noticed whenever we try to add the first commit to a bad named branch, allowing us to rename the branch easily before any commit is added to it.
Now, in order to do so, we need two things in our script: the commit message and the branch name. The commit message will be provided to the hook as a parameter, while in order to get the branch name we will need to execute a git branch
command inside our node.js script.
Gathering parameters
Parameters passed to a node shell script can be accessed from the special process.argv
array. The process
module is a special node object hat is accesible from every node file or module, just like the module
object.
In every node script, the first two parameters will be the javaScript interpreter -node- and the script filename. So, parameters provided to our script will be found in process.argv[2]
and more. There are dedicated node packages to handle with command line arguments, like minimist, but is not like we want to add a node_modules
to our hooks
folder. We are just grabbing one single argument, so just:
process.argv[2]
will do the trick.
Now, I said that the commit message will be passed as an argument.That’s, well, not exactly true, because if we log process.argv[2]
, we will see this:
.git/COMMIT_EDITMSG
Which is actually a filename, which in turn contains the commit message -this time is true-. So, in order to get the commit message, we need to read the file, like in the following snippet:
(notice how we are requiring the fs module. Native node modules like fs
,path
or util
are always available in node shell scripts).
You may read before that you must never use synchronous api’s in node, like readFileSync()
. Well, that’s true for code being ran in a web server, but in a shell script is absolutely safe to use synchronous api’s to grab things from the filesystem.
That snippet ahead just reads the filename at process.argv[2]
, that contains the commit message. Passing an encoding as the second parameter is mandatory to get a string instead of a buffer. The trim()
call just prevents extra spaces or carriage returns characters to get into our commit message.
Getting the current branch
Sadly, the git branch is not passed to the hook as a parameter, so we will need to grab it from the OS. We can execute any command from a node script using the child_process
module. child_process
exports an object which provides two methods to handle with OS external processes: exec
and spawn
. Both are asynchronous, and spawn
on top of that will return a stream, while exec
will return a buffer or a string. When dealing with commands that output large amount of data spawn
is the way to go, but to capture the output of a git branch
call, exec
will suffice, and it’s easier to use.
Just like in the official exec
documentation, we will use the util
module to promisify the exec
call. A way to get the git branch
output in a node script will be like the following:
The exec
callback, apart from the error first parameter, has two parameters: stdout
and stderr
. The command output will, if all works as expected, be passed in the stdout
parameter, which means that, when we promisify and await for the exec
call, we need to check the stdout
field.
The previous script will print a text like the following:
* feature/HP-100-new-feature
master
feature/HP-101-another-branch
Just a list of lines with the branch names, and the current branch marked with an asterisk. Now, to get the current branch, we need to select the one with an asterisks. Let’s say we captured the output in a branches
variable, the next statement will return the currently selected branch:
const current = branches.split('\n').find(b => b.charAt(0) === '*')
.trim().substring(2);
This just splits the lines into an array of lines, one per each branch, then finds the one that starts with the asterisk. The substring()
call at the end just gets rid of the asterisk in the name, while the extra trim()
prevents any undesired space or special char to slip into our current branch name variable.
Enforcing the naming policy
Now we know how to get the commit message and the branch name, so we just need to check if both of them comply with our naming policy. There are a lot of ways to do this: you can either start searching for specific substrings at the start of the messages, or use regular expression. For this particular case I prefer the latter. The following regular expressions will express the naming policies for the branch names and the commit messages:
const BRANCH_CONTRACT = /^(feature|hotfix)\/AP-[0–9]{1,6}-/;
const CODE_CONTRACT = /AP-[0–9]{1,6}-/;
In order to check if a string match a regex, we can just use the regex test()
method:
BRANCH_CONTRACT.test('bad-branch-name'); //false
CODE_CONTRACT.test('AP-501-added-favicon.ico'); //true
Creating the Hook
We now know all we needed to create a node shell script as our commit-msg
git hook. The following snippet is a valid git hook that will ensure that our commit messages and branch names follow our naming policy:
Caveats
If you use Source Tree -or, I guess, any git GUI, at least on MacOs, which is my current environment-, and add this hook to your project, you will see how Source Tree starts complaining about not finding node.
Following this SO thread, there is a way to fix this, just by calling source tree from the command line (like open /Applications/SourceTree.app/Contents/MacOS/SourceTree). It’s pretty awkward, but it works.