Git Hooks as Code for PHP Applications

Git hooks are scripts that run automatically every time a particular event occurs in a Git repository. They let you customize Git's internal behavior and trigger customizable actions at key points in the development life cycle.

By design, every Git Repository comes with Git Hooks. Or more accurately with Git Hook sample files. You can find them inside the .git/hooks folder. To use them, you need to simply remove the .sample extension.

But it is important to note that Git hooks aren’t committed to a Git repository themselves. They’re local, untracked files. When you write an important hook that you want to share with colleagues, or enforce that it’s being used by all developed, you need to move it into a directory that’s managed by Git.

Since managing Git native hooks is tedious and far from practical – they are not part of the Repository, need to be re-configured on every developers computer. There are a couple of third party tools, though that aim to solve these shortcomings:

  • Husky which requires NPM is highly flexible
  • commitlint.js also, NPM-based with extensive documentation
  • pre-commit which has many useful pre-made functions out-of-the-box. Unfortunately it’s, as the name suggests, limited to the pre-commit hook.
  • Lint Staged is also worth mentioning. Based on NPM as well.
  • Personally, for PHP projects I prefer composer-git-hooks as it does not require NPM.

Composer Git Hooks

I want to look at this specific tool, here. Mainly to show how it can be used.

Installation

Run composer require --dev brainmaestro/composer-git-hooks to add the package to your project.

The hooks are defined by adding them in your composer.json file:

composer.json
"extra": { "hooks": { "pre-commit": [ ".hooks/pre-commit" ], "commit-msg": [ ".hooks/commit-msg" ], "post-commit": [ ".hooks/post-commit" ] } }

Each event accepts an array of bash scripts, or composer run scripts. That said, for the most flexibility, I’d recommend to declare one bash script per event, like it’s shown above. This is what native Git hooks look like and it allows you to do more complex things than with one-liner commands inside the composer json.

An example hook

As you can see in the snippet above, I will create a .hooks folder where I can store my hook scripts.

So let’s say we want a .hooks/commit-msg file that enforces conventional commit messages, we can do this like so:

.hooks/commit-msg
#!/usr/bin/env sh # .hooks/commit-msg # Enforces conventional commit messages RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' NC='\033[0m' # No Color # commit headline must be in the correct format commit_msg_file=$(git rev-parse --git-dir)/COMMIT_EDITMSG # Read the commit message from the COMMIT_EDITMSG file commit_msg=$(cat "$commit_msg_file") # Regular expression pattern for conventional commit format pattern="^(build|wip|ci|docs|feat|fix|perf|refactor|style|test|chore|revert|release)(\(.+\))?: .{1,}" # Check if the commit message matches the pattern if [[ ! $commit_msg =~ $pattern ]]; then printf "${RED}Aborting. ${YELLOW}Your commit message is invalid.${NC} Syntax: ${YELLOW}<type>${NC}(${YELLOW}<scope>${NC}): ${YELLOW}<subject>${NC} ${YELLOW}<type>${NC} can be one of build chore ci docs feat fix perf refactor revert style test ${YELLOW}<scope>${NC} is optional ${YELLOW}<subject>${NC} there must be a description of the change Find more on this topic here: https://www.conventionalcommits.org/ " exit 1 fi

Activate the hooks

Once you save the file, you need to make sure your custom scripts are executable:

chmod +x .hooks/*

The documentation also suggests to add post-install-cmd and post-update-cmd run scripts:

composer.json
{ "scripts": { "post-install-cmd": "vendor/bin/cghooks add --ignore-lock", "post-update-cmd": "vendor/bin/cghooks update" } }

Finally, you need to execute composer update to bring it all together.

Composer will run the cghooks script which creates a minimal hook script inside .git/hooks that points to our custom hook script we created in the .hooks folder.

That’s it. You are all set. The next time you make a commit to your repository, the headline of your commit message will be validated.

Having these scripts in place ensures that every developer will be using the git hooks. It is worth mentioning, that you need to be cautious about your Continuous Integration pipeline. Since we required the brainmaestro/composer-git-hooks package as a dev dependency, it might not be present, especially if you run composer install on a Runner system with the --no-dev option.

There are several workarounds, each with different implications.

  • Run composer install without the --no-dev option (not recommended, you should not install nor need dev requirements in production)
  • Add the package to your main requirements (instead of in require-dev)
  • Run composer install with --no-scripts option on the Runner

Conclusion

Besides the very trivial example validating a commit message headline, Git Hooks are really useful and powerful. The more interesting use-case is the pre-commit hook, though. I use it to:

  • Apply Clean Code checks and apply fixes to the changed .php files with Easy Coding Standards (which are then added back to the staging area, or ammended to the current commit in the post-commit hook)
  • Test changed php files with PHPStan and/or with PHPMD
  • Prevent commented out code being committed (using Easy-CI)
  • Prevent unresolved or erroneous conflicts from being committed (using Easy-CI)
  • Lint and prettify JS, JSON and YAML files

With these simple checks in place, as a developer I get instant feedback about my code, every time I commit to the repository. And that is great, as it makes me a better developer.

Whenever problems are detected, the pre-commit hook aborts the commit with exit 1.

I can fix errors, improve my code, and make sure my commits always meet the projects coding standards, before any potential errors ever reach the remote repository.

Also, by running these static code checks only against committed files and not against the complete application, all these steps are super fast:

.hooks/pre-commit
CHANGED_FILES=$(git diff --cached --name-only --diff-filter=ACM -- '*.php') if [ -n "$CHANGED_FILES" ]; then vendor/bin/ecs check ${CHANGED_FILES} --fix fi

Skipping Hook Execution

While there’s no good reason to skip hook execution like ever, I should still mention that it’s possible.

The initialization of these git hooks could be prevented by using composer install --no-scripts or composer update --no-scripts. To skip hook execution upon commit one can also do git commit --no-verify -m 'my invalid commit message'.

So to be 100% secure, you will still need code quality checks in your continuous integration or build pipeline.