El módulo anterior terminó con Ana revirtiendo en producción un commit que rompía el almacenamiento de tareas. En la retrospectiva del equipo salió una frase que se repite en todos los equipos del mundo: "esto lo habríamos visto si alguien hubiera mirado". Y a los dos días, Bruno subió un console.log('AQUI!!') olvidado en app.js que llegó hasta main.
Ninguno de los dos problemas es de Git. Son problemas de disciplina, y la disciplina humana falla a las siete de la tarde de un viernes. Lo que sí puede hacer Git es ejecutar comprobaciones automáticamente en momentos concretos: justo antes de crear un commit, justo después de fusionar, justo antes de enviar al servidor.
Esos puntos de enganche se llaman hooks, y son la primera de las herramientas de este módulo. Con ellos, gestor-tareas dejará de aceptar commits con console.log, con marcadores de conflicto sin resolver o con mensajes de una sola palabra. Y de paso entenderás por qué un hook de cliente nunca es una medida de seguridad, aunque a mucha gente le gustaría que lo fuera.
Contenido
- Qué es un hook y dónde vive
- Los
.samplede fábrica - Anatomía de un hook: entrada, salida y código de retorno
- Tabla de los hooks de cliente
- Un
pre-commitreal paragestor-tareas - Un
commit-msgque valida la forma del mensaje - Más hooks útiles:
post-checkout,post-merge,pre-push --no-verify: por qué los hooks de cliente no son un control de seguridad- El problema de
.git/hooks: no se versiona core.hooksPathy los gestores de hooks- Los hooks de servidor, en una página
- Qué es un hook y dónde vive
Un hook es simplemente un fichero ejecutable con un nombre concreto dentro de .git/hooks/. No hay registro, ni configuración, ni plugin: si el fichero existe, tiene el nombre exacto que Git espera y tiene permiso de ejecución, Git lo lanza en el momento correspondiente.
applypatch-msg.sample pre-merge-commit.sample commit-msg.sample pre-push.sample fsmonitor-watchman.sample pre-rebase.sample post-update.sample pre-receive.sample prepare-commit-msg.sample push-to-checkout.sample pre-applypatch.sample update.sample pre-commit.sample sendemail-validate.sample
Recuerda de la lección 01-04 que .git/ es el repositorio de verdad: los objetos, las referencias y la configuración. hooks/ es un subdirectorio más de ese espacio administrativo, y eso tiene una consecuencia importante que veremos en el apartado 9: no forma parte del contenido versionado.
Tres reglas que gobiernan todo lo demás:
- El nombre es exacto y sin extensión.
pre-commit, nopre-commit.shniprecommit. - Tiene que ser ejecutable.
chmod +x .git/hooks/pre-commit. Es el fallo número uno. - Puede estar escrito en cualquier lenguaje. Git solo lo ejecuta; la línea
#!(shebang) decide el intérprete. Usaremosbash, pero un#!/usr/bin/env nodefunciona igual de bien.
- Los
.sample de fábrica
.sample de fábricaCuando haces git init (o git clone), Git copia un juego de ejemplos en .git/hooks/. Todos terminan en .sample precisamente para que no se ejecuten: como el nombre no coincide con el que Git busca, quedan ahí como documentación viva.
Activar uno es quitarle el sufijo:
cd ~/proyectos/gestor-tareas
mv .git/hooks/pre-commit.sample .git/hooks/pre-commit
chmod +x .git/hooks/pre-commitMerece la pena leer el pre-commit.sample que trae Git, porque hace algo útil de verdad: detecta nombres de fichero no ASCII y espacios en blanco al final de línea, usando git diff-index --check. Es un buen punto de partida.
Dónde salen los
.sample. Se copian de una plantilla, que por defecto está en/usr/share/git-core/templates/(Linux) o dentro de la instalación de Git en macOS/Windows. Se puede cambiar coninit.templateDir, y esa es una forma —tosca, pero real— de que todos tus repositorios nuevos nazcan con los mismos hooks.
- Anatomía de un hook: entrada, salida y código de retorno
Un hook recibe información de tres maneras posibles, según cuál sea:
| Vía | Ejemplo |
|---|---|
| Argumentos de línea de comandos | commit-msg recibe la ruta del fichero con el mensaje |
Entrada estándar (stdin) |
pre-push recibe una línea por cada referencia que se va a enviar |
| El propio repositorio | Cualquier hook puede lanzar git diff, git log, etc. |
Y devuelve información de una sola manera: su código de salida.
exit 0→ todo correcto, Git continúa.exitdistinto de 0 → en los hooks previos (pre-*), Git aborta la operación. En los posteriores (post-*), Git ya ha hecho el trabajo y el código de salida se ignora en la práctica.
Esa distinción es la que hay que interiorizar:
flowchart LR
A["git commit"] --> B{"pre-commit"}
B -- "exit 0" --> C["prepare-commit-msg"]
B -- "exit != 0" --> X["Commit ABORTADO"]
C --> D["Editor del mensaje"]
D --> E{"commit-msg"}
E -- "exit 0" --> F["Se crea el commit"]
E -- "exit != 0" --> X
F --> G["post-commit (informativo)"]
Todo lo que el hook escriba en la salida estándar o de error lo ve el usuario en el terminal. Por eso un hook bien escrito no solo falla: explica qué ha fallado y cómo arreglarlo.
- Tabla de los hooks de cliente
Estos son los que se ejecutan en la máquina de Ana, Bruno o Carla. Los de servidor son otra familia y los vemos en el apartado 11.
| Hook | Cuándo se ejecuta | Qué recibe | Si devuelve != 0 |
|---|---|---|---|
pre-commit |
Antes de pedir el mensaje, con el índice ya preparado | Nada | Aborta el commit |
prepare-commit-msg |
Tras generar el mensaje por defecto, antes de abrir el editor | 1) ruta del fichero de mensaje 2) origen (message, template, merge, squash, commit) 3) SHA si aplica |
Aborta el commit |
commit-msg |
Con el mensaje ya escrito, antes de crear el objeto | 1) ruta del fichero con el mensaje final | Aborta el commit |
post-commit |
Justo después de crear el commit | Nada | Se ignora (ya está hecho) |
pre-rebase |
Antes de iniciar un rebase | 1) rama base 2) rama que se rebasa (vacío si es la actual) | Impide el rebase |
post-checkout |
Tras git checkout/switch/clone que cambie el árbol |
1) SHA anterior 2) SHA nuevo 3) flag: 1 cambio de rama, 0 de ficheros |
Se ignora (salvo que impida el clone) |
post-merge |
Tras completar una fusión con éxito | 1) flag: 1 si era un merge squash |
Se ignora |
pre-push |
Antes de transferir objetos al remoto | Args: 1) nombre del remoto 2) URL. Por stdin: <ref-local> <sha-local> <ref-remota> <sha-remoto> por cada ref |
Aborta el push |
Algunos matices que ahorran sorpresas:
pre-commitno ve el mensaje, porque todavía no existe. Si necesitas el mensaje, tu hook escommit-msg.pre-commitve el índice, no el árbol de trabajo. Es la distinción de las tres zonas de la lección 01-03, y en el apartado 5 veremos por qué es crítica.prepare-commit-msgse usa poco para validar y mucho para rellenar: insertar el número de incidencia a partir del nombre de la rama, por ejemplo.post-checkoutse dispara también al clonar, lo cual es útil para avisar de dependencias que hay que instalar.pre-rebasees el mecanismo estándar para proteger ramas: "enmainno se rebasa nunca" (la regla de oro de la lección 05-01, hecha código).
Existen más hooks de cliente que no cubrimos aquí (applypatch-msg, pre-applypatch, post-rewrite, pre-auto-gc, post-index-change...). git help hooks los lista todos con precisión; los ocho de la tabla cubren el 95 % de los usos reales.
- Un
pre-commit real para gestor-tareas
pre-commit real para gestor-tareasAna escribe el hook que el equipo necesita. Requisitos, salidos directamente de los dos incidentes:
- Ningún
console.logen el código JavaScript que se confirma. - Ningún marcador de conflicto sin resolver (
<<<<<<<,=======,>>>>>>>), que es lo que pasa cuando alguien confirma en medio de una fusión mal terminada (lección 03-05).
#!/usr/bin/env bash
#
# .git/hooks/pre-commit — gestor-tareas
# Rechaza console.log y marcadores de conflicto sin resolver.
set -euo pipefail
# Colores solo si la salida es un terminal
if [ -t 1 ]; then
ROJO=$'\033[31m'; AMARILLO=$'\033[33m'; NEUTRO=$'\033[0m'
else
ROJO=''; AMARILLO=''; NEUTRO=''
fi
fallos=0
# Ficheros AÑADIDOS, COPIADOS o MODIFICADOS que están en el índice.
# --cached: mira el índice, no el árbol de trabajo.
# --diff-filter=ACM: ignora los borrados (no tiene sentido analizarlos).
# -z + read -d '': soporta nombres con espacios.
mapfile -d '' ficheros < <(git diff --cached --name-only --diff-filter=ACM -z)
if [ ${#ficheros[@]} -eq 0 ]; then
exit 0
fi
for f in "${ficheros[@]}"; do
# Se analiza el CONTENIDO DEL ÍNDICE, no el del disco.
contenido=$(git show ":$f" 2>/dev/null) || continue
# 1. Marcadores de conflicto, en cualquier fichero de texto
if printf '%s\n' "$contenido" | grep -qE '^(<{7}|={7}|>{7})( |$)'; then
echo "${ROJO}✗ $f contiene marcadores de conflicto sin resolver.${NEUTRO}"
fallos=1
fi
# 2. console.log, solo en JavaScript
case "$f" in
*.js)
coincidencias=$(printf '%s\n' "$contenido" | grep -nE 'console\.(log|debug)\(' || true)
if [ -n "$coincidencias" ]; then
echo "${ROJO}✗ $f contiene llamadas a console.log:${NEUTRO}"
printf '%s\n' "$coincidencias" | sed 's/^/ /'
fallos=1
fi
;;
esac
done
if [ "$fallos" -ne 0 ]; then
echo
echo "${AMARILLO}Commit abortado por el hook pre-commit.${NEUTRO}"
echo "Corrige lo anterior, vuelve a hacer 'git add' y repite el commit."
echo "Si estás absolutamente seguro: git commit --no-verify"
exit 1
fi
exit 0Instalación:
Lo importante de este script, línea a línea de lo que no es obvio:
-
set -euo pipefail.-eaborta si un comando falla,-usi se usa una variable no definida,-o pipefailpropaga el fallo de cualquier tubería. Sin esto, un error tipográfico dentro del hook hace que termine con éxito y no valide nada — un hook roto que dice "todo bien" es peor que no tener hook. -
git diff --cached --name-only --diff-filter=ACM. Es la clave del hook.--cachedcompara el índice conHEAD, es decir, exactamente lo que se va a confirmar. Si usaras el árbol de trabajo, ungit add -p(lección 02-04) que hubiera preparado solo media línea daría falsos positivos por lo que quedó fuera.--diff-filter=ACMdeja fuera los ficheros borrados. -
git show ":$f". La sintaxis:<ruta>significa "esa ruta en el índice". Es la misma notación de las etapas de conflicto de la lección 03-05 (:1:,:2:,:3:), con la etapa 0 implícita. Vuelve a ser el mismo principio: se valida lo que se confirma, no lo que hay en disco. -
grep -qE '^(<{7}|={7}|>{7})( |$)'. Los marcadores de conflicto son siete caracteres al principio de línea, seguidos de un espacio o del final de línea. Esa precisión evita que una línea de guiones decorativa dispare una falsa alarma. -
|| truetras elgrepdeconsole.log.grepdevuelve 1 cuando no encuentra nada, y conset -eeso mataría el script. El|| trueneutraliza ese código de salida. -
El mensaje de error menciona
--no-verify. Es deliberado: un hook que bloquea sin ofrecer salida acaba desinstalado por alguien harto. Mejor que la salida de emergencia sea explícita y consciente.
Probando el hook:
echo "console.log('AQUI!!');" >> app.js
git add app.js
git commit -m "Añadir el contador de tareas pendientes"✗ app.js contiene llamadas a console.log:
142: console.log('AQUI!!');
Commit abortado por el hook pre-commit.
Corrige lo anterior, vuelve a hacer 'git add' y repite el commit.
Si estás absolutamente seguro: git commit --no-verifyEl commit no existe. git log no ha cambiado, el índice sigue preparado y basta con corregir y repetir.
Un hook lento es un hook que se desinstala. El
pre-commitse ejecuta en cada commit. Si tarda cuarenta segundos porque lanza toda la batería de pruebas, el equipo empezará a usar--no-verifypor costumbre y habrás perdido la partida. Regla práctica: enpre-commit, comprobaciones de menos de dos segundos sobre los ficheros modificados. Lo pesado va enpre-push(apartado 7) o en integración continua (lección 07-06).
- Un
commit-msg que valida la forma del mensaje
commit-msg que valida la forma del mensajeEl segundo hook del equipo comprueba el mensaje. Recibe la ruta de un fichero temporal con el mensaje ya escrito, y puede leerlo, modificarlo o rechazarlo.
#!/usr/bin/env bash
#
# .git/hooks/commit-msg — gestor-tareas
# Comprueba la FORMA del mensaje, no su contenido.
set -euo pipefail
fichero_mensaje="$1"
# Primera línea que no sea comentario ni esté vacía
asunto=$(grep -v '^#' "$fichero_mensaje" | sed '/^[[:space:]]*$/d' | head -n 1)
# Las fusiones y reversiones automáticas se dejan pasar
case "$asunto" in
"Merge "*|"Revert "*|"fixup!"*|"squash!"*) exit 0 ;;
esac
if [ -z "$asunto" ]; then
echo "✗ El mensaje de commit está vacío."
exit 1
fi
if [ ${#asunto} -lt 15 ]; then
echo "✗ El asunto es demasiado corto (${#asunto} caracteres, mínimo 15)."
echo " Asunto: '$asunto'"
echo " Describe QUÉ cambia, no 'arreglos' ni 'cambios'."
exit 1
fi
if [ ${#asunto} -gt 72 ]; then
echo "✗ El asunto es demasiado largo (${#asunto} caracteres, máximo 72)."
echo " Resume en la primera línea y amplía en el cuerpo, dejando"
echo " una línea en blanco entre ambos."
exit 1
fi
if printf '%s' "$asunto" | grep -qE '\.$'; then
echo "✗ El asunto no debe terminar en punto."
exit 1
fi
exit 0Con él instalado:
✗ El asunto es demasiado corto (8 caracteres, mínimo 15). Asunto: 'arreglos' Describe QUÉ cambia, no 'arreglos' ni 'cambios'.
Dos observaciones sobre el diseño de este hook:
- Valida forma, no fondo. Un script puede comprobar longitud, puntuación o que el asunto empiece por verbo si sigues una plantilla. No puede comprobar que el mensaje sea útil. Eso lo hace la revisión de código (lección 07-02).
- Deja pasar
Merge,Revert,fixup!ysquash!. Son mensajes generados por Git (lecciones 03-03, 05-06 y 05-02). Un hook que los rechace convierte cada fusión y cada--autosquashen una pelea.
Qué debe decir un buen mensaje de commit —el asunto, el cuerpo, el imperativo, los formatos tipo Conventional Commits— es el tema completo de la lección 08-01: Escribiendo Buenos Mensajes de Confirmación. Aquí solo nos interesa el mecanismo:
commit-msges el punto donde esas convenciones, sean las que sean, se pueden hacer cumplir automáticamente. Cuando lleguemos a 08-01 tendrás las reglas; el hook para aplicarlas ya lo sabes escribir.
Un uso alternativo, y muy práctico, es modificar el mensaje en lugar de rechazarlo. Con prepare-commit-msg se puede añadir el identificador de la incidencia deducido del nombre de la rama:
#!/usr/bin/env bash
# .git/hooks/prepare-commit-msg
# Si la rama es 'funcionalidad/GT-123-lo-que-sea', añade [GT-123] al final.
fichero_mensaje="$1"
origen="${2:-}"
# No tocar mensajes de fusión, squash ni de un commit reutilizado (-C)
case "$origen" in
merge|squash|commit) exit 0 ;;
esac
rama=$(git symbolic-ref --short HEAD 2>/dev/null || echo "")
incidencia=$(printf '%s' "$rama" | grep -oE 'GT-[0-9]+' || true)
if [ -n "$incidencia" ] && ! grep -q "$incidencia" "$fichero_mensaje"; then
printf '\nRefs: %s\n' "$incidencia" >> "$fichero_mensaje"
fiCarla trabaja en funcionalidad/GT-214-borrado-multiple y sus commits salen con Refs: GT-214 sin que ella tenga que acordarse. Fíjate en git symbolic-ref --short HEAD: es el mismo comando de la lección 03-01 que devuelve el nombre de la rama actual, y aquí resuelve el problema de un plumazo.
- Más hooks útiles:
post-checkout, post-merge, pre-push
post-checkout, post-merge, pre-pushpost-merge y post-checkout: avisar de que hay que reinstalar dependencias.
#!/usr/bin/env bash
# .git/hooks/post-merge
if git diff-tree -r --name-only HEAD@{1} HEAD | grep -q '^package-lock.json$'; then
echo "⚠ package-lock.json ha cambiado. Ejecuta 'npm install'."
fiHEAD@{1} es el reflog: dónde estaba HEAD antes de la fusión. git diff-tree -r --name-only entre esos dos puntos lista los ficheros que han cambiado. Si el fichero de bloqueo está entre ellos, hay dependencias nuevas. Es un aviso, no un bloqueo: los hooks post-* no pueden abortar nada.
pre-push: lo pesado, aquí.
pre-push es donde deben ir las comprobaciones que tardan, porque se ejecuta una vez cada muchos commits. Recibe por stdin una línea por referencia:
<ref-local> <sha-local> <ref-remota> <sha-remoto> refs/heads/main a1b2c3d... refs/heads/main e5f6a7b...
#!/usr/bin/env bash
#
# .git/hooks/pre-push — gestor-tareas
# Impide enviar a main commits marcados como WIP o fixup!.
set -euo pipefail
remoto="$1"
url="$2"
sha_vacio="0000000000000000000000000000000000000000"
while read -r ref_local sha_local ref_remota sha_remoto; do
# Borrado de rama: nada que comprobar
[ "$sha_local" = "$sha_vacio" ] && continue
# Solo nos importa main
[ "$ref_remota" = "refs/heads/main" ] || continue
if [ "$sha_remoto" = "$sha_vacio" ]; then
rango="$sha_local" # rama nueva en el remoto
else
rango="$sha_remoto..$sha_local"
fi
sospechosos=$(git rev-list --grep='^\(WIP\|fixup!\|squash!\)' "$rango")
if [ -n "$sospechosos" ]; then
echo "✗ Hay commits WIP/fixup! sin consolidar en lo que vas a enviar a main:"
git log --oneline --grep='^\(WIP\|fixup!\|squash!\)' "$rango" | sed 's/^/ /'
echo
echo " Consolídalos con: git rebase -i --autosquash origin/main"
exit 1
fi
done
exit 0Este hook usa todo lo del módulo 5: los rangos A..B (lección 02-06), los commits fixup! (lección 05-02) y el --autosquash como solución sugerida. Y respeta el caso de una rama que aún no existe en el remoto, en el que sha_remoto viene a ceros.
--no-verify: por qué los hooks de cliente no son un control de seguridad
--no-verify: por qué los hooks de cliente no son un control de seguridadTodo lo anterior se salta con una opción:
--no-verify (o -n en git commit) desactiva pre-commit, commit-msg y prepare-commit-msg en el commit, y pre-push en el envío. Y ni siquiera hace falta: como el hook es un fichero del disco del usuario, cualquiera puede borrarlo, editarlo o quitarle el permiso de ejecución.
De ahí la conclusión que hay que grabar a fuego:
Los hooks de cliente son una ayuda, no una barrera. Protegen del despiste, no de la voluntad. Cualquier regla que deba cumplirse se aplica en el servidor, no en el portátil de nadie.
| Hook de cliente | Comprobación en servidor / CI | |
|---|---|---|
| Dónde se ejecuta | Máquina del desarrollador | Servidor Git o plataforma de CI |
| ¿Se puede saltar? | Sí, con --no-verify o borrando el fichero |
No |
| ¿Se distribuye al clonar? | No (apartado 9) | Sí, es único y central |
| Velocidad percibida | Debe ser inmediata | Puede tardar minutos |
| Momento del aviso | Antes de crear el commit: barato de corregir | Después de enviar: más caro |
| Papel correcto | Detectar el despiste pronto | Garantizar que la regla se cumple |
Los dos son complementarios, y esa es la forma sana de plantearlo: el hook local te ahorra el viaje de ida y vuelta al servidor; el servidor es el que decide de verdad. Un --no-verify puntual y consciente —por ejemplo, para guardar trabajo a medias en una rama personal— es perfectamente legítimo. Lo que no es legítimo es que sea la costumbre.
- El problema de
.git/hooks: no se versiona
.git/hooks: no se versionaAna tiene un pre-commit estupendo. Bruno hace git clone y... no lo tiene. Carla tampoco.
.git/hooks/ está dentro de .git/, y .git/ no se clona como contenido. Al clonar se transfieren objetos y referencias (lección 04-01), no el directorio administrativo del otro. Cada repositorio nace con sus .sample de fábrica y nada más.
flowchart TD
subgraph Ana
A1["Árbol de trabajo<br/>app.js, index.html..."]
A2[".git/hooks/pre-commit ✅"]
end
subgraph Servidor["git.ejemplo.es"]
S1["Objetos y referencias"]
end
subgraph Bruno
B1["Árbol de trabajo<br/>app.js, index.html..."]
B2[".git/hooks/ solo .sample ❌"]
end
A1 -->|push| S1
S1 -->|clone| B1
A2 -.->|"NO viaja"| B2
Hay tres formas de resolverlo, de peor a mejor.
A. A mano. Guardar los hooks en un directorio versionado (herramientas/hooks/) y que cada uno los copie:
Funciona, se documenta en el README.md y se olvida el primer día. Sirve para equipos de dos personas y poco más.
B. core.hooksPath. Es la solución nativa de Git desde la versión 2.9, y la mejor si no quieres dependencias externas.
C. Un gestor de hooks. Es lo estándar en proyectos con ecosistema (npm, Python...). Ambas se ven en el apartado siguiente.
core.hooksPath y los gestores de hooks
core.hooksPath y los gestores de hookscore.hooksPath
Esta opción le dice a Git que busque los hooks en otro directorio, que sí puede estar versionado:
# En el repositorio, con los hooks ya en herramientas/hooks/
git config core.hooksPath herramientas/hooksA partir de ahí, .git/hooks/ se ignora por completo y Git ejecuta herramientas/hooks/pre-commit, herramientas/hooks/commit-msg, etc.
La estructura de gestor-tareas queda así:
gestor-tareas/
├── app.js
├── estilos.css
├── index.html
├── README.md
└── herramientas/
└── hooks/
├── commit-msg
├── pre-commit
├── prepare-commit-msg
└── pre-pushLos hooks se editan, se revisan en una pull request y evolucionan con el proyecto, como cualquier otro fichero. Con dos matices:
- Los permisos de ejecución sí se versionan. Git guarda el bit ejecutable en el modo de la entrada del árbol (lección 01-04:
100755frente a100644). Si añades un hook sinchmod +x, hazlo después congit update-index --chmod=+x herramientas/hooks/pre-commit. core.hooksPathes configuración local, y la configuración no se clona (lección 01-05). Sigue haciendo falta que cada persona ejecute una vez elgit config. La diferencia es que ahora es un comando único en lugar de mantener copias sincronizadas: se pone en elREADME.mdo en un scriptherramientas/instalar.sh.
#!/usr/bin/env bash
# herramientas/instalar.sh — ejecutar una vez tras clonar
git config core.hooksPath herramientas/hooks
chmod +x herramientas/hooks/*
echo "Hooks de gestor-tareas instalados."Cuidado con el ámbito. Si lo pones con
--globalafectará a todos tus repositorios y romperá los que dependan de sus propios hooks.core.hooksPathcasi siempre debe ser local al repositorio.
Gestores de hooks
En proyectos con un ecosistema de paquetes detrás, lo habitual es delegar en una herramienta:
| Herramienta | Ecosistema | Cómo funciona | Cuándo encaja |
|---|---|---|---|
| Husky | Node.js / npm | Fija core.hooksPath a .husky/ en un script de instalación; cada hook es un fichero corto en ese directorio |
Proyectos JS/TS, como gestor-tareas |
| pre-commit (framework en Python) | Multilenguaje | Un .pre-commit-config.yaml declara qué comprobaciones correr; la herramienta instala el hook y gestiona los entornos |
Equipos con muchos linters o varios lenguajes |
| Lefthook | Multilenguaje (binario Go) | Configuración YAML, ejecuta tareas en paralelo | Repositorios grandes donde importa la velocidad |
core.hooksPath a pelo |
Ninguno | Scripts propios versionados | Sin dependencias, control total, más trabajo manual |
Con Husky, el pre-commit de gestor-tareas quedaría en .husky/pre-commit y solo tendría que invocar la comprobación:
lint-staged aplica el linter solo a los ficheros preparados, que es la misma idea del git diff --cached del apartado 5, resuelta por otro. Ese es el argumento de fondo para usar un gestor: no inventar de nuevo la lógica de "qué ficheros están en el índice", "cómo restaurar si falla" o "cómo cachear entornos".
Y el argumento en contra, igual de real: añades una dependencia y una capa de indirección para algo que en el fondo es un script de veinte líneas. Para gestor-tareas, core.hooksPath es suficiente y el equipo entiende exactamente qué se ejecuta.
- Los hooks de servidor, en una página
Como quedó claro en el apartado 8, lo que debe cumplirse siempre se comprueba en el lado del servidor. Allí hay otra familia de hooks, que viven en el hooks/ del repositorio bare (lección 04-01):
| Hook | Cuándo | Qué recibe | Si devuelve != 0 |
|---|---|---|---|
pre-receive |
Una vez, antes de aceptar nada del push | Por stdin: <sha-viejo> <sha-nuevo> <ref> por cada ref |
Rechaza el push entero |
update |
Una vez por cada referencia | Args: <ref> <sha-viejo> <sha-nuevo> |
Rechaza esa referencia; las demás pueden pasar |
post-receive |
Después de aceptar, con todo ya actualizado | Igual que pre-receive, por stdin |
Se ignora |
Usos típicos: pre-receive para rechazar force pushes sobre main o commits de más de X megas; update para permitir que solo ciertas personas creen etiquetas; post-receive para notificar a un chat, disparar un despliegue o avisar a un sistema de tickets.
Dos precisiones importantes:
- Requieren acceso al servidor. En un servidor propio se editan directamente; en una plataforma alojada (GitHub, GitLab, Bitbucket) no tienes acceso a
hooks/, y el equivalente son sus mecanismos propios: branch protection rules, push rules, required status checks y webhooks. - Ese es justamente el terreno de la integración continua. Cómo se configura una tubería que ejecute las pruebas en cada envío y en cada pull request, qué es un status check obligatorio y cómo se conecta todo eso con el flujo de trabajo del equipo es el contenido de la lección 07-06: Integración Continua con Git. Aquí basta con saber que existen, dónde viven y por qué son el sitio correcto para las reglas innegociables.
Errores Comunes y Consejos
Error 1: olvidar chmod +x. Git no avisa: simplemente no ejecuta el hook, y tú crees que tu validación funciona. Comprueba con ls -l .git/hooks/ que aparece la x. Es, con diferencia, el fallo más frecuente.
Error 2: dejarle la extensión. pre-commit.sh no se ejecuta nunca. El nombre debe ser exactamente el que Git espera.
Error 3: validar el árbol de trabajo en lugar del índice. Si tu pre-commit lee los ficheros del disco, un git add -p parcial dará falsos positivos o —peor— falsos negativos. Usa git diff --cached y git show :<ruta>.
Error 4: dar por hecho que los hooks se clonan. No se clonan. Sin core.hooksPath o un gestor, tú tienes la validación y tus compañeros no.
Error 5: hooks lentos en pre-commit. La batería completa de pruebas en cada commit garantiza que el equipo acabe usando --no-verify por sistema. Lo rápido en pre-commit, lo lento en pre-push, lo definitivo en CI.
Error 6: confiar en los hooks de cliente como control de seguridad. Se saltan con una opción. Las reglas obligatorias van en el servidor.
Error 7: un hook que falla sin explicar por qué. Un exit 1 mudo es la peor experiencia posible. Di qué fichero, qué línea y cómo se arregla.
Error 8: bloquear los mensajes automáticos. Un commit-msg que rechace Merge branch ... o fixup! hace imposible fusionar y usar --autosquash. Dale una excepción explícita.
Consejo 1: prueba el hook antes de confiar en él. Puedes ejecutarlo a mano con el índice preparado: .git/hooks/pre-commit; echo "salida: $?".
Consejo 2: escribe hooks defensivos. set -euo pipefail, manejo de nombres con espacios (-z + mapfile -d '') y || true donde un comando pueda devolver un código no nulo legítimamente.
Consejo 3: registra las excepciones. Si alguien usa --no-verify, que sea una decisión, no un hábito. Un post-commit puede dejar constancia local de cuándo se saltó una comprobación.
Consejo 4: empieza por uno solo. Un pre-commit que evita console.log y marcadores de conflicto ya justifica la herramienta. Añadir seis hooks el primer día es la vía rápida al rechazo.
Consejo 5: git help hooks es la referencia definitiva. Está instalada en tu máquina, es exacta para tu versión de Git y documenta cada argumento de cada hook.
Ejercicios
Ejercicio 1: un pre-commit que impide confirmar TODO:
En un repositorio de pruebas, escribe un hook pre-commit que:
- Rechace el commit si algún fichero
.jspreparado contiene la cadenaTODO:. - Muestre el fichero y el número de línea de cada coincidencia.
- Analice el contenido del índice, no el del árbol de trabajo.
- Demuestra que funciona: crea un fichero con un
TODO:, prepáralo e intenta confirmarlo. Después demuestra que--no-verifylo salta.
Ejercicio 2: commit-msg con prefijo obligatorio
Escribe un hook commit-msg que exija que el asunto empiece por uno de estos prefijos: feat:, fix:, docs: o refactor:. Debe:
- Rechazar
Actualizar el READMEy aceptardocs: actualizar el README. - Dejar pasar los mensajes que empiecen por
Merge,Revert,fixup!osquash!. - Mostrar la lista de prefijos válidos cuando rechace.
Ejercicio 3: hooks versionados con core.hooksPath
Partiendo de los dos hooks anteriores:
- Muévelos a
herramientas/hooks/dentro del repositorio y confírmalos. - Configura
core.hooksPathpara que Git los use. - Verifica que siguen funcionando y que
.git/hooks/pre-commitya no se ejecuta (déjalo con unechodistinto para comprobarlo). - Clona el repositorio en otro directorio y comprueba qué pasa: ¿se ejecutan los hooks en el clon? ¿Qué falta?
Soluciones
Solución 1:
cat > .git/hooks/pre-commit <<'FIN'
#!/usr/bin/env bash
set -euo pipefail
fallos=0
mapfile -d '' ficheros < <(git diff --cached --name-only --diff-filter=ACM -z)
for f in "${ficheros[@]:-}"; do
case "$f" in
*.js)
encontrado=$(git show ":$f" | grep -nE 'TODO:' || true)
if [ -n "$encontrado" ]; then
echo "✗ $f contiene TODO: sin resolver"
printf '%s\n' "$encontrado" | sed 's/^/ linea /'
fallos=1
fi
;;
esac
done
[ "$fallos" -eq 0 ] || { echo "Commit abortado."; exit 1; }
exit 0
FIN
chmod +x .git/hooks/pre-commitprintf 'function guardar() {\n // TODO: validar la entrada\n}\n' > app.js
git add app.js
git commit -m "Añadir la función de guardado"El hook no ha intervenido: --no-verify lo ha saltado por completo. Esa es exactamente la demostración del apartado 8.
Solución 2:
cat > .git/hooks/commit-msg <<'FIN'
#!/usr/bin/env bash
set -euo pipefail
asunto=$(grep -v '^#' "$1" | sed '/^[[:space:]]*$/d' | head -n 1)
case "$asunto" in
"Merge "*|"Revert "*|"fixup!"*|"squash!"*) exit 0 ;;
esac
if ! printf '%s' "$asunto" | grep -qE '^(feat|fix|docs|refactor): .+'; then
echo "✗ El asunto debe empezar por un prefijo válido."
echo " Prefijos: feat: fix: docs: refactor:"
echo " Recibido: '$asunto'"
exit 1
fi
exit 0
FIN
chmod +x .git/hooks/commit-msg✗ El asunto debe empezar por un prefijo válido. Prefijos: feat: fix: docs: refactor: Recibido: 'Actualizar el README'
Y la excepción de las fusiones:
git switch -c rama-prueba
echo "x" > x.txt && git add . && git commit -m "feat: añadir el fichero x"
git switch main
git merge --no-ff rama-prueba -m "Merge branch 'rama-prueba'"El mensaje Merge branch ... no cumple el patrón de prefijos, pero la excepción lo deja pasar.
Solución 3:
mkdir -p herramientas/hooks
git mv .git/hooks/pre-commit herramientas/hooks/pre-commit 2>/dev/null \
|| cp .git/hooks/pre-commit herramientas/hooks/pre-commit
cp .git/hooks/commit-msg herramientas/hooks/commit-msg
chmod +x herramientas/hooks/*
# Marcamos el hook viejo para distinguirlo
printf '#!/usr/bin/env bash\necho "ESTE ES EL HOOK VIEJO DE .git/hooks"\nexit 1\n' \
> .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
git add herramientas
git commit --no-verify -m "feat: añadir los hooks versionados del proyecto"git config core.hooksPath herramientas/hooks
printf 'const a = 1; // TODO: revisar\n' > otro.js
git add otro.js
git commit -m "feat: añadir el modulo otro"El mensaje es el del hook versionado, no el "ESTE ES EL HOOK VIEJO": core.hooksPath ha desviado la búsqueda por completo.
# El clon
cd /tmp
git clone /tmp/practica-hooks practica-hooks-clon
cd practica-hooks-clon
ls herramientas/hooks/
git config --get core.hooksPathLos ficheros de los hooks sí han viajado, porque están versionados. Lo que no ha viajado es la configuración core.hooksPath, que es local (lección 01-05). Por eso hace falta el paso de instalación:
Y esa es la razón de existir de herramientas/instalar.sh: convertir el "copia estos cuatro ficheros y dales permisos" en un único comando que se ejecuta una vez tras clonar.
Conclusión
Los hooks convierten a Git en algo más que un almacén de historial: en un sitio donde enganchar automatismos. Lo esencial:
- Un hook es un fichero ejecutable con un nombre exacto en
.git/hooks/. Sin registro, sin plugins: si está y es ejecutable, se ejecuta. - Los
.samplede fábrica están desactivados por el sufijo y sirven de documentación; quitarles el.samplelos activa. - La comunicación es simple: argumentos o
stdinde entrada, código de salida de salida. En los hookspre-*, un código distinto de cero aborta la operación; en lospost-*solo informa. - Los ocho hooks de cliente de la tabla cubren casi todo:
pre-commitycommit-msgpara validar lo que entra,prepare-commit-msgpara rellenar,pre-pushpara lo caro, ypost-checkout/post-merge/post-commitpara avisar. - Un
pre-commitdebe analizar el índice (git diff --cached,git show :<ruta>), no el árbol de trabajo, y debe ser rápido. --no-verifyse lo salta todo, y el fichero está en el disco del usuario: los hooks de cliente ayudan contra el despiste, no son un control de seguridad. Lo obligatorio se comprueba en el servidor..git/hooks/no se versiona. La solución nativa escore.hooksPathapuntando a un directorio del repositorio; la del ecosistema, un gestor tipo Husky, pre-commit o Lefthook.- Los hooks de servidor (
pre-receive,update,post-receive) son los que sí obligan; su desarrollo práctico, junto con las tuberías de comprobación automática, está en la lección 07-06. Las convenciones concretas de los mensajes que validacommit-msg, en la 08-01.
Con los hooks, el equipo de gestor-tareas ya impide que ciertos fallos entren en el historial. Pero quedan los que entraron antes de instalarlos, y ese es un problema distinto: alguien reporta que el borrado de tareas ha dejado de funcionar, funcionaba seguro en la versión 1.0, y entre aquella etiqueta y hoy hay más de doscientos commits. Nadie sabe cuál lo rompió.
Revisarlos uno a uno son doscientas pruebas. Pero si el historial es una secuencia ordenada en la que algo pasó de funcionar a no funcionar, hay una técnica que resuelve eso en ocho pruebas en lugar de doscientas. La vemos en la lección 06-02: Git Bisect.
Dominando Git: De Principiante a Avanzado
Módulo 1: Introducción a Git
- ¿Qué es Git?
- Instalando Git
- Terminología Básica de Git
- El Modelo de Datos de Git
- Configurando Git
- Configuración Inicial
Módulo 2: Operaciones Básicas de Git
- Creando un Repositorio
- Clonando un Repositorio
- Flujo de Trabajo Básico de Git
- Preparando y Confirmando Cambios
- Inspeccionando Cambios con git diff
- Visualizando el Historial de Confirmaciones
Módulo 3: Ramas y Fusión
- Entendiendo las Ramas
- Creando y Cambiando Ramas
- Fusionando Ramas
- Estrategias de Fusión
- Resolviendo Conflictos de Fusión
- Gestión de Ramas
Módulo 4: Trabajando con Repositorios Remotos
- Entendiendo los Repositorios Remotos
- Agregando un Repositorio Remoto
- Autenticación con Repositorios Remotos
- Obteniendo y Extrayendo Cambios
- Enviando Cambios
- Rastreando Ramas
Módulo 5: Operaciones Avanzadas de Git
- Rebase
- Rebase Interactivo
- Cherry-Picking de Confirmaciones
- Guardando Cambios Temporales
- Etiquetando Confirmaciones
- Revirtiendo Confirmaciones
Módulo 6: Herramientas y Técnicas de Git
- Usando Git Hooks
- Git Bisect
- Git Blame
- Git Log y Alias
- Submódulos de Git
- Múltiples Copias de Trabajo con git worktree
Módulo 7: Estrategias de Colaboración y Flujo de Trabajo
- Forks y Pull Requests
- Revisiones de Código con Git
- Flujo de Trabajo Git Flow
- GitHub Flow
- Trunk Based Development
- Integración Continua con Git
Módulo 8: Mejores Prácticas y Consejos de Git
- Escribiendo Buenos Mensajes de Confirmación
- Manteniendo un Historial Limpio
- Ignorando Archivos con .gitignore
- Atributos de Fichero con .gitattributes
- Mejores Prácticas de Seguridad
- Consejos de Rendimiento
Módulo 9: Solución de Problemas y Depuración
- Problemas Comunes de Git
- Deshaciendo Cambios
- Resolviendo Divergencias con el Remoto
- Recuperando Confirmaciones Perdidas
- Tratando con Repositorios Corruptos
- Técnicas Avanzadas de Depuración
