Git hooks are scripts that run automatically when specific Git events — commits, pushes, and so on — occur. They’re a handy way to bake custom rules into your local development workflow, but if you use them without understanding what hooks actually exist, why they aren’t shared with your team, or how they should divide responsibility with server-side protections, you can run into some unexpected pitfalls. This article walks through the full picture: implementing a pre-push hook that blocks direct pushes to main, sharing hooks across a team, and how all of this relates to GitHub’s branch protection.
1. What Are Git Hooks?
Git hooks are executable scripts placed in a repository’s .git/hooks directory, automatically invoked before or after specific Git operations. Hooks fall broadly into client-side hooks and server-side hooks; the pre-push hook covered in the first half of this article belongs to the former (server-side hooks are covered in Section 5).
Here are the major client-side hooks and when each one fires:
| Hook | When It Fires | Typical Use |
|---|---|---|
pre-commit | When git commit runs, before the commit message is entered | Running lint, formatting, or quick tests |
prepare-commit-msg | Before the commit message editor opens | Inserting a message template |
commit-msg | After the commit message is finalized, before the commit is created | Validating message format (e.g. Conventional Commits) |
post-commit | After a commit is created | Notifications, follow-up processing |
pre-rebase | Before git rebase runs | Forbidding rebase on specific branches, etc. |
post-checkout | After git checkout/git switch runs | Prompting a dependency reinstall, etc. |
post-merge | After git merge runs | Post-merge follow-up processing |
pre-push | Right before git push sends data to the remote | Blocking direct pushes to protected branches, running tests before push |
When you initialize a repository, .git/hooks comes pre-populated with sample scripts carrying a .sample extension, like pre-commit.sample. To activate one, you rename it to the extensionless hook name (e.g. pre-commit), give it execute permission, and edit its contents. If a hook returns a non-zero exit code, the corresponding Git operation (commit or push) is aborted.
2. Using pre-push to Block Direct Pushes to main
The pre-push hook runs right before the git push command sends data to the remote repository. If this hook returns a non-zero exit code, the entire push operation is aborted.
The Simple Implementation: Checking the Current Branch
The easiest implementation is to check the name of the branch you currently have checked out.
Navigate to the
.git/hooksdirectorycd .git/hooksCreate the
pre-pushscriptCreate a file named
pre-pushwith the following content.#!/bin/bash # Get the current branch name current_branch=$(git symbolic-ref HEAD --short) # If the current branch is 'main', display an error message and prohibit the push if [ "$current_branch" = "main" ]; then echo "Error: Direct push to 'main' branch is not allowed." echo "Please create a new branch and open a pull request." exit 1 # Returning a non-zero exit code aborts the push fi # For any other branch, allow the push exit 0#!/bin/bash: Specifies that the script should be executed with Bash.git symbolic-ref HEAD --short: Gets the name of the currently checked-out branch.if [ "$current_branch" = "main" ]; then ... fi: Checks whether the current branch ismain.exit 1: Returns a non-zero exit code, aborting the push.exit 0: Lets the push continue.
Grant execute permission
chmod +x pre-push
Now, if you run git push while main is checked out, an error message is displayed and the push is rejected.
A More Robust Implementation: Checking the Destination ref
The simple implementation above has a blind spot. The pre-push hook is actually invoked with two arguments — <remote name> <remote URL> — and information about what’s being pushed is passed in separately via standard input (stdin). For each ref (branch) being pushed, stdin receives a line in the following format:
<local ref> SP <local sha1> SP <remote ref> SP <remote sha1> LF
An implementation that only checks the current branch name misses a case like being on the feature branch and pushing to main via an explicit refspec — git push origin feature:main. Reading the destination information directly gives you a more robust implementation:
#!/bin/sh
protected_branch='refs/heads/main'
while read local_ref local_sha remote_ref remote_sha
do
if [ "$remote_ref" = "$protected_branch" ]; then
echo "Error: Direct push to 'main' is not allowed."
echo "Please create a new branch and open a pull request."
exit 1
fi
done
exit 0
The while read loop reads each line of stdin and directly checks whether the destination ref ($remote_ref) matches refs/heads/main. Regardless of what your current local branch is, this precisely detects only operations that actually push toward main — which is where it differs from the simple implementation.
3. The Limits of .git/hooks, and core.hooksPath
Every implementation so far shares a common weakness. The .git/hooks directory is part of a Git repository’s metadata area, not something that gets committed. So when you git clone a repository, the contents of .git/hooks aren’t copied along with it — other developers end up with nothing but *.sample files. In other words, hooks written directly into .git/hooks can’t, by their very nature, be shared across a team.
One way around this limitation is the core.hooksPath setting. It tells Git that “the directory it should look in for hooks may be changed to somewhere other than .git/hooks,” and it’s available from Git 2.9 onward. If you point it at a directory inside the repository (one that does get committed), the hooks themselves can live under version control.
mkdir .githooks
mv .git/hooks/pre-push .githooks/pre-push
git config core.hooksPath .githooks
git add .githooks
git commit -m "Add shared pre-push hook"
But core.hooksPath has its own limitation. The setting itself is stored in each developer’s local Git config (.git/config), so every developer has to manually run git config core.hooksPath .githooks themselves after cloning. In practice, running core.hooksPath on its own means “the hook’s contents can be shared, but the small step of enabling it still falls on every team member.” Automating away that “small step of enabling it” is exactly the role played by tools like lefthook and husky, covered next.
4. Sharing Hooks Across a Team: lefthook/husky
Git hook management tools like lefthook and husky eliminate the need to manually run core.hooksPath, automatically activating hooks at the point of npm install or initial setup.
lefthook
lefthook is a Git hook manager written in Go, where you define hooks simply by listing commands in a YAML config file (lefthook.yml). Running lefthook install (or wiring it into package.json’s prepare script) unpacks the config into the repository’s actual Git hooks.
pre-push:
commands:
protect-main:
run: |
branch=$(git symbolic-ref HEAD --short)
if [ "$branch" = "main" ]; then
echo "Error: Direct push to 'main' branch is not allowed."
exit 1
fi
This blog itself actually uses lefthook — running Prettier auto-formatting on pre-commit, and a Hugo build check on pre-push. The biggest difference from writing directly into .git/hooks is that simply committing the config file gives everyone who clones the repository the same checks.
husky
husky is a Git hook manager widely used in the Node.js ecosystem. Running npx husky init sets up a .husky directory and adds a prepare script to package.json. From there, all you need to do is create a file named after the hook under .husky.
#!/usr/bin/env sh
branch=$(git symbolic-ref HEAD --short)
if [ "$branch" = "main" ]; then
echo "Error: Direct push to 'main' branch is not allowed."
exit 1
fi
Because the prepare script runs automatically whenever npm install runs, simply cloning the repository and installing dependencies delivers the same hooks to everyone on the team. lefthook and husky share the same core design philosophy — commit the hook’s contents to the repository, then auto-activate it on install — so which one you pick comes down to which toolchain you’d rather lean on, Go-flavored tooling or the Node.js ecosystem.
5. Server-Side Protection: Dividing Responsibility with GitHub Branch Protection
Every hook covered so far shares a common limitation. The git push --no-verify option lets you skip the hook entirely and push anyway. There’s also no way to stop a push coming from an environment where the hook was never installed correctly. In short, client-side hooks are only a supplementary mechanism for catching a developer’s own honest mistakes early, locally — they carry no enforcement power.
If you want actual enforcement, you need server-side protection. On GitHub, you can set up protection rules for the main branch from the repository’s Settings > Branches (or the newer Rulesets feature). Typical settings include:
- Prohibiting merges that don’t go through a pull request (blocking direct pushes)
- Requiring specific status checks (CI) to pass before merging
- Prohibiting force pushes
- Restricting which users or teams can push
Because branch protection is enforced server-side on GitHub, it can’t be bypassed by client-side tricks like --no-verify. So the practical way to divide responsibility is: client-side hooks give developers early feedback on their own machine, while branch protection is the final enforcement layer across the whole repository. Rather than relying on hooks alone to fully prevent direct pushes to a protected branch, it’s best to combine both.
Incidentally, if you haven’t set up your connection to GitHub yet (an SSH key), it’s worth doing that first — see How to Set Up an SSH Connection to GitHub (in Japanese) — so the push-related setup covered here goes smoothly.
6. Summary
Git hooks are scripts that run automatically alongside events like commits and pushes, and a pre-push hook lets you block direct pushes to main locally. Because .git/hooks isn’t committed to the repository, though, it isn’t shared with your team as-is. core.hooksPath lets you put the hook’s contents under version control, but the step of enabling it still falls on each developer. Adopting a tool like lefthook or husky automatically activates hooks at install time, so the whole team shares the same rules. And ultimately, hooks are only early feedback on a developer’s own machine — if you want enforcement with real teeth, you need to pair them with a server-side mechanism like GitHub’s branch protection.
Frequently Asked Questions (FAQ)
Q. Why don’t hooks get shared via git clone?
The .git/hooks directory holds a Git repository’s management metadata — it isn’t part of the files that get committed (the working tree’s contents). When you git clone, a fresh .git directory is created, but its hooks subdirectory starts out with nothing but *.sample files. Even if you place a working hook script in .git/hooks, it’s treated as a kind of local config file, so it isn’t copied over when another developer clones the same repository. To get around this, you either point core.hooksPath at a directory that does get committed, or use a tool like lefthook or husky.
Q. How do you temporarily skip a hook?
Adding the --no-verify flag — as in git push --no-verify (or git commit --no-verify to skip pre-commit and commit-msg) — lets the command run while skipping the hook. That’s fine when you’re intentionally bypassing validation for something like a quick sanity check or an emergency fix, but keep in mind that a rule like “no direct pushes to main” can just as easily be sidestepped with this same flag. If you want to enforce a rule across the whole team, you can’t rely on hooks alone — you need to pair them with server-side protection.
Q. If you have branch protection, do you still need hooks?
Yes. Branch protection carries the final enforcement power across the whole repository, but it’s only evaluated after the push — once it’s already reached the server. Hooks offer immediacy that branch protection doesn’t: catching an error locally before you push, not having to wait for CI to run, and working even offline. The two aren’t a substitute for each other but complement one another, so it’s best to combine the early feedback hooks provide with the enforcement branch protection provides.
Related Articles
- Managing Unified Git Commit Messages with Commitizen - How to automate and standardize commit message conventions
- Git Branch Strategies: rebase vs merge and Team Workflows - Best practices for branch management in team development
Related Books
If you want to go back and build a solid foundation in Git — from the basics through team development workflows — the following book is a good reference.
Classic books covering other fields are collected in 10 Recommended Technical Books for Engineers (in Japanese).