Originally sourced from DZone. This article expands on the lessons learned when optimizing Git hook performance after migrating a central repository from SVN to Git.
Background: Why Git Hooks Matter After an SVN Migration
Switching a team’s primary code repository from SVN to Git brings considerable freedom — but also new responsibilities. One powerful mechanism Git provides is hooks: executable scripts placed in the .git/hooks directory (or your_repo.git/hooks for a bare repository) that run automatically at specific points in the Git workflow. Hooks can write output to the console during a push or commit, and can even abort those operations entirely.
Common use cases for hooks on a central (bare) repository include:
- Preventing large binary files from being pushed into the repository.
- Enforcing a commit-message naming convention (e.g., requiring an issue tracker ID).
- Blocking pushes to protected branches without passing CI checks.
The hook covered in depth here is the update hook, which is the right tool for enforcing rules on a central shared repository.
Understanding the Update Hook
The update hook fires on the server side every time someone pushes to a repository. It runs once per ref being updated, meaning a push that updates three branches will invoke the hook three times.
Git passes three positional arguments to the hook:
- The ref name being updated — for example,
refs/heads/my_fancy_branch. - The old object SHA-1 — the commit currently at the tip of that ref in the remote repository.
- The new object SHA-1 — the commit at the tip of the ref in the developer’s local repository.
Exit with a non-zero status to reject the push; exit with zero to accept it. Because the hook runs before branch labels are updated, you can inspect exactly which commits are new without worrying about race conditions with other pushes.
The Naive Approach — and Why It Falls Apart
The most obvious implementation iterates over every commit between $old_ref and $new_ref and checks each one individually:
# Pseudo-code — do not copy verbatim
commits=$(git rev-list $old_ref..$new_ref)
for commit in $commits; do
if ! check_commit "$commit"; then
echo "Commit $commit failed the check."
exit 1
fi
doneCode language: PHP (php)
This works in simple cases but has three serious problems:
- Slow per-commit overhead. Each call to
check_committypically spawns one or more subprocesses (e.g.,grep,awk,git cat-file). Process-spawn overhead accumulates quickly across hundreds of commits. - Early exit hides multiple problems. The script exits on the first bad commit. Developers must fix issues one at a time and re-push repeatedly before they see all violations — a frustrating experience.
- Merge commits inflate the revision range.
git rev-list $old_ref..$new_refreturns all reachable commits, including those pulled in frommastervia a merge. Those commits were already validated when they landed onmaster; re-checking them wastes time and can push hook execution into the minutes range on long-running feature branches.
The Merge-Inflation Problem Illustrated
Consider this common workflow:
- Developer works on
feature_branch. - Developer merges
masterintofeature_branchto stay current. - Developer adds a few more commits on
feature_branch. - Developer pushes.
The naive rev-list call returns every commit reachable from $new_ref but not from $old_ref — including potentially hundreds of commits that came from master. On a busy repository, a feature branch delayed by a few weeks could drag hook execution to several minutes per push. That is unacceptable for developer experience.
The Smarter Approach
Two goals drive the improved design:
- Skip commits that are already reachable from existing branches (i.e., already verified).
- Validate all remaining commits in a single pipeline rather than one at a time.
Filtering Already-Known Commits
The key insight is that the update hook runs after all new objects have been received but before any branch labels are moved. That means existing branch labels still point to where they were before the push. You can exploit this to exclude any commit reachable from a known branch:
git rev-list $old_ref..$new_ref --not --branches='*'Code language: PHP (php)
The --not --branches='*' flag tells Git to exclude any commit reachable from any local branch ref. Because branch labels haven’t moved yet, this effectively limits the output to commits that are genuinely new to the repository — exactly the set that needs checking.
Processing Commits in One Pipeline
Instead of looping and spawning a process per commit, pipe the entire set of new commits through a sequence of Git and Unix commands in one shot. Collect all violations at once, report them all, and then exit with a failure code if any exist. Developers see all problems in a single push attempt.
Practical Example 1: Block Oversized Files
Large binary files committed to a Git repository cause repository bloat that is painful to undo. Here is an efficient pipeline to detect and reject them:
# Step 1: list SHA1 hashes and paths of all objects touched by new commits
git rev-list --objects $old_ref..$new_ref --not --branches='*' \
# Step 2: for each object, print its SHA1, size in bytes, and path
| git cat-file --batch-check='%(objectname) %(objectsize) %(rest)' \
# Step 3: filter objects exceeding the size limit, remove duplicates
| awk -v limit="$FILESIZE_LIMIT_BYTES" '$2 > limit { print }' \
| sort \
| uniq --skip-fields=2Code language: PHP (php)
The output is a list of SHA1 size path triplets for every oversized object. If the output is non-empty, print a helpful message listing each offending file and its size, then exit 1 to reject the push.
Why this is fast: git cat-file --batch-check reads all objects in a single pass without checking out file contents. The entire pipeline runs in roughly constant time relative to the number of commits, not file sizes.
Practical Example 2: Enforce Commit-Message Format
If your team requires every commit message to reference an issue tracker ID (e.g., PROJ-1234), the following pipeline validates all new commits in one pass:
# List SHA1 and subject line for non-merge commits only
git log --no-merges --pretty='%H %s' $old_ref..$new_ref --not --branches='*' \
# Filter out commits whose subject line matches the required pattern
| grep -Ev "$MESSAGE_REGEX"Code language: PHP (php)
Any line remaining after the grep -Ev filter represents a commit that violates the naming convention. Collect the output, print the offending commit hashes and subjects as a single error block, and exit 1 only after showing everything. This way developers can fix all non-conforming messages in one go before re-pushing.
Note: The --no-merges flag skips merge commits, which typically have auto-generated messages that do not follow the team convention.
Edge Cases to Handle
The examples above cover the common push case. Real-world hooks also need to handle:
- New branch creation:
$old_refwill be the null SHA-1 (0000000000000000000000000000000000000000). Your script must detect this and adjust therev-listrange accordingly — otherwise the range is invalid. - Branch deletion:
$new_refwill be the null SHA-1. There are no commits to check; just allow or deny based on policy. - Force pushes: The old and new refs may not share a common ancestor. Consider whether your checks should still apply and how your
rev-listrange behaves. - Tags: The ref name will start with
refs/tags/rather thanrefs/heads/. Decide whether your hook logic applies to tag objects.
General Principles for Hook Performance
- Prefer batch operations over per-commit loops. Unix pipelines and Git’s built-in batch modes (
--batch-check,--prettyformat strings) are far more efficient than spawning a subprocess per commit. - Report all violations before exiting. Collect every problem, display them all at once, then exit. Never make developers push multiple times just to discover new errors.
- Exclude already-verified commits. Use
--not --branches='*'to avoid re-checking work that is already on an accepted branch. - Keep hooks focused. One hook file can chain multiple checks, but each check should be independently understandable and testable.
- Test hooks locally before deploying. Hooks can be copied into a local
.git/hooksdirectory and invoked manually with representative SHA-1 arguments.
Conclusion
Git’s update hook is a powerful enforcement point on a central repository, but a naïve per-commit loop can turn every push into a slow, frustrating experience. By using --not --branches='*' to skip already-accepted commits and processing the remaining set in a single pipeline, you can enforce rules like file-size limits and commit-message conventions in a matter of seconds. The result is a hook infrastructure that protects code quality without getting in developers’ way.