husky pre-commit script failed (code 127): causa CRLF

Haces git commit y Husky aborta el commit con un mensaje que parece un problema de PATH:

.husky/pre-commit: 6: : not found
husky - pre-commit script failed (code 127)
husky - command not found in PATH=node_modules/.bin:/usr/local/bin:/usr/bin:/bin

Las líneas que añade Husky pueden variar según la versión, pero el detalle importante es el mismo: el comando que "no se encuentra" tiene el nombre vacío (6: : not found). Pasa al usar Git en WSL2 (Ubuntu), Linux o macOS con hooks que se editaron o se descargaron en Windows. Este artículo explica por qué no es un problema de PATH, cómo confirmarlo y cómo evitar que vuelva a ocurrir.

Por qué pasa

Un archivo de texto termina cada línea con LF (\n, Linux y macOS) o con CRLF (\r\n, Windows). El intérprete sh solo reconoce \n como fin de línea, así que el \r sobrante se queda dentro de la línea. En una línea en blanco guardada con CRLF, ese carácter es lo único que hay: sh lo toma como un comando llamado \r, no lo encuentra y responde "not found" con estado de salida 127. El estándar POSIX define 127 como el estado de un comando que no se encuentra.

Por eso el mensaje tiene un nombre vacío entre los dos puntos: el carácter \r no se ve. Según el terminal, incluso puede verse desordenado, porque el retorno de carro mueve el cursor al inicio de la línea. El número (6) es la línea del hook donde sh tropezó con el comando vacío.

Husky ejecuta cada hook con sh -e (el script se detiene en el primer comando que falla) y solo informa del código de salida. En las versiones recientes, cuando ese código es 127 añade además el aviso command not found in PATH. Ese aviso es genérico y desvía el diagnóstico hacia el PATH, aunque la causa sea el formato del archivo.

El CRLF llega al hook por varias vías: un editor en Windows configurado para guardar con CRLF, una herramienta que genera el archivo, o core.autocrlf=true al clonar el repositorio en Windows y luego usarlo desde WSL. Si el comando faltante fuera real, el mensaje mostraría su nombre (por ejemplo pnpm: not found). El nombre vacío apunta al fin de línea.

Solución

  1. Confirma que el hook tiene CRLF. Desde la raíz del repositorio, en WSL o Linux:

    cat -A .husky/pre-commit

    Con CRLF, cada línea termina en ^M$; con LF solo aparece $:

    pnpm lint-staged^M$
    ^M$
    pnpm exec tsc --noEmit^M$

    La opción -A es de GNU coreutils. En macOS puedes usar file .husky/pre-commit (muestra "with CRLF line terminators") o git ls-files --eol .husky (el valor w/crlf indica CRLF en el directorio de trabajo).

  2. Convierte los hooks a LF. Hazlo con todos los hooks que tengas (pre-commit, pre-push, commit-msg, etc.):

    sed -i 's/\r$//' .husky/pre-commit .husky/pre-push

    Esa sintaxis de sed -i es la de GNU (Linux y WSL). En macOS funciona perl -pi -e 's/\r$//' .husky/pre-commit .husky/pre-push, y en cualquier sistema puedes usar dos2unix si lo tienes instalado. También puedes cambiar el fin de línea desde el editor: en VS Code, haz clic en el indicador CRLF de la barra de estado y elige LF.

  3. Fija LF con .gitattributes. Crea o edita el archivo en la raíz del repositorio:

    .husky/* text eol=lf
    *.sh text eol=lf

    Según la documentación de Git, eol=lf indica que el archivo se escribe con LF en el directorio de trabajo al hacer checkout, y el atributo eol activa text si no estaba definido, de modo que Git también normaliza a LF al guardar en el índice. Solo cuando eol no está definido Git recurre a core.autocrlf o core.eol.

  4. Renormaliza lo que ya estaba guardado con CRLF.

    git add --renormalize .
    git status
    git commit -m "chore: normalize hook line endings to LF"

    --renormalize vuelve a aplicar el filtro de normalización a todos los archivos rastreados, algo pensado justo para cuando cambias atributos de fin de línea. Actualiza el índice, no el directorio de trabajo: por eso el paso 2 va antes. Revisa git status antes de confirmar; solo deberían aparecer archivos que coincidan con esos patrones. Si el propio commit falla porque el hook aún tiene CRLF, usa git commit --no-verify solo para este commit de corrección.

Cómo verificar que quedó resuelto

cat -A .husky/pre-commit
git ls-files --eol .husky
sh -e .husky/pre-commit; echo $?

El primer comando no debe mostrar ^M. El segundo debe mostrar i/lf y w/lf con el atributo text eol=lf para cada hook. El tercero ejecuta el hook como lo hace Husky y debe terminar con 0 si las comprobaciones del hook pasan. Para cerrar, haz un commit y un push de prueba: ya no debe aparecer el código 127.

Variantes y errores relacionados

El mismo error en pre-push y otros hooks

La causa es idéntica en cualquier hook. Convierte todos los archivos de .husky/ que hayas creado, no solo el que falló primero.

Mensajes distintos según el intérprete

Con Bash, el mismo problema se ve como line 6: $'\r': command not found. Si el hook tiene un shebang y se ejecuta directamente, el sistema busca un intérprete llamado sh\r y el error es de este estilo: env: $'sh\r': No such file or directory. También termina con estado 127. En todos los casos la solución es la misma.

Cuando sí es un problema de PATH

Si el mensaje incluye el nombre de un comando real (pnpm: not found, node: not found), el fallo es de entorno. Es habitual con gestores de versiones de Node o con clientes gráficos de Git que no cargan tu configuración de shell. La documentación de Husky tiene una sección sobre gestores de versiones y PATH.

Otros scripts del repositorio

Los archivos .sh, los scripts de entrada de un Dockerfile y cualquier script que ejecute Linux fallan igual con CRLF. La segunda línea de .gitattributes (*.sh text eol=lf) los cubre.

Resumen

  • .husky/pre-commit: 6: : not found con el nombre vacío no es un problema de PATH: es un \r de CRLF que sh interpreta como un comando.
  • El código 127 significa "comando no encontrado" y el aviso de PATH que añade Husky es genérico.
  • Confirma con cat -A (busca ^M$) y corrige con sed -i 's/\r$//' o con el selector de fin de línea del editor.
  • Evita que vuelva con .husky/* text eol=lf y *.sh text eol=lf en .gitattributes, más git add --renormalize ..
  • Verifica con git ls-files --eol .husky y un commit y un push reales.

Referencias

Comentarios