Maintenir un historique propre avec Git

De Ensiwiki
Aller à : navigation, rechercher

Vous connaissez les bases de l'utilisation de Git ? On vous a appris un jour que tout ce qui a été « commité » dans un dépôt Git était enregistré pour toujours ? Que git commit devait toujours être utilisé avec l'option -a (mais vous vous demandez toujours pourquoi) ? Cette page est faite pour vous.

Jusqu'ici, Git vous a probablement servi à résoudre deux problèmes :

  1. Travailler à plusieurs, en automatisant les opérations de fusions (merge)
  2. Gérer les sauvegardes, en archivant un historique.

En fait, il y a bien d'autres questions pour lesquelles un gestionnaire de versions comme Git peut être utile, voire indispensable. Par exemple :

  1. Faciliter la revue de code
  2. Documenter le code
  3. Identifier les regressions

Avertissement

Les méthodes présentées ci-dessous sont potentiellement destructrices, au sens où du code commité dans un dépôt peut être détruit a postériori. Si vous n'êtes pas sûr de vous, n'utilisez pas ces commandes.

Exemple et motivations

Pour mieux comprendre les motivations, nous allons prendre l'exemple de deux équipes, ayant chacune deux fonctionnalités à implémenter. Pour l'exemple, nous les appellerons « fonctionnalité foo » et « fonctionnalité bar ».

Les débutants : Alice et Bob

La première est l'équipe d'Alice et Bob, tous deux débutants avec Git. Alice a implémenté les deux fonctionnalités, son historique ressemble à ceci (dans la réalité, il faudrait sans doute ajouter quelques commits de merge) :

* 364db49 (HEAD, master) Modification de fonctionnalité bar
* 20affa6 Correction d'un bug dans la fonctionnalité foo
* df33d6c Fonctionnalité bar et correction de bug dans foo
* c2d226f Suite (et fin ?) de fonctionnalité foo
* 70b2a42 Oups, le commit précédent ne compile pas, c'est corrigé
* d376e1e debut de fonctionnalité foo (je finirai demain)

L'utilisation de Git par Alice et Bob répond bien aux questions 1. et 2. ci-dessus : on pourra si besoin revenir à n'importe quelle version intermédiaire, et on a pu utiliser git push et git pull pour envoyer du code et faire des fusions. Par contre :

  • Bob voudrait relire les patchs successifs pour vérifier si les implémentations de foo et bar sont correctes. Il perd beaucoup de temps : en relisant les premiers commits (d376e1e, 70b2a42 et c2d226f), il remarque deux bugs et en discute avec Alice, alors que les deux sont déjà corrigés dans les commits suivants (df33d6c et 20affa6). Par ailleurs, le commit « Fonctionnalité bar et correction de bug dans foo » est difficile à relire puisqu'il parle de deux choses différentes dans le même patch. Les changements introduisant les fonctionnalités foo et bar sont entremêlés, ce qui ne facilite encore pas la tâche.
  • Plus tard, Alice ou Bob reviendra sur le code et se posera la question : « tiens, pourquoi avait-on écrit cette ligne de code au fait ? ». Pour répondre à cette question, la commande git blame (ou git gui blame) permettra de rechercher, pour chaque ligne de code, quel commit l'a introduit. Malheureusement, sur la ligne en question, git blame répond « Oups, le commit précédent ne compile pas, c'est corrigé », ce qui n'apporte pas grand chose.
  • Après la sortie de la version 2.0 du logiciel d'Alice et Bob, un utilisateur fait remarquer une régression : ce qu'il faisait avec la version 1.0 ne marche plus avec la nouvelle version. Bob cherche à identifier quel commit a introduit cette régression, en parcourant l'historique entre la version 1.0 et la 2.0. Il se pose la question « est-ce que le commit d376e1e a aussi le bug », mais ce commit ne compile même pas donc il ne peut pas tester. Pour le commit 70b2a42, le projet compile mais est complètement cassé puisque la fonctionnalité foo est à moitié codée seulement. En supposant que Bob finisse tout de même par identifier le commit problématique, il peut tomber sur df33d6c, mais vu que le commit mélange deux changements, il ne sait pas lequel des changements a introduit le bug.

Les utilisateurs expérimentés : Charlie et Dave

Une deuxième équipe, celle de Charlie et Dave, a codé la même chose. Charlie a codé la même chose qu'Alice, dans le même ordre, mais c'est un utilisateur avancé de Git. Il a triché a postériori sur son historique, et l'a fait ressembler à ceci avant de l'envoyer à Dave avec git push :

* 3255658 (HEAD, master) Implémentation de la fonctionnalité bar
* e758cb2 Implémentation de la fonctionnalité foo
* fb65f83 Refactoring de code pour préparer foo

Dave et Charlie peuvent maintenant répondre aux questions A., B. et C. ci-dessus :

  • Dave peut relire les patchs sans être distrait par les erreurs déjà corrigées de Charlie.
  • La commande git blame pointe maintenant sur des commits précis et propres. En lançant git gui blame sur un fichier source, et en passant la souris sur une ligne de code, on peut maintenant avoir des informations pertinentes sur la raison d'être de cette ligne de code : les messages de commits deviennent aussi utiles que les commentaires dans le code !
  • Si une régression est détectée, on peut utiliser la commande git bisect, qui va faire une recherche dichotomique dans l'historique pour trouver le premier commit incorrect dont les ancêtres sont corrects. Comme chaque commit est compilable et bien testé, la commande git bisect peut faire son travail automatiquement et pointer sur le commit coupable.

Par ailleurs, Dave et Charlie connaissent les bonnes pratiques et savent écrire de bons messages de commit avec Git, ce qui facilite la relecture des patchs, et permet d'obtenir des informations pertinentes avec git blame et git bisect.

La différence

La différence entre l'équipe d'Alice et Bob et celle de Charlie et Dave est plus qu'une différence de maitrise de l'outil, c'est vraiment une manière différente de travailler.

Alice et Bob ont appliqué le principe « les erreurs font partie de l'histoire », leur historique reflète ce qui s'est passé réellement, y compris les erreurs.

Charlie et Dave ont appliqué le principe « L'histoire est une suite de mensonges sur lesquels on est d'accord » (citation attribuée à Napoléon Bonaparte, utilisateur de Git avant l'heure ?). En d'autre termes, les versions intermédiaires et buggées de Charlie étaient considérées comme des brouillons, et Charlie a d'abord mis son historique au propre avant de le montrer au reste du monde. L'historique n'est plus resprésentatif de ce que Charlie a vraiment fait, mais c'est une explication de comment aller du point de départ au point d'arrivée. On ne s'intéresse pas seulement à avoir un bon résultat final, mais aussi à avoir un chemin propre entre le point de départ et le point d'arrivée. Pour Charlie et Dave, l'historique local (les commits qui n'ont pas encore été publiés avec git push) représente le brouillon, qu'on peut encore modifier, et seul ce qui est partagé entre développeurs est un historique immutable.

Faire des commits propres en utilisant l'index : git add, git add -p, ...

Un premier problème simple à résoudre : en codant la fonctionnalité bar, Charlie remarque et corrige un bug dans la fonctionnalité foo. Son arbre de travail contient donc une implémentation (possiblement incomplète) de bar, et une correction de bug, qui mériteraient d'apparaitre dans des commits séparés.

Pour cela, nous allons utiliser l'index de Git. L'index peut être vu comme une « zone de préparation du prochain commit » (« staging area » en anglais). L'index contient la liste des fichiers gérés par Git, et le contenu de chacun de ses fichiers.

Quand on utilise git commit -a, l'option -a dit à Git d'ajouter à l'index tous les changements de l'arbre de travail (c'est à dire des fichiers qu'on a modifié), puis de faire un commit en se basant dessus.

Une autre solution est d'utiliser git commit sans l'option -a. Dans ce cas, Git va créer un commit en se basant sur le contenu actuel de l'index, et non sur le contenu des fichiers de l'arbre de travail. C'est donc à l'utilisateur de spécifier ce qu'il souhaite avoir dans le prochain commit, ce qui se fait essentiellement avec la commande git add.

Commits partiels au niveau fichier

On va supposer dans un premier temps que les changements liés à foo et à bar sont fait dans des fichiers différents.

Avant de commencer, Charlie regarde la liste des changements :

$ git status
On branch master
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

      modified:   bar.adb
      modified:   foo.adb

no changes added to commit (use "git add" and/or "git commit -a")

Pour l'instant, les modifications sont toutes dans la section « Changes not staged for commit ». Git a vu que les fichiers étaient modifiés, mais n'a pas ajouté le nouveau contenu dans l'index. Si Charlie lance git commit (sans -a), le commit va échouer : Git refuse de faire un commit vide.

Charlie ajoute maintenant le nouveau contenu de foo.adb à l'index :

git add foo.adb

(Les habitués d'autres gestionnaires de versions comme Subversion ou Mercurial trouveront sans doute étrange de faire un git add sur un fichier déjà géré par Git, mais git add ne fait pas la même chose que svn add ou hg add)

La commande git status indique maintenant :

$ git status
On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

      modified:   foo.adb

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

      modified:   bar.adb

Les changements du fichier bar.adb sont toujours dans la section « Changes not staged for commit », mais la modification du fichier foo.adb est passé dans une nouvelle section « Changes to be committed ». En d'autres termes, cette modification est enregistrée dans l'index. Charlie peut maintenant faire un commit (sans -a) :

git commit

Ce commit contiendra uniquement les modifications du fichier foo.adb. Il peut faire un second commit avec les modifications de bar.adb.

Commits partiels d'une portion de patch

Supposons maintenant que la correction de bug et la nouvelle fonctionnalité aient été faits dans le même fichier (disons, foobar.adb). En lançant git diff HEAD, Charlie voit un diff ressemblant à ceci :

$ git diff HEAD
diff --git a/foobar.c b/foobar.c
index 138dd60..3d51d75 100644
--- a/foobar.c
+++ b/foobar.c
@@ -1,7 +1,7 @@
 #include <stdio.h>

 int foo() {
-       printf("This is fiture foo\n");
+       printf("This is feature foo\n");
 }

 int happy;
@@ -13,3 +13,7 @@ char boz() {
                printf("I'm not so happy\n");
        }
 }
+
+void bar() {
+       printf("This new feature rocks\n");
+}

Il voudrait ajouter la première portion de patch (« patch hunk ») à l'index, mais pas la seconde. Il lance pour cela la commande « git add -p », qui va lui poser la question de l'ajout à l'index pour chaque portion. Il répond « y » (yes) pour la première, et « n » (no) pour la seconde :

$ git add -p
diff --git a/foobar.c b/foobar.c
index 138dd60..3d51d75 100644
--- a/foobar.c
+++ b/foobar.c
@@ -1,7 +1,7 @@
 #include <stdio.h>
 
 int foo() {
-       printf("This is fiture foo\n");
+       printf("This is feature foo\n");
 }
 
 int happy;
Stage this hunk [y,n,q,a,d,/,j,J,g,e,?]? y
@@ -13,3 +13,7 @@ char boz() {
                printf("I'm not so happy\n");
        }
 }
+
+void bar() {
+       printf("This new feature rocks\n");
+}
Stage this hunk [y,n,q,a,d,/,K,g,e,?]? n

La commande git status montre maintenant qu'une partie des modifications de foobar.c est ajoutée à l'index, mais qu'une partie n'est pas programmée pour le prochain commit :

$ git status
On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

      modified:   foobar.c

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

      modified:   foobar.c

Pour vérifier en détails ce qu'il a fait, Charlie peut utiliser la commande git diff de plusieurs manières :

  • git diff (sans option) : visualiser les différences entre l'arbre de travail et l'index (i.e. les changements qui n'iront pas dans le prochain commit)
  • git diff --staged : visualiser les différences entre le dernier commit (HEAD) et l'index (i.e. les changements qui iront dans le prochain commit)
  • git diff HEAD : visualiser les changements entre l'arbre de travail et le dernier commit (en ne tenant pas compte du contenu de l'index)

Dans notre cas, on voit :

$ git diff
diff --git a/foobar.c b/foobar.c
index adf32b7..3d51d75 100644
--- a/foobar.c
+++ b/foobar.c
@@ -13,3 +13,7 @@ char boz() {
                printf("I'm not so happy\n");
        }
 }
+
+void bar() {
+       printf("This new feature rocks\n");
+}
$ git diff --staged
diff --git a/foobar.c b/foobar.c
index 138dd60..adf32b7 100644
--- a/foobar.c
+++ b/foobar.c
@@ -1,7 +1,7 @@
 #include <stdio.h>
 
 int foo() {
-       printf("This is fiture foo\n");
+       printf("This is feature foo\n");
 }
 
 int happy;

et enfin :

$ git diff HEAD
diff --git a/foobar.c b/foobar.c
index 138dd60..3d51d75 100644
--- a/foobar.c
+++ b/foobar.c
@@ -1,7 +1,7 @@
 #include <stdio.h>
 
 int foo() {
-       printf("This is fiture foo\n");
+       printf("This is feature foo\n");
 }
 
 int happy;
@@ -13,3 +13,7 @@ char boz() {
                printf("I'm not so happy\n");
        }
 }
+
+void bar() {
+       printf("This new feature rocks\n");
+}

Charlie peut maintenant faire un commit (toujours sans -a) :

git commit

Tester le résultat avant de faire un commit

Un problème avec l'approche précédente est que le code qui est commité n'a pas pu être testé, car l'état correspondant n'a jamais été présent dans l'arbre de travail. Sur l'exemple précédent, si la correction de bug sur foo utilise une fonction qui n'est définie que dans la fonctionnalité bar, alors l'arbre de travail de Charlie, qui contient les deux changements, est compilable, mais le commit qui n'inclue que la correction de bug de foo ne l'est pas.

Une solution à ce problème est de tester l'état intermédiaire avant de faire le commit, par exemple avec les commandes suivantes :

git stash --keep-index
# Compiler
# Tester
git commit
git stash pop

git stash met de côté les changements de l'arbre de travail, et l'option --keep-index (ou -k) permet de conserver le contenu de l'index dans l'arbre de travail. Après avoir lancé cette commande, l'arbre de travail contient la même chose que l'index. On peut compiler et tester, puis faire un commit.

La commande git stash pop permet de récupérer les changements qui avaient été mis de côté par git stash.

En résumé

  • git diff HEAD : changements entre l'arbre de travail et le dernier commit
  • git diff : changements entre l'arbre de travail et l'index
  • git diff --staged : changements entre l'index et HEAD
  • git add fichier : ajouter le contenu de fichier à l'index.
  • git add -p : ajouter des portions de patch à l'index
  • git add -i : ajouter ou supprimer des fichiers ou portions de patch à l'index
  • git reset : ré-initialiser l'index avec le contenu du dernier commit
  • git reset -p : inverse de git add -p, enlève des portions de patch de l'index.

Plus de documentation

Modifier le dernier commit : git commit --amend

Même en étant très méthodique, il peut toujours arriver qu'on fasse un commit, et qu'on se rende compte peu après que quelque chose n'allait pas avec ce commit. Plusieurs cas sont possibles :

  • Le commit a déjà été publié (via git push). Si c'est le cas, alors il ne faut plus le modifier, la seule solution raisonnable est d'assumer nos erreurs, conserver le commit fautif, et probablement créer un nouveau commit qui corrige l'erreur, plus tard dans l'historique.
  • Le commit est local (il n'a pas été publié avec git push), mais on a déjà fait d'autres commits entre temps (i.e. le commit à corriger n'est plus le dernier commit). À ce moment là, lire la section suivante sur git rebase.
  • Le commit est local, et c'est le dernier (i.e. le premier qui apparait en exécutant git log). C'est ce dernier cas qui nous intéresse ici.

La commande git commit --amend permet de remplacer le dernier commit par un autre commit, en apportant des modifications supplémentaires. Elle revient à annuler le dernier commit, puis à exécuter git commit comme si ce commit n'avait jamais existé. On peut bien sûr utiliser toutes les variantes de git commit, comme d'habitude. Prenons deux exemples :

Première situation : Charlie vient de faire un commit sur la fonctionnalité foo, il n'a pas encore fait d'autres changements, et se rend compte qu'il y a un problème avec son commit. Il corrige le problème dans son arbre de travail, teste le résultat, puis au lieu de faire un nouveau commit avec un message du type « correction du commit précédent », il lance la commande :

git commit --amend -a

Il peut s'il le souhaite modifier le message de commit, et l'option -a fait que Git récupère toutes les modifications de l'arbre de travail dans le nouveau commit.

Deuxième situation : Charlie pensait avoir terminé la fonctionnalité foo, l'avait commité, mais en codant la fonctionnalité bar, il corrige un bug dans foo. Son arbre de travail contient à la fois la correction de bug et une nouvelle fonctionnalité. Charlie peut, comme dans la section précédente, utiliser git add -p pour ajouter la correction de bug à l'index, puis il utilise git commit sans -a :

git add -p
# réponse 'y' pour toutes les portions qui correspondent à la correction de bug
git commit

Éviter les « merge commits » inutiles : git rebase

Quand on fait des git pull régulièrement, on obtient souvent un historique parsemé de commits de merge (fusion), qui peut ressembler à ceci :

*   1c53fac (HEAD, master) Merge branch 'my-branch'
|\  
| *   2b15553 Merge branch 'master' into my-branch
| |\  
| * | 96d008e Codage d'une autre fonctionnalité
* | | b53e417 Correction de typo
| |/  
|/|   
* | d4e3f22 Modification de fonctionnalité bar
|/  
* a5636dd Fonctionnalité foo implémentée

Sur cet exemple, les commits de merge polluent l'historique et le rendent difficile à lire. Git propose la commande git rebase pour éviter cette situation. Pour comprendre son fonctionnement, on considère cet exemple :

* 5df49a1 (HEAD, master) ma version
| * 454cddd (une-branche) sa version
|/  
* 105a452 ancetre commun

On pourrait faire une fusion normale comme ceci :

$ git merge une-branche
Merge made by the 'recursive' strategy.

On obtiendrait cet historique :

*   d79e731 (HEAD, master) Merge branch 'une-branche'
|\  
| * 454cddd (une-branche) sa version
* | 5df49a1 ma version
|/  
* 105a452 ancetre commun

À la place, on peut utiliser la commande git rebase :

$ git rebase une-branche
First, rewinding head to replay your work on top of it...
Applying: ma version

Git s'est placé sur la branche une-branche, puis il a ré-appliqué nos commits. Le résultat est un historique aplati :

* a8f9e63 (HEAD, master) ma version
* 454cddd (une-branche) sa version
* 105a452 ancetre commun

On peut remarquer que le nouveau commit a8f9e63, bien qu'il apporte les mêmes changements que l'ancien commit 5df49a1, avec le même message, est tout de même un commit différent (en particulier, il a un identifiant différent).

rebase, fetch et pull

Dans l'exemple précédent, on fusionnait deux branches locales (ou on en rebasait une sur l'autre). Mais concrètement : Dave vient d'envoyer des nouveaux commits vers une archive partagée, comment fait Charlie pour rebaser son historique par dessus celui de Dave ?

Première solution : Charlie va utiliser git fetch à la place de git pull. En fait, git pull est essentiellement un script qui exécute git fetch pour télécharger les nouveaux commits, puis git merge pour faire la fusion. On peut faire la même chose manuellement :

$ git fetch
From http://example.com/archive-partagee/
   3b28eb9..f7c8fbc  master     -> origin/master
$ git merge origin/master
Updating 3b28eb9..f7c8fbc
Fast-forward
 foo.txt | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

Pour rebaser ses changements, Charlie peut donc faire :

$ git fetch
...
$ git rebase origin/master

S'il trouve que c'est trop long de donner deux commandes au lieu d'une, il peut aussi utiliser git pull --rebase qui fait exactement la même chose.

(En fait, Charlie n'a même pas besoin de ça, car il a lu man git-config et a positionné pull.rebase une bonne fois pour toutes dans ce dépôt !)

Et les conflits ?

Bien sûr, un rebase n'est pas toujours possible sans conflit. Si les commits de Charlie touchent les mêmes lignes de code que ceux de Dave, alors il y aura des conflits (avec rebase comme avec un merge traditionnel). Dans ces situations, git rebase s'arrête sur le commit problématique, et demande à l'utilisateur de résoudre les conflits manuellement.

Une fois les conflits résolus (et marqués comme résolus avec git add), il faut utiliser git rebase --continue.

Si vous êtes perdus, la commande git status vous donnera des conseils sur ce qu'il vous reste à faire.

Avertissement : les dangers du rebase

Attention, git rebase est une commande très puissante, mais elle est également dangereuse, pour au moins deux raisons :

  • Faire un git rebase crée un nouvel historique, qui ressemble à l'ancien, mais ce ne sont plus les mêmes commits. Si on tente de rebaser des commits qui ont déja été publiés, alors on court droit à la catastrophe : nos coéquipiers peuvent avoir déjà fait un git pull et construit de nouvelles choses sur l'ancien historique, et il va falloir fusionner l'ancien historique avec le nouveau, avec des risques de conflits supplémentaires et un historique encore moins lisible qu'avec des merge classiques.
  • Même si on a testé chaque commit avant le rebase, le nouvel historique créé par git rebase n'a pas vraiment été testé. Si on rebase 10 commits sur un nouvel historique, on a peut-être vérifié que ces 10 commits étaient corrects, mais pas qu'ils l'étaient toujours une fois rebasé.

Une bonne pratique est d'utiliser git rebase sur son historique local avant de faire un git push, mais de ne jamais rebaser un historique publié. C'est une bonne idée de tester tous les commits de son historique après un rebase, même si on omet parfois de le faire (la commande git rebase -i --exec décrite plus bas est une réponse possible à ce problème).

Retravailler son historique : git rebase -i

Pour des utilisateurs novices (Alice et Bob ci-dessus), l'historique Git représente la succession des modifications, dans l'ordre chronologique. Mais la gestion de versions ne s'arrête pas là : l'historique qu'on souhaite partager avec ses coéquipiers, et conserver à long terme devrait représenter la succession logique des modifications nécessaires pour aller du point de départ au point d'arrivée. Pour rédiger un document écrit, on commence souvent par un brouillon où les idées sont mal organisées, les erreurs visibles, puis on se base sur ce brouillon pour rédiger le document final, bien structuré. Nos utilisateurs avancés (Charlie et Dave) vont donc retravailler leur historique local avant de le publier (i.e. avant de faire un git push).

Reprenons l'exemple de l'introduction. L'historique local de Charlie avant édition ressemblait à ceci :

* 364db49 (HEAD, master) Modification de fonctionnalité bar
* 20affa6 Correction d'un bug dans la fonctionnalité foo
* df33d6c Fonctionnalité bar et correction de bug dans foo
* c2d226f Suite (et fin ?) de fonctionnalité foo
* 70b2a42 Oups, le commit précédent ne compile pas, c'est corrigé
* d376e1e debut de fonctionnalité foo (je finirai demain)

Pour le commit « 70b2a42 Oups, le commit précédent ne compile pas, c'est corrigé », on aurait pu utiliser git commit --amend. Par contre, pour le commit « 364db49 Modification de fonctionnalité bar» , il était déjà trop tard, vu que le commit à modifier n'était pas le dernier dans l'historique.

Réordonner et fusionner ses commits : pick et squash

Charlie utilise la commande git rebase -i. Cette commande permet de ré-appliquer les changements des commits, mais en permettant d'en changer l'ordre. Utilisée sans argument, elle va considérer tous les commits qui n'ont pas encore été publiés, ce qui est précisément ce qu'on souhaite faire. La commande va présenter la liste des commits à appliquer dans un éditeur de texte. L'action à effectuer pour chaque commit est par défaut pick (« cueillir », parfois traduit par « picorer »), qui veut dire « appliquer le commit normalement ». La liste ressemble à ceci :

pick d376e1e debut de fonctionnalité foo (je finirai demain)
pick 70b2a42 Oups, le commit précédent ne compile pas, c'est corrigé
pick c2d226f Suite (et fin ?) de fonctionnalité foo
pick df33d6c Fonctionnalité bar et correction de bug dans foo
pick 20affa6 Correction d'un bug dans la fonctionnalité foo
pick 364db49 Modification de fonctionnalité bar

Charlie commence par ré-ordonner ces commits, et il obtient ceci :

pick d376e1e debut de fonctionnalité foo (je finirai demain)
pick 70b2a42 Oups, le commit précédent ne compile pas, c'est corrigé
pick c2d226f Suite (et fin ?) de fonctionnalité foo
pick 20affa6 Correction d'un bug dans la fonctionnalité foo
pick df33d6c Fonctionnalité bar et correction de bug dans foo
pick 364db49 Modification de fonctionnalité bar

Il a maintenant un historique qui mélange un peu moins les fonctionnalités. Les quatre premiers commits correspondent en fait à la même fonctionnalité, et peuvent être groupés (à moins qu'ils ne décrivent vraiment trois étapes logiques séparées). Charlie les groupe donc, avec la commande squash (« écraser », « aplatir ») :

pick d376e1e debut de fonctionnalité foo (je finirai demain)
squash 70b2a42 Oups, le commit précédent ne compile pas, c'est corrigé
squash c2d226f Suite (et fin ?) de fonctionnalité foo
squash 20affa6 Correction d'un bug dans la fonctionnalité foo
pick df33d6c Fonctionnalité bar et correction de bug dans foo
pick 364db49 Modification de fonctionnalité bar

Charlie n'est pas encore content du résultat, mais il décide de s'arrêter là pour l'instant (il continuera le nettoyage en faisant un autre rebase). Il enregistre et quitte son éditeur de texte (comme quand git commit demande un message de commit).

Le « rebase » démarre alors. Git applique les 4 premiers commits, puis lance un éditeur de texte demandant à l'utilisateur d'écrire le message de commit correspondant à la fusion des 4 commits. Par défaut, c'est la concaténation des messages des 4 commits :

# This is a combination of 4 commits.
# The first commit's message is:
debut de fonctionnalité foo (je finirai demain)

# This is the 2nd commit message:

Oups, le commit précédent ne compile pas, c'est corrigé

# This is the 3rd commit message:

Suite (et fin ?) de fonctionnalité foo

# This is the 4th commit message:

Correction d'un bug dans la fonctionnalité foo

# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
# rebase in progress; onto c1cc647

Charlie rassemble ces messages en un seul (et en profite éventuellement pour détailler un peu mieux ce qu'il a fait et pourquoi il l'a fait) :

Fonctionnalité foo implémentée

blablabla, blablabla

Il enregistre et quitte. git rebase finit par afficher :

Successfully rebased and updated refs/heads/master.

L'historique ressemble maintenant à ceci :

* 2222c21 (HEAD, master) Modification de fonctionnalité bar
* 82013ee Fonctionnalité bar et correction de bug dans foo
* cfec6f1 Fonctionnalité foo implémentée

Éditer des commits, couper un commit en deux : edit

Charlie souhaite maintenant découper le commit du milieu (82013ee Fonctionnalité bar et correction de bug dans foo), qui avait été écrit un peu trop vite (il aurait été préférable de faire des commits partiels avec git add -p et git commit sans </code>-a</code>, mais il n'est pas trop tard pour réparer cette erreur). On va encore utiliser git rebase -i, mais cette fois-ci avec la commande edit. La commande commence par afficher la liste :

pick cfec6f1 Fonctionnalité foo implémentée
pick 82013ee Fonctionnalité bar et correction de bug dans foo
pick 2222c21 Modification de fonctionnalité bar

Charlie change le pick du commit concerné par un edit :

pick cfec6f1 Fonctionnalité foo implémentée
edit 82013ee Fonctionnalité bar et correction de bug dans foo
pick 2222c21 Fonctionnalité bar

Il enregistre et quitte. git rebase applique les commits, et s'arrête sur le commit à éditer :

Stopped at 82013ee0b57bab2e548f7de729fe3854b5adce9b... Fonctionnalité bar et correction de bug dans foo
You can amend the commit now, with

        git commit --amend

Once you are satisfied with your changes, run

        git rebase --continue

Charlie commence par annuler le commit 82013ee, sans toucher à l'arbre de travail :

git reset HEAD^

Cette commande réinitialise l'index et HEAD sur l'avant dernier commit (HEAD^). Si on refaisait un git commit -a, on re-créerait le même commit que celui qu'on vient d'annuler.

Charlie remet maintenant les modifications liées à la fonctionnalité bar dans l'index avec git add -p. Après cela, il se trouve dans la situation suivante :

$ git status
rebase in progress; onto c1cc647
You are currently splitting a commit while rebasing branch 'master' on 'c1cc647'.
  (Once your working directory is clean, run "git rebase --continue")

Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

        modified:   foo.txt

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

        modified:   foo.txt

Charlie peut faire un git commit (toujours sans -a), et il crée un commit bien propre qui contient uniquement la des modifications liées à la fonctionnalité bar. Il refait un git add -p pour ajouter le code lié à foo, et fait un commit. Il n'y a maintenant plus rien à ajouter, le découpage du commit est terminé :

$ git status
rebase in progress; onto c1cc647
You are currently editing a commit while rebasing branch 'master' on 'c1cc647'.
  (use "git commit --amend" to amend the current commit)
  (use "git rebase --continue" once you are satisfied with your changes)

nothing to commit, working directory clean

En suivant les conseils donnés par git status, Charlie lance la commande git rebase --continue. Il obtient :

$ git rebase --continue
Successfully rebased and updated refs/heads/master.

Son historique ressemble maintenant à :

* 4cac041 (HEAD, master) Fonctionnalité bar
* 4f30794 Code lie a foo
* 03a4537 Code lie a bar
* cfec6f1 Fonctionnalité foo implémentée

Il peut relancer un dernier git rebase -i pour réordonner les commits et finalement obtenir :

* d4e3f22 (HEAD, master) Fonctionnalité bar
* a5636dd Fonctionnalité foo implémentée

Tester ses commits après un rebase

Charlie est assez content du résultat, mais il réalise que l'état du projet entre les deux commits n'a jamais été testé. Comme il souhaite avoir un historique ou chaque commit compile et passe les tests, il va tester chaque commit avant de faire un push. Il utilise git rebase -i --exec 'make check' (si la commande make check est la commande qui lance la base de tests). Si un test échoue, le rebase s'arrête et propose à Charlie de corriger le problème.

Et les conflits ?

Bien sûr, réordonner des commits n'est pas toujours possible (e.g. Si un commit récent modifie du code introduit par un commit plus ancien, on ne pourra pas réordonner ces commits automatiquement). De la même manière qu'avec un git rebase non interactif, il peut arriver que git rebase s'arrête sur le commit problématique, et demande à l'utilisateur de résoudre les conflits. Une fois terminé, on fera également git rebase --continue.

Avertissement : Les dangers de git rebase -i

git rebase -i est une forme évoluée de git rebase, donc les avertissements ci-dessus restent applicables. Le fait que les commits intermédiaires n'aient pas été testés est particulièrement visible avec un git rebase -i, puisqu'il devient très facile de permuter des commits qui avaient une dépendance sémantique (par exemple, placer le commit qui utilise une fonction avant celui qui la définie ...).

Se faire conseiller pendant un rebase

La commande git status affiche (en haut de la sortie) quelques informations et conseils parfois précieux pendant un rebase. Par exemple :

$ git status
rebase in progress; onto c1cc647
You are currently editing a commit while rebasing branch 'master' on 'c1cc647'.
  (use "git commit --amend" to amend the current commit)
  (use "git rebase --continue" once you are satisfied with your changes)

(à noter que ces messages ont été ajoutés par des étudiants Ensimag en projet de spécialité !)

En résumé

git rebase -i

Démarrer un rebase interactif (vers la branche amont par défaut)

git rebase -i HEAD~3

Démarrer un rebase interactif sur les 3 derniers commits de la branche courante (attention, dangereux si l'un de ces commits est déjà publié)

git rebase --continue

Continuer un rebase après une pause (pour résoudre un conflit ou suite à la commande edit)

git rebase --abort

Abandonner un rebase, et revenir à l'état où on était avant de démarrer le rebase

git rebase --skip

Sauter le commit courant pendant un rebase. Attention, si le commit en question contenait des changements importants, ils sont perdus.

Plus de documentation

Quand on a cassé quelque chose : git reflog

Même un bon utilisateur comme Charlie se trompe parfois. Il vient de terminer un rebase mais se rend compte que l'ancien historique était mieux que le nouveau. Comment revenir en arrière ?

Pas de panique ... Git enregistre les changements de destination des références du dépôt local (le commit courant, HEAD, est une référence, et chaque branche est une référence) dans ce qu'il appelle le « reflog » (reference log). Après un rebase, il peut ressembler à ceci :

$ git reflog show HEAD
080927e (HEAD, master) HEAD@{0}: rebase finished: returning to refs/heads/master
080927e (HEAD, master) HEAD@{1}: rebase: Un commit local
326c951 (une-branche) HEAD@{2}: rebase: checkout une-branche
8460cd4 HEAD@{3}: commit: Un commit local

Avant le rebase, on se trouvait sur le commit 8460cd4. On voit ce qu'a fait git rebase pour nous : se placer sur la branche une-branche, puis ré-appliquer notre commit local. On peut avoir une vision moins détaillée en affichant le reflog de la branche courante au lieu de HEAD :

080927e (HEAD, master) master@{0}: rebase finished: refs/heads/master onto 326c951a98554b2404a6858ac1b77
8460cd4 master@{1}: commit: Un commit local

Tout ceci est purement local, et ne sera pas envoyé à l'archive partagée par git push. C'est l'historique des brouillons de Charlie, et Dave n'y a pas accès.

Revenons à notre problème : Charlie veut annuler le rebase qu'il vient de terminer. Il s'assure qu'il n'a pas fait de git push du résultat, sinon il est déjà trop tard. Dans l'historique, il remarque que le commit sur lequel il veut revenir est 8460cd4, qu'il peut appeler master@{1} (commit numéro 1 dans le reflog de master) ou HEAD@{3}. Il peut faire :

$ git reset --keep master@{1}

HEAD et la branche courante (master) pointent maintenant sur cet ancien commit. Vu que c'est le commit qui contient les références vers ses parents, et donc sur la suite de l'historique, on vient également de restaurer l'historique dans l'état où il était avant rebase.

Contrairement à l'historique Git, le reflog n'a pas vocation à être propre, et il est rare de remonter loin en arrière. Il reste un outil précieux pour réparer les dégâts après une bêtise !

Plus de documentation