Stage 2 · Tools
Hooks & Automation
Client-Side Hooks
Use Git's built-in hook system to lint, format, and validate commits before they leave your machine.
How Git Hooks Work
Git hooks are scripts that Git executes automatically before or after certain events — commits, merges, pushes, and more. They live in .git/hooks/ and are triggered by the Git process itself. No external tool is required to run them; Git finds the script, executes it, and checks the exit code.
When you initialize a repository with git init, Git populates .git/hooks/ with sample scripts (.sample extension). These are disabled by default. To activate a hook, rename it to remove the .sample extension and make it executable.
ls .git/hooks/
# applypatch-msg.sample post-update.sample pre-commit.sample
# commit-msg.sample pre-applypatch.sample pre-push.sample
# post-commit.sample pre-rebase.sample prepare-commit-msg.sampleEach .sample file is a hook that is currently inactive. Rename or copy one without the .sample extension to enable it.
Hooks live in .git/hooks/, which is not tracked by Git. This means they are not automatically shared with your team. You need a tool like Lefthook or Husky (covered in a later lesson) to version-control hooks and distribute them.
Hook Script Format
Git hooks are plain scripts — shell, bash, Python, Ruby, Node.js, or anything the system can execute. Git calls the script and reads its exit code to decide what happens next.
| Exit Code | Behavior |
|---|---|
| 0 | Success — Git proceeds normally |
| Non-zero (1–125) | Failure — Git blocks the operation (commit, push, etc.) |
| 128 + signal | Git exits with the same signal (e.g., 130 for SIGINT) |
#!/bin/sh
# .git/hooks/pre-commit
echo "Running pre-commit checks..."
exit 0The shebang (#!/bin/sh) tells the system which interpreter to use. Exit 0 means success — Git continues the commit. Any non-zero exit code blocks the commit.
A hook script must have execute permission. Run chmod +x .git/hooks/pre-commit after creating or modifying a hook. Without this permission, Git will skip the hook silently.
The pre-commit Hook
The pre-commit hook runs before a commit is created, after you type git commit but before the message editor opens. It receives no arguments. This is the most common hook — use it to lint code, run tests, check formatting, and scan for secrets.
#!/bin/sh
#
# pre-commit: Lint staged files before allowing a commit.
#
# Redirect output to stderr.
exec 1>&2
# Get the list of staged files (added or modified).
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)
# Check for TypeScript/JavaScript files.
if echo "$STAGED_FILES" | grep -qE '\.(ts|tsx|js|jsx)$'; then
echo "Running ESLint on staged files..."
# Lint only the staged files.
echo "$STAGED_FILES" | xargs npx eslint --max-warnings=0
if [ $? -ne 0 ]; then
echo ""
echo "❌ Lint failed. Fix errors before committing."
echo " To skip this hook (not recommended): git commit --no-verify"
exit 1
fi
fi
# Check for secrets or API keys.
if echo "$STAGED_FILES" | xargs grep -lE '(AKIA[0-9A-Z]{16}|sk_live_[a-zA-Z0-9]+)'; then
echo ""
echo "❌ Potential secrets found in staged files!"
exit 1
fi
echo "✅ All checks passed."
exit 0This hook lists staged files, runs ESLint only on files that have been staged (not the entire project), and scans for AWS keys or Stripe secret keys. If anything fails, the commit is blocked.
Key patterns in this hook:
exec 1>&2— Redirects stdout to stderr so hook output doesn't interfere with Git's own output.git diff --cached— Shows only staged changes, since the commit hasn't happened yet.xargs npx eslint— Runs the linter on each staged file individually, so you don't lint unchanged code.--no-verify— The escape hatch that skips all hooks (use sparingly).
The commit-msg Hook
The commit-msg hook runs after you enter a commit message but before the commit is saved. Git passes one argument: the path to the temporary file containing the message. The hook can read this file, validate the format, and exit non-zero to reject the commit.
#!/bin/sh
#
# commit-msg: Enforce Conventional Commits format.
#
# Expected: <type>(<scope>): <description>
# Valid types: feat, fix, docs, style, refactor, test, chore, perf, ci, build
#
COMMIT_MSG_FILE=$1
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
# Pattern: type(optional-scope): description
PATTERN='^(feat|fix|docs|style|refactor|test|chore|perf|ci|build)(\(.+\))?!?: .{1,72}'
if ! echo "$COMMIT_MSG" | grep -qE "$PATTERN"; then
echo ""
echo "❌ Invalid commit message format."
echo ""
echo " Expected: <type>(<scope>): <description>"
echo " Examples:"
echo " feat: add login page"
echo " fix(auth): handle expired tokens"
echo " docs: update API reference"
echo ""
echo " Valid types: feat, fix, docs, style, refactor, test, chore, perf, ci, build"
echo ""
echo " Your message: $COMMIT_MSG"
exit 1
fi
echo "✅ Commit message is valid."
exit 0This hook validates the Conventional Commits format by reading the message file and checking it against a regex pattern. If the format doesn't match, the commit is rejected with a helpful error message.
The commit-msg hook should only validate the message format. Do not run linters or tests here — use pre-commit for code checks and commit-msg for message standards.
The prepare-commit-msg Hook
The prepare-commit-msg hook runs between the default message creation and the message editor opening. It receives three arguments: the message file, the source of the default message (message, template, merge, squash, or commit), and the relevant commit SHA for merge/squash operations. Use this hook to automatically insert branch names, ticket numbers, or templates into commit messages.
#!/bin/sh
#
# prepare-commit-msg: Prepend branch name to commit message.
#
COMMIT_MSG_FILE=$1
SOURCE=$2
# Only modify commits created by the user (not merges, squashes, etc.)
if [ "$SOURCE" != "message" ] && [ "$SOURCE" != "commit" ]; then
exit 0
fi
# Get the current branch name.
BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null)
# Skip if on main or detached HEAD.
if [ -z "$BRANCH" ] || [ "$BRANCH" = "main" ] || [ "$BRANCH" = "master" ]; then
exit 0
fi
# Prepend the branch name as a scope.
sed -i.bak -e "1s/^[[:space:]]*/[$BRANCH] /" "$COMMIT_MSG_FILE"This hook prepends the current branch name as a scope prefix (e.g., [feature/login] fix typo). It skips merges and squashes, and does nothing on main/master.
post-commit & pre-push
The post-commit hook runs after the commit is finalized. It receives no arguments and its exit code does not affect the commit (the commit has already happened). Use it for notifications or non-blocking tasks.
The pre-push hook runs before Git sends data to the remote. It receives the name and URL of the remote as the first two arguments, and a list of refspecs on stdin. Use it to run tests, check for secrets, or prevent accidental pushes to protected branches.
#!/bin/sh
#
# pre-push: Prevent pushing to main/master.
#
REMOTE=$1
URL=$2
# Read the refspecs from stdin.
while read LOCAL_REF LOCAL_SHA REMOTE_REF REMOTE_SHA; do
BRANCH=$(echo "$LOCAL_REF" | sed -e 's|^refs/heads/||')
if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "master" ]; then
echo ""
echo "❌ Direct push to main/master is not allowed."
echo " Create a pull request instead."
echo ""
exit 1
fi
done
exit 0This hook blocks direct pushes to main or master, forcing developers to create pull requests. It reads refspecs from stdin and checks each branch name.
| Hook | When It Runs | Arguments | Blocks Operation? |
|---|---|---|---|
| pre-commit | Before commit message editor | None | Yes |
| prepare-commit-msg | After default message, before editor | file, source, SHA | Yes |
| commit-msg | After commit message is entered | file path | Yes |
| post-commit | After commit is finalized | None | No |
| pre-push | Before push to remote | remote name, URL (stdin) | Yes |
Client-side hooks are not a security boundary — any developer can run git commit --no-verify to skip them. Their value is in catching mistakes early and maintaining team standards without relying on CI to reject bad commits.
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.