Automate Your Workflow: A Practical Guide to Git Client-Side Hooks

Use Git client-side hooks for fast local checks, shared setup, commit message rules, and safer post-merge automation.

Automate Your Workflow: A Practical Guide to Git Client-Side Hooks

Git client-side hooks are small scripts that run on your machine when Git reaches certain points in a workflow. A pre-commit hook runs before a commit is created. A commit-msg hook runs after you write the message but before Git accepts it. A post-merge hook runs after a merge finishes. Used well, hooks catch boring mistakes early: forgotten formatting, broken generated files, missing dependency installs, or commit messages that do not match your team's convention.

The important limitation is that client-side hooks are local. They do not automatically travel with the repository when someone clones it. That makes them great for fast feedback and local convenience, but weak as the only enforcement layer for a team rule. If a check truly protects the main branch, put it in CI or a server-side rule too.

Every repository has a hooks directory under .git/hooks:

ls .git/hooks

A new repository usually contains sample files such as pre-commit.sample. A sample hook does nothing until you create an executable file without the .sample suffix:

cp .git/hooks/pre-commit.sample .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit

Hooks can be shell scripts, Python scripts, Ruby scripts, Node scripts, or anything else your machine can execute. The first line should point to the interpreter:

#!/usr/bin/env bash

For most teams, the better long-term pattern is not to hand-edit .git/hooks on every laptop. Store hook scripts in the repository, then configure Git to use that directory:

git config core.hooksPath .githooks
mkdir -p .githooks

Now a hook at .githooks/pre-commit can be committed and reviewed like normal project code. Each developer still needs the core.hooksPath setting, but setup can be added to a bootstrap script or documented in onboarding.

A Useful Pre-Commit Hook

A good pre-commit hook should be fast and focused. If it takes two minutes on every commit, people will bypass it with git commit --no-verify, and the hook will become noise. Save full test suites for CI unless the project is small enough that they really are quick.

Here is a practical shell hook that checks only staged files. That distinction matters. You may have unfinished work in your working tree that you do not want tested yet. The commit should be judged by what is staged.

Create .githooks/pre-commit:

#!/usr/bin/env bash
set -u

changed_files=$(git diff --cached --name-only --diff-filter=ACMR)

if [ -z "$changed_files" ]; then
  exit 0
fi

if git diff --cached --check; then
  :
else
  echo "Fix whitespace errors before committing."
  exit 1
fi

secret_matches=$(git diff --cached --name-only --diff-filter=ACMR | xargs grep -nE 'AKIA[0-9A-Z]{16}|BEGIN RSA PRIVATE KEY' 2>/dev/null || true)
if [ -n "$secret_matches" ]; then
  echo "Possible secret found in staged files:"
  echo "$secret_matches"
  exit 1
fi

python_files=$(printf '%s\n' "$changed_files" | grep '\.py$' || true)
if [ -n "$python_files" ]; then
  printf '%s\n' "$python_files" | while IFS= read -r file; do
    [ -f "$file" ] || continue
    python3 -m py_compile "$file" || exit 1
  done
fi

exit 0

This hook does three modest things: it lets Git detect whitespace errors, checks staged files for a couple of obvious secret patterns, and compiles changed Python files. It is not a replacement for a real secret scanner or a test suite. It is a quick tripwire.

One common mistake is using grep against filenames instead of file contents. This broken pattern only checks whether the path contains TODO, not whether the file contains it:

git diff --cached --name-only | grep TODO

If you want to block TODO comments, inspect the staged diff instead:

if git diff --cached -U0 | grep -E '^\+.*TODO:'; then
  echo "Staged TODO comments found."
  exit 1
fi

Even then, be careful. Some teams use TODO comments responsibly. Blocking every TODO can be more annoying than helpful.

Commit Message Hooks

A commit-msg hook receives the path to the temporary commit message file as its first argument. That makes it useful for rules like "every commit must start with a ticket ID" or "use Conventional Commits."

A small example:

#!/usr/bin/env bash
set -u

message_file="$1"
first_line=$(head -n 1 "$message_file")

if printf '%s' "$first_line" | grep -Eq '^(feat|fix|docs|test|refactor|chore)(\(.+\))?: .+'; then
  exit 0
fi

echo "Commit message should look like: fix(api): handle empty token"
exit 1

This is helpful when release notes or changelogs are generated from commits. It is less helpful when your team does squash merges and rewrites PR titles anyway. Match the hook to the workflow you actually use.

Post-Merge Hooks

A post-merge hook is best for local cleanup after your working tree changes. The classic example is refreshing dependencies after a lockfile changes.

#!/usr/bin/env bash
set -u

previous_head="HEAD@{1}"

if git diff --name-only "$previous_head" HEAD | grep -Eq '(^package-lock\.json$|^pnpm-lock\.yaml$|^yarn\.lock$)'; then
  if command -v npm >/dev/null 2>&1 && [ -f package-lock.json ]; then
    echo "Lockfile changed; running npm install."
    npm install
  fi
fi

if git diff --name-only "$previous_head" HEAD | grep -q '^\.gitmodules$'; then
  echo "Submodule config changed; syncing submodules."
  git submodule sync --recursive
  git submodule update --init --recursive
fi

This hook should not make surprising changes. If it installs dependencies, print what it is doing. If the install fails, tell the developer how to recover. A hook that silently changes the working tree is hard to trust.

Sharing Hooks Without Making a Mess

There are three common ways to share hooks.

The simplest is core.hooksPath, where the repository contains .githooks/ and setup sets Git to use it. This is transparent and does not require another package manager.

JavaScript projects often use Husky because it integrates with npm, pnpm, or yarn install flows. That can be a good fit when every contributor already uses the Node toolchain.

Many mixed-language teams use the pre-commit framework. It installs and runs hooks defined in .pre-commit-config.yaml, with pinned versions for tools such as formatters, linters, and file checks. It adds another tool, but it solves the "how do we install the same hooks everywhere?" problem better than a wiki page.

What I avoid is copying large scripts into .git/hooks by hand. Nobody reviews them, nobody knows which version is installed, and debugging becomes personal archaeology.

Debugging Hooks

When a hook does not run, check these in order:

git config --get core.hooksPath
ls -l .git/hooks .githooks 2>/dev/null

If core.hooksPath is set, Git ignores .git/hooks and uses the configured directory. If the hook file is not executable on macOS or Linux, Git will not run it:

chmod +x .githooks/pre-commit

When a hook runs but fails mysteriously, add temporary tracing:

set -x
pwd
env | sort

Hooks run from the repository root in normal Git usage, but GUI clients and IDEs can expose path or environment differences. Use command -v toolname inside the hook before assuming a linter or package manager is available.

Also remember the bypass switch:

git commit --no-verify

This is not a security hole by itself; it is how Git works. It is another reason serious enforcement belongs in CI or protected-branch rules.

A Sensible Hook Policy

Use hooks for checks that are fast, deterministic, and easy to explain. Formatting staged files, catching whitespace errors, validating commit messages, and reminding developers to install dependencies are good candidates. Avoid hooks that require network access, take a long time, or depend on fragile local state.

If a hook blocks a commit, its message should say exactly what failed and how to fix it. "Hook failed" is not enough. A developer in the middle of a merge or production hotfix needs a clear next command.

Client-side Git hooks work best when they feel like a helpful guardrail rather than a local bureaucracy. Keep them small, keep them versioned, and keep the final authority in CI.

Keep Hooks Friendly During Emergencies

Hooks should help during normal work without trapping someone during an urgent fix. That means every blocking hook needs a clear failure message and a realistic escape hatch. Git already provides --no-verify for commit and push hooks, but your team should still decide when bypassing is acceptable. A production hotfix is different from skipping formatting because a developer is in a hurry.

A good hook message says what failed, where it failed, and what to run next:

echo "ESLint failed on staged JavaScript files."
echo "Run: npm run lint -- --fix"
exit 1

A bad message says only failed or dumps pages of tool output with no context. People learn to ignore that kind of hook.

If the hook modifies files, be extra careful. Formatters can be useful in pre-commit, but they can also create confusion when they change unstaged parts of a file. Many teams prefer checking formatting in the hook and letting the developer run the formatter manually. Others use tools that format only staged hunks. Pick one behavior and document it in the repository, not in a chat thread that disappears.

For teams, review hook changes like application code. A hook can slow every commit, leak environment details into logs, or break contributors on Windows if it assumes Bash-only behavior. If your project has Windows contributors, test hooks in Git Bash or use a cross-platform hook runner. If your project has containers or dev shells, consider running hooks inside the same environment as the app so everyone uses the same tool versions.

The best hooks are almost invisible when everything is fine and very specific when something is wrong. That is the standard to aim for.

Version Hooks Like Product Code

A hook script becomes part of the developer experience. If it breaks, every contributor feels it. Keep the scripts small, name helper functions clearly, and avoid clever shell tricks when a straightforward command would do. If a hook grows beyond a screen or two, move the real logic into a tested project script and let the hook call that script.

For example, instead of embedding a long lint routine in .githooks/pre-commit, call:

./scripts/check-staged-files.sh

That script can be run by developers, hooks, and CI. It also means a developer can reproduce the failure without pretending to make a commit. Reproducibility is the difference between a helpful hook and a mysterious local obstacle.

Pin tool versions where you can. A hook that calls whatever black, eslint, or prettier happens to be first in PATH may behave differently across machines. Project-local dependencies, lockfiles, containers, or version managers make hook output more predictable.

Finally, keep hooks scoped to the repository. Global hooks sound convenient, but they often surprise you months later when an unrelated repository starts failing because of an old personal rule. Use global hooks only for truly personal preferences, not team policy.

One last practical rule: never let hooks be the only place a command exists. If the hook checks staged Python files, keep that command in a script or task runner too. Developers should be able to run the same check on purpose, before Git interrupts them.

For open-source projects, assume contributors may not have your full toolchain installed yet. A hook that fails with a friendly setup message is fine. A hook that throws a stack trace from a missing local binary feels broken. Check prerequisites before running heavier commands, and point people to the setup command used by the project.

Also think about partial commits. Many experienced developers stage only part of a file. Hooks that format the whole file can accidentally pull unstaged work into the commit. If your team uses partial commits often, prefer checks that read the staged diff or tools designed for staged content.

If a hook keeps getting bypassed, treat that as feedback. Either the check is too slow, the failure message is unclear, or the rule belongs in CI instead of the local commit path. Fix the friction rather than blaming developers for using the bypass Git provides.