Consistency and automated checks are key to maintaining code quality in a team. Git hooks are an excellent tool for enforcing standards early in the workflow, running tests and validators locally before code even hits the repository.

While our teams already rely on Git hooks to ensure quality, configuring them across projects can be a hassle. Enter libraries or tools that simplify the setup and management of Git hooks, making it easier to implement coding standards consistently across all projects.

In this article, we’ll show you how CaptainHook streamlines Git hook configuration, within a real-life docker-based local environment. We’ll provide examples for running validations, tests, and more so that every commit meets your quality standards with minimal effort.

Git hooks recap

Git hooks are scripts that run at specific points in the Git workflow to automate tasks or enforce rules. These hooks allow you to trigger actions before or after certain Git commands, such as commits, pushes, or merges. They help ensure quality checks, automate workflows, and streamline development processes.

Git hooks scripts reside in the .git/hooks subdirectory of any Git repository. The script files are named exactly like the hooks listed below (i.e. pre-commit), and when present, they are executed by Git at a certain point in time (see When below). Depending on the hook name, we may write the script to do some actions through the hook’s script (see Purpose below).

pre-commit

  • When: Before a commit is made.
  • Purpose: Commonly used for running linters, formatters, or tests before a commit is created.

prepare-commit-msg

  • When: Before the commit message editor opens.
  • Purpose: Modify or set the commit message programmatically.

commit-msg

  • When: After the commit message is entered, before the commit is finalized.
  • Purpose: Validate or enforce commit message rules: ensuring a specific format for the comment or prepending/appending some text to the message.

post-commit

  • When: After a commit is completed.
  • Purpose: Trigger post-commit actions, such as notifying systems, logging, or starting other processes.

pre-push

  • When: Before changes are pushed to a remote repository.
  • Purpose: Perform final checks before pushing, such as running tests or validating that the local branch is up-to-date with the remote.

post-push

  • When: After code is pushed to the remote repository.
  • Purpose: Run tasks after a push, such as triggering CI/CD pipelines or post-push notifications.

Skipping Git hooks form executing

Sometimes, you may want to bypass Git hooks, especially during quick testing or when running specific commands. To skip hooks, you can use the --no-verify flag with commands like git commit or git push.

For example:

git commit --no-verify
git push --no-verify

This will skip all hooks associated with the command, allowing the action to complete without running the pre-configured checks or scripts.

Introducing git hook libraries

Now that we have recalled what git hooks are and how they work it’s time to take it to the next level. Of course, you can craft your own git hook scripts and execute various command line tools such as linters, code style fixers, unit testers from within these scripts on all source files or even better, only on the changed files.

As a matter of fact, if I am to remember my previous long-term project, my team was using custom git hook scripts to ensure the quality of our code before pushing to remote. We were running tools such as php lint, phpstan, phpmd, php-cs-fixer as part of the pre-commit hook and phpunit as part of the pre-push hook. We were using an in-house scripted tool to spin up docker containers for local development so we had to write our hooks to run these tools inside a container started up from the same base image as our local development, in order to have a consistent environment. The team embraced this workflow, as it ensured only good quality code, that adheres to standards, was ever going to reach merge requests.

On the other hand, we also experienced downsides of this custom setup. Here are some of them: – we had to build our own script in the project root to copy/install these hooks under .git/hooks when running composer install; – if we wanted a new testing tool to run as part of a Git hook, we had to change a rather complex bash script, then instruct everyone to perform a composer install in order to update the hooks on their local env; – when we had to change our docker image build scheme we had to also adapt these hooks, as the name or the tag of the image used to run tools inside the containers changed.

Needless to say we had all the reasons to try to find better ways to set up this local workflow. Then we discovered the libraries/tools that automate the configuration and installation of git hooks. Out of all available ones, two draw our attention: CaptainHook and GrumPHP.

After playing around with both tools, we decided to deep dive into CaptainHook, and for a series of reasons: – it allows to easily configure hooks to run inside a docker container, besides running them directly on the local system; – it allows more fine-grained configuration of actions to run for each of the hooks; – it provides a nice set of very useful built-in actions, on top of just running command line tools; – it comes with Composer integration, which installs the hooks after a composer install or update; – it allows extending with custom actions, conditions, or plugins. Therefore, your team can extend the tool if it misses some functionality through some PHP classes. You may even put these in a repository and reuse among all the company’s PHP projects.

Setting up the local development environment

We wanted to make our test with CaptainHook as close as possible to our usual work setup, therefore we chose to test in a local development environment that uses docker containers. We had a look at several solutions, such as Laravel Sail, Symfony Docker, and DDEV. In the end we chose DDEV, as it shown more advantages, mainly the fact that it doesn’t impose using a certain PHP framework, it comes with a globally available command line utility, being decoupled from the application composer requirements.

For installing DDEV on Linux we opted for using the installation script, although there are numerous ways to install it, detailed in the documentation:

> curl -fsSL https://ddev.com/install.sh | bash
...
> ddev -v
ddev version v1.24.2

As part of the installation it will place the ddev binary under /usr/local/bin/ so that you can use it everywhere after that. It also creates a root CA certificate under /home/{user}/.local/share/mkcert, and registers it for HTTPS support.

Next, we go to our example project root directory where we set up DDEV (partial command return shown below):

> cd example
> ddev config
Creating a new DDEV project config in the current directory (/home/dan/projects/example)
Once completed, your configuration will be written to /home/dan/projects/example/.ddev/config.yaml
...

So easy! First, it created a .ddev directory in the project root where it placed all the configuration, then it asked for confirmation about the project name inferred from the directory name. After that, it confirmed that the document root is indeed the public subdirectory. Finally, it confirmed that we were dealing with a Laravel project (which was the case in our test).

Next step is to start our containers and visit the URL of our homepage:

> ddev start

After creating the containers (ddev-example-web and ddev-example-db) and starting them up, the above command lets us know that we can visit our website at: https://example.ddev.site.

If we want more details about running services for our application, especially after we add some extra services besides the web and db, we can use:

> ddev describe

The above displays a table with all the services currently configured, their status, their URLs and ports that they listen to, and some version details about what runs in those containers. It is not our purpose to fully describe DDEV’s capabilities, and since the tool’s documentation is detailed we invite you to check it out and discover all its powers.

For our git hooks library testing scenario, we just need to know how to execute a given command inside the web container. And with DDEV that is ddev exec:

> ddev exec php -v
PHP 8.3.16 (cli) (built: Jan 19 2025 13:29:20) (NTS)
...

Installing CaptainHook

CaptainHook can be installed as a dev required library through Composer, or as a PHAR archive either through Composer or through PHIVE. We opted for the full source code, as we wanted to browse the internals and understand a bit how it works under the hood.

> composer require --dev captainhook/captainhook

This will install the captainhook executable usually located under vendor/bin/captainhook.

Next we need to configure the tool, a process through which we will be asked which hooks we want to mark as enabled in the tool’s config. We are answering negative to all questions, so that we will get an empty, no actions configuration file we will define ourselves later:

> vendor/bin/captainhook configure
  Do you want to validate your commit messages? [y,n] n
  Do you want to check your files for syntax errors? [y,n] n
  Do you want to run phpunit before committing? [y,n] n
  Do you want to run phpcs before committing? [y,n] n
Configuration created successfully
Run 'vendor/bin/captainhook install' to activate your hook configuration

This tool just created a JSON file in the project root named captainhook.json.

Before we install the actual hooks under the .git/hooks directory we need to configure this tool to run inside DDEV and not on the host OS. Therefore, we need to edit the captainhook.json file and add the following:

{
    "config": {
        "verbosity": "debug",
        "run": {
            "mode": "docker",
            "exec": "ddev exec"
        }
    },
 ...
}

The verbosity: debug option helps while testing the changes made to the config and setting up the tool. Later it makes more sense to go for normal or even quiet, to only have the relevant tool output.

Keep in mind that a Git repository must be initialized in the project root before running captainhook install:

> vendor/bin/captainhook install
Install commit-msg hook? [Y,n] y
✔ commit-msg installed
Install pre-push hook? [Y,n] y
✔ pre-push installed
Install pre-commit hook? [Y,n] y
✔ pre-commit installed
Install prepare-commit-msg hook? [Y,n] y
✔ prepare-commit-msg installed
Install post-commit hook? [Y,n] n
Install post-merge hook? [Y,n] n
Install post-checkout hook? [Y,n] n
Install post-rewrite hook? [Y,n] n

In summary, the command above created four script files under .git/hooks for the four selected hooks.

Checking one of the files, let’s say pre-commit, we can see that the integration with DDEV that we configured earlier worked:

#!/bin/sh

# installed by CaptainHook 5.25.0

# if necessary read original hook stdIn to pass it in as --input option
input=""

if sh -c ": >/dev/tty" >/dev/null 2>/dev/null; then
    exec < /dev/tty
fi

ddev exec vendor/bin/captainhook --bootstrap=vendor/autoload.php --input=\""$input"\" hook:pre-commit "$@"

Exploring CaptainHook capabilities

Without trying to be exhaustive about the tool’s capabilities, and being conscious that we can barely scratch the surface within this article we’d like to showcase some bits of functionality that CaptainHook packs, just to stir your curiosity about it.

Some hands-on examples

Without further ado, here are some hands-on examples that we’ve tried, and we were pleased with the results. All the JSON configuration snippets presented below go into the captainhook.json file located in the project root. Every snippet refers to an action nested under a particular hook configuration. In real life, you would merge all actions in the same hook actions array to have a complete workflow.

How to make sure that composer.lock file is up to date

"pre-commit": {
  "enabled": true,
  "actions": [
    {
      "action": "\\CaptainHook\\App\\Hook\\Composer\\Action\\CheckLockFile",
      "options": []
    }
  ]
}

The CheckLockFile built-in action offers a bare minimum check when trying to commit changes made to the composer.json file, but without having the composer update run beforehand. Under the hood, the action class logic simply reads the content-hash key in the composer.lock, then selects the eleven relevant keys in the composer.json file and hashes their contents with md5(). If these two hashes differ, the action fails, and you are presented with a nice message asking you to run composer update.

How to prepend each commit message with the issue identifier

This one is super helpful if you have a rule within your team that every commit message must include the issue identifier, let’s say the Jira project and issue number identifier. Some new members, or devs outside your team that contribute at some point might not be consistent or even aware about this rule within the team, so better automate it with a configuration such as the one below:

"prepare-commit-msg": {
  "enabled": true,
  "actions": [
    {
      "action": "\\CaptainHook\\App\\Hook\\Message\\Action\\InjectIssueKeyFromBranch",
      "options": {
        "regex": "#([A-Z]+\\-[0-9]+)#",
        "into": "subject",
        "mode": "prepend",
        "pattern": "$1",
        "prefix": "[",
        "suffix": "] ",
        "force": false
      }
    }
  ]
}

Example scenario: – you are on branch PROJECT-1234 – you’ve done some changes to the code – you commit with simple message:

> git add . && git commit -m "Fixed missing use case"
  • the commit is created with message “[PROJECT-1234] Fixed missing use case”

How to validate that the commit subject matches desired pattern

Let’s say you just want to enforce commit messages to follow a certain naming pattern. There is at least one way to do that, with Regex built-in action, as follows:

"commit-msg": {
  "enabled": true,
  "actions": [
    {
      "action": "\\CaptainHook\\App\\Hook\\Message\\Action\\Regex",
      "options": {
        "regex": "#^\\[PROJECT-\\d+\\] .*$#",
        "error": "The commit message must match: %s",
        "success": "The commit message matched required regexp!"
      }
    }
  ]
}

How to validate that the commit subject adheres to a set of rules

"commit-msg": {
  "enabled": true,
  "actions": [
    {
      "action": "\\CaptainHook\\App\\Hook\\Message\\Action\\Rules",
      "options": [
        [
            "\\CaptainHook\\App\\Hook\\Message\\Rule\\LimitSubjectLength",
            [ 50 ]
        ],
        "\\CaptainHook\\App\\Hook\\Message\\Rule\\MsgNotEmpty",
        "\\CaptainHook\\App\\Hook\\Message\\Rule\\CapitalizeSubject",
        "\\CaptainHook\\App\\Hook\\Message\\Rule\\SeparateSubjectFromBodyWithBlankLine",
        "\\CaptainHook\\App\\Hook\\Message\\Rule\\UseImperativeMood"
      ]
    }
  ]
}

The Rules action allows combining a set of built-in or custom rules that must be validated by the commit message. The ones used in our example above are all provided by the CaptainHook library, but you can extend the set with your own.

Here we are validating that: – the subject (first line of the commit message) has a maximum length of 50 characters – the commit message is not empty – there has to be an empty line between the subject and the body of the commit message – the message subject has to avoid past tense reporting such as “created”, “fixed” and so on (there is a list of forbidden verbs actually), and instead have an imperative case, for example “create”, “fix” etc.

How to enforce that the pushed branch matches a desired pattern

Here is another action that I personally like a lot: it enforces consistency among team members regarding branch naming.

Here is an example:

"pre-push": {
  "enabled": true,
  "actions": [
    {
      "action": "\\CaptainHook\\App\\Hook\\Branch\\Action\\EnsureNaming",
      "options": {
        "regex": "#PROJECT\\-[0-9]+#"
      }
    }
  ]
}

With the above hook configuration in place, pushing any branch to the remote will fail when the branch name does not match the regular expression. Therefore, you may use this to always enforce a link between a Jira ticket and its corresponding branch, so that every branch that gets pushed on the remote is linked to a task in the board.

In conclusion, CaptainHook offers a very useful set of built-in actions you can directly make use of in your configured hooks. We’ve just presented a few here, but you can find the full list in the documentation.

Conditioned hook actions

The tool also provides conditions. These can be either built-in or custom ones. For the custom ones you can either use the exit code of an executed command or code the condition in a PHP class that you refer. These conditions mey be used to decorate the actions and define when they are applied during the hook execution.

The list of built-in conditions is quite extensive, so we will let you discover all the goodies that it has to offer.

For now let’s just make one of our previous hook actions a bit smarter. We want to make the action apply only when we are on a branch that is named according to a regular expression that represents our task identifier pattern:

"commit-msg": {
  "enabled": true,
  "actions": [
    {
      "action": "\\CaptainHook\\App\\Hook\\Message\\Action\\Regex",
      "options": {
        "regex": "#^\\[PROJECT-\\d+\\] .*$#",
        "error": "The commit message must match: %s",
        "success": "The commit message matched required regexp!"
      },
      "conditions": [
        {
            "exec": "\\CaptainHook\\App\\Hook\\Condition\\Branch\\OnMatching",
            "args": [
              "#^PROJECT-\\d+$#"
            ]
        }
      ]
    }
  ]
}

CLI actions for external scripts

Now, if we are to take this further, we’d like an action that ensures that when on a PROJECT-1234 branch, the commits are going to be prepended with [PROJECT-1234] and not [PROJECT-4321] for example, then we would need to make use of an external shell script that will check for the exact match between the branch name and the commit message.

Therefore, we will create tools/commit-message-matches-branch-name.sh with the contents:

#!/bin/bash
BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD)
COMMIT_MSG=$(cat "$1")

if [[ "$COMMIT_MSG" != "[$BRANCH_NAME] "* ]]; then
  echo "❌ Error: Commit message must match: #[$BRANCH_NAME] .+# "
  exit 1
fi

And then we will configure a commit-msg hook action in CaptainHook that will execute only when on a task/feature branch:

"commit-msg": {
  "enabled": true,
  "actions": [
    {
      "action": "./tools/commit-message-matches-branch-name.sh {$ARG|value-of:MESSAGE_FILE}",
      "conditions": [
        {
          "exec": "\\CaptainHook\\App\\Hook\\Condition\\Branch\\OnMatching",
          "args": [
            "#^PROJECT-\\d+$#"
          ]
        }
      ]
    }
  ]
}

The example above introduces two other features of CaptainHook:

  • executing an external binary or script as an action. This also serves as an example of how you can easily run your code tools such as phpstan, phpmd, php-cs-fixer, phpunit and so on as actions of the proper hooks. We won’t exemplify integrating these tools here, but rather let you play around and find what works best for your projects. Actually you can start from some nice examples from the Captain’s favorite hooks.
  • using placeholders in defining CLI actions. In the example above we needed to pass to our script the name of the file that contains the commit message when the commit-msg hook is executed.

Closing notes

Git hooks are a game-changer for any development team, and leveraging them effectively can streamline workflows and boost productivity. Our journey started with a rigid, limited approach to hooks, but through the right tools, we found a smarter way forward.

DDEV became our go-to for setting up a local environment with Docker—just enough to get us up and running. But the real star of the show? CaptainHook, a sleek and powerful tool that makes installing and managing Git hooks effortless.

While we’ve only scratched the surface, we’ve achieved our goal: transforming the way we use Git hooks. We hope you found this journey as exciting and insightful as we did!