Git Hooks

TL;DR: Git Hooks are custom scripts that Git automatically executes before or after crucial actions like committing, merging, or pushing code. By placing scripts inside the .git/hooks/ directory, you can intercept these events to run linters, validate commit messages, or trigger test suites, aborting operations if the scripts fail.


A common challenge in software teams is maintaining code consistency and quality. Developers commit syntax errors, forget to format their files, or write cryptic commit messages. While continuous integration (CI) pipelines catch these issues on the server, waiting for a pipeline to run, fail, and notify you is a slow feedback loop.

What if you could intercept developer actions locally and check the code before a commit or push is even completed?

That is exactly what Git Hooks do. Git hooks are event-driven scripts that live inside your .git folder. When specific events trigger—like writing a commit message, checking out a branch, or pushing to a server—Git pauses its operations, executes your script, and continues or aborts the operation depending on the script’s exit status.

In this tutorial, we will look inside .git/hooks/, learn how Git runs these scripts, write custom pre-commit and commit-msg hooks, and explore modern workflows for sharing hooks across a team.


What Do Git Hooks Actually Do?

At its core, a Git hook is an interceptor. It is built on a simple lifecycle model:

graph TD
    Trigger[Git Action Triggered: e.g. git commit] --> Check{Does .git/hooks/pre-commit <br> exist & is executable?}
    Check -- No --> Proceed[Execute Git Action normally]
    Check -- Yes --> RunScript[Run pre-commit script]
    RunScript --> ExitStatus{Does script exit <br> with status 0?}
    ExitStatus -- Yes --> Proceed
    ExitStatus -- No --> Abort[Abort Git Action with error]

Script Execution and Exit Codes

When an event occurs, Git runs your script and evaluates the exit code:

  • Exit code 0 (Success): Git assumes everything is fine and proceeds with the operation.
  • Non-zero exit code (e.g., 1 through 255): Git aborts the action. The commit, merge, or push is stopped dead in its tracks.

Client-Side vs. Server-Side Hooks

Hooks are broadly categorized by where they are run.

1. Client-Side Hooks

These scripts execute on your local workstation. They help developers clean up code and validate messages before uploading them to the team.

  • Triggering Actions: Intercepting local commits (git-commit), merges (git-merge), rebases (git-rebase), or checkout actions.
  • Examples: pre-commit, prepare-commit-msg, commit-msg, pre-push.

2. Server-Side Hooks

These scripts execute on the server hosting the remote repository (e.g. self-hosted GitLab, GitHub Enterprise, or Gitea).

  • Triggering Actions: Intercepting incoming pushes (git-push).
  • Examples: pre-receive (runs before updating branch references), update (runs once per updated branch), post-receive (runs after updates, used for notifications or deployments).

Language Agnosticism

Git hooks do not have to be written in Bash. Git doesn’t care about the implementation language; it only cares about the file name and the exit code. You can write hooks in Python, Node.js, Ruby, Perl, or even compile them as binary executables in Go or Rust.

To use a specific language, you simply specify the correct shebang on the very first line of the file (e.g., #!/usr/bin/env node or #!/usr/bin/env python3).


What Happens Inside .git/ (Hands-on Exploration)

Let’s build a repository, explore the template hooks, and write a custom pre-commit script to block secret keys from getting committed.

Step 1: Initialize a Repository and Examine Samples

Let’s initialize a new repository:

$ mkdir git-hooks-demo
$ cd git-hooks-demo
$ git init
Initialized empty Git repository in /path/to/git-hooks-demo/.git/

Now let’s list the files inside .git/hooks/:

$ ls -F .git/hooks/
applypatch-msg.sample      pre-applypatch.sample      pre-rebase.sample
commit-msg.sample          pre-commit.sample          prepare-commit-msg.sample
fsmonitor-watchman.sample  pre-merge-commit.sample    push-to-checkout.sample
post-update.sample         pre-push.sample            update.sample

Every hook file has the extension .sample. Git ignores these files. They are provided as template references to show you what arguments each hook receives.

Step 2: Create a Custom Secret Detector Hook (pre-commit)

We want to create a pre-commit hook that scans the staged code for hardcoded API keys or private keys (e.g. patterns like AWS_SECRET_KEY or BEGIN PRIVATE KEY) and aborts the commit if found.

Create a new file in .git/hooks/pre-commit without any extension:

$ touch .git/hooks/pre-commit

Open the file in your text editor and write the following Bash script:

#!/bin/bash
# File: .git/hooks/pre-commit

echo "--- Running Pre-Commit Security Checks ---"

# Look for patterns of secrets in files that are staged (cached) for commit
SECRETS_FOUND=$(git diff --cached --name-only | xargs grep -E "AWS_SECRET|API_KEY|BEGIN PRIVATE KEY" 2>/dev/null)

if [ ! -z "$SECRETS_FOUND" ]; then
    echo -e "\e[31m[SECURITY ALERT] Possible secret key detected in staged code:\e[0m"
    echo "$SECRETS_FOUND"
    echo -e "\e[31mCommit rejected! Please remove the secret key or update your config.\e[0m"
    exit 1 # Non-zero exit code aborts the commit
fi

echo "Security check passed."
exit 0

Step 3: Test Hook Execution Permissions

Let’s see what happens if we stage a file and try to commit it before marking the script as executable.

$ echo "my secret AWS_SECRET=abc123xyz" > server.config
$ git add server.config
$ git commit -m "Add server configurations"
[main (root-commit) 8c474d2] Add server configurations
 1 file changed, 1 insertion(+)
 create mode 100644 server.config

The commit succeeded! The secret key was added.

Why did the hook fail to run? Because Git requires hooks to be marked explicitly as executable files on Unix-like filesystems. If the file is not executable, Git silently ignores it.

Step 4: Activating the Hook

Let’s reset the last commit, keep the changes in our stage, and give the hook executable permissions:

$ git reset --soft HEAD~1
$ chmod +x .git/hooks/pre-commit

Now, let’s try to commit the secret again:

$ git commit -m "Add server configurations"
--- Running Pre-Commit Security Checks ---
[SECURITY ALERT] Possible secret key detected in staged code:
server.config:my secret AWS_SECRET=abc123xyz
Commit rejected! Please remove the secret key or update your config.

The commit was successfully aborted! The file remains staged, but no commit object was written to the database.

Let’s edit server.config to remove the secret:

$ echo "using env configurations" > server.config
$ git add server.config
$ git commit -m "Add server configurations"
--- Running Pre-Commit Security Checks ---
Security check passed.
[main 7f2d8a1] Add server configurations
 1 file changed, 1 insertion(+)
 create mode 100644 server.config

The hook successfully analyzed the changes, allowed the clean commit to proceed, and exited cleanly.


Detailed Command Options & Advanced Usage

As you scale Git hooks across teams or build complex automation pipelines, you need to understand configuration tricks and bypass methods.

1. Version Controlling Hooks via git config

By default, Git searches for hooks inside .git/hooks/. However, the .git/ folder is private to the developer’s machine and is not pushed to remote servers. This makes it impossible to commit .git/hooks/pre-commit directly to version control.

To solve this, Git introduced the core.hooksPath configuration variable in version 2.9 (configured via git-config). This variable lets you redirect the hooks folder to a directory tracked in your repository.

Step-by-Step Configuration:

  1. Create a version-controlled hooks directory at the root of your project:
    $ mkdir .githooks
  2. Move your hooks from .git/hooks/ to .githooks/ and commit them:
    $ mv .git/hooks/pre-commit .githooks/
    $ git add .githooks/pre-commit
    $ git commit -m "Add shared pre-commit hook"
  3. Tell Git to read hooks from the new folder:
    $ git config core.hooksPath .githooks

Now, anyone who clones your repository simply needs to run that single git config command to activate all hooks in the .githooks/ directory.

2. Bypassing Local Hooks in Emergencies

Sometimes, you need to commit a work-in-progress draft or commit files that trigger false-positives on your hooks. You can tell Git to skip running local client-side hooks by using the --no-verify (or -n) flag.

$ git commit -m "WIP: bypass checks" --no-verify

Or for pushes:

$ git push origin main --no-verify

Note: You cannot bypass server-side hooks (like pre-receive or update) using this flag, as the server controls their execution.

3. Understanding Arguments and Standard Input in Hooks

Depending on the hook, Git passes information using command-line arguments or standard input (stdin).

  • commit-msg: Git passes the path to the temporary file containing the commit message (usually .git/COMMIT_EDITMSG) as the first argument ($1).
    # Check if commit message matches a regex
    MSG_FILE=$1
    MSG_CONTENT=$(cat "$MSG_FILE")
  • pre-push: Git passes the remote name ($1) and the remote URL ($2). The list of commits being pushed is sent via standard input (stdin) in the format: <local ref> <local sha> <remote ref> <remote sha> Your script can read this stream to analyze what commits are about to cross the network.

Real-World Workflow: Lint-Staged & Husky

In the Node.js ecosystem, configuring git config core.hooksPath and handling executable permissions manually on cross-platform teams (where Windows users might not preserve Unix permissions) can be brittle.

To simplify this, teams use Husky and Lint-staged:

{
  "name": "my-project",
  "devDependencies": {
    "husky": "^8.0.0",
    "lint-staged": "^13.0.0"
  },
  "scripts": {
    "prepare": "husky install"
  },
  "lint-staged": {
    "*.js": [
      "eslint --fix",
      "prettier --write"
    ]
  }
}
  1. husky install: Runs automatically during package installation (via the prepare script). It sets up a local .husky/ folder and points core.hooksPath to it.
  2. lint-staged: Instead of linting the entire 10,000-file repository on every commit (which would take minutes), lint-staged queries Git to find only the files currently in the staging index (staged via git add) and runs linting/formatting exclusively on those files. This keeps the pre-commit checks fast (under 2 seconds).

Common Pitfalls and Mistakes

1. The Missing Executable Permission (Chmod)

The Mistake: Writing a shell script, saving it as pre-commit, but having Git bypass it completely. Why it fails: On Linux and macOS, files must be marked as executable. If the file has standard permissions (e.g., -rw-r--r--), Git will ignore it. The Fix: Run chmod +x .git/hooks/[hook-name].

2. Relying on Client-Side Hooks for Security

The Mistake: Relying on a client-side pre-commit hook to prevent developers from committing bad code or credentials. Why it fails: Client-side hooks run on the user’s local machine. Any developer can run git commit --no-verify to bypass the hook completely, or modify/delete the hook file in their private .git/hooks folder. The Fix: Use client-side hooks for developer convenience, but enforce rules on the server-side using server hooks or a CI/CD build step (like GitHub Actions) that runs tests on pull requests.

3. Non-Interactive Shell Failures

The Mistake: Writing a hook that prompts the user for keyboard input (e.g. read -p "Do you want to continue? [y/n]"). Why it fails: Many developers use Git GUI clients (VS Code, GitKraken, SourceTree) or automation tools. During these runs, Git redirects standard input (stdin) to /dev/null. When the script attempts to read user input, it hangs indefinitely or crashes immediately. The Fix: Always write hooks to be fully automated. If they need to fail, write clear error messages to standard output and exit with exit 1.


Command Quick Reference

Hook NameLifecycle PhaseTypical Use Case
pre-commitRuns before commit message input.Linting, formatting checks, secrets scanning.
prepare-commit-msgRuns before commit message editor opens.Pre-populating messages with ticket numbers.
commit-msgRuns after commit message is saved.Validating commit messages (Conventional Commits).
post-commitRuns after commit is saved.Triggering desktop notifications, logging.
pre-pushRuns before objects are sent to server.Running integration test suites.
pre-receiveRuns on server before updating branches.Restricting force-pushes, enforcing permissions.
post-receiveRuns on server after branch updates.Triggering CI/CD deployments, Slack webhooks.

FAQ (People Also Ask)

How do I share Git hooks with my team?

Because the .git folder is not pushed to remote repositories, you cannot share hooks directly inside .git/hooks/. Instead, you should save your hook scripts in a tracked folder (e.g., .githooks/), commit them to version control, and set up your repository to run git config core.hooksPath .githooks locally to activate them.

Can a Git hook bypass git commit --no-verify?

No, the --no-verify flag explicitly bypasses local client-side hooks (pre-commit, prepare-commit-msg, and commit-msg). If you want to guarantee validation, you must configure server-side hooks (like pre-receive) on your Git server, which ignore local flags and cannot be bypassed.

What language can Git hooks be written in?

Git hooks can be written in any scripting language that your operating system knows how to interpret (such as Bash, Python, Ruby, Perl, Node.js) or can be compiled into a binary executable. The only requirement is that the file contains the correct path to the interpreter (the “shebang”) on the first line (e.g., #!/usr/bin/env python3).

What happens to staged files if a pre-commit hook fails?

If a pre-commit hook fails, the commit operation is aborted, but your changes remain safely in the staging area (index) and the working directory. No changes are lost. You can modify the files, run git add again, and re-commit.


Test Your Knowledge

Loading quiz…