Reescritura del Historial de Commits
Reescribe el historial de commits de un repositorio existente para que cumpla con commits convencionales. Analiza commits, los clasifica por tipo, identifica candidatos a squash y ejecuta la reescritura con barreras de seguridad completas.
Descripción General
El historial es documentación. Cada mensaje de commit en la rama main es un registro permanente que futuros ingenieros (y agentes) utilizan para entender qué cambió y por qué. Mensajes como "arreglo", "actualización", "wip" destruyen esta documentación. Release-please analiza los mensajes de commit para determinar incrementos de versión; los mensajes no conformes producen versiones incorrectas o ninguna.
El código es una responsabilidad. La ley de Hyrum también se aplica al historial de git: con suficientes herramientas que dependen de mensajes de commit (changelogs, automatización de versiones, git bisect, git blame), el formato de esos mensajes se convierte en un contrato. Limpiar el historial antes de adoptar automatización evita que los mensajes antiguos contaminen todas las herramientas posteriores.
Esta habilidad reescribe los mensajes, no el código. El árbol de archivos debe ser idéntico antes y después; solo cambian los metadatos del commit.
Cuándo Usar
- Adoptar commits convencionales en un repositorio con historial existente no conforme
- El registro de commits está contaminado con mensajes WIP, fixup, fusión o sin sentido
- Preparar un repositorio para release-please (que necesita commits convencionales para funcionar)
- Limpiar antes de hacer público o transferir un repositorio
Cuándo NO Usar
- El repositorio ya se ha publicado con commits convencionales (no hay nada que reescribir)
- Ramas compartidas con colaboradores activos que no han sido notificados
- Solo necesitas corregir los últimos commits (usa
git rebase -i HEAD~Ndirectamente) - El repositorio es un fork donde el upstream controla el formato de commits
Proceso
Paso 1: Evaluar el Repositorio
# Contar commits totales
git rev-list --count HEAD
# Mostrar resumen de mensajes de commit
git log --oneline --no-merges
# Verificar colaboradores (diversidad de autoría)
git shortlog -sne --no-merges
# Identificar densidad de commits de fusión
git log --merges --oneline | wc -l
Punto de decisión: Si el número de commits < 50, usa rebase interactivo. Si >= 50, usa git-filter-repo.
Paso 2: Clasificar Commits Existentes
Analiza cada commit usando dos señales: el texto del mensaje y los archivos modificados. Cuando el mensaje es ambiguo (por ejemplo, "actualizar cosas"), el diff es la fuente de verdad.
Señal 1: Contenido del Mensaje
| Señal en el mensaje | Tipo Asignado |
|---|---|
| "add", "new", "implement", "create", "introduce", "support" | feat |
| "fix", "bug", "patch", "resolve", "correct", "handle error" | fix |
| "refactor", "restructure", "reorganize", "simplify", "extract", "move" | refactor |
| "update dep", "bump", "upgrade", "version", "config", "setup" | chore |
| "test", "spec", "coverage", "assert" | test |
| "doc", "readme", "comment", "typo in doc" | docs |
| "ci", "workflow", "pipeline", "action", "deploy" | ci |
Señal 2: Archivos Modificados (cuando el mensaje es ambiguo)
git show --stat --format="" <hash>
| Patrón de archivo | Tipo Asignado |
|---|---|
Solo *.md, docs/** | docs |
Solo *test*, *spec*, __tests__/** | test |
Solo .github/workflows/**, .gitlab-ci.yml, Jenkinsfile | ci |
Solo package.json, *.lock, requirements.txt, Cargo.toml | chore |
Solo archivos de configuración (.eslintrc, tsconfig.json, .prettierrc) | chore |
Candidatos a Squash
| Señal | Acción |
|---|---|
| El mensaje es "WIP", "wip", "temp", "save", "checkpoint", una sola palabra o vacío | hacer squash en el commit significativo más cercano |
| El mensaje es "fixup", "fix typo", "oops", "lint", "formato" | hacer squash en el anterior |
| Commits de fusión (de fusiones no squash) | candidato a squash — evaluar si el contenido ya está en main |
Para commits ambiguos, revisa el diff completo (git show <hash>) para determinar el tipo a partir del contenido.
Paso 3: Escribir Mensajes de Calidad
Los mensajes reescritos deben seguir los estándares de mensajes de commit:
<tipo>[ámbito opcional]: <descripción imperativa corta>
[cuerpo opcional explicando POR QUÉ, no qué]
Buenas reescrituras:
"configuración inicial" → "chore: configuración inicial del proyecto"
"agregado inicio de sesión" → "feat: agregar inicio de sesión de usuario"
"corregir error" → "fix: resolver bucle de redirección de inicio de sesión"
"Actualizar README.md" → "docs: actualizar README con instrucciones de configuración"
"dependencias actualizadas" → "chore(deps): actualizar dependencias"
Malas reescrituras (no hagas esto):
"cosas" → "chore: cosas" ← sigue sin significado
"cambios" → "feat: cambios" ← sin descripción
"arreglo" → "fix: arreglo" ← redundante
Cuando el mensaje original no tiene significado, lee el diff para escribir una descripción adecuada. Si el diff es demasiado grande o ambiguo, pregunta al usuario de qué trataba el commit.
Paso 4: Presentar el Plan de Reescritura
Antes de cualquier acción destructiva, muestra al usuario un plan completo:
PLAN DE REESCRITURA
====================
Commits totales: 47
Commits a reescribir: 32
Commits a squashear: 8
Commits ya conformes: 7
Autores preservados: 3 (Alice <alice@co>, Bob <bob@co>, CI Bot <bot@co>)
CAMBIOS PROPUESTOS:
abc1234 "configuración inicial" → "chore: configuración inicial del proyecto"
def5678 "agregado inicio de sesión" → "feat: agregar inicio de sesión de usuario"
ghi9012 "wip" → [SQUASH en def5678]
jkl3456 "corregir error" → "fix: resolver bucle de redirección de inicio de sesión"
mno7890 "Actualizar README.md" → "docs: actualizar README con instrucciones de configuración"
pqr1234 "feat: agregar búsqueda" → [MANTENER — ya conforme]
...
SEGURIDAD:
✓ Rama de respaldo: backup/pre-rewrite-<marca de tiempo>
⚠ Se requiere push forzado después de la reescritura
⚠ Todos los colaboradores deben reclonar o reiniciar después del push
SIN CAMBIOS:
- Árbol de archivos (cero cambios de contenido)
- Nombre, correo electrónico y fecha del autor en cada commit
- Orden de commits y relaciones padre (excepto commits squasheados)
Espera la aprobación explícita del usuario antes de continuar. No continúes con "suena bien" — se requiere un "sí" o equivalente.
Paso 5: Crear Respaldo
# Crear una rama de respaldo en el HEAD actual
git branch "backup/pre-rewrite-$(date +%Y%m%d-%H%M%S)"
# Verificar que el respaldo existe
git branch -l 'backup/*'
# Opcionalmente, enviar el respaldo al remoto para mayor seguridad
git push origin "backup/pre-rewrite-$(date +%Y%m%d-%H%M%S)"
Paso 6: Ejecutar la Reescritura
Opción A: Rebase Interactivo (< 50 commits)
# Rebase desde la raíz
git rebase -i --root
En el editor:
- Marca los candidatos a squash con
squashofixup - Marca las reescrituras de mensajes con
reword - Preserva el orden de los commits
Preservación de autoría: El rebase interactivo preserva el nombre, correo electrónico y fecha del autor de forma predeterminada. NO uses --reset-author. NO uses --committer-date-is-author-date a menos que se solicite explícitamente.
Opción B: git-filter-repo (>= 50 commits)
Crea un script Python de callback (rewrite-messages.py) que asigna mensajes antiguos a nuevos:
import re
REESCRITURAS = {
b'prefijo hash antiguo': b'mensaje nuevo',
# ... generado a partir del plan
}
def reescribir(commit, metadata):
# Buscar por mensaje original o hash
if commit.original_id in REESCRITURAS:
commit.message = REESCRITURAS[commit.original_id]
Aplica con:
git filter-repo --commit-callback "$(cat rewrite-messages.py)" --force
Preservación de autoría: git-filter-repo preserva la autoría de forma predeterminada. NO uses --mailmap ni banderas de reescritura de autor a menos que el usuario lo solicite explícitamente.
Squash de Commits
Para candidatos a squash, combínalos en el commit significativo más cercano. El commit resultante:
- Usa el mensaje de commit convencional del commit significativo
- Preserva el autor original del commit significativo (no el objetivo del squash)
- Combina todos los cambios del rango squasheado
Paso 7: Verificar
Ejecuta todas las comprobaciones de verificación. Cada comprobación debe pasar antes de proceder al push.
# 1. Revisar el registro reescrito
git log --oneline --no-merges
# 2. Validar que TODOS los mensajes cumplan con commits convencionales
FALLOS=$(git log --format="%s" --no-merges | grep -cvE "^(feat|fix|docs|chore|refactor|test|ci)(\(.+\))?!?: .+")
if [[ "$FALLOS" -gt 0 ]]; then
echo "FALLO: $FALLOS commits no conformes encontrados:"
git log --format="%h %s" --no-merges | grep -vE "^[a-f0-9]+ (feat|fix|docs|chore|refactor|test|ci)(\(.+\))?!?: .+"
exit 1
fi
echo "PASA: todos los commits son conformes"
# 3. Verificar CERO cambios de contenido (crítico — esto demuestra que solo cambiaron los mensajes)
DIFF=$(git diff "backup/pre-rewrite-"*..HEAD --stat)
if [[ -n "$DIFF" ]]; then
echo "FALLO: se detectaron diferencias de contenido — la reescritura cambió código, no solo mensajes"
echo "$DIFF"
exit 1
fi
echo "PASA: árbol de archivos idéntico"
# 4. Verificar preservación de autoría
echo "=== Autores antes ==="
git -C . log backup/pre-rewrite-* --format="%an <%ae>" --no-merges | sort -u
echo "=== Autores después ==="
git log --format="%an <%ae>" --no-merges | sort -u
# Estas dos listas deben ser idénticas
Si alguna comprobación falla, NO hagas push. Restablece al respaldo e investiga:
git reset --hard backup/pre-rewrite-*
Paso 8: Push (con Confirmación)
Esto es destructivo para los colaboradores remotos. Confirma con el usuario:
⚠ ADVERTENCIA DE PUSH FORZADO
Rama: main
Remoto: origin
Acción: git push --force-with-lease origin main
Después de este push, todos los colaboradores deben ejecutar:
git fetch origin
git reset --hard origin/main
Escribe "sí" para continuar:
git push --force-with-lease origin main
Usa --force-with-lease (no --force) para evitar sobrescribir cambios remotos concurrentes. Si --force-with-lease es rechazado, alguien ha hecho push a la rama desde la reescritura; investiga antes de reintentar.
Limpieza Posterior a la Reescritura
Después del push exitoso y la notificación a los colaboradores:
# Verificar que el remoto coincida con el local
git log --oneline -5
git log --oneline -5 origin/main
# Estos deben ser idénticos
# Mantén la rama de respaldo durante al menos 2 semanas, luego elimínala
# git branch -D backup/pre-rewrite-*
# git push origin --delete backup/pre-rewrite-*
Racionalizaciones
| Racionalización | Realidad |
|---|---|
| "El historial antiguo no importa" | Release-please analiza los mensajes de commit para determinar versiones. Un historial no conforme produce versiones incorrectas o ninguna. git bisect, git blame y git log --grep dependen de mensajes significativos. |
| "Empezaré de nuevo desde este commit" | Pierdes autoría, atribución y contexto. Una rama --orphan destruye la narrativa del desarrollo. Reescribir preserva todo excepto los malos mensajes. |
| "Es demasiado arriesgado reescribir" | La rama de respaldo hace que esto sea completamente reversible. Verifica que el diff muestre cero cambios de contenido. Si algo sale mal: git reset --hard backup/pre-rewrite-*. |
| "Reescribiré más tarde cuando sea importante" | Las reescrituras de historial se vuelven más difíciles con el tiempo a medida que más personas clonan y crean ramas a partir de él. Hazlo ahora, antes de que existan más dependencias posteriores. |
| "El push forzado es peligroso" | --force-with-lease protege contra sobrescribir el trabajo de otros. Combinado con la rama de respaldo, esto es seguro. |
| "Los mensajes son suficientemente cercanos" | "Suficientemente cercano" significa que release-please no los analizará. Los commits convencionales son un formato estricto: o un mensaje es conforme o no lo es. |
| "Puedo etiquetar versiones manualmente" | Las etiquetas manuales se desvían, se olvidan y carecen de integración con changelog. El objetivo es la automatización, pero la automatización necesita entrada analizable. |
Banderas Rojas
- Omitir la creación de la rama de respaldo
- Usar
--forceen lugar de--force-with-lease - Reescribir una rama en la que otros están trabajando activamente sin notificación
- Diferencias de contenido que aparecen en el diff del Paso 7 (significa que la reescritura cambió código, no solo mensajes)
- Recuentos de autores que cambian después de la reescritura (significa que la autoría no se preservó)
- Proceder sin la aprobación del usuario del plan de reescritura
- Mensajes reescritos que siguen sin significado (por ejemplo,
"chore: cosas") - Squash de commits de diferentes autores sin discusión
- No mantener la rama de respaldo durante al menos 2 semanas después de la reescritura