Guide
Compress Images Before You Commit Them, Because Git Never Forgets
Put the compression in a pre-commit hook, because a large image committed once is in the repository forever. Deleting it later does not help. We measured it: a fixture repo holding three successive PNG hero exports reached an 8.34 MiB pack. Deleting the PNG entirely and replacing it with a 175,594-byte WebP took the pack to 8.51 MiB. It went up.
The same three revisions, compressed before they were ever staged, produced a 422.16 KiB pack. Same commits, same visible result, 20 times less to clone, forever, for everyone.
Below: a blocking hook and an auto-fixing hook, both run; a lint-staged config tested on version 17.6.0 including its failure path; a CI budget script that emits GitHub annotations; and what git filter-repo costs when the files are already in history. Measured with git 2.54.0 on macOS 27.0, 27 September 2026.
Why does a deleted image still make my repository slow to clone?
Because a commit is a snapshot, not a diff. Every blob you have ever committed stays reachable from some commit, and git clone fetches all of them. Deleting a file writes a new commit in which the file is absent. The old blob is still there, still in the pack, still on the wire.
Here is the whole thing, measured, one commit at a time. Pack size is git count-objects -vH after git gc --prune=now:
| Commit | What it did | Pack size |
|---|---|---|
| 1 | Two source files, no images | 1.52 KiB |
| 2 | Added public/hero.png, 3,715,606 B | 3.55 MiB |
| 3 | Re-exported the hero at a different size, 3,212,317 B | 6.61 MiB |
| 4 | Re-exported smaller, 1,813,827 B | 8.34 MiB |
| 5 | Deleted the PNG, added a 175,594 B WebP | 8.51 MiB |
At that point the working tree held three files totalling under 200 KB and .git was 8.6 MB. A second repo that took the same three hero revisions through cwebp before staging them finished at 422.16 KiB, and its cloned .git was 548 KB against 8.6 MB.
Three re-exports of one hero image. That is not an unusual number for a marketing page in its first month. Multiply by every designer handoff over two years and you have the repository everyone on the team complains about, where nobody can point to the commit that did it, because no single commit did.
The asymmetry is what makes this worth automating. Compressing before the commit costs a few hundred milliseconds. Fixing it afterwards costs a rewritten history, a force push, and every open branch on the team.
How do I compress images automatically before a git commit?
Two designs, and the choice matters more than the code. A blocking hook refuses the commit and tells you what to run. An auto-fixing hook rewrites the file and stages the result. Blocking is the right default on a shared repo, because a hook that silently changes what you are committing is a hook that will eventually silently change the wrong thing.
Put hooks in a tracked directory so the whole team gets them: git config core.hooksPath .githooks. Then .githooks/pre-commit, executable:
#!/usr/bin/env bash
# Reject a commit that stages an image above the budget.
# Checks the STAGED blob, not the working tree — they are not always the same.
set -euo pipefail
MAX_BYTES=${IMAGE_MAX_BYTES:-300000} # 300 KB
fail=0
while IFS= read -r -d '' f; do
case "${f##*.}" in
png|jpg|jpeg|gif|webp|avif|PNG|JPG|JPEG|GIF|WEBP|AVIF) ;;
*) continue ;;
esac
size=$(git cat-file -s ":$f" 2>/dev/null || echo 0)
if [ "$size" -gt "$MAX_BYTES" ]; then
printf ' %-34s %9s bytes (budget %s)\n' "$f" "$size" "$MAX_BYTES" >&2
fail=1
fi
done < <(git diff --cached --name-only --diff-filter=ACM -z)
if [ "$fail" -ne 0 ]; then
echo "Commit blocked: staged images exceed the size budget." >&2
echo "Fix them, git add the result, and commit again." >&2
echo "Override once with: IMAGE_MAX_BYTES=99999999 git commit" >&2
exit 1
fiStaging a 3,715,606-byte PNG against that produced public/hero.png 3715606 bytes (budget 300000), exit status 1, and no commit. The file stayed staged, which is what you want: you fix it and commit again without re-adding everything.
Three details in there are load-bearing, and most published hooks miss all three.
git cat-file -s ":$f" reads the size of the staged blob. Reading the file off disk is wrong and it is the most common bug in this genre. Stage a 1.8 MB version, keep editing, and your working tree can hold 3.7 MB while the index holds 1.8 MB. We reproduced exactly that: index blob 1,813,827 bytes, worktree 3,715,606 bytes, at the same path, at the same moment. A worktree-reading hook checks a file you are not committing.
--diff-filter=ACM skips deletions and renames. Without it the loop tries to cat-file a path that no longer exists.
-z with read -d '' survives filenames with spaces and newlines. Screenshot 2026-09-27 at 09.14.02.png is a real filename and it will break a naive for f in $(git diff --cached --name-only).
The auto-fixing variant swaps the check for work, still reading from the index, and re-stages:
while IFS= read -r -d '' f; do
case "${f##*.}" in jpg|jpeg|png|JPG|JPEG|PNG) ;; *) continue ;; esac
before=$(git cat-file -s ":$f")
tmp=$(/usr/bin/mktemp -t precommit).${f##*.}
git cat-file blob ":$f" > "$tmp" # the staged bytes, not the worktree
out="${tmp%.*}-out.${f##*.}"
if /usr/bin/sips -Z 1600 -s formatOptions 70 "$tmp" --out "$out" >/dev/null 2>&1; then
after=$(/usr/bin/stat -f%z "$out")
if [ "$after" -lt "$before" ]; then # never hand back a bigger file
/bin/cp "$out" "$f"
git add -- "$f"
printf 'compressed %s: %s -> %s bytes\n' "$f" "$before" "$after"
fi
fi
/bin/rm -f "$tmp" "$out"
done < <(git diff --cached --name-only --diff-filter=ACM -z)Running that on the same PNG printed compressed public/hero.png: 3715606 -> 1824206 bytes, and the committed blob, the index and the working tree all agreed at 1,824,206 bytes afterwards. Note the size guard: sips defaults to JPEG quality 75 and will happily return something larger, a behavior documented in the sips reference. Note also that 1.8 MB is still far too big for a hero, which is the subject of the encoder section below.
How do I do this with lint-staged?
If the repo already runs lint-staged, add a glob and stop writing shell plumbing. It hands your command the staged paths as arguments and re-stages whatever the command leaves on disk, which removes the git add step and, more usefully, the stash-and-restore logic you would otherwise need for a partially staged file.
{
"lint-staged": {
"*.{png,jpg,jpeg}": "scripts/shrink-image.sh",
"*.{svg,webp,avif}": "scripts/check-image-size.sh"
}
}#!/usr/bin/env bash
# scripts/shrink-image.sh — lint-staged passes staged paths as "$@"
set -euo pipefail
for f in "$@"; do
before=$(/usr/bin/stat -f%z "$f")
tmp="${f%.*}.tmp.${f##*.}"
/usr/bin/sips -Z 1600 -s formatOptions 70 "$f" --out "$tmp" >/dev/null 2>&1 || continue
after=$(/usr/bin/stat -f%z "$tmp")
if [ "$after" -lt "$before" ]; then
/bin/mv "$tmp" "$f"
printf 'shrunk %s: %s -> %s bytes\n' "$f" "$before" "$after"
else
/bin/rm -f "$tmp"
fi
doneOn lint-staged 17.6.0 this took the staged blob from 3,715,606 to 1,824,206 bytes and re-staged it without being asked. The commit landed at the smaller size.
The failure path is the part worth testing before you trust it. We staged a 1,813,827-byte file named .webp against a 300,000-byte budget and the checking script exited non-zero. lint-staged printed is 1813827 bytes, over the 300000 budget, then Reverting to original state because of errors, and put the index back exactly as it was. That rollback is the reason to use lint-staged over a hand-rolled hook: a script that dies halfway through a multi-file commit leaves you with a half-processed index, and lint-staged does not.
How do I enforce an image budget in CI?
Hooks are advisory. git commit --no-verify exists, people clone without running core.hooksPath, and CI is the only place the rule is actually enforced. Keep the check portable: your Mac has BSD stat and the runner has GNU stat, and the flags differ. Ask git for the size instead and the problem disappears.
#!/usr/bin/env sh
# scripts/ci-image-budget.sh — no bashisms, no stat(1), no GNU tools.
set -eu
: "${RASTER_MAX:=300000}"
: "${SVG_MAX:=40000}"
status=0
for f in $(git ls-files -- '*.png' '*.jpg' '*.jpeg' '*.gif' '*.webp' '*.avif' '*.svg'); do
size=$(git cat-file -s "HEAD:$f")
case "$f" in
*.svg) max=$SVG_MAX ;;
*) max=$RASTER_MAX ;;
esac
if [ "$size" -gt "$max" ]; then
printf '::error file=%s::%s bytes exceeds the %s byte budget\n' "$f" "$size" "$max"
status=1
fi
done
[ "$status" -eq 0 ] && printf 'All tracked images within budget.\n'
exit $statusRun against the fixture repo while the hero was still a 1,824,206-byte PNG it printed ::error file=public/hero.png::1824206 bytes exceeds the 300000 byte budget and exited 1. That ::error file= prefix is a GitHub Actions workflow command, so the failure lands as an annotation on the file in the pull request diff rather than as a line somewhere in a log nobody opens. After converting the hero to WebP the same script printed All tracked images within budget. and exited 0.
Two refinements once it is green. Scope the loop to git diff --name-only origin/main...HEAD so an existing offender does not fail every unrelated PR while you work through the backlog. And set SVG_MAX low, because an SVG exported from a design tool with embedded raster data is the single most common way a 4 MB file arrives in a repository looking like a vector.
What if the big files are already in history?
Then you rewrite history, and the rewrite is the expensive part, not the disk saving. git-filter-repo is the tool; git’s own documentation now steers people away from filter-branch, and filter-branch itself prints a warning about “a glut of gotchas generating mangled history rewrites” before it will run.
# One file, everywhere it has ever existed
git filter-repo --invert-paths --path public/hero.png
# Or everything above a size, across all history
git filter-repo --strip-blobs-bigger-than 300KOn the fixture repo that worked exactly as advertised and cost exactly what you would expect:
| Before | After | |
|---|---|---|
| Pack size | 8.51 MiB | 173.49 KiB |
| Commits on main | 5 | 2 |
| HEAD | e96b425 | e2f4a4b |
| Remotes configured | origin | none |
Read the second and third rows before you celebrate the first. Three commits disappeared, because once the image was removed they changed nothing and filter-repo prunes empty commits by default. Every surviving commit got a new hash. That means a force push, every open pull request rebased or reopened, every teammate re-cloning rather than pulling, and every hash in a Jira ticket, a changelog or a deploy log pointing at a commit that no longer exists.
The empty origin is filter-repo being deliberately protective: it removes the remote so you cannot reflexively push a rewritten history over a shared branch. Treat that as a checkpoint, not an inconvenience.
Schedule it, announce it, freeze merges, do it, tell everyone to re-clone. It is a half-day for a team of five. Which is the argument for the twelve-line hook.
Which encoder should the hook actually call?
Whichever one is smallest for your format, and for web assets that is almost never sips. Same 3,715,606-byte source PNG, same 1,600 px target, three routes:
| Command | Output | Of the original |
|---|---|---|
sips -Z 1600 hero.png --out out.png | 1,824,206 B | 49.1% |
cwebp -q 72 -resize 1600 0 | 96,176 B | 2.6% |
| Smol, WebP, quality 72, fit 1,600 px | 105,004 B | 2.8% |
cwebp won, by 8.4% over our own encoder, and we are going to say so rather than leave it out. If your hook is a shell script and the answer is one format, brew install webp and call cwebp. It is free, it is fast, it runs identically on the Linux CI runner, and there is no version of this article where we pretend otherwise.
What sips is doing in that first row is worth understanding: it resized the PNG and re-encoded it as a PNG, which is the wrong container for a photograph and halves the bytes when it should be removing 97% of them. A hook that calls sips -Z on PNGs and calls it done is the most common half-fix in this whole category. The format change is where the win lives, covered properly in converting PNG to WebP on a Mac.
Where an agent changes the shape of this. The reason image budgets rot is not that hooks are hard, it is that the fix lands on whoever is mid-task when the hook fires, and converting a hero to WebP, updating the <picture> element and re-running the build is a context switch nobody wants at 6pm. Smol ships an MCP server, so the coding agent already in your editor can do all of it inside the same session: read the blocked path out of the hook output, plan the conversion and show the predicted output paths before writing anything, run it locally, update the markup, and re-stage. The 105,004-byte figure above came out of exactly that call, made from this editing session against the file on disk. As of Smol 1.0.35, Claude Code, Codex and Google Antigravity each connect with one click from the app’s Smol for AI panel, and nothing leaves the machine.
When you do not need any of this
Your repo has no images. A backend service, a CLI, a library. Skip the hook. Add the CI budget check anyway, because it costs nothing and catches the day somebody commits a screenshot into the README folder.
You already use Git LFS. Then the blobs are pointers and clone size is not your problem, though bandwidth billing might be. A size budget still helps; a compression hook largely does not.
The assets are not yours to change. Brand masters, print originals, legal exhibits, anything with a signed-off checksum. Do not put a compressing hook anywhere near them. Exclude the path explicitly in the glob rather than relying on people to remember.
You want one command and no app. cwebp, sips, oxipng, sharp in a Node script: all free, all scriptable, all correct answers for a developer who is already in a terminal. Nothing on this page needs paid software, and a hook you wrote and understand beats a tool you trust on faith.
Where Smol’s one-time $29 earns a place in this workflow is the messy half: a designer handoff folder of mixed PNGs, PDFs, MP4s and a Sketch export, dropped in one go, with per-file results showing which three refused to shrink and an output policy that cannot overwrite an original. That is a different job from the hook, and it is worth being clear that the hook is the part that actually protects the repository.
For the drag-and-drop version of the same job, a watched folder that compresses on drop handles the handoff directory. If the assets arrive as a Finder selection, a Quick Action is two clicks away. For bulk resizing before any of this, Automator can do it, as long as you know it overwrites your originals by default. And documentation repositories tend to accumulate PDFs rather than PNGs, which is its own measured problem.
Frequently asked questions
Does deleting a large image from a git repository make it smaller?
No. A commit is a snapshot, so the old blob stays reachable and every clone still downloads it. In a test repo, three PNG hero exports took the pack to 8.34 MiB; deleting the PNG and adding a 175,594 byte WebP took it to 8.51 MiB, slightly larger. Only a history rewrite with git filter-repo removes the bytes.
How do I compress images automatically before a git commit?
Add an executable .githooks/pre-commit and point git at it with git config core.hooksPath .githooks. Read the staged paths with git diff --cached --name-only --diff-filter=ACM -z, get each size with git cat-file -s ":$f", then either reject the commit over a byte budget or compress and re-stage. Read the staged blob, never the working tree.
Should a pre-commit hook read the file from disk or from the git index?
From the index. Staged content and working-tree content diverge as soon as you keep editing after git add. We reproduced it: the index held 1,813,827 bytes while the same path on disk held 3,715,606 bytes. Use git cat-file -s ":$f" for the size and git cat-file blob ":$f" for the content.
How do I check image sizes with lint-staged?
Add a glob such as "*.{png,jpg,jpeg}" pointing at a script. lint-staged passes the staged paths as arguments and re-stages whatever the script leaves on disk. On lint-staged 17.6.0 a shrink script took a staged file from 3,715,606 to 1,824,206 bytes automatically, and a failing budget check made lint-staged revert the index to its original state.
How do I remove large images from git history?
Use git filter-repo, for example git filter-repo --invert-paths --path public/hero.png or --strip-blobs-bigger-than 300K. On a test repo the pack fell from 8.51 MiB to 173.49 KiB, but three commits vanished as empty, every remaining hash changed, and filter-repo deleted the origin remote so you cannot accidentally push. Everyone must re-clone.
Which tool should a pre-commit image hook call on a Mac?
For web assets, cwebp. On one 3,715,606 byte PNG resized to 1,600 px, cwebp -q 72 produced 96,176 bytes, Smol produced 105,004, and sips -Z 1600 produced 1,824,206 because it kept the PNG container. The format change does most of the work, so a hook that only resizes leaves roughly 95% of the savings on the table.
Keep reading