#!/usr/bin/env bash
#
# Pre-commit hook: enforce PHP syntax + whitespace/indentation conventions
# on staged files. Detect-only — never modifies code.
#
# Pipeline:
# 1. php -l — syntax / lint check (blocks on parse errors)
# 2. phpcs — whitespace + indentation check (blocks on violations)
#
# To auto-fix violations manually (safe — ruleset has no semantic-changing
# rules, only whitespace):
# tools/phpcbf.phar --standard=phpcs.xml path/to/file.php
#
# Install:
# bash scripts/install-hooks.sh
#
# Bypass (use sparingly):
# git commit --no-verify
set -u
export PATH="/opt/homebrew/bin:$PATH"
# ---- config -----------------------------------------------------------------
# PHP_BIN="${PHP_BIN:-php}"
PHP_BIN="${PHP_BIN:-/opt/homebrew/bin/php}"
ROOT="$(git rev-parse --show-toplevel)"
resolve_bin() {
# $1 = tool name; echoes path to first executable found.
# Search order: repo tools/ PHAR → composer vendor/bin → system PATH.
local tool="$1"
for candidate in \
"$ROOT/tools/$tool.phar" \
"$ROOT/vendor/bin/$tool" \
"$(command -v "$tool" 2>/dev/null)"; do
if [ -n "$candidate" ] && [ -x "$candidate" ]; then
echo "$candidate"
return 0
fi
done
return 1
}
PHPCS_BIN="$(resolve_bin phpcs || true)"
RED=$'\033[0;31m'
YELLOW=$'\033[0;33m'
GREEN=$'\033[0;32m'
RESET=$'\033[0m'
fail() { echo "${RED}✗ $*${RESET}" >&2; }
warn() { echo "${YELLOW}! $*${RESET}" >&2; }
ok() { echo "${GREEN}✓ $*${RESET}"; }
# ---- collect staged php files ----------------------------------------------
# ACMR = Added, Copied, Modified, Renamed (skip Deleted).
# Portable read loop (macOS ships bash 3.2, which lacks `mapfile`).
STAGED_PHP=()
while IFS= read -r line; do
[ -n "$line" ] && STAGED_PHP+=("$line")
done < <(git diff --cached --name-only --diff-filter=ACMR -- '*.php')
if [ "${#STAGED_PHP[@]:-0}" -eq 0 ]; then
exit 0
fi
echo "Pre-commit: checking ${#STAGED_PHP[@]} staged PHP file(s)..."
# phpcs.xml exclude-patterns are NOT applied when input is read from stdin,
# so we mirror the exclusion list here. Keep this in sync with phpcs.xml.
is_excluded() {
case "$1" in
wp-admin/*|*/wp-admin/*) return 0 ;;
wp-includes/*|*/wp-includes/*) return 0 ;;
vendor/*|*/vendor/*) return 0 ;;
predis/*|*/predis/*) return 0 ;;
tools/*|*/tools/*) return 0 ;;
*/node_modules/*) return 0 ;;
csv_file/*|*/csv_file/*) return 0 ;;
*.min.php) return 0 ;;
wp-activate.php|wp-blog-header.php|wp-comments-post.php) return 0 ;;
wp-config-sample.php|wp-config.php|wp-cron.php) return 0 ;;
wp-links-opml.php|wp-load.php|wp-login.php|wp-mail.php) return 0 ;;
wp-settings.php|wp-signup.php|wp-trackback.php) return 0 ;;
xmlrpc.php|index.php) return 0 ;;
esac
return 1
}
# Filter out excluded paths up front.
CHECK_PHP=()
SKIPPED_EXCLUDED=()
for file in "${STAGED_PHP[@]}"; do
if is_excluded "$file"; then
SKIPPED_EXCLUDED+=("$file")
else
CHECK_PHP+=("$file")
fi
done
if [ "${#SKIPPED_EXCLUDED[@]:-0}" -gt 0 ]; then
echo " (${#SKIPPED_EXCLUDED[@]} file(s) excluded by phpcs.xml: WP core / vendor / tools)"
fi
if [ "${#CHECK_PHP[@]:-0}" -eq 0 ]; then
ok "no project files to check"
exit 0
fi
# Build tmp files holding the staged blob for each path. We lint the staged
# blob (not the working tree) so unstaged edits don't influence the result.
TMPDIR_HOOK="$(mktemp -d -t precommit-php-XXXXXX)"
trap 'rm -rf "$TMPDIR_HOOK"' EXIT
declare -a TMP_FILES=()
for file in "${CHECK_PHP[@]}"; do
tmp="$TMPDIR_HOOK/$(echo "$file" | tr '/' '_')"
if ! git show ":$file" > "$tmp" 2>/dev/null; then
fail "could not read staged content of $file"
exit 1
fi
TMP_FILES+=("$tmp::$file")
done
# ---- 1. syntax check (php -l) ----------------------------------------------
syntax_failed=0
for entry in "${TMP_FILES[@]}"; do
tmp="${entry%%::*}"
orig="${entry##*::}"
if ! out=$("$PHP_BIN" -l "$tmp" 2>&1); then
fail "syntax error in $orig"
echo "$out" | sed "s|$tmp|$orig|g" >&2
syntax_failed=1
fi
done
if [ "$syntax_failed" -ne 0 ]; then
fail "php -l failed. Commit aborted."
exit 1
fi
ok "syntax OK"
# ---- 2. whitespace + indentation lint (phpcs) ------------------------------
if [ -z "$PHPCS_BIN" ]; then
warn "phpcs not found — skipping whitespace check."
warn "Expected at: $ROOT/tools/phpcs.phar"
warn "Run: bash scripts/install-tools.sh"
exit 0
fi
# Require the project ruleset; without it we'd silently fall back to a
# different standard (e.g. PSR-12) and surprise the dev with naming errors.
RULESET="$ROOT/phpcs.xml"
if [ ! -f "$RULESET" ]; then
fail "phpcs.xml not found at repo root. Cannot enforce whitespace rules."
exit 1
fi
phpcs_failed=0
for entry in "${TMP_FILES[@]}"; do
tmp="${entry%%::*}"
orig="${entry##*::}"
# --stdin-path makes phpcs.xml exclude-patterns match the real repo path
# (the tmp file lives in /tmp/... and would never match e.g. wp-admin/*).
if ! out=$("$PHPCS_BIN" -n --colors --standard="$RULESET" --stdin-path="$orig" - <"$tmp" 2>&1); then
fail "whitespace/indent violations in $orig"
echo "$out" >&2
phpcs_failed=1
fi
done
if [ "$phpcs_failed" -ne 0 ]; then
fail "phpcs failed. Fix manually, or run:"
fail " tools/phpcbf.phar --standard=phpcs.xml <file>"
fail "Bypass (not recommended): git commit --no-verify"
exit 1
fi
ok "whitespace OK"
exit 0
Jagdish Sarma Asked question 2 minutes ago