0

#!/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
Add a Comment