Esta mañana corrí git branch --no-merged main en el repo de este sitio para encontrar ramas que borrar. Esperaba una o dos rezagadas. Me encontré una lista larga, y cada una de esas ramas ya había llegado a producción. Los commits de squash estaban ahí mismo en origin/main: content: ... (#24), content: ... (#23), feat(brand): ... (#28). El trabajo estaba publicado, pero git insistía en que las ramas que lo produjeron no estaban fusionadas.
Si tu equipo fusiona pull requests con squash merge (la mayoría lo hace), git branch --no-merged te está mintiendo de la misma manera. Aquí va el porqué, y el pequeño snippet con gh que uso ahora en su lugar.
Por qué git branch —no-merged falla con squash merge
Tanto git branch --merged como git branch --no-merged responden exactamente una pregunta: ¿el commit en la punta de esta rama es ancestro de main? Nada más. No comparan diffs, no revisan contenido, pura ascendencia.
Con un merge normal (el que crea un commit de merge), ese modelo funciona. Los commits de tu rama se integran en la historia de main, la punta se convierte en ancestro, y --merged dice la verdad.
El squash merge hace algo fundamentalmente distinto. Cuando le das a “Squash and merge” en GitHub, toma el diff completo de tu PR y crea un único commit nuevo en main, con un hash nuevo. Los commits originales de tu rama nunca entran a la historia de main. Ni uno. El contenido llegó, los commits no.
Así que, desde el punto de vista de git, la punta de tu rama no es ancestro de main y nunca lo será. Según la única medida que --no-merged conoce, esa rama quedará sin fusionar para siempre, aunque cada línea de su trabajo se haya publicado hace semanas.
La lista engañosa, en la práctica
Esto es más o menos lo que vi hoy (nombres recortados):
$ git branch --no-merged main
content/post-validacion-astro
content/post-peso-imagenes
feat/brand-refresh
fix/newsletter-gracias
...Una lista que asusta. Todo publicado. En mi última limpieza real conté 8 ramas locales viejas, y todas, sin excepción, resultaron estar ya fusionadas vía squash.
La trampa empeora cuando intentas actuar. El borrado educado se niega:
$ git branch -d feat/brand-refresh
error: the branch 'feat/brand-refresh' is not fully mergedGit te está protegiendo con la misma verificación de ascendencia que ya vimos que está rota para este flujo. Te quedan dos malas opciones: forzar el borrado con -D a ciegas y rezar para que ninguna de esas ramas tenga trabajo sin publicar, o dejar el cementerio de ramas y verlo crecer. Yo conviví con el cementerio un buen rato. Y crece rápido cuando tienes un agente de IA programando de noche que abre un PR por tarea; una semana de eso y tu lista de ramas locales parece una bodega abandonada.
La solución: pregúntale al forge, no a la ascendencia
Este es el cambio de perspectiva que me lo resolvió: después de un squash merge, “¿esta rama está fusionada?” deja de ser una pregunta para git. Git genuinamente no lo sabe. Pero GitHub sí, porque GitHub ejecutó el merge. Sabe exactamente qué PRs se fusionaron y de qué rama salió cada uno.
Entonces, en vez de preguntarle a git por ascendencia, cruzo mis ramas locales contra las ramas de origen de los PRs fusionados:
# branches whose PR was merged (squash-safe)
gh pr list --state merged --limit 200 --json headRefName --jq '.[].headRefName' > /tmp/merged-branches
for b in $(git branch --format='%(refname:short)' | grep -v '^main$'); do
grep -qxF "$b" /tmp/merged-branches && echo "MERGED, safe to delete: $b"
doneY luego, para cada rama confirmada:
git branch -D feat/brand-refreshSí, -D, el borrado forzado. Después de años de tratar -D como señal de alarma, la primera vez se siente mal. Pero aquí es exactamente lo correcto: la protección de -d mide ascendencia, y la ascendencia es la medida equivocada en un flujo con squash merge. El PR se fusionó, el trabajo está en main, y la rama local es solo una etiqueta sobrante apuntando a commits que ya nadie necesita.
Dos detalles del snippet que vale la pena conocer. --limit 200 cubre todo el historial de PRs de este repo; súbelo en proyectos más viejos. Y grep -qxF compara la línea completa como texto literal, así que feat/foo no puede coincidir con feat/foo-2 por accidente y borrarte algo por una colisión de subcadenas.
Es la misma lección que sigo reaprendiendo con distintos disfraces desde que armé mi stack de desarrollo con IA: una verificación solo merece tu confianza si mide lo que de verdad te importa. La ascendencia siempre fue un proxy de “¿esto ya se publicó?”. El squash merge rompió el proxy, y la respuesta es ir a preguntarle al sistema que tiene el registro real.
La advertencia honesta
Esto es un efecto específico del squash merge (y del rebase merge, que también reescribe los commits sobre main con hashes nuevos). Si tu equipo fusiona con commits de merge normales, la ascendencia se conserva y git branch --merged sigue diciendo la verdad. Ahí no hay nada que cambiar. La razón por la que esta trampa muerde a tanta gente es que el squash es la cultura por defecto en la mayoría de los equipos, elegido por su historia lineal y limpia, y el flujo de limpieza de ramas nunca se actualizó para acompañarlo.
Una suposición más que conviene dejar clara: el snippet dice “el PR de esta rama se fusionó”, no “la punta actual de esta rama coincide con lo que se fusionó”. Si seguiste haciendo commits en una rama después de que su PR se fusionara (yo no lo hago, aquí las ramas mueren al fusionarse, pero hay quien sí), esos commits extra se perderían con -D. En ese flujo, revisa gh pr view <rama> antes de borrar, o simplemente no reutilices nombres de ramas para trabajo nuevo.
Dónde me deja esto
git branch --no-merged salió de mi caja de herramientas de limpieza para cualquier repo que use squash merge, que hoy son todos los míos. No es que sea software roto: es una respuesta correcta a una pregunta que dejó de ser la pregunta correcta. Para “¿qué ramas puedo borrar?”, la fuente de verdad es el forge, y gh pr list --state merged es la forma de preguntarle.
El snippet de arriba ya vive en el historial de mi shell. Ocho ramas menos, cero adivinanzas, y la próxima tanda nocturna de PRs no va a dejar un cementerio atrás.