Saltar al contenido principal

Lefthook — Gestor de Git hooks

Qué es

Lefthook es un gestor de Git hooks escrito en Go. Permite definir en un archivo lefthook.yaml qué comandos se ejecutan antes de cada commit (pre-commit) o al validar el mensaje del commit (commit-msg), de forma paralela y con filtrado por archivos modificados.

Por qué lo usamos en Wiedii

AlternativaProblemaLefthook
huskyRequiere Node/npm activos para correr los hooksBinario independiente — funciona sin entorno Node
Scripts manuales en .git/hooks/No se versionan en el repolefthook.yaml se commitea y se comparte con todo el equipo
Sin hooksErrores de lint/formato llegan al pipelineLos problemas se detectan antes del push, en la máquina del dev

Lefthook corre los comandos en paralelo por defecto, lo que lo hace más rápido que ejecutores secuenciales como husky.

Instalación

Lefthook se gestiona con mise en mise.toml, no como dependencia de npm. Así funciona correctamente aunque el proyecto no tenga node_modules instalado todavía.

# mise.toml
[tools]
"aqua:evilmartians/lefthook" = "2.1.3"
mise install --yes # instala lefthook junto al resto de herramientas

Los hooks se registran en .git/hooks/ ejecutando:

lefthook install

Esto ocurre automáticamente al correr mise install gracias al [hooks].postinstall en mise.toml:

[hooks]
postinstall = "[ -z \"$CI\" ] && lefthook install || true"

Es decir: mise run setupmise install --yes[hooks].postinstalllefthook install → hooks registrados. Sin pasos manuales, sin dependencia de Node, sin ejecución en CI.


Configuración estándar en Wiedii

# lefthook.yaml

pre-commit:
parallel: true
jobs:
- run: mise trust
- run: mise install --yes
commands:
toml:
priority: 1
glob: '*.toml'
run: mise exec -- taplo fmt {staged_files}
stage_fixed: true

prettier:
priority: 2
glob: '*.{js,cjs,mjs,ts,json,md,yaml}'
run: mise exec -- bun prettier --write --cache {staged_files}
stage_fixed: true

lint:
priority: 3
glob: '*.{ts,tsx,js,jsx}'
run: mise exec -- bun eslint {staged_files} --max-warnings=0

shellcheck:
priority: 1
glob: '*.sh'
run: mise exec -- shellcheck {staged_files}

lefthook-validate:
priority: 1
glob: 'lefthook.yaml'
run: lefthook validate

commit-msg:
parallel: false
jobs:
- run: mise trust
- run: mise install --yes
commands:
commitlint:
priority: 1
run: mise exec -- bun commitlint --config commitlint.config.js --edit "$1"

post-merge:
parallel: false
jobs:
- run: mise trust
- run: mise install --yes
commands:
bun-install:
glob: '{package.json,bun.lock,bunfig.toml}'
run: |
if [ ! -d node_modules ] || git diff --name-only ORIG_HEAD HEAD -- package.json bun.lock bunfig.toml | grep -q .; then
echo "bun-install: running bun install --frozen-lockfile"
mise exec -- bun install --frozen-lockfile
else
echo "bun-install: no dependency file changes detected, skipping"
fi

post-checkout:
parallel: false
jobs:
- run: mise trust
- run: mise install --yes
commands:
bun-install:
glob: '{package.json,bun.lock,bunfig.toml}'
run: |
if [ ! -d node_modules ] || git diff --name-only ORIG_HEAD HEAD -- package.json bun.lock bunfig.toml | grep -q .; then
echo "bun-install: running bun install --frozen-lockfile"
mise exec -- bun install --frozen-lockfile
else
echo "bun-install: no dependency file changes detected, skipping"
fi

post-rewrite:
parallel: false
jobs:
- run: mise trust
- run: mise install --yes
commands:
bun-install:
glob: '{package.json,bun.lock,bunfig.toml}'
run: |
if [ ! -d node_modules ] || git diff --name-only ORIG_HEAD HEAD -- package.json bun.lock bunfig.toml | grep -q .; then
echo "bun-install: running bun install --frozen-lockfile"
mise exec -- bun install --frozen-lockfile
else
echo "bun-install: no dependency file changes detected, skipping"
fi

Puntos clave de esta configuración:

  • parallel: true — todos los comandos de pre-commit corren en paralelo
  • jobsmise trust + mise install --yes garantizan que las herramientas estén disponibles antes de correr los comandos; sin eval "$(mise env)" para no contaminar el entorno
  • mise exec -- comando — cada comando corre con el entorno de mise aislado para ese comando
  • priority — dentro de un hook paralelo, ordena ejecución: 1 corre antes que 2, 2 antes que 3
  • glob — cada comando solo se ejecuta si hay archivos en stage que coincidan
  • {staged_files} — lefthook pasa solo los archivos en stage, no todo el proyecto
  • stage_fixed: true — si un formatter modifica archivos, lefthook los re-añade al stage automáticamente
  • "$1" en commit-msg — path del archivo de mensaje de commit que Git pasa al hook
  • post-merge / post-checkout — instalan deps automáticamente si cambia el lockfile al cambiar de rama o hacer merge

Configuraciones compartidas — wiedii-configs

Los hooks commit-msg, post-merge, post-checkout y post-rewrite están centralizados en wildcat/wiedii-configs. En lugar de copiar el YAML completo en cada repo, se consume vía remotes de lefthook:

# lefthook.yaml — repos JS/TS
remotes:
- git_url: git@gitlab.wiedii.co:wildcat/wiedii-configs.git
configs:
- lefthook/base.yaml # commit-msg con commitlint
- lefthook/js.yaml # post-merge, post-checkout, post-rewrite con bun install

Los hooks compartidos disponibles:

ArchivoHooksStack
lefthook/base.yamlcommit-msg (commitlint)Todos los stacks
lefthook/js.yamlpost-merge, post-checkout, post-rewrite (bun install)JS / TS
lefthook/python.yaml(próximamente)Python
lefthook/go.yaml(próximamente)Go

El lefthook.yaml local del repo solo necesita los hooks específicos del proyecto (pre-commit con linters) y el bloque remotes. Los hooks centralizados se descargan y cachean en .lefthook/ al correr lefthook install.

.lefthook/ debe estar en .gitignore — es un artefacto local generado, no código fuente.


Comandos útiles

# Registrar los hooks en .git/hooks/ (se hace una vez por clon)
lefthook install

# Ejecutar manualmente los hooks pre-commit (útil para verificar que todo pasa)
lefthook run pre-commit

# Ejecutar un hook específico
lefthook run commit-msg

# Ver qué comandos están configurados
lefthook list

# Saltar hooks puntualmente (⚠️ solo en casos justificados)
git commit --no-verify -m "chore: fix typo"
LEFTHOOK=0 git commit -m "chore: fix typo"

--no-verify debe usarse solo en situaciones excepcionales. Si los hooks fallan con frecuencia, el problema es la configuración del hook, no el hook en sí.


Integración con mise

Los hooks de Git se ejecutan en un contexto de shell aislado donde el entorno de mise puede no estar activado (commits desde IDE, GUIs de Git, SSH). El patrón en Wiedii combina dos mecanismos:

jobs — para preparar el entorno antes de ejecutar los comandos. Solo mise trust y mise install --yes: garantizan que las herramientas del mise.toml estén disponibles, sin modificar el entorno del hook.

mise exec -- comando — cada comando individual se ejecuta con los binarios de mise de forma aislada, sin contaminar el PATH del shell padre ni el contexto del hook.

commit-msg:
parallel: false
jobs:
- run: mise trust # acepta el mise.toml del repo sin prompts
- run: mise install --yes # instala herramientas si faltan
commands:
commitlint:
run: mise exec -- bun commitlint --config commitlint.config.js --edit "$1"

Por qué mise exec -- en lugar de eval "$(mise env)": eval "$(mise env)" exporta todas las variables de mise al shell actual, modificando el PATH de forma permanente dentro del hook. mise exec -- programa crea un entorno limpio solo para ese comando. Más preciso, sin efectos secundarios.


Troubleshooting rápido

Los hooks no se ejecutan al hacer commit:

lefthook install # verificar que los hooks están registrados
ls .git/hooks/ # debe existir pre-commit y commit-msg

mise exec: command not found en el hook:

# mise debe estar en el PATH del hook. Verificar en .zshrc o .zprofile:
which mise # debe retornar /opt/homebrew/bin/mise

Un comando falla solo en el IDE pero no en terminal:

# El IDE puede tener un PATH distinto. Usar siempre `mise exec --` en lefthook.yaml
# y verificar que mise está activado con shims:
mise activate zsh --shims # debe estar en .zprofile

Quiero omitir un hook puntualmente sin deshabilitar todos:

# lefthook.yaml — agregar `skip` a un comando concreto
pre-commit:
commands:
typecheck:
skip: true # deshabilitar solo este comando temporalmente
run: mise exec -- bun tsc --noEmit

Referencias