5 min read

On Running Multiple Git Hooks For Any Event

You want to know what’s cool? Not running one Git hook when creating a commit, but N number of Git hooks. Um.

There are many ways to do this, but my preferred way is to add N number of entries for a particular Git lifecycle event in .gitconfig that will be called in order when the event is triggered.

Let’s dive in.

Git Hooks

Hopefully, everyone is familiar with Git hooks, so I’m not going to spend time on defining them. In this post, I’ll be talking specifically about a specific client-side hook, the pre-commit hook, although the information here will apply to any of the hooks.

Every time a repository is initialized or cloned, a .git hidden directory is created in the root of the repository that contains information about all of the internals of the project, such as branches and objects.

Another of the directories that is created is the hooks directory, and within it templates for all of Git’s client-side lifecycle events. Here are a few:

  • pre-commit
  • pre-push
  • pre-rebase

All of the files are templates with the .sample file extension and can be used as-is by simply removing the .sample extension. Although they are mostly shell scripts, they can be written in any scripting language that is present on the system. As long as the files are executable and are properly named, they’ll be executed by Git as the same-named event is fired.

Note that, for security reasons, client-side hooks are not copied when cloned. Instead, the directory will be newly-created with the usual hook templates.

The Scenario

From the official site:

The pre-commit hook is run first, before you even type in a commit message. It’s used to inspect the snapshot that’s about to be committed, to see if you’ve forgotten something, to make sure tests run, or to examine whatever you need to inspect in the code. Exiting non-zero from this hook aborts the commit, although you can bypass it with git commit --no-verify.

Now, if you only have one hook, then simply name it pre-commit, make it executable, and drop it into the hooks directory. However, if multiple scripts should be invoked when this event occurs, then things can get a bit dicey (ok, not really, as we’ll see in a moment).

For example, let’s say that I not only want to lint, but I want to ensure that all text files end with a newline character and that it has the most recent version of the product’s license at the top of the file.

For this purpose, I’ll want the following three scripts to run every time I commit:

  • lint.bash
  • EOF.bash
  • license.bash

Let’s look at how to implement this.

The Implementation

The first thing to do is to update the appropriate Git config file with the hooks to be run. They will should be defined in either the global config file (usually $HOME/.gitconfig) or a repo’s local config file (.git/config) and will be called in the order in which they are defined:

[hooks "pre-commit"]
        hook = lint.bash
        hook = EOF.bash
        hook = license.bash

To set them, you can open the config file and edit manually as above, or you can set them at the command line:

$ git config --global --add hooks.pre-commit.hook "lint.bash"
$ git config --global --add hooks.pre-commit.hook "EOF.bash"
$ git config --global --add hooks.pre-commit.hook "license.bash"

For local configs, simply replace global with local.

The second thing to do is to replace the pre-commit hook in .git/hooks, if present, with one that will execute each hook against the individual files that make up the snapshot:

pre-commit

#!/bin/bash

# Try for local hooks first.
HOOKS=$(git config --get-all --local hooks.pre-commit.hook)

if [ -z "$HOOKS" ]; then
    HOOKS=$(git config --get-all --global hooks.pre-commit.hook)
fi

if [ -n "$HOOKS" ]; then
    for HOOK in $HOOKS; do
        bash ./.git/hooks/pre-commit.d/"$HOOK"

        if [ "$?" -eq 1 ]; then
            exit 1
        else
            # Separate the hooks by an empty line.
            echo
        fi
    done
fi

The Bash shell script will first look for any locally-defined pre-commit hooks in a repository’s .git/config before searching for globally-defined hooks (again, usually in $HOME/.gitconfig).

If it finds any, it will push them into the HOOKS variable that can be iterated over in the for loop, calling each shell script respectively and doing a hard fail on the first script to return a failure value of 1.

Any script to be run must be placed within .git/hooks/pre-commit.d/ in this example. Of course, you can name that whatever you’d like.

Git relies upon your scripts returning the appropriate return value to know how to proceed.

A return value of zero will have Git continue with the commit, while a non-zero return value will abort the commit.

Other Git Hooks

As alluded to earlier, this same method can be used for any Git event.

For example, in addition to the pre-commit event, let’s also add some hooks for the pre-rebase event:

$ git config --local --add hooks.pre-rebase.hook "foo.bash"
$ git config --local --add hooks.pre-rebase.hook "bar.bash"

From the official site:

The pre-rebase hook runs before you rebase anything and can halt the process by exiting non-zero. You can use this hook to disallow rebasing any commits that have already been pushed. The example pre-rebase hook that Git installs does this, although it makes some assumptions that may not match with your workflow.

Now, the local config looks like the following:

[hooks "pre-rebase"]
        hook = foo.bash
        hook = bar.bash
[hooks "pre-commit"]
        hook = lint.bash
        hook = EOF.bash
        hook = license.bash

Then, simply perform the following:

  1. Copy the current pre-commit hook in .git/hooks to pre-rebase.
  2. Rename the pre-commit text strings in the new pre-rebase hook script to pre-rebase.
  3. Create a pre-rebase.d in the .git/hooks directory and put your foo.bash and bar.bash scripts inside it.
  4. Pet a goat.
  5. Automate.

You’re done-zo. Sweet.

Conclusion

That’s it! It’s pretty simple, and the implementation leverages Git’s own config definitions, which I like. I found this implementation years ago in a blog post written by one of the authors of Git (I think it was Junio Hamano), but the link now results in a 404, and I can’t find another.

So it goes.