Git rebase, cherry-pick y reescribir la historia
Rebase vs merge con grafos reales, rebase interactivo por script, cherry-pick a release y un force push que borró en silencio el commit de un compañero.
Serie: curso de Git 4/6 (intermedio). Para quien ya sabe crear ramas, hacer merge y push (clases 1–3) y ahora recibe un comentario de code review del tipo "por favor haz rebase sobre main y junta los fixups". Al terminar vas a poder hacer rebase, limpiar una rama desordenada sin abrir un editor, copiar un solo fix a una rama de release y saber cuándo reescribir la historia va a perjudicar a un compañero.
Esta clase es para quien tiene un pull request con ocho commits llamados "WIP" y "fix typo", una rama 30 commits detrás de main, y escuchó que git push --force es peligroso pero no exactamente por qué. Es la situación típica del primer mes en una startup o del proyecto final en equipo de un bootcamp, cuando el líder técnico empieza a pedir una historia limpia. Rebase y cherry-pick son herramientas de "copiar commits a otro lugar"; todo el peligro está en quién más tiene ya los originales. Vamos a mostrar, con salida real, cómo el commit de un compañero desaparece de una rama compartida sin ningún mensaje de error.
Todos los comandos se ejecutaron con Git 2.54.0 (Apple Git-157) en una Mac con Apple Silicon (M1 Pro), en repositorios de prueba con una identidad de demostración y fechas de commit fijas (para que los hashes sean reproducibles), y con un repositorio bare local como "servidor". Las rutas están acortadas a ~/demo/….
Merge contra rebase sobre la misma rama
Preparación: main tiene dos commits; feature/coupon agrega dos commits; mientras tanto alguien agrega un README a main. Traemos el cambio de main a la rama de feature de las dos formas.
git switch -c try-merge feature/coupon
git merge --no-edit main
git log --graph --oneline --all
* a514a17 Merge branch 'main' into try-merge
|\
| * 2f373eb Add README
* | e6de6d4 Validate coupon codes
* | d7647be Add coupon model
|/
* 90e23df Add price list
* 7f32e75 Initial app
El merge conserva las dos líneas de historia y agrega un commit con dos padres. Ahora el rebase:
git switch feature/coupon
git rebase main
git log --graph --oneline feature/coupon
Successfully rebased and updated refs/heads/feature/coupon.
* de4f46d Validate coupon codes
* 7839332 Add coupon model
* 2f373eb Add README
* 90e23df Add price list
* 7f32e75 Initial app
Una línea recta, como si hubieras empezado tu trabajo después del README. Pero mira los IDs: antes del rebase los dos commits de la feature eran d7647be y e6de6d4; después son 7839332 y de4f46d. El rebase no mueve commits. Crea commits nuevos con los mismos cambios y mensajes pero con otro padre y, por lo tanto, con otro hash (la clase 6 muestra por qué el padre forma parte del hash). Los commits viejos siguen existiendo; solo el nombre de la rama apunta ahora a las copias.
Un conflicto de rebase: por qué "ours" y "theirs" están al revés
La rama de feature baja un precio a 90; mientras tanto, main lo subió a 120. El rebase vuelve a aplicar los commits de la feature uno por uno, y el primero choca:
Rebasing (1/2)Auto-merging config.txt
CONFLICT (content): Merge conflict in config.txt
error: could not apply 8286dc4... Discount price to 90
hint: Resolve all conflicts manually, mark them as resolved with
hint: "git add/rm <conflicted_files>", then run "git rebase --continue".
hint: You can instead skip this commit: run "git rebase --skip".
hint: To abort and get back to the state before "git rebase", run "git rebase --abort".
hint: Disable this message with "git config set advice.mergeConflict false"
Could not apply 8286dc4... # Discount price to 90
El archivo muestra HEAD arriba, y aquí está la trampa:
<<<<<<< HEAD
price=120
=======
price=90
>>>>>>> 8286dc4 (Discount price to 90)
tax=10
Durante un rebase, HEAD es la rama sobre la que haces el rebase (main), no tu rama. Lo comprobamos con las opciones abreviadas:
$ git checkout --ours config.txt -> price=120 (main's version)
$ git checkout --theirs config.txt -> price=90 (your commit)
O sea, "quedarse con ours" en un rebase significa "tirar mi cambio". Si resuelves los conflictos con --ours/--theirs por costumbre de merge, vas a descartar en silencio tu propio trabajo. Intentar continuar sin resolver da:
config.txt: needs merge
You must edit all merge conflicts and then
mark them as resolved using git add
Escribimos el valor acordado (price=108), ejecutamos git add config.txt y git rebase --continue; el segundo commit se aplicó sin problemas. Si te pierdes, git rebase --abort devuelve todo al estado anterior al rebase.
Rebase interactivo sin editor
git rebase -i abre una lista de tareas (todo list) en tu editor. En un servidor, en CI o en un script puede no haber un editor utilizable, y en un tutorial no se pueden mostrar teclas, así que lo controlamos con dos variables de entorno:
GIT_SEQUENCE_EDITORes el programa que edita la lista de tareas.GIT_EDITORes el programa que edita los mensajes de commit (lo usanrewordysquash).
Nuestra rama desordenada:
fe6b7fb fix typo in comment
b2fb93e remove debug
6656888 WIP
c8673f4 debug print
87fd1dd Add search
Primero, mira lo que generaría Git usando cat como "editor" (imprime y no cambia nada):
$ GIT_SEQUENCE_EDITOR=cat git rebase -i main
pick 87fd1dd # Add search
pick c8673f4 # debug print
pick 6656888 # WIP
pick b2fb93e # remove debug
pick fe6b7fb # fix typo in comment
Ahora escribe la lista de tareas que quieres. fixup funde un commit en el de arriba y descarta su mensaje; reword conserva el cambio pero pide un mensaje nuevo; las líneas se pueden reordenar:
cat > todo.txt <<'EOF'
pick 87fd1dd Add search
fixup c8673f4 debug print
fixup b2fb93e remove debug
fixup fe6b7fb fix typo in comment
reword 6656888 WIP
EOF
cat > msg.sh <<'EOF'
#!/bin/sh
# git calls this with the commit-message file as $1
printf 'Add search tests\n' > "$1"
EOF
chmod +x msg.sh
GIT_SEQUENCE_EDITOR="cp $PWD/todo.txt" GIT_EDITOR="$PWD/msg.sh" git rebase -i main
Rebasing (2/5)Rebasing (3/5)Rebasing (4/5)Rebasing (5/5)[detached HEAD 43afe11] Add search tests
Date: …
1 file changed, 1 insertion(+)
create mode 100644 test_search.py
Successfully rebased and updated refs/heads/feature/search.
$ git log --oneline main..
43afe11 Add search tests
11d1870 Add search
Cinco commits se volvieron dos, y git diff before-tidy HEAD no imprimió nada: los archivos finales son idénticos byte por byte, solo cambió la historia. Ese diff vacío es la comprobación que siempre deberías hacer después de una limpieza (antes, guarda una rama de respaldo: git branch before-tidy).
Si el "editor" falla, Git se detiene antes de tocar nada:
$ GIT_SEQUENCE_EDITOR=false git rebase -i main
error: there was a problem with the editor 'false'
Commits fixup y --autosquash
Limpiar después es tedioso. El mejor hábito es marcar el arreglo de un commit anterior en el momento en que lo haces:
# found a bug in 11d1870 "Add search"
git add search.py
git commit --fixup=11d1870
GIT_SEQUENCE_EDITOR=true git rebase -i --autosquash main
86ccfa8 fixup! Add search <- before
9655543 Test search_all
...
Successfully rebased and updated refs/heads/feature/search.
67a71fb Test search_all <- after: the fixup! commit is gone,
a31a276 Add search_all its change is now inside "Add search"
3bc14a8 Add search tests
91dd3d7 Add search
GIT_SEQUENCE_EDITOR=true acepta la lista de tareas tal como la ordenó Git: --autosquash ya movió la línea fixup! debajo de su destino. Fíjate en que todos los commits posteriores al corregido recibieron un ID nuevo, no solo el destino.
cherry-pick: copiar un solo fix a una rama de release
develop tiene tres commits: una feature de modo oscuro, un fix del timeout y un cambio de reintentos. La rama de release necesita solo el fix.
$ git switch release/1.0
$ git cherry-pick -x 42758df
[release/1.0 16a5673] Fix: lower timeout to 10s
Date: …
1 file changed, 1 insertion(+), 1 deletion(-)
$ git log -1 --format='%h %s%n%n%b'
16a5673 Fix: lower timeout to 10s
(cherry picked from commit 42758dfeb88d68ecc9d2915678f9377241e34360)
-x agrega el ID original al mensaje, y así es como la gente descubre después que los dos commits son "el mismo fix". Git también puede saberlo comparando los parches: git cherry marca con - los cambios ya aplicados.
$ git cherry -v release/1.0 develop
+ 7226ea7b... Add dark mode flag
- 42758dfe... Fix: lower timeout to 10s
+ 2acd9f81... Retry 3 times
Tres mensajes que te vas a encontrar:
# picking the same commit twice
The previous cherry-pick is now empty, possibly due to conflict resolution.
If you wish to commit it anyway, use:
git commit --allow-empty
Otherwise, please use 'git cherry-pick --skip'
# picking a merge commit
error: commit a8e75a5f... is a merge but no -m option was given.
fatal: cherry-pick failed
# a real conflict (the release branch had changed the same line)
CONFLICT (content): Merge conflict in net.conf
error: could not apply 2acd9f8... Retry 3 times
hint: After resolving the conflicts, mark them with
hint: "git add/rm <pathspec>", then run
hint: "git cherry-pick --continue".
git cherry-pick -m 1 <merge> sí funciona: aplica "todo lo que trajo el merge en relación con su primer padre" como un único commit normal. En una rama de release eso casi nunca es lo que quieres; elige los commits individuales.
Nunca reescribas historia compartida: lo que perdimos de verdad
Reglas como "no hagas rebase de ramas públicas" son fáciles de repetir. Así se ve romperlas. Dos clones, me y mate, comparten feature/login en un remoto bare. El compañero sube un commit de tests. Yo, sin hacer fetch, hago rebase de feature/login sobre un main más nuevo y hago push.
$ git push origin feature/login
! [rejected] feature/login -> feature/login (fetch first)
error: failed to push some refs to '~/demo/remote.git'
hint: Updates were rejected because the remote contains work that you do not
hint: have locally.
Bien, Git nos protege. --force-with-lease también se niega, porque mi referencia de seguimiento remoto está desactualizada:
$ git push --force-with-lease origin feature/login
! [rejected] feature/login -> feature/login (stale info)
Ahora la secuencia más común del mundo: "¿stale info? hago fetch primero", y vuelvo a hacer push.
$ git fetch
$ git push --force-with-lease origin feature/login
+ 0c266b9...114c254 feature/login -> feature/login (forced update)
$ git --git-dir=../remote.git log --oneline feature/login
114c254 Add login
2c0213a Main moves on
98574c9 Initial
El 0c266b9 Teammate: add login test del compañero desapareció de la rama compartida. Ningún error, ninguna advertencia: el lease solo comprueba que el remoto coincida con lo que trajiste en tu último fetch, y lo acabas de traer. Agregar --force-if-includes lo habría detectado, porque además comprueba que la punta remota esté en algún lugar de tu historia local:
$ git push --force-with-lease --force-if-includes origin feature/login
! [rejected] feature/login -> feature/login (remote ref updated since checkout)
Del lado del compañero es peor. Un git pull a secas se detiene con fatal: Need to specify how to reconcile divergent branches. y un bloque de pistas. La respuesta "moderna", git pull --rebase, imprimió éxito:
$ git pull --rebase
Successfully rebased and updated refs/heads/feature/login.
$ git log --oneline -3
114c254 Add login
2c0213a Main moves on
98574c9 Initial
El commit del compañero desapareció también de su propia rama. La causa es la lógica de fork-point: pull --rebase mira el reflog de origin/feature/login, ve que 0c266b9 alguna vez fue "upstream" (lo fue: él lo subió) y supone que el upstream lo quitó a propósito.
$ git reflog show origin/feature/login
114c254 refs/remotes/origin/feature/login@{0}: pull: forced-update
0c266b9 refs/remotes/origin/feature/login@{1}: update by push
Recuperación (habíamos creado una rama de respaldo; sin ella, sirve el reflog de la clase 5):
$ git reset --hard mate-backup
$ git rebase --no-fork-point origin/feature/login
warning: skipped previously applied commit 2b5dd1d
Rebasing (1/1)Successfully rebased and updated refs/heads/feature/login.
94fb347 Teammate: add login test
114c254 Add login
La lección no es "nunca hagas force push". Es: haz force push solo en ramas que nadie más trajo con fetch, y cuando se hizo force push sobre una rama compartida, avísales a quienes la usan, porque sus herramientas pueden descartar trabajo sin decir nada.
Tabla de decisión: ¿rebase, merge o cherry-pick?
| Situación | Usa | Por qué |
|---|---|---|
| Tu rama local está detrás de main y aún no la subiste | git rebase main |
Historia recta; nadie tiene los IDs viejos |
| Tu rama está subida y solo la usas tú | rebase, luego push --force-with-lease --force-if-includes |
Seguro si pasan las comprobaciones de lease e include |
| Otros hicieron pull de tu rama o trabajan encima | git merge main |
Reescribir descartaría o duplicaría su trabajo |
| Necesitas un fix de develop en una rama de release | git cherry-pick -x <id> |
Copia exactamente un cambio y registra el origen |
| Muchos commits chicos de "fix typo" antes de la review | commit --fixup + rebase -i --autosquash |
Review limpia sin editar la lista a mano |
| Llevar a main una feature completa y terminada | merge (o el squash-merge de tu equipo) | Mantiene la feature como unidad, fácil de revertir |
| No estás seguro | merge | Un merge nunca destruye nada |
Medición: rebase de 100 contra 1,000 commits
El rebase vuelve a aplicar los commits uno por uno, así que su costo crece con el largo de tu rama. Pusimos al día una rama de feature de N commits (cada uno agrega una línea a un archivo) con un main que tenía un commit nuevo. Cada ejecución parte de la misma rama (git reset --hard feature-orig); el gc automático estaba desactivado (mira los errores comunes más abajo). Mac con Apple Silicon (M1 Pro), Git 2.54.0, 3 ejecuciones.
| N commits | rebase ejecución 1 / 2 / 3 | merge ejecución 1 / 2 / 3 |
|---|---|---|
| 100 | 0.855 / 0.665 / 0.657 s | 0.075 / 0.069 / 0.067 s |
| 1,000 | 7.901 / 7.271 / 8.669 s | 0.104 / 0.133 / 0.140 s |
El rebase escaló de forma más o menos lineal (unos 7 ms por commit) mientras que el merge se quedó cerca de 0.1 s, y los 100 (o 1,000) commits rebaseados recibieron IDs nuevos. Para las ramas de todos los días, de 5 a 30 commits, el costo es invisible; en una rama de larga vida con cientos de commits, cada commit en conflicto es además una parada aparte, y ese es el costo real.
Errores comunes
Resolver un conflicto de rebase con --ours. Qué pasa: te quedas con el lado de main y descartas tu propio cambio; el rebase termina "con éxito". Solución: recuerda que ours = la rama sobre la que haces el rebase; compruébalo con git diff before-rebase HEAD.
Hacer rebase y luego git pull porque se rechazó el push. Qué pasa: pull hace merge de las copias viejas de tus commits con las nuevas, y cada commit aparece dos veces en el log. Solución: si la rama es solo tuya, push --force-with-lease --force-if-includes; si no, no le hagas rebase.
Errores en benchmarks o scripts por el gc en segundo plano. Nuestra primera medición imprimió error: Could not read 3f472ec… / fatal: revision walk setup failed: un git gc automático que la ejecución anterior dejó corriendo en segundo plano seguía trabajando en un directorio que habíamos borrado y vuelto a crear. En scripts que crean muchos commits rápido, ejecuta git config gc.auto 0 en el repositorio de prueba.
Límites honestos y notas de versión
- Comprobado solo en Git 2.54.0 (Apple Git-157). La redacción
git config set advice…de las pistas viene de versiones nuevas de Git; las versiones anteriores imprimengit config advice…. git switchllegó en Git 2.23;--force-if-includesen Git 2.30; la estrategia de mergeortpor defecto, la que aparece en "Merge made by the 'ort' strategy", desde 2.34.- Que
pullsinpull.rebaseconfigurado se detenga confatal: Need to specify how to reconcile divergent branches.depende de la versión; 2.54 se detiene. - El comportamiento de fork-point de arriba es como funcionan por diseño
git pull --rebaseygit rebasesin un upstream explícito; está documentado en--fork-pointdentro degit rebase --help. - Los tiempos son de una sola computadora con archivos chicos; con archivos grandes o muchos conflictos, el rebase es más lento.
Ejercicios
- Una rama tiene los commits A (el más viejo), B "fix typo in A" y C. Escribe la lista de tareas que convierte B en parte de A y conserva C, y di qué variable de entorno configurarías para aplicarla desde un script.
- Durante
git rebase mainquieres quedarte con tu versión deconfig.txtpara el commit en conflicto. ¿Qué opción le pasas agit checkout?
Respuestas
pick A/fixup B/pick C, aplicada conGIT_SEQUENCE_EDITOR="cp todo.txt"(cualquier programa que escriba el archivo que Git le pasa como argumento). Más fácil todavía: haz el commit B congit commit --fixup=Ay ejecutaGIT_SEQUENCE_EDITOR=true git rebase -i --autosquash main.git checkout --theirs config.txt. En un rebase, "theirs" es el commit que se está volviendo a aplicar, que es el tuyo.
Anterior: Curso de Git 3: remotos, push, pull y fetch · Siguiente: Curso de Git 5: deshacer con reset, revert y reflog · Antes: ramas y conflictos de merge · Vuelve a la ruta de estudio para tu primer trabajo.
Preguntas frecuentes
¿Cuándo usar git rebase y cuándo git merge?
Haz rebase de tu propia rama, todavía no compartida, sobre el main más reciente para obtener una historia en línea recta; usa merge cuando la rama ya está subida y otros pueden haber trabajado encima. El rebase crea IDs de commit nuevos para cada commit que vuelve a aplicar: en nuestra prueba, los 100 commits rebaseados recibieron hashes nuevos, mientras que un merge los dejó intactos y agregó un merge commit.
¿Por qué ours y theirs están al revés en un conflicto de rebase?
Un rebase vuelve a aplicar tus commits encima de la rama de destino, así que HEAD (ours) es la rama de destino y el commit que se está aplicando (theirs) es el tuyo. En nuestra prueba, git checkout --ours dio el price=120 de main y --theirs dio el price=90 de la feature, justo lo contrario de lo que espera la mayoría.
¿git push --force-with-lease es siempre seguro?
No. Solo comprueba que la rama remota siga coincidiendo con tu referencia de seguimiento remoto. Si ejecutaste git fetch después del push de un compañero, el lease coincide y el push sobrescribe su commit; reprodujimos exactamente eso. Agrega --force-if-includes (Git 2.30+), que rechazó el mismo push con 'remote ref updated since checkout'.