Enforcing file size limits on the remote Jump to heading
Git stores every version of every file forever. A 400 MB build artefact committed once is not a 400 MB problem โ it is a permanent tax on every clone, every fetch, and every CI runner that ever touches the repository, and removing it later means rewriting history that other people have already built on. The window where refusing it is free lasts exactly as long as the push transaction. This recipe closes that window using the pre-receive mechanics introduced in Server-Side Hook Enforcement.
When to use this approach Jump to heading
- Your repository has been growing faster than the code in it, and
git count-objects -vHshows a size out of proportion to the source. - Contributors occasionally commit build output, dependency archives, or media that belongs in artefact storage or Git LFS.
- You run a self-hosted remote or self-managed platform where custom hooks are available; on SaaS platforms, use the equivalent push rule if one exists.
- Clone times are already a complaint, and the fixes in Large Repository Performance are treating the symptom while new weight keeps arriving.
- You want a refusal that teaches, rather than a mysterious rejection that sends the contributor to search the wiki.
Step 1 โ Pick a limit and a grace path Jump to heading
Choose a number and, at the same time, choose what a contributor should do when they hit it. A limit without an alternative just produces a support ticket.
Start at 10 MB if you have no data. Then measure: git rev-list --objects --all | git cat-file --batch-check='%(objecttype) %(objectsize) %(rest)' | awk '$1=="blob" && $2>5000000' lists what already exceeds 5 MB, which tells you whether the number you picked will reject legitimate work on day one.
Step 2 โ Enumerate only the objects new to this push Jump to heading
The single most important line in this hook is the one that decides what to inspect. Checking every object in the repository on every push is both slow and wrong: it re-rejects files that were accepted years ago.
# Commits that this push introduces and the remote does not already have.
git rev-list "$newrev" --not --all What changed: --not --all excludes everything reachable from any existing ref, so a push of three commits inspects three commits regardless of how much history sits behind them. Without it, a first push to a mirror would walk the entire project.
# Verify the scoping on any repository:
git rev-list HEAD --not --all | wc -l # 0 on a fully pushed branch
git commit --allow-empty -m "probe" && git rev-list HEAD --not --all | wc -l # now 1
git reset --hard HEAD~1 # undo the probe Step 3 โ Measure each blob and reject the oversize ones Jump to heading
#!/bin/sh
# hooks/pre-receive โ refuse blobs above the size limit.
limit_mb=10
limit=$((limit_mb * 1024 * 1024))
zero='0000000000000000000000000000000000000000'
status=0
while read -r oldrev newrev refname; do
[ "$newrev" = "$zero" ] && continue # deletion: no new objects
# List every blob introduced by this push, with its size and path.
# %(objecttype) %(objectsize) %(rest) -> "blob 41231234 path/to/file"
git rev-list --objects "$newrev" --not --all \
| git cat-file --batch-check='%(objecttype) %(objectsize) %(rest)' \
| while read -r otype osize opath; do
[ "$otype" = "blob" ] || continue
[ -n "$opath" ] || continue # blobs with no path: skip
[ "$osize" -le "$limit" ] && continue
mb=$((osize / 1024 / 1024))
echo "" >&2
echo " Rejected: $opath is ${mb} MB (limit ${limit_mb} MB)." >&2
echo " Large files must not enter Git history โ they stay in every clone forever." >&2
echo "" >&2
exit 1
done || status=1
: "$oldrev" "$refname"
done
exit "$status" What changed: every blob the push introduces is measured, and any single file above the limit rejects the whole push. The || status=1 is load-bearing โ the while loop runs in a subshell because it is on the right of a pipe, so its exit status has to be captured explicitly rather than assigned to a variable inside the loop.
sh -n hooks/pre-receive && chmod +x hooks/pre-receive
# Then, on a scratch remote:
head -c 12000000 /dev/urandom > big.bin && git add big.bin && git commit -m "oversized"
git push scratch main
# Expect: remote: Rejected: big.bin is 11 MB (limit 10 MB). SAFETY WARNING โ do not โfixโ a rejected push by rewriting the branch that already contains the large file if that branch is shared. Rewriting a shared branch orphans everyone elseโs work. Remove the file from the unpushed commits instead (
git reset --soft HEAD~1, drop the file, recommit), and reserve history rewriting for the documented removal procedure when the object is already on the remote.
Step 4 โ Tell the contributor what to do instead Jump to heading
A rejection that names the file is good; one that names the alternative is what stops the ticket being filed.
echo " Options:" >&2
echo " 1. Drop it from the commit and keep the file untracked:" >&2
echo " git reset --soft HEAD~1 && git restore --staged $opath" >&2
echo " echo '$opath' >> .gitignore" >&2
echo " 2. Track it with Git LFS if it must be versioned:" >&2
echo " git lfs track '$opath' && git add .gitattributes" >&2
echo " 3. Publish it as a build artefact and reference it by URL." >&2
echo "" >&2 What changed: the message now covers the three situations that produce oversized commits โ an accident, a genuinely large asset, and a build output โ so the contributor picks a path instead of asking which one exists.
Step 5 โ Keep the hook fast on large pushes Jump to heading
The naive version spawns git cat-file once per object. On a push of a thousand objects that is a thousand processes and several seconds of stalled terminal. The batch form above already avoids it, but two more habits matter.
Second, filter before you measure: --not --all in Step 2 keeps the object list proportional to the push, not the repository. Third, resist the urge to inspect file contents โ matching on extension or magic bytes means reading every blob, which turns a metadata check into an I/O bound one. Size alone catches the cases that matter.
Step 6 โ Verify with a deliberately oversized file Jump to heading
# 1. Scratch remote with the hook installed
git init --bare /tmp/size.git
install -m 0755 hooks/pre-receive /tmp/size.git/hooks/pre-receive
git remote add sizetest /tmp/size.git
# 2. An ordinary push passes
git push sizetest main
# 3. An oversized blob is refused, and names itself
head -c 15000000 /dev/urandom > assets/demo.bin
git add assets/demo.bin && git commit -m "add demo asset"
git push sizetest main
# Expect: remote: Rejected: assets/demo.bin is 14 MB (limit 10 MB).
# 4. Recover locally without rewriting anything shared
git reset --soft HEAD~1
git restore --staged assets/demo.bin
echo "assets/demo.bin" >> .gitignore
git add .gitignore && git commit -m "ignore large demo asset"
git push sizetest main # succeeds Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Why check size on the server when a pre-commit hook is faster? Jump to heading
Because a pre-commit hook can be skipped with --no-verify and is absent entirely on a fresh clone until someone installs it. A large blob is uniquely unforgiving: once it is in a pushed commit, removing it means rewriting history for everyone. The server hook is the only place where refusal is still cheap, so it is the place the rule has to hold. Run the same check locally as well โ it just cannot be the only copy.
Does the limit apply to files already in history? Jump to heading
No, and it should not. The hook inspects only objects new to the push, so files that predate the policy stay put and existing branches keep merging cleanly. If you also want the old ones gone, that is a separate, deliberate history rewrite โ not something a push-time hook should attempt.
How large is too large? Jump to heading
For source repositories, a limit between 5 MB and 25 MB catches accidents without annoying anyone: compiled binaries, database dumps and video files land above it, while legitimate assets such as fonts, icons and test fixtures sit below. Set it where your largest legitimate file sits plus headroom, then lower it once Git LFS is available for the genuinely large assets.
Related Jump to heading
- Server-Side Hook Enforcement โ the parent guide: which hook runs when, and how to roll one out without blocking the team.
- Blocking Force Pushes with a Pre-Receive Hook โ the same hook shape applied to ref updates rather than object sizes.
- Migrating Large Binaries to Git LFS โ the grace path this hook points contributors towards, end to end.