Réécriture d'historique de commits
Réécrire l'historique des commits d'un dépôt existant pour le conformer aux commits conventionnels. Analyse les commits, les classifie par type, identifie les candidats à l'écrasement (squash) et exécute la réécriture avec des garde-fous complets.
Aperçu
L'historique est une documentation. Chaque message de commit sur main est un enregistrement permanent que les futurs ingénieurs (et agents) utiliseront pour comprendre ce qui a changé et pourquoi. Des messages comme "fix", "update", "wip" détruisent cette documentation. Release-please analyse les messages de commit pour déterminer les incréments de version — des messages non conformes produisent des versions erronées ou aucune sortie.
Le code est un passif. La loi de Hyrum s'applique aussi à l'historique git : avec suffisamment d'outils dépendant des messages de commit (changelogs, automatisation des versions, git bisect, git blame), le format de ces messages devient un contrat. Nettoyer l'historique avant d'adopter l'automatisation empêche les anciens messages de polluer tous les outils en aval.
Cette compétence réécrit les messages, pas le code. L'arborescence des fichiers doit être identique avant et après — seules les métadonnées des commits changent.
Quand l'utiliser
- Adoption de commits conventionnels dans un dépôt avec un historique non conforme existant
- Le journal des commits est pollué par des messages WIP, fixup, merge ou insignifiants
- Préparation d'un dépôt pour release-please (qui nécessite des commits conventionnels pour fonctionner)
- Nettoyage avant l'open-sourcing ou le transfert d'un dépôt
Quand NE PAS l'utiliser
- Le dépôt a déjà été publié avec des commits conventionnels (rien à réécrire)
- Branches partagées avec des collaborateurs actifs qui n'ont pas été notifiés
- Vous avez seulement besoin de corriger les quelques derniers commits (utilisez
git rebase -i HEAD~Ndirectement) - Le dépôt est un fork où l'amont contrôle le format des commits
Processus
Étape 1 : Évaluer le dépôt
# Compter le nombre total de commits
git rev-list --count HEAD
# Afficher un résumé des messages de commit
git log --oneline --no-merges
# Vérifier les collaborateurs (diversité des auteurs)
git shortlog -sne --no-merges
# Identifier la densité des commits de merge
git log --merges --oneline | wc -l
Point de décision : Si le nombre de commits < 50, utilisez le rebase interactif. Si >= 50, utilisez git-filter-repo.
Étape 2 : Classifier les commits existants
Analysez chaque commit en utilisant deux signaux : le texte du message et les fichiers modifiés. Lorsque le message est ambigu (par exemple, "update stuff"), la diff est la source de vérité.
Signal 1 : Contenu du message
| Signal dans le message | Type assigné |
|---|---|
| "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 |
Signal 2 : Fichiers modifiés (lorsque le message est ambigu)
git show --stat --format="" <hash>
| Motif de fichier | Type assigné |
|---|---|
Uniquement *.md, docs/** | docs |
Uniquement *test*, *spec*, __tests__/** | test |
Uniquement .github/workflows/**, .gitlab-ci.yml, Jenkinsfile | ci |
Uniquement package.json, *.lock, requirements.txt, Cargo.toml | chore |
Uniquement les fichiers de configuration (.eslintrc, tsconfig.json, .prettierrc) | chore |
Candidats à l'écrasement (squash)
| Signal | Action |
|---|---|
| Le message est "WIP", "wip", "temp", "save", "checkpoint", un seul mot, ou vide | écraser dans le commit significatif le plus proche |
| Le message est "fixup", "fix typo", "oops", "lint", "formatting" | écraser dans le précédent |
| Commits de merge (provenant de merges non squash) | candidat à l'écrasement — évaluer si le contenu est déjà sur main |
Pour les commits ambigus, vérifiez la diff complète (git show <hash>) pour déterminer le type à partir du contenu.
Étape 3 : Rédiger des messages de qualité
Les messages réécrits doivent respecter les normes des messages de commit :
<type>[portée facultative]: <description impérative courte>
[corps facultatif expliquant POURQUOI, pas quoi]
Bonnes réécritures :
"initial setup" → "chore: initial project setup"
"added login" → "feat: add user login"
"fix bug" → "fix: resolve login redirect loop"
"Update README.md" → "docs: update README with setup instructions"
"updated deps" → "chore(deps): update dependencies"
Mauvaises réécritures (ne faites pas ça) :
"stuff" → "chore: stuff" ← toujours insignifiant
"changes" → "feat: changes" ← pas de description
"fix" → "fix: fix" ← redondant
Lorsque le message original est insignifiant, lisez la diff pour rédiger une description appropriée. Si la diff est trop grande ou ambiguë, demandez à l'utilisateur à quoi le commit faisait référence.
Étape 4 : Présenter le plan de réécriture
Avant toute action destructive, montrez à l'utilisateur un plan complet :
PLAN DE RÉÉCRITURE
===================
Nombre total de commits : 47
Commits à réécrire : 32
Commits à écraser : 8
Commits déjà conformes : 7
Auteurs préservés : 3 (Alice <alice@co>, Bob <bob@co>, CI Bot <bot@co>)
CHANGEMENTS PROPOSÉS :
abc1234 "initial setup" → "chore: initial project setup"
def5678 "added login" → "feat: add user login"
ghi9012 "wip" → [ÉCRASER dans def5678]
jkl3456 "fix bug" → "fix: resolve login redirect loop"
mno7890 "Update README.md" → "docs: update README with setup instructions"
pqr1234 "feat: add search" → [CONSERVER — déjà conforme]
...
SÉCURITÉ :
✓ Branche de sauvegarde : backup/pre-rewrite-<timestamp>
⚠ Push forcé requis après la réécriture
⚠ Tous les collaborateurs doivent recloner ou réinitialiser après le push
INCHANGÉ :
- Arborescence des fichiers (zéro modification de contenu)
- Nom, email et date de l'auteur sur chaque commit
- Ordre des commits et relations parent (sauf commits écrasés)
Attendez l'approbation explicite de l'utilisateur avant de procéder. Ne continuez pas sur un "ça a l'air bien" — exigez "oui" ou équivalent.
Étape 5 : Créer une sauvegarde
# Créer une branche de sauvegarde à HEAD actuel
git branch "backup/pre-rewrite-$(date +%Y%m%d-%H%M%S)"
# Vérifier que la sauvegarde existe
git branch -l 'backup/*'
# Optionnel : pousser la sauvegarde sur le dépôt distant pour plus de sécurité
git push origin "backup/pre-rewrite-$(date +%Y%m%d-%H%M%S)"
Étape 6 : Exécuter la réécriture
Option A : Rebase interactif (< 50 commits)
# Rebase depuis la racine
git rebase -i --root
Dans l'éditeur :
- Marquez les candidats à l'écrasement avec
squashoufixup - Marquez les réécritures de messages avec
reword - Préservez l'ordre des commits
Préservation des auteurs : Le rebase interactif préserve par défaut le nom, l'email et la date de l'auteur. N'utilisez PAS --reset-author. N'utilisez PAS --committer-date-is-author-date sauf demande explicite.
Option B : git-filter-repo (>= 50 commits)
Créez un script de callback Python (rewrite-messages.py) qui mappe les anciens messages aux nouveaux :
import re
REWRITES = {
b'ancien préfixe de hash': b'nouveau message',
# ... généré à partir du plan
}
def rewrite(commit, metadata):
# Rechercher par message ou hash original
if commit.original_id in REWRITES:
commit.message = REWRITES[commit.original_id]
Appliquez avec :
git filter-repo --commit-callback "$(cat rewrite-messages.py)" --force
Préservation des auteurs : git-filter-repo préserve la paternité par défaut. N'utilisez PAS --mailmap ni les indicateurs de réécriture d'auteur sauf demande explicite de l'utilisateur.
Écrasement des commits
Pour les candidats à l'écrasement, combinez-les dans le commit significatif le plus proche. Le commit résultant :
- Utilise le message de commit conventionnel du commit significatif
- Préserve l'auteur original du commit significatif (pas la cible d'écrasement)
- Combine toutes les modifications de la plage écrasée
Étape 7 : Vérifier
Exécutez toutes les vérifications. Chaque vérification doit réussir avant de procéder au push.
# 1. Examiner le journal réécrit
git log --oneline --no-merges
# 2. Valider que TOUS les messages correspondent aux commits conventionnels
FAILURES=$(git log --format="%s" --no-merges | grep -cvE "^(feat|fix|docs|chore|refactor|test|ci)(\(.+\))?!?: .+")
if [[ "$FAILURES" -gt 0 ]]; then
echo "ÉCHEC : $FAILURES commits non conformes trouvés :"
git log --format="%h %s" --no-merges | grep -vE "^[a-f0-9]+ (feat|fix|docs|chore|refactor|test|ci)(\(.+\))?!?: .+"
exit 1
fi
echo "SUCCÈS : tous les commits sont conformes"
# 3. Vérifier zéro modification de contenu (critique — prouve que seuls les messages ont changé)
DIFF=$(git diff "backup/pre-rewrite-"*..HEAD --stat)
if [[ -n "$DIFF" ]]; then
echo "ÉCHEC : différences de contenu détectées — la réécriture a changé le code, pas seulement les messages"
echo "$DIFF"
exit 1
fi
echo "SUCCÈS : arborescence des fichiers identique"
# 4. Vérifier la préservation des auteurs
echo "=== Auteurs avant ==="
git -C . log backup/pre-rewrite-* --format="%an <%ae>" --no-merges | sort -u
echo "=== Auteurs après ==="
git log --format="%an <%ae>" --no-merges | sort -u
# Ces deux listes doivent être identiques
Si une vérification échoue, NE FAITES PAS de push. Réinitialisez vers la sauvegarde et enquêtez :
git reset --hard backup/pre-rewrite-*
Étape 8 : Pousser (avec confirmation)
Ceci est destructeur pour les collaborateurs distants. Confirmez avec l'utilisateur :
⚠ AVERTISSEMENT DE FORCE PUSH
Branche : main
Distant : origin
Action : git push --force-with-lease origin main
Après ce push, tous les collaborateurs doivent exécuter :
git fetch origin
git reset --hard origin/main
Tapez "oui" pour continuer :
git push --force-with-lease origin main
Utilisez --force-with-lease (pas --force) pour éviter d'écraser les modifications distantes simultanées. Si --force-with-lease est rejeté, quelqu'un a poussé sur la branche depuis la réécriture — enquêtez avant de réessayer.
Nettoyage post-réécriture
Après un push réussi et la notification des collaborateurs :
# Vérifier que le distant correspond au local
git log --oneline -5
git log --oneline -5 origin/main
# Ces deux doivent être identiques
# Conserver la branche de sauvegarde pendant au moins 2 semaines, puis supprimer
# git branch -D backup/pre-rewrite-*
# git push origin --delete backup/pre-rewrite-*
Rationalisations
| Rationalisation | Réalité |
|---|---|
| "L'ancien historique n'a pas d'importance" | Release-please analyse les messages de commit pour déterminer les versions. Un historique non conforme produit des versions erronées ou aucune sortie. git bisect, git blame et git log --grep dépendent tous de messages significatifs. |
| "Je vais simplement repartir de zéro à partir de ce commit" | Vous perdez le blame, la paternité et le contexte. Une branche --orphan détruit le récit de développement. La réécriture préserve tout sauf les mauvais messages. |
| "C'est trop risqué de réécrire" | La branche de sauvegarde rend ce processus entièrement réversible. Vérifiez que la diff ne montre aucune modification de contenu. Si quelque chose tourne mal : git reset --hard backup/pre-rewrite-*. |
| "Je réécrirai plus tard quand ce sera important" | Les réécritures d'historique deviennent plus difficiles avec le temps à mesure que plus de personnes clonent et bifurquent. Faites-le maintenant, avant que davantage de dépendances en aval n'existent. |
| "Le force push est dangereux" | --force-with-lease protège contre l'écrasement du travail des autres. Combiné avec la branche de sauvegarde, c'est sûr. |
| "Les messages sont suffisamment proches" | "Suffisamment proche" signifie que release-please ne les analysera pas. Les commits conventionnels sont un format strict — soit un message est conforme, soit il ne l'est pas. |
| "Je peux simplement taguer les versions manuellement" | Les tags manuels dérivent, sont oubliés et manquent d'intégration avec le changelog. L'objectif est l'automatisation — mais l'automatisation a besoin d'une entrée analysable. |
Drapeaux rouges
- Sauter la création de la branche de sauvegarde
- Utiliser
--forceau lieu de--force-with-lease - Réécrire une branche sur laquelle d'autres travaillent activement sans notification
- Des différences de contenu apparaissant dans la diff de l'étape 7 (signifie que la réécriture a changé le code, pas seulement les messages)
- Les nombres d'auteurs changeant après la réécriture (signifie que la paternité n'a pas été préservée)
- Procéder sans l'approbation de l'utilisateur du plan de réécriture
- Messages réécrits encore insignifiants (par exemple,
"chore: stuff") - Écraser des commits entre différents auteurs sans discussion
- Ne pas conserver la branche de sauvegarde pendant au moins 2 semaines après la réécriture