How to Use Git Hooks: Preventing Direct Pushes to main with pre-push, and Sharing Them Across a Team (lefthook/husky)

From the basics of what Git hooks are and when each one fires, through implementing a pre-push hook that blocks direct pushes to main, why .git/hooks isn't shared and core.hooksPath, sharing hooks across a team with lefthook/husky, and how that relates to GitHub branch protection.

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:

HookWhen It FiresTypical Use
pre-commitWhen git commit runs, before the commit message is enteredRunning lint, formatting, or quick tests
prepare-commit-msgBefore the commit message editor opensInserting a message template
commit-msgAfter the commit message is finalized, before the commit is createdValidating message format (e.g. Conventional Commits)
post-commitAfter a commit is createdNotifications, follow-up processing
pre-rebaseBefore git rebase runsForbidding rebase on specific branches, etc.
post-checkoutAfter git checkout/git switch runsPrompting a dependency reinstall, etc.
post-mergeAfter git merge runsPost-merge follow-up processing
pre-pushRight before git push sends data to the remoteBlocking 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.

  1. Navigate to the .git/hooks directory

    cd .git/hooks
    
  2. Create the pre-push script

    Create a file named pre-push with 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 is main.
    • exit 1: Returns a non-zero exit code, aborting the push.
    • exit 0: Lets the push continue.
  3. 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.

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.

いちばんやさしいGit&GitHubの教本 第3版(横田紋奈・宇賀神みずき、インプレス)

Classic books covering other fields are collected in 10 Recommended Technical Books for Engineers (in Japanese).