1. Introduction

Similar to other hook mechanisms, Git hooks are a method of running given code around the execution of Git actions such as committing and pushing. Effectively, this enables a stripped-down and often simpler version of the functionality offered by implementations such as Jenkins and Concourse.

In this tutorial, we explore Git hooks and ways to customize them. First, we explain what a Git hook is and what types of hooks exist. After that, we delve into hook management. Finally, we create client and server hooks to demonstrate their function.

We tested the code in this tutorial on Debian 12 (Bookworm) with GNU Bash 5.2.15. Unless otherwise specified, it should work in most POSIX-compliant environments.

2. Git Hooks

Git hooks are specific executable files that automatically run commands for certain Git actions. They can be basic shell scripts or whole binary executables.

There are two main types of hooks:

  • local or client hooks, triggered by local repository events, e.g., commits, merges, and others
  • remote or server hooks, triggered by remote repository events, e.g., configuration, pushes, and others

Let’s see an example diagram with some common hook types and where they run:

         Remote Repository
+---------------------------------+
|         [+] post-receive        |
|          ^                      |
|          |                      |
|          +------+               |
|          |Remote|               |
|          |Origin|               |
|          +------+               |
|          ^                      |
|          |                      |
|         [+] update              |
|         [+] pre-receive         |
|          ^                      |
+--|Push|-------------------------+
|          ^                      |
|          |                      |
|         [+] pre-push            |
|          ^                      |
|          |                      |
|         ...                     |
|         [+] post-commit         |
|          ^                      |
|          |                      |
|  Commit [+] commit-msg          |
|          ^                      |
|          |                      |
|         [+] prepare-commit-msg  |
|         [+] pre-commit          |
|          ^                      |
|          |                      |
|          +------+               |
|          |Commit|               |
|          | Data |               |
|          +------+               |
+---------------------------------+
        Local Repository

In addition, there are many other hooks as well.

2.1. Client-Side Hooks

Several main hook types can run during regular operations on the client side:

  • post-checkout: runs on git checkout and git clone, just before checkout
  • pre-commit: runs on git commit before commit creation
  • prepare-commit-msg: runs on git commit just before message editing
  • commit-msg: runs on git commit just after message editing
  • post-commit: runs on git commit after commit creation
  • post-commit: runs on git commit after commit creation
  • pre-push: runs on git push before pushing

Separately, we have hooks for specific and more rare actions:

  • applypatch-msg: runs on git am during message creation
  • pre-applypatch: runs on git am after applying a patch, before committing the changes
  • post-applypatch: runs on git am after applying and committing a patch
  • pre-rebase: runs on git rebase just before the rebase
  • post-rewrite: runs on git commit –amend and git-rebase, when rewriting history
  • post-merge: runs on git merge or git pull, just after a merge (can’t abort)

Further, the pre-auto-gc garbage collection hook runs on git gc –auto.

2.2. Server-Side Hooks

When it comes to the remote repository, the server mainly reacts to pushes:

  • pre-receive: runs on git-receive-pack just before updating pushed refs (references)
  • update: runs on git-receive-pack once per pushed ref just before ref updates
  • post-receive: runs on git-receive-pack after updating all pushed refs
  • post-update: runs on git-receive-pack after all refs are pushed

Since hooks are related to the Git actions, clients may have to wait for a hook to finish its run before receiving a response. Because of this, it’s good practice to make hooks short in terms of runtime and start detached tasks when possible.

Notably, we can get a full list of hooks and their parameters via man githooks.

3. Git Hook Management

Git hooks reside under the repository directory, usually .git, in a subdirectory called hooks:

$ ls -1 .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
push-to-checkout.sample
update.sample

Notably, each new repository usually contains a number of templates in the form of .sample files

3.1. Hook Parameters and Environment

Some hooks receive stdin data in the form of parameters when called. For example, we can distinguish between a commit –amend and a rebase for a post-rewrite hook by reading the standard input.

Further, certain hook types have preset environment variables accessible within the context of the hook executable. They enable additional information to pass from the Git activity to the hook-handling commands.

Finally, we can add options for some hook types. For instance, –push-option can be read on the server side during hooks that process pushed data.

3.2. Hook Mechanics

Configured hooks run on any related operation, regardless of whether any data transfer or modification occurs.

Critically, the main hook file must be executable and is expected to conclude with a 0 exit code for the Git action to continue and be successful. Of course, we can make the hook and respective Git operation fail if the command fails.

Thus, it’s a good idea to use the relevant hook file as a central point to call any related scripts and other executables, ensuring critical actions cause the main hook to return non-zero. In addition, hook executables usually perform any necessary checks to avoid redundant runs.

3.3. Create or Enable a Hook

To enable a hook, we perform several actions:

  1. create a file under .git/hooks/
  2. name the file with the exact hook name
  3. begin the file with the relevant shebang
  4. make the file executable

Of course, we can use any of the .sample files by copying it and removing the extension.

4. Example Hooks

To demonstrate, let’s create two basic hooks from scratch.

4.1. Client-Side Hook

As an example, let’s create a basic pre-commit hook that performs tests on the code and prompts the user whether they want a commit to go through if the tests fail:

$ cat .git/hooks/pre-commit
#!/usr/bin/env bash
exec < /dev/tty
npm test
failed=$?
if [ $failed ]; then
  while true; do
    read -p 'Continue with failing tests [Yn]: ' yn
    case $yn in
      [Yy]* ) exit 0;;
      [Nn]* ) exit 1;;
          * ) echo 'Y or N';;
    esac
  done
fi
$ chmod +x .git/hooks/pre-commit

Here, the first line of the pre-commit hook changes the stdin handle to point to /dev/tty, which is usually the keyboard. This way, we can prompt the user and receive a reply. Critically, we lose the data in stdin from before the change.

In this case, npm test performs tests on the repository code, after which we save the exit status in the $failed variable. If the tests do fail, the if conditional expression is true. Then, we run an endless loop that uses read to [-p]rompt the user whether they want to continue, accepting only y, Y, n, and N.

In short, based on the test results and the user reply, if any, the commit is accepted or denied.

4.2. Server-Side Hook

Now, we can create a Git server hook that runs a deployment script upon receipt of a big push:

$ git init --bare
[...]
$ cat hooks/post-receive
#!/usr/bin/env bash
refs=$(cat | wc --lines)
if [ $refs -gt 4 ]; then
  fly --target=node trigger-job --job nodetasks/deploy 2>&1 >/dev/null &
  disown
  echo 'deployed'
else
  echo 'not deployed'
fi
$ chmod +x hooks/post-receive

In this basic post-receive hook, we trigger a Concourse job if there are changes to more than 4 refs. In this case, the exit code isn’t of consequence, since the hook can’t interrupt a process.

Notably, we & background, 2>&1 >/dev/null silence, and disown the Concourse job run. This enables the client to continue working instead of waiting for the process to complete.

4.3. Trigger Hooks

At this point, we can configure the respective remote and trigger both hooks from the local repository.

First, we make and stage changes:

$ echo $(date) | tee --append file{1..5} >/dev/null
$ git add file{1..5}

After that, we trigger the local hook with a commit:

$ git commit --all --message 'upDate'
[...]
X 1 problem (1 error, 0 warnings)
Continue with failing tests [Yn]: Y
[master 13f7540] upDate
 5 files changed, 5 insertions(+)
 create mode 100644 file1
 create mode 100644 file2
 create mode 100644 file3
 create mode 100644 file4
 create mode 100644 file5

Despite the error, we decide to submit the commit.

Next, we add the origin:

$ git remote add origin https://gerganov.com/repository

Finally, the remote post-receive hook triggers after a successful push:

$ git push --all
Enumerating objects: 4, done.
Counting objects: 100% (4/4), done.
Delta compression using up to 4 threads
Compressing objects: 100% (3/3), done.
Writing objects: 100% (3/3), 309 bytes | 666.00 KiB/s, done.
Total 3 (delta 0), reused 0 (delta 0), pack-reused 0
remote: deployed
To /root/xremo
   775778b..13f7540  master -> master

Thus, we see the hooks run and the code is deployed.

5. Summary

In this article, we talked about Git hooks and how to manage and create them.

In conclusion, although fairly rudimentary when compared with full automation systems, Git hooks enable many activities like testing, updates, checks, standardizing, and similar.

Comments are open for 30 days after publishing a post. For any issues past this date, use the Contact form on the site.