23 Reescribir la historia

Los capítulos anteriores mostraron cómo modificar el commit más reciente (commit --amend, see Deshacer cambios). Este capítulo cubre cómo modificar commits que están más atrás en el historial, no solo el último.

23.1 Rebase interactivo

git rebase -i HEAD~5

Abre un editor con una lista de los últimos cinco commits, uno por línea, en orden cronológico (el más antiguo primero), cada uno precedido por una palabra clave que indica qué hacer con él:

pick a1b2c3d Agrega validación de formulario
pick e4f5g6h Corrige typo
pick i7j8k9l Refactoriza el módulo de pagos
pick m1n2o3p Agrega pruebas
pick q4r5s6t Corrige el cálculo de totales

Cambiar la palabra clave de un commit determina qué le ocurre al reaplicarlo:

pick

Aplica el commit sin cambios.

reword

Aplica el commit, pero se detiene para permitir editar su mensaje.

edit

Aplica el commit y detiene el proceso justo después, dejando ese commit como HEAD, para poder modificar su contenido libremente antes de continuar.

squash

Combina el commit con el anterior en la lista, fusionando ambos mensajes para editarlos juntos.

fixup

Igual que squash, pero descarta el mensaje de este commit, conservando solo el del anterior.

drop

Elimina el commit por completo del historial resultante.

También se puede reordenar el historial simplemente cambiando el orden de las líneas antes de guardar: los commits se reaplican en el orden en que aparecen en el archivo.

23.1.1 Editar el contenido de un commit antiguo

Con edit, el proceso se detiene justo después de aplicar ese commit:

pick a1b2c3d Agrega validación de formulario
edit e4f5g6h Corrige typo
pick i7j8k9l Refactoriza el módulo de pagos

En ese punto, HEAD está en el commit e4f5g6h y se puede modificar el contenido libremente: editar archivos, hacer git add, y luego:

git commit --amend
git rebase --continue

Esto reemplaza ese commit específico por una versión corregida, y Git continúa reaplicando automáticamente todos los commits posteriores (i7j8k9l y los que sigan) sobre la nueva versión. Como cada commit incluye el hash de su padre (see El modelo de objetos), modificar un commit intermedio cambia inevitablemente el hash de todos los commits que vienen después, aunque su contenido no se haya tocado: son objetos nuevos, aunque representen los mismos cambios.

23.1.2 Por qué esto es seguro solo localmente

Al igual que con commit --amend, reescribir commits que ya fueron compartidos (subidos con push a un repositorio que otras personas usan) invalida las copias que esas personas tengan descargadas: sus ramas locales quedarán basadas en commits que ya no existen en el remoto, generando conflictos confusos la próxima vez que intenten sincronizar. La convención general (see Rebase frente a merge) es reescribir libremente el historial de ramas propias no compartidas, y evitarlo por completo en ramas ya compartidas —o coordinarlo explícitamente con el equipo si es estrictamente necesario, usando push --force-with-lease (see Repositorios remotos).

23.2 Reescribir historia a gran escala: git filter-repo

El rebase interactivo es adecuado para reescribir un puñado de commits recientes. Para operaciones masivas sobre todo el historial —eliminar un archivo grande que se subió por error hace años, purgar una credencial filtrada de todos los commits, o extraer una carpeta a su propio repositorio— la herramienta recomendada es git filter-repo, un programa externo (no incluido en Git, se instala aparte) que reemplazó al antiguo y más lento git filter-branch.

# Eliminar un archivo de todo el historial
git filter-repo --path secretos.env --invert-paths

# Conservar solo una carpeta, convirtiéndola en la raíz del repositorio
git filter-repo --path src/modulo --path-rename src/modulo/:

filter-repo reescribe todos los commits afectados por el cambio, desde el primero hasta el último: es la forma más drástica de reescritura de historia, y produce un repositorio con hashes de commit completamente distintos a partir del primer commit afectado. Después de una operación así, cualquier copia clonada anteriormente queda irremediablemente desincronizada y debe reclonarse desde cero; no hay forma de "sincronizar" con pull después de una reescritura de esta magnitud.

Importante: eliminar un archivo del historial con filter-repo no revoca una credencial que ya fue expuesta públicamente (por ejemplo, si el repositorio ya fue clonado por terceros, o si el archivo llegó a un remoto público). Reescribir el historial es una limpieza necesaria, pero no sustituye a rotar cualquier secreto que se haya filtrado.