Git hooks are scripts that run automatically when Git reaches specific events. They help you automate repetitive checks so problems are caught before code is committed or pushed.
Hooks are usually grouped into two categories:
Tip: Hooks are best for checks people frequently forget, such as formatting, test commands, or commit message rules.
Client-side hooks run inside a local repository during actions like commit, merge, and push. Common examples include:
pre-commitcommit-msgpre-pushServer-side hooks run on a remote repository and can reject pushes or enforce policy. On hosted platforms, CI and branch protection often serve a similar role.
Note: Local hooks improve developer experience, but CI is still the shared source of truth because local hooks can be skipped or may differ across machines.
pre-commit runs before the commit is created. If the script exits with a non-zero status, Git stops the commit.
Typical uses:
Example:
#!/bin/sh
npm run lint
npm run format:checkcommit-msg validates the commit message itself. It is the usual place to enforce Conventional Commits.
#!/bin/sh
message_file="$1"
grep -Eq '^(feat|fix|docs|chore): .+' "$message_file" || {
echo "Commit message must start with feat:, fix:, docs:, or chore:"
exit 1
}pre-push runs right before Git sends commits to the remote. It is useful for checks that are important but slightly heavier than commit-time checks.
#!/bin/sh
npm testA repository's built-in hooks live in:
.git/hooks/Git often creates sample files there, such as pre-commit.sample. To install a hook manually, create a file with the exact hook name and make it executable.
Example:
nano .git/hooks/pre-commit
chmod +x .git/hooks/pre-commitIf the file is not executable, Git ignores it.
Here is a small pre-commit hook that runs linting:
cat > .git/hooks/pre-commit <<'HOOK'
#!/bin/sh
npm run lint
HOOK
chmod +x .git/hooks/pre-commitAnd a commit-msg hook that blocks non-standard messages:
cat > .git/hooks/commit-msg <<'HOOK'
#!/bin/sh
message_file="$1"
grep -Eq '^(feat|fix|docs|chore): .+' "$message_file" || exit 1
HOOK
chmod +x .git/hooks/commit-msgPlain .git/hooks/ scripts are local only. They are not shared automatically when another developer clones the repository. That leads to three common problems:
Husky is a popular solution in JavaScript projects because it stores hook definitions inside the repository.
Basic setup:
npm install --save-dev husky
npx husky initThat creates a .husky/ directory. A typical pre-commit hook might look like this:
#!/bin/sh
. "./_/husky.sh"
npm run lint
npm run format:checkA commit-msg hook can call commitlint:
#!/bin/sh
. "./_/husky.sh"
npx commitlint --edit "$1"Fast hooks get used. Slow hooks get bypassed. Put quick checks in pre-commit and slightly heavier ones in pre-push.
Hooks provide local convenience, but CI should still verify important rules centrally.
If a hook blocks work, explain what failed and which command fixes it.
Git hooks automate work around commits and pushes. pre-commit is great for lint or format checks, commit-msg enforces message rules, and pre-push can run tests before code is shared. Manual hooks live in .git/hooks/ and need chmod +x, while Husky makes hooks versioned and team-friendly.
<MCQExercise
exercise={{
id: 'git-hooks-q1',
title: 'Exercise 1: Choosing the right hook',
description: 'Which hook should you use to reject commit messages that do not follow Conventional Commits?',
options: [
{ id: 'a', text: 'pre-commit' },
{ id: 'b', text: 'commit-msg' },
{ id: 'c', text: 'pre-push' },
{ id: 'd', text: 'post-merge' },
],
correctId: 'b',
explanation: 'The commit-msg hook receives the commit message file and is designed for validating commit message format. That makes it the correct place to enforce standards like Conventional Commits.',
wrongExplanations: {
a: 'A pre-commit hook usually runs code checks such as linting or formatting before the commit is created.',
c: 'A pre-push hook runs later, before sending commits to a remote, so it is not the normal place to validate each commit message.',
d: 'A post-merge hook runs after a merge and does not control whether a commit message is accepted.',
},
}}
/>
<MCQExercise
exercise={{
id: 'git-hooks-q2',
title: 'Exercise 2: Installing a local hook',
description: 'What step is required after creating a script in .git/hooks/pre-commit so Git can run it?',
options: [
{ id: 'a', text: 'Run git hook enable pre-commit' },
{ id: 'b', text: 'Push the hook to origin first' },
{ id: 'c', text: 'Make the file executable with chmod +x' },
{ id: 'd', text: 'Rename it to pre-commit.sh' },
],
correctId: 'c',
explanation: 'Git expects hook files to be executable. After placing the script in .git/hooks/, you normally run chmod +x on the file so Git can execute it.',
wrongExplanations: {
a: 'There is no standard git hook enable command for normal local hook installation.',
b: 'Local hooks are not shared automatically by pushing to a remote repository.',
d: 'Git looks for the exact hook filename like pre-commit, not a .sh extension.',
},
}}
/>
<MCQExercise
exercise={{
id: 'git-hooks-q3',
title: 'Exercise 3: Sharing hooks with a team',
description: 'Why do many JavaScript teams use Husky instead of relying only on .git/hooks/?',
options: [
{ id: 'a', text: 'Because Husky turns Git into a centralized version control system' },
{ id: 'b', text: 'Because Husky stores hooks in the repository so the team can version and share them' },
{ id: 'c', text: 'Because Husky replaces the need for CI pipelines entirely' },
{ id: 'd', text: 'Because Husky only works for server-side hooks on Git hosting platforms' },
],
correctId: 'b',
explanation: 'The lesson highlighted that plain .git/hooks/ scripts are local to each clone. Husky helps by keeping hook definitions in the repository, which makes them reviewable, repeatable, and easier to share across a team.',
wrongExplanations: {
a: 'Husky manages hooks, but it does not change Git's distributed model.',
c: 'Hooks improve local feedback, but CI is still needed for shared verification on the server side.',
d: 'Husky is mainly used for client-side hooks committed with the project, not for remote Git hosting server hooks.',
},
}}
/>
Because .git/hooks is outside the repository's tracked content, so hooks are never committed, pushed or cloned. Every developer would have to create them by hand, and nothing keeps them in sync. The standard fix is a manager such as Husky or pre-commit, which stores hook definitions in tracked files and points Git's core.hooksPath at a committed directory during install, so a fresh clone plus npm install sets everything up.
Client-side hooks run on a developer's machine, pre-commit, commit-msg, pre-push, and are about fast feedback: catching a lint error in two seconds rather than two minutes of CI. Server-side hooks such as pre-receive and update run on the remote when a push arrives, and are about enforcement, because they cannot be skipped. Hosted platforms generally do not expose server-side hooks; branch protection rules and required status checks fill that role instead.
Yes, trivially, git commit --no-verify skips pre-commit and commit-msg, and git push --no-verify skips pre-push. That is a feature, since you sometimes need to commit through a broken hook, but it means client-side hooks are a convenience and not a control. Anything that genuinely must hold has to be checked where the developer cannot reach it: a required CI check on a pull request, or a server-side hook on self-hosted infrastructure.
Only fast checks, and preferably only on the files being committed. Formatting and linting staged files is the classic use, lint-staged exists specifically for this, plus a scan for accidentally committed secrets. Keep it under a couple of seconds: a pre-commit hook that runs the full test suite gets disabled by the team within a week, and rightly so. Put the slow, comprehensive checks in CI, where they belong.
Install it as a dev dependency, run npx husky init, and it creates a .husky directory and a prepare script so the hooks install automatically after npm install. Add a hook by writing the command into .husky/pre-commit, commonly npx lint-staged, and commit that file. Because .husky is tracked, everyone gets the same hooks on their next install, which is the whole problem raw .git/hooks cannot solve.
Compare GitFlow, GitHub Flow and trunk-based development honestly, see which suits which team, why long-lived branches hurt and how feature flags help.
Configure Git once and stop fighting it: .gitconfig scopes, per-directory identities with includeIf, handy aliases and the defaults worth changing.
Pin one repository inside another with Git submodules: clone with --recurse-submodules, know the two update commands, handle detached HEAD and subtrees.