La lección anterior terminó con un ejercicio incómodo: un BREAKING CHANGE perfectamente escrito desapareció al integrar la rama con squash merge, y la herramienta de versionado publicó como MINOR un cambio que rompía a todos los consumidores. El mensaje era impecable; lo que falló fue la política de integración.

Esta lección resuelve esa cuestión y, con ella, la que dejamos deliberadamente abierta en la lección 07-04: cuando llega el momento de fusionar una pull request, ¿merge, squash o rebase? Es probablemente la discusión más recurrente y más religiosa de todos los equipos que usan Git, y tiene respuesta razonada —aunque no sea la misma para todos los proyectos.

Pero antes hay que responder a una pregunta previa que casi nadie se hace: ¿limpio para qué? Un historial no es una obra de arte. Es una herramienta de trabajo con usos concretos y medibles. Un historial "bonito" que no sirve para ninguno de esos usos no es un historial limpio: es decoración cara.

Contenido

  1. Para qué sirve realmente un historial
  2. El commit atómico
  3. Cómo llegar a commits atómicos: add -p y rebase -i
  4. La decisión de integración: merge, squash o rebase
  5. Tabla comparativa a fondo
  6. Qué política encaja con cada flujo
  7. Recomendación razonada por tipo de proyecto
  8. Limpiar antes de publicar, no tocar lo publicado
  9. Commits de fusión: cuándo aportan y cuándo son ruido
  10. Historial lineal frente a historial con topología
  11. Los commits de formateo masivo y .git-blame-ignore-revs
  12. Qué significa que un historial sea "bisecable"

  1. Para qué sirve realmente un historial

Si nunca vas a mirar atrás, cualquier historial vale. La calidad del historial solo se paga cuando lo consultas, y se consulta para cinco cosas muy concretas. Todas ellas ya las conoces del curso:

Uso Herramienta Qué necesita del historial
Entender el porqué de una línea git blame + git show (06-03) Commits pequeños con mensajes que expliquen la motivación
Localizar dónde se rompió algo git bisect (06-02) Que cada commit compile y pase las pruebas
Deshacer un cambio concreto git revert (05-06) Que ese cambio esté aislado en su propio commit
Revisar una propuesta git diff main...rama (07-02) Commits que cuenten una historia paso a paso
Auditar qué entró y cuándo git log, git tag --contains (05-05, 06-04) Trazabilidad de rama, autor y ticket

Fíjate en que ninguno de los cinco pide un grafo bonito. Piden commits pequeños, autocontenidos, bien descritos y que funcionen. Ese es el objetivo real. La topología —lineal o con ramas— es un medio, no un fin, y por eso la discusión merge/squash/rebase solo se puede resolver preguntando cuál de estos cinco usos importa más en tu proyecto.

Definición de trabajo: un historial limpio es aquel en el que cualquier commit se puede entender, probar, revertir y atribuir por separado.

  1. El commit atómico

Un commit atómico es un commit que contiene un cambio completo y solo uno. Las dos mitades de la definición importan igual:

  • Completo: no deja el proyecto roto. Compila, pasa las pruebas, la funcionalidad que toca funciona.
  • Solo uno: no arrastra cambios que no tienen que ver con su propósito.

Qué entra y qué no

Sí entra en el commit No entra
El código del cambio Otro cambio distinto que hiciste de paso
Sus pruebas Reformateo de ficheros que solo pasaste a leer
La documentación que ese cambio invalida Renombrar variables "ya que estoy"
La migración de datos que ese cambio exige Corregir una errata en otro módulo
El cambio de dependencia que ese código requiere Un console.log de depuración

El caso patológico clásico, que todo el mundo ha cometido:

git log --oneline -1 --stat
a1b2c3d Añade el filtro por etiqueta

 app.js            | 340 ++++++++++++++++++++++++-------------------
 estilos.css       | 128 +++++++++++++----
 index.html        |  22 ++-
 README.md         |   4 +-
 package.json      |   6 +-
 .github/ci.yml    |  18 ++-
 6 ficheros modificados, 340 inserciones(+), 178 eliminaciones(-)

340 líneas cambiadas en app.js para "añadir un filtro". Dentro hay, con toda seguridad: el filtro (30 líneas), un reformateo del fichero entero al guardar con el editor (250 líneas), un renombrado de dos variables (20), una corrección de una errata sin relación (5) y la subida de una dependencia (el package.json).

Qué pierdes con ese commit:

  • git blame sobre cualquier línea de app.js te lleva a él, aunque esa línea solo cambiara de indentación.
  • Si el filtro tiene un fallo, git revert te obliga a revertir también la subida de la dependencia.
  • git bisect lo señalará como culpable, pero el diff es tan grande que no habrás avanzado nada.
  • El revisor no puede evaluarlo: 340 líneas de las que 250 son ruido.

La prueba del asunto

Hay un test muy simple, y enlaza con la lección anterior: si no puedes describir el commit en un asunto de 50 caracteres sin usar "y", no es atómico. La incapacidad de nombrarlo es el síntoma.

  1. Cómo llegar a commits atómicos: add -p y rebase -i

La objeción práctica es real: "pero yo no programo así, yo voy tocando cosas". Nadie programa en commits atómicos. Los commits atómicos se fabrican después, con dos herramientas que ya dominas.

Antes de confirmar: git add -p

En la lección 02-04 vimos git add -p para preparar cambios por trozos. Ese es exactamente su propósito aquí: has tocado tres cosas en app.js y quieres tres commits.

git add -p app.js
@@ -12,7 +12,7 @@ function renderizarLista(tareas) {
-  const visibles = tareas.filter(t => !t.completada);
+  const visibles = aplicarFiltros(tareas);

(1/4) ¿Preparar este trozo [y,n,q,a,d,j,J,g,/,s,e,?]?

Respondes y a los trozos del filtro y n a los demás, confirmas, y repites. Recuerda las teclas que más rentan:

Tecla Efecto
y / n Preparar / no preparar este trozo
s Partir el trozo en trozos más pequeños
e Editar el trozo a mano (para partirlo línea a línea)
q Salir

Y una comprobación imprescindible antes de confirmar, porque preparar por trozos permite crear un commit que no compila:

git diff --staged        # lo que va a entrar
git stash push --keep-index   # aparta lo que NO va a entrar
npm test                 # ¿el commit funciona por sí solo?
git commit
git stash pop            # recupera el resto

Ese stash --keep-index de la lección 05-04 es la única forma honesta de verificar que un commit fabricado con add -p es realmente completo.

Después de confirmar: git rebase -i

Si ya has confirmado y el resultado es un desastre, el rebase interactivo de la lección 05-02 lo arregla, siempre que no lo hayas publicado (apartado 8).

git rebase -i main
pick a1b2c3d wip
pick b2c3d4e sigo con el filtro
pick c3d4e5f arregla lo de antes
pick d4e5f6a ahora sí
pick e5f6a7b añade pruebas del filtro

Se convierte en:

pick a1b2c3d wip
fixup b2c3d4e sigo con el filtro
fixup c3d4e5f arregla lo de antes
fixup d4e5f6a ahora sí
pick e5f6a7b añade pruebas del filtro

Y con reword en el primero, el resultado son dos commits limpios: feat(filtros): añade el filtro por etiqueta y test(filtros): cubre el filtrado por múltiples etiquetas.

Las tres órdenes que resuelven el 90 % de los casos:

Orden Cuándo
fixup El commit era una corrección del anterior; su mensaje sobra
squash Igual, pero quieres conservar y combinar los mensajes
reword El cambio está bien, el mensaje no
edit Necesitas partir un commit en dos (con reset HEAD^ y add -p)

Y el flujo que hace todo esto automático, de la lección 05-02: git commit --fixup=<sha> mientras trabajas, y git rebase -i --autosquash main al final.

  1. La decisión de integración: merge, squash o rebase

Llegamos al punto. La rama GT-134-timeout-sync de Bruno tiene cuatro commits, está aprobada y va a entrar en main. Hay tres formas de hacerlo, y producen tres historiales distintos.

Partimos siempre de esta situación:

gitGraph
    commit id: "A"
    commit id: "B"
    branch GT-134
    commit id: "C1"
    commit id: "C2"
    commit id: "C3"
    checkout main
    commit id: "D"

main ha avanzado con D mientras Bruno trabajaba, así que no hay avance rápido posible.

Opción 1: merge commit (--no-ff)

git switch main
git merge --no-ff GT-134-timeout-sync
gitGraph
    commit id: "A"
    commit id: "B"
    branch GT-134
    commit id: "C1"
    commit id: "C2"
    commit id: "C3"
    checkout main
    commit id: "D"
    merge GT-134 id: "M"

Los tres commits de Bruno entran tal cual, con sus SHA y sus fechas originales, y se añade un commit de fusión M con dos padres que marca dónde y cuándo se integró la rama. Es lo que vimos en la lección 03-03.

Opción 2: squash merge

git switch main
git merge --squash GT-134-timeout-sync
git commit          # se escribe un mensaje nuevo
gitGraph
    commit id: "A"
    commit id: "B"
    commit id: "D"
    commit id: "S (C1+C2+C3)" type: HIGHLIGHT

Los tres commits se funden en uno solo, S, con un mensaje nuevo. main queda lineal. La rama original sigue existiendo en el repositorio de Bruno, pero main no tiene ninguna referencia a ella: Git no sabe que S viene de C1, C2 y C3.

Opción 3: rebase and merge

git switch GT-134-timeout-sync
git rebase main
git switch main
git merge --ff-only GT-134-timeout-sync
gitGraph
    commit id: "A"
    commit id: "B"
    commit id: "D"
    commit id: "C1'"
    commit id: "C2'"
    commit id: "C3'"

Los tres commits se reescriben encima de D (nuevos SHA, mismo contenido y mismo mensaje) y main avanza en línea recta. Es el rebase de la lección 05-01.

La observación clave

Las tres producen exactamente el mismo árbol de ficheros. El código final es idéntico byte a byte. Lo único que cambia es qué información queda registrada sobre cómo se llegó hasta ahí. Por eso la decisión no es técnica: es una decisión sobre qué información quieres poder recuperar dentro de un año.

  1. Tabla comparativa a fondo

Dimensión Merge commit (--no-ff) Squash Rebase
Historial de main Con topología: burbujas de rama Estrictamente lineal Estrictamente lineal
Nº de commits en main por PR N + 1 (los de la rama más la fusión) Exactamente 1 N
¿Se conservan los commits originales? Sí, con su SHA No, desaparecen No: mismo contenido, SHA nuevos
Trazabilidad de la rama Máxima: el commit M dice qué rama, cuándo y quién fusionó Solo lo que diga el mensaje del squash Ninguna: los commits quedan sueltos
Legibilidad de git log --oneline Ruidoso si hay muchos wip Máxima: una línea por cambio Depende de la disciplina de la rama
git bisect Puede aterrizar en un commit intermedio roto Óptimo: cada paso es un cambio íntegro y probado Bueno si cada commit compila; malo si no
Granularidad de bisect Fina (llega al commit exacto) Gruesa (llega a la PR entera) Fina
git revert Un revert -m 1 deshace la PR completa Trivial: un commit, un revert Hay que revertir N commits, en orden inverso
git blame Apunta al commit real, con su mensaje detallado Apunta al squash: mensaje más genérico Apunta al commit real
Atribución de autoría Correcta y por commit Un solo autor; el resto solo si hay Co-authored-by Correcta y por commit
Fechas Se conservan las de autoría original Fecha única, la de la fusión Fecha de autoría original, fecha de commit nueva
Conflictos Se resuelven una vez, en la fusión Se resuelven una vez Se resuelven commit a commit (puede repetirse)
Riesgo de reescritura Ninguno Ninguno en main; la rama queda huérfana La rama se reescribe: obliga a push --force-with-lease
Compatibilidad con la regla de oro (05-01) Total Total Solo si la rama es de una persona o está coordinada
--first-parent Muy útil: da la lista de PRs Irrelevante (no hay fusiones) Irrelevante
Deducción de SemVer (08-01) Lee todos los commits: fiable Lee solo el squash: frágil Lee todos: fiable
Curva de aprendizaje Baja Mínima Alta (hay que entender rebase y force-push)

Los tres puntos donde de verdad se decide

1. revert frente a bisect. Squash gana en revert y en legibilidad; merge y rebase ganan en granularidad de bisect y en blame. Pregúntate qué haces más a menudo: ¿deshacer funcionalidades enteras, o cazar el commit exacto que rompió algo?

2. La calidad real de los commits de tu equipo. Si las ramas llegan con wip, wip2 y ahora sí, el merge y el rebase meten esa basura en main para siempre. El squash la absorbe. Squash es la política que protege de la indisciplina; merge y rebase son las que la premian cuando existe.

3. El tamaño de las pull requests. Con PRs de 200 líneas, un squash pierde poca información. Con PRs de 2.000 líneas, el squash produce commits monstruosos que arruinan blame y bisect a la vez. Squash y PRs pequeñas van juntos; es coherente con lo que dijimos sobre el tamaño de la PR en la lección 07-02.

  1. Qué política encaja con cada flujo

Cada flujo del módulo 7 tiene una política que le sienta de forma natural:

Flujo (módulo 7) Política natural Motivo
Git Flow (07-03) Merge --no-ff siempre El valor está en la topología: hay que poder ver qué entró en develop, qué se promovió a release y qué se coló por un hotfix. Aplanar destruye la información que justifica el flujo.
GitHub Flow (07-04) Squash por defecto Ramas cortas, de una persona, con una funcionalidad. Un commit por PR en main produce un historial legible donde cada línea es una unidad desplegable.
Trunk Based Development (07-05) Rebase o squash Las ramas viven horas. El objetivo es un tronco estrictamente lineal, y con ramas tan pequeñas la diferencia entre las dos opciones es mínima.
Fork externo (07-01) Merge o squash Con Diego, que no tiene permiso de escritura, el rebase de su rama es incómodo: no puedes reescribir su fork. Se fusiona o se aplasta desde el lado del proyecto.

Un matiz sobre Git Flow que se pasa por alto: en un repositorio con --no-ff sistemático, la lectura de alto nivel se hace con --first-parent (apartado 9), que oculta el interior de cada burbuja y deja solo la secuencia de integraciones. Sin esa herramienta, un historial con topología es efectivamente ilegible, y de ahí viene buena parte del rechazo al merge.

  1. Recomendación razonada por tipo de proyecto

No hay una respuesta universal, pero sí hay respuestas defendibles según el proyecto:

Aplicación web con despliegue continuo, equipo de 3 a 15 personas → squash. Es el caso de gestor-tareas. Las PRs son pequeñas, cada una corresponde a un ticket GT-NNN, y lo que más se hace es responder a "¿qué cambió esta semana?" y "revierte eso que rompió producción". main queda con una línea por cambio, revert es trivial y bisect aterriza en la PR culpable, que es una granularidad suficiente cuando las PRs son de 200 líneas. Condición innegociable: el título del squash se escribe con el mismo cuidado que un commit (lección 08-01) y se propagan los trailers relevantes, incluidos Co-authored-by y BREAKING CHANGE.

Biblioteca o producto con versiones y soporte de varias ramas → merge --no-ff. Aquí hay que responder a "¿en qué versión entró esto?", "¿este parche está en la rama 2.x?" y "¿de qué release salió?". La topología es la respuesta, y git log --first-parent, git tag --contains y git branch --contains dependen de ella. Además, con cherry-pick entre ramas de mantenimiento (lección 05-03), tener los commits originales intactos facilita muchísimo el trabajo.

Proyecto de código abierto con muchos colaboradores externos → merge o squash, según la calidad de las contribuciones. El proyecto Git y el kernel de Linux usan merge con historial de topología, pero exigen a los contribuidores series de parches ya limpias. Un proyecto que no puede exigir eso hace mejor en aplastar.

Repositorio interno pequeño, 1 o 2 personas → lo que os resulte cómodo. Con dos personas y contexto compartido, la diferencia práctica es pequeña. Elegid uno y no le deis más vueltas.

Regla transversal, la que de verdad importa:

Elige una política, configúrala en la plataforma para que sea la única disponible, y escríbela en el README.md. Lo peor de todos los mundos es un main donde algunos aplastaron, otros fusionaron y otros rebasaron: no se puede leer con ninguna herramienta de forma consistente, --first-parent da resultados absurdos y nadie sabe qué esperar.

En gestor-tareas, el acuerdo escrito quedó así:

## Política de integración

Todas las pull requests se integran con **squash merge**.

- El título de la PR es el asunto del commit: `tipo(ámbito): descripción` (ver 08-01).
- La descripción de la PR es el cuerpo del commit: el porqué.
- Los trailers `Refs: GT-NNN`, `Co-authored-by` y `BREAKING CHANGE` se
  copian al cuadro de squash antes de confirmar la fusión.
- Las PRs se mantienen por debajo de 400 líneas. Si crecen, se parten.
- La rama se borra tras fusionar.

  1. Limpiar antes de publicar, no tocar lo publicado

Todo lo del apartado 3 —rebase -i, fixup, reword, partir commits— tiene una frontera clarísima, y es la regla de oro de la lección 05-01:

Reescribe libremente lo que solo existe en tu máquina. No reescribas nunca lo que otros ya han descargado.

La razón, recordada en una frase: reescribir crea commits nuevos con SHA distintos. Quien tuviera los viejos se queda con dos versiones divergentes de la misma historia, y su siguiente pull produce un enredo que hay que deshacer a mano.

flowchart LR
    A["Trabajo en local<br/>commits sucios"] -->|"rebase -i, fixup,<br/>add -p, amend"| B["Serie limpia"]
    B -->|"push"| C["Publicado"]
    C -->|"revert, commits nuevos"| D["Sigue avanzando"]
    C -.->|"PROHIBIDO<br/>rebase, amend, force"| B

La zona gris: tu propia rama de PR

Hay un caso intermedio que hay que resolver explícitamente, porque genera discusiones eternas: tu rama de funcionalidad ya está en origin y abierta como PR, y quieres limpiarla antes de fusionar.

Es legítimo, con condiciones:

  • La rama es tuya y nadie más ha basado trabajo en ella. Si Carla ha hecho un commit encima, ya no.
  • Usas siempre git push --force-with-lease (lección 04-05), nunca --force a secas: si alguien ha enviado algo mientras tanto, la operación se rechaza en lugar de destruirlo.
  • Avisas en la PR. Los revisores pierden el hilo de sus comentarios cuando los SHA cambian.
  • Si ya hay comentarios de revisión, añade commits nuevos en lugar de reescribir, y aplasta al final con el squash de la fusión. Es la razón principal por la que el squash es tan popular: hace innecesario el rebase de limpieza.
  • Para revisar qué cambió entre la versión anterior y la reescrita, git range-diff de la lección 07-02.

Nunca, bajo ninguna circunstancia: reescribir main, una rama de release, o una rama compartida. Si algo hay que deshacer allí, se deshace con git revert (lección 05-06), que añade un commit nuevo en lugar de borrar el viejo.

  1. Commits de fusión: cuándo aportan y cuándo son ruido

No todos los commits de fusión son iguales. Hay dos clases y conviene distinguirlas.

Fusiones que aportan

Son las deliberadas: git merge --no-ff de una rama de funcionalidad a main. El commit registra una decisión —"esta funcionalidad se integró aquí"— y su mensaje puede documentarla:

Merge branch 'GT-134-timeout-sync'

Sube el tiempo de espera de la sincronización tras las mediciones
en preproducción de la semana del 12. Aprobado por Ana.

Refs: GT-134

Ese commit es información pura, y con --first-parent produce un historial de alto nivel excelente.

Fusiones que son ruido

Son las accidentales: las que aparecen al hacer git pull cuando el remoto ha avanzado.

Merge branch 'main' of git.ejemplo.es:equipo/gestor-tareas

Ese commit no registra ninguna decisión. Registra que Carla hizo pull un martes a las 11:40. Un main con doscientos de estos es ilegible, y llenan el grafo de burbujas que no significan nada.

La cura es la de la lección 04-04, y debería estar en la configuración global de todo el mundo:

git config --global pull.rebase true

O, si prefieres que Git te obligue a decidir en vez de elegir por ti:

git config --global pull.ff only
# Si no hay avance rápido posible, git pull falla y decides tú

Leer un historial con muchas fusiones: --first-parent

Un commit de fusión tiene dos padres: el primero es la rama donde estabas (main) y el segundo es la que fusionaste. --first-parent sigue únicamente el primero, con lo que oculta el contenido interno de cada rama y deja solo la secuencia de integraciones.

# Todo, incluidos los commits internos de cada rama
git log --oneline
9f8e7d6 Merge branch 'GT-141-indexeddb'
6c5b4a3 test(sync): cubre la migración desde localStorage
3a2b1c0 feat(sync): guarda las tareas en IndexedDB
8d7c6b5 refactor(sync): extrae el acceso a datos a un módulo
7f6e5d4 Merge branch 'GT-134-timeout-sync'
4e3d2c1 fix(sync): evita duplicar tareas al reintentar
1b0a9f8 chore(sync): sube el tiempo de espera a 30 s
...
# Solo la línea de integraciones
git log --oneline --first-parent
9f8e7d6 Merge branch 'GT-141-indexeddb'
7f6e5d4 Merge branch 'GT-134-timeout-sync'
...

De cientos de líneas a una decena. Este es el argumento que rescata al merge: un historial con topología no es ilegible, es un historial con dos niveles de lectura. El de alto nivel se obtiene con --first-parent, y el detalle sigue estando ahí cuando lo necesitas.

Merece un alias, retomando los de la lección 06-04:

git config --global alias.integraciones \
  "log --oneline --first-parent --decorate"

--first-parent funciona también en otros comandos:

git log --first-parent --stat        # qué ficheros tocó cada PR
git bisect start --first-parent      # bisecar por PRs, no por commits internos
git blame --first-parent fichero     # atribuir a la fusión, no al commit interno

Ese git bisect --first-parent es especialmente útil: convierte un historial con merges en algo con la misma granularidad que un historial de squash, pero sin perder el detalle cuando quieres bajar.

  1. Historial lineal frente a historial con topología

flowchart TB
    subgraph LIN["Lineal (squash o rebase)"]
        direction LR
        L1["A"] --> L2["B"] --> L3["C"] --> L4["D"] --> L5["E"]
    end
    subgraph TOP["Con topología (merge --no-ff)"]
        direction LR
        T1["A"] --> T2["B"] --> TM1["M1"] --> TM2["M2"]
        T2 --> R1["C1"] --> R2["C2"] --> TM1
        TM1 --> S1["D1"] --> S2["D2"] --> TM2
    end
Lineal Con topología
Leer git log --oneline Directo, sin herramientas Necesita --first-parent para el alto nivel
git log --graph Una columna Varias columnas; con muchas ramas paralelas, ilegible
Contexto de cada cambio Se pierde la agrupación por rama Explícito: se ve qué commits fueron juntos
Cuándo se hizo el trabajo El orden es el de integración, no el real Se ve el trabajo concurrente tal como ocurrió
bisect Directo, sin sorpresas Puede entrar dentro de una rama; --first-parent lo evita
revert de una PR Squash: un commit. Rebase: N commits revert -m 1 sobre la fusión, uno solo
Honestidad histórica Es una reconstrucción: el orden nunca ocurrió así Es un registro: refleja lo que pasó de verdad
Coste de mantenimiento Requiere rebase y force-push (rebase) o perder detalle (squash) Ninguno: es lo que sale solo

La última fila es la tensión de fondo, y merece decirse claramente: un historial lineal es una ficción útil. Nadie trabajó en ese orden. Ana y Bruno programaron a la vez durante tres días; el historial lineal cuenta que uno terminó y luego empezó el otro. La pregunta honesta no es "¿qué es más verdadero?" sino "¿me sirve de algo la verdad concurrente?". Para la mayoría de los productos, no. Para un proyecto con soporte de varias versiones en paralelo, sí, y mucho.

  1. Los commits de formateo masivo y .git-blame-ignore-revs

Hay un tipo de commit que rompe el historial más que ningún otro, y no es un commit malo: es necesario. El día que el equipo de gestor-tareas decidió adoptar Prettier, Ana ejecutó el formateador sobre todo el proyecto:

npx prettier --write .
git commit -am "style: aplica Prettier a todo el proyecto"
 app.js       | 1204 ++++++++++++++++++++--------------------
 estilos.css  |  486 +++++++--------
 index.html   |  152 ++---
 3 ficheros modificados, 921 inserciones(+), 921 eliminaciones(-)

El daño: a partir de ese momento, git blame app.js atribuye todas las líneas del fichero a Ana y a ese commit. Toda la información de autoría e intención de los últimos dos años queda enterrada bajo un commit que no cambió ni una coma de comportamiento.

Las reglas para que duela lo mínimo:

  1. Aísla el formateo en su propio commit. Nunca mezclado con cambios funcionales: es la aplicación más importante del principio del commit atómico.
  2. Que ese commit no cambie el comportamiento. Solo el formateador; nada a mano. Debe poder reproducirse ejecutando la herramienta.
  3. Dilo en el mensaje, incluida la versión de la herramienta y su configuración, para que sea reproducible.
  4. Regístralo en .git-blame-ignore-revs.

Ese fichero, que vimos en la lección 06-03 y que ahora cerramos, es una lista de SHA que git blame debe atravesar como si no existieran:

# .git-blame-ignore-revs
# Commits de formateo masivo que blame debe ignorar.
# Documentación: git blame --ignore-revs-file
#
# Adopción de Prettier 3.2 en todo el proyecto (GT-160)
a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0
#
# Migración de tabuladores a 2 espacios en estilos.css (GT-171)
b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1
#
# Reordenación alfabética de las propiedades CSS (GT-183)
c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2

Requisitos: SHA completos de 40 caracteres (los abreviados no valen) y una línea por commit.

Se usa así:

# Puntualmente
git blame --ignore-revs-file .git-blame-ignore-revs app.js

# Permanentemente, para este repositorio
git config --local blame.ignoreRevsFile .git-blame-ignore-revs

Con esa configuración local, git blame app.js vuelve a mostrar los autores reales de cada línea. Las plataformas de alojamiento más habituales reconocen el fichero por su nombre convencional y lo aplican automáticamente en su vista de blame.

El detalle que hay que documentar en el README.md: blame.ignoreRevsFile es configuración local (lección 01-05), así que no viaja con el clon. El fichero sí está versionado, pero cada persona tiene que activarlo. Añádelo al script de puesta en marcha del proyecto:

# script de arranque del repositorio
git config --local blame.ignoreRevsFile .git-blame-ignore-revs
git config --local commit.template .gitmensaje
git config --local core.hooksPath .githooks

  1. Qué significa que un historial sea "bisecable"

En la lección 06-02 dijimos que git bisect solo es útil sobre un historial "bisecable" y remitimos aquí. Ya tenemos todas las piezas para definirlo.

Un historial es bisecable cuando cualquier commit escogido al azar se puede compilar, arrancar y probar.

git bisect funciona por búsqueda binaria: te planta en un commit intermedio y te pregunta si está bien o mal. Si ese commit ni siquiera compila, no puedes responder ninguna de las dos cosas. Tienes que marcarlo git bisect skip, y cada skip degrada la búsqueda. Con suficientes commits rotos, la bisección deja de converger y no sirve de nada.

Qué rompe la bisecabilidad

Práctica Efecto sobre bisect
Commits wip que no compilan Cada uno es un skip obligado
Separar el código de sus pruebas en dos commits El commit intermedio "falla" por una razón falsa
Añadir una dependencia en un commit y usarla en el anterior El commit intermedio no arranca
Cambiar el esquema de datos sin su migración Arranca pero falla en tiempo de ejecución
PRs gigantes aplastadas Compila siempre, pero bisect solo llega a "esta PR de 2.000 líneas"
add -p sin verificar el resultado Commits que no incluyen todo lo que necesitan

Qué la garantiza

  1. Commits atómicos y completos: apartado 2. Cada uno deja el proyecto funcionando.
  2. El código y sus pruebas, en el mismo commit.
  3. CI sobre cada commit, no solo sobre la punta de la rama. Es la única comprobación real. Con la validación de la lección 07-06, se puede ejecutar la compilación sobre cada commit de la PR:
      - name: Comprobar que cada commit compila
        run: |
          BASE="${{ github.event.pull_request.base.sha }}"
          for sha in $(git rev-list --reverse "$BASE..HEAD" --no-merges); do
            echo "::group::Compilando $sha"
            git checkout --quiet "$sha"
            npm ci --silent && npm run build --silent || {
              echo "::error::El commit $sha no compila: rompe la bisecabilidad"
              exit 1
            }
            echo "::endgroup::"
          done
  1. git rebase --exec, de la lección 05-02, para comprobarlo tú antes de publicar. Es la herramienta perfecta para esto:
# Ejecuta las pruebas en cada commit desde main; se detiene en el primero que falle
git rebase main --exec "npm test"

Si el rebase se para, tienes un commit no bisecable en la rama. Lo arreglas con edit o fixup y continúas.

Y la política de integración

Cierra el círculo del apartado 4:

  • Squash garantiza la bisecabilidad casi gratis: cada commit de main es una PR entera, que pasó el CI. La granularidad es gruesa, pero nunca falla.
  • Merge con --first-parent da lo mismo que el squash, y permite bajar al detalle cuando la PR es grande.
  • Rebase da la granularidad más fina, pero solo si cada commit compila, lo cual exige disciplina y una comprobación como la de arriba.

Errores Comunes y Consejos

Error 1: confundir "limpio" con "bonito". Un grafo con una sola línea recta que no permite revertir, bisecar ni entender nada no es limpio. Los cinco usos del apartado 1 son el criterio; la estética no.

Error 2: mezclar políticas de integración en el mismo repositorio. Es peor que elegir la política equivocada. Configúrala en la plataforma dejando una sola opción activa.

Error 3: aplastar PRs enormes. Un squash de 2.000 líneas destruye blame y deja bisect sin granularidad. Squash exige PRs pequeñas; si no las tienes, arregla eso primero.

Error 4: perder el BREAKING CHANGE en el squash. Es lo que vimos en el ejercicio 3 de la lección 08-01. El cuadro de mensaje del squash sale prerrellenado con la concatenación de todos los commits; léelo y edítalo, no lo aceptes tal cual ni lo vacíes.

Error 5: rebasar una rama compartida. La regla de oro de la 05-01 no tiene excepciones prácticas. Si dudas de si alguien más la tiene, no la rebases.

Error 6: acumular fusiones de pull. git config --global pull.rebase true y desaparecen. Es un cambio de una línea que mejora el historial de por vida.

Error 7: hacer el formateo masivo mezclado con un cambio funcional. Enterrarás el cambio funcional en 900 líneas de ruido y arruinarás el blame sin posibilidad de rescate, porque .git-blame-ignore-revs no puede ignorar un commit que además contiene código real.

Error 8: pensar que --first-parent no existe. Mucha gente rechaza el merge por "ilegible" sin haber probado nunca git log --first-parent. Pruébalo antes de decidir.

Consejo 1: escribe la política en el README.md. Con las condiciones (tamaño de PR, formato del título, trailers). Diego, que viene de fuera, la necesita antes de su primera PR.

Consejo 2: git rebase main --exec "npm test" antes de abrir la PR. Es la forma más barata de garantizar que tu rama es bisecable.

Consejo 3: alias para leer. git config --global alias.integraciones "log --oneline --first-parent --decorate".

Consejo 4: revisa tu rama antes de publicarla. git log --oneline main..HEAD. Si ves un wip, todavía te queda trabajo.

Consejo 5: .git-blame-ignore-revs desde el primer commit. Créalo vacío, con la cabecera comentada. Así, el día del primer formateo masivo, el sitio ya existe.

Consejo 6: borra las ramas fusionadas. Con squash, la rama no queda enlazada desde main; dejarla viva sugiere que hay trabajo pendiente. Recuerda git branch --merged y git fetch --prune de la lección 03-06.

Ejercicios

Ejercicio 1: los tres historiales, con las manos

Monta un repositorio de pruebas que reproduzca la situación del apartado 4: main con dos commits, una rama con tres commits y un commit posterior en main. Luego, desde tres copias del mismo estado, integra la rama con cada una de las tres políticas.

Para cada resultado, responde:

  1. ¿Cuántos commits tiene main?
  2. ¿Qué muestra git log --oneline --graph?
  3. ¿Qué muestra git log --oneline --first-parent?
  4. ¿Qué comando exacto usarías para deshacer la integración completa?
  5. Si bisect señalara el problema, ¿a qué nivel de detalle te dejaría?

Ejercicio 2: fabricar commits atómicos de un desastre

Simula el commit patológico del apartado 2:

  1. Crea un app.js con tres funciones y confírmalo.
  2. En un solo cambio, modifica una función (cambio funcional), reindenta otra (formateo) y corrige una errata en un comentario de la tercera.
  3. Usa git add -p (con s y e cuando haga falta) para separar los tres en tres commits atómicos con mensajes en Conventional Commits.
  4. Verifica con git stash push --keep-index que el primer commit funciona por sí solo.
  5. Comprueba con git log --oneline --stat que cada commit toca solo lo suyo.

Ejercicio 3: rescatar un blame arruinado

  1. Crea un repositorio con un estilos.css de unas 20 líneas, escrito en tres commits de tres autores distintos (usa git -c user.name=... -c user.email=... commit).
  2. Comprueba con git blame estilos.css que la autoría es correcta.
  3. Aplica un reformateo masivo (por ejemplo, cambia toda la indentación) y confírmalo como style: reindenta estilos.css a 2 espacios.
  4. Comprueba el daño con git blame.
  5. Crea .git-blame-ignore-revs, configura blame.ignoreRevsFile y demuestra que la autoría original se recupera.
  6. Explica por qué el paso 5 habría sido imposible si el reformateo hubiera venido mezclado con un cambio funcional.

Soluciones

Solución 1:

# Estado de partida, reutilizable
mkdir /tmp/practica-hist && cd /tmp/practica-hist
git init -b main
git config user.name "Ana Ferrer"; git config user.email "[email protected]"

echo "linea A" > app.js && git add . && git commit -m "chore: commit inicial (A)"
echo "linea B" >> app.js && git commit -am "feat: base del proyecto (B)"

git switch -c GT-134
echo "C1" >> app.js && git commit -am "chore(sync): sube el tiempo de espera (C1)"
echo "C2" >> app.js && git commit -am "fix(sync): evita duplicar al reintentar (C2)"
echo "C3" >> app.js && git commit -am "test(sync): cubre el reintento (C3)"

git switch main
echo "D" >> README.md && git add . && git commit -m "docs: añade el README (D)"

# Tres copias del mismo estado
cd /tmp && cp -r practica-hist h-merge && cp -r practica-hist h-squash && cp -r practica-hist h-rebase
# --- MERGE ---
cd /tmp/h-merge
git merge --no-ff GT-134 -m "Merge branch 'GT-134'"
git log --oneline --graph
*   f1e2d3c Merge branch 'GT-134'
|\
| * c3d4e5f test(sync): cubre el reintento (C3)
| * b2c3d4e fix(sync): evita duplicar al reintentar (C2)
| * a1b2c3d chore(sync): sube el tiempo de espera (C1)
* | 9a8b7c6 docs: añade el README (D)
|/
* 8f7e6d5 feat: base del proyecto (B)
* 7e6d5c4 chore: commit inicial (A)
# --- SQUASH ---
cd /tmp/h-squash
git merge --squash GT-134
git commit -m "fix(sync): evita duplicar tareas al reintentar el envío" \
           -m "Sube el tiempo de espera a 30 s y añade la cobertura del reintento." \
           -m "Refs: GT-134"
git log --oneline
5d4c3b2 fix(sync): evita duplicar tareas al reintentar el envío
9a8b7c6 docs: añade el README (D)
8f7e6d5 feat: base del proyecto (B)
7e6d5c4 chore: commit inicial (A)
# --- REBASE ---
cd /tmp/h-rebase
git switch GT-134 && git rebase main
git switch main && git merge --ff-only GT-134
git log --oneline
3c2b1a0 test(sync): cubre el reintento (C3)
2b1a0f9 fix(sync): evita duplicar al reintentar (C2)
1a0f9e8 chore(sync): sube el tiempo de espera (C1)
9a8b7c6 docs: añade el README (D)
8f7e6d5 feat: base del proyecto (B)
7e6d5c4 chore: commit inicial (A)

Las respuestas:

Merge Squash Rebase
1. Commits en main 6 (3+1 nuevos) 4 (1 nuevo) 6 (3 nuevos)
2. --graph Bifurcación visible con dos ramas Una columna Una columna
3. --first-parent 3 líneas: A, B, D, M Igual que --oneline Igual que --oneline
4. Deshacer git revert -m 1 f1e2d3c git revert 5d4c3b2 git revert 3c2b1a0 2b1a0f9 1a0f9e8 (o git revert 9a8b7c6..HEAD)
5. Detalle de bisect Commit exacto (o la PR, con --first-parent) La PR entera Commit exacto

Observa la fila 4: con rebase, deshacer la integración exige revertir tres commits en orden inverso, y si el intermedio depende del primero, hay conflictos. Es la desventaja más práctica del rebase puro.

Solución 2:

mkdir /tmp/practica-atom && cd /tmp/practica-atom && git init -b main

cat > app.js <<'EOF'
function saludar(n) { return "Hola " + n; }
function sumar(a, b) { return a + b; }
// Devuelve el numero de tares    <-- errata
function contar(l) { return l.length; }
EOF
git add . && git commit -m "chore: estado inicial"
# 2. Los tres cambios de golpe
cat > app.js <<'EOF'
function saludar(n) { return `Hola ${n}`; }
function sumar(a, b) {
  return a + b;
}
// Devuelve el número de tareas
function contar(l) { return l.length; }
EOF
# 3. Separar con add -p
git add -p app.js
# Trozo 1 (saludar):  y
# Trozo 2 (sumar):    n
# Trozo 3 (comentario): n
git commit -m "refactor(app): usa plantillas de cadena en saludar()"

git add -p app.js
# Trozo de sumar: y ; el del comentario: n
git commit -m "style(app): reformatea sumar() a varias líneas"

git add app.js
git commit -m "docs(app): corrige la errata del comentario de contar()"

Si los cambios estuvieran en el mismo trozo, s los parte y e permite editar el trozo a mano, dejando con - las líneas que no quieres preparar.

# 4. Verificar el aislamiento del primer commit
git reset --soft HEAD~2       # deja los dos últimos como cambios preparados
git stash push --keep-index    # aparta lo no preparado... (ver nota)
node -e "require('./app.js')"  # o npm test
git stash pop

En la práctica se hace antes de confirmar: preparas el trozo, git stash push --keep-index aparta todo lo demás, ejecutas las pruebas sobre exactamente lo que va a entrar, y solo entonces confirmas y haces git stash pop.

# 5. Cada commit toca lo suyo
git log --oneline --stat -3

Solución 3:

mkdir /tmp/practica-blame && cd /tmp/practica-blame && git init -b main

printf '.tarea {\n    color: #333;\n}\n' > estilos.css
git -c user.name="Ana Ferrer" -c user.email="[email protected]" \
    commit -am "feat(css): estilo base de la tarea" --allow-empty-message 2>/dev/null || {
  git add . && git -c user.name="Ana Ferrer" -c user.email="[email protected]" \
    commit -m "feat(css): estilo base de la tarea"
}

printf '.tarea.completada {\n    opacity: 0.5;\n}\n' >> estilos.css
git add . && git -c user.name="Bruno Salas" -c user.email="[email protected]" \
    commit -m "feat(css): atenúa las tareas completadas"

printf '.tarea.vencida {\n    border-left: 3px solid #c00;\n}\n' >> estilos.css
git add . && git -c user.name="Carla Vidal" -c user.email="[email protected]" \
    commit -m "feat(css): marca las tareas vencidas"
# 2. Autoría correcta
git blame estilos.css
^a1b2c3d (Ana Ferrer   2026-07-20 .tarea {
^a1b2c3d (Ana Ferrer   2026-07-20     color: #333;
b2c3d4e5 (Bruno Salas  2026-07-21 .tarea.completada {
c3d4e5f6 (Carla Vidal  2026-07-22 .tarea.vencida {
# 3. El reformateo masivo
sed -i 's/^    /  /' estilos.css
git -c user.name="Ana Ferrer" -c user.email="[email protected]" \
    commit -am "style: reindenta estilos.css a 2 espacios"

# 4. El daño
git blame estilos.css

Ahora todas las líneas indentadas aparecen a nombre de Ana y del commit de estilo.

# 5. El rescate
SHA=$(git rev-parse HEAD)         # SHA completo de 40 caracteres, imprescindible
cat > .git-blame-ignore-revs <<EOF
# Commits de formateo masivo que blame debe ignorar.
# Reindentación de estilos.css a 2 espacios
$SHA
EOF
git add .git-blame-ignore-revs
git commit -m "chore: registra el commit de reindentado en blame-ignore-revs"

git config --local blame.ignoreRevsFile .git-blame-ignore-revs
git blame estilos.css       # autoría original recuperada

6. Porque .git-blame-ignore-revs funciona saltando el commit entero: cuando blame encuentra ese SHA, atribuye la línea al commit anterior que la tocó. Si el commit contuviera además un cambio funcional real, ignorarlo atribuiría también ese cambio funcional a quien no lo hizo, y perderías la información de quién introdujo el código de verdad. El fichero solo es seguro con commits que demostrablemente no cambian el comportamiento. Por eso la regla 1 del apartado 11 —aislar el formateo— no es un consejo estético: es la condición que hace posible el rescate.

Conclusión

Lo esencial de esta lección:

  • Un historial es limpio cuando cualquier commit se puede entender, probar, revertir y atribuir por separado. Los cinco usos reales —blame, bisect, revert, revisión y auditoría— son el criterio; la estética del grafo no lo es.
  • El commit atómico —un cambio completo y solo uno— es la unidad que hace posible todo lo demás. No se programa así: se fabrica después, con git add -p antes de confirmar y con git rebase -i después.
  • La decisión que dejamos abierta en la 07-04 queda cerrada así:
    • Merge --no-ff cuando la topología es información: Git Flow, bibliotecas con varias versiones vivas, proyectos con ramas de mantenimiento. Se lee con --first-parent.
    • Squash cuando lo que importa es que main sea una lista legible de cambios desplegables: GitHub Flow, aplicaciones web, equipos cuyas ramas llegan con wip. Exige PRs pequeñas y un título de squash escrito con cuidado.
    • Rebase cuando quieres granularidad fina y linealidad a la vez, y el equipo tiene la disciplina para que cada commit compile: Trunk Based Development.
    • Y por encima de las tres: elige una, configúrala como única opción en la plataforma y escríbela en el README.md. Mezclarlas es peor que elegir mal.
  • Se limpia antes de publicar; no se toca lo publicado. La regla de oro de la 05-01 se mantiene, con la única zona gris de tu propia rama de PR, y siempre con --force-with-lease y aviso previo.
  • Los commits de fusión deliberados son información; los accidentales de git pull son ruido que se elimina con pull.rebase true. Y --first-parent convierte un historial con topología en un historial de dos niveles de lectura, lo que rescata al merge de la acusación de ilegible.
  • Un formateo masivo se aísla en su propio commit, se documenta y se registra en .git-blame-ignore-revs con el SHA completo, activándolo con blame.ignoreRevsFile. Aislarlo no es estética: es lo que hace posible el rescate.
  • Un historial es bisecable cuando cualquier commit compila y se puede probar. Se garantiza con commits atómicos, con el código y sus pruebas juntos, con git rebase --exec antes de publicar y con CI sobre cada commit.

gestor-tareas tiene ya mensajes que explican el porqué y un historial que se puede leer, bisecar y revertir. Pero sigue teniendo dentro cosas que no deberían estar ahí: la carpeta node_modules que Bruno subió sin darse cuenta, los .DS_Store de macOS, los Thumbs.db de Windows y un fichero de configuración con una contraseña.

Lo siguiente es decidir qué no entra nunca en un repositorio, en la lección 08-03: Ignorando Archivos con .gitignore.

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