GitClient-Side Hooks

Client-Side Hooks

Client-side hooks run on your local machine in response to Git operations you perform. They are the developer's first line of defence — catching lint errors, enforcing commit message formats, and running quick tests before code ever leaves your workstation.

pre-commit

pre-commit fires before Git even opens the commit message editor. It receives no arguments. If it exits non-zero, the commit is aborted. Use it to run formatters, linters, and fast unit tests against the staged files.

.git/hooks/pre-commit — lint only staged JS/TS files

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

# Get the list of staged files
STAGED=$(git diff --cached --name-only --diff-filter=ACM | grep -E '.(js|ts|jsx|tsx)$' || true)

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

echo "Running ESLint on staged files..."
echo "$STAGED" | xargs ./node_modules/.bin/eslint --max-warnings=0

echo "Running Prettier check on staged files..."
echo "$STAGED" | xargs ./node_modules/.bin/prettier --check

echo "✓ pre-commit checks passed"

.git/hooks/pre-commit — run fast tests

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

echo "Running unit tests..."
npm test -- --passWithNoTests --watchAll=false --silent

echo "✓ All tests passed"
Tip
Keep `pre-commit` fast — under 10 seconds. Slow hooks get bypassed with `--no-verify`. Reserve full test suites for `pre-push`. Use `--diff-filter=ACM` to limit checks to Added, Copied, and Modified files rather than the entire codebase.
prepare-commit-msg

prepare-commit-msg runs after the default commit message template is set up but before the editor is shown to the user. It receives three arguments:

Argument

Value

$1

Path to the file containing the commit message (e.g., .git/COMMIT_EDITMSG).

$2

Type of commit: message, template, merge, squash, or commit (amend).

$3

SHA of the commit being amended (only present when $2 is commit).

.git/hooks/prepare-commit-msg — auto-prepend branch name

Bash
#!/usr/bin/env bash
# Prepend the Jira ticket number from the branch name to the commit message.
# Branch pattern: feature/PROJ-1234-some-description
# Result: "[PROJ-1234] " prepended to every commit message

COMMIT_MSG_FILE="$1"
COMMIT_SOURCE="$2"

# Don't modify merge commits, amends, or squash messages
if [ "$COMMIT_SOURCE" = "merge" ] || [ "$COMMIT_SOURCE" = "squash" ] || [ "$COMMIT_SOURCE" = "commit" ]; then
  exit 0
fi

# Extract ticket from branch name
BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || true)
TICKET=$(echo "$BRANCH" | grep -oE '[A-Z]+-[0-9]+' | head -1 || true)

if [ -n "$TICKET" ]; then
  # Prepend ticket only if the message doesn't already contain it
  if ! grep -qF "[$TICKET]" "$COMMIT_MSG_FILE"; then
    sed -i.bak "1s/^/[$TICKET] /" "$COMMIT_MSG_FILE"
   
  fi
fi
commit-msg

commit-msg receives one argument: the path to the file that contains the commit message the user typed. It can validate, modify, or reject the message. This is the ideal place to enforce Conventional Commits or any house style.

.git/hooks/commit-msg — enforce Conventional Commits

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

COMMIT_MSG_FILE="$1"
MSG=$(cat "$COMMIT_MSG_FILE")

# Conventional Commits pattern:
# type(optional-scope): description
PATTERN='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)((.+))?: .{1,72}$'

# Ignore merge commits, fixup!, squash! prefixes
if echo "$MSG" | grep -qE '^(Merge|Revert|fixup!|squash!)'; then
  exit 0
fi

# Check first line
FIRST_LINE=$(echo "$MSG" | head -1)

if ! echo "$FIRST_LINE" | grep -qE "$PATTERN"; then
  echo ""
  echo "✗ Invalid commit message format."
  echo ""
  echo "  Your message: $FIRST_LINE"
  echo ""
  echo "  Required format: <type>(scope): <description>"
  echo "  Examples:"
  echo "    feat(auth): add OAuth2 login flow"
  echo "    fix: prevent crash on empty user list"
  echo "    docs: update API reference for /users endpoint"
  echo ""
  echo "  Valid types: feat, fix, docs, style, refactor, perf,"
  echo "               test, build, ci, chore, revert"
  echo ""
  exit 1
fi

# Check that the description doesn't end with a period
if echo "$FIRST_LINE" | grep -qE '.$'; then
  echo "✗ Commit message should not end with a period."
  exit 1
fi

exit 0

Example rejection output

Text
✗ Invalid commit message format.

  Your message: Added login button

  Required format: <type>(scope): <description>
  Examples:
    feat(auth): add OAuth2 login flow
    fix: prevent crash on empty user list

  Valid types: feat, fix, docs, style, refactor, perf,
               test, build, ci, chore, revert
post-commit

post-commit fires immediately after a commit is successfully created. It cannot abort anything — it is purely informational. Common uses: notifications, logging, updating a local issue tracker.

.git/hooks/post-commit — show a summary notification

Bash
#!/usr/bin/env bash
# Show a macOS notification with the commit summary
HASH=$(git rev-parse --short HEAD)
MSG=$(git log -1 --pretty=%s)
BRANCH=$(git symbolic-ref --short HEAD)

osascript -e "display notification "$MSG" with title "Git commit $HASH" subtitle "$BRANCH""
# On Linux with libnotify:
# notify-send "Git commit $HASH" "$MSG"
pre-push

pre-push runs before Git transfers objects to the remote. It receives the remote name and URL as arguments, and reads a list of refs to be pushed from stdin (one per line: <local-ref> <local-sha> <remote-ref> <remote-sha>). Use this as a final gate — run the full test suite or security checks.

.git/hooks/pre-push — block pushing to main, run tests

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

REMOTE="$1"
URL="$2"

# Block direct pushes to main/master
while read local_ref local_sha remote_ref remote_sha; do
  if [[ "$remote_ref" == "refs/heads/main" || "$remote_ref" == "refs/heads/master" ]]; then
    echo "✗ Direct pushes to 'main' or 'master' are not allowed."
    echo "  Please open a pull request."
    exit 1
  fi
done

# Run full test suite before any push
echo "Running test suite before push..."
npm test -- --watchAll=false

echo "✓ Tests passed. Pushing..."

.git/hooks/pre-push — check for secrets with gitleaks

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

# Requires gitleaks: https://github.com/gitleaks/gitleaks
if command -v gitleaks &>/dev/null; then
  echo "Scanning for secrets with gitleaks..."
  gitleaks protect --staged --redact --verbose
  echo "✓ No secrets detected."
fi
post-checkout

post-checkout fires after git checkout or git switch completes. It receives three arguments: <previous-HEAD> <new-HEAD> <is-branch-checkout> (1 if a branch was checked out, 0 if a file was checked out). Use it to install dependencies when the branch changes.

.git/hooks/post-checkout — auto-install when package.json changes

Bash
#!/usr/bin/env bash
PREV_HEAD="$1"
NEW_HEAD="$2"
IS_BRANCH_CHECKOUT="$3"

# Only run on branch checkouts, not file checkouts
if [ "$IS_BRANCH_CHECKOUT" = "0" ]; then
  exit 0
fi

# Check if package.json changed between the old and new branch
if git diff --name-only "$PREV_HEAD" "$NEW_HEAD" | grep -q "package.json"; then
  echo "package.json changed. Running npm install..."
  npm install
  echo "✓ Dependencies updated."
fi
post-merge

post-merge fires after a successful git merge. It receives one argument: 1 if the merge was a squash, 0 if it was a normal merge. Use it similarly to post-checkout to sync dependencies.

.git/hooks/post-merge — sync dependencies after merge

Bash
#!/usr/bin/env bash
# Check if package-lock.json changed during the merge
if git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD | grep -q "package-lock.json"; then
  echo "package-lock.json changed in merge. Running npm ci..."
  npm ci
  echo "✓ Dependencies synced."
fi
pre-rebase

pre-rebase runs before a rebase starts. It receives the upstream (first argument) and the branch being rebased (second, if not the current). Use it to prevent rebasing shared branches or to warn about dangerous operations.

.git/hooks/pre-rebase — prevent rebasing pushed branches

Bash
#!/usr/bin/env bash
UPSTREAM="$1"
BRANCH="${2:-$(git symbolic-ref --short HEAD)}"

# Prevent rebasing main or release branches
PROTECTED_BRANCHES="main master release/"

for protected in $PROTECTED_BRANCHES; do
  if [[ "$BRANCH" == "$protected"* ]]; then
    echo "✗ Rebasing '$BRANCH' is not allowed (protected branch)."
    exit 1
  fi
done

# Warn if the branch has been pushed
REMOTE_EXISTS=$(git ls-remote --exit-code --heads origin "$BRANCH" 2>/dev/null || echo "")
if [ -n "$REMOTE_EXISTS" ]; then
  echo "⚠  Warning: '$BRANCH' has been pushed to origin."
  echo "   Rebasing will rewrite history. Others may already have this branch."
  read -p "   Continue? (y/N) " -n 1 -r
  echo
  if [[ ! $REPLY =~ ^[Yy]$ ]]; then
    exit 1
  fi
fi
Quick reference

Hook

Arguments

Can abort

Primary use

pre-commit

None

Yes

Lint, format, fast tests on staged files

prepare-commit-msg

<msg-file> [<type> [<sha>]]

Yes

Auto-populate commit message template

commit-msg

<msg-file>

Yes

Validate commit message format

post-commit

None

No

Notifications, logging

pre-push

<remote> <url>; refs on stdin

Yes

Full test suite, secret scanning, branch protection

post-checkout

<prev> <new> <is-branch>

No

Install deps, set up env per branch

post-merge

<is-squash>

No

Sync deps after merge

pre-rebase

<upstream> [<branch>]

Yes

Protect shared branches from rebase

pre-merge-commit

None

Yes

Final check before merge commit is created

Warning
All client-side hooks can be skipped with `git commit --no-verify` or `git push --no-verify`. This makes them useful for developer convenience but unsuitable as a security enforcement mechanism. Critical policies (no force-pushes to main, signed commits required) must be enforced server-side or in CI where they cannot be bypassed.
stdin for pre-push
The `pre-push` hook receives each ref being pushed on stdin in the format: `<local-ref> <local-sha> <remote-ref> <remote-sha>`. When deleting a remote branch, `<local-sha>` will be the zero SHA (`0000000000000000000000000000000000000000`). Check for this if your hook inspects commit contents — there is nothing to inspect for a deletion.
Tip
Combine `lint-staged` (npm) with Husky for a best-in-class pre-commit experience on JavaScript projects. `lint-staged` automatically passes only the staged files to each linter, making the hook 10–100x faster than linting the entire project. `npx lint-staged` in your `.husky/pre-commit` is all it takes.