DEV Community

Cover image for Client side Git hooks 101
Sven Schwyn
Sven Schwyn

Posted on • Edited on

Client side Git hooks 101

Git 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 app on which a team of developers are working. The code is hosted on GitLab 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 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:

#!/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
Enter fullscreen mode Exit fullscreen mode

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:

#!/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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
#
Enter fullscreen mode Exit fullscreen mode

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
(...)
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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 or overcommit.

(Photo by engin akyurt on Unsplash)

Top comments (0)