Git Para Humanos: Merge, Diff y el Flujo de un Git Master

Git Para Humanos: Merge, Diff y el Flujo de un Git Master

Aprende a hacer merges adecuados, leer cambios con git diff y log, y trabajar como un git master de manera divertida, con memes, gifs y ejemplos reales.

Por Omar Flores

Son las 6:47 PM de un viernes. Llevas dos semanas trabajando en una feature. Tu rama tiene 34 commits, tres archivos de configuración tocados, y una refactorización que nadie pidió pero que tenía que hacerse. main se movió mientras tanto. Mucho.

El cursor está sobre el botón de merge.

Tu mano no se mueve.

Un desarrollador sudando frente a la pantalla, decidiendo si presionar el botón

Todos hemos estado ahí. Y la diferencia entre quien presiona ese botón con confianza y quien lo presiona con los ojos cerrados rezando no es la suerte. Es entender qué está pasando adentro de Git cuando ejecutas ese comando, y tener el flujo de trabajo para verificarlo antes.

Esta guía es eso: el conocimiento real, sin filtros de documentación, con la honestidad de quien ha visto merges salir mal en producción. Y con algunos memes, porque si no podemos reírnos de los conflictos de merge, ¿de qué podemos reírnos?


Lo que Realmente Pasa Cuando Ejecutas git merge

Antes de tocar el comando, necesitas entender qué hace Git por dentro. No en términos de objetos DAG — en términos que puedas usar mientras estás en el momento.

Cuando haces git merge feature desde main, Git busca tres puntos en el historial:

  1. El último commit de main (la punta de tu rama actual)
  2. El último commit de feature (la punta de la rama que estás integrando)
  3. El ancestro común — el commit donde las dos ramas se separaron

Con esos tres puntos, Git ejecuta un merge de tres vías. Compara los cambios que main hizo sobre el ancestro, y los cambios que feature hizo sobre el mismo ancestro. Si los cambios son en áreas distintas del código, Git los combina solo. Si los dos tocaron la misma línea, hay conflicto y Git te lo reporta.

Imagínalo como dos personas que modificaron el mismo documento de Google Docs mientras la última versión guardada tenía una versión anterior. Si uno cambió el párrafo 3 y el otro cambió el párrafo 7, el sistema puede hacer el merge solo. Si los dos modificaron el párrafo 3 de formas distintas, alguien tiene que decidir cuál versión queda.

Eso es literalmente lo que hace Git. La diferencia es que Git te deja los dos textos en el archivo para que tú decidas, en lugar de simplemente guardar el último cambio.

    Ancestro común
         |
    D---E (main se separó aquí)
         \
          F---G---H (feature, 3 commits nuevos)
               \
         main: I---J (2 commits nuevos en main mientras trabajabas)

El resultado del merge es un nuevo commit K que tiene a J y a H como padres. El historial queda registrado fielmente: estas dos líneas de trabajo existieron, y en este punto se integraron.


Primero Lee, Luego Mergea

El error más caro que he visto en equipos: hacer merge sin saber qué va a pasar. El comando es dos palabras. La revisión tarda tres minutos. Los tres minutos salvan horas.

El flujo de lectura antes de cualquier merge:

# Paso 1: Trae el estado actual del remote
git fetch origin

# Paso 2: Ve el árbol completo — dónde está cada rama
git log --oneline --graph --all --decorate

# Paso 3: Ve qué tiene tu rama que main no tiene
git log main..feature --oneline

# Paso 4: Ve exactamente qué cambios trae feature
git diff main...feature

Esos cuatro comandos te dan una imagen completa antes de ejecutar el merge. Cuántos commits trae la rama. En qué archivos. Qué líneas cambiaron.

La diferencia entre main..feature (dos puntos) y main...feature (tres puntos) importa aquí. Con dos puntos ves los commits que están en feature pero no en main. Con tres puntos en el contexto de git diff ves los cambios desde el ancestro común — lo que feature hizo diferente desde que se separó. Ese último es el que quieres para revisar antes del merge.

Después de leer, y solo después, decides el tipo de merge. Porque no todos los merges son iguales.


Los Tres Tipos de Merge y Cuándo Usarlos

Git tiene tres estrategias de merge que deberías conocer. Cada equipo y cada proyecto tiene sus propias reglas, pero aquí está el marco para decidir.

Fast-forward es el merge más simple. Ocurre cuando main no avanzó mientras trabajabas en feature. Git simplemente mueve el puntero de main al último commit de feature. No crea un commit de merge. El historial queda lineal.

# Explícito: si no puede hacer fast-forward, falla en lugar de crear merge commit
git merge --ff-only feature

Úsalo cuando: tu rama es corta, personal, y main no se movió. Un fix rápido, una corrección de typo, cambios de un solo desarrollador.

Merge commit (--no-ff) crea un commit de merge aunque el fast-forward sea posible. Preserva el contexto de que hubo una rama separada. El historial muestra claramente “esto fue una feature independiente que se integró aquí”.

# Crea siempre un merge commit, incluso si podría hacer fast-forward
git merge --no-ff feature -m "feat: integrar sistema de pagos con Stripe"

Úsalo cuando: la feature tiene valor como unidad de trabajo identificable. Quieres que el historial refleje el trabajo en paralelo. Es el modo correcto para ramas de features en Gitflow.

Squash merge aplana todos los commits de la feature en uno solo antes de integrarlos. No crea un merge commit. El historial de main queda limpio como si toda la feature hubiera sido un solo commit.

git merge --squash feature
# El squash no hace commit automáticamente, lo haces tú
git commit -m "feat(pagos): implementar flujo completo de Stripe checkout"

Úsalo cuando: la rama tiene commits de “work in progress” que no vale la pena conservar. El historial de main es sagrado y debe ser legible. GitHub lo llama “Squash and merge” en sus PRs.

El error más común que veo: usar fast-forward para todo en un equipo de 8 personas. El historial queda completamente lineal y perdes el contexto de qué se hizo en paralelo con qué. Cuando algo falla en producción dos semanas después, git bisect sobre un historial lineal de 200 commits es una pesadilla.


Cómo Leer el Historial Como un Git Master

Un perro programador diciendo "No tengo idea de lo que hago"

Este es el GIF de todo desarrollador mirando git log por primera vez sin flags adicionales. Una pared de hashes y mensajes, sin estructura visible.

El antídoto es este comando. Memorízalo:

git log --oneline --graph --all --decorate

Lo que hace cada parte:

  • --oneline: una línea por commit, hash abreviado + mensaje
  • --graph: dibuja el árbol de ramas con ASCII art a la izquierda
  • --all: muestra todas las ramas, no solo la actual
  • --decorate: muestra los nombres de las ramas y tags en cada commit

El output se ve así:

* a3f2c1d (HEAD -> main, origin/main) feat: agregar endpoint de pagos
*   b7e91a2 Merge branch 'feature/auth'
|\
| * c4d83b1 feat(auth): implementar refresh tokens
| * 2a91f8e feat(auth): agregar middleware de validación JWT
|/
* e6c74d3 fix: corregir query de búsqueda de usuarios
* 1b892c0 feat: agregar paginación en listado de productos

Ahora puedes ver el árbol real. Qué rama viene de dónde. Dónde se hicieron los merges. Qué está en origin/main vs tu main local.

Para ir más profundo, git log tiene flags adicionales que usan los que saben:

# Ver los cambios de cada commit (el diff completo)
git log -p

# Ver solo qué archivos cambiaron y cuántas líneas
git log --stat

# Ver los commits de un archivo específico — historia de un archivo
git log --follow -p src/handlers/payments.go

# Ver quién modificó qué línea de un archivo y en qué commit
git blame -L 45,60 src/handlers/payments.go

# Ver los commits entre dos puntos específicos
git log v1.2.0..v1.3.0 --oneline

git blame tiene mala reputación pero es una herramienta de entendimiento, no de culpa. Cuando encuentras un fragmento de código que no entiendes, git blame te dice en qué commit se introdujo. Ese commit tiene un mensaje. Ese mensaje (si el equipo escribe buenos commits) te dice el porqué.


El Arte de Leer Diffs

El diff es el corazón de todo en Git. Antes del merge, después del merge, durante el code review. Quien lee diffs bien lee código bien.

Los tres escenarios más comunes:

# ¿Qué cambié desde el último commit?
git diff HEAD

# ¿Qué está listo para commit (staged)?
git diff --staged

# ¿Qué diferencia hay entre dos ramas?
git diff main..feature

# ¿Qué cambió en un commit específico?
git show a3f2c1d

# ¿Qué archivos cambiaron entre dos commits? (sin el contenido)
git diff --name-only HEAD~5 HEAD

# ¿Cuántas líneas cambiaron en cada archivo?
git diff --stat main..feature

El output de git diff tiene un formato que parece intimidante al principio pero se vuelve natural rápido:

diff --git a/src/handlers/payments.go b/src/handlers/payments.go
index 3b4a91f..7c2d8e1 100644
--- a/src/handlers/payments.go
+++ b/src/handlers/payments.go
@@ -45,8 +45,12 @@ func ProcessPayment(w http.ResponseWriter, r *http.Request) {
     if err := validateAmount(req.Amount); err != nil {
         http.Error(w, err.Error(), http.StatusBadRequest)
         return
     }
+
+    if req.Currency == "" {
+        req.Currency = "USD"
+    }
+
     result, err := stripe.Charge(req)

Las líneas con + son lo que se agregó. Las líneas con - son lo que se quitó. El @@ muestra en qué línea del archivo estás. Lo que está sin prefijo es el contexto sin cambios.

Con git show puedes ver cualquier commit en detalle, incluyendo su diff completo:

# Ver un commit específico
git show a3f2c1d

# Ver solo el mensaje sin el diff
git show a3f2c1d --stat

# Ver el estado de un archivo en un commit pasado
git show main:src/handlers/payments.go

Ese último comando es uno de los más infravalorados. Cuando necesitas comparar tu versión actual con cómo estaba un archivo hace tres commits sin hacer checkout, git show es la respuesta.


El Conflicto de Merge: No Es el Fin del Mundo

El perro diciendo "todo está bien" rodeado de fuego

Los conflictos de merge provocan esta reacción en la mayoría de desarrolladores. Pero un conflicto no es un error de Git. Es Git diciéndote: “las dos ramas tocaron el mismo lugar de formas distintas, y yo no soy quien debe decidir cuál versión es correcta. Esa decisión es tuya.”

Cuando tienes un conflicto, el archivo se ve así:

<<<<<<< HEAD
    return c.ValidateCard(card, user.Region)
=======
    return c.ValidateCard(card, user.Country)
>>>>>>> feature/payment-refactor

Todo entre <<<<<<< HEAD y ======= es lo que tiene tu rama actual. Todo entre ======= y >>>>>>> feature/payment-refactor es lo que trae la rama que intentas integrar. Tienes que decidir qué queda, editar el archivo, y borrar los marcadores.

La resolución puede ser quedarte con uno de los dos, combinarlos, o escribir algo completamente nuevo que tenga en cuenta ambos cambios:

// Opción 1: te quedas con HEAD
return c.ValidateCard(card, user.Region)

// Opción 2: te quedas con la feature branch
return c.ValidateCard(card, user.Country)

// Opción 3: los combinas con lógica nueva
field := user.Country
if field == "" {
    field = user.Region
}
return c.ValidateCard(card, field)

Para hacer esto más manejable, configura el estilo de conflicto diff3 que también muestra el ancestro común:

git config --global merge.conflictStyle diff3

Con diff3 el conflicto muestra tres versiones: tu versión, la versión original antes de que ninguno tocara el código, y la versión de la otra rama. Tener el original a la vista hace más fácil entender qué cambió cada lado.

El flujo de resolución:

# Ver todos los archivos con conflictos
git status

# Después de resolver manualmente cada archivo:
git add src/handlers/payments.go

# Cuando todos los conflictos estén resueltos:
git merge --continue
# o simplemente:
git commit

Si en algún momento decides que el merge salió mal y quieres volver atrás:

# Aborta el merge y vuelves al estado antes de empezar
git merge --abort

git merge --abort es tu red de seguridad. No hay situación de conflicto de la que no puedas salir con este comando. Úsalo sin culpa cuando necesitas pensar con más calma.


Rebase: El Arma de Doble Filo

Dos Spidermans señalándose el uno al otro

El debate eterno de cualquier equipo de software: ¿merge o rebase? Ambos producen el mismo resultado final en el código, pero con historiales completamente distintos. Y en Git, el historial es el producto.

git rebase main toma los commits de tu rama y los reaplica encima del último commit de main. En lugar de un merge commit con dos padres, el historial queda como si siempre hubieras trabajado sobre la versión más nueva de main.

Antes del rebase:
main:    D---E---F---G
              \
feature:       A---B---C

Después de git rebase main desde feature:
main:    D---E---F---G
                      \
feature:               A'--B'--C'

Los commits A, B, C se convirtieron en A', B', C' — mismos cambios, distintos hashes, nueva posición en el árbol. El historial queda lineal y limpio.

La regla de oro del rebase, sin excepciones: nunca hagas rebase de ramas que otros desarrolladores tienen en su máquina.

Cuando haces rebase, los commits existentes desaparecen y se crean commits nuevos con hashes distintos. Si tu compañero tiene feature con los commits originales A, B, C, y tú hiciste rebase produciendo A', B', C', cuando tu compañero haga push o pull, Git verá dos historiales divergentes del mismo trabajo. El resultado es el caos.

El rebase es seguro en un solo caso: en tu propia rama local, antes de hacer push por primera vez o antes de abrir un PR.

Para limpiar el historial de tu rama antes de un PR, el rebase interactivo es la herramienta correcta:

# Rebase interactivo sobre los últimos 4 commits
git rebase -i HEAD~4

Esto abre un editor con la lista de commits:

pick a1b2c3 wip: empezando la validación
pick d4e5f6 fix: corrección de typo
pick g7h8i9 wip: continuando validación
pick j0k1l2 feat(pagos): validación completa de tarjetas

# Comandos:
# pick = usar el commit
# squash = combinar con el commit anterior
# reword = cambiar el mensaje
# drop = eliminar el commit

Cambias pick por squash en los commits de “wip”, guardas, y Git los combina en uno. El resultado es un historial limpio antes de que nadie más lo vea.


El Flujo Diario de un Git Master

Un git master no tiene secretos en los comandos. Tiene hábitos. Aquí está el flujo real:

Al empezar el día:

# Trae el estado actual sin modificar tu rama
git fetch origin

# Ve qué se movió overnight
git log --oneline --graph origin/main..origin/HEAD

# Si tu rama principal está desactualizada:
git pull --rebase origin main

git pull --rebase en lugar de git pull evita los merge commits automáticos que Git crea cuando tu rama local y la remota divergieron. El historial queda más limpio.

Antes de abrir un PR:

# Actualiza tu rama con lo más nuevo de main
git fetch origin
git rebase origin/main

# Revisa exactamente qué vas a mandar
git diff origin/main..HEAD

# Revisa que el historial de tus commits sea limpio y descriptivo
git log origin/main..HEAD --oneline

# Si necesitas limpiar commits intermedios:
git rebase -i origin/main

Aliases que cambian la vida. Agrégatelos a tu ~/.gitconfig:

[alias]
    lg = log --oneline --graph --all --decorate
    st = status --short
    co = checkout
    br = branch -vv
    df = diff
    ds = diff --staged
    last = log -1 HEAD --stat
    undo = reset HEAD~1 --mixed

Con estos aliases, git lg te da el árbol completo, git st te da un status compacto, y git undo deshace el último commit sin perder los cambios (los devuelve al working directory).


Los Comandos que Nadie Te Enseñó

Estos cuatro comandos separan a los que sobreviven Git de los que lo dominan.

git reflog — el comando que salva carreras. Cada vez que mueves HEAD en Git, el reflog lo registra. Borrar una rama, hacer un reset, perder commits por un rebase malo — todo recuperable con reflog.

# Ver todo lo que Git recordó que hiciste con HEAD
git reflog

# Recuperar commits "perdidos" por un reset mal hecho
git reflog
# Encuentras el hash del commit que querías
git checkout -b rescue a3f2c1d

git bisect — debug binario en el historial. Cuando tienes un bug que sabes que no existía hace 3 semanas pero no sabes en cuál de los 200 commits intermedios apareció, bisect hace búsqueda binaria en el historial.

git bisect start
git bisect bad                  # el commit actual tiene el bug
git bisect good v2.3.0          # esta versión no tenía el bug

# Git hace checkout en el commit del medio
# Pruebas si el bug existe
git bisect bad  # o git bisect good

# Git hace checkout en el siguiente commit del medio
# Repites hasta que bisect encuentra el commit exacto que introdujo el bug
git bisect reset  # cuando termines

Bisect puede encontrar el commit culpable en 7 pasos sobre un historial de 128 commits. Sin él, eso son 128 revisiones manuales.

git stash — el cajón de “lo termino después”. Cuando necesitas cambiar de rama urgente pero tienes trabajo a medias:

# Guarda el trabajo en progreso
git stash push -m "wip: validación de tarjetas sin terminar"

# Cambia de rama, haz lo urgente, vuelve
git checkout main
# ... trabajo urgente ...
git checkout feature/pagos

# Recupera tu trabajo
git stash pop
# o si tienes varios stashes:
git stash list
git stash apply stash@{2}

git cherry-pick — traer un commit específico de otra rama sin mergear toda la rama. Útil cuando un fix de seguridad que hiciste en develop necesita ir a production antes de que la feature completa esté lista.

# Trae solo el commit de security fix a la rama actual
git cherry-pick a3f2c1d

# Traer múltiples commits
git cherry-pick a3f2c1d b7e91a2 c4d83b1

# Traer un rango de commits
git cherry-pick v2.3.0..v2.3.2

Usa cherry-pick con moderación. Duplica commits en el historial. Si usas cherry-pick con frecuencia, es señal de que el flujo de ramas necesita revisión.


El Alias que Cierra Todo

Antes de irte, un último alias que uso en todos los proyectos. Agrégatelo:

[alias]
    ready = !git fetch origin && git log --oneline --graph origin/main..HEAD && git diff --stat origin/main

git ready antes de cualquier merge o PR: trae el estado actual, te muestra qué commits tienes que main no tiene, y te muestra el resumen de archivos cambiados. Tres segundos de contexto que evitan diez minutos de sorpresas.

El desarrollador senior no presiona el botón de merge con más valor. Lo presiona con más información.


Git no guarda código. Guarda decisiones. Cada commit es una decisión documentada, y cada merge es el momento en que dos líneas de decisiones se vuelven una.