Git: rama divergida y conflictos tras squash and merge

Trabajas con ramas permanentes (por ejemplo feature hacia develop, luego qa y por último main) y fusionas cada PR con "Squash and merge". Después de fusionar, aparecen tres síntomas:

  • La rama local figura como divergida de develop aunque el contenido es idéntico.
  • El siguiente PR desde la misma rama lista archivos que ya habías fusionado, o informa conflictos.
  • Un PR de qa hacia main marca conflictos en archivos que main no ha tocado desde la última promoción.

El primer síntoma se ve así en Git. Ocurre, por ejemplo, con una rama creada con git switch -c feature/x origin/develop, que por defecto hereda origin/develop como rama de seguimiento, después de que develop recibió el squash de sus 2 commits:

On branch feature/x
Your branch and 'origin/develop' have diverged,
and have 2 and 1 different commits each, respectively.

Este artículo explica por qué ocurre, cómo comprobar que no se perdió nada y cómo resincronizar la rama para que los PR siguientes vuelvan a mostrar solo tus cambios.

Por qué pasa

Según la documentación de GitHub, "Squash and merge" combina todos los commits del PR en un único commit nuevo sobre la rama base. Ese commit tiene su propio hash y solo un padre: los commits originales de tu rama (F1, F2) nunca llegan a la rama base.

develop:  B ---------------- S     (S: un commit nuevo, sin relación con F1 ni F2)
           \
feature:    F1 -- F2                (siguen siendo commits distintos de S)

El contenido es el mismo, pero para Git son historias distintas. Git no compara commit por commit; al fusionar, según su documentación, solo considera las dos puntas y la base de fusión (el último ancestro común). Tras el squash, esa base sigue siendo B, el punto donde la rama se separó. De ahí salen los síntomas:

  • GitHub muestra en un PR una diferencia de tres puntos: compara la base de fusión con la punta de la rama. Con la base antigua, los cambios ya fusionados vuelven a aparecer. La propia documentación de GitHub avisa de que, si sigues trabajando en la misma rama y abres otro PR hacia la misma base, los commits que ya combinaste se listan de nuevo y puede que debas resolver los mismos conflictos una y otra vez.
  • Si después de la fusión vuelves a editar las mismas líneas, el cambio de la rama base (S) y el tuyo parten de la versión antigua B, así que Git ve dos ediciones distintas de la misma línea y marca conflicto.
  • En develop, qa y main, que se reutilizan en cada ciclo, el efecto se repite en cada promoción: el squash que entra en main nunca aparece en la historia de qa.

Por la misma razón, las herramientas que comparan commits no sirven para saber si algo ya está integrado: en la prueba, git cherry -v origin/develop HEAD marcó con + los dos commits, porque ninguno coincide con el commit de squash. Compara contenido, como en el primer paso de la solución.

Solución

  1. Comprueba que el contenido es idéntico. Justo después de cada squash merge:

    git fetch origin
    git diff origin/develop --stat

    Si no imprime nada, tu rama y develop tienen exactamente los mismos archivos y puedes resincronizar sin perder trabajo. Si imprime archivos, hay cambios locales que aún no están en develop: no resincronices a ciegas.

  2. Resincroniza la rama. Fusiona origin/develop en tu rama:

    git merge origin/develop

    Como ambos lados contienen los mismos cambios, la fusión se completa sin conflictos (en la reproducción local así fue) y la base de fusión pasa a ser la punta de develop. Los PR siguientes solo mostrarán los commits nuevos. Si no necesitas conservar los commits antiguos, también puedes crear la rama de nuevo desde el remoto:

    git switch -c feature/x-2 origin/develop

    git reset --hard origin/develop deja el mismo resultado, pero descarta cualquier cambio local sin guardar; úsalo solo cuando el paso 1 salió vacío y no tienes nada pendiente.

  3. Comprueba la fusionabilidad antes de pedir revisión. En local, git merge-tree simula la fusión sin tocar tu directorio de trabajo ni el índice:

    git merge-tree --write-tree --name-only origin/develop HEAD

    El código de salida es 0 si la fusión es limpia y 1 si hay conflictos; en ese caso imprime los archivos en conflicto. (Según su documentación, no crea commits ni lee o escribe el directorio de trabajo o el índice; solo genera un objeto de árbol. El modo --write-tree existe en versiones recientes de Git; la prueba usó la 2.54.) En GitHub, justo después de abrir el PR:

    gh pr view NUMERO --json mergeable,mergeStateStatus

    Según la referencia de la API GraphQL, MERGEABLE indica que se puede fusionar, CONFLICTING que tiene conflictos y UNKNOWN que aún se está calculando; en mergeStateStatus, CLEAN significa que se puede fusionar y las comprobaciones pasan, y DIRTY que la fusión no se puede crear sin conflictos. Si ves UNKNOWN, repite la consulta unos segundos después.

  4. Para un PR de qa a main con conflictos, crea una rama de sincronización desde main. Necesitas el SHA del commit de qa que se promovió la última vez (por ejemplo, el último commit del PR anterior). En Git Bash o WSL:

    git fetch origin
    ABSORBIDO=SHA_DE_QA_DE_LA_ULTIMA_PROMOCION
    git switch -c sync/qa-main origin/main
    git merge origin/qa
    
    git diff --name-only --diff-filter=U | while IFS= read -r f; do
      actual=$(git rev-parse -q --verify "origin/main:$f")
      absorbido=$(git rev-parse -q --verify "$ABSORBIDO:$f")
      if [ -n "$actual" ] && [ "$actual" = "$absorbido" ]; then
        git checkout --theirs -- "$f" && git add -- "$f"
      else
        echo "REVISAR a mano: $f"
      fi
    done
    git commit --no-edit

    La lógica: si el contenido de un archivo en main es idéntico (mismo hash de blob) al que tenía qa cuando se promovió por última vez, main no lo ha cambiado desde entonces y la versión correcta es la de qa. En una fusión, --theirs es la rama que fusionas (qa). Si los hashes difieren, main tiene cambios propios y debes resolver ese archivo a mano.

    Evita el atajo git merge -X theirs origin/qa: según la documentación, resuelve a favor del otro lado todos los trozos en conflicto, incluidos los que contienen una corrección real hecha en main.

  5. Cambia el método de fusión en las promociones entre ramas permanentes. Mientras develop → qa → main se fusionen con squash, el problema volverá en el ciclo siguiente. Con "Create a merge commit", GitHub añade todos los commits de la rama de origen a la base mediante un commit de fusión, de modo que la base de fusión avanza. Mantén "Squash and merge" para feature → develop, donde la rama se descarta tras fusionar. Esto exige que el repositorio permita ese método de fusión y deja commits de fusión en la historia.

Cómo verificar que quedó resuelto

Tras resincronizar tu rama, el primer número de este comando (commits que solo están en develop) debe ser 0:

git rev-list --left-right --count origin/develop...HEAD
git diff --stat origin/develop...HEAD

El segundo comando es lo que verá el PR: en la reproducción local, antes de resincronizar listaba 2 archivos (uno de ellos ya fusionado por el squash) y, después de resincronizar, solo el archivo del commit nuevo. En el mismo caso, git merge-tree pasó de terminar con código 1 (conflicto en el archivo editado dos veces) a terminar con código 0.

En la rama de sincronización qa → main, comprueba que el árbol resultante coincide con el de qa:

git rev-parse "HEAD^{tree}"
git rev-parse "origin/qa^{tree}"

Los dos hashes deben ser iguales. En PowerShell las comillas son necesarias: sin ellas, HEAD^{tree} falló en la prueba con Windows PowerShell 5.1. Si main recibió cambios que qa no tiene (por ejemplo, una corrección urgente), los árboles diferirán justo en esos archivos; revisa con git diff --stat origin/qa HEAD que solo aparecen ellos. Por último, una vez abierto el PR de sincronización, gh pr view debe mostrar MERGEABLE y, si las comprobaciones pasan, CLEAN. Esa parte depende de GitHub y no se reprodujo en local: la reproducción usó repositorios locales que simulan el squash con git merge --squash.

Variantes y errores relacionados

error: the branch 'feature/x' is not fully merged

Después del squash, si la rama remota se borró (y se ejecutó git fetch --prune), git branch -d feature/x responde con ese error aunque el contenido esté en develop: así ocurrió en la prueba con Git 2.54, con develop actualizada y como rama actual. Comprueba primero que git diff develop feature/x --stat no imprime nada y entonces usa git branch -D feature/x.

Conflictos de tipo "modificado/eliminado"

Si un archivo se creó o se borró en uno de los lados, git rev-parse -q --verify no devuelve ningún hash para él. El script lo marca como "REVISAR a mano" en lugar de tomar una decisión por ti.

El mismo problema en develop → qa

No es exclusivo de main. Cualquier promoción por squash entre dos ramas que siguen vivas acumula la misma divergencia; la receta de la rama de sincronización sirve para cualquier par de ramas.

Resumen

  • "Squash and merge" crea un commit nuevo en la rama base; tus commits originales no entran en su historia, aunque el contenido sea idéntico.
  • Tras cada squash, ejecuta git fetch origin y git diff origin/develop --stat: vacío significa mismo contenido.
  • Resincroniza enseguida con git merge origin/develop (o recrea la rama desde el remoto) para que los PR muestren solo tus archivos.
  • Para qa → main, fusiona origin/qa en una rama creada desde origin/main y resuelve por archivo comparando hashes de blob; verifica con los hashes de árbol.
  • Para romper el ciclo, usa "Create a merge commit" en las promociones entre ramas permanentes.

Referencias

Comentarios