Revisar el resultado, no la configuración: cómo funciona git-wrong-email
git-wrong-email es un script de shell que se niega a dejar salir un commit con el correo equivocado. Por qué el hook que más importa es el de pre-push, qué cuesta instalar un hook global sin apagar los hooks de cada repo, y qué casos todavía se le escapan.
En el post anterior conté por qué existe git-wrong-email: un cliente gráfico de git reescribió mi user.email global, y mis proyectos personales empezaron a firmar con el correo de un cliente sin que nada me avisara. La idea de la herramienta es una sola: no revisar la configuración, sino el resultado. El correo que git de verdad va a poner en el commit.
Este post es sobre cómo está hecho. Es un script de shell POSIX de unas 140 líneas, sin dependencias, y casi todas esas líneas existen por algún caso que encontré usándolo.
Leer lo que git va a escribir
La configuración de git tiene muchas capas: el ~/.gitconfig global, los includeIf, el .git/config del repo, las variables de entorno como GIT_AUTHOR_EMAIL, y lo que sea que el editor o el agente de código le pase al proceso. Intentar reconstruir cuál gana es justo el error que quería evitar.
Git ya resuelve esa pregunta, y la contesta con git var:
git var GIT_AUTHOR_IDENT # Sergio Infante <[email protected]> 1730000000 +0100
git var GIT_COMMITTER_IDENT
Es la identidad final, después de aplicar todas las capas. El hook saca el correo de ahí y lo compara con la regla de la carpeta. No le importa cómo llegó el correo a ese valor.
Revisa los dos, el autor y el committer, porque no siempre son el mismo. En un cherry-pick o un rebase, el autor es quien escribió el cambio y el committer eres tú. Si un repo lleva commits de otra persona, se puede permitir su correo como autor:
git config --add wrongEmail.allowedAuthor [email protected]
Solo como autor. El committer siempre soy yo, y para eso no hay excepción.
Las reglas
Las reglas viven en ~/.config/git-wrong-email/rules, una por línea:
~/Code/cliente/ [email protected]
~/Code/personal/ [email protected]
* [email protected]
La primera carpeta que contiene al repo gana, así que las reglas van de la más específica a la más general, y * va al final. Si un repo no cae en ninguna regla, se rechaza. Un repo sin regla es un repo en el que todavía no pensé.
La comparación agrega una barra al final de los dos lados, para que ~/Code/cliente/ no acepte también ~/Code/cliente-notas. Es el mismo detalle que hay que cuidar en gitdir: de los includeIf, y es fácil equivocarse.
Por qué el pre-push es el que importa
El chequeo en pre-commit es el que se ve: el commit no se crea y el mensaje sale en la terminal. Pero no es el que protege.
Un commit puede llegar al repo sin pasar por pre-commit. Con git commit --no-verify. Con una herramienta que hace commits sin correr los hooks. Con un rebase o un cherry-pick que reescribe commits. Lo que de verdad no se puede deshacer no es el commit local: es que llegue a un remoto público. Un force-push después no lo borra de los forks, de los clones ni de los caches.
Por eso el pre-push no confía en que el pre-commit haya corrido. Vuelve a leer los commits que se van a enviar:
git rev-list --format='%H %ae %ce' $local_sha --not --remotes
y revisa el autor y el committer de cada uno. Si te saltaste el primer chequeo con --no-verify, el segundo te detiene antes de que el commit salga de la máquina.
El push que bloqueé al día siguiente
La primera versión, la del 13 de octubre, revisaba en el pre-push el rango remote_sha..local_sha de cada rama: todo lo que estaba en mi rama y no en su versión remota.
Al día siguiente hice merge de main en una rama de trabajo que ya estaba en GitHub, y el push se bloqueó. El merge había traído commits de merge que GitHub crea al cerrar un PR, con committer [email protected]. Esos commits no estaban en mi rama remota, pero sí en main. Ya eran públicos y nadie podía corregirlos.
Un chequeo que bloquea por historia que no se puede arreglar solo te enseña a usar --no-verify, y después lo usas también cuando el chequeo tenía razón. El arreglo fue cambiar la pregunta. No “qué tiene mi rama que su remoto no tiene”, sino “qué tiene mi rama que ningún remoto tiene”:
git rev-list $local_sha --not $remote_sha --remotes
El --remotes excluye todo lo que ya está en cualquier rama remota conocida. Depende de que refs/remotes esté al día, y si no lo está el chequeo se vuelve más estricto, nunca más permisivo. Es el error en la dirección que prefiero.
Lo que cuesta un hook global
Para cubrir toda la máquina sin tocar ningún repo, el instalador apunta core.hooksPath global a ~/.config/git-wrong-email/hooks. Así cubre los repos que ya tengo, los que clone mañana y los del cliente, donde no puedo agregar nada.
El problema es que core.hooksPath no se suma a los hooks de cada repo: los reemplaza. Cuando está definido, git ignora .git/hooks por completo. Un repo que usa un pre-commit local para correr el linter dejaría de correrlo sin avisar. Por eso casi nadie hace chequeos globales con hooks.
La solución tiene dos partes.
Instalarse con todos los nombres de hook. No solo los tres que tienen chequeos. El instalador copia el mismo script con los 18 nombres de hooks del lado del cliente: commit-msg, post-checkout, post-merge, prepare-commit-msg y los demás. Un nombre que no instalo es un hook local que deja de correr.
Encadenar al hook del repo. Después de sus chequeos, el script busca el hook del mismo nombre en el repo y lo ejecuta:
next="$(git rev-parse --git-common-dir)/hooks/$hook"
[ -x "$next" ] && exec "$next" "$@"
Con un detalle en el pre-push: git le pasa la lista de refs por la entrada estándar, y el script ya la leyó para revisarla. Así que la guarda y se la vuelve a pasar al hook del repo.
Si core.hooksPath ya apuntaba a otro lado cuando se instaló, por ejemplo a la carpeta de lefthook o de un repo de dotfiles, el instalador no lo pisa en silencio. Lo guarda como wrongEmail.chainTo y encadena a esa carpeta en vez de a .git/hooks. Al desinstalar, la restaura.
Lo que encontré después de publicarlo
Separarlo de mis dotfiles y empaquetarlo para Homebrew trajo su propia lista de casos:
- Homebrew corre los comandos a través de un symlink. El binario en
$(brew --prefix)/bines un link hacia el Cellar, y el instalador buscaba el hook al lado del link, no al lado del archivo real. Fallaba con “cannot find git-wrong-email-hook”. Ahora sigue el link. git initdispara hooks antes de que exista el repo. El hookreference-transactioncorre tres veces durante ungit init, cuando todavía no hay un directorio.gital que preguntar, y cada vez imprimía unfatal: not a git repository. Ahora, sin repo, sale sin decir nada.- No encadenarse a sí mismo. Mi gestor de dotfiles escribe el
core.hooksPathen el.gitconfigantes de que el instalador cree la carpeta. El instalador veía una carpeta ajena, la guardaba comochainTo, y al desinstalar “restauraba”core.hooksPatha la carpeta que acababa de vaciar. Ahora reconoce su propia ruta aunque esté vacía.
Lo que todavía se le escapa
- Un
core.hooksPathdentro del repo gana. husky, por ejemplo, define uno desde el scriptpreparede npm, y en ese repo git nunca corre mis hooks. No puedo evitarlo desde fuera, perogit wrong-email status, corrido dentro del repo, ahora avisa que el chequeo no corre ahí y cómo encontrar dónde se definió. - Solo ve el correo, no la cuenta. Si hago push a GitHub autenticado con la cuenta equivocada, el correo del commit puede estar bien y el problema sigue ahí.
- Las reglas no se comparan con los
includeIf. Si los dos archivos dicen cosas distintas, me entero cuando el chequeo rechaza un commit. Undoctorque los compare es lo siguiente en la lista. brew upgradeno actualiza los hooks. El instalador copia el script, así que después de actualizar hay que volver a corrergit wrong-email install.
Probarlo
brew install neosergio/tap/git-wrong-email
git wrong-email install
$EDITOR ~/.config/git-wrong-email/rules
git wrong-email status
Los tests crean un $HOME temporal con su propia configuración global y corren git de verdad, así que no tocan tu ~/.gitconfig. El código está en GitHub con licencia MIT.
Comentarios