Client side Git hooks 101
Tags: git, hook, commit, automation
Published: 2024-03-31 12:50:36

[Git](https://git-scm.com) hooks are simple yet powerful, essentially just scripts executed when certain events like commit or push occur. Most notably, they are really useful for enforcing code and commit quality. However, there's one problem: Since the hook scripts are stored inside the `.git/` directory, they cannot be committed to the repository and shared with other developers as is. Let's see what we can do about this. 

(Side note: Every fresh Git repository created with `git init` comes with a bunch of example hooks in `.git/hooks/`. They are named in a pretty self-explanatory fashion such as `pre-commit.sample`. Go ahead and take a look at them.)

## Issues everywhere

Here's a real life example: Imagine a [Ruby on Rails](https://rubyonrails.org/) app on which a team of developers are working. The code is hosted on [GitLab](https://gitlab.com) and all the work is coordinated using GitLab issues. In other words: For every commit, there's an associated issue and the issue number acts as a sort of primary key for documentation, time reporting and so forth. This convention has a few advantages, most notably the ability to easily learn more about how, when and by whom features were implemented as well as how this implementation came to be.
 
As far as Git is concerned, there are two rules:

* Feature branches reference the issue ID in their name like `1234_signin_with_passkey`.
* Every commit message begins with a reference to the issue ID as well, for instance `GL-1234: Add webauthn gem`.

Putting these references to the respective beginning makes it easy to scan and search for them down the road. Also, GitLab hyperlinks all occurrences of `GL-....` with the corresponding issue which greatly improves navigation.

(Side note: Issues are usually hash-prefixed like `#1234` both on GitLab and GitHub. However, commit messages must not begin with a hash, they would be considered a comment and ignored. Therefore, GitHub has introduced the alternative prefix `GH-` and [I've contributed a similar prefix](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/109024) `GL-` to GitLab a while ago.) 

This convention works fine until one day, the unavoidable happens and someone commits `Fix typo (#123)`.

## Hook me up

First, we need a place inside the repository to store the hook scripts. You might go for `.githooks/`, but since this example is a Rails app, we'll use `lib/git/hooks/`.

The first script `lib/git/hooks/commit-msg` makes sure the commit messages are formatted the way we want:

```sh
#!/bin/sh

# Assure the commit message to correctly reference a GitLab issue, CVE
# or GHSA identifier.

regexps=(
    "^GL-\d+: [A-Z0-9]"
    "^CVE-\d{4}-\d+: [A-Z0-9]"
    "^GHSA-[a-z\d]{4}-[a-z\d]{4}-[a-z\d]{4}: [A-Z0-9]"
)

passed=false
for regexp in "${regexps[@]}"; do
  if head -n 1 "$1" | grep -qE "$regexp"; then
    passed=true
  fi
done
if ! $passed; then
  echo "The commit message does not match any of the following constraints:"
  for regexp in "${regexps[@]}"; do
    echo "* $regexp"
  done
  exit 1
fi
```

This hook script receives a file which contains the commit message as the first argument `$1` and compares it against the regular expressions which only allow our GitLab issue prefixes as well as two more prefixes for CVE and GHSA advisories.

(Side note: If you want to allow commit messages without any prefixes as well, you might add something like `"^[^:]+$"` to the `regexps` array.)

To make our lives a little easier, we'd like all commits to a properly named branch to get the issue prefix prefilled for us – enter `lib/git/hooks/prepare-commit-msg`:

```sh
#!/bin/sh

# Extract the issue number from the beginning of the branch name (e.g.
# 1234_my_cool_feature) and create a commit message template with the 
# corresponding prefix for GitLab:

prefix="GL"
if ! grep -qv '^#\|^$' "$1"; then
  issue=$(git branch --show-current | grep -oE "\d+[_-]" | grep -oE "\d+")
  if ! [ -z "$issue" ]; then
    echo "$prefix-$issue: $(cat "$1")" >"$1"
  fi
fi
``` 

Make sure both scripts are executable!

At their current location, Git won't use them. You could symlink them into `.git/hooks/`, but there's a better way: Tell Git to look for the hook scripts where you've just put them.

```
git config core.hooksPath lib/git/hooks
```

On a Rails app, you might want to add a Rake task `lib/tasks/git.rake` for this:

```ruby
namespace :git do
  namespace :hooks do
    desc "Install client side hooks"
    task :install do
      `git config core.hooksPath lib/git/hooks` if Dir.exist? 'lib/git/hooks'
    end
  end
end
```

Make sure, new developers install the hooks before they start committing by adding the Rake task to the README or the setup script of your project:

```
rake git:hooks:install
```

You could even add this line somewhere in the `Gemfile` to always run this task when the bundle is installed, however, it's a little bit too intrusive for my taste.

## And action!

Time to see this at work. Let's create a feature branch and commit some stuff:

```
git checkout -b 1234_my_awesome_feature
touch foobar.txt
git add .
git commit
```

The last command opens the preferred editor and shows:

```
GL-1234: 
# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
#
# On branch 1234_my_awesome_feature
# Changes to be committed:
#       new file:   foobar.txt
#
```

Great, all that's missing is the actual message behind the prefilled `GH-1234: ` and the commit is complete. But unfortunately, it's early morning, the coffee maker is broken and by accident, we edit the message to either:

```
My awesome commit
GL: My awesome commit
GL-ABCD: My awesome commit
GL-1234 My awesome commit
(...)
```

The `commit-msg` hook won't accept any of those and output:

```
The commit message does not match any of the following constraints:
* ^GL-\d+: [A-Z0-9]
* ^CVE-\d{4}-\d+: [A-Z0-9]
* ^GHSA-[a-z\d]{4}-[a-z\d]{4}-[a-z\d]{4}: [A-Z0-9]
```

## Hook management

This lightweight approach is great for projects which require simple hook scripts only.

In case you wish to run entire pipelines of actions, say a combination of linting, style and license checking along with a required sign-off in the commit message, you should take a look at hook management tools like [Lefthook](https://github.com/Arkweid/lefthook) or [overcommit](https://github.com/sds/overcommit).

*(Photo by <a href="https://unsplash.com/@enginakyurt?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash">engin akyurt</a> on <a href="https://unsplash.com/photos/a-metal-object-sitting-on-top-of-a-sandy-beach-c4pr2doyAJo?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash">Unsplash</a>)*