Add a pre-commit hook in Git

Posted by : on

Category : powershell   scripts   network


Introduction

Like many other Version Control Systems, Git has a way to fire off custom scripts when certain important actions occur.

Git hook scripts are useful for identifying simple issues before submission to code review. We run our hooks on every commit to automatically point out issues in code such as missing semicolons, trailing whitespace, and debug statements. By pointing these issues out before code review, this allows a code reviewer to focus on the architecture of a change while not wasting time with trivial style nitpicks. Also it helps protect the depo from devs who push changes without any attention!

There are two groups of these hooks: client-side and server-side. You can use these hooks for all sorts of reasons.

  1. Client-side hooks are triggered by operations such as committing and merging.
  2. Server-side hooks run on network operations such as receiving pushed commits.

Installing a Hook

The hooks are all stored in the hooks subdirectory of the Git directory. In most projects, that’s .git/hooks

To enable a hook script, put a file in the hooks subdirectory of your .git directory that is named appropriately (without any extension) and is executable. From that point forward, it will be called.

There is 4 files that can be executed before and after your commit:

  1. pre-commit
  2. prepare-commit-msg
  3. commit-msg
  4. post-commit (after the commit)

The pre-commit hook is run first, before you even type in a commit message. It’s used to inspect the snapshot that’s about to be committed, to see if you’ve forgotten something, to make sure tests run, or to examine whatever you need to inspect in the code. Exiting non-zero from this hook aborts the commit, although you can bypass it with git commit –no-verify. You can do things like check for code style (run lint or something equivalent), check for trailing whitespace (the default hook does exactly this), or check for appropriate documentation on new methods.

The prepare-commit-msg hook is run before the commit message editor is fired up but after the default message is created. It lets you edit the default message before the commit author sees it. This hook takes a few parameters: the path to the file that holds the commit message so far, the type of commit, and the commit SHA-1 if this is an amended commit. This hook generally isn’t useful for normal commits; rather, it’s good for commits where the default message is auto-generated, such as templated commit messages, merge commits, squashed commits, and amended commits. You may use it in conjunction with a commit template to programmatically insert information.

The commit-msg hook takes one parameter, which again is the path to a temporary file that contains the commit message written by the developer. If this script exits non-zero, Git aborts the commit process, so you can use it to validate your project state or commit message before allowing a commit to go through. In the last section of this chapter, we’ll demonstrate using this hook to check that your commit message is conformant to a required pattern.

After the entire commit process is completed, the post-commit hook runs. It doesn’t take any parameters, but you can easily get the last commit by running git log -1 HEAD. Generally, this script is used for notification or something similar.

Example

To add a pre-commit hook in Git, follow these steps:

  1. Navigate to the .git/hooks directory in your repository:
    cd path/to/your/repo/.git/hooks
    
  2. Create or modify the pre-commit script:
    • If the pre-commit file exists, edit it.
    • If it doesn’t exist, create a new file named pre-commit:
      touch pre-commit
      
  3. Make the pre-commit script executable:
    chmod +x pre-commit
    
  4. Write your script inside the pre-commit file: Here’s an example of a simple pre-commit hook that prevents commits with unformatted Python code using black:
    #!/bin/sh
    
    # Run black to format Python files
    black --check .
    
    if [ $? -ne 0 ]; then
        echo "Python files are not formatted properly. Run 'black .' to format them."
        exit 1
    fi
    
  5. Test the hook: Now, try committing some changes. If the conditions in the hook aren’t met (e.g., Python files aren’t formatted), the commit will be blocked, and you’ll see the error message from the hook.

Using pre-commit framework:

If you want a more sophisticated setup with multiple hooks or linters, you can use the pre-commit framework:

  1. Install pre-commit:
    pip install pre-commit
    
  2. Create a .pre-commit-config.yaml file in the root of your repository: ```yaml repos:
    • repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.0.1 hooks:
      • id: trailing-whitespace
      • id: end-of-file-fixer ```
  3. Install the pre-commit hook:
    pre-commit install
    

Now, every time you commit, it will automatically check for the conditions defined in .pre-commit-config.yaml.

Global hooks

To make a Git pre-commit hook global for all users on a system or across multiple repositories, you can configure a global or shared Git hooks directory. Here’s how to do it:

Option 1: Set up a Global Hooks Directory

  1. Create a Global Hooks Directory: Choose a directory where you want to store the global hooks, e.g., /usr/local/share/git-core/hooks, or in the home directory for user-specific hooks, e.g., ~/.git-hooks.

    mkdir -p ~/.git-hooks
    
  2. Tell Git to Use the Global Hooks Directory: Set the core.hooksPath in your global Git configuration to point to this directory:

    git config --global core.hooksPath ~/.git-hooks
    

    This command updates your ~/.gitconfig file to tell Git to look in ~/.git-hooks for hooks by default.

  3. Create the Pre-commit Hook: Now, inside your global hooks directory, create a pre-commit script:

    touch ~/.git-hooks/pre-commit
    chmod +x ~/.git-hooks/pre-commit
    

    Add your desired hook logic to this script. For example, here’s a simple script that checks for uncommitted changes:

    #!/bin/sh
    
    if git diff-index --quiet HEAD --; then
        echo "Pre-commit checks passed."
    else
        echo "Uncommitted changes detected. Please commit or stash changes first."
        exit 1
    fi
    
  4. Verify: Now, whenever any Git repository you manage attempts to commit, Git will use this global pre-commit hook.

Option 2: Create a Template for New Repositories

  1. Set up a Global Template Directory: Git provides the ability to set up a template directory that is used when initializing new repositories. Create a directory that will serve as a template for hooks:

    mkdir -p ~/.git-template/hooks
    
  2. Create the Pre-commit Hook in the Template: Inside the template directory, create the pre-commit hook as before:

    touch ~/.git-template/hooks/pre-commit
    chmod +x ~/.git-template/hooks/pre-commit
    

    Add your script to this hook.

  3. Set the Template Globally: Configure Git to use this template for all new repositories:

    git config --global init.templateDir ~/.git-template
    
  4. Apply to Existing Repositories: This template will only affect newly created repositories. To apply it to existing repositories, you’ll need to manually copy the hooks to their .git/hooks/ directories.

Notes:

  • Existing Repositories: The global hooks will not automatically affect existing repositories unless you reinitialize them or manually copy the hook.
  • Global Hooks Across Users: If you’re managing multiple users on a system, you can set the global hooks directory at the system level using git config --system, but it requires admin privileges:

     sudo git config --system core.hooksPath /path/to/global/hooks
    

Example of Another Pre-Commit Hook (local.conf changed)

Edit the pre-commit file and add the following script:

   #!/bin/sh
   # Pre-commit hook to prevent changes to 'prod/local.conf' file

   # Check if 'prod/local.conf' is staged for commit
   if git diff --cached --name-only | grep -q '^prod/local.conf$'; then
       echo "Error: Changes to 'prod/local.conf' are not allowed."
       exit 1
   fi

   # Allow commit if no changes are found
   exit 0

How the Script Works:

  • The script uses git diff --cached --name-only to list all the files that are staged for commit.
  • It then checks if prod/local.conf is among the files that are staged.
  • If prod/local.conf is detected, the hook will output an error message and prevent the commit by exiting with exit 1.
  • If prod/local.conf is not found in the list of staged files, the commit will proceed as normal (the script exits with exit 0).

Example of Another Pre-Commit Hook (Checking for TODO Comments)

Here’s an example of a pre-commit hook that blocks commits if they contain TODO comments:

#!/bin/sh
# A pre-commit hook to block commits with TODO comments.

# Check for TODOs in the staged files
if git diff --cached | grep -i 'TODO'; then
    echo "Commit contains TODO comments. Please resolve them before committing."
    exit 1
fi

Summary of Steps:

  1. Navigate to your repository’s .git/hooks directory.
  2. Create or edit the pre-commit file.
  3. Write your hook script inside the pre-commit file.
  4. Make the pre-commit script executable with chmod +x pre-commit.
  5. Test your hook by making a commit.

Now your Git project will run the pre-commit hook every time a commit is attempted.

Bypass pre-commit hooks

To bypass a pre-commit hook for a specific commit, you can use the --no-verify option when committing. This will prevent Git from running the pre-commit hooks for that particular commit.

Example Command to Bypass a Pre-commit Hook

To make a commit without running the pre-commit hooks, use:

git commit -m "Your commit message" --no-verify

Important Notes:

  • The --no-verify flag skips all hooks, including pre-commit, commit-msg, and pre-push hooks.
  • Use this option sparingly since it bypasses important checks or validations that the pre-commit hook may be enforcing.
  • You can still push to the repository normally, but be mindful that server-side hooks (like pre-receive or update hooks) will not be bypassed by this method, so your push might still be blocked at the server level if additional validations are in place.

This is useful when you need to make an emergency commit or are aware that a specific pre-commit check is unnecessary for a particular case.

Server-side hooks

A server-side Git pre-receive hook is used to enforce certain rules on a Git server before accepting pushed commits from clients. This hook can be particularly useful to enforce policies across all repositories or ensure code quality checks, such as verifying commit messages, preventing certain file types, or ensuring code passes tests. By setting up a pre-receive hook on the Git server, you can enforce custom policies and prevent unwanted commits from being pushed to the repository. The hook scripts are highly flexible and can be adapted to enforce any specific checks or conditions you require for your project.

Steps to Add a Server-Side Pre-Receive Hook:

1. Access the Git Server

You need access to the Git server where the repository is hosted.

  • SSH into your Git server:
    ssh your-user@your-server
    

2. Navigate to the Git Repository on the Server

Find the Git repository directory on the server. This will typically be inside a directory like /var/git or /home/git, depending on your server setup.

   cd /path/to/repository.git

Ensure you’re in the bare repository (which ends with .git). Bare repositories are the ones that clients push to directly.

3. Go to the hooks Directory

Inside the .git folder (for non-bare repositories) or at the root of the bare repository, there is a hooks directory where server-side hooks reside.

   cd hooks

4. Create or Edit the pre-receive Hook

If a pre-receive hook doesn’t exist, create one:

   touch pre-receive

Open the file in a text editor:

   nano pre-receive

5. Add Your Hook Logic

The pre-receive hook script is executed every time a git push is attempted. It reads input through standard input (stdin) and expects three fields:

  • The old commit reference (old SHA1 hash)
  • The new commit reference (new SHA1 hash)
  • The refname (e.g., refs/heads/master)

You can implement checks and enforce rules based on these fields.

Here’s a basic example of a pre-receive hook that prevents force pushes to the master branch:

   #!/bin/sh
   # Prevent force pushes to the master branch

   while read oldrev newrev refname
   do
       if [ "$refname" = "refs/heads/master" ]; then
           # If the old revision is not zero (which means the branch exists),
           # and new revision is zero (which indicates deletion), reject.
           if ! git merge-base --is-ancestor $oldrev $newrev; then
               echo "Force pushing to master is not allowed!"
               exit 1
           fi
       fi
   done

   exit 0

This hook blocks any non-fast-forward changes (i.e., force pushes) to the master branch.

6. Make the Hook Executable

After adding the logic to the pre-receive hook, make the file executable:

   chmod +x pre-receive

7. Test the Hook

To test the hook, try pushing to the repository from a client. The hook will run each time a git push is made to the server.

For example, try force-pushing to the master branch from a client:

   git push --force origin master

If the hook is set up correctly, the push should be rejected, and you should see the message Force pushing to master is not allowed!.

8. Hook Logic Examples

Here are a few more examples of what you can do with a server-side pre-receive hook:

  • Reject pushes with invalid commit messages:

     #!/bin/sh
     # Ensure commit messages contain a JIRA ticket ID (e.g., JIRA-123)
    
     while read oldrev newrev refname
     do
         commits=$(git rev-list $oldrev..$newrev)
         for commit in $commits
         do
             message=$(git log --format=%B -n 1 $commit)
             if ! echo "$message" | grep -q "JIRA-[0-9]\+"; then
                 echo "Commit $commit is missing a JIRA ticket ID!"
                 exit 1
             fi
         done
     done
    
     exit 0
    
  • Reject pushes with large files:

     #!/bin/sh
     # Block pushes if they contain files larger than 10MB
    
     MAX_SIZE=10485760
    
     while read oldrev newrev refname
     do
         for file in $(git diff-tree --no-commit-id --name-only -r $newrev)
         do
             if [ $(git cat-file -s "$newrev:$file") -gt $MAX_SIZE ]; then
                 echo "File $file is larger than 10MB! Push rejected."
                 exit 1
             fi
         done
     done
    
     exit 0
    

9. Apply the Hook Globally (Optional)

If you want this pre-receive hook to apply to all repositories on the server, you can configure a global hooks directory:

  1. Set the core.hooksPath globally in the system-wide Git config file (/etc/gitconfig or similar):

    sudo git config --system core.hooksPath /path/to/global/hooks
    
  2. Place your pre-receive hook in this global hooks directory.

This will apply the hook to all repositories on the server that use the global hook configuration.

GitLab

Please see this page for info on setting up Gitlab server-side pre-receive hooks.

References

  1. Pre-Commit : A framework for managing and maintaining multi-language pre-commit hooks
  2. Customizing Git: Git Hooks
  3. pre-commit-hooks: Some out-of-the-box hooks for pre-commit.

About Guillaume Plante
Guillaume Plante

A developper with a passion for technology, music, astronomy and art. Coding range: hardware/drivers, security, ai,. c/c++, powershell

Email : guillaumeplante.qc@gmail.com

Website : https://arsscriptum.ddns.net

Useful Links