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 reforzar un estándar mínimo viable — en local, antes de la CI.
Ender Puentes
Contexto: un estándar mínimo viable 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:
1. Lee core.hooksPath local; si no apunta a .githooks, lo configura.
2. Verifica que exista el directorio .githooks/.
3. 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 .githooksCó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 el estándar mínimo viable
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.
Guía de implementación
Creá el árbol de directorios en la raíz del repo. Los hooks son scripts shell (#!/bin/sh). El script init configura core.hooksPath y corre automáticamente después de pnpm install vía prepare. Reemplazá los comandos pnpm por el toolchain de tu proyecto si hace falta.
Estructura de directorios
.githooks/
├── README.md
├── pre-commit
├── commit-msg
├── pre-push
└── scripts/
└── init.jspackage.json
Agregá estos scripts para que cada clon active Git Auditor al instalar:
"setup:githooks:init": "node .githooks/scripts/init.js",
"prepare": "pnpm run setup:githooks:init"Script init (.githooks/scripts/init.js)
#!/usr/bin/env node
/* eslint-disable @typescript-eslint/no-require-imports */
'use strict';
/**
* Cross-platform Git hooks initialization.
* Configures core.hooksPath and ensures hook scripts are executable.
*/
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. Hooks will:');
console.log(' • Validate branch names (main, develop, or issue branches)');
console.log(' • Validate commit messages (Conventional Commits format)');
console.log(' • Run linting before commits');
console.log(' • Run type checking, knip, and linting before pushes');
pre-commit
Corre lint-staged sobre archivos staged. Omite merge commits.
#!/bin/sh
set -e
# Allow merge commits
if [ -f .git/MERGE_HEAD ]; then
exit 0
fi
# Function to check if error is due to missing dependencies
check_dependency_error() {
error_output="$1"
if echo "$error_output" | grep -qiE "(cannot find module|module not found|cannot resolve|missing|dependency|pnpm|npm|node_modules|command not found)" 2>/dev/null; then
echo ""
echo "⚠️ Possible missing dependencies detected!"
echo " Try running: pnpm install"
echo ""
return 0
fi
return 1
}
echo "🔍 Running lint-staged..."
if ! error_output=$(pnpm exec lint-staged 2>&1); then
echo "$error_output"
check_dependency_error "$error_output"
echo "❌ Lint-staged failed"
exit 1
fi
echo "✅ All pre-commit checks passed successfully."
commit-msg
Valida nombres de rama (main, develop o ramas de issue) y Conventional Commits. En main solo permite merge commits y hotfix(scope):.
#!/bin/sh
set -e
# Get current branch
branch=$(git branch --show-current)
# Validate branch name: main, develop, or GitHub issue branches (e.g. 123-add-user-auth)
if [ -n "$branch" ]; then
case "$branch" in
main|develop) ;;
*)
if ! echo "$branch" | grep -qE '^[0-9]+-[a-z0-9]+(-+[a-z0-9]+)*$'; then
echo ""
echo "🌿 Invalid branch name: \"$branch\""
echo ""
echo "This project uses a simple naming standard to keep work traceable:"
echo ""
echo " ✅ main — production branch"
echo " ✅ develop — integration branch"
echo " ✅ 123-add-user-auth — issue branches (number + slug)"
echo ""
echo "To create a valid branch:"
echo " 1. Open a GitHub Issue describing the work you want to do"
echo " 2. Click \"Create a branch\" from the issue sidebar"
echo " (GitHub will suggest a name like 42-your-issue-title)"
echo " 3. Check out that branch locally and start working"
echo ""
echo "Branch names like feat/*, fix/*, or other custom conventions"
echo "don't match our workflow. Starting from an issue keeps the branch"
echo "number linked to the ticket and helps the whole team stay aligned."
echo ""
exit 1
fi
;;
esac
fi
# Get commit message from file (first line only, trim whitespace)
commit_msg=$(head -n1 "$1" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
# Validate that commit message is not empty
if [ -z "$commit_msg" ]; then
echo "❌ Commit message cannot be empty"
exit 1
fi
# Check if it's a merge commit
# Merge commits can have various formats:
# - "Merge branch 'xxx' into yyy"
# - "Merge remote-tracking branch 'origin/xxx' into yyy"
# - "Merge commit 'xxx' into yyy"
# - "Merge pull request #xxx"
is_merge=false
if echo "$commit_msg" | grep -qiE "^Merge (branch|remote-tracking branch|commit|pull request)"; then
is_merge=true
fi
# Validation rules by branch
case "$branch" in
main)
# In main: allow hotfix, chore(release), or merge commits
if [ "$is_merge" = true ]; then
echo "✅ Merge commit allowed in main"
exit 0
fi
# Check for hotfix prefix
if echo "$commit_msg" | grep -qE "^hotfix\([^)]+\):"; then
echo "✅ Hotfix commit allowed in main"
exit 0
fi
echo "❌ Commits to 'main' must be:"
echo " - Merge commits from other branches"
echo " - Commits with prefix 'hotfix(scope):'"
echo ""
echo "Current commit message: $commit_msg"
exit 1
;;
esac
# TEMP: develop allows Conventional Commits during initial repo setup.
# Restore merge-only policy in commit-msg when issue-branch workflow is active.
# Validate Conventional Commits format for other branches
# Format: type(scope): subject or type: subject
# Allowed types: feat, fix, hotfix, chore, test, docs, style, ci
# Max header length: 120 characters
# Skip validation for merge commits
if [ "$is_merge" = true ]; then
exit 0
fi
# Check header length (max 120 characters)
# Use wc -m to count characters (not bytes) for proper UTF-8 support
header_length=$(printf '%s' "$commit_msg" | wc -m)
if [ "$header_length" -gt 120 ]; then
echo "❌ Commit message header exceeds 120 characters (current: $header_length)"
echo ""
echo "Current commit message: $commit_msg"
exit 1
fi
# Validate Conventional Commits format
# Pattern: type(scope): subject or type: subject
# Must start with type (no leading spaces), have optional scope in parentheses,
# followed by colon and space, then non-empty subject
# Allowed types: feat, fix, hotfix, chore, test, docs, style, ci
if ! echo "$commit_msg" | grep -qE "^(feat|fix|hotfix|chore|test|docs|style|ci)(\([^)]+\))?: .+"; then
echo "❌ Invalid commit message format"
echo ""
echo "Commit messages must follow Conventional Commits format:"
echo " type(scope): subject"
echo " or"
echo " type: subject"
echo ""
echo "Rules:"
echo " - Must start with type (no leading spaces)"
echo " - Scope is optional but must be in parentheses if present"
echo " - Must have colon followed by space"
echo " - Subject cannot be empty"
echo ""
echo "Allowed types: feat, fix, hotfix, chore, test, docs, style, ci"
echo ""
echo "Examples:"
echo " ✅ feat(auth): add social login"
echo " ✅ fix(api): resolve timeout issue"
echo " ✅ chore: update dependencies"
echo " ❌ feat(auth): (empty subject)"
echo " ❌ feat(auth):(no space after colon)"
echo ""
echo "Current commit message: $commit_msg"
exit 1
fi
echo "✅ Commit message format is valid"
pre-push
Revalida nombres de rama y corre typecheck, knip y lint completo antes del push.
#!/bin/sh
set -e
branch=$(git branch --show-current)
if [ -n "$branch" ]; then
case "$branch" in
main|develop) ;;
*)
if ! echo "$branch" | grep -qE '^[0-9]+-[a-z0-9]+(-+[a-z0-9]+)*$'; then
echo ""
echo "🌿 Invalid branch name: \"$branch\""
echo ""
echo "This project uses a simple naming standard to keep work traceable:"
echo ""
echo " ✅ main — production branch"
echo " ✅ develop — integration branch"
echo " ✅ 123-add-user-auth — issue branches (number + slug)"
echo ""
echo "To create a valid branch:"
echo " 1. Open a GitHub Issue describing the work you want to do"
echo " 2. Click \"Create a branch\" from the issue sidebar"
echo " (GitHub will suggest a name like 42-your-issue-title)"
echo " 3. Check out that branch locally and start working"
echo ""
echo "Branch names like feat/*, fix/*, or other custom conventions"
echo "don't match our workflow. Starting from an issue keeps the branch"
echo "number linked to the ticket and helps the whole team stay aligned."
echo ""
exit 1
fi
;;
esac
fi
# Function to check if error is due to missing dependencies
check_dependency_error() {
error_output="$1"
if echo "$error_output" | grep -qiE "(cannot find module|module not found|cannot resolve|missing|dependency|pnpm|npm|node_modules|command not found)" 2>/dev/null; then
echo ""
echo "⚠️ Possible missing dependencies detected!"
echo " Try running: pnpm install"
echo ""
return 0
fi
return 1
}
# ============================================================================
# Code Quality Checks
# ============================================================================
# TypeScript type checks (strict enforced)
echo "🔍 Running TypeScript type checks..."
if ! error_output=$(pnpm typecheck 2>&1); then
echo "$error_output"
check_dependency_error "$error_output"
echo "❌ TypeScript (tsc) failed. Please fix the type errors before pushing."
exit 1
fi
# Run knip
echo "🔍 Running knip..."
if ! error_output=$(pnpm knip 2>&1); then
echo "$error_output"
check_dependency_error "$error_output"
echo "❌ Knip failed. Please fix the issues before pushing."
exit 1
fi
# Run full lint (complements pre-commit hook)
echo "🔍 Running lint..."
if ! error_output=$(pnpm lint 2>&1); then
echo "$error_output"
check_dependency_error "$error_output"
echo "❌ Lint failed. Please fix the issues before pushing."
exit 1
fi
echo ""
echo "✅ All pre-push checks passed successfully."
En Unix, init.js marca los hooks como ejecutables (chmod +x). Verificá con: git config --get core.hooksPath
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
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