Cómo Corregir Commits Antiguos con git history
git history en Git 2.55 reescribe commits antiguos con fixup, reword o split, actualiza ramas apiladas y muestra vistas previas seguras con dry-run.
El comando experimental git history reescribe un único commit antiguo, incorporando cambios preparados en el índice (staged), reemplazando su mensaje o dividiéndolo en dos, y desplaza todas las ramas locales construidas sobre él, sin necesidad de iniciar un rebase interactivo.
Si mantienes varias ramas apiladas unas sobre otras, es probable que reconozcas el escenario de fallo que esto reemplaza: detectas una errata tres commits más abajo, inicias un rebase -i, te topas con un conflicto en un commit que nunca pretendías tocar y, de repente, estás a mitad de un rebase con un HEAD desasociado (detached) y una decisión que tomar. git history está diseñado para evitar que acabes en esa situación. Sus subcomandos comparten una garantía: la operación se completa o aborta con un error sin modificar nada.
Este artículo recorre los tres subcomandos disponibles en Git 2.55 con ejemplos de salida, muestra el flag --dry-run para previsualizar una reescritura antes de que nada se mueva, y cubre sus limitaciones: no admite historiales con merges, ni operaciones que generen conflictos, ni hooks.
Puntos Clave
- En Git 2.55,
git historytiene tres subcomandos:fixupincorpora cambios preparados en un commit antiguo,rewordreemplaza su mensaje ysplitlo divide en dos. - Cada operación es atómica: si la reescritura produjera un conflicto en cualquier punto del historial reproducido, el comando aborta y no modifica nada.
- Por defecto, se actualizan todas las ramas locales que descienden del commit reescrito;
--update-refs=headmueve únicamente el HEAD actual. --dry-runno mueve nada y, en su lugar, imprime las actualizaciones de referencias para quegit update-reflas aplique más adelante, lo que constituye la forma segura de previsualizar una reescritura.- El comando es experimental, no ejecuta hooks y rechaza historiales que contengan merges; en esos casos, utiliza
git rebase --rebase-merges.
¿Por Qué Existe el Comando git history?
git history existe para corregir un commit antiguo sin la ceremonia ni el riesgo de un rebase interactivo. El manual oficial lo contrapone a git rebase: menos opciones y un comando más sencillo al que recurrir cuando la edición es acotada. También lleva la etiqueta de experimental, por lo que sus flags y su comportamiento pueden cambiar entre versiones.
Dos propiedades lo hacen digno de aprender. La primera es la atomicidad: cualquier operación que pudiera terminar en un conflicto de merge simplemente no se ofrece. Se trata de una decisión deliberada, porque el comando trata la reescritura como un único intento en lugar de una sesión que se recorre paso a paso. No hay --continue, no hay --abort ni ningún estado a medio terminar del que recuperarse. La segunda es el alcance: por defecto actualiza todas las ramas locales que apuntan a descendientes del commit reescrito, que es exactamente lo que necesita un flujo de trabajo con ramas apiladas.
El comando llegó en dos etapas. reword y split se incorporaron en Git 2.54; Git 2.55 añadió fixup. Eso suma tres subcomandos a partir de la versión 2.55, que es la que cubre este artículo:
| Subcomando (Git 2.55) | Qué hace | Funciona en un repositorio bare |
|---|---|---|
git history fixup | Incorpora cambios preparados en un commit antiguo | No, porque lee el índice |
git history reword | Reemplaza el mensaje de un commit antiguo | Sí |
git history split | Divide un commit en dos | Sí |
Es de esperar que esa lista crezca. El manual en desarrollo de git history ya documenta un cuarto subcomando, drop, que elimina un commit y reproduce sus descendientes sobre su padre, así que consulta git history -h en tu propia versión antes de dar por hecho que tres es el conjunto completo.
fixup: Incorporar Cambios Preparados en un Commit Antiguo
git history fixup <commit> toma todo lo que esté preparado en el índice y lo integra en el commit objetivo. Por debajo, se trata de un merge a tres bandas, con HEAD, el commit objetivo y un árbol construido a partir de tus cambios preparados como las tres entradas. El objetivo conserva su mensaje y su autor originales, a menos que solicites un editor con --reedit-message. Este es el equivalente moral de git commit --fixup seguido de git rebase --autosquash, comprimido en un solo paso que no puede dejarte varado.
Supongamos que un cargador de configuración se integró hace dos commits sin un valor por defecto, y que hay una rama de funcionalidad apilada encima:
$ git log --oneline --branches
c41f9e2 (feature/retries) add retry logic
7b2d8a0 (HEAD -> main) add http client
3e59c11 add config loader
$ git add src/config.js
$ git history fixup 3e59c11
$ git log --oneline --branches
f8a01d3 (feature/retries) add retry logic
92c6b7e (HEAD -> main) add http client
5d40e19 add config loader
Los tres hashes cambiaron, y tanto main como feature/retries apuntan ahora al historial reescrito. Ese es el valor por defecto --update-refs=branches en acción: se mueven todas las ramas locales que descienden del objetivo, no solo aquella en la que tienes el checkout. Pasa --update-refs=head para mover únicamente HEAD. Esto llega más lejos que git rebase --update-refs, que solo actualiza las referencias que apuntan a commits dentro del rango que se está rebasando.
Antes de ejecutar esto sobre una pila que te importe, añade --dry-run. Ninguna referencia se mueve. Lo que obtienes en su lugar es una lista impresa de los movimientos que habría realizado, presentada de forma que puedas pasársela a git update-ref más adelante. Git sigue escribiendo los objetos nuevos que necesitaba, razón por la cual reproducir esa lista después normalmente funciona sin problemas. Es la forma honesta de ver exactamente qué ramas tocará una reescritura antes de comprometerte con ella.
Fíjate en la diferencia de alcance respecto a enmendar (amend): git commit --amend solo alcanza a HEAD, mientras que fixup alcanza cualquier commit de tu historial lineal. Es además el único subcomando que necesita tu índice, razón por la cual no puede ejecutarse en un repositorio bare como sí lo hacen los otros dos.
reword: Reemplazar el Mensaje de un Commit Antiguo
git history reword <commit> cambia una sola cosa del commit objetivo: su mensaje. Todo lo demás del commit se conserva, cada descendiente se reproduce encima y las referencias de rama lo siguen.
$ git history reword 7b2d8a0
Tu editor se abre precargado con el mensaje actual, add http client. Guarda uno mejor y la pila se reconstruye:
$ git log --oneline --branches
3ba90cf (feature/retries) add retry logic
a1d27e8 (HEAD -> main) add http client with timeout handling
5d40e19 add config loader
Dado que reword no toca ni el índice ni el árbol de trabajo, funciona perfectamente en un repositorio bare. También puedes corregir el mensaje de una rama en la que no tienes el checkout, sin alterar aquello en lo que estés trabajando en ese momento.
split: Convertir un Commit en Dos
git history split <commit> te guía hunk a hunk por el diff que introdujo ese commit. Todo lo que selecciones va a parar a un commit nuevo, que se inserta por debajo del original como su nuevo padre. El original conserva los hunks que dejaste atrás. Seleccionar todos los hunks, o ninguno, se rechaza: cualquiera de las dos opciones dejaría uno de los dos commits vacío.
Dado un commit que mezclaba un limitador de tasa con código de métricas no relacionado:
$ git history split 91b04c7
Responde y a los hunks de métricas y n a los del limitador. El editor solicita ambos mensajes de commit, la autoría se mantiene igual a la del original y el resultado es un par limpio:
$ git log --oneline
e7d3f21 (HEAD -> main) add rate limiter
b19c8a4 add request metrics
Un pathspec al final (git history split 91b04c7 -- src/metrics.js) acota la división a los archivos que indiques. Todo lo que quede fuera de esa lista permanece donde está, en el commit original. Al igual que reword, split opera puramente sobre el grafo de commits y funciona en un repositorio bare.
¿Qué No Hará git history?
La sección LIMITATIONS del manual es breve y conviene tomarla al pie de la letra. Los merge commits quedan fuera de alcance: si el historial que quieres reescribir contiene alguno, el manual te remite a git rebase --rebase-merges. Cualquier cosa que pudiera terminar en un conflicto se rechaza, una restricción que podría relajarse si Git llegara a incorporar conflictos de primera clase. Los hooks tampoco se ejecutan hoy en día, y el manual deja abierta la puerta a que eso cambie.
fixup tiene un caso límite adicional gestionado por --empty=(drop|keep|abort). Aquí hay dos vías por las que puede surgir un commit vacío. Tu corrección preparada podría anular por completo el commit objetivo, o bien un commit posterior podría ya contener el mismo cambio que acabas de empujar hacia un ancestro. drop es el valor por defecto y descarta esos commits mientras se reconstruye el historial; keep los mantiene en su lugar; abort detiene el comando con un error en lugar de decidir por ti. Eliminar el commit raíz aún no está soportado.
¿Cuándo Deberías Recurrir a rebase en Su Lugar?
git history edita un solo commit; git rebase sigue siendo la herramienta para todo lo demás. Rebase es la respuesta cuando todo un rango de commits necesita una nueva base, y el rebase interactivo cuando quieres repasar varios commits de una sentada. Y si lo que realmente buscas es una herramienta que arrastre estados en conflicto a lo largo de un rebase para resolverlos más tarde, eso es el modelo de conflictos de primera clase de jj, algo que git history deliberadamente no intenta abordar.
Conclusión
Para la edición de historial más habitual —corregir un commit que tiene ramas apiladas encima—, git history reemplaza un arriesgado rebase interactivo por una operación que o bien tiene éxito por completo, o bien se niega a ejecutarse. Elige una pila real, ejecuta git history fixup <commit> --dry-run sobre ella y lee las actualizaciones de referencias que habría realizado. Ese experimento de cinco minutos te dirá si este comando merece un sitio en tu flujo de trabajo diario. Eso sí, ten presente la etiqueta de experimental mientras lo decides, porque los flags y el comportamiento aún pueden cambiar de una versión a otra.
Preguntas Frecuentes
¿Cuál es la diferencia entre git history fixup y git commit --fixup con autosquash?
git history fixup incorpora los cambios preparados en el commit objetivo en un único paso atómico. git commit --fixup solo registra un commit fixup! que un posterior git rebase --autosquash debe aplastar (squash), y ese rebase interactivo puede detenerse a mitad de camino por conflictos. git history fixup además mueve por defecto todas las ramas locales descendientes, y aborta sin modificar nada si la reescritura produjera un conflicto.
¿Actualiza git history las ramas remotas o los commits que ya he subido?
No. git history actualiza únicamente ramas locales; las referencias de seguimiento remoto (remote-tracking) quedan intactas. Dado que los commits reescritos reciben nuevos hashes, los commits ya subidos requieren un force-push (git push --force-with-lease) y coordinación con cualquiera que haya hecho pull del historial antiguo, igual que con cualquier reescritura. Es más seguro usarlo en commits que aún no se han subido, o en ramas en las que solo trabajas tú.
¿Cómo deshago una reescritura hecha con git history?
Usa el reflog. git history mueve las referencias de rama a nuevos commits, pero los commits originales permanecen en el repositorio y, en un repositorio normal (no bare), cada rama movida registra la actualización en su reflog. Ejecuta git reflog show con el nombre de la rama para encontrar el hash previo a la reescritura, y luego restáuralo con git reset --hard en una rama con checkout, o con git update-ref en caso contrario.
¿Puede git history eliminar un commit por completo?
No en Git 2.55. Sus tres subcomandos (fixup, reword, split) modifican o dividen un commit, pero no pueden eliminar ninguno. Para borrar un commit hoy, usa git rebase -i y marca el commit como 'drop', o git rebase --onto para omitirlo. Un subcomando drop aparece en la documentación en desarrollo de Git para git history, así que podría llegar una opción nativa en una versión futura.