El módulo anterior terminó con un diagnóstico incómodo: el equipo de gestor-tareas tiene ya un proceso impecable —forks, pull requests, revisiones, ramas protegidas, integración continua— y aun así puede acabar con un historial que nadie sea capaz de leer. Empezamos a corregirlo por el punto más cotidiano y más descuidado de todos: el mensaje de confirmación.

Esta lección salda por fin dos deudas del curso. En la lección 02-04 aprendimos a confirmar cambios con git commit -m y dijimos que las convenciones de mensajes llegarían aquí. En la lección 06-01 escribimos un hook commit-msg que exigía un ticket con formato GT-NNN, pero validamos una convención que aún no habíamos explicado. Y en la 07-06 validamos esa misma convención en el CI. Es el momento de explicar de dónde sale todo eso.

Conviene entender la magnitud del asunto antes de empezar. Un desarrollador escribe entre cinco y veinte mensajes de confirmación al día. Son el único texto en prosa que produce el proyecto de forma sistemática, el único que sobrevive a los refactores, y el único que estará ahí cuando el autor original ya no esté en la empresa. El código dice qué hace el programa; el historial es el único sitio donde puede quedar escrito por qué lo hace así.

Contenido

  1. Para quién se escribe un mensaje de confirmación
  2. El porqué importa más que el qué
  3. Anatomía de un buen mensaje
  4. La línea de asunto: las siete reglas
  5. El cuerpo: motivación y contexto
  6. El pie: referencias, tickets y coautoría
  7. Mensajes malos y su versión buena
  8. Conventional Commits
  9. BREAKING CHANGE y la versión derivada
  10. Plantillas con commit.template
  11. Escribir en el editor, no con -m
  12. Hacer cumplir la convención: hook y CI
  13. El idioma del mensaje

  1. Para quién se escribe un mensaje de confirmación

Hay una respuesta intuitiva y equivocada: "para mí, para acordarme de lo que hice". Es equivocada porque tú, dentro de una semana, ya no necesitarás el mensaje: tendrás el contexto fresco. El mensaje existe para tres lectores que no lo tienen.

Lector 1: tu yo de dentro de dos años. No recuerda nada del contexto. No recuerda la reunión donde se decidió aquello, ni el cliente que se quejó, ni la limitación del navegador que obligó a hacer una cosa rara. El commit es lo único que queda.

Lector 2: quien ejecute git blame sobre una línea rara. Es el caso más frecuente y el más valioso. Alguien encuentra en app.js una condición absurda:

// Nunca se filtra por fecha si el navegador es Safari < 15
if (!esSafariAntiguo()) { aplicarFiltroFecha(); }

Su primer impulso será borrarla. En la lección 06-03 vimos que git blame responde qué commit introdujo esa línea. Si ese commit dice arreglos, el lector borrará la condición y reintroducirá el fallo. Si dice Evitar el filtro por fecha en Safari 14: su Intl.DateTimeFormat devuelve la zona horaria en un formato que rompe el parseo, no la borrará. La calidad del mensaje decide si el fallo vuelve.

Lector 3: quien revise tu pull request. En la lección 07-02 vimos que revisar bien exige entender la intención antes que el diff. Una PR con commits atómicos y bien descritos se revisa commit a commit y en la mitad de tiempo. Una PR con ocho commits llamados wip, wip2 y ahora sí obliga al revisor a leer un diff gigante sin ninguna guía.

Hay un cuarto lector que además es una máquina: las herramientas que generan changelogs, deducen versiones y filtran el historial. De eso trata el apartado 8.

flowchart LR
    C["Commit<br/>+ mensaje"] --> A["Tu yo futuro<br/>(2 años)"]
    C --> B["git blame<br/>sobre una línea rara"]
    C --> D["Revisor de la PR"]
    C --> E["Herramientas:<br/>changelog, SemVer"]

  1. El porqué importa más que el qué

Esta es la idea central de toda la lección, y la que más cuesta interiorizar.

El diff ya dice el qué. Está ahí, completo, exacto y para siempre. git show te lo enseña con precisión de carácter. Un mensaje que dice Cambia el color del botón a azul cuando el diff dice - color: #c00; / + color: #06c; es redundante: no aporta ni un bit de información nueva.

Lo que el diff no puede decir nunca:

El diff sabe El diff no sabe
Qué líneas cambiaron Por qué había que cambiarlas
Qué ficheros se tocaron Qué alternativas se descartaron y por qué
El valor nuevo de una constante De dónde sale ese valor
Que se añadió una condición Qué fallo concreto previene
Que se borró una función Si estaba muerta o si se movió
Que la versión de una dependencia subió Si fue por una vulnerabilidad o por una funcionalidad

Compara estos dos mensajes sobre exactamente el mismo diff de gestor-tareas:

Sube el tiempo de espera a 30 segundos
Sube el tiempo de espera de la sincronización a 30 segundos

El valor anterior de 5 segundos venía del prototipo, cuando la
sincronización solo enviaba las tareas modificadas. Desde GT-118
enviamos también los adjuntos, y en conexiones móviles lentas el
primer envío de una tarea con imagen supera holgadamente ese
límite: el usuario ve un error y reintenta, duplicando la tarea.

30 segundos cubre el percentil 99 de los tiempos medidos en
preproducción durante una semana. No lo subimos más porque a
partir de ahí el usuario asume que la aplicación se ha colgado.

Refs: GT-134

El primero es información que ya estaba en el diff. El segundo contiene cuatro hechos que no están en ninguna otra parte del repositorio: de dónde venía el valor viejo, qué fallo concreto provocaba, cómo se eligió el nuevo y por qué no es mayor. Dentro de dos años, cuando alguien proponga bajarlo otra vez "porque 30 segundos es una barbaridad", ese mensaje es la respuesta.

La regla: si tu mensaje se puede deducir leyendo el diff, todavía no has escrito el mensaje.

  1. Anatomía de un buen mensaje

Git no impone ningún formato, pero sí trata el texto de forma estructurada, y casi todas las herramientas del ecosistema asumen la misma convención. La estructura canónica tiene tres partes:

Resume el cambio en 50 caracteres o menos

Cuerpo explicativo, envuelto a 72 columnas. Explica la motivación
del cambio y el contexto necesario para entenderlo. El qué está en
el diff; aquí va el porqué.

Puede tener varios párrafos, separados por líneas en blanco.

- Y listas, si aclaran
- Por ejemplo, para enumerar los efectos

Refs: GT-134
Co-authored-by: Bruno Salas <[email protected]>
flowchart TD
    A["Línea de asunto<br/>≤ 50 caracteres, imperativo, sin punto"] --> B["Línea en blanco<br/>(obligatoria)"]
    B --> C["Cuerpo<br/>72 columnas · motivación y contexto"]
    C --> D["Línea en blanco"]
    D --> E["Pie<br/>Refs, Co-authored-by, BREAKING CHANGE"]

La línea en blanco entre el asunto y el cuerpo no es estética: es sintaxis. Git usa la primera línea como asunto en git log --oneline, en %s de --pretty, en el asunto de los correos de git format-patch y en el título por defecto de una pull request. Si te la saltas, el mensaje entero se convierte en asunto:

# MAL: sin línea en blanco
git commit -m "Corrige el filtro
El filtro no distinguía mayúsculas."

git log --oneline -1
a1b2c3d Corrige el filtro El filtro no distinguía mayúsculas.

Todo el texto pegado, y el --oneline inservible.

  1. La línea de asunto: las siete reglas

Regla 1: 50 caracteres o menos

No es una cifra mágica arbitraria: es el límite práctico para que git log --oneline, la lista de commits de una plataforma y el git shortlog quepan en una línea sin truncarse. Las plataformas de alojamiento cortan visualmente alrededor de los 72 caracteres, y GitHub avisa a partir de 50.

Si no cabe en 50 caracteres, casi siempre es una de estas dos cosas: el commit hace demasiadas cosas (lo veremos en la lección 08-02) o estás metiendo en el asunto lo que va en el cuerpo.

Regla 2: en modo imperativo

Escribe como si dieras una orden al repositorio. La prueba infalible es completar esta frase:

"Si se aplica, este commit _______"

Mal Bien
~~Añadido~~ el filtro por etiqueta Añade el filtro por etiqueta
~~Añadiendo~~ el filtro por etiqueta Añade el filtro por etiqueta
~~He arreglado~~ el guardado Arregla el guardado en local
~~Arreglos varios~~ Corrige el desbordamiento en móvil

No es capricho: el propio Git escribe así. Merge branch 'x', Revert "...", Initial commit. Si tus mensajes usan otro tiempo verbal, el historial mezcla dos voces.

Regla 3: sin punto final

Es un título, no una frase. Y en 50 caracteres, un carácter cuenta.

Regla 4: mayúscula inicial

Salvo que uses Conventional Commits (apartado 8), donde la convención habitual es minúscula tras los dos puntos. Elige una y sé coherente.

Regla 5: sé específico

Corrige el error no dice nada. Corrige el borrado de tareas con adjunto sí.

Regla 6: no repitas el nombre del fichero

Cambia app.js es inútil: git log --stat ya lo dice. El ámbito, si hace falta, va con la sintaxis de Conventional Commits.

Regla 7: si necesitas "y", probablemente son dos commits

Añade el filtro por etiqueta y corrige el margen del pie describe dos cambios independientes. La lección 08-02 desarrolla esta idea a fondo.

  1. El cuerpo: motivación y contexto

El cuerpo es opcional para un cambio trivial y obligatorio para cualquier cambio no obvio. Se envuelve manualmente a 72 columnas por una razón muy concreta: git log indenta el cuerpo con cuatro espacios, así que 72 + 4 = 76, y cabe en un terminal de 80 columnas sin que Git reenvuelva el texto (no lo hace: Git nunca reformatea tu mensaje).

Un cuerpo útil responde a estas preguntas, no necesariamente todas:

  1. ¿Cuál era el problema o la necesidad? El estado anterior y por qué no valía.
  2. ¿Por qué esta solución? Y sobre todo, qué alternativas se descartaron.
  3. ¿Qué efectos colaterales tiene? Rendimiento, compatibilidad, migraciones.
  4. ¿Qué NO hace este cambio? Delimitar es tan útil como describir.
  5. ¿Qué hay que saber para revisarlo? Un enlace, una medición, un comando de reproducción.

Ejemplo completo, sobre un cambio real de gestor-tareas que hizo Carla:

Guarda las tareas en IndexedDB en lugar de localStorage

localStorage tiene un límite práctico de 5 MB por origen y es
síncrono: cada guardado bloquea el hilo principal. Con listas de
más de 2.000 tareas, Carla midió bloqueos de 180 ms en cada
pulsación de tecla del campo de búsqueda, porque el autoguardado
se dispara en cada cambio.

IndexedDB es asíncrona y no tiene ese límite. La migración es
transparente: al arrancar, si detectamos datos en localStorage y
la base de datos está vacía, los importamos y limpiamos la clave
antigua. Ese código de migración se puede borrar cuando pasen dos
versiones (GT-141).

Se descartó usar la API File System Access porque solo está en
navegadores basados en Chromium y Bruno necesita Safari.

Este cambio NO toca la sincronización con el servidor, que sigue
leyendo del mismo módulo de acceso a datos.

Refs: GT-137

Fíjate en lo que aporta y que no está en el diff: la medición concreta, la alternativa descartada con su motivo, la fecha de caducidad del código de migración y el límite explícito del cambio.

  1. El pie: referencias, tickets y coautoría

El pie (trailer en la terminología de Git) es un bloque de líneas Clave: valor al final del mensaje, separado por una línea en blanco. Git lo entiende de forma nativa a través de git interpret-trailers, y las plataformas lo interpretan también.

Trailer Para qué sirve
Refs: GT-134 Relaciona con un ticket sin cerrarlo
Closes: GT-134 Cierra el ticket al fusionar (según la plataforma)
Fixes: GT-134 Igual, para correcciones de fallo
Co-authored-by: Nombre <correo> Atribuye el commit a más de una persona
Signed-off-by: Nombre <correo> Certifica el origen (DCO); lo añade git commit -s
Reviewed-by: Nombre <correo> Deja constancia de la revisión en el propio commit
BREAKING CHANGE: ... Marca una ruptura de compatibilidad (apartado 9)

La coautoría merece una mención especial, porque resuelve un problema real del equipo. Cuando Ana y Bruno programan en pareja, el commit lo firma solo quien lo escribe, y git blame (lección 06-03) atribuye todo a esa persona. Con el trailer, las plataformas reconocen a los dos:

git commit -m "Refactoriza el módulo de sincronización" -m "$(cat <<'EOF'
Extrae la lógica de reintentos a un módulo propio para poder
probarla sin red.

Co-authored-by: Bruno Salas <[email protected]>
EOF
)"

Detalle importante: la línea Co-authored-by debe ir al final, precedida de una línea en blanco, y el correo debe ser el que la persona tiene registrada en la plataforma. Si no, el nombre aparece pero no se enlaza a la cuenta.

Puedes automatizarlo con git interpret-trailers:

# Añade un trailer a un mensaje ya escrito
git interpret-trailers --in-place --trailer "Refs: GT-134" mensaje.txt

# Y consultarlos después
git log -1 --pretty="%(trailers:key=Refs,valueonly)"

  1. Mensajes malos y su versión buena

Esta tabla recoge mensajes reales del historial temprano de gestor-tareas, cuando el equipo todavía no tenía convención, junto a lo que deberían haber sido.

Mensaje real Por qué es malo Versión buena
cambios No dice absolutamente nada Añade el filtro de tareas por etiqueta
fix ¿Qué se arregló? ¿Dónde? Corrige el borrado de tareas con adjunto
actualiza app.js El nombre del fichero ya está en el diff Extrae el renderizado de la lista a una función
wip No debería estar en main (ver 08-02) Aplastar en el commit definitivo con rebase -i
asdfasdf Prisa Añade el atajo Ctrl+K para el buscador
Arreglado el bug que comentó Carla ayer Contexto efímero; "ayer" no significa nada dentro de un año Corrige el orden de las tareas vencidas + cuerpo con el detalle
Cambios pedidos en la revisión Cierto hoy, incomprensible mañana Valida la longitud del título antes de guardar
Ahora sí Depende del commit anterior para tener sentido Aplastar con --fixup (lección 05-02)
Añade el filtro y corrige el CSS del pie y sube la dependencia Tres cambios en un commit Tres commits
Merge branch 'main' of git.ejemplo.es:... Fusión de ruido por hacer pull sin --rebase Evitarlo con pull.rebase true (lección 04-04)
Actualiza dependencias ¿Cuáles? ¿Por qué? Sube marked a 12.0.1 por CVE en el saneado de HTML
. El clásico universal Cualquier cosa

Fíjate en el patrón: los mensajes malos casi siempre son cortos por prisa o dependientes de un contexto que se evapora ("ayer", "lo que comentó Carla", "ahora sí").

  1. Conventional Commits

Hasta aquí hemos hablado de prosa para humanos. Conventional Commits es una convención que además hace el asunto legible por máquinas, sin perder legibilidad humana. Es la que usa el hook commit-msg que escribimos en la lección 06-01.

El formato:

<tipo>(<ámbito opcional>): <descripción>

[cuerpo opcional]

[pie(s) opcional(es)]

Ejemplos sobre gestor-tareas:

feat(filtros): añade el filtro por etiqueta
fix(sync): evita duplicar tareas al reintentar el envío
docs(readme): documenta las variables de entorno necesarias
refactor(app): extrae el renderizado de la lista a una función
perf(lista): virtualiza el listado por encima de 500 tareas
test(sync): cubre el reintento con red intermitente
build(deps): sube marked a 12.0.1
ci(actions): cachea node_modules por hash del bloqueo
style(css): ordena las propiedades de estilos.css
chore(git): añade .env a .gitignore

Tabla de tipos

Tipo Qué significa ¿Afecta a la versión SemVer?
feat Funcionalidad nueva para el usuario MINOR
fix Corrección de un fallo PATCH
docs Solo documentación No
style Formato, espacios, comas; sin cambio de comportamiento No
refactor Reestructuración sin cambiar comportamiento No
perf Mejora de rendimiento PATCH (a veces MINOR)
test Añade o corrige pruebas No
build Sistema de compilación o dependencias No
ci Configuración de integración continua No
chore Tareas de mantenimiento sin efecto en el código de producción No
revert Revierte un commit anterior (lección 05-06) Depende

El ámbito entre paréntesis es libre y lo define cada proyecto. En gestor-tareas el equipo acordó: filtros, sync, lista, ui, css, readme, deps, ci. Un ámbito estable convierte git log --oneline | grep '(sync)' en una consulta útil.

Qué se gana

  1. Filtrar el historial es trivial. git log --oneline --grep '^feat' te da todas las funcionalidades nuevas.
  2. Changelog automático. Herramientas como git-cliff, standard-version o semantic-release agrupan los commits por tipo y generan CHANGELOG.md sin intervención humana.
  3. Versión SemVer derivada. Esto enlaza directamente con la lección 05-05: allí acordamos MAJOR.MINOR.PATCH y etiquetas anotadas, pero decidíamos el número a mano. Con Conventional Commits, el número se deduce del historial.
  4. Disciplina de granularidad. Si no sabes qué tipo poner, casi siempre es porque el commit hace más de una cosa.

Un ejemplo de derivación

Supón que desde v1.4.0 el historial de gestor-tareas contiene:

git log --oneline v1.4.0..HEAD
9f3a1c2 docs(readme): corrige el enlace de instalación
7b2e4d1 fix(sync): evita duplicar tareas al reintentar el envío
4c8a9f0 feat(filtros): añade el filtro por etiqueta
2d1b3e7 test(sync): cubre el reintento con red intermitente

Hay un feat y ningún BREAKING CHANGE, así que la siguiente versión es v1.5.0. Si solo hubiera habido fix, sería v1.4.1. Si no hubiera habido ni feat ni fix, no habría versión que publicar.

  1. BREAKING CHANGE y la versión derivada

Una ruptura de compatibilidad es un cambio que obliga a quien consume tu código a hacer algo. En SemVer sube el número MAJOR, y es el único caso en que la deducción automática no puede fallar sin consecuencias graves.

Se marca de dos formas equivalentes:

Forma 1 — con ! tras el tipo o el ámbito:

feat(api)!: elimina el parámetro `orden` de listarTareas()

Forma 2 — con un trailer en el pie (permite explicar la migración):

feat(api): unifica la ordenación en un único parámetro

BREAKING CHANGE: `listarTareas()` ya no acepta el parámetro
`orden`. Usa `criterio`, que admite los mismos valores más
`vencimiento`. Sustituye `listarTareas({orden: 'alfa'})` por
`listarTareas({criterio: 'alfa'})`.

Refs: GT-152

La segunda es netamente mejor: el mensaje incluye la guía de migración, y las herramientas de changelog la copian tal cual a la sección de rupturas. Usa las dos a la vez si quieres que el ! sea visible en --oneline.

Tipos presentes desde la última etiqueta Versión siguiente
Solo docs, test, chore, ci, style Ninguna publicación
Al menos un fix, ningún feat PATCH: 1.4.01.4.1
Al menos un feat, sin rupturas MINOR: 1.4.01.5.0
Al menos un BREAKING CHANGE o ! MAJOR: 1.4.02.0.0

Cuidado con el aplastado. Si integras las pull requests con squash merge (lo veremos a fondo en la lección 08-02), el mensaje que cuenta para la deducción es el del commit aplastado, no los de la rama. Un BREAKING CHANGE escondido en el tercer commit de una rama desaparece si el título del squash es feat: mejoras varias. Es un argumento fuerte para revisar el título del squash antes de fusionar.

  1. Plantillas con commit.template

La forma más barata de que todo un equipo escriba mensajes mejores es poner el recordatorio delante de sus ojos en el momento exacto. Eso hace commit.template: un fichero cuyo contenido precarga el editor cada vez que confirmas.

Crea el fichero en el repositorio, para que Ana, Bruno y Carla usen el mismo:

# En la raíz de gestor-tareas

Contenido de .gitmensaje:

# <tipo>(<ámbito>): <descripción en imperativo, ≤50 caracteres>
#
# Tipos: feat fix docs style refactor perf test build ci chore revert
# Ámbitos: filtros sync lista ui css readme deps ci
#
# --- Cuerpo (envuelve a 72 columnas) -----------------------------|
# ¿Por qué era necesario este cambio? ¿Qué problema resuelve?
# ¿Qué alternativas se descartaron y por qué?
# ¿Qué efectos colaterales tiene?
#
# --- Pie ---------------------------------------------------------
# Refs: GT-NNN
# Co-authored-by: Nombre <[email protected]>
# BREAKING CHANGE: describe la migración necesaria

Y se activa:

# Solo para este repositorio (recomendado: la plantilla es del proyecto)
git config --local commit.template .gitmensaje

# O para todos tus repositorios
git config --global commit.template ~/.gitmensaje

Tres detalles que importan:

  • Las líneas que empiezan por # se descartan al guardar, así que las instrucciones no llegan al historial. El carácter de comentario se puede cambiar con core.commentChar si necesitas un # literal (por ejemplo, para escribir #123).
  • commit.template no se versiona solo. El fichero .gitmensaje sí está en el repositorio, pero la configuración que lo activa vive en .git/config, que como vimos en la lección 01-05 es local. Documenta el git config --local commit.template .gitmensaje en el README.md, o añádelo al script de arranque del proyecto.
  • La plantilla no se aplica con -m. Solo cuando Git abre el editor. Lo cual nos lleva al apartado siguiente.

  1. Escribir en el editor, no con -m

git commit -m "..." es cómodo y por eso es el hábito por defecto de todo el mundo. También es la causa estructural de los mensajes malos, por tres motivos:

  1. La comilla del terminal te presiona a ser breve. Escribir un párrafo entre comillas en la shell es incómodo, así que no lo escribes.
  2. No ves el contexto. El editor te muestra, comentada, la lista de ficheros modificados. Muchas veces ahí descubres que has preparado un fichero que no querías.
  3. No hay revisión. Con -m pulsas Intro y ya está. En el editor lees lo que has escrito antes de guardar.

Compara:

# El hábito rápido
git commit -m "arregla el filtro"

# El hábito bueno: abre el editor con la plantilla y el contexto
git commit

Al ejecutar git commit a secas, el editor (core.editor, lección 01-06) se abre con la plantilla y con esto debajo:

# Por favor, escribe el mensaje de confirmación de tus cambios.
# Las líneas que comienzan con '#' serán ignoradas.
#
# En la rama GT-134-timeout-sync
# Tu rama está actualizada con 'origin/GT-134-timeout-sync'.
#
# Cambios a ser confirmados:
#	modificado:     app.js
#	modificado:     README.md

Y con -v ves además el diff completo dentro del editor, que es la mejor ayuda posible para escribir el porqué:

git commit -v

Actívalo siempre:

git config --global commit.verbose true

Cuándo sí usar -m: para commits verdaderamente triviales (docs: corrige una errata), en scripts, y para los --fixup de la lección 05-02, que van a desaparecer en el rebase.

Si el mensaje es largo y prefieres no depender del editor, hay una alternativa limpia:

# Varios -m se convierten en párrafos separados por línea en blanco
git commit -m "fix(sync): evita duplicar tareas al reintentar" \
           -m "El reintento no comprobaba si el envío anterior había llegado. Con red intermitente eso creaba duplicados." \
           -m "Refs: GT-134"

  1. Hacer cumplir la convención: hook y CI

Una convención que no se comprueba se erosiona en tres semanas. En la lección 06-01 escribimos un hook commit-msg; ahora que la convención está explicada, aquí está la versión completa y comentada.

#!/usr/bin/env bash
# .githooks/commit-msg — valida el formato del mensaje
# Git pasa como $1 la ruta del fichero temporal con el mensaje.

mensaje_completo=$(cat "$1")
# Primera línea que no sea comentario ni esté vacía: el asunto.
asunto=$(grep -v '^#' "$1" | grep -v '^[[:space:]]*$' | head -n 1)

# Las fusiones y las reversiones las genera Git: no las validamos.
if echo "$asunto" | grep -qE '^(Merge|Revert) '; then
  exit 0
fi

# Formato: tipo(ámbito opcional)!: descripción
patron='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-z0-9-]+\))?!?: .{1,}$'

if ! echo "$asunto" | grep -qE "$patron"; then
  echo "ERROR: el asunto no sigue Conventional Commits." >&2
  echo "  Recibido: $asunto" >&2
  echo "  Formato:  tipo(ámbito): descripción" >&2
  echo "  Tipos:    feat fix docs style refactor perf test build ci chore revert" >&2
  exit 1
fi

# Longitud del asunto
if [ ${#asunto} -gt 72 ]; then
  echo "ERROR: el asunto tiene ${#asunto} caracteres (máximo 72, ideal 50)." >&2
  exit 1
fi

# Sin punto final
case "$asunto" in
  *.) echo "ERROR: el asunto no debe terminar en punto." >&2; exit 1 ;;
esac

# Referencia al ticket obligatoria en algún sitio del mensaje
if ! echo "$mensaje_completo" | grep -qE 'GT-[0-9]{3,}'; then
  echo "ERROR: falta la referencia al ticket (GT-NNN)." >&2
  echo "  Añade una línea 'Refs: GT-134' al final del mensaje." >&2
  exit 1
fi

exit 0

Recuerda de la lección 06-01 los tres requisitos para que funcione:

chmod +x .githooks/commit-msg
git config --local core.hooksPath .githooks

Y recuerda también la advertencia, que la lección 07-06 convirtió en principio: un hook de cliente no es un control. git commit --no-verify lo salta. Por eso la misma comprobación tiene que estar en el CI, donde nadie manda:

# .github/workflows/ci.yml (fragmento)
  mensajes:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0          # necesitamos el historial completo
      - name: Validar los mensajes de la PR
        run: |
          # Solo los commits que aporta esta rama, no los de main
          BASE="${{ github.event.pull_request.base.sha }}"
          fallos=0
          while read -r sha; do
            asunto=$(git log -1 --pretty=%s "$sha")
            if ! echo "$asunto" | grep -qE '^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-z0-9-]+\))?!?: .+'; then
              echo "::error::$sha — asunto inválido: $asunto"
              fallos=1
            fi
          done < <(git rev-list "$BASE..HEAD" --no-merges)
          exit $fallos

Un matiz importante que se olvida siempre: si tu política de integración es squash merge, validar los commits individuales de la rama tiene poco sentido —van a desaparecer— y lo que hay que validar es el título de la pull request, que será el asunto del commit aplastado. Cuál de las dos políticas conviene es exactamente la pregunta que resuelve la lección siguiente.

  1. El idioma del mensaje

Es una discusión que aparece en todos los equipos y que tiene una única respuesta correcta: elige uno y respétalo.

Opción A favor En contra
Inglés Es el idioma del ecosistema; las palabras clave de Conventional Commits ya lo son; facilita contratar y colaborar fuera Si el equipo no lo domina, los mensajes se vuelven telegráficos y pierden el porqué
Español Mensajes más ricos y precisos si es la lengua de trabajo; menos fricción para escribir cuerpos largos Choca visualmente con feat/fix; complica abrir el proyecto a colaboradores externos

Lo verdaderamente malo no es elegir mal: es no elegir. Un historial donde se alternan feat: add tag filter y feat: añade el filtro por etiqueta es más difícil de buscar (--grep deja de funcionar), más difícil de leer y transmite desidia.

En gestor-tareas el equipo acordó, y lo escribió en el README.md: tipo y ámbito en inglés (porque son palabras clave de la convención y las entienden las herramientas), descripción, cuerpo y trailers en español (porque es la lengua de trabajo de los cuatro y permite explicar el porqué sin empobrecerlo). Es un compromiso muy común y funciona bien.

feat(filtros): añade el filtro por etiqueta

Las listas de más de 200 tareas eran inmanejables sin filtrado...

Refs: GT-134

Diego, el colaborador externo, se encontró la decisión escrita en el README.md de su fork antes de abrir su primera pull request. Ese es el sitio donde va: en el repositorio, no en la cabeza de nadie.

Errores Comunes y Consejos

Error 1: describir el qué en lugar del porqué. Es el error dominante. Cambia el valor a 30 no aporta nada; El valor de 5 s venía del prototipo y ya no cubre el envío de adjuntos lo aporta todo. Prueba de fuego: si el mensaje se deduce del diff, todavía no has escrito el mensaje.

Error 2: olvidar la línea en blanco tras el asunto. Rompe --oneline, los títulos de PR y el %s de --pretty. Es sintaxis, no estilo.

Error 3: contexto efímero. "Como hablamos ayer", "lo que pidió Carla", "según el correo de esta mañana". Dentro de seis meses no significan nada. Escribe el contenido, no el puntero.

Error 4: enlazar a un sistema externo en lugar de resumir. Ver GT-134 te deja vendido el día que se migre el gestor de tickets o alguien lo consulte sin acceso. Referencia el ticket además de resumir, nunca en lugar de.

Error 5: mensajes de proceso. Cambios pedidos en la revisión, Ahora sí, Otro intento. Describen tu jornada, no el código. Aplástalos con --fixup y rebase -i --autosquash (lección 05-02) antes de publicar.

Error 6: creer que se puede arreglar después. El mensaje de un commit publicado solo se cambia reescribiendo el historial, y eso choca con la regla de oro de la lección 05-01. Un mensaje malo publicado es permanente. (Si aún no lo has enviado, git commit --amend lo arregla; la lección 09-02 desarrolla los casos de deshacer.)

Error 7: aplicar Conventional Commits a medias. Media docena de commits con feat: y el resto con prosa libre produce lo peor de ambos mundos: la disciplina de la convención sin ninguno de sus beneficios automáticos, porque el changelog sale incompleto.

Consejo 1: escribe el mensaje antes que el código. Suena raro y funciona. Si no eres capaz de resumir en una línea lo que vas a hacer, el cambio todavía no está delimitado.

Consejo 2: activa commit.verbose hoy mismo. git config --global commit.verbose true pone el diff dentro del editor. Es la mejora de calidad más barata de todo el módulo.

Consejo 3: lee tu propio historial una vez al mes. git log --oneline -30. Si no entiendes tus commits de hace tres semanas, tienes un problema que crecerá.

Consejo 4: usa git shortlog -sn --no-merges y git log --oneline --grep. Si tus consultas al historial no devuelven nada útil, es señal de que los mensajes no lo son.

Consejo 5: en las revisiones, revisa también los mensajes. Es legítimo pedir en una PR "reescribe el mensaje del segundo commit". Es la única forma de que la convención sobreviva.

Consejo 6: la plantilla en el repositorio, no en tu $HOME. Así Diego la tiene al clonar el fork.

Ejercicios

Ejercicio 1: reescribir cinco mensajes malos

Para cada uno de estos mensajes reales del historial temprano de gestor-tareas, escribe la versión buena: asunto en imperativo de ≤50 caracteres y, cuando el caso lo pida, un cuerpo. Inventa el contexto verosímil que falte.

  1. fix css
  2. actualiza app.js
  3. Añadido el buscador y arreglado el margen del pie de página
  4. Ahora sí funciona
  5. Sube la dependencia

Ejercicio 2: montar la infraestructura completa

En un repositorio de pruebas:

  1. Crea un fichero de plantilla .gitmensaje con la estructura de Conventional Commits y actívalo con commit.template a nivel local.
  2. Activa commit.verbose.
  3. Instala el hook commit-msg del apartado 12 en .githooks/ y configura core.hooksPath.
  4. Comprueba que rechaza arreglos varios, que rechaza feat: añade el filtro (falta el ticket) y que acepta feat(filtros): añade el filtro por etiqueta con Refs: GT-134 en el cuerpo.
  5. Demuestra que --no-verify lo esquiva y explica qué barrera lo detendría.

Ejercicio 3: deducir la versión

Dado este historial desde la etiqueta v2.3.1 de gestor-tareas:

e5f6a7b chore(deps): sube las dependencias de desarrollo
c4d5e6f feat(sync): permite sincronizar en segundo plano
b3c4d5e fix(lista): corrige el orden de las tareas vencidas
a2b3c4d docs(readme): documenta el nuevo modo sin conexión
9182b3c feat(api)!: elimina el parámetro `orden` de listarTareas()
  1. ¿Qué versión SemVer corresponde publicar y por qué?
  2. Escribe el mensaje completo del commit 9182b3c con su trailer BREAKING CHANGE y su guía de migración.
  3. Escribe el comando de git log que extraería solo los feat de ese rango.
  4. Si el equipo integrara esta rama con squash merge y el título del squash fuera feat: mejoras de sincronización, ¿qué versión deduciría la herramienta? ¿Qué problema revela eso?

Soluciones

Solución 1:

Original Versión buena
fix css fix(css): corrige el desbordamiento del pie en móvil

Cuerpo: "El pie usaba un ancho fijo de 960 px, que en pantallas de menos de 360 px provocaba desplazamiento horizontal en toda la página. Se sustituye por max-width con width: 100%." + Refs: GT-121
actualiza app.js refactor(app): extrae el renderizado de la lista a una función

El nombre del fichero sobra: --stat ya lo dice. Lo que importa es qué se hizo dentro.
Añadido el buscador y arreglado el margen del pie Dos commits. feat(ui): añade el buscador de tareas por título y fix(css): corrige el margen del pie en pantallas estrechas. Además, Añadido no es imperativo.
Ahora sí funciona No tiene versión buena: es un commit de proceso. Lo correcto es git commit --fixup=<sha-del-commit-roto> y aplastarlo con git rebase -i --autosquash antes de publicar (lección 05-02).
Sube la dependencia build(deps): sube marked a 12.0.1 por CVE en el saneado de HTML

Especifica cuál y por qué. "Por seguridad" y "por una funcionalidad nueva" merecen urgencias distintas.

Solución 2:

mkdir /tmp/practica-mensajes && cd /tmp/practica-mensajes
git init -b main
git config user.name "Ana Ferrer"
git config user.email "[email protected]"
# 1. La plantilla
cat > .gitmensaje <<'EOF'
# <tipo>(<ámbito>): <descripción en imperativo, ≤50 caracteres>
#
# Tipos: feat fix docs style refactor perf test build ci chore revert
#
# --- Cuerpo (72 columnas) ----------------------------------------|
# ¿Por qué era necesario? ¿Qué alternativas se descartaron?
#
# --- Pie ---------------------------------------------------------
# Refs: GT-NNN
EOF

git config --local commit.template .gitmensaje

# 2. El diff dentro del editor
git config --local commit.verbose true
# 3. El hook (copia aquí el script del apartado 12)
mkdir -p .githooks
# ... crear .githooks/commit-msg con el contenido de la lección ...
chmod +x .githooks/commit-msg
git config --local core.hooksPath .githooks
# 4. Las tres pruebas
echo "hola" > app.js && git add .

git commit -m "arreglos varios"
# ERROR: el asunto no sigue Conventional Commits.

git commit -m "feat: añade el filtro"
# ERROR: falta la referencia al ticket (GT-NNN).

git commit -m "feat(filtros): añade el filtro por etiqueta" -m "Refs: GT-134"
# [main a1b2c3d] feat(filtros): añade el filtro por etiqueta
# 5. La vía de escape
echo "más" >> app.js && git add .
git commit --no-verify -m "cualquier cosa"
# [main d4e5f6a] cualquier cosa   ← el hook ni se ejecutó

--no-verify es una opción del cliente: no viaja por la red y el servidor no se entera de que se usó. La barrera que sí lo detiene es la del CI (apartado 12) declarada como comprobación obligatoria en una rama protegida, tal como vimos en la lección 07-06. El hook local es una ayuda para el desarrollador; la rama protegida es el control.

Solución 3:

1. Corresponde v3.0.0. La presencia de feat(api)! marca una ruptura de compatibilidad, y en SemVer eso sube el número MAJOR y pone MINOR y PATCH a cero, independientemente de que haya además dos feat y un fix. La ruptura manda siempre.

2.

feat(api)!: elimina el parámetro `orden` de listarTareas()

Teníamos dos formas de ordenar la lista: el parámetro `orden`
(heredado del prototipo, con valores 'alfa' y 'fecha') y el
parámetro `criterio` (introducido en la 2.1 para soportar la
ordenación por vencimiento). Mantener los dos obligaba a resolver
la precedencia en cada llamada y era fuente constante de fallos
como GT-149.

Se elimina `orden` y se conserva `criterio`, que es un
superconjunto estricto.

BREAKING CHANGE: `listarTareas()` ya no acepta el parámetro
`orden`. Usa `criterio`, que admite los mismos valores más
`vencimiento`:

  listarTareas({orden: 'alfa'})  ->  listarTareas({criterio: 'alfa'})
  listarTareas({orden: 'fecha'}) ->  listarTareas({criterio: 'fecha'})

Si pasas `orden`, se ignora en silencio; revisa tus llamadas.

Refs: GT-152

3.

git log --oneline --grep '^feat' v2.3.1..HEAD

Y para localizar específicamente las rupturas, que pueden estar en el cuerpo:

git log v2.3.1..HEAD --grep 'BREAKING CHANGE' --pretty='%h %s'

4. La herramienta deduciría v2.4.0 (un feat, ninguna ruptura visible), porque el BREAKING CHANGE estaba en el cuerpo de un commit que el aplastado hizo desaparecer. El resultado es grave: se publica como MINOR un cambio que rompe a todos los consumidores, y estos actualizan confiando en la garantía de compatibilidad de SemVer.

Lo que revela es que la política de integración no es una decisión estética: cambia qué mensajes sobreviven en la línea principal y, por tanto, qué información queda disponible para las personas y para las herramientas. Si se aplasta, el título del squash debe validarse con el mismo rigor que un commit, y los trailers relevantes deben propagarse a él. Esa política es justo el tema de la lección siguiente.

Conclusión

Lo esencial de esta lección:

  • Un mensaje de confirmación se escribe para tres lectores sin contexto: tu yo de dentro de dos años, quien haga blame sobre una línea rara y quien revise tu PR. Y para un cuarto que es una máquina.
  • El diff ya dice el qué; el mensaje existe para el porqué. Si tu mensaje se deduce del diff, todavía no lo has escrito. Lo que solo cabe en el mensaje es la motivación, las alternativas descartadas, los efectos colaterales y los límites del cambio.
  • La anatomía es sintaxis, no estética: asunto de ≤50 caracteres en imperativo y sin punto, línea en blanco obligatoria, cuerpo a 72 columnas, y pie con trailers (Refs, Co-authored-by, BREAKING CHANGE).
  • Conventional Commits hace el asunto legible por máquinas sin dejar de serlo para las personas: tipo(ámbito): descripción. A cambio de una disciplina mínima, obtienes filtrado del historial, changelog automático y, enlazando con la lección 05-05, la versión SemVer derivada del propio historial.
  • Una plantilla con commit.template en el repositorio pone el recordatorio delante de los ojos en el momento justo. Y escribir en el editor con commit.verbose en lugar de con -m mejora la calidad más que ninguna otra medida individual.
  • La convención se sostiene con el hook commit-msg de la lección 06-01 para la comodidad del desarrollador y con la validación en el CI sobre rama protegida de la lección 07-06 para lo innegociable, porque --no-verify existe.
  • El idioma se elige una vez, se escribe en el README.md y no se discute más.

Queda un cabo suelto que el ejercicio 3 ha dejado a la vista: un mensaje excelente no sirve de nada si la política de integración lo borra. Y esa política —merge, squash o rebase— es exactamente la decisión que dejamos abierta en la lección 07-04 sobre GitHub Flow.

La resolvemos ahora, junto con todo lo demás que hace que un historial sea legible, bisecable y reversible, en la lección 08-02: Manteniendo un Historial Limpio.

Dominando Git: De Principiante a Avanzado

Módulo 1: Introducción a Git

Módulo 2: Operaciones Básicas de Git

Módulo 3: Ramas y Fusión

Módulo 4: Trabajando con Repositorios Remotos

Módulo 5: Operaciones Avanzadas de Git

Módulo 6: Herramientas y Técnicas de Git

Módulo 7: Estrategias de Colaboración y Flujo de Trabajo

Módulo 8: Mejores Prácticas y Consejos de Git

Módulo 9: Solución de Problemas y Depuración

Módulo 10: Git en el Mundo Real

© Copyright 2026. Todos los derechos reservados