A Costly Lesson from a Mistaken “Enter”
Six months ago on a Friday night, my team almost lost a week’s worth of work due to a basic mistake. A new developer, unfamiliar with resolving conflicts, accidentally ran git push --force directly into the main branch. The result? 48 clean commits vanished in 2 seconds, replaced by their own unfinished code.
In reality, we cannot manage projects based solely on trust or verbal reminders. In a team of 15-20 people, mistakes are inevitable. That’s when I realized: we need a hard gate right on the server. Client-side hooks are great, but they are easily bypassed with the --no-verify flag. We need something more authoritative.
Why GitHub Actions or CI/CD Aren’t Enough
Many might wonder: “Isn’t using GitHub Actions or GitLab CI to check for errors enough?”. Yes, but they still have a major loophole.
First, Client-side hooks reside in the .git/hooks directory on individual machines. Just adding the -n flag when committing makes all barriers disappear. It’s like asking a security guard to lock the door, but giving them the option to ignore it if they feel lazy.
Second, CI/CD Pipelines usually run after the code has reached the server. Even if the CI shows a bright red error, that “junk” code is already in the Git history. To clean it up, you have to revert or reset, creating unnecessary extra commits.
The ultimate solution is Server-side Hooks. These are scripts that run directly on the server hosting the repository. If the script returns an error (a non-zero exit code), the push command is immediately rejected. The faulty code doesn’t even touch the server’s disk.
Why Team Rules Are Frequently Broken
After observing how the team works, I identified 3 reasons why repositories become chaotic:
- Overly Broad Permissions: Anyone can push code directly to
mainordevelop. - Arbitrary Commit Messages: Some write “fix bug,” others just type “.”, making future lookups extremely frustrating.
- Forgetting Local Tests: Logic errors are pushed because developers are in a hurry to grab a drink or head home early.
The Perfect Duo: pre-receive and update hooks
On a Git server, these are the two most important “guards” you need to master:
1. Pre-receive Hook
This script runs as soon as the server receives a push command. It triggers only once per push operation, regardless of how many branches you are pushing. Input data from stdin is formatted as: <old-rev> <new-rev> <ref-name>.
I often use it to check general rules. For example: banning files larger than 5MB or blocking sensitive files like .env and node_modules from entering the repository.
2. Update Hook
Unlike the previous one, the update hook runs individually for each branch. If you push 3 branches at once, the script will run 3 times. This feature is extremely useful for setting specific rules for each branch, such as banning force pushes on main while allowing them on feature/ branches.
Practical Implementation on Production
Suppose you have a Bare Repository at /home/git/project.git. Enter the hooks/ directory inside it to get started.
Step 1: Blocking Force Pushes on Critical Branches using the update hook
Create the hooks/update file and grant it chmod +x permissions:
#!/bin/bash
refname=$1
oldrev=$2
newrev=$3
# Only protect main branch
if [ "$refname" == "refs/heads/main" ]; then
# 1. Do not allow branch deletion
if [ "$newrev" == "0000000000000000000000000000000000000000" ]; then
echo "[ERROR] Stop! The main branch is untouchable."
exit 1
fi
# 2. Block Force Push by checking descendants
if [ "$oldrev" != "0000000000000000000000000000000000000000" ]; then
base=$(git merge-base $oldrev $newrev)
if [ "$base" != "$oldrev" ]; then
echo "[ERROR] Force push is banned on main. Please pull and merge before trying again!"
exit 1
fi
fi
fi
exit 0
Step 2: Standardizing Commit Formats using the pre-receive hook
To make the Git history look more professional, I force everyone to use prefixes like FEAT:, FIX:, or CHORE::
#!/bin/bash
while read oldrev newrev refname; do
# Get the list of new commits (excluding what's already on the server)
commits=$(git rev-list $oldrev..$newrev)
for commit in $commits; do
subject=$(git log -1 --format=%s $commit)
if [[ ! $subject =~ ^(FEAT|FIX|CHORE|DOCS):\ .+ ]]; then
echo "[ERROR] Invalid commit message format: '$subject'"
echo "Correct pattern: 'FEAT: Add payment via MoMo feature'"
exit 1
fi
done
done
exit 0
The Integration Secret: Speed is Top Priority
My hard-earned experience: Never run Unit Tests in Server Hooks. Why? Because hooks run synchronously. If a developer has to wait 5 minutes just to know if their push succeeded, they will get frustrated and immediately look for ways to bypass the rules.
Optimal Strategy:
- Server Hooks: Only check for extremely fast things (under 2 seconds) like commit formats, branch names, and file sizes.
- CI/CD (GitHub/GitLab): Reserved for heavy tasks like running linters, logic tests, and building Docker images.
Results After 6 Months of Implementation
When first implemented, the team complained about being “rejected” by the server constantly. But after just one month, the habit of writing proper commits became second nature. The Git history is now as clean as a textbook. Most importantly, I no longer have to stay up late to recover data because someone accidentally force-pushed.
If you are leading an important project, set up these “checkpoints” today. It not only protects the code but also builds team discipline in the most natural way possible.

