Keep your codebase clean with Git hooks

  • avatar
  • 106 Views
  • 9 mins read

Every developer has been there: you push a commit, CI fails, and it turns out there was a linting error, a forgotten debug statement, or a test that nobody ran. Pre-commit hooks are the safety net that catches these problems before they ever leave your machine. They are not a complex feature or an advanced Git topic. They are just scripts, and once you understand how they work, you will find yourself reaching for them on every project.

What pre-commit hooks actually are

Git has a built-in hook system: scripts that run automatically at specific points in the Git workflow. A pre-commit hook runs right before a commit is recorded.

When you run git commit, Git pauses and executes the script placed in .git/hooks/pre-commit. If that script exits with a non-zero status code, the commit is aborted. If it exits cleanly, the commit goes through.

The scripts can be written in any language your system supports: bash, PHP, Python, or anything else available on the machine. Git doesn't care what runs them, only what they return.

There are actually several hook types beyond pre-commit, each firing at a different point in the Git workflow. The most commonly used ones are:

  • pre-commit runs before the commit is recorded. Used for code checks, linting, and file validation.

  • commit-msg runs after you write your commit message but before the commit is saved. Used for enforcing message format rules.

  • pre-push runs before a git push is sent to the remote. Used for running tests or blocking pushes to protected branches.

Each one follows the same contract: exit 0 to allow, exit 1 to block.

Setting up a hook from scratch

A hook is just an executable file placed in .git/hooks/ with the correct name. To see what's available, list the directory in any Git repository:

ls .git/hooks/

Git ships sample files for every hook type. They all end in .sample, which means Git ignores them. Remove that extension and make the file executable, and the hook becomes active.

Let's write a commit-msg hook that enforces the Conventional Commits format. Create .git/hooks/commit-msg:

#!/bin/bash
# Exit immediately if any command fails
set -e

# Git passes the path to a temp file containing the commit message as $1
# Read its contents into a variable
commit_msg=$(cat "$1")

# The pattern enforces: type(scope): description
# Scope is optional. Description is capped at 100 characters.
pattern='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\\(.+\\))?: .{1,100}$'

# grep -qP tests the message silently using Perl-compatible regex
# The "!" negates the result: if the pattern does NOT match, block the commit
if ! echo "$commit_msg" | grep -qP "$pattern"; then
echo ""
echo "Error: commit message does not follow the Conventional Commits format."
echo ""
echo "Expected format: type(scope): description"
echo "Example: feat(auth): add OAuth2 login support"
echo ""
echo "Allowed types: feat, fix, docs, style, refactor, test, chore"
echo ""
# Exit with 1 to abort the commit
exit 1
fi

# Exit with 0 to allow the commit
exit 0

Make it executable:

chmod +x .git/hooks/commit-msg

This is the full mechanism behind every Git hook. The logic inside can be as simple or as involved as needed, but the contract with Git is always the same: exit 1 to block, exit 0 to allow.

What are Conventional Commits

The example above enforces a specification called Conventional Commits. It's a widely adopted convention for writing structured, human-readable commit messages. If you're not familiar with it, we have a dedicated article covering it in detail.

In short, the format looks like this:

type(scope): description

Some real examples:

feat(auth): add OAuth2 login support
fix(api): handle null response from payment gateway
docs(readme): update installation instructions
chore(deps): bump guzzlehttp/guzzle to 7.8

The benefit goes beyond readability. Tools like conventional-changelog can parse this format to automatically generate changelogs, and tools like semantic-release use it to determine version bumps automatically. Enforcing the format at commit time means those tools always have clean, consistent input to work with.

Sharing hooks across the team

Native hooks live inside .git/hooks/, which Git itself does not track. They don't get committed to the repository and don't travel with the code when someone clones it. Every developer has to configure them manually on their own machine, which in practice means they rarely get set up consistently across a team.

The solution is a hook manager: a tool that stores hook configuration in a regular committed file inside the repository. When a new developer clones the project and runs the setup, they get the same hooks as everyone else automatically.

Different ecosystems have their own preferred tools:

  • PHP: CaptainHook, configured via captainhook.json, integrates naturally with Composer workflows.

  • JavaScript / Node.js: Husky is the standard choice, with hooks defined alongside package.json.

  • Python: The pre-commit framework uses a .pre-commit-config.yaml file and pulls hook definitions from external repositories.

  • Go: Lefthook uses a lefthook.yml file and installs as a single binary, making it easy to add to any project regardless of the stack.

  • Ruby: Overcommit manages hooks through a .overcommit.yml configuration file.

  • Any language: If your stack doesn't have a dedicated tool, a shell script committed to the repository and symlinked into .git/hooks/ during project setup works fine.

Configuring hooks with CaptainHook

We'll use CaptainHook as a concrete example in this article. It's a PHP-native hook manager that keeps everything inside a captainhook.json file you commit to the repository, making it the natural choice for PHP projects. The concepts here translate directly to Husky, pre-commit, Lefthook, Overcommit, or any other manager.

Install it via Composer:

composer require --dev captainhook/captainhook
./vendor/bin/captainhook install

The second command places thin hook scripts in .git/hooks/ that delegate everything to CaptainHook. From this point, your configuration lives in captainhook.json.

Here's a simple starting configuration:

{
"pre-commit": {
"enabled": true,
"actions": [
{
"action": "\\\\CaptainHook\\\\App\\\\Hook\\\\PHP\\\\Action\\\\Linting"
},
{
"action": "\\\\CaptainHook\\\\App\\\\Hook\\\\File\\\\Action\\\\DoesNotContainRegex",
"options": {
"regex": "/var_dump\\\\(/",
"message": "Remove var_dump() calls before committing."
}
},
{
"action": "\\\\CaptainHook\\\\App\\\\Hook\\\\File\\\\Action\\\\MaxSize",
"options": {
"size": "1M"
}
}
]
}
}

Three actions, each doing one job:

  • Linting

    Checks every staged PHP file for syntax errors before it reaches the repository. A file with a parse error is caught here immediately.

  • DoesNotContainRegex

    Scans staged files for a given pattern and blocks the commit if it finds a match. Here it catches leftover var_dump() calls, but the same action can be reused for any pattern you want to ban, such as hardcoded credentials or debug flags.

  • MaxSize

    Blocks any single file larger than 1MB, preventing accidental commits of binaries, database dumps, or generated assets.

Enforcing Conventional Commits with CaptainHook

CaptainHook also handles commit-msg hooks, so the Conventional Commits check doesn't need a custom bash script. Add it to captainhook.json:

{
"commit-msg": {
"enabled": true,
"actions": [
{
"action": "\\\\CaptainHook\\\\App\\\\Hook\\\\Message\\\\Action\\\\Regex",
"options": {
"regex": "/^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\\\\(.+\\\\))?: .{1,100}$/",
"message": "Commit message must follow the Conventional Commits format.\\nExpected: type(scope): description\\nExample: feat(auth): add OAuth2 login support\\nAllowed types: feat, fix, docs, style, refactor, test, chore"
}
}
]
}
}

The built-in Regex action tests the commit message against the pattern and blocks the commit if it doesn't match. No custom scripting needed, and the rule is visible to everyone on the team through the committed config file.

A combined captainhook.json with both pre-commit and commit-msg configured looks like this:

{
"pre-commit": {
"enabled": true,
"actions": [
{
"action": "\\\\CaptainHook\\\\App\\\\Hook\\\\PHP\\\\Action\\\\Linting"
},
{
"action": "\\\\CaptainHook\\\\App\\\\Hook\\\\File\\\\Action\\\\DoesNotContainRegex",
"options": {
"regex": "/var_dump\\\\(/",
"message": "Remove var_dump() calls before committing."
}
},
{
"action": "\\\\CaptainHook\\\\App\\\\Hook\\\\File\\\\Action\\\\MaxSize",
"options": {
"size": "1M"
}
}
]
},
"commit-msg": {
"enabled": true,
"actions": [
{
"action": "\\\\CaptainHook\\\\App\\\\Hook\\\\Message\\\\Action\\\\Regex",
"options": {
"regex": "/^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\\\\(.+\\\\))?: .{1,100}$/",
"message": "Commit message must follow the Conventional Commits format.\\nExpected: type(scope): description\\nExample: feat(auth): add OAuth2 login support\\nAllowed types: feat, fix, docs, style, refactor, test, chore"
}
}
]
}
}

New team members run composer install followed by ./vendor/bin/captainhook install, and they're set up identically to everyone else.

Skipping hooks when necessary

Sometimes you genuinely need to bypass checks, typically for a rough work-in-progress commit on a personal branch. Git provides a flag for that:

git commit --no-verify -m "wip: rough draft"

The --no-verify flag skips all hooks entirely. Use it sparingly. The value of pre-commit hooks comes from them running consistently, and bypassing them regularly on shared branches defeats the purpose.

Conclusion

Pre-commit hooks are one of those tools that feel like a minor addition but quietly improve the entire development workflow. Problems caught locally are cheaper than problems caught in CI, and issues that never reach a pull request don't slow down code reviews. Whether you go with a raw bash script or a full hook manager like CaptainHook, the investment is small and the payoff compounds over time as the codebase and the team grow.

 Join Our Monthly Newsletter

Get the latest news and popular articles to your inbox every month

We never send SPAM nor unsolicited emails

0 Comments

Leave a Reply

Your email address will not be published.

Replying to the message: View original

Hey visitor! Unlock access to featured articles, remove ads and much more - it's free.