What are Git Hooks, and why should I use them?
Git Hooks are a Git feature, which allows developers to execute scripts at certain points in their Git lifecycle, like before accepting a commit, before pushing to a remote repository or before accepting a commit message. Git Hooks can be used to "shift left" and automate tasks and enforce policies throughout the Git workflow.
Git Hooks live in the .git/hooks
folder of a repository. Here, we can place scripts with the same name as the Git Hooks they should execute on (max. one per hook). Below, an example which checks the commit message for a certain pattern.
#!/bin/bash
MESSAGE=$(cat $1)
COMMITFORMAT="^(feat|fix|docs|style|refactor|test|chore|perf|other)(\((.*)\))?: #([0-9]+) (.*)$"
if ! [[ "$MESSAGE" =~ $COMMITFORMAT ]]; then
echo "Your commit was rejected due to the commit message. Skipping..."
exit 1
fi
Keep in mind, that each script in this folder has to be marked as executable by running chmod +x pre-commit-msg
.
You can find more information and a list of available Git Hooks at githooks.com.
What to use them for?
- Check commit messages: Especially useful, if you use conventional commits. Also, to avoid the famous "WIP" or "fix" commit messages.
- Check coding style and Linters: A no-brainer. Nothing is more frustrating than going back to your code and fix that trailing space, after the CI pipeline failed because of it.
- Check for secrets: (Optional) We should check our code for accidentally checked-in secrets, like passwords or connection strings.
- Build and Test: (Optional) At least before pushing changes to a remote, we could make sure the code builds and the tests pass.
Problems
Can be bypassed
As Git Hooks can be bypassed. For example, with the --no-verfify
flag for the git commit
command. So we should never rely on their execution but rather see them as a helper to avoid frustration amongst developers, when they find their Pull Request checks failing.
As we can not rely on their execution, Git Hook checks should be repeated when checking the Pull Request on the server!
Not installed to each development environment by default
As Git Hooks live in the .git/hooks
folder and the .git
folder itself is not part of the version control, scripts that are placed into that folder will not be checked in. So new developers that are cloning the repository won't have the Git Hooks in their local .git folder. Also, remote environments, like GitHub's In-Browser editing or Dev Containers won't have the Git Hooks setup by default.
A common practice around this behavior is placing the scripts into a .githooks
folder in the repository root, and then configuring Git to use its Hooks from there with the git config core.hooksPath .githooks
command. This allows to check in the scripts into version control, but the command still needs to be run in every new environment and won't make the Hooks available by default on new machines or remote environments.
Dependencies might not be installed
Most scripts for checking coding style violations or commit message patterns aren't as basic as the one from above but use other tools like Prettier, ESLint or editorconfig-checker for Coding Style checks or Gitlint (highly recommended) for commit message policies. These tools might not be installed on new or remote environments.
Managing Git Hooks (and their dependencies) with pre-commit
To deal with the problems from above, there are many tools out there which make working with Git Hooks easier and more straight-forward. Amongst the Node.js and JavaScript community, Husky gained some popularity, as it natively integrates into the package.json
file, where app dependencies and development dependencies are managed.
As I deal with projects across different programming languages and frameworks, I was looking for a more generic approach, which can be used with any project. The tool that convinced me the most is pre-commit, as it
- manages and installs Git Hooks
- manages and installs dependencies (= tools, that my Git Hooks use)
- can be run in CI pipelines to check, if Git Hooks haven't been skipped
- is technology independent (in contrast to popular Husky)
The configuration for pre-commit is stored in a .pre-commit-config.yaml
file at the repository root and contains the hooks to configure and the tools that should be executed.
default_install_hook_types:
- pre-commit
- pre-push
- commit-msg
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.0.1
hooks:
- name: Check, that no large files have been committed
id: check-added-large-files
- repo: https://github.com/editorconfig-checker/editorconfig-checker.python
rev: '2.7.1'
hooks:
- name: Check EditorConfig
id: editorconfig-checker
alias: ec
stages: ["pre-push"]
- repo: https://github.com/jorisroovers/gitlint
rev: v0.19.1
hooks:
- name: Lint commit messages
id: gitlint
- id: gitlint-ci
args: ['--commits', 'origin/main..HEAD']
Installing Git Hooks for each developer
The configuration above allows to manage Git Hooks and their dependencies centrally in the repository. But the pre-commit tool itself still needs to be installed on each machine. Once installed, the hooks (and their dependencies) can be installed with a single command.
pre-commit install
Cross-Checking Git Hooks in the CI pipeline
As Git Hooks can be bypassed, it is highly recommended to cross-check them in Pull Request Checks or the CI pipeline. This is another reason, why I like pre-commit so much: The scripts can be executed at-hoc for checking the current Git repository and its commits for violations.
With GitHub Actions for example, you can check Pull Requests with the following workflow.
name: PR Check
on:
pull_request:
jobs:
pr-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install pre-commit
run: pip install pre-commit
- name: Check commits
run: pre-commit run --hook-stage manual
Mitigate violations
But what to do, when Pre-Push or Pull Request checks find policy violations in our code or commit messages, but the commit is already created and part of the current branch's history?
In case of a committed file that needs to be adjusted, the history of the current branch can be soft-reset to undo all commits that happened on that branch. Don't worry, your work won't be lost. This will put all modifications back to the list of uncommitted changes.
git checkout pr_branch
git reset --soft main
Before creating a commit, we can now adjust the files. Afterward, we can create a new commit without the violation.
git add -A && git commit -m "commit message goes here"
git push --force
To change a commit message afterward, we can follow a comprehensive Guide from GitHub.
Both, resetting the history and changing commit messages is annoying. To avoid that, it is generally recommended to do most checks at the pre-commit level.