Git — Hooks & Automation
Git hooks are scripts that run automatically at specific points in Git's workflow. They let you enforce policies, run checks, and automate tasks — from preventing commits with lint errors to deploying code after pushes.
This guide covers both client-side and server-side hooks, writing custom hook scripts, and modern tooling like husky, lint-staged, and commitlint.
Client-side hooks run on your local machine during Git operations. They are stored in .git/hooks/ and are not shared with the repository by default. The most commonly used hooks are:
| Hook | Trigger | Common Use |
|---|---|---|
| pre-commit | Before commit message editor | Lint code, run tests, check formatting |
| prepare-commit-msg | After default message is created | Add issue numbers, templates |
| commit-msg | After commit message is edited | Validate commit message format |
| post-commit | After commit completes | Notifications, CI triggers |
| pre-push | Before push to remote | Run full test suite, check secrets |
| post-merge | After merge completes | Install dependencies, run migrations |
| pre-rebase | Before rebasing | Ensure branch is not shared |
Hooks are executable scripts named exactly after the hook event. They receive input via stdin and command-line arguments, and they can stop the Git operation by exiting with a non-zero status.
| 1 | # Example: Simple pre-commit hook (.git/hooks/pre-commit) |
| 2 | #!/bin/sh |
| 3 | echo "Running linter..." |
| 4 | npm run lint |
| 5 | if [ $? -ne 0 ]; then |
| 6 | echo "Linting failed. Commit aborted." |
| 7 | exit 1 |
| 8 | fi |
| 9 | |
| 10 | # Make the hook executable (required!) |
| 11 | chmod +x .git/hooks/pre-commit |
info
Server-side hooks run on the remote repository (e.g., GitHub, GitLab, or your own Git server) when receiving pushes. They are useful for enforcing team-wide policies.
| Hook | Trigger | Use Case |
|---|---|---|
| pre-receive | Before any refs are updated | Block force pushes, check file sizes |
| update | Before each ref is updated | Per-branch policies, fast-forward only |
| post-receive | After all refs are updated | Deploy, CI, notifications, changelog |
| 1 | # Example: post-receive hook for deployment |
| 2 | #!/bin/sh |
| 3 | # Deploy main branch to production |
| 4 | while read oldrev newrev refname |
| 5 | do |
| 6 | if [ "$refname" = "refs/heads/main" ]; then |
| 7 | echo "Deploying main to production..." |
| 8 | git --work-tree=/var/www/app checkout -f main |
| 9 | cd /var/www/app && npm install && pm2 restart app |
| 10 | echo "Deployment complete." |
| 11 | fi |
| 12 | done |
| 13 | |
| 14 | # Install as server-side hook: |
| 15 | # On your Git server, place the script at: |
| 16 | # /path/to/repo.git/hooks/post-receive |
| 17 | # Make it executable: chmod +x .../post-receive |
Hooks can be written in any scripting language (bash, Python, Node.js, Ruby, etc.) as long as the file is executable. Each hook receives specific input that you can use to make decisions.
| 1 | # pre-push hook — run tests before push |
| 2 | #!/bin/sh |
| 3 | remote="$1" |
| 4 | url="$2" |
| 5 | |
| 6 | echo "Running tests before push..." |
| 7 | npm test || exit 1 |
| 8 | |
| 9 | # Check for sensitive data |
| 10 | if git diff --cached -S"API_KEY" --quiet; then |
| 11 | echo "Error: Found potential API key in staged changes!" |
| 12 | exit 1 |
| 13 | fi |
| 14 | |
| 15 | exit 0 |
| 1 | # commit-msg hook — validate commit message format |
| 2 | #!/bin/sh |
| 3 | commit_msg_file="$1" |
| 4 | commit_msg=$(cat "$commit_msg_file") |
| 5 | |
| 6 | # Check for conventional commit format |
| 7 | if ! echo "$commit_msg" | grep -qE "^(feat|fix|chore|docs|style|refactor|perf|test|ci)((.+))?!?: .{1,}$"; then |
| 8 | echo "ERROR: Commit message must follow Conventional Commits format" |
| 9 | echo "Examples:" |
| 10 | echo " feat(auth): add login page" |
| 11 | echo " fix(api): handle null response" |
| 12 | echo " docs: update README" |
| 13 | exit 1 |
| 14 | fi |
| 15 | |
| 16 | # Check message length |
| 17 | if [ ${#commit_msg} -gt 72 ]; then |
| 18 | echo "ERROR: Commit message must be 72 characters or less" |
| 19 | exit 1 |
| 20 | fi |
| 21 | |
| 22 | exit 0 |
husky is the most popular tool for managing Git hooks in Node.js projects. It makes hooks configurable via package.json or .husky/ directory, and ensures hooks are shared with everyone who installs the project.
| 1 | # Install husky (v9+) |
| 2 | npm install --save-dev husky |
| 3 | |
| 4 | # Initialize husky (creates .husky/ directory) |
| 5 | npx husky init |
| 6 | |
| 7 | # This creates .husky/pre-commit with: |
| 8 | # npx lint-staged |
| 9 | |
| 10 | # Add a hook |
| 11 | npx husky add .husky/commit-msg 'npx --no -- commitlint --edit "$1"' |
| 12 | |
| 13 | # Manually create a hook file (.husky/pre-push) |
| 14 | #!/bin/sh |
| 15 | . "$(dirname "$0")/_/husky.sh" |
| 16 | npm test |
| 17 | |
| 18 | # The .husky/ directory structure: |
| 19 | # .husky/ |
| 20 | # _/ (husky internal scripts) |
| 21 | # pre-commit (runs before commit) |
| 22 | # commit-msg (validates commit message) |
| 23 | # pre-push (runs before push) |
| 24 | |
| 25 | # Package.json integration (husky v4 style — older) |
| 26 | # "husky": { |
| 27 | # "hooks": { |
| 28 | # "pre-commit": "lint-staged", |
| 29 | # "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" |
| 30 | # } |
| 31 | # } |
| 32 | |
| 33 | # Modern approach (husky v9+): |
| 34 | # Hooks are files in .husky/ directory, |
| 35 | # committed to the repository and shared with the team. |
pro tip
lint-staged runs linters only on files staged for commit. This is dramatically faster than running the linter on the entire project and ensures that only the code you're about to commit meets quality standards.
| 1 | # Install lint-staged |
| 2 | npm install --save-dev lint-staged |
| 3 | |
| 4 | # Configure in package.json: |
| 5 | { |
| 6 | "lint-staged": { |
| 7 | "*.{js,ts,tsx}": ["eslint --fix", "prettier --write"], |
| 8 | "*.{json,md,css}": ["prettier --write"], |
| 9 | "*.py": ["black --check", "flake8"] |
| 10 | } |
| 11 | } |
| 12 | |
| 13 | # Works with husky pre-commit hook: |
| 14 | # .husky/pre-commit: |
| 15 | # npx lint-staged |
| 16 | |
| 17 | # What happens when you commit: |
| 18 | # 1. lint-staged gets the list of staged files |
| 19 | # 2. Files matching patterns get the configured commands |
| 20 | # 3. If any command fails, the commit is aborted |
| 21 | # 4. Files are re-staged after formatting changes |
| 22 | |
| 23 | # Use with git add: |
| 24 | git add src/app.js src/utils.js |
| 25 | git commit -m "feat: add user dashboard" |
| 26 | # lint-staged automatically runs on the two staged files |
info
Consistent commit messages make history readable and enable automated changelog generation, semantic versioning, and release notes. The most widely adopted convention is Conventional Commits.
| 1 | # Conventional Commits format: |
| 2 | <type>[optional scope]: <description> |
| 3 | |
| 4 | [optional body] |
| 5 | |
| 6 | [optional footer(s)] |
| 7 | |
| 8 | # Types: |
| 9 | feat: A new feature |
| 10 | fix: A bug fix |
| 11 | docs: Documentation only changes |
| 12 | style: Formatting, missing semicolons, etc. |
| 13 | refactor: Code change that neither fixes a bug nor adds a feature |
| 14 | perf: Performance improvement |
| 15 | test: Adding or fixing tests |
| 16 | chore: Build process, dependencies, etc. |
| 17 | ci: CI/CD configuration changes |
| 18 | |
| 19 | # Examples: |
| 20 | feat(auth): add OAuth login flow |
| 21 | fix(api): handle 429 rate limit response |
| 22 | docs: update API reference for v2 |
| 23 | refactor(core): extract database connection pool |
| 24 | perf(cache): reduce TTL on user sessions |
| 25 | ci: add deployment to staging environment |
| 26 | |
| 27 | # Breaking changes: |
| 28 | feat(api)!: remove deprecated /v1/endpoint |
| 29 | |
| 30 | BREAKING CHANGE: The /v1/ endpoint has been removed. |
| 31 | Use /v2/ endpoints instead. |
commitlint enforces these conventions by checking commit messages against a configurable set of rules.
| 1 | # Install commitlint |
| 2 | npm install --save-dev @commitlint/cli @commitlint/config-conventional |
| 3 | |
| 4 | # Configure commitlint: |
| 5 | echo "module.exports = {extends: ['@commitlint/config-conventional']}" > commitlint.config.js |
| 6 | |
| 7 | # Or use a file-based config (.commitlintrc.js or .commitlintrc.json) |
| 8 | # { |
| 9 | # "extends": ["@commitlint/config-conventional"], |
| 10 | # "rules": { |
| 11 | # "type-enum": [2, "always", ["feat", "fix", "docs", "chore"]], |
| 12 | # "subject-case": [2, "always", "sentence-case"] |
| 13 | # } |
| 14 | # } |
| 15 | |
| 16 | # Use with husky commit-msg hook: |
| 17 | # .husky/commit-msg: |
| 18 | npx --no -- commitlint --edit "$1" |
| 19 | |
| 20 | # If the commit message doesn't follow conventions: |
| 21 | # ⧗ input: oops forgot to lint |
| 22 | # ✖ subject must not be sentence-case [subject-case] |
| 23 | # ✖ type must be one of [feat, fix, docs, style, ...] [type-enum] |
| 24 | # ✖ found 2 problems, 0 warnings |
best practice
Keep Hooks Fast
Developers will disable hooks that take too long. Run fast checks (linting changed files) in pre-commit and leave slow checks (full test suite) for pre-push or CI.
Provide a Bypass Mechanism
Allow developers to skip hooks with git commit --no-verify or git push --no-verify for emergencies. Trust your team — hooks are guardrails, not prison bars.
Use Node.js for Complex Logic
For hooks with complex logic, write them in Node.js instead of bash. They are easier to test, maintain, and debug. husky handles the invocation, and your script can use any npm packages.
Version Your Hooks
Keep hook scripts under version control. Whether you use husky, a hooks/ directory, or a template, ensure hooks are tracked, reviewed, and updated like any other code.