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
- Para qué sirve realmente un historial
- El commit atómico
- Cómo llegar a commits atómicos:
add -pyrebase -i - La decisión de integración: merge, squash o rebase
- Tabla comparativa a fondo
- Qué política encaja con cada flujo
- Recomendación razonada por tipo de proyecto
- Limpiar antes de publicar, no tocar lo publicado
- Commits de fusión: cuándo aportan y cuándo son ruido
- Historial lineal frente a historial con topología
- Los commits de formateo masivo y
.git-blame-ignore-revs - Qué significa que un historial sea "bisecable"
- 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.
- 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:
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 blamesobre cualquier línea deapp.jste lleva a él, aunque esa línea solo cambiara de indentación.- Si el filtro tiene un fallo,
git revertte obliga a revertir también la subida de la dependencia. git bisectlo 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.
- Cómo llegar a commits atómicos:
add -p y rebase -i
add -p y rebase -iLa 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.
@@ -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 restoEse 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).
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.
- 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)
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
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-syncgitGraph
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.
- 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.
- 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.
- 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 unmaindonde algunos aplastaron, otros fusionaron y otros rebasaron: no se puede leer con ninguna herramienta de forma consistente,--first-parentda 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.
- 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--forcea 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-diffde 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.
- 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-134Ese 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.
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:
O, si prefieres que Git te obligue a decidir en vez de elegir por ti:
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.
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 ...
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:
--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 internoEse 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.
- 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.
- Los commits de formateo masivo y
.git-blame-ignore-revs
.git-blame-ignore-revsHay 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:
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:
- 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.
- Que ese commit no cambie el comportamiento. Solo el formateador; nada a mano. Debe poder reproducirse ejecutando la herramienta.
- Dilo en el mensaje, incluida la versión de la herramienta y su configuración, para que sea reproducible.
- 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)
c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2Requisitos: 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-revsCon 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
- 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
- Commits atómicos y completos: apartado 2. Cada uno deja el proyecto funcionando.
- El código y sus pruebas, en el mismo commit.
- 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::"
donegit 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
maines una PR entera, que pasó el CI. La granularidad es gruesa, pero nunca falla. - Merge con
--first-parentda 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:
- ¿Cuántos commits tiene
main? - ¿Qué muestra
git log --oneline --graph? - ¿Qué muestra
git log --oneline --first-parent? - ¿Qué comando exacto usarías para deshacer la integración completa?
- Si
bisectseñ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:
- Crea un
app.jscon tres funciones y confírmalo. - En un solo cambio, modifica una función (cambio funcional), reindenta otra (formateo) y corrige una errata en un comentario de la tercera.
- Usa
git add -p(consyecuando haga falta) para separar los tres en tres commits atómicos con mensajes en Conventional Commits. - Verifica con
git stash push --keep-indexque el primer commit funciona por sí solo. - Comprueba con
git log --oneline --statque cada commit toca solo lo suyo.
Ejercicio 3: rescatar un blame arruinado
- Crea un repositorio con un
estilos.cssde unas 20 líneas, escrito en tres commits de tres autores distintos (usagit -c user.name=... -c user.email=... commit). - Comprueba con
git blame estilos.cssque la autoría es correcta. - Aplica un reformateo masivo (por ejemplo, cambia toda la indentación) y confírmalo como
style: reindenta estilos.css a 2 espacios. - Comprueba el daño con
git blame. - Crea
.git-blame-ignore-revs, configurablame.ignoreRevsFiley demuestra que la autoría original se recupera. - 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 --oneline5d4c3b2 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 --oneline3c2b1a0 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 popEn 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.
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"^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.cssAhora 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 recuperada6. 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 -pantes de confirmar y congit rebase -idespués. - La decisión que dejamos abierta en la 07-04 queda cerrada así:
- Merge
--no-ffcuando 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
mainsea una lista legible de cambios desplegables: GitHub Flow, aplicaciones web, equipos cuyas ramas llegan conwip. 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.
- Merge
- 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-leasey aviso previo. - Los commits de fusión deliberados son información; los accidentales de
git pullson ruido que se elimina conpull.rebase true. Y--first-parentconvierte 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-revscon el SHA completo, activándolo conblame.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 --execantes 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
- ¿Qué es Git?
- Instalando Git
- Terminología Básica de Git
- El Modelo de Datos de Git
- Configurando Git
- Configuración Inicial
Módulo 2: Operaciones Básicas de Git
- Creando un Repositorio
- Clonando un Repositorio
- Flujo de Trabajo Básico de Git
- Preparando y Confirmando Cambios
- Inspeccionando Cambios con git diff
- Visualizando el Historial de Confirmaciones
Módulo 3: Ramas y Fusión
- Entendiendo las Ramas
- Creando y Cambiando Ramas
- Fusionando Ramas
- Estrategias de Fusión
- Resolviendo Conflictos de Fusión
- Gestión de Ramas
Módulo 4: Trabajando con Repositorios Remotos
- Entendiendo los Repositorios Remotos
- Agregando un Repositorio Remoto
- Autenticación con Repositorios Remotos
- Obteniendo y Extrayendo Cambios
- Enviando Cambios
- Rastreando Ramas
Módulo 5: Operaciones Avanzadas de Git
- Rebase
- Rebase Interactivo
- Cherry-Picking de Confirmaciones
- Guardando Cambios Temporales
- Etiquetando Confirmaciones
- Revirtiendo Confirmaciones
Módulo 6: Herramientas y Técnicas de Git
- Usando Git Hooks
- Git Bisect
- Git Blame
- Git Log y Alias
- Submódulos de Git
- Múltiples Copias de Trabajo con git worktree
Módulo 7: Estrategias de Colaboración y Flujo de Trabajo
- Forks y Pull Requests
- Revisiones de Código con Git
- Flujo de Trabajo Git Flow
- GitHub Flow
- Trunk Based Development
- Integración Continua con Git
Módulo 8: Mejores Prácticas y Consejos de Git
- Escribiendo Buenos Mensajes de Confirmación
- Manteniendo un Historial Limpio
- Ignorando Archivos con .gitignore
- Atributos de Fichero con .gitattributes
- Mejores Prácticas de Seguridad
- Consejos de Rendimiento
Módulo 9: Solución de Problemas y Depuración
- Problemas Comunes de Git
- Deshaciendo Cambios
- Resolviendo Divergencias con el Remoto
- Recuperando Confirmaciones Perdidas
- Tratando con Repositorios Corruptos
- Técnicas Avanzadas de Depuración
