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/, or assets/ 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:

  1. No API key — prints a warning and exits 0. This allows developers without a key to commit without being blocked. Useful during initial rollout.
  2. 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.