Using pre-commit and post-commit Git hooks in Drupal

By Kevin, October 15th, 2023

A few months back (sorry, its been a minute!) I detailed how you could set up PHPDocumentor to automatically generate documentation for your custom code and modules for Drupal. Another step you can take is automating this process for everyone in your projects by taking advantage of git hooks. The official documentation on git hooks are very thorough so I won't go over all of it, only that we will be using two git hooks: the pre-commit and post-commit hook.

The pre-commit hook runs just before a commit is officially saved and the post-commit hook runs immediately after that commit is saved. Both are shell scripts and within them we can do virtually anything we want to do. If these scripts return an unsuccessful exit code (i.e. anything other than 0), it will halt the action in progress. In this case, if our pre-commit hook returned an exit status of 1, git will prevent that commit from being saved. You will have to correct what is wrong so the hook succeeds and allows the commit to be saved.

In this article we are going to do two things, lint our code base using phpcs with the ruleset from the Drupal coder module, and then run PHPDocumentor to generate documentation off our code. Git hooks will serve our needs well, because we can make both tasks automatic and enforce these behaviors for anyone on the project. This will ensure a consistent workflow, enforce Drupal coding standards, and generate documentation for your projects simply by committing your work.

Let's start with the pre-commit hook. Create a file called pre-commit with no file extension. This script will be very simple. We want to run the phpcs linter. If the phpcs command does not return 0 (successful exit code), prevent the user from committing the code.

Add the following to the pre-commit file:

#!/usr/bin/env bash

ddev exec php ./vendor/bin/phpcs

if [ $? -ne 0 ]; then
  echo "Linter issues found - please review and fix them before committing."
  exit 1
fi

This (using DDEV) will run phpcs against your code base. If the command is not successful ($? -ne 0 - the $? is shorthand for the result of the last command) the user is told there are linter issues and that they have to fix them and try again.

Save the file and copy it into your project .git/hooks directory. Make it executable with chmod +x pre-commit. It will now run automatically every time you try to commit code. This goes a long way to preventing code that doesn't meet the coding standards, best practices, or other style rules from entering your codebase, and you don't have to waste code review time going over those superficial items.

As a bonus, you can run another command that will attempt to automatically fix the easy linter violations:

ddev exec php ./vendor/bin/phpcbf

It can't fix everything, but in some cases will give you a good start.

When you have corrected the reported issues, the commit is allowed to be saved and you can push it up to the central repository.

Now, lets take a look at the post-commit hook. This will run immediately after the pre-commit hook completes successfully. In this step, we are going to run PHPDocumentor to generate updated documentation.

Create a file called post-commit with no file extension. In that file, add the following:

#!/usr/bin/env bash

ls `git rev-parse --git-dir` | grep rebase &> /dev/null
IS_REBASING=$?

git rev-list -1 MERGE_HEAD &> /dev/null
IS_MERGING=$?

# If neither rebasing or merging, generate documentation if needed.
if [[ $IS_REBASING != 0 && $IS_MERGING = 128 ]]; then
  echo "Checking updated code for documentation generation."
  
  GIT_ROOT=$(git rev-parse --show-toplevel)
  
  docker run -u $(id -u ${USER}):$(id -g ${USER}) --rm -v ${PWD}:/data phpdoc/phpdoc:3 --quiet
  
  git add $GIT_ROOT/docs
  
  CHANGED=$(git diff --quiet HEAD $REF -- $GIT_ROOT/docs || echo changed)
  
  if [[ $CHANGED = "changed" ]]; then
    echo "Auto generating documentation and adding to previous commit."
    git commit --amend --no-verify &> /dev/null
  fi
fi

This one is a little more involved than the pre-commit hook for a couple of reasons. First, we don't want to run the commands if you are rebasing or merging in git. This will cause merge conflict issues in some cases and since rebasing creates new commits it will trigger the post-commit hook to fire repeatedly, which we don't want.

If the exit codes from those actions are good, meaning we can detect we are neither rebasing or merging, it proceeds. The script determines the root of the project using git and runs the PHPDocumentor command using docker run and then adds the docs directory with git (the docs directory is explained in the first article). 

Then, we check with git to see if anything within the docs directory actually changed (i.e. did anything actually get updated from that action). If we do detect changes in the docs directory, we add those to the last commit using git commit --amend. At this point the post-commit hook is complete, and when you push your result up to the central repository, it will contain new or updated documentation generated by PHPDocumentor.

This seems like a lot of actions but really all we are doing is automating all the steps a developer would otherwise have to do themselves. By amending it to the commit (the one passed through a successful pre-commit) that is the same as a developer writing code, running the doc generator, and committing that result themselves. It is one less task everyone has to remember to do manually.

Now we have a really good quality gatecheck during commit and convenient automation after that commit succeeds.

You might be asking yourself how to get the same hooks installed on everyones machine in a project. It is a good question since the .git directory lives on everyones individual machine and isn't something you can commit changes within. You have two options - you can inform everyone how to copy these two hooks into the right place and make them executable. Or, you can automate that step too so it gets done correctly.

If you are running your stack on a tool like DDEV, you can add a pre-start hook for DDEV to do the following:

  1. Add both scripts from this article to .ddev/git/hooks in your project.
  2. Add the following to .ddev/config.yaml in your project:
hooks:
  pre-start:
    - exec-host: cp .ddev/git/hooks/pre-commit .git/hooks
    - exec-host: cp .ddev/git/hooks/post-commit .git/hooks
    - exec-host: chmod +x .git/hooks/pre-commit
    - exec-host: chmod +x .git/hooks/post-commit

The next time DDEV starts, it will copy those files to .git/hooks and make them executable. If you are using any other local stack you can follow a similar pattern - you just have to copy the files in and make them executable with cp and chmod.

You can add this to your projects right now for great quality of life improvements across the team. Feel free to experiment with the above scripts to tailor them further to your needs.