Shipping unoptimized images is one of the most common accidental performance regressions in web projects. The right enforcement point is the commit: if a developer adds a PNG, they should automatically get the WebP version added to the commit without any extra steps. A pre-commit hook handles this transparently.
TL;DR
# .git/hooks/pre-commit
#!/usr/bin/env bash
set -euo pipefail
git diff --cached --name-only --diff-filter=ACM | grep -E '\.png$' | while IFS= read -r f; do
webp="${f%.png}.webp"
curl -sf --max-time 60 \
-H "Authorization: Bearer $CTF_API_KEY" \
-F "file=@$f" -F "target=webp" \
-o "$webp" \
https://changethisfile.com/v1/convert
git add "$webp"
done
The use case
Web projects typically serve images in WebP for modern browsers while keeping the original PNG as a fallback or source of truth. Maintaining both formats manually is error-prone — developers forget to convert, or convert but forget to stage the WebP file.
A pre-commit hook solves this at the source: the repo always contains both formats, and they're always in sync because the WebP is generated at commit time from the current PNG.
This pattern is especially useful for:
- Static sites (Hugo, Jekyll, Astro, Eleventy) where all images are committed
- Documentation repos with screenshot-heavy content
- Design system repos where icons and graphics are managed in git
- Any repo with a
public/,static/, orassets/directory tracked in git
The hook only processes PNGs that are staged for commit (--diff-filter=ACM = Added, Copied, or Modified). Deleting a PNG doesn't trigger conversion.
Complete pre-commit hook
#!/usr/bin/env bash
# .git/hooks/pre-commit
# Auto-converts staged PNGs to WebP at commit time.
# Requires: CTF_API_KEY environment variable
set -euo pipefail
API_KEY="${CTF_API_KEY:-}"
API_URL="https://changethisfile.com/v1/convert"
# Bail out gracefully if no API key configured — don't block commits
if [[ -z "$API_KEY" ]]; then
echo "[ctf-hook] CTF_API_KEY not set — skipping WebP conversion" >&2
exit 0
fi
# Get staged PNGs (Added, Copied, Modified)
mapfile -t staged_pngs < <(
git diff --cached --name-only --diff-filter=ACM | grep -E '\.png$' || true
)
if [[ ${#staged_pngs[@]} -eq 0 ]]; then
exit 0 # Nothing to convert
fi
echo "[ctf-hook] Converting ${#staged_pngs[@]} PNG(s) to WebP..."
failed=0
for png in "${staged_pngs[@]}"; do
[[ -f "$png" ]] || continue # File might be deleted in the working tree
webp="${png%.png}.webp"
tmp="$webp.tmp.$$"
echo " $png -> $webp"
http_status=$(curl -sf \
--max-time 60 \
-w "%{http_code}" \
-H "Authorization: Bearer $API_KEY" \
-F "file=@$png" \
-F "target=webp" \
-o "$tmp" \
"$API_URL" 2>/dev/null
) || { echo " [SKIP] curl error for $png" >&2; rm -f "$tmp"; continue; }
if [[ "$http_status" == "200" ]] && [[ -s "$tmp" ]]; then
mv "$tmp" "$webp"
git add "$webp"
echo " [OK] $webp ($(du -h "$webp" | cut -f1))"
else
echo " [FAIL] HTTP $http_status for $png" >&2
rm -f "$tmp"
((failed++)) || true
fi
done
if [[ $failed -gt 0 ]]; then
echo "[ctf-hook] $failed conversion(s) failed — commit blocked" >&2
exit 1
fi
echo "[ctf-hook] Done"
Make it executable: chmod +x .git/hooks/pre-commit
Error handling and failure modes
The hook has two failure modes, handled differently:
- No API key — prints a warning and exits 0. This allows developers without a key to commit without being blocked. Useful during initial rollout.
- API error — exits 1, blocking the commit. You can switch this to a warning-only mode by changing exit 1 to exit 0 if you'd rather let the commit through on API failures.
To set the API key for all hooks in your shell session:
# In ~/.bashrc or ~/.zshrc
export CTF_API_KEY="ctf_sk_your_key_here"
For team environments where not everyone has a key, the graceful degradation (exit 0 when no key) lets the hook be committed to the repo without forcing everyone to have an account. Those with keys get auto-conversion; those without get a warning.
To share the hook across the team, use a .githooks/ directory in the repo root and configure git to use it:
# In .git/config or via command:
git config core.hooksPath .githooks
# Or for everyone at once:
echo 'git config core.hooksPath .githooks' >> .git/hooks/post-checkout
Extending to other formats and CI enforcement
Convert JPG to WebP on commit alongside PNGs:
mapfile -t staged_images < <(
git diff --cached --name-only --diff-filter=ACM | grep -E '\.(png|jpg|jpeg)$' || true
)
for img in "${staged_images[@]}"; do
webp="${img%.*}.webp"
# ... same curl + git add pattern
done
For CI enforcement (GitHub Actions) — verify that every committed PNG has a corresponding WebP:
name: Check WebP assets
on: [push, pull_request]
jobs:
check-webp:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Verify WebP files exist for all PNGs
run: |
missing=0
while IFS= read -r png; do
webp="${png%.png}.webp"
if [[ ! -f "$webp" ]]; then
echo "Missing WebP for: $png"
((missing++)) || true
fi
done < <(find . -name '*.png' -not -path './.git/*')
[[ $missing -eq 0 ]] || exit 1
Production tips
- Use --diff-filter=ACM, not --diff-filter=A. Modified PNGs also need fresh WebP conversion. If a designer updates an icon and the old WebP isn't regenerated, you'll ship a stale WebP.
- Add *.webp to .gitignore if you prefer WebP to stay untracked. In this pattern, git add "$webp" adds it to the staging area but it won't be committed on future runs unless it's in the working tree. Some teams prefer generated files outside git; others prefer them in. Both are valid.
- The hook only runs locally. Commits pushed via git push --no-verify or from CI without the hook configured bypass this. The GitHub Actions CI check above closes that gap.
- Use --max-time 60, not the default (infinite). A PNG conversion should complete in under 10 seconds. 60 seconds is a generous timeout that still protects you from a hung commit if the API is slow.
- Free tier covers 1K conversions/month. A typical developer committing images daily uses well under 100 conversions/month. The free tier handles most individual developer workflows.
A pre-commit hook is the highest-leverage point to enforce image optimization — it runs before bad assets enter the repo rather than after. Once installed, developers never have to think about WebP conversion again. Get a free API key — the free tier is sufficient for individual developer workflows.