Skip to content

Automating Your Workflow with Git Hooks

Git hooks are small scripts that Git executes automatically at specific events in the version control lifecycle. They let you "hook" custom behavior into your workflow, acting as your personal quality gatekeepers. They can safeguard your process against common errors, enforce project standards, or automate tedious tasks like code linting before a commit.

In short, they're your first line of defense against "oops" moments.

There are two types of hooks: client-side (running on your machine) and server-side (running on the Git server). We'll focus on the client-side hooks, as they are the ones you'll use daily to make your own life easier.

Client-Side Hooks: Your Personal Assistant

These hooks live inside your local repository in the .git/hooks/ directory. By default, Git populates this folder with a bunch of .sample files. To activate a hook, you just need to remove the .sample extension and make the script executable.

Sharing is Caring: Versioning Your Hooks

The .git/ directory isn't versioned, which means your carefully crafted hooks won't be shared with your team by default. That's a problem.

The modern solution is to store your hooks in a versioned directory (like .githooks/ at the root of your project) and tell Git to look there instead.

You can configure this for a single repository:

shell
git config core.hooksPath .githooks/

Or, even better, set it globally for all your repositories:

shell
git config --global core.hooksPath .githooks/

Now you can commit your hooks and share the love.

Here are the most useful client-side hooks:

  • pre-commit: Runs after you type git commit but before the commit message editor appears. This is your chance to run linters, formatters, or quick tests. If the script exits with a non-zero status, the commit is aborted.
  • prepare-commit-msg: Runs after the default commit message is created and just before the editor opens. It's perfect for programmatically tweaking the commit message, like adding a ticket number from the branch name.
  • commit-msg: Runs after you close the commit message editor. It receives the full commit message as an argument, making it the ideal place to validate that your message follows project conventions (like Conventional Commits).
  • post-commit: Runs immediately after the commit is finalized. You can't change the outcome of the commit here, but it's great for triggering notifications or other follow-up actions.
  • pre-push: Runs before you push your commits to a remote. This is your last chance to catch issues locally. It's a great place to run your full test suite to prevent breaking the build.

Now, let's see how to put them to work. Here are a couple of practical examples I've used over the years.

Example 1: Lint Before You Commit

Nothing is more annoying than pushing code only to have the CI pipeline fail because of a simple linting error. This pre-commit hook stops that from ever happening. It runs ESLint on your staged JavaScript/TypeScript files and aborts the commit if any issues are found.

Save this as pre-commit in your hooks folder and make it executable (chmod +x pre-commit).

bash
#!/bin/bash

set -e

# Get staged JavaScript/TypeScript files
staged_files=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(js|jsx|ts|tsx)$' || true)

if [[ -z "$staged_files" ]]; then
    echo "No JavaScript/TypeScript files staged, skipping ESLint."
    exit 0
fi

echo "Running ESLint on staged files..."

# Run ESLint on staged files (lint only, no fix)
if ! npx eslint $staged_files; then
    echo "ESLint found issues. Please fix them before committing."
    exit 1
fi

echo "ESLint passed successfully."
exit 0

Example 2: Enforce Conventional Commits

This commit-msg hook is a powerhouse. It automatically formats your commit messages to follow our Conventional Commits standard.

  • It intelligently guesses the commit type (feat, fix, etc.) based on your message.
  • If it can't guess, it falls back to using hints from your branch name.
  • It automatically extracts the ticket ID from your branch name and adds it as the scope.

This hook does the heavy lifting, so you can focus on writing a clear message without worrying about formatting rules.

Save this script as commit-msg in your hooks folder:

shell
#!/bin/sh

# Define color codes
RED='\033[0;31m'
YELLOW='\033[0;33m'
BLUE='\033[0;36m'
NC='\033[0m' # No Color

has_warning=false

# Extract current branch name
branch_name=$(git symbolic-ref --short HEAD 2>/dev/null)

# Define branch name regex
branch_regex='^(task|bugfix|feature|hotfix|build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)\/([A-Za-z0-9]{1,6}-[0-9]{1,6})((_|-)(\w|-|_)*)?'

# Handle detached HEAD state
if [ -z "$branch_name" ]; then
  echo -e "${YELLOW}WARNING: You are in a detached HEAD state. Branch name and Jira ID cannot be inferred${NC}"
  exit 0
fi

# Check if branch name matches the regex
if [ "$branch_name" = "main" ]; then
  echo -e "${RED}ERROR: You are on main. Checkout your own branch!${NC}"
  exit 1
fi
if ! echo "$branch_name" | grep -qE "$branch_regex"; then
  echo -e "${RED}ERROR: Branch name does not follow the required pattern.${NC}"
  echo "At the minimum, branch should start by feat/, feature/, bugfix/, fix/, build/, chore/, ci/, docs/, perf/, refactor/, revert/, style/ or test/ followed by the ticket ID."
  echo "You can also add a short description if you add a dash or underscore after the ticket ID."
  exit 1
fi

# ConventionalCommit-compliant regex
commit_regex="^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\((([A-Za-z0-9]{1,6}-[0-9]{1,6})|((([0-9]+).([0-9]+).([0-9]+))))\))(!)?(:\s.*)?|^(Merge \w+)|(Initial commit$)|(Notes added by \'git notes add\')"

# Read the commit message
commit_msg_file=$1
commit_msg=$(cat "$commit_msg_file")

if echo "$commit_msg" | grep -qE "$commit_regex"; then
    exit 0
fi

echo -e "${YELLOW}WARNING: Your commit message does not comply with conventional commit guidelines!${NC}"

# Extract type and ticket ID from branch name
branch_type=$(echo "$branch_name" | sed -nE "s/$branch_regex/\1/p")
ticket_id=$(echo "$branch_name" | sed -nE "s/$branch_regex/\2/p")

echo -e "${BLUE}Scope ($ticket_id) was inferred from your branch name.${NC}"

# Extract the existing type if any
existing_type=$(echo "$commit_msg" | grep -oE "^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)")

# Filter out any occurrence of a type or scope from the commit message
description=$(echo "$commit_msg" | sed -E "s/^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test|task|feature|bugfix|fix|hotfix)(\([0-9]{1,6}\))?:\s*//")

if [ -z "$existing_type" ]; then
  prefix=$(echo "$commit_msg" | grep -oE "(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test|task|feature|bugfix|fix|hotfix)" | head -n 1)
  
  # si pas de type existant: prendre celui de la branche
  if [ -z "$prefix" ]; then
    prefix="$branch_type"
  fi

  # Fixing the prefix
  case $prefix in
    task|feature)
      prefix="feat"
      ;;
    bugfix|fix|hotfix)
      prefix="fix"
      ;;
  esac

  echo -e "${BLUE}Type <$prefix> was inferred from your branch name or commit message content.${NC}"
  commit_msg="${prefix}(${ticket_id}): $description"
else
  echo -e "${BLUE}Type <$existing_type> was inferred from your commit message content.${NC}"
  commit_msg="${existing_type}(${ticket_id}): $description"
fi

if ! echo "$commit_msg" | grep -qE "$commit_regex"; then
    echo -e "${RED}FATAL ERROR: something came up and your commit message could not be fixed${NC}"
    echo "Please review your commit message and make it comply with conventional commit guidelines."
    exit 1
fi

# Write the updated commit message back to the file
echo -e "${BLUE}New commit message: ${NC}$commit_msg"
echo "$commit_msg" > "$commit_msg_file"

echo -e "${BLUE}Make sure your commit message is still what you had in mind.${NC}"

exit 0

This hook is assuming a Jira scheme for ticket ID, feel free to change this group in $branch_regex and $commit_regex:

shell
[A-Za-z0-9]{1,6}-[0-9]{1,6}

Server-side hooks

These run on the Git server and typically require admin access. If you're using a hosted Git service (GitHub, GitLab, etc.), these won't be available, but if you manage your own Git server, you can use:

  • pre-receive: Runs when receiving push data, but before saving it-perfect for rejecting commits with bad messages or branch names
  • update: Like pre-receive, but runs once per branch being pushed
  • post-receive: Runs after the data is saved - great for triggering notifications on Slack... Or a pipeline 😄

Enforce Concention-Commit server-side

Save this script as pre-receive on your server:

shell
#!/bin/sh

# Reject pushes that contains commits with messages that do not adhere
# to the Conventional Commits specifications
#

set -e

zero_commit='0000000000000000000000000000000000000000'
msg_regex="^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\((([A-Za-z0-9]{1,6}-[0-9]{1,6})|((([0-9]+).([0-9]+).([0-9]+))))\))(!)?(:\s.*)?|^(Merge \w+)|(Initial commit$)|(Notes added by \'git notes add\')"

while read -r oldrev newrev refname; do

	# Branch or tag got deleted, ignore the push
    [ "$newrev" = "$zero_commit" ] && continue

    # Calculate range for new branch/updated branch
    [ "$oldrev" = "$zero_commit" ] && range="$newrev" || range="$oldrev..$newrev"

	for commit in $(git rev-list "$range" --not --all); do
		if ! git log --max-count=1 --format=%B $commit | grep -iqE "$msg_regex"; then
			echo "ERROR:"
			echo "ERROR: Your push was rejected because the commit"
			echo "ERROR: $commit in ${refname#refs/heads/}"
			echo "ERROR: is not ConventionalCommit compliant."
			echo "ERROR:"
			echo "ERROR: Please fix the commit message and push again."
			echo "ERROR: https://www.conventionalcommits.org/en/v1.0.0/"
			echo "ERROR"
			exit 1
		fi
	done

done