Git Auditor: hooks nativos con shell para auditoría de equipo
Cuando un equipo crece, los estilos de commit se dispersan y el lint llega tarde. Git Auditor usa hooks shell versionados en .githooks/ para aplicar una base común acordada — en local, antes de la CI.
Contexto: una base común acordada para el equipo
Cuando un equipo crece — o cuando vuelve a un repo después de un tiempo — suele aparecer la misma fricción: cada uno commitea con un estilo distinto, las ramas no se entienden a simple vista y los problemas de lint o tipos llegan tarde, en el push o en CI. No es mala intención; es falta de un piso común acordado.
Este documento describe cómo abordamos eso con Git Auditor: hooks nativos en .githooks/ que refuerzan un estándar mínimo viable — mensajes de commit legibles, ramas trazables, lint y tipos antes de subir — sin complicar el día a día.
La idea es simple: las reglas viven en el repo, se activan solas al instalar dependencias y ayudan a que todo el mundo escriba código de forma parecida antes de que el remoto o la CI tengan que corregir.
Cómo está armado
El patrón separa tres capas que conviene no mezclar:
- Init (prepare) · Activa los hooks en la máquina del desarrollador — Node (solo para el bootstrap)
- Hooks (.githooks/*) · Aplica las reglas acordadas en cada commit/push — Shell + Git (universal)
- Comandos invocados · Ejecutan lint, types, dead-code checks, etc. — El runtime del proyecto (pnpm, go test, ruff, …)
Los hooks son scripts #!/bin/sh. Ahí va la política ligera — formato de commits, nombres de rama, qué corre en pre-commit vs pre-push — con herramientas que Git ya sabe ejecutar. Los chequeos más pesados (linter, TypeScript, etc.) se delegan al toolchain del proyecto.
Elegimos hooks nativos con core.hooksPath porque Git ya trae el gancho; versionamos los scripts en el repo y evitamos depender de otra capa solo para orquestar lo mismo. Husky, Commitlint y herramientas similares pueden cumplir un rol parecido; aquí priorizamos scripts legibles, pocas dependencias en la validación de mensajes y un patrón que se puede replicar en otros stacks cambiando solo los comandos que invoca cada hook.
Por qué .git/ no versiona hooks (y hace falta un script)
Git guarda metadatos locales del repositorio dentro de .git/. Ese directorio no se sube al remoto: cada clon es independiente. Los hooks que Git ejecuta por defecto viven en .git/hooks/ — también locales, también fuera del control de versiones.
En la práctica, eso significa que un pre-commit copiado a mano en .git/hooks/ no llega al resto del equipo con un git pull. Quien clona el repo de cero no hereda esos scripts. Para compartir reglas hace falta otro enfoque.
La salida es un patrón en dos piezas:
- Scripts de auditoría · .githooks/ en la raíz del repo — Sí — viajan con el código
- Enlace “usa estos scripts como hooks” · core.hooksPath en .git/config — No — es config local de cada clon
core.hooksPath le dice a Git: “cuando vaya a ejecutar pre-commit, commit-msg, etc., búscalos en .githooks/ del working tree, no en .git/hooks/”. Los archivos sí están en el repo; el enlace no. Por eso hace falta correr un script de init (setup:githooks:init) que ejecute, como mínimo:
git config core.hooksPath .githooksSin ese paso, el equipo comparte las reglas en .githooks/ pero Git nunca las invoca. El script cierra esa brecha en cada máquina. Variantes del init pueden delegar a init.sh / init.bat en Windows o concentrar la lógica en un solo init.js; el problema y la solución son los mismos.
Cómo se activa: el script prepare
En package.json:
"setup:githooks:init": "node .githooks/scripts/init.js",
"prepare": "pnpm run setup:githooks:init"prepare es un lifecycle script de npm/pnpm que corre automáticamente después de `pnpm install`. Así, clonar el repo e instalar dependencias ya dispara el init — no dependemos de que alguien recuerde un paso extra en el README.
Lo que hace el init en cada ejecución:
- Lee core.hooksPath local; si no apunta a .githooks, lo configura.
- Verifica que exista el directorio .githooks/.
- En Unix, marca los hooks como ejecutables (chmod +x).
Si alguien clonó antes de que existiera prepare, o borró su .git/config, puede reactivar manualmente:
pnpm run setup:githooks:init
# equivalente:
git config core.hooksPath .githooksOcultar .githooks en el explorador
¿Qué hace esto en VS Code? Cursor y VS Code leen la configuración del workspace en .vscode/settings.json. La clave files.exclude solo oculta carpetas del explorador de archivos — no las borra del disco ni impide que Git las use. Es ergonomía: la política sigue versionada y corre sola con pnpm install; no hace falta que .githooks/ compita por atención mientras escribes features. Para abrir un hook oculto: Ctrl/Cmd+P y la ruta, o quita temporalmente la entrada en files.exclude.
Los hooks tienen que existir en el repo y ser editables cuando el equipo decide cambiar la política. Eso no lo discutimos. Lo que sí nos pasaba es que .githooks/ en el árbol de archivos generaba ruido: alguien junior abría la carpeta, veía scripts de shell, se asustaba o los tocaba “para ver qué pasa”. Otros simplemente se distraían con carpetas que no forman parte del día a día del feature.
No es un muro de seguridad. Cualquiera puede desactivar los hooks con --no-verify o borrar core.hooksPath. Es un compromiso de ergonomía: la política está versionada y corre sola con pnpm install; no hace falta que esté en primer plano mientras escribes componentes.
En .vscode/settings.json — Cursor hereda la misma config — agregamos .githooks a files.exclude:
"files.exclude": {
".githooks": true
}La carpeta sigue en disco. Git la sigue usando. Si tienes que cambiar un hook, abres el path directo o desactivas el exclude un momento. Para el resto del equipo, el repo se ve más limpio y la atención queda en src/, no en cableado.
Más adelante aplicamos la misma idea con .agents/ y las carpetas de editores de IA: ocultar el mecanismo, mostrar solo la fuente de verdad.
Cómo se aplican las reglas en cada máquina
Conviene distinguir dos cosas: las reglas que el repo define y cómo se activan en cada computadora.
- Versionadas: el contenido de .githooks/* — qué validar, qué mensajes de error mostrar, qué comandos correr.
- Locales: la activación (core.hooksPath) y la ejecución en la máquina de cada desarrollador al hacer git commit o git push.
Cada miembro del equipo tiene su propia copia de .git/config. Dos personas con el mismo repo pueden estar en estados distintos: una con hooks activos, otra con core.hooksPath vacío si nunca corrió install/init. La auditoría no es centralizada como un pre-receive hook en el remoto; es un acuerdo de proceso reforzado en local.
Eso implica límites explícitos:
- Un desarrollador puede ejecutar git commit --no-verify o git push --no-verify y saltarse los hooks.
- Puede editar o borrar core.hooksPath en su config local.
- Actualizar .githooks/pre-push en el repo no retroactivamente “instala” nada en máquinas que no vuelvan a correr init o pnpm install (donde prepare revalida el enlace).
Por eso los hooks son un acompañamiento del estándar de equipo — mejor experiencia de desarrollo, no un muro de seguridad. Ayudan temprano, en el flujo habitual, a quien tenga el init hecho. Para lo que quede fuera de ese alcance, CI y branch protection en el remoto siguen siendo la red de contención.
Por qué usamos shell en los hooks
Git ejecuta el hook como un proceso hijo del binario git. El contrato es simple: un ejecutable que termina con código 0 (ok) o distinto de 0 (bloquea la operación).
Shell cumple ese contrato en Linux, macOS y la mayoría de entornos de desarrollo sin compilar nada:
- Portabilidad del patrón: el mismo esquema .githooks/ + prepare funciona en cualquier stack. Solo cambian los comandos que el hook invoca (pnpm lint, make test, cargo clippy, etc.).
- Validaciones ligeras sin runtime: reglas de Conventional Commits, longitud de header, regex de nombre de rama o detección de merge commits no necesitan Node. Un grep -qE en commit-msg es suficiente y arranca en milisegundos.
- Un solo lugar de verdad: .githooks/commit-msg es la fuente de verdad del formato de commits; la documentación del repo apunta ahí.
- Menos puntos de fallo en el hook mismo: si el hook dependiera de node node_modules/.bin/commitlint, un install incompleto o un PATH roto impediría commitear antes de que el mensaje llegue a validarse.
Los hooks sí delegan en el stack del proyecto para la parte pesada del estándar: linter, comprobación de tipos, detección de código muerto. Shell orquesta; el lenguaje del repo ejecuta.
Qué puede incluir la base común
Las reglas concretas se definen por equipo y madurez del repo. El patrón admite combinar capas sin cambiar de mecanismo:
- pre-commit · Antes de crear el commit — Lint en archivos staged; formato; tests rápidos. Omite merges.
- commit-msg · Al escribir el mensaje — Conventional Commits; longitud máxima; política por rama (main solo merge/hotfix).
- pre-push · Antes de subir — Typecheck; lint completo; dead-code analysis; validación de nombre de rama.
Bypass explícito (solo cuando hace falta): git commit --no-verify / git push --no-verify.
El init lo llama Git Auditor en consola: resume la intención de canalizar el estándar antes de que el código llegue al remoto o a CI.
El estándar mínimo viable crece con el equipo — más reglas en pre-push cuando el repo madura, ventanas de release, protección de ramas — pero siempre con el mismo contrato: política versionada en `.githooks/`, activación local por script, ejecución en shell, checks pesados delegados al toolchain del proyecto.
Cuándo tiene sentido y cuándo no
Tiene sentido cuando:
- Quieres que todo el equipo escriba código y commits bajo las mismas reglas base, sin depender de memoria o revisión manual constante.
- El equipo usa Git de forma homogénea y puedes enganchar el init a pnpm install (o equivalente con prepare en npm/yarn).
- Prefieres hooks legibles y ajustables sin un framework extra para validar mensajes.
Limitaciones honestas (complemento al apartado de restricciones locales):
- Windows requiere atención (sh, Git Bash, o scripts .bat en el init).
- Si las reglas crecen mucho, conviene extraer funciones compartidas en .githooks/lib/ (sigue siendo shell) antes de migrar a otra herramienta.
Pasos para implementarlo en tu repo
Antes de empezar: necesitas un repo con Git, Node.js instalado (18 o superior) y un package.json en la raíz. Si usas npm o yarn en lugar de pnpm, cambia pnpm por npm o yarn en los ejemplos — el script init.js es igual.
1. Crear la carpeta .githooks/
En la raíz del repo, crea la estructura. Al inicio puedes dejar los hooks vacíos o con un placeholder; lo importante para este paso es la carpeta y el script de init:
mkdir -p .githooks/scripts
touch .githooks/pre-commit .githooks/commit-msg .githooks/pre-pushEstructura objetivo:
.githooks/
├── pre-commit # lint en staged (lint-staged) — lo defines después
├── commit-msg # Conventional Commits + política por rama
├── pre-push # typecheck, knip, lint completo
├── scripts/
│ └── init.js # ← copia el archivo de abajo
└── README.md # documentación para el equipo (opcional al inicio)Cada hook será un script #!/bin/sh ejecutable. La política va ahí; los chequeos pesados delegan al toolchain del proyecto (pnpm lint, pnpm typecheck, etc.). Primero haz funcionar el init; después llenas los hooks con tu lógica.
2. Crear el init — copia este archivo completo
Crea el archivo .githooks/scripts/init.js y pega todo este contenido (no hace falta instalar paquetes npm extra; solo usa Node y Git):
#!/usr/bin/env node
'use strict';
/**
* Git hooks init — configura core.hooksPath y marca hooks como ejecutables.
* Documentación: tu nota / repo .githooks/README.md
*/
const { execFileSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const HOOKS_PATH = '.githooks';
const root = path.resolve(__dirname, '../..');
const hooksDir = path.join(root, HOOKS_PATH);
function getHooksPath() {
try {
return execFileSync('git', ['config', '--get', 'core.hooksPath'], {
cwd: root,
encoding: 'utf8',
}).trim();
} catch {
return '';
}
}
const currentHooksPath = getHooksPath();
if (currentHooksPath !== HOOKS_PATH) {
console.log('🔧 Initializing Git Auditor...');
console.log(` Configuring Git hooks path to ${HOOKS_PATH}...`);
execFileSync('git', ['config', 'core.hooksPath', HOOKS_PATH], {
cwd: root,
stdio: 'inherit',
});
console.log('✅ Git Auditor initialized successfully');
} else {
console.log('✅ Git Auditor already initialized');
}
if (!fs.existsSync(hooksDir)) {
console.error(`⚠️ Warning: Hooks directory ${HOOKS_PATH} not found`);
process.exit(1);
}
if (process.platform !== 'win32') {
for (const entry of fs.readdirSync(hooksDir)) {
const filePath = path.join(hooksDir, entry);
if (fs.statSync(filePath).isFile()) {
try {
fs.chmodSync(filePath, 0o755);
} catch {
// Ignore chmod errors (e.g. read-only filesystem)
}
}
}
}
console.log('');
console.log('Git Auditor is now active.');
console.log(' Verify: git config --get core.hooksPath → should print .githooks');Qué hace este script (en simple):
- Le dice a Git: “usa los hooks de .githooks/, no los de .git/hooks/”.
- Comprueba que la carpeta .githooks/ exista.
- En Mac/Linux, marca los archivos de la carpeta como ejecutables.
Ese enlace vive en tu máquina (.git/config), no se sube al remoto. Por eso hace falta correr el init después de clonar.
3. Conectar el init a package.json
Abre package.json y agrega (o fusiona) estos scripts:
{
"scripts": {
"setup:githooks:init": "node .githooks/scripts/init.js",
"prepare": "pnpm run setup:githooks:init"
}
}Si ya tienes otro init (por ejemplo .agents), encadénalos en el mismo prepare:
"prepare": "pnpm run setup:githooks:init && pnpm run setup:ai:init"Prueba manual (sin esperar a un commit):
pnpm run setup:githooks:init
git config --get core.hooksPathLa segunda línea debe imprimir exactamente: .githooks. Si ves eso, el procedimiento funcionó.
Luego, pnpm install en cualquier clone nuevo volverá a ejecutar prepare y reactivará el enlace automáticamente.
4. Definir qué valida cada hook
En nuestro repo quedó así:
- `pre-commit` — lint-staged sobre archivos staged; omite merge commits.
- `commit-msg` — formato Conventional Commits, header máximo 120 caracteres, reglas distintas para main, develop e issue branches.
- `pre-push` — pnpm typecheck, pnpm knip, pnpm lint.
Ajusta los comandos a tu stack. El patrón no cambia: shell orquesta, el runtime del repo ejecuta.
5. Ocultar .githooks en VS Code (opcional pero recomendado)
En .vscode/settings.json:
"files.exclude": {
".githooks": true
}Cursor hereda esta config. La carpeta sigue en disco y Git la usa; solo desaparece del explorador para que nadie se distraiga ni toque scripts sin querer. No es seguridad — es ergonomía.
6. Documentar y verificar
Deja .githooks/README.md con qué hace cada hook y cómo bypassar (--no-verify) cuando haga falta.
Verificación en una máquina nueva o después de clonar:
pnpm install
git config --get core.hooksPath # debe imprimir: .githooks
pnpm run setup:githooks:init # re-ejecutar si hace faltaHaz un commit de prueba con mensaje inválido — debería fallar en commit-msg. Un push sin typecheck limpio — debería fallar en pre-push. Si eso pasa, el equipo ya tiene el piso común activo.
Referencia rápida
Objetivo: estándar mínimo viable de código y commits, reforzado en local
Activación: pnpm install → prepare → setup:githooks:init
Config Git: git config core.hooksPath .githooks (local, en .git/config)
Hooks: .githooks/pre-commit | commit-msg | pre-push (versionados)
Init: .githooks/scripts/init.js
Ocultar ruido: .vscode/settings.json → files.exclude → .githooks
Documentación: .githooks/README.mdVerificación manual:
git config --get core.hooksPath # debe imprimir: .githooks
pnpm run setup:githooks:init # re-ejecutar init si hace falta