Git Hooks Fundamentals: Local Automation
AI generated
git
HEAD
Git · Hooks · Automation · DevOps
Git Hooks Fundamentals: Local Automation
Understanding client- and server-side hooks in practice

Git hooks are scripts that Git automatically runs on certain events such as commit, push, or merge, locally inside the .git/hooks directory and server side inside a bare repository. This article shows how client and server hooks differ, how a pre-commit hook comes together step by step, how Git interprets exit codes, and how core.hooksPath finally makes hooks version-controlled across the team.

12 min. read pre-commit · pre-push · commit-msg core.hooksPath · pre-receive

1. What Git hooks are and where they live

A Git hook is an executable script that Git automatically starts as soon as a specific event occurs in the repository, such as a commit, a push, or a checkout. Technically, hooks are ordinary shell scripts or programs in any language, as long as the file is executable and starts with a valid shebang line. Git calls them by a fixed, predefined name such as pre-commit or pre-push as soon as the matching action takes place, with no extra registration or configuration file required.

Every repository created with git init or git clone already contains a .git/hooks directory with a set of sample scripts that carry the .sample extension and are therefore inactive by default. To activate a hook, it is enough to strip the .sample extension and make the file executable with chmod +x. This simplicity is also the biggest strength of hooks: there is no plugin system, no external dependency, and no extra learning curve, just an executable file with the right name in the right place on the filesystem.


# View the .git/hooks directory of a freshly initialized repo
ls -la .git/hooks

# List available sample hooks (all carry the .sample extension)
ls .git/hooks/*.sample

# Activate a sample hook: strip .sample and make it executable
mv .git/hooks/pre-commit.sample .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit

# Verify Git recognizes the hook as executable
test -x .git/hooks/pre-commit && echo "pre-commit is active"

2. Client-side vs. server-side hooks

Git fundamentally distinguishes between client-side hooks, which run on a single developer's machine, and server-side hooks, which run on the repository that receives pushes, usually a central bare repository on a Git server such as GitLab, Gitea, or a self-hosted server. Client-side hooks include pre-commit, commit-msg, pre-push, post-checkout, and post-merge, among others. Server-side hooks include pre-receive, update, and post-receive, all of which run on the target repository when it receives a push, not on the developer's machine.

The practical difference matters: client-side hooks give fast, local feedback, but they can be bypassed at any time with git commit --no-verify, or they may simply be missing, since, as described in the next section, they are not cloned along with the repository by default. Server-side hooks, on the other hand, run on infrastructure controlled by the team rather than by the individual developer, and cannot be bypassed from a developer's client. Anyone who genuinely wants to enforce a rule, such as banning force-pushes to main, needs to implement it server side, while client-side hooks mainly serve convenience and early detection.

3. The most important client-side hooks at a glance

pre-commit runs before the commit editor opens and is the classic place for linting, formatting checks, or blocking debug output such as var_dump in staged PHP files. commit-msg runs right after and receives, as its only argument, the path to a temporary file containing the commit message already typed in, ideal for enforcing formats such as Conventional Commits or a mandatory ticket reference before the commit is actually created.

pre-push runs before objects are transferred to the remote and receives a list of the affected local and remote refs via stdin, making it the right place for a quick test run or a local check against protected branches. post-checkout runs after switching branches and is useful for flagging new database migrations. post-merge runs after a successful merge or pull and is often used to automatically run composer install when composer.lock changed as part of the merge.

4. Writing a pre-commit hook step by step

A pre-commit hook receives no command-line arguments and must figure out for itself which files are staged. The standard command for that is git diff --cached --name-only --diff-filter=ACM, which returns only added, copied, and modified files and deliberately excludes deleted files, since a linter would fail on a file that no longer exists anyway. From this file list you typically filter by extension, for example only .php files, before calling an external tool such as phpcs or php -l per file.

It's important to start the hook with the correct shebang line, such as #!/usr/bin/env bash, and to terminate explicitly with an exit code at the end, as described in the next section. A common beginner mistake is checking only the working-directory version of a file instead of the actually staged version, which produces incorrect results for partially staged files created with git add -p. The robust solution checks the staged content directly via git show :file.php instead of simply reading the file from disk.


#!/usr/bin/env bash
set -euo pipefail

echo "Running pre-commit checks..."

# Collect staged PHP files (added, copied, modified), ignore deleted files
staged_php_files=$(git diff --cached --name-only --diff-filter=ACM -- '*.php')

if [ -z "$staged_php_files" ]; then
  echo "No staged PHP files, skipping lint."
  exit 0
fi

exit_code=0

for file in $staged_php_files; do
  # Lint the staged content, not the working-directory version
  if ! php -l "$file" > /dev/null 2>&1; then
    echo "Syntax error in: $file"
    exit_code=1
  fi

  # Block accidental debug output in committed code
  if git show ":$file" | grep -nE 'var_dump\(|dd\(|print_r\(' > /dev/null; then
    echo "Debug output found in: $file"
    exit_code=1
  fi
done

if [ "$exit_code" -ne 0 ]; then
  echo "pre-commit checks failed, commit aborted."
fi

exit $exit_code

5. Exit codes: how Git interprets hook results

After every hook, Git evaluates only the exit code, never the text output. An exit code of 0 means success, and Git proceeds with the operation. Any other exit code, whether 1, 2, or any other value between 1 and 255, counts as a failure and, for most client-side hooks, aborts the associated Git operation entirely. A pre-commit hook with a non-zero exit code prevents the commit completely, and the user lands back in the terminal without a new commit being created.

Not every hook can block the operation: post-commit, post-checkout, and post-merge only run after the actual action has already completed, so their exit code no longer affects the outcome and only serves informational or follow-up purposes. A common mistake when writing your own hooks is implicitly relying on the exit code of the last command even though an earlier command already failed. set -euo pipefail at the top of the script and an explicit exit call at the end create clarity here and prevent a hook from falsely reporting success.


#!/usr/bin/env bash
# Minimal example: explicit exit codes instead of relying on the last command

check_branch_name() {
  local branch
  branch=$(git symbolic-ref --short HEAD)

  if [[ "$branch" =~ ^(feature|bugfix|hotfix)/.+ ]]; then
    return 0
  fi

  echo "Branch name '$branch' does not match feature|bugfix|hotfix/*"
  return 1
}

if check_branch_name; then
  exit 0
else
  # Non-zero exit code tells Git to abort the operation
  exit 1
fi

6. Server-side hooks: the pre-receive example

pre-receive runs on the target repository before any ref gets updated and is the most powerful server-side hook, because it can accept or reject an entire push atomically, including every commit it contains. Git delivers one line per updated reference over stdin, with three space-separated values: the old object hash, the new object hash, and the full ref name such as refs/heads/main. The hook reads these lines and can check any rule against those values before Git actually updates the references.

Typical use cases are banning force-pushes to protected branches, enforcing commit-message conventions server side, checking for signed commits, or preventing direct pushes to main outside a merge-request workflow. The decisive advantage over client-side hooks: a pre-receive hook cannot be bypassed with --no-verify, because it never runs on the developer's machine at all, only on the server the team controls. On hosted platforms like GitHub or GitLab.com, direct access to pre-receive is usually restricted, which is why branch-protection rules or server-side CI checks take on the same role there.


#!/usr/bin/env bash
# Server-side hook: runs on the bare repository before refs are updated

zero_sha="0000000000000000000000000000000000000000"

while read -r old_sha new_sha ref_name; do
  # Reject deletions and force-pushes on the protected main branch
  if [ "$ref_name" = "refs/heads/main" ]; then
    if [ "$new_sha" = "$zero_sha" ]; then
      echo "Rejected: deleting the main branch is not allowed."
      exit 1
    fi

    # A force-push means the new history does not contain the old tip
    if [ "$old_sha" != "$zero_sha" ] && ! git merge-base --is-ancestor "$old_sha" "$new_sha"; then
      echo "Rejected: force-push to main is not allowed."
      exit 1
    fi
  fi
done

exit 0

7. Why hooks are not versioned by default

The .git directory itself is never part of the versioned project content, it is Git's internal metadata store and gets fully recreated, not copied, whenever a repository is cloned. Anything inside .git/hooks therefore counts as local, machine-specific configuration, just like .git/config or remote-tracking information. When a new teammate clones the repository, they get a fresh .git/hooks directory containing only the standard sample files, regardless of which hooks were active in the source repository.

This behavior is not a limitation of Git, it follows directly from the purpose of .git: hooks can execute arbitrary code on the target system, and an automatically active, cloned script would be a significant security risk, comparable to automatically executing downloaded code. This is exactly why there is no built-in mechanism to version hooks that live in .git/hooks. A manual workaround, such as documenting scripts in a README and asking developers to copy them by hand, is error prone and regularly gets forgotten in practice as soon as a new hook is added or an existing one changes.

8. core.hooksPath: versioning and distributing hooks team-wide

The Git configuration option core.hooksPath, available since Git 2.9, solves the problem at its root: it tells Git to stop looking for hooks in .git/hooks and instead look in any other directory, one that can be a completely normal, versioned part of the project, such as .githooks/ at the project root. Once git config core.hooksPath .githooks is set, Git calls the scripts from that directory on every matching event, following exactly the same naming and executable-bit rules as before with .git/hooks.

The remaining manual step is running the git config command once after each clone, which can be automated through a setup script, a Makefile target, or an instruction in the README. For Node.js- or npm-based projects, tools like Husky take over exactly this task automatically via a prepare script in package.json that runs on every npm install, handling the actual configuration of core.hooksPath invisibly to the developer. How Husky works in combination with lint-staged, and how it lets you enforce hooks team-wide without any manual step, is covered in detail in a separate follow-up article.


# Versioned hooks directory inside the repository
mkdir -p .githooks

# Move the existing pre-commit hook into the versioned directory
mv .git/hooks/pre-commit .githooks/pre-commit
chmod +x .githooks/pre-commit

# Tell Git to look here instead of .git/hooks
git config core.hooksPath .githooks

# Commit the hooks directory like any other project file
git add .githooks
git commit -m "Add versioned pre-commit hook via core.hooksPath"

# Every teammate runs this once after cloning
git config core.hooksPath .githooks

9. Hook strategies compared

The three strategies that matter in practice for using hooks consistently across a team differ mainly in versionability, bypassability, and setup effort. The table below summarizes when each approach is the right choice.

Approach Versioned in repo Bypassable with --no-verify Setup effort
Manual .git/hooks No Yes None, but useless after every clone
core.hooksPath Yes Yes One-time git config command per clone
Husky / lint-staged (npm) Yes Yes Automatic via npm install
Server-side (pre-receive) Yes, in the server setup No Requires access to the Git server

In practice, client-side and server-side hooks complement each other: client-side hooks like pre-commit give immediate feedback right in the editor workflow, while server-side pre-receive hooks act as the final, non-bypassable layer enforcing rules that genuinely need to be binding. core.hooksPath makes the decisive difference between a hook that exists only on a single machine and a hook that is part of the project history and immediately available to every clone.

Mironsoft

Git hooks setup, CI/CD automation, and developer training for Magento teams

Ready to establish reliable Git hooks in your team?

We set up versioned Git hooks for your team via core.hooksPath or Husky, implement server-side pre-receive rules for protected branches, and train developers in the safe use of local automation.

Hook setup & core.hooksPath

Versioned pre-commit, commit-msg, and pre-push hooks for your repository

Server-side enforcement

pre-receive rules for protected branches and commit conventions

Developer training

Hands-on workshop on Git hooks, exit codes, and CI/CD integration

10. Summary

Git hooks solve a central automation problem in daily development: recurring checks such as linting, commit-message formats, or test runs no longer need to be run manually, they run automatically on commit, push, or merge. Client-side hooks like pre-commit, commit-msg, and pre-push give fast, local feedback right in the terminal, but they can be bypassed with --no-verify and are missing by default after every fresh clone, since .git/hooks is never part of the versioned project content.

core.hooksPath closes exactly this gap by telling Git to load hooks from a versioned project directory such as .githooks/ instead of the unversioned .git/hooks. For rules that genuinely need to be binding and that no developer should be able to bypass, a server-side pre-receive hook on the Git server remains the only reliable layer. Combining client-side hooks for fast feedback with server-side hooks for binding enforcement covers both layers of automation cleanly, regardless of whether tools like Husky or a manually maintained core.hooksPath setup are used.

Git Hooks Fundamentals, The Essentials at a Glance

Client vs. server

Client-side hooks run locally and are bypassable, server-side hooks like pre-receive run on the Git server and are binding.

Exit codes matter

Exit code 0 allows the operation, any other value aborts it entirely for most hooks.

Not versioned

.git/hooks is never copied when cloning, because .git itself is not versioned project content.

core.hooksPath as the fix

Points Git at a versioned directory such as .githooks/, so hooks travel with the repository.

11. FAQ: Git Hooks Fundamentals

1What exactly is a Git hook?
An executable script that Git automatically starts on events such as commit, push, or merge. Sits as a file with a fixed name like pre-commit in the hooks directory, no extra registration needed.
2Where exactly do Git hooks live on the filesystem?
By default in .git/hooks of every repository. With core.hooksPath, this location can be redirected to a versioned directory such as .githooks/.
3Why aren't Git hooks cloned automatically?
Because .git is internal metadata storage and gets recreated, not copied, when cloning. An automatically active, cloned script would also be a security risk.
4What's the difference between client-side hooks and server-side hooks?
Client-side hooks run on the developer's machine and can be bypassed with --no-verify. Server-side hooks like pre-receive run on the Git server and cannot be bypassed from the client.
5Which client-side hooks are used most often?
pre-commit for linting, commit-msg for message formats, pre-push for tests before pushing, post-checkout and post-merge for cleanup and setup tasks.
6How can I intentionally bypass a pre-commit hook?
With the --no-verify flag, for example git commit --no-verify. Works for all client-side hooks, but not for server-side hooks like pre-receive.
7What happens when a hook exits with code 1?
Git aborts the associated operation entirely. post-commit, post-checkout, and post-merge can no longer stop the operation, since they run after it already happened.
8What exactly does core.hooksPath do?
Tells Git to load hooks from a freely chosen directory instead of .git/hooks. That directory can be part of the repository and therefore becomes versioned.
9What is a pre-receive hook typically used for?
To prevent force-pushes to protected branches, enforce commit conventions server side, or require signed commits, binding and with no bypass for developers.
10Are tools like Husky an alternative to core.hooksPath?
Yes, Husky configures core.hooksPath automatically via an npm script, especially common in Node.js and frontend-heavy projects. Details covered in a separate follow-up article.