Intermediate

Git Hooks

I am a Software Architect and I have designed and architected software for over 10 years.

Table of Contents

  1. Course Overview
  2. Client-side Hooks
  1. Server-side Hooks
  1. Quick hook reference
  2. Good practices

1. Course Overview

Hello everyone! My name is Timothy and welcome to my Git Hooks course. I am a Software Architect and I have designed and architected software for over 10 years.

If you’ve ever wondered how to customize your Git experience — for example, adding commits and checks before a Git command is executed, or even implementing post-command notifications — then Git Hooks are the answer to your questions.

Course objectives

In this course, we’ll cover a quick introduction to customizing Git commands using Git Hooks. The topics covered are:

  • Prerequisites: Knowledge of basic Git commands is required to follow the examples in this course.
  • Definition: We are going to explain what Git Hooks are.
  • Practical examples: We will see examples of writing hooks, both on the client side and on the server side of the Git repository.
  • Automation: The examples will demonstrate how to automate everyday use cases when interacting with Git.

Expected result

By the end of this course, you will know the basics of Git Hooks and be ready to implement your own hooks for your Git repositories.


2. Client side Hooks

2.1 Introduction

Welcome to the first of two modules on Git Hooks. In this module, we’ll talk about client-side hooks — those that install on the client side of the Git repository.

2.2 Module overview

Before we go any further, let’s take a moment to review the topics we’ll cover in this module:

  1. Basic concepts about Git Hooks.
  2. Hooks related to the commit process: There are three, and we will cover them in detail.
  3. Two additional hooks on the client side:
  • A hook related to the rebase operation.
  • A hook related to checkout.

2.3 Basic concepts

2.3.1 Defining Git Hooks

Git Hooks are extensibility points that are triggered when specific Git actions occur or when specific Git workflows are activated, such as the commit workflow that we’ll look at later in this module.

In summary:

  • These are scripts or executables placed at a specific location in the Git repository.
  • Git automatically calls them at key moments of its execution.
  • They allow you to automate, validate or notify during Git operations.

2.3.2 The three types of hooks

Git defines several types of hooks, which can be grouped according to their name prefix:

PrefixExecution timeImpact on Git command
pre-Before starting the Git commandCan cancel the command if the exit code is non-0
post-After the GitThe exit code does not affect the command
MiscellaneousWhile running the GitCan cancel the command if the exit code is non-0

Group 1 — pre- hooks: This group includes all hooks called by Git before Git commands are actually started. These hooks have the ability to cancel the Git command if they exit with a non-0 exit code.

Group 2 — post- hooks: As the name suggests, these hooks are called after the Git command has finished executing. Therefore, their exit code will not affect the Git command.

Group 3 — Miscellaneous hooks: This third group is a miscellaneous set of hooks called by Git during the execution of Git commands. Git typically uses these hooks to get specific data it needs during command execution. These hooks also have the ability to cancel the Git command if they exit with a non-0 exit code.

2.3.3 The commit workflow

One of the main Git workflows where all three types of hooks exist is the commit workflow — the process of applying changes to the Git repository.

Here is the complete flow of the commit workflow with its hooks:

git commit
     │
     ▼
[1] pre-commit hook
     │  → Type : pre- (peut annuler le commit)
     │  → Usage : validations, vérifications de code, application de styles
     │
     ▼ (si exit code = 0)
[2] prepare-commit-msg hook
     │  → Type : divers (peut annuler le commit)
     │  → Usage : populer le fichier de message de commit par défaut
     │
     ▼ (si exit code = 0)
    [Éditeur présenté au committer pour modifier le message]
     │
     ▼
[3] commit-msg hook
     │  → Type : divers (peut annuler le commit)
     │  → Usage : valider le message de commit final
     │
     ▼ (si exit code = 0)
    [Les changements sont écrits dans le dépôt Git]
     │
     ▼
[4] post-commit hook
     │  → Type : post- (ne peut PAS annuler le commit)
     │  → Usage : notifications, déclenchement d'actions post-commit

Detail of each step:

  1. pre-commit: Git invokes this hook first. It receives a snapshot of the changes that will be committed. It is typically used to perform various code checks, validations, or even to apply code styles. If this hook exits with a non-0 exit code (failure), the entire commit workflow will be rolled back. This hook belongs to the first type (pre- hooks).

  2. prepare-commit-msg: After the pre-commit hook succeeds, the actual commit process starts. Git will invoke this hook to get the default commit message. If this hook completes successfully, Git will present the committer with an editor to modify the default commit message. If this hook fails, the entire commit workflow will be rolled back. This hook belongs to the third type (miscellaneous hooks).

  3. commit-msg: Before the changes are completely written to the Git repository, Git invokes this hook to commit the commit message. If this hook completes successfully, the changes are written to the Git repository. If it fails, the entire commit workflow will be rolled back. This hook also belongs to the third type (miscellaneous hooks).

  4. post-commit: After the commit workflow completes, Git invokes this last hook. The exit code from this hook has no effect on the commit process. This hook belongs to the second type (post- hooks).

2.3.4 Nature of Git Hooks — what Git expects

It’s very important to know that Git Hooks are basically just executables, and that’s all Git cares about: an executable that it can run.

Implications:

  • Any programming language can be used to write a Git Hook: Perl, Python, Bash, C#, Java, or any other language — as long as you produce an executable.
  • Primitive communication: When Git executes the executable, it uses primitive means of communication with this process:
  • command line arguments (command line arguments)
  • The input stream (input stream / stdin)
  • The output and error streams (output and error streams / stdout, stderr)
  • environment variables (environment variables)
  • Exit code: Git checks the exit code of this executable to determine whether it succeeded or failed.
  • IDE neutrality: Since hooks run at the Git level, they are IDE neutral used by the developer. Regardless of the IDE, as long as it integrates Git, Git will decide when to invoke the Git Hook, and it will be executed.
  • Working directory: Git will define the working directory of this executable as the root of the Git repository on which the hook is installed.
# Exemple de hook écrit en Bash
#!/bin/bash
echo "Hook exécuté depuis : $PWD"
exit 0

# Exemple de hook écrit en Python
#!/usr/bin/env python3
import sys
print("Hook Python exécuté")
sys.exit(0)

2.3.5 Client-side vs server-side hooks

An important aspect of Git Hooks is the fact that some are intended to be executed client-side, while others are intended to be executed server-side.

Main difference:

CharacteristicClient-side HooksServer-side Hooks
ControlUnder the full control of the developerControlled by server administrators
BypassCan be easily bypassed or disabledCannot be bypassed by client-side switches
DeactivationMay be forgotten by the developerCan be disabled by deleting them from the filesystem (but not by the client)
Main useDeveloper productivity and compliance with project standardsPolicy enforcement of the server-side repository
  • Client-side hooks: They are under the full control of the developer and can be easily bypassed or disabled, or even forgotten. Their main goal is developer productivity and perhaps a defensive style to maintain project standards.

  • Server-side hooks: Although they can be disabled by removing them from their location on the filesystem, they cannot be bypassed by client-side switches or arguments. Their main use case is to enforce server-side policies on the Git repository.

2.4 Demo: First look at Git Hooks

In this demo, we’ll take a first look at Git Hooks.

Background: The instructor is starting work on a calculator project and has decided to use Git as his source control system.

Location of hooks in a Git repository:

.git/
└── hooks/
    ├── applypatch-msg.sample
    ├── commit-msg.sample
    ├── fsmonitor-watchman.sample
    ├── post-update.sample
    ├── pre-applypatch.sample
    ├── pre-commit.sample
    ├── pre-merge-commit.sample
    ├── pre-push.sample
    ├── pre-rebase.sample
    ├── pre-receive.sample
    ├── prepare-commit-msg.sample
    ├── push-to-checkout.sample
    └── update.sample

Inside the .git folder there is a subfolder called hooks that Git has pre-populated with a set of files ending with the .sample extension.

Origin of these files:

For each new Git repository, Git will take a copy of these files from a shared folder:

usr/share/git-core/templates/hooks/

Git also provides configurations to change these two locations (the one at the repository level and the shared folder).

Nature of these files:

All of these files are shell scripts. However, since all Git cares about is having an executable in this folder, the instructor will create a .NET console application to demonstrate the concept.

What the trace application does:

For this discovery demo, the .NET application is designed to:

  • Read ProcessName and standardInput
  • Log executable invocation time in a file called hooks-trace.log
  • Create file with process name (i.e. hook name)
  • Write the environment variables, the command line arguments, and the standard input that were passed by Git
// Exemple de logique de l'application console .NET
var processName = Process.GetCurrentProcess().ProcessName;
var standardInput = Console.In.ReadToEnd();
var invocationTime = DateTime.Now;

// Enregistrement dans le fichier de trace
File.AppendAllText("hooks-trace.log", $"{invocationTime} - {processName}{Environment.NewLine}");

// Création du fichier de log du hook
var logFileName = $"{processName}.log";
var envVars = Environment.GetEnvironmentVariables();
var args = Environment.GetCommandLineArgs();

var content = new StringBuilder();
content.AppendLine("=== Environment Variables ===");
foreach (DictionaryEntry env in envVars)
    content.AppendLine($"{env.Key}={env.Value}");

content.AppendLine("=== Command Line Arguments ===");
foreach (var arg in args)
    content.AppendLine(arg);

content.AppendLine("=== Standard Input ===");
content.AppendLine(standardInput);

File.WriteAllText(logFileName, content.ToString());

Observed results:

After making a simple commit to the calculator project:

  • The hooks-trace.log file was created.
  • Three log files were created by three different hooks invoked by Git.

Contents of the hooks-trace.log file:

The three hooks invoked by Git during commit, in order:

  1. pre-commit
  2. prepare-commit-msg
  3. commit-msg

This confirms the order of execution of hooks during the commit workflow described in Basic Concepts.

2.5 Demo: The pre-commit hook

Hook description:

The pre-commit hook is the first hook invoked by Git during the commit operation. It receives a snapshot of the changes that will be committed to Git. It is typically used to perform various code checks and validations, or even to apply code styles.

Use case in the demo:

The trainer will use this hook to execute the test cases and ensure that all pass before the commit is written to the Git repository.

Implementation:

When this hook is executed by Git, it will in turn execute the dotnet test command and configure this command to display the results in a folder called TestResults:

#!/bin/bash
# Hook pre-commit : exécution des tests avant le commit

dotnet test --results-directory TestResults

# Si dotnet test se termine avec un code de sortie non-zéro
# (certains cas de test ont échoué), on sort avec le même code
# ce qui annulera le processus de commit
exit $?
// Implémentation en C# (dans l'application .NET de hooks)
private static int HandlePreCommit()
{
    var result = RunProcess("dotnet", "test --results-directory TestResults");
    return result.ExitCode; // Si non-0, annule le commit
}

Expected behavior:

  • If the dotnet test command exits with a non-zero exit code (some test cases failed), the hook will also exit with the same exit code, which will in effect abort the commit process.
  • If all tests pass (exit code 0), the commit continues normally.

Demo scenario:

  1. The trainer added a test case for a non-existent feature — so this test is expected to fail.
  2. When attempting to make a commit, the pre-commit hook will execute the test cases.
  3. Test case fails → commit command also fails → changes remain staged.

The --no-verify switch:

In some cases, we want to separate commits that add test cases from commits that implement them. To bypass the pre-commit hook in these situations:

git commit -m "Add failing test case" --no-verify

The --no-verify switch tells Git to bypass the pre-commit hook.

Important note: The --no-verify switch also bypasses the commit-msg hook. However, the prepare-commit-msg hook is unaffected by this switch.

Summary of behaviors with --no-verify:

HookAffected by --no-verify?
pre-commit✅ Yes, bypassed
prepare-commit-msg❌ No, executed anyway
commit-msg✅ Yes, bypassed
post-commit❌ No, executed anyway

2.6 Demo: The prepare-commit-msg hook

Hook description:

The prepare-commit-msg hook is the second hook invoked by Git during the commit process. It is used to populate the commit message file with a default message that will be presented to the committer.

Arguments received by the hook:

By examining the log file generated by this hook, we see that Git passes two command line arguments to it:

  1. First argument: A file path to a file containing the commit message (the COMMIT_EDITMSG file).
  2. Second argument: A string value that indicates the source of the commit message inside the file.
  • In case the message is specified as a parameter of the git commit command, the value will be message.

When is it useful?

In cases where the git commit command is called without specifying a commit message, the prepare-commit-msg hook can be used to populate the commit message file with a default message that will be presented to the committer.

Implementation in the demo:

The trainer defines a simple mapping between users’ emails and their active task descriptions:

// Mapping email → tâche active
private static readonly Dictionary<string, string> UserTaskMapping = new()
{
    { "timothy@example.com", "[TASK-123] Implement subtraction feature" },
    { "alice@example.com",   "[TASK-456] Fix division by zero bug" },
    { "bob@example.com",     "[TASK-789] Add unit tests for multiplication" }
};

private static int HandlePrepareCommitMsg(string commitEditMsgFile, string source)
{
    // Récupérer l'email de l'auteur depuis les variables d'environnement
    var authorEmail = Environment.GetEnvironmentVariable("GIT_AUTHOR_EMAIL")
                   ?? Environment.GetEnvironmentVariable("GIT_COMMITTER_EMAIL")
                   ?? string.Empty;

    // Chercher la description de la tâche active
    if (UserTaskMapping.TryGetValue(authorEmail, out var taskDescription))
    {
        // Populer le fichier commitEditMsg avec la description de la tâche
        File.WriteAllText(commitEditMsgFile, taskDescription);
    }

    return 0; // Succès - ne pas annuler le commit
}

Environment variables available for this hook:

GIT_AUTHOR_NAME      - Nom de l'auteur du commit
GIT_AUTHOR_EMAIL     - Email de l'auteur du commit
GIT_COMMITTER_NAME   - Nom du committer
GIT_COMMITTER_EMAIL  - Email du committer
GIT_AUTHOR_DATE      - Date de l'auteur
GIT_COMMITTER_DATE   - Date du committer

Demo progress:

  1. The functionality to pass the test case has been implemented.
  2. When running git commit (without a message), Git prompts for the commit message.
  3. The default commit message is now populated by the value provided by the prepare-commit-msg hook.
  4. The instructor accepts this message by default because it correctly describes the changes made.
  5. After saving and closing the editor, the commit completes successfully.
# Appel sans message → le hook prepare-commit-msg entre en jeu
git commit

# L'éditeur s'ouvre avec le message pré-rempli :
# [TASK-123] Implement subtraction feature
# ~
# # Please enter the commit message for your changes. Lines starting
# # with '#' will be ignored, and an empty message aborts the commit.

2.7 Demo: The commit-msg hook

Hook description:

The commit-msg hook is the last hook invoked by Git before the Git command completes successfully. It is used to validate the final commit message.

Arguments received by the hook:

By examining the log file generated by this hook, we see that Git passes it a single command line argument: the path to the same COMMIT_EDITMSG file that was passed to the prepare-commit-msg hook.

At this point, this file contains the final version of the commit message that will be added to the commit before it is written to the Git repository.

Use case in the demo:

The formatter will use the commit-msg hook to ensure that the commit message contains a valid task number, because one of the project standards is that all commit messages must have valid task IDs.

Implementation:

private static int HandleCommitMsg(string commitEditMsgFile)
{
    var commitMessage = File.ReadAllText(commitEditMsgFile);

    // Vérifier que le message contient un numéro de tâche valide
    // Format attendu : [TASK-XXXX] ou #XXXX
    var taskNumberPattern = new Regex(@"\[TASK-\d+\]|#\d+");

    if (!taskNumberPattern.IsMatch(commitMessage))
    {
        Console.Error.WriteLine("ERROR: Le message de commit doit contenir un numéro de tâche valide.");
        Console.Error.WriteLine("Format attendu : [TASK-123] ou #123");
        return 1; // Code non-0 → annuler le commit
    }

    return 0; // Validation réussie
}
# Exemple en Bash
#!/bin/bash
COMMIT_MSG_FILE=$1
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")

# Vérifier la présence d'un numéro de tâche
if ! echo "$COMMIT_MSG" | grep -qE '\[TASK-[0-9]+\]|#[0-9]+'; then
    echo "ERROR: Le message de commit doit contenir un numéro de tâche valide."
    echo "Format attendu : [TASK-123] ou #123"
    exit 1
fi

exit 0

Demo progress:

  1. The formatter has a new commit to make.
  2. It executes git commit and accepts the default commit message provided by the prepare-commit-msg hook.
  3. However, it does not include any task ID in this commit message.
  4. Result: The commit-msg hook rejects the commit → the commit is rolled back.
  5. When checking the changes, we see that they are still staged (the commit has not taken place).
$ git commit
# L'éditeur s'ouvre avec le message par défaut...
# (le formateur sauvegarde sans ajouter d'ID de tâche)

error: Le message de commit doit contenir un numéro de tâche valide.
Format attendu : [TASK-123] ou #123

$ git status
On branch master
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        modified:   Calculator.cs
# Les changements sont toujours stagés - le commit a été annulé

Full flow of three commit hooks:

git commit
    ↓
[pre-commit]
    → Exécute dotnet test
    → Si tests échouent : exit(1) → COMMIT ANNULÉ
    → Si tests passent : exit(0) → continue
    ↓
[prepare-commit-msg] (commitEditMsgFile, source)
    → Lit GIT_AUTHOR_EMAIL
    → Recherche la tâche active correspondante
    → Écrit le message par défaut dans commitEditMsgFile
    → exit(0) → continue
    ↓
[Éditeur présenté au committer]
    ↓
[commit-msg] (commitEditMsgFile)
    → Lit le contenu final du commitEditMsgFile
    → Vérifie la présence d'un numéro de tâche
    → Si absent : exit(1) → COMMIT ANNULÉ
    → Si présent : exit(0) → COMMIT RÉUSSI

2.8 Demo: The post-checkout hook

Hook description:

The post-checkout hook is a hook invoked by Git in several situations:

  • When repositories are cloned (git clone)
  • When files are checked out (git checkout <file>)
  • When we switch from one branch to another (git checkout <branch> or git switch <branch>)

Problem identified in the project:

As the formatter makes commits to its Git repository, more and more files accumulate:

  • Log files generated by hooks.
  • Test results files generated by the dotnet test command.

This disorder is particularly problematic when changing branches, because we want:

  • Distinguish which test result logs were generated on which branch.
  • Distinguish which hook log files were generated on which branch.

Use case in the demo:

The trainer will use the post-checkout hook to automatically clean these temporary files at each branch change.

Implementation:

private static int HandlePostCheckout(string previousHead, string newHead, bool isBranchCheckout)
{
    if (isBranchCheckout)
    {
        // Supprimer les fichiers de log générés par les hooks liés au commit
        foreach (var logFile in Directory.GetFiles(".", "*.log"))
        {
            File.Delete(logFile);
        }

        // Supprimer le dossier TestResults
        if (Directory.Exists("TestResults"))
        {
            Directory.Delete("TestResults", recursive: true);
        }

        // Supprimer le fichier hooks-trace.log
        if (File.Exists("hooks-trace.log"))
        {
            File.Delete("hooks-trace.log");
        }
    }

    return 0;
}
#!/bin/bash
# Hook post-checkout
PREVIOUS_HEAD=$1
NEW_HEAD=$2
IS_BRANCH_CHECKOUT=$3  # 1 = changement de branche, 0 = checkout de fichier

if [ "$IS_BRANCH_CHECKOUT" = "1" ]; then
    # Supprimer les fichiers de log des hooks
    rm -f *.log
    
    # Supprimer le dossier TestResults
    rm -rf TestResults/
    
    # Supprimer le fichier de trace des hooks
    rm -f hooks-trace.log
    
    echo "Nettoyage effectué lors du changement de branche."
fi

exit 0

Arguments received by the post-checkout hook:

PositionDescription
$1The SHA of the previous HEAD (before checkout)
$2The SHA of the new HEAD (after checkout)
$31 if it’s a branch change, 0 if it’s a file checkout

Demo result:

By moving to another branch, we see that:

  • The TestResults folder has been deleted.
  • The other log files there were also deleted.
$ git checkout feature-branch
Switched to branch 'feature-branch'
Nettoyage effectué lors du changement de branche.

$ ls
Calculator.cs
Calculator.Tests/
# Plus de fichiers de log ni de dossier TestResults !

2.9 Demo: The pre-rebase hook

Hook description:

The pre-rebase hook is invoked by Git each time branches are rebased (git rebase).

Context and motivation:

Rebase operations rewrite source control history. In general, they are considered dangerous operations, particularly in a team context where several developers are working on the same repository.

Use case in the demo:

The trainer will use this hook to prevent any rebase operation on the branches of his Git repository.

Implementation:

private static int HandlePreRebase(string upstreamBranch, string rebasingBranch)
{
    Console.Error.WriteLine($"ERROR: Les opérations de rebase sont interdites sur ce dépôt.");
    Console.Error.WriteLine($"Tentative de rebase sur la branche : {upstreamBranch}");
    Console.Error.WriteLine("Utilisez git merge à la place.");
    return 1; // Code non-0 → annuler le rebase
}
#!/bin/bash
# Hook pre-rebase - Interdire toutes les opérations de rebase
UPSTREAM_BRANCH=$1
REBASING_BRANCH=$2

echo "ERROR: Les opérations de rebase sont interdites sur ce dépôt."
echo "Branche upstream : $UPSTREAM_BRANCH"
echo "Utilisez 'git merge' à la place du rebase."
exit 1

Arguments received by the pre-rebase hook:

PositionDescription
$1The name of the upstream branch on which we are trying to rebase
$2The name of the branch being rebased (can be empty)

Demo progress:

  1. The formatter attempts to rebase the master branch on one of its other branches.
  2. The pre-rebase hook immediately rejects the operation and rolls back.
  3. By examining the log file generated by the pre-rebase hook, we can see that Git passed as a command line argument the name of the branch on which we were trying to rebase.
$ git rebase feature-branch
error: Les opérations de rebase sont interdites sur ce dépôt.
Branche upstream : feature-branch
Utilisez 'git merge' à la place du rebase.

$ cat pre-rebase.log
=== Command Line Arguments ===
feature-branch
# Git a passé le nom de la branche comme argument

Note: Unlike the pre-commit hook which can be bypassed with --no-verify, the pre-rebase hook cannot be bypassed as easily by a standard Git switch. This feature makes it more robust for enforcing client-side policies.

2.10 Module Summary

This module covered the following topics:

  1. Basic concepts of Git Hooks: Definition, types, and characteristics.
  2. Client-side hooks related to commit:
  • pre-commit: Execution of tests before commit.
  • prepare-commit-msg: Automatic population of the commit message.
  • commit-msg: Validation of the commit message format.
  1. Additional client-side hooks:
  • post-checkout: Cleaning temporary files when changing branches.
  • pre-rebase: Prohibition of rebase operations.

Fundamental takeaway of module:

client-side hooks are under the full control of the developer, and therefore cannot be used to enforce repository policies. This is why we need server-side hooks, which we will talk about in the next module.


3. Server side Hooks

3.1 Module overview

Welcome to the second module on Git Hooks. In this module, we will talk about server-side hooks. We’ll look at some of the server-side hooks that will be invoked by Git during a git push operation, and then we’ll look at how to use these hooks to enforce server-side policies on our Git repository.

Hooks covered in this module:

HookSummoning TimeTypical usage
pre-receiveBefore accepting pushed commitsGlobal commits on all commits/branches
updateFor each updated branchBranch-specific validations
post-receiveAfter accepting all commitsNotifications, CI/CD, deployments

3.2 Demo: First look at server-side hooks

In this demo, we’ll take a first look at server-side hooks, doing something similar to what we did with client-side hooks.

Initial configuration:

After installing the hooks on the server repository, the formatter pushes all its branches. By examining the contents of the server repository folder:

server-repo/
├── hooks-trace.log          ← fichier de trace généré
├── pre-receive.log          ← log du hook pre-receive
├── update-branch1.log       ← log du hook update (branche 1)
├── update-branch2.log       ← log du hook update (branche 2)
└── post-receive.log         ← log du hook post-receive

Execution order of server-side hooks:

By examining the contents of the hooks-trace.log file, the execution order is confirmed:

1. pre-receive   → Invoqué une seule fois pour toutes les branches
2. update        → Invoqué pour chaque branche mise à jour (2 fois dans cet exemple)
3. post-receive  → Invoqué une seule fois après toutes les mises à jour

What each hook receives:

Hook pre-receive — via stdin:

The log file for the pre-receive hook shows that it received two lines in the Standard Input — one line per branch pushed from the client side:

<ancien-SHA> <nouveau-SHA> refs/heads/master
<ancien-SHA> <nouveau-SHA> refs/heads/feature-branch

Each line contains:

  • The old SHA (state before push)
  • The new SHA (state after push)
  • The reference name (branch name)

Hook update — via command line arguments:

The first log file of the update hook shows that it received via command line arguments the same data as in the pre-receive log file, but for the first branch only. The second log file of the update hook contains the second line of the pre-receive file.

# Arguments reçus par le hook update pour chaque branche :
# $1 = nom de la référence (ex: refs/heads/master)
# $2 = ancien SHA
# $3 = nouveau SHA

Hook post-receive — via stdin:

The post-receive hook log file contains the same data as the pre-receive hook (all branches, via stdin).

3.3 Demo: The pre-receive hook

Hook description:

As seen in the previous demo, the pre-receive hook receives in its standard input a line for each branch updated by the client. For each branch, it also receives the range of commits that are pushed by the client.

This is therefore a very suitable place to perform global validations and checks on all branches updated by clients.

Use case in the demo:

The trainer will use this hook to enforce a global policy: any commit pushed to any branch must have a task number in its message.

Implementation:

private static int HandlePreReceive()
{
    var stdin = Console.In;
    string? line;

    while ((line = stdin.ReadLine()) != null)
    {
        // Chaque ligne correspond à une branche mise à jour
        var parts = line.Split(' ');
        var oldSha = parts[0];
        var newSha = parts[1];
        var refName = parts[2]; // ex: refs/heads/master

        // Scanner la plage de commits poussés pour cette branche
        var commitRange = $"{oldSha}..{newSha}";
        var commits = GetCommitsInRange(commitRange);

        foreach (var commit in commits)
        {
            var commitMessage = GetCommitMessage(commit);

            // Vérifier la présence d'un numéro de tâche
            if (!HasTaskNumber(commitMessage))
            {
                Console.Error.WriteLine($"ERROR: Le commit {commit} n'a pas de numéro de tâche.");
                Console.Error.WriteLine($"Message : {commitMessage}");
                return 1; // Annuler le push
            }
        }
    }

    return 0; // Tous les commits sont valides
}

private static bool HasTaskNumber(string message)
{
    return Regex.IsMatch(message, @"\[TASK-\d+\]|#\d+");
}

private static IEnumerable<string> GetCommitsInRange(string range)
{
    var result = RunProcess("git", $"log {range} --format=%H");
    return result.Output.Split('\n', StringSplitOptions.RemoveEmptyEntries);
}

private static string GetCommitMessage(string sha)
{
    var result = RunProcess("git", $"log -1 --format=%s {sha}");
    return result.Output.Trim();
}
#!/bin/bash
# Hook pre-receive - Validation globale des messages de commit

while read oldSHA newSHA refName; do
    # Pour chaque branche mise à jour
    branchName=${refName#refs/heads/}
    
    # Scanner tous les commits dans la plage poussée
    git log "$oldSHA..$newSHA" --format="%H %s" | while read sha message; do
        # Vérifier la présence d'un numéro de tâche
        if ! echo "$message" | grep -qE '\[TASK-[0-9]+\]|#[0-9]+'; then
            echo "ERROR: Le commit $sha n'a pas de numéro de tâche valide."
            echo "Message : $message"
            echo "Tous les commits doivent contenir un numéro de tâche [TASK-XXX]."
            exit 1
        fi
    done
    
    # Vérifier le code de sortie du sous-shell
    if [ $? -ne 0 ]; then
        exit 1
    fi
done

exit 0

Demo progress:

  1. In the calculator project, the trainer merged the subtraction branch into the master branch.
  2. As part of this merge, a few commits were added to the master branch without task number.
  3. When attempting to push the master branch, the pre-receive hook rejects the push operation because one of the commits does not have a task number in its message.
$ git push origin master
Counting objects: 5, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (3/3), done.
Writing objects: 100% (3/3), 320 bytes | 320.00 KiB/s, done.
Total 3 (delta 2), reused 0 (delta 0)
remote: ERROR: Le commit abc1234 n'a pas de numéro de tâche valide.
remote: Message : Add subtraction functionality
remote: Tous les commits doivent contenir un numéro de tâche [TASK-XXX].
To server:/path/to/repo.git
 ! [remote rejected] master -> master (pre-receive hook declined)
error: failed to push some refs to 'server:/path/to/repo.git'

Special case — New deposit:

During a first push to an empty repository, the old SHA will consist of all zeros (000000000000000000000000000000000000000000). This particular case must be handled in the implementation:

# Si oldSHA est tout en zéros, c'est un nouveau dépôt
if [ "$oldSHA" = "0000000000000000000000000000000000000000" ]; then
    # Scanner depuis le début de l'historique
    commitRange="$newSHA"
    git log "$commitRange" --format="%H %s" | while read sha message; do
        # ...validation...
    done
else
    commitRange="$oldSHA..$newSHA"
    # ...scanning normal...
fi

3.4 Demo: The update hook

Hook description:

The update hook is the second hook invoked by Git during the push operation. As seen in the previous demo, this hook is invoked for each branch updated by the push operation.

Arguments received:

On each invocation, the update hook receives in its command line arguments:

  1. The updated branch name
  2. The starting SHA of the pushed commit range (old SHA)
  3. The trailing SHA of the pushed commit range (new SHA)

So this is the best place to do branch-specific commits and checks before accepting pushed commits.

Use case in the demo:

The trainer will use this hook to implement branch access controls on the server — allowing for each branch only a subset of users to push commits.

Implementation — Access Control List (ACL):

// Définition de l'ACL (Access Control List)
private static readonly Dictionary<string, List<string>> BranchAccessControl = new()
{
    // Seul Timothy peut pousser des commits sur master
    { "refs/heads/master", new List<string> { "timothy@example.com" } },

    // Alice et Bob peuvent pousser sur la branche develop
    { "refs/heads/develop", new List<string> { "alice@example.com", "bob@example.com" } },

    // N'importe qui peut pousser sur les branches feature
    // (non spécifié = pas de restriction)
};

private static int HandleUpdate(string refName, string oldSha, string newSha)
{
    // Vérifier si la branche est dans l'ACL
    if (!BranchAccessControl.TryGetValue(refName, out var allowedUsers))
    {
        // Branche non listée dans l'ACL → pas de restriction
        return 0;
    }

    // Scanner les commits dans la plage poussée
    var commitRange = $"{oldSha}..{newSha}";
    var commits = GetCommitsInRange(commitRange);

    foreach (var commit in commits)
    {
        var authorEmail = GetCommitAuthorEmail(commit);

        // Vérifier que l'email de l'auteur est dans la liste des utilisateurs autorisés
        if (!allowedUsers.Contains(authorEmail))
        {
            Console.Error.WriteLine($"ERROR: L'utilisateur {authorEmail} n'est pas autorisé");
            Console.Error.WriteLine($"       à pousser des commits sur la branche {refName}.");
            Console.Error.WriteLine($"Utilisateurs autorisés : {string.Join(", ", allowedUsers)}");
            return 1; // Rejeter le push
        }
    }

    return 0; // Push autorisé
}

private static string GetCommitAuthorEmail(string sha)
{
    var result = RunProcess("git", $"log -1 --format=%ae {sha}");
    return result.Output.Trim();
}
#!/bin/bash
# Hook update - Contrôle d'accès par branche

REF_NAME=$1
OLD_SHA=$2
NEW_SHA=$3

# ACL - Définir les branches et leurs utilisateurs autorisés
declare -A BRANCH_ACL
BRANCH_ACL["refs/heads/master"]="timothy@example.com"
BRANCH_ACL["refs/heads/develop"]="alice@example.com bob@example.com"

# Extraire le nom court de la branche
BRANCH_SHORT=${REF_NAME#refs/heads/}

# Vérifier si la branche est dans l'ACL
if [ -z "${BRANCH_ACL[$REF_NAME]}" ]; then
    # Branche non restreinte
    exit 0
fi

ALLOWED_USERS="${BRANCH_ACL[$REF_NAME]}"

# Scanner les commits dans la plage poussée
git log "$OLD_SHA..$NEW_SHA" --format="%H" | while read COMMIT_SHA; do
    AUTHOR_EMAIL=$(git log -1 --format="%ae" "$COMMIT_SHA")
    
    # Vérifier si l'auteur est autorisé
    if ! echo "$ALLOWED_USERS" | grep -qw "$AUTHOR_EMAIL"; then
        echo "ERROR: $AUTHOR_EMAIL n'est pas autorisé à pousser sur $BRANCH_SHORT."
        echo "Utilisateurs autorisés : $ALLOWED_USERS"
        exit 1
    fi
done

exit 0

Demo progress:

  1. Formatter has a new commit to push.
  2. Looking at this commit, the author email of this commit is not the trainer email.
  3. When attempting to push this commit to the master branch on the server, the update hook rejects the push because the email in the commit is not allowed to push commits to the master branch.
$ git push origin master
Counting objects: 3, done.
Writing objects: 100% (3/3), 285 bytes | 285.00 KiB/s, done.
remote: ERROR: other.user@example.com n'est pas autorisé à pousser sur master.
remote: Utilisateurs autorisés : timothy@example.com
To server:/path/to/repo.git
 ! [remote rejected] master -> master (hook declined)
error: failed to push some refs to 'server:/path/to/repo.git'

Difference between pre-receive and update:

Characteristicpre-receiveupdate
Summon FrequencyOnly once for all refsOnce per ref (per branch)
Data receivedstdin (all branches)Command line arguments (one branch)
Best Useglobal policiesbranch-specific policies
PowerCan reject everything in one outputCan selectively reject by branch

3.5 Demo: The post-receive hook

Hook description:

The post-receive hook is the last hook executed by Git during the push operation. As seen in the previous demo, this hook receives the same data as the pre-receive hook (via stdin).

Fundamental difference with pre- hooks:

Unlike the pre-receive and update hooks, the formatter will not use this information to perform validations or checks. It will simply take advantage of the fact that this hook is invoked at the end of the push operation to implement a simple continuous integration (CI) functionality in its server-side Git repository.

Use case in the demo:

When this hook is executed by Git, the formatter will:

  1. Clone the server-side repository into a workspace folder.
  2. Run a dotnet build on the server side.
private static int HandlePostReceive()
{
    var workspacePath = Path.Combine(Path.GetTempPath(), "calculator-build");

    // Nettoyer le workspace précédent si existant
    if (Directory.Exists(workspacePath))
    {
        Directory.Delete(workspacePath, recursive: true);
    }

    // Cloner le dépôt serveur dans le workspace
    var serverRepoPath = Environment.GetEnvironmentVariable("GIT_DIR") ?? ".";
    var cloneResult = RunProcess("git", $"clone {serverRepoPath} {workspacePath}");

    if (cloneResult.ExitCode != 0)
    {
        Console.Error.WriteLine("ERROR: Le clonage du dépôt a échoué.");
        return 0; // post-receive ne peut pas annuler le push
    }

    Console.WriteLine($"Dépôt cloné dans : {workspacePath}");

    // Exécuter dotnet build
    var buildResult = RunProcess("dotnet", "build", workingDirectory: workspacePath);

    if (buildResult.ExitCode != 0)
    {
        Console.Error.WriteLine("WARNING: Le build a échoué !");
    }
    else
    {
        Console.WriteLine("Build réussi ! L'application est prête à être déployée.");
    }

    return 0; // Le code de sortie n'affecte pas le push
}
#!/bin/bash
# Hook post-receive - Intégration continue simple

WORKSPACE_PATH="/tmp/calculator-build"
SERVER_REPO_PATH="$GIT_DIR"

# Nettoyer le workspace précédent
rm -rf "$WORKSPACE_PATH"

# Cloner le dépôt serveur
echo "--- CI: Clonage du dépôt ---"
git clone "$SERVER_REPO_PATH" "$WORKSPACE_PATH"

if [ $? -ne 0 ]; then
    echo "ERROR: Le clonage a échoué."
    exit 0  # Ne pas annuler le push (post-receive ne le peut pas)
fi

echo "--- CI: Build de l'application ---"
cd "$WORKSPACE_PATH"
dotnet build

if [ $? -ne 0 ]; then
    echo "WARNING: Le build a échoué !"
else
    echo "--- CI: Build réussi ! ---"
    echo "L'application est disponible dans : $WORKSPACE_PATH/bin/"
fi

exit 0

Important: Even if the build fails, the post-receive hook cannot cancel the push — the commits have already been accepted. This is why important validations should always be done in the pre-receive or update hooks.

Demo progress:

  1. The trainer has one last commit to make to make his calculator usable from the command line.
  2. By pushing this commit to the server, the post-receive hook:
  • Complete repository cloning.
  • Run build on server.
  1. Going to the build location and checking the output, the calculator was built successfully.
  2. When I tried it, it works correctly.
$ git push origin master
Counting objects: 4, done.
Writing objects: 100% (4/4), 450 bytes | 450.00 KiB/s, done.
remote: --- CI: Clonage du dépôt ---
remote: Clonage dans '/tmp/calculator-build'...
remote: --- CI: Build de l'application ---
remote: Build succeeded.
remote:     0 Warning(s)
remote:     0 Error(s)
remote: --- CI: Build réussi ! ---
remote: L'application est disponible dans : /tmp/calculator-build/bin/
To server:/path/to/repo.git
   abc1234..def5678  master -> master

$ /tmp/calculator-build/bin/Calculator 5 + 3
= 8

$ /tmp/calculator-build/bin/Calculator 10 - 4
= 6

Overview of the complete workflow during a git push:

git push origin master
     │
     ▼
[pre-receive]
     │  → Reçoit via stdin : toutes les branches poussées
     │  → Validation globale : numéros de tâche dans les commits
     │  → Si échec : exit(1) → PUSH REJETÉ pour toutes les branches
     │
     ▼ (si exit code = 0)
[update] × N (une fois par branche)
     │  → Reçoit via args : nom de branche, ancien SHA, nouveau SHA
     │  → Validation par branche : ACL (contrôle d'accès)
     │  → Si échec : exit(1) → PUSH REJETÉ pour cette branche
     │
     ▼ (si exit code = 0)
    [Les commits sont acceptés dans le dépôt serveur]
     │
     ▼
[post-receive]
     │  → Reçoit via stdin : toutes les branches (comme pre-receive)
     │  → Pas de validation - seulement des actions post-push
     │  → Ex: clone + build (CI), notifications, déploiements
     │  → Exit code : N'AFFECTE PAS le push (déjà accepté)

3.6 Course Summary

In this course we talked about Git Hooks. Here are the key points covered:

1. Basic concepts:

  • Setting Git Hooks as extensibility points
  • Their types (pre-, post-, miscellaneous)
  • Their characteristics (executables, primitive communication, neutrality towards IDEs)

2. Client-side hooks:

  • These hooks can only be used for developer productivity.
  • They cannot be used to enforce team-wide repository policies.

3. Server-side hooks:

  • These hooks allow enforcing repository policies on the server side.
  • They can also be used to implement other server-side aspects of the Git repository, such as continuous integration.

Hooks covered in this course:

HookTypeCategoryDemonstrated use
pre-commitpre-CustomerRunning Tests Before Commit
prepare-commit-msgmiscellaneousCustomerAutomatic commit message population
commit-msgmiscellaneousCustomerValidating commit message format
post-checkoutpostCustomerCleaning temporary files
pre-rebasepre-CustomerProhibition of rebase operations
pre-receivepre-ServerGlobal validation of commits (task numbers)
updatemiscellaneousServerBranch Access Control (ACL)
post-receivepostServerContinuous integration (clone + build)

To obtain a copy of the Git Hook project developed throughout this course, visit the project site on GitHub.


4. Hooks Quick Reference

Client-side hooks

HookArgumentsstdinCan cancel?Bypassable by --no-verify
pre-commitNoneNone✅ Yes (exit ≠ 0)✅ Yes
prepare-commit-msg<msg-file> [<source> [<sha1>]]None✅ Yes (exit ≠ 0)❌ No
commit-msg<msg-file>None✅ Yes (exit ≠ 0)✅ Yes
post-commitNoneNone❌ No❌ No (already done)

Other client-side hooks

HookArgumentsstdinCan cancel?
pre-checkoutNoneNoneN/A
post-checkout<old-HEAD> <new-HEAD> <branch-flag>None❌ No
pre-rebase<upstream> [<branch>]None✅ Yes (exit ≠ 0)
pre-push<remote-name> <remote-url>Push Refs✅ Yes (exit ≠ 0)
pre-merge-commitNoneNone✅ Yes (exit ≠ 0)
post-merge<flag-squash>None❌ No

Server-side hooks

HookArgumentsstdinCan cancel the push?Frequency
pre-receiveNone<old-SHA> <new-SHA> <ref> (one line/branch)✅ Yes (exit ≠ 0)Once per push
update<ref> <old-SHA> <new-SHA>None✅ Yes (exit ≠ 0)Once per ref
post-receiveNone<old-SHA> <new-SHA> <ref> (one line/branch)❌ NoOnce per push
post-update<ref1> [<ref2>...]None❌ NoOnce per push

Hook location

# Dans un dépôt Git local (client-side)
<racine-du-dépôt>/.git/hooks/

# Dans un dépôt Git bare (server-side)
<racine-du-dépôt>/hooks/

# Templates Git (source par défaut des hooks)
/usr/share/git-core/templates/hooks/    # Linux/macOS
C:\Program Files\Git\share\git-core\templates\hooks\    # Windows

Enable a .sample hook

# Renommer le fichier en supprimant l'extension .sample
mv .git/hooks/pre-commit.sample .git/hooks/pre-commit

# Rendre le script exécutable (Linux/macOS)
chmod +x .git/hooks/pre-commit

# Vérifier que le hook est exécutable
ls -la .git/hooks/pre-commit

Minimal structure of a shell hook

#!/bin/bash
# Hook : pre-commit (exemple minimal)

# Votre logique ici...
echo "Hook pre-commit exécuté"

# Sortir avec 0 pour le succès, non-0 pour l'échec/annulation
exit 0

Debug hooks

# Ajouter des logs dans le hook
#!/bin/bash
exec 1>/tmp/hook-debug.log 2>&1
set -x  # Afficher chaque commande exécutée

echo "Arguments : $@"
echo "Variables d'environnement liées à Git :"
env | grep GIT_

# Votre logique...
exit 0

5. Best practices

For client-side hooks

  1. Never block legitimate workflows without a workaround option: The --no-verify switch is intentionally available for cases where the developer knows what he is doing (eg: committing a failed test to work on it).

  2. Keep hooks fast: A slow pre-commit hook discourages developers from making frequent commits. If the tests take too long, only run tests related to modified files.

  3. Provide clear error messages: When a hook rejects an action, clearly explain why and how to correct the problem.

  4. Document project hooks: Hooks are not versioned by default in .git/hooks/. It is recommended to place them in a versioned folder (eg: scripts/hooks/) and to provide an installation script.

    # scripts/install-hooks.sh
    #!/bin/bash
    HOOKS_DIR=".git/hooks"
    SOURCE_DIR="scripts/hooks"
    
    for hook in "$SOURCE_DIR"/*; do
        hook_name=$(basename "$hook")
        ln -sf "../../$SOURCE_DIR/$hook_name" "$HOOKS_DIR/$hook_name"
        chmod +x "$HOOKS_DIR/$hook_name"
        echo "Hook installé : $hook_name"
    done
    
  5. Share hooks via dedicated tools: Tools like husky (for Node.js projects) or pre-commit (for Python) simplify the distribution and installation of hooks within the team.

For server-side hooks

  1. Commit early with pre-receive: Reject invalid pushes as early as possible, before accepting commits.

  2. Use update for per-branch policies: The update hook is perfect for branch-specific validations (ACLs, branch name formats, etc.).

  3. Reserve post-receive for non-critical actions: Since this hook cannot cancel the push, use it only for actions that can fail without impacting the integrity of the repository (notifications, CI, deployments).

  4. Handle the case of a new deposit: When the previous SHA is 00000000000000000000000000000000000000000, this is a first push. Ensure that the validation logic handles this case correctly.

  5. Test hooks in isolation: Before deploying a hook on the server, test it locally by simulating the arguments and stdin that Git would pass to it.

    # Simuler l'appel au hook pre-receive
    echo "0000000 abc1234 refs/heads/master" | .git/hooks/pre-receive
    
    # Simuler l'appel au hook update
    .git/hooks/update refs/heads/master 0000000 abc1234
    
projet/
├── .git/
│   └── hooks/      ← Hooks installés (non versionnés)
├── scripts/
│   ├── hooks/      ← Sources des hooks (versionnés)
│   │   ├── pre-commit
│   │   ├── commit-msg
│   │   ├── prepare-commit-msg
│   │   └── pre-push
│   └── install-hooks.sh    ← Script d'installation
├── README.md
│   └── # Section : Configuration du développement
│       # 1. Installer les hooks Git : bash scripts/install-hooks.sh
└── ...


Search Terms

git · hooks · ci/cd · devops · hook · client-side · server-side · commit · side

Interested in this course?

Contact us to book it or get a custom training plan for your team.