Un developer revisa una PR y después de unas pocas rondas de feedback, finalmente la aprueba y la mergea. Su feedback quedaría así:
- "Te dejaste un
var_dumpenOrderService.php." Push fix.- "La rama se llama
cosas-de-juan, renómbrala siguiendo la convención." Push fix.- "Los mensajes de commit deberían seguir Conventional Commits." Rebase, force push.
- "Hay un parse error en la migración que claramente no has corrido en local." Push fix.
- "Y el
composer.lockno está actualizado aunque bumpeaste la versión encomposer.json." Push fix, por fin verde.
Todas se podrían haber detectado de forma automática con un script antes de que se hiciese el primer commit. Pero no, hemos perdido tiempo muy valioso de nuestro equipo revisando y detectando y notificando fallos que podrían haberse evitado.
Para esto son los 💡 git hooks Scripts que git ejecuta automáticamente en eventos concretos del repositorio: antes de un commit, antes de un push, cuando se escribe un mensaje de commit, etc. Más info → . Una utilidad muy buena que tienen son detectar de forma simple esta clase de errores tontucos que nos hacen perder tiempo.
TL;DR rapidito
No los que imponen arquitectura. Los que cazan errores triviales antes de que lleguen a la cola del reviewer.
Un pre-commit con el lint de PHP elimina toda la clase de PRs del tipo "se me olvidó probarlo en local".
Una regex en el diff staged borra del mapa el comentario de review más típico en proyectos PHP.
captainhook, lefthook o un script de instalación. .git/hooks/ a pelo va bien en solitario; en equipo necesitas algo gestionado.
Hook 1: comprobar la sintaxis de PHP (pre-commit)
El flag -l (lint) de PHP parsea un fichero y reporta errores de sintaxis sin ejecutarlo. Pasándola por cada PHP staged antes del commit se acaba el romper el parser y meter cosas rotas en producción.
#!/bin/bash
# .git/hooks/pre-commit
set -e
STAGED_PHP=$(git diff --cached --name-only --diff-filter=ACMR | grep '\.php$' || true)
[ -z "$STAGED_PHP" ] && exit 0
ERRORS=0
for FILE in $STAGED_PHP; do
if ! php -l "$FILE" > /dev/null 2>&1; then
echo "Error de sintaxis en $FILE:"
php -l "$FILE" || true # sin el || true, set -e corta el bucle en el primer error
ERRORS=$((ERRORS + 1))
fi
done
if [ $ERRORS -gt 0 ]; then
echo ""
echo "$ERRORS fichero(s) con errores de sintaxis. Commit bloqueado."
exit 1
fi
El --diff-filter=ACMR filtra por Añadidos, Copiados, Modificados y Renombrados, saltándose los Deletes. El hook solo mira ficheros staged, no el repo entero, así que en un proyecto de 10.000 ficheros se ejecuta en menos de un segundo.
ConsejoPruébalo: crea un fichero PHP con un error y lánzale
php -l:echo '<?php class Foo {' > roto.php php -l roto.php # PHP Parse error: Unclosed '{' on line 1 (salida de PHP 8.4)
Es el hook que se amortiza solo en la primera vez que alguien se olvida de probar una migración en local. El parse error se pilla al hacer commit y no unos minutos después en CI, no cuando un compañero hace pull y se encuentra una rama rota.
Hook 2: detectar debug olvidado (pre-commit)
Otro caso típico de review es detectar y abrir threads por que nos hemos dejado código debug tipo var_dump . Otro hook lo puede detectar fácil y evitar que pushee al repo:
#!/bin/bash
# .git/hooks/pre-commit (extensión)
set -e
STAGED_PHP=$(git diff --cached --name-only --diff-filter=ACMR | grep '\.php$' || true)
[ -z "$STAGED_PHP" ] && exit 0
PATTERNS='var_dump\(|\bdd\(|dump\(|print_r\(|xdebug_break\('
FOUND=$(git diff --cached --diff-filter=ACMR -U0 -- $STAGED_PHP \
| grep -E "^\+[^+]" \
| grep -E "$PATTERNS" || true)
if [ -n "$FOUND" ]; then
echo "Debug olvidado en los cambios staged:"
echo "$FOUND" | head -20
echo ""
echo "Quítalo, o usa 'git commit --no-verify' si es intencionado."
exit 1
fi
El hook mira las líneas añadidas en el diff, no el contenido actual del fichero. La regex ^\+[^+] matchea líneas que empiezan con + pero no con +++ (la cabecera del diff). Así, un var_dump que llevaba años en un fichero que no has tocado no dispara el hook, solo las líneas nuevas o modificadas.
El xdebug_break() pilla los breakpoints programáticos de Xdebug que algunos usan al debuggear. Y el \b delante de dd\( importa: sin él, cualquier ->add( legítimo (un DateTime, una colección) dispararía el hook. Añade o quita patrones según las convenciones de tu equipo: en Laravel igual te interesa meter \Illuminate\Support\Facades\Log::debug o dump() (Symfony VarDumper); si usáis ray() de Spatie, mételo también.
NotaLa regex no intenta cubrir el 100% de casos. Código que de verdad necesite
print_r(logging en producción muy concreto) tira de--no-verifyo envuelve la llamada de forma que no matchee. Es para el caso del 95%, no para el del 100%.
Hook 3: detección de secretos (pre-commit)
Este es el hook que previene toda una categoría de marrones catastróficos. Una 💡 AWS key Credencial de acceso a Amazon Web Services. Si se filtra, alguien puede levantar servidores a tu costa o robar datos de tus buckets. Aunque la borres del commit siguiente, queda en el historial de git para siempre. committeada, o un token de OpenAI, aunque la revertas dos segundos después, queda en el historial de git para siempre y hay que rotarla. El coste de rotar va de pereza a caída en producción. Prevenir el commit es dramáticamente más barato.
#!/bin/bash
# .git/hooks/pre-commit (extensión)
set -e
STAGED=$(git diff --cached --name-only --diff-filter=ACMR || true)
[ -z "$STAGED" ] && exit 0
PATTERNS=(
'AKIA[0-9A-Z]{16}' # AWS access key
'aws_secret_access_key\s*=\s*[A-Za-z0-9/+=]{40}'
'\bsk-[A-Za-z0-9_-]{20,}' # OpenAI (sk-, sk-proj-) / Anthropic (sk-ant-)
'AIza[0-9A-Za-z_-]{35}' # Google API key
'ghp_[A-Za-z0-9]{36}' # GitHub PAT
'xox[baprs]-[A-Za-z0-9-]+' # Slack tokens
'BEGIN (RSA |DSA |EC |OPENSSH )?PRIVATE KEY'
'"password"\s*:\s*"[^"]{8,}"' # campos password en JSON
)
FOUND=""
for PATTERN in "${PATTERNS[@]}"; do
MATCH=$(git diff --cached --diff-filter=ACMR -U0 -- $STAGED \
| grep -E "^\+[^+]" \
| grep -E "$PATTERN" || true)
[ -n "$MATCH" ] && FOUND="${FOUND}${MATCH}\n"
done
if [ -n "$FOUND" ]; then
echo "Posibles secretos en los cambios staged:"
echo -e "$FOUND" | head -10
echo ""
echo "Si es un falso positivo, usa 'git commit --no-verify'."
echo "Mejor aún: rota cualquier secreto real que casi acaba en el repo."
exit 1
fi
Los patrones son estrechos a propósito porque así matchean el formato concreto de cada tipo de credencial. Un patrón más ancho (cualquier string de 32 caracteres en base64) pillaría más secretos pero te daría falsos positivos en hashes, IDs, y contenido que podría parecerse a una key.
Para un escaneo más serio existen herramientas dedicadas como 💡 gitleaks Herramienta open source que escanea repositorios git buscando secretos. Tiene cientos de patrones y se puede integrar tanto en hooks como en CI. Más info → , trufflehog o detect-secrets: tienen cientos de patrones y muchísimos menos falsos positivos. Los equipos que tocan infra sensible deberían usar las dos cosas: este hook para feedback rápido en local y un scanner dedicado en CI para escanear a fondo.
PeligroSi el hook se ha disparado con una key real, rótala ya. No basta con quitarla del staging. La key estuvo en disco, igual está en los ficheros recientes del IDE, igual la pegaste en un chat. Trata el aviso del hook como un susto, no como un acierto limpio.
Hook 4: composer.json y composer.lock sincronizados (pre-commit)
El fallo de CI más cansino del mundo: la build casca porque composer install --no-dev produce un lock distinto al que está committeado. Causa raíz: alguien tocó el composer.json (añadió una dependencia, bumpeó una versión) pero no corrió composer update ni committeó el lock actualizado.
#!/bin/bash
# .git/hooks/pre-commit (extensión)
set -e
STAGED=$(git diff --cached --name-only --diff-filter=ACMR || true)
JSON_STAGED=$(echo "$STAGED" | grep -c "^composer\.json$" || true)
LOCK_STAGED=$(echo "$STAGED" | grep -c "^composer\.lock$" || true)
if [ "$JSON_STAGED" -gt 0 ] && [ "$LOCK_STAGED" -eq 0 ]; then
echo "composer.json está staged pero composer.lock no."
echo "Corre 'composer update' o 'composer install' y añade el lock al staging."
exit 1
fi
if [ "$JSON_STAGED" -eq 0 ] && [ "$LOCK_STAGED" -gt 0 ]; then
echo "composer.lock está staged pero composer.json no."
echo "¿Querías actualizar los dos? Usa --no-verify si es una actualización de seguridad."
exit 1
fi
El hook comprueba las dos direcciones: composer.json sin lock es el caso típico, pero el contrario (lock sin json) también pasa, normalmente cuando alguien hace composer update y se traga updates de versiones menores.
Hook 5: bloquear .env y ficheros de claves (pre-commit)
Algo parecido a la detección de secretos pero a otro nivel. Hay nombres de fichero que nunca deberían entrar al repo, da igual lo que tengan dentro.
#!/bin/bash
# .git/hooks/pre-commit (extensión)
set -e
STAGED=$(git diff --cached --name-only --diff-filter=ACMR || true)
[ -z "$STAGED" ] && exit 0
# Bloquea .env pero deja pasar .env.example, .env.dist, .env.testing
DANGEROUS=$(echo "$STAGED" | grep -E '(^|/)\.env$' || true)
KEY_FILES=$(echo "$STAGED" | grep -E '\.(pem|key|p12|pfx)$' || true)
if [ -n "$DANGEROUS" ] || [ -n "$KEY_FILES" ]; then
echo "Bloqueando ficheros que típicamente contienen secretos:"
[ -n "$DANGEROUS" ] && echo "$DANGEROUS"
[ -n "$KEY_FILES" ] && echo "$KEY_FILES"
echo ""
echo "Añádelos al .gitignore."
exit 1
fi
La regex (^|/)\.env$ matchea .env y ruta/al/.env pero explícitamente no las variantes con sufijo. .env.example y .env.dist sí queremos que se committeen (son plantillas que dicen qué variables hay que configurar). Ojo con esto: .env.local y .env.production también pasan el filtro, y esos llevan secretos reales. Si tu equipo los usa, amplía la regex.
Las extensiones .pem, .key, .p12, .pfx son típicamente claves privadas de SSL, firma o auth. Si tienes fixtures de tests con claves legítimas, --no-verify y palante. El hook prefiere "bloquear por defecto, escape hatch para excepciones".
Hook 6: marcadores de merge conflict (pre-commit)
Cuando un merge o rebase mete conflictos, el dev los resuelve en el editor. Y a veces (más a menudo de lo que parece) guarda sin quitar todos los marcadores, y luego commitea. El resultado es un fichero con líneas literales <<<<<<< HEAD que PHP no sabe parsear.
#!/bin/bash
# .git/hooks/pre-commit (extensión)
set -e
STAGED=$(git diff --cached --name-only --diff-filter=ACMR || true)
[ -z "$STAGED" ] && exit 0
FOUND=$(git diff --cached --diff-filter=ACMR -U0 -- $STAGED \
| grep -E "^\+[^+]" \
| grep -E '^\+(<{7}|={7}$|>{7})' || true)
if [ -n "$FOUND" ]; then
echo "Marcadores de conflicto encontrados en los cambios staged:"
echo "$FOUND" | head -10
echo ""
echo "Resuelve el conflicto antes de commitear."
exit 1
fi
El patrón matchea exactamente 7 caracteres consecutivos <, > o = al principio de línea, que es el formato concreto que mete git. Otros bloques de 7 caracteres iguales (un comentario decorativo, ASCII art) no matchean porque van a mitad de línea, no al principio.
El hook 1 (lint de PHP) ya pilla esto indirectamente porque los marcadores rompen el parser, pero este es más rápido (no levanta proceso PHP, solo texto) y da un mensaje más claro. Tenerlos los dos juntos sale gratis y atacan el mismo problema desde ángulos distintos.
Hook 7: ficheros grandes (pre-commit)
Un asset binario committeado por error es un compromiso para siempre. Una vez está en el historial, sacarlo de ahí requiere reescribir el historial y forzar push, una operación destructiva operación destructiva Una operación que reescribe el historial de git de forma irreversible para los compañeros que ya hayan hecho pull. Hay que coordinarse con todo el equipo antes. tan disruptiva que la mayoría de equipos no lo hace nunca. El artefacto se queda en el repo para siempre, hinchando los clones y ralentizando todo.
#!/bin/bash
# .git/hooks/pre-commit (extensión)
set -e
MAX_SIZE_KB=500
STAGED=$(git diff --cached --name-only --diff-filter=ACMR || true)
[ -z "$STAGED" ] && exit 0
LARGE_FILES=""
while IFS= read -r FILE; do
[ -z "$FILE" ] && continue
[ ! -f "$FILE" ] && continue
SIZE_KB=$(du -k "$FILE" | cut -f1)
if [ "$SIZE_KB" -gt "$MAX_SIZE_KB" ]; then
LARGE_FILES="${LARGE_FILES}${FILE} (${SIZE_KB}KB)\n"
fi
done <<< "$STAGED"
if [ -n "$LARGE_FILES" ]; then
echo "Ficheros que pasan del límite de ${MAX_SIZE_KB}KB:"
echo -e "$LARGE_FILES"
echo "Plantéate usar Git LFS para assets binarios."
exit 1
fi
El umbral de 500KB es conservador y pilla la mayoría de commits accidentales. Si tu equipo tiene assets legítimos grandes (datasets de ejemplo, fixtures, imágenes que de verdad pintan estar en el repo), sube el límite o lista rutas específicas como excepción.
Hook 8: validación del nombre de rama (pre-push)
Este es un hook de proceso, no de corrección. Algunos equipos cuidan los nombres de rama por razones de tooling (auto-deploy en release/*, link automático con Jira en fix/JIRA-123), otros no. Para los que sí, bloquear nombres malos es free comparado con renombrar después.
#!/bin/bash
# .git/hooks/pre-push
set -e
BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || echo "")
PATTERN='^(main|develop|master|(feature|fix|chore|hotfix|release|refactor|test|docs)/[a-z0-9._-]+)$'
if [[ ! "$BRANCH" =~ $PATTERN ]]; then
echo "El nombre de rama '$BRANCH' no encaja con los patrones permitidos:"
echo " feature/descripcion-corta"
echo " fix/issue-123"
echo " chore/limpieza-x"
echo " hotfix/cosa-urgente"
echo ""
echo "Renómbrala con 'git branch -m <nuevo-nombre>'."
exit 1
fi
Esto va en pre-push y no en pre-commit porque el nombre de rama solo importa al compartirla. Commits locales en una rama con nombre raro no molestan a nadie. Cazarlo al hacer push significa que el dev puede seguir trabajando en cosas-de-paco hasta que esté listo, y entonces renombra a feature/actualizar-inventario una vez.
ConsejoAjusta el hook a tus necesidades, este es de ejemplo. Hay equipos que exigen ticket (
feature/JIRA-1234-nombre), otros solo permiten ciertos prefijos. La estructura del hook se queda igual, solo cambia la regex.
Hook 9: Conventional Commits (commit-msg)
El último hook, y el que más se quejan los devs hasta que se acostumbran. 💡 Conventional Commits Convención para escribir mensajes de commit con estructura type(scope): description. Hace que el historial sirva para generar changelogs y releases automáticamente.
Más info →
da estructura a los commits y hace que git log sirva de verdad para changelogs, releases y arqueología del historial.
#!/bin/bash
# .git/hooks/commit-msg
set -e
MSG_FILE=$1
MSG=$(head -1 "$MSG_FILE")
# Salta merges, reverts y fixups (git los genera automáticamente)
if echo "$MSG" | grep -qE '^(Merge |Revert |fixup! |squash! )'; then
exit 0
fi
PATTERN='^(feat|fix|chore|refactor|test|docs|perf|style|build|ci)(\([a-z0-9_-]+\))?!?: .{1,70}$'
if [[ ! "$MSG" =~ $PATTERN ]]; then
echo "El mensaje de commit no sigue Conventional Commits:"
echo " '$MSG'"
echo ""
echo "Esperado: type(scope): descripción"
echo "Ejemplos:"
echo " feat(auth): añadir soporte SAML SSO"
echo " fix(checkout): manejar tax rates nulos"
echo " chore: actualizar dependencias de composer"
echo ""
echo "Tipos permitidos: feat, fix, chore, refactor, test, docs, perf, style, build, ci"
exit 1
fi
Este hook vive en commit-msg, no en pre-commit. La diferencia es que commit-msg corre después de que el mensaje esté compuesto, con el fichero del mensaje como $1. El hook puede leerlo o modificarlo; en este caso solo valida la primera línea.
El límite de 70 caracteres en la descripción sigue la convención del kernel de Linux y de la mayoría de guías de estilo. Hay equipos que prefieren 50: es un parámetro, no una religión. Los merges, reverts y los fixup! de git commit --fixup se saltan porque git les genera el mensaje automáticamente y no siguen la convención (los fixup además desaparecen en el rebase).
Lo que esto desbloquea con el tiempo: git log --oneline pasa a ser autoexplicativo. Herramientas como conventional-changelog generan release notes directamente desde los commits. Bumpear versiones 💡 semver Semantic Versioning. Sistema MAJOR.MINOR.PATCH donde feat → MINOR, fix → PATCH y un breaking change → MAJOR.
Más info →
en base al tipo de commit (feat = minor, fix = patch, feat! o BREAKING CHANGE: = major) se vuelve posible. El hook es la puerta de entrada a un ecosistema entero de automatización que depende de mensajes consistentes.
Compartir hooks en el equipo
Todos los hooks de arriba viven en .git/hooks/, que no se versiona con el repo. Cada dev se los tiene que instalar a mano. Para proyectos en solitario va bien, en equipo no.
Tres patrones lo solucionan:
Commiteas los hooks en bin/git-hooks/ y un install.sh los copia o linka a .git/hooks/. Simple, sin dependencias, requiere disciplina.
Gestor PHP-nativo de git hooks. Configurado vía captainhook.json, instalado como dependencia de composer dev. La opción más idiomática para equipos PHP.
Gestor cross-language. lefthook.yml, hooks en paralelo (más rápido). Buen fit para equipos políglotas con varios lenguajes.
Para la mayoría de equipos PHP, captainhook es la recomendación por defecto: se instala vía composer, integra con PHPStan y otras tools PHP de forma nativa, y se configura por repo. Los devs en solitario y los equipos pequeños pueden empezar con .git/hooks/ a pelo más un script de instalación, y migrar a una herramienta cuando crezca el equipo.
Ejemplo mínimo de bin/install-hooks.sh:
#!/bin/bash
# bin/install-hooks.sh — instala los hooks del repo en .git/hooks/
set -e
HOOKS_DIR=".git/hooks"
SOURCE_DIR="bin/git-hooks"
if [ ! -d "$HOOKS_DIR" ]; then
echo "Esto no parece un repo de git. Aborto."
exit 1
fi
for HOOK in "$SOURCE_DIR"/*; do
NAME=$(basename "$HOOK")
ln -sf "../../$HOOK" "$HOOKS_DIR/$NAME"
chmod +x "$HOOK"
echo "Hook instalado: $NAME"
done
echo "Listo. EA, ya tienes los hooks instalados."
ImportanteHazlos ejecutables. Un script de pre-commit sin
chmod +xno hace nada en silencio. El script de instalación debería poner los permisos explícitamente.captainhookylefthooklo hacen solos; los setups manuales hay que cuidarlos.
Errores a evitar
- Hooks que corren en menos de 2 segundos
- Mensajes de error claros que apuntan al fix
- Solo revisar lo que el commit cambia, no el repo entero
- Dejar --no-verify disponible como válvula de escape
- Hooks rápidos en local + checks completos en CI
- Hooks que tardan más de 2 segundos (la gente los bypasea)
- Reformatear código en pre-commit (debug nightmare)
- Tirar PHPStan completo en pre-commit (eso es trabajo de CI)
- Atar hooks a una versión de PHP que no tiene todo el equipo
- Crear culpa social alrededor de --no-verify (la gente se va a la web UI)
AdvertenciaEl error más común es reformatear código automáticamente en pre-commit (PHP-CS-Fixer con
--fixy re-stage). Parece útil pero genera infierno al debuggear porque el código committeado no coincide con lo que el dev vio en local. Corre los formatters en modo check (--dry-runy exit con error si hay diferencias).
Concluyendo
Con una serie de hooks bien planteados y configurados, una PR se convierte en lo que debe ser. Una lectura de cambios en el código en el que podemos hacer focus realmente a lo que importa y así dejar de desviar la atención del reviewer a lo que de verdad importa y necesita criterio humano.
Nos vemos en los bares! 🍻