EthicsComputerCourses
Join training +91 90532 10052

Git Hooks & pre-commit

Git · Lesson 39 of 42 Advanced 9 min read Updated

A hook is a script that Git runs by itself at a fixed moment, such as just before a commit is created or just before a push is sent. If the script exits with an error, Git stops the action. Hooks let you catch problems on your own laptop in seconds: a forgotten console.log, a password typed into a config file, or a commit message that breaks the team's rules.

In this lesson you will write three real hooks for Priya's sweet-shop project, share them with the team, try the new configuration-based hooks in Git 2.54 and later, and meet the popular pre-commit framework. You will also learn why a hook is a helper, not a guard.

Where hooks live

Every repository has a .git/hooks folder. git init fills it with example scripts that end in .sample, so they do nothing until you rename them:

Terminal
git init sweet-shop
ls sweet-shop/.git/hooks
Output
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
sendemail-validate.sample
update.sample

The file name is the hook event. These are the ones you will use most:

HookRunsTypical use
pre-commitBefore Git asks for the commit messageLint code, block secrets and debug lines
commit-msgAfter you write the messageCheck the message format
pre-pushBefore anything is sent to the remoteRun tests, stop pushes to main
post-merge, post-checkoutAfter a merge or a branch switchRemind you to reinstall packages
pre-receive, updateOn the server, when a push arrivesReject pushes that break the rules

The first four are client-side hooks: they run on the developer's machine. Server-side hooks run on the machine that receives pushes. On GitHub you cannot install your own server-side hooks; branch rules play that role (see Branch Protection, Rulesets & CODEOWNERS).

A hook is any executable file. Git passes it information as arguments or on standard input, and only cares about its exit code: 0 means "carry on", anything else means "stop".

A pre-commit hook that blocks mistakes

The .git folder is never committed, so hooks placed there stay on one laptop. Priya wants the whole team to get the same checks, so she keeps them in a normal folder called .githooks that is committed with the project. Here is her pre-commit hook. It looks only at the lines being added in the staged changes:

Terminal
#!/bin/sh
# .githooks/pre-commit: block debug code and things that look like secrets.
added=$(git diff --cached -U0 --no-color | grep '^+' | grep -v '^+++')

if echo "$added" | grep -q 'console\.log'; then
  echo "pre-commit: remove console.log before committing." >&2
  exit 1
fi

if echo "$added" | grep -Eiq '(api_key|secret|password)[[:space:]]*[:=]'; then
  echo "pre-commit: this change looks like it contains a secret." >&2
  echo "Move it to .env (which is ignored) and try again." >&2
  exit 1
fi

exit 0

git diff --cached -U0 prints the staged diff with no context lines, so the script checks exactly what is about to be committed, not the whole file. Messages go to standard error (>&2) so they appear even when output is redirected.

Now she tells Git to look for hooks in .githooks instead of .git/hooks, makes the scripts executable, and tries to commit a debug line and then an API key:

Terminal
git config set core.hooksPath .githooks
chmod +x .githooks/*
git add menu.js
git commit -m "feat: log sweets"
git add config.js
git commit -m "feat: add sms config"
Output
pre-commit: remove console.log before committing.
pre-commit: this change looks like it contains a secret.
Move it to .env (which is ignored) and try again.

Both commits were refused and nothing was recorded. This simple pattern check will not catch every secret, but it stops the most common slip. For real secret detection, use a dedicated scanner, and read Leaked Secrets: Rotate, Then Rewrite for what to do if one gets through.

core.hooksPath is a local setting, so each teammate runs that one git config line once after cloning. Put it in the README.

Checking commit messages with commit-msg

The commit-msg hook receives one argument: the path of a file holding the message. Priya's team uses Conventional Commits, so the hook checks the first line against that pattern:

Terminal
#!/bin/sh
# .githooks/commit-msg: the first line must look like "type(scope): subject"
first_line=$(head -n 1 "$1")
pattern='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-z0-9-]+\))?!?: .+'

if ! echo "$first_line" | grep -Eq "$pattern"; then
  echo "commit-msg: \"$first_line\" is not a Conventional Commit." >&2
  echo "Example: feat(menu): add diwali gift boxes" >&2
  exit 1
fi
Terminal
git commit -m "added prices"
git commit -m "feat(menu): add prices per kg"
Output
commit-msg: "added prices" is not a Conventional Commit.
Example: feat(menu): add diwali gift boxes
[main 282a06e] feat(menu): add prices per kg
 1 file changed, 1 insertion(+)

Running checks before a push

A pre-push hook gets one line on standard input for every ref being pushed: the local ref, its commit, the remote ref and the remote's commit. That is enough to stop a direct push to main, which should only change through a pull request:

Terminal
#!/bin/sh
# .githooks/pre-push: changes reach main only through a pull request
while read -r local_ref local_oid remote_ref remote_oid; do
  if [ "$remote_ref" = "refs/heads/main" ]; then
    echo "pre-push: do not push to main directly. Push a branch and open a PR." >&2
    exit 1
  fi
done
exit 0
Output
pre-push: do not push to main directly. Push a branch and open a PR.
error: failed to push some refs to '../sweet-shop-remote.git'

Pushing a branch such as feat/gift-boxes goes through normally. Many teams also run the test suite in pre-push, because it is slower than a pre-commit check but still much faster than waiting for CI.

Hooks are helpers, not guards

Anyone can skip pre-commit, commit-msg and pre-push with --no-verify:

Terminal
git commit -am "chore: temporary debug" --no-verify
Output
[feat/gift-boxes 7d10d70] chore: temporary debug
 1 file changed, 1 insertion(+)

A teammate may also have forgotten to set core.hooksPath. So treat hooks as a fast, friendly first check, and repeat every important check in CI with GitHub Actions, where nobody can skip it. Make the CI check required with branch protection. Use --no-verify only when you understand why the hook complained, for example on a throwaway branch.

Priya adds a size check to all her repositories through her global config. git hook list shows what will run for an event, and --show-scope says where each hook was defined:

Terminal
git config set --global hook.size-check.command '~/bin/check-size.sh'
git config set --global --append hook.size-check.event pre-commit
git hook list --show-scope pre-commit
Output
global	size-check
hook from hookdir

Configured hooks run in the order Git finds them, and the classic hook from the hooks folder runs last. git hook run pre-commit runs them by hand, which is handy while you are writing a hook.

The pre-commit framework and Husky

Writing shell scripts for every check gets tiring. The pre-commit framework (from pre-commit.com, installed with pip install pre-commit) lets you list ready-made checks in a YAML file that is committed with the project:

YAML
# .pre-commit-config.yaml
repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v5.0.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-yaml
      - id: check-added-large-files
      - id: detect-private-key

Each teammate runs pre-commit install once, which writes a small .git/hooks/pre-commit that calls the framework. pre-commit run --all-files checks the whole project (a good first run and a good CI step), and pre-commit autoupdate moves each rev to the newest released tag. The framework downloads each listed repository the first time, so pin rev to a tag and review updates like any other dependency.

JavaScript projects often use Husky instead: it is installed as an npm dev dependency, stores hook scripts in a .husky folder, and sets core.hooksPath for you when teammates run npm install. The idea is the same as Priya's .githooks folder.

Common mistakes

  • Editing .sample files and wondering why nothing runs. The file must be named exactly after the event, such as pre-commit, with no extension.
  • Forgetting the executable bit on macOS or Linux. Git skips a hook it cannot execute and only prints a hint, so the check never runs.
  • Putting slow work in pre-commit. A hook that takes a minute trains people to use --no-verify. Keep it quick; move the full test suite to pre-push or CI.
  • Trusting hooks to enforce rules. Hooks run on machines you do not control. Required CI checks and branch rules are the real gate.

Key points

  • A hook is a script Git runs at a fixed moment; a non-zero exit code stops the action.
  • pre-commit checks staged changes, commit-msg checks the message, pre-push runs before data leaves your laptop.
  • .git/hooks is not committed; share hooks in a folder such as .githooks and set core.hooksPath.
  • Since Git 2.54, hooks can be defined in config with hook.<name>.command and hook.<name>.event; git hook list shows them.
  • --no-verify skips client hooks, so repeat important checks in CI and make them required.
  • The pre-commit framework and Husky manage shared hooks from a committed config file.

Exercise

In a practice repository, create a .githooks folder with a pre-commit hook that refuses a commit when any staged line contains the word debugger. Point core.hooksPath at the folder, then show one refused commit and one successful commit. Finally, run git hook list pre-commit to see the hook.

Show solution
Terminal
mkdir .githooks
cat > .githooks/pre-commit <<'EOF'
#!/bin/sh
if git diff --cached -U0 | grep '^+' | grep -q 'debugger'; then
  echo "pre-commit: remove debugger statements" >&2
  exit 1
fi
EOF
chmod +x .githooks/pre-commit
git config set core.hooksPath .githooks
git hook list pre-commit

Stage a file containing debugger; and git commit prints the message and stops. Remove the line, stage again and the commit succeeds. git hook list prints hook from hookdir, because the hook comes from the folder named in core.hooksPath.

Quick Quiz

1. What makes Git stop a commit when a pre-commit hook runs?

2. Why do teams keep hooks in a committed folder such as .githooks?

3. Which command skips the pre-commit and commit-msg hooks?

4. What does a commit-msg hook receive?

Learn Git with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call