Vous faites un pull du dernier main et Git s’arrête avec CONFLICT (content): Merge conflict in checkout.js. Vous résolvez le conflit, et le log affiche maintenant un commit nommé Merge branch 'main' into feature/checkout, posé au-dessus des trois commits que vous aviez réellement écrits. Quatre commits, un seul changement réel. Un collègue qui lit cet historique le mois prochain ne peut pas savoir lequel est lequel.
git merge et git rebase répondent à la même question : comment mettre ma branche à jour par rapport aux changements faits ailleurs. Ils laissent des traces très différentes, et choisir le mauvais sur une branche partagée cause de vrais dégâts.
Ce que merge et rebase font différemment au graphe de commits
Supposons que main a reçu les commits D, E, F pendant que vous travailliez sur feature, qui a vos propres commits A, B, C partis de l’ancienne pointe de main :
| |
git merge main, lancé depuis feature, crée un nouveau commit avec deux parents : la pointe de feature et la pointe de main. Rien ne change dans A, B ou C. Mêmes hashes, mêmes parents, même place dans le graphe :
| |
git rebase main, lancé lui aussi depuis feature, fait l’inverse. Il laisse main intact et rejoue A, B et C un par un par-dessus F, en générant un nouveau commit pour chacun : A’, B’, C’, avec de nouveaux hashes et de nouveaux parents :
| |
Le résultat ressemble exactement à ce que vous auriez obtenu en créant la branche depuis F dès le départ, sans jamais diverger. C’est tout l’intérêt : un historique linéaire, sans commit de merge dans le log. Le coût, c’est que A, B et C n’existent plus pour Git. A’, B’ et C’ les remplacent, et quiconque avait déjà récupéré A, B ou C se retrouve avec des commits qui ne correspondent plus aux vôtres.
Ce qu’est un fast-forward merge et quand Git s’en sert à la place d’un commit de merge
Un merge n’a besoin d’un commit de merge que si les deux branches ont bougé. Si feature est partie de F et que main n’a pas bougé depuis, fusionner feature dans main n’a rien à concilier : Git avance simplement le pointeur de main jusqu’à C et s’arrête. C’est un fast-forward, et cela laisse le même historique plat qu’un rebase :
| |
Git ne crée un commit de merge que lorsque les deux côtés ont ajouté des commits que l’autre n’a pas. Certaines équipes en forcent un quand même, pour que chaque branche de feature laisse une marque visible dans main à l’endroit où elle a été intégrée :
| |
Comment nettoyer vos commits avec le rebase interactif
Le rebase peut aussi modifier votre propre historique, sans qu’aucune autre branche n’intervienne. git rebase -i ouvre vos N derniers commits dans un éditeur, une ligne par commit :
| |
| |
Changez le mot en début de ligne et Git agit en conséquence quand le rebase démarre :
| Commande | Effet |
|---|---|
pick | Conserve le commit tel quel |
reword | Conserve le commit, modifie son message |
squash | Le fusionne avec le commit précédent, en combinant les deux messages |
fixup | Le fusionne avec le commit précédent, en abandonnant ce message |
drop | Supprime le commit |
edit | Met le rebase en pause ici pour modifier le commit à la main |
Transformez cette liste en squash e4f5a6b fix typo et fixup c7d8e9f WIP: still debugging et vous obtenez un seul commit, Add checkout validation, au lieu de trois qui n’ont de sens que dans l’ordre où vous les avez écrits. Faites-le avant d’ouvrir la pull request, pas après que quelqu’un ait récupéré votre branche — le squash réécrit le hash de chaque commit qui suit, comme n’importe quel autre rebase.
Si vous corrigez des commits antérieurs au fur et à mesure, git commit --fixup <hash> suivi de git rebase -i --autosquash <base> déplace chaque fixup à côté de sa cible et le marque pour vous, si bien que vous ne réordonnez jamais la liste à la main.
Comment git pull --rebase garde votre branche plate
Le même choix revient à chaque pull. Un git pull classique, sur une branche où vous avez des commits locaux et où le remote a avancé, fait un fetch suivi d’un merge. C’est comme ça que vous vous retrouvez avec un commit Merge branch 'main' into feature/checkout pour aucune autre raison qu’un mauvais timing. git pull --rebase récupère les mêmes commits et rejoue les vôtres par-dessus :
| |
Des changements non commités font échouer le démarrage du rebase. Ajoutez --autostash et Git les met de côté, fait le rebase, puis les réapplique :
| |
Configurez-le une bonne fois pour toutes plutôt que de taper le flag à chaque fois, par branche ou globalement :
| |
C’est sûr, car cela ne réécrit que des commits qui n’existent que sur votre branche locale. Dès que ce n’est plus le cas, le rebase cesse d’être sûr.
La règle d’or : ne jamais rebaser des commits que quelqu’un d’autre a déjà récupérés
Le rebase réécrit les hashes des commits. Si vous rebasez une branche dont les anciens commits sont déjà entre les mains de quelqu’un d’autre — il a récupéré votre branche, ou c’est main, ou c’est n’importe quoi avec plus d’un contributeur — son historique et le vôtre ne sont plus d’accord sur ce qu’est cette branche. Git n’a aucun moyen de savoir que votre A et son A’ sont le même changement logique. Il voit deux commits sans lien, et le prochain merge entre les deux copies duplique tout ce qui a divergé.
La règle qui évite ça : ne rebasez que les branches sur lesquelles personne d’autre n’a encore commencé à travailler. Votre propre branche de feature, avant d’avoir donné à quelqu’un un hash sur lequel s’appuyer, c’est du terrain libre. Une branche déjà pushée que des collègues utilisent, main, ou n’importe quelle branche partagée de longue durée : celles-là se fusionnent, jamais on ne les rebase.
Rebaser une branche déjà pushée exige un push forcé pour que le remote accepte l’historique réécrit :
| |
--force-with-lease refuse le push si le remote a des commits que vous n’avez pas encore récupérés — exactement la situation où quelqu’un a construit sur l’ancien historique et où un --force brut écraserait silencieusement son travail. Jamais de --force brut, et seulement sur une branche dont vous êtes sûr qu’elle n’appartient qu’à vous.
Comment récupérer d’un mauvais rebase avec git reflog
Un rebase sur la mauvaise base, ou un rebase interactif où vous avez supprimé le mauvais commit, semble irrécupérable : les commits que vous aviez une minute plus tôt n’apparaissent plus dans git log. Ils n’ont pas disparu. git reflog enregistre chaque position vers laquelle HEAD a pointé sur votre machine, rebases compris :
| |
| |
HEAD@{3} est l’endroit où pointait votre branche juste avant le début du rebase. Faites un reset vers ce point et vous retrouvez exactement l’état d’avant, commits supprimés compris :
| |
Les entrées du reflog expirent après 90 jours par défaut, ou 30 jours pour les commits vers lesquels aucune autre référence ne pointe. C’est un filet de sécurité pour la dernière heure ratée, pas un stockage permanent. C’est aussi purement local : ça ne se pushe jamais, et ça ne peut pas défaire une réécriture que quelqu’un d’autre a déjà récupérée.
Le même réflexe — revenir à un état connu et fonctionnel après un mauvais changement — s’applique aussi en dehors de l’historique Git. Un Deployment Kubernetes garde son propre historique de rollouts pour la même raison, et kubectl rollout undo fait pour un déploiement raté ce que git reset --hard sur une entrée du reflog fait pour un rebase raté.
Git rebase vs merge : lequel utiliser et quand
| Situation | Utilisez |
|---|---|
Mettre à jour votre branche locale, pas encore pushée, par rapport à main | rebase (ou pull --rebase) |
| Nettoyer vos propres commits avant d’ouvrir une pull request | rebase -i |
Intégrer une branche de feature terminée dans main | merge (avec ou sans --no-ff, selon la convention de votre équipe) |
La branche est partagée, pushée, ou c’est main elle-même | merge — ne la rebasez jamais |
Vous devez savoir exactement quand une feature est arrivée dans main | merge --no-ff — le commit de merge le marque |
| Vous voulez un historique linéaire sans commit de merge dans le log | rebase de votre côté, avant d’ouvrir la pull request |
La décision porte en réalité sur qui d’autre a déjà vu ces commits. Vous seul ? Rebase, et gardez un historique propre. Au moins une autre personne ? Merge. Un log un peu plus désordonné ne vous coûte rien ; un historique partagé réécrit coûte un après-midi à tous ceux qui l’ont récupéré.
Si votre équipe n’a pas encore de convention, commencez ici : pull.rebase = true en local, rebase -i pour ranger vos commits avant d’ouvrir la pull request, puis un simple merge pour les intégrer dans main. Vous obtenez un historique propre par branche sans jamais réécrire un commit dont dépend quelqu’un d’autre, CI comprise. Un workflow comme Construire et Publier une Image Docker avec GitHub Actions tague chaque build avec le SHA du commit, et ces SHA doivent continuer d’exister après que le pipeline les a utilisés. Définissez git config --global pull.rebase true dès aujourd’hui : à lui seul, il élimine la plupart des commits de merge accidentels de votre log.