Git hooks and automation
In this series (8 parts)
Git hooks are scripts that run automatically when specific Git events occur. They are the simplest way to automate quality checks before code reaches a remote repository. Combined with a shift-left security approach, hooks catch problems at the earliest possible moment.
How hooks work
Every Git repository has a .git/hooks/ directory. It contains sample scripts with a .sample extension. Remove the extension and make the file executable to activate a hook.
ls .git/hooks/
# pre-commit.sample commit-msg.sample pre-push.sample ...
Hooks are executable files. They can be written in any language: Bash, Python, Node.js, Ruby. Git runs them and checks the exit code. Zero means success. Non-zero means failure, and Git aborts the operation.
Client-side hooks
Client-side hooks run on the developer’s machine. They are not pushed to the remote. Each developer must install them locally.
pre-commit
Runs before Git creates a commit. This is the most commonly used hook.
#!/bin/sh
# .git/hooks/pre-commit
# Run linter on staged files
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep '\.js$')
if [ -n "$STAGED_FILES" ]; then
npx eslint $STAGED_FILES
if [ $? -ne 0 ]; then
echo "Lint errors found. Fix them before committing."
exit 1
fi
fi
Common uses:
- Run linters (ESLint, Pylint, Rubocop).
- Format code (Prettier, Black, gofmt).
- Check for debugging statements (
console.log,debugger). - Scan for secrets (API keys, passwords).
commit-msg
Runs after the user writes the commit message. Receives the message file path as an argument.
#!/bin/sh
# .git/hooks/commit-msg
MSG_FILE=$1
MSG=$(cat "$MSG_FILE")
# Enforce conventional commits format
if ! echo "$MSG" | grep -qE '^(feat|fix|docs|style|refactor|test|chore)(\(.+\))?: .{1,72}'; then
echo "Commit message must follow Conventional Commits format."
echo "Example: feat(auth): add login endpoint"
exit 1
fi
Enforcing commit message conventions automates changelog generation and semantic versioning. Tools like commitlint provide more sophisticated validation.
prepare-commit-msg
Runs before the commit message editor opens. Useful for prepopulating messages.
#!/bin/sh
# .git/hooks/prepare-commit-msg
# Prepend branch name as ticket reference
BRANCH=$(git symbolic-ref --short HEAD)
TICKET=$(echo "$BRANCH" | grep -oE '[A-Z]+-[0-9]+')
if [ -n "$TICKET" ]; then
sed -i.bak "1s/^/[$TICKET] /" "$1"
fi
If your branch is feature/PROJ-123-add-auth, the commit message starts with [PROJ-123] automatically.
pre-push
Runs before data is transferred to the remote. Useful for final validation.
#!/bin/sh
# .git/hooks/pre-push
# Run tests before pushing
npm test
if [ $? -ne 0 ]; then
echo "Tests failed. Push aborted."
exit 1
fi
Pre-push hooks are heavier than pre-commit hooks. Running a full test suite on every push is reasonable. Running it on every commit is too slow.
post-merge
Runs after a successful merge. Useful for installing dependencies.
#!/bin/sh
# .git/hooks/post-merge
# Check if package.json changed
CHANGED_FILES=$(git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD)
if echo "$CHANGED_FILES" | grep -q "package.json"; then
echo "package.json changed. Running npm install..."
npm install
fi
Server-side hooks
Server-side hooks run on the Git server (or hosting platform). They cannot be bypassed by clients.
pre-receive
Runs when the server receives a push. Can reject the entire push.
#!/bin/sh
# server: hooks/pre-receive
while read oldrev newrev refname; do
# Prevent force pushes to main
if [ "$refname" = "refs/heads/main" ]; then
FORCED=$(git merge-base --is-ancestor "$oldrev" "$newrev" 2>/dev/null; echo $?)
if [ "$FORCED" -ne 0 ]; then
echo "Force push to main is not allowed."
exit 1
fi
fi
done
update
Similar to pre-receive but runs once per branch being updated. Allows granular per-branch policies.
post-receive
Runs after a push completes. Used for notifications, CI triggers, and deployments.
#!/bin/sh
# server: hooks/post-receive
while read oldrev newrev refname; do
if [ "$refname" = "refs/heads/main" ]; then
# Trigger deployment
curl -X POST https://deploy.example.com/trigger \
-H "Authorization: Bearer $DEPLOY_TOKEN"
fi
done
The hook lifecycle
graph TD A["Developer runs git commit"] --> B["pre-commit hook"] B -->|pass| C["prepare-commit-msg hook"] C --> D["User edits message"] D --> E["commit-msg hook"] E -->|pass| F["Commit created"] F --> G["post-commit hook"] B -->|fail| X["Commit aborted"] E -->|fail| X H["Developer runs git push"] --> I["pre-push hook"] I -->|pass| J["Data sent to server"] J --> K["pre-receive hook"] K -->|pass| L["update hook"] L -->|pass| M["Refs updated"] M --> N["post-receive hook"] I -->|fail| Y["Push aborted"] K -->|fail| Y
The hook lifecycle from commit to push. Any hook that fails aborts its operation.
Managing hooks with Husky
The main problem with Git hooks: they live in .git/hooks/, which is not version-controlled. Every developer must install them manually. Husky solves this.
Setup
npm install --save-dev husky
npx husky init
This creates a .husky/ directory in your project root and configures Git to use it.
Adding hooks
# Create a pre-commit hook
echo "npx lint-staged" > .husky/pre-commit
# Create a commit-msg hook
echo "npx commitlint --edit \$1" > .husky/commit-msg
Since .husky/ is version-controlled, every developer gets the hooks when they clone the repo and run npm install.
lint-staged
Husky pairs well with lint-staged, which runs linters only on staged files.
{
"lint-staged": {
"*.{js,ts}": ["eslint --fix", "prettier --write"],
"*.css": ["stylelint --fix"],
"*.md": ["prettier --write"]
}
}
This keeps pre-commit hooks fast. Only changed files are linted, not the entire codebase.
Secret scanning with hooks
Preventing secrets from entering the repository is critical. A pre-commit hook can scan for patterns.
#!/bin/sh
# Scan for common secret patterns
PATTERNS="AKIA[0-9A-Z]{16}|password\s*=\s*['\"][^'\"]+|-----BEGIN (RSA |EC )?PRIVATE KEY"
STAGED=$(git diff --cached --name-only)
for FILE in $STAGED; do
if git show ":$FILE" | grep -qEi "$PATTERNS"; then
echo "Potential secret detected in $FILE"
exit 1
fi
done
Tools like gitleaks and detect-secrets provide more comprehensive scanning with lower false positive rates.
# Using gitleaks as a pre-commit hook
npx gitleaks detect --staged --verbose
Bypassing hooks
Hooks can be skipped when necessary.
# Skip pre-commit and commit-msg hooks
git commit --no-verify -m "Emergency fix"
# Skip pre-push hook
git push --no-verify
Use --no-verify sparingly. If you find yourself using it often, the hooks need adjustment. They should be fast and accurate enough that skipping them is rare.
Hook best practices
Keep hooks fast. A pre-commit hook that takes 30 seconds will be bypassed constantly. Target under 5 seconds.
Use lint-staged. Run checks only on changed files.
Fail with clear messages. Tell the developer what went wrong and how to fix it.
Version-control hooks. Use Husky, pre-commit (Python), or Lefthook to share hooks across the team.
Layer your checks. Pre-commit for fast checks (lint, format). Pre-push for slower checks (tests). CI for everything else.
Alternatives to Husky
| Tool | Language | Notes |
|---|---|---|
| Husky | JavaScript | Most popular in JS ecosystem |
| pre-commit | Python | Language-agnostic hook framework |
| Lefthook | Go | Fast, config-driven, polyglot |
| overcommit | Ruby | Ruby ecosystem standard |
All solve the same problem: making hooks portable and version-controlled.
What comes next
Hooks automate checks at the developer level. The next article covers monorepos and large repository management, where the challenges shift to scale. Sparse checkout, shallow clones, and Git LFS keep large repositories workable.