Il y a 10 ans -
Temps de lecture 11 minutes
Craftsman Recipes: Refactorez votre commit log avec Git
Depuis quelques temps, les systèmes de gestion de sources distribués connaissent un regain d’intérêt fulgurant. Le plus connu est sans conteste Git avec une plateforme en ligne, Github.
Git est un outil qui repose sur un concept simple mais qui est redoutablement efficace.
Que peut offrir Git au développeur de plus qu’un système classique à la SVN ? Il n’y aura pas ici d’introduction au fonctionnement de Git mais plus une liste de bonnes pratiques. Car oui, le commit log est un livrable qui fait partie intégrante de l’application. En tant que craftsman, nous portons un intérêt à faire les choses proprement. L’historique des commits doit pouvoir être aussi propre que notre code. Nous allons voir comment Git nous permet de faire du refactoring de commit et de travailler de façon incrémentale et sûre.
Je ne reviendrai ici ni sur la structure de Git (différence entre stage, commit et push) ni sur les commandes de base :
- git add
- git commit
- git checkout
Si ces concepts ne vous sont pas familiers, je vous conseille d’acheter ou de lire en ligne gratuitement ce guide complet.
Nous allons prendre ici pour exemple la construction d’un projet simpliste Java. Il n’y a pas besoin de repository distant. C’est là une des magie de Git, on peut tout tester en local ! Pour MacOS, je vous conseille GitX (L) comme outil graphique de visualisation de commit, Simple et efficace.
Tout au long de l’article, j’utiliserai la commande suivante pour lister les commits :
git log --oneline
git commit –amend
Tout d’abord, je crée un projet Java avec son pom.xml dans mon IDE préféré. Je crée ensuite le fichier .gitignore puis ajoute le pom.xml et ce fichier à Git pour enfin créer mon premier commit (69f904b). Je peux maintenant commencer à développer. Mais pour cela, il me faut au moins JUnit !
J’ajoute la dépendance au pom.xml et je crée deux classes fr.xebia.blog.User et sa classe de test. Je crée un commit uniquement avec la modification du pom.xml pour le séparer du commit de création de classes.
Si je regarde la branche master, j’ai la suite de commits :
31be36f Add the JUnit dependencies 69f904b Init
Pour tester le projet je lance mvn clean test. Je me rends alors compte que le répertoire target n’est pas ignoré. Je modifie le .gitignore dans ce sens. Seulement, je souhaite ajouter cette modification à mon commit de modification du pom.xml. Comment faire si ce commit est déjà effectué ? Avec Git, même si le commit à déjà été fait, je peux encore le changer !
J’ajoute la modification du .gitignore et je commit avec git commit –amend. Voici maintenant que le commit log :
4f9194e Add the JUnit dependencies 69f904b Init
Comme attendu, je n’ai que deux commits. Seulement le SHA1 est différent ! Pourquoi ? Parce qu’un commit est créé une seule fois et n’est pas modifiable. En faisant un amend, Git a créé un tout nouveau commit qui contient les modifications du premier commit en plus de celles que vous venez d’ajouter. Tout cela est bien sûr transparent.
Il est facile de se rendre compte de cela en ajoutant des tags sur les commits avant et après l’opération.
Je peux maintenant faire le commit de mes classes. Au final, j’ai le commit log suivant :
7c0d84a Create user 4f9194e Add the JUnit dependencies 69f904b Init
rebase interactif
Je viens de créer mes classes mais je me rends compte que j’ai oublié de mettre JUnit en scope test dans mon POM. J’aimerais bien que cette modification soit dans le commit d’ajout de JUnit. Comment faire pour modifier un commit qui n’est pas le dernier créé ? La solution : git rebase -i
Il permet tout simplement de réécrire l’histoire. Démonstration :
Tout d’abord, je commites la modification du scope dans le pom.xml. J’ai alors les logs suivants :
6f474a0 Add Junit in scope test 7c0d84a Create user 4f9194e Add the JUnit dependencies 69f904b Init
Nous allons ensuite fusionner les commits 6f474a0 et 4f9194e. Pour cela, je peux réécrire l’histoire de mes trois derniers commits :
git rebase -i HEAD~3
Git affiche ensuite le commit log courant avec des opérations possibles, je choisi de déplacer les commits de place pour placer l’ajout du scope sous l’ajout de la dépendance. Comme opération sur ce commit, je choisi fixup.
pick 4f9194e Add the JUnit dependencies fixup 6f474a0 Add Junit in scope test pick 7c0d84a Create user
J’enregistre mes modifications et voici le résultat:
e3f366e Create user e888982 Add the JUnit dependencies 69f904b Init
Je n’ai toujours que trois commits. Le commit 4f9194e est devenu e888982 et 7c0d84ae3f366e. Le premier commit a changé car nous avons ajouté son contenu. Par contre, pourquoi le dernier commit a été modifié ?
Un commit est composé entre autre :
- une liste de modification ;
- à partir d’un ensemble de commits parents (il peut y en avoir plusieurs dans le cas d’un merge).
Si j’inspecte ce commit de création de classe avant l’opération de rebase, voici le détail des informations dans Gitx
Son parent est le commit 4f9194e.
Voici le détail du nouveau commit :
Son parent est le commit e888982, c’est à dire le nouveau commit contenant les deux modifications fusionnées.
Si on regarde l’ensemble du commit log, voici ce que l’on obtient :
On retrouve toutes les branches qui ont été créées automatiquement par Git. Mais la branche master est quant à elle propre à ce qui est le résultat attendu.
rebase interfactif + merge
J’ai ajouté deux commits sur master dont voici la log:
fcda951 THAT'S A CHANGE a85c4e9 USELESS CHANGES e3f366e Create user e888982 Add the JUnit dependencies 69f904b Init
On est jeudi soir, et je dois créer une version de l’application en urgence ! Seulement sur master, il y a un commit de trop (a85c4e9 USELESS CHANGES). Comment faire ? Je peux faire comme sous SVN : un revert du commit, faire ma version, puis un revert de revert du commit, ce qui est bien mais l’on peut mieux faire. Comme Git me permet de créer des branches facilement, je vais me servir de cette fonctionnalité !
Je créé une branche bugfix au niveau de master avec la commande :
git checkout -b bugfix
Ensuite, j’utilise le rebase interactif pour supprimer le commit choisi.
git rebase -i HEAD~3
Je supprime le commit de ma liste de commandes :
pick 1cbf6bc THAT'S A CHANGE pick e3f366e Create user
Voici le commit-log suite à cette opération :
Je réalise ensuite ma version, c’est à dire :
- un commit de version 1.0 ;
- un tag 1.0 ;
- un commit de passage à la version suivante.
La version est créée. Je peux maintenant revenir sur master et monter la version sur cette branche en faisant un merge de la branche bugfix sur master pour ensuite supprimer cette branche temporaire.
git checkout master git merge bugfix git branch -d bugfix
Le commit log est alors le suivant :
L’avantage de cette méthode est de permettre de modifier l’historique de commit qui ont déjà été poussés sur un repository distant. En effet, c’est sur une branche que nous avons écrit une version parallèle de l’histoire. Je n’ai pas touché à l’historique de master. Cela est au prix d’un commit de merge qui ne sont parfois pas simple à lire. Mais il n’y a pas de revert de revert de revert…
git reset HEAD^
Je viens de faire un commit (bb0b66b WRONG AMEND). Seulement, j’ai mis à l’intérieur deux fichiers qui n’ont rien à voir. Je souhaiterais plutôt avoir deux commits séparés. C’est le rôle de la commande suivante :
git reset HEAD^
Voici le résultat de cette commande dans la console :
Unstaged changes after reset: M src/main/java/fr/xebia/blog/Group.java M src/main/java/fr/xebia/blog/User.java
Je peux ensuite créer maintenant mes deux commits :
git add src/main/java/fr/xebia/blog/Group.java git commit -m "yet another useless commit" git add src/main/java/fr/xebia/blog/User.java git commit -m "another log change"
A la fin, j’ai le commit log attendu :
C’est une commande très utile après un git commit -am ou un git add . un peu abusif.
git checkout
La version de l’application courante est défectueuse et l’on vous demande de tester un algorithme dans une version précédente. C’est le rôle du git checkout. Contrairement aux autres commandes vues, celle-ci ne réécrit pas l’histoire mais permet de s’y promener. Cette commande déplace la tête de lecture (HEAD) sans modifier aucune référence sur les branches.
Je peux par exemple sur mon système de fichier revenir à tout moment au tout premier commit de mon application :
git checkout 69f904b
Voici le message de Git :
Note: checking out '69f904b'. You are in 'detached HEAD' state. You can look around, make experimental changes and commit them, and you can discard any commits you make in this state without impacting any branches by performing another checkout. If you want to create a new branch to retain commits you create, you may do so (now or later) by using -b with the checkout command again. Example: git checkout -b new_branch_name HEAD is now at 69f904b... Init
Vous êtes revenus sur un commit passé qui a déjà un futur. Et il n’est pas possible de réécrire le futur. Alors Git vous prévient et vous propose de créer une branche pour pouvoir en écrire un futur parallèle. Si vous travaillez sur ce commit sans créer de branche, ces commits seront enregistrés mais seront éligibles à n’importe quel moment au garbage collecting de git ! Ils ne seront référencés que par leur SHA1 (identifiant unique).
git reset –hard
Du point de vue de mon système de fichier, cette commande est équivalente à la précédente. Votre système de fichier reflète l’état du projet au commit demandé. Seulement, Git va en plus changer la référence de la branche courante. Voici dans Gitx le détail du dernier commit de la branche master:
On voit que ce commit est référencé par master. Une branche dans Git n’est qu’une référence vers un commit. Avec git reset –hard, je modifie la référence de la branche en cours. Je peux donc reprendre tout mon travail depuis le début avec la commande suivante :
git reset --hard 69f904b
Voici le résultat dans gitx :
C’est maintenant le commit 69f904b qui est référencé par master et mon commit log est vide !
Si vous n’avez pas tagué le commit de master avant, et bien vous l’avez tout simplement perdu. Cette commande est donc pour mois l’une des plus sensibles de Git. A manier avec prudence !
git reflog
Bon, en fait vous n’êtes pas forcément totalement perdu. Il existe encore une porte de sortie dans Git, git reflog. Le reflog contient la liste de tous les mouvements que vous avez fait dans l’historique. Il est donc totalement personnel et n’est pas poussé sur un repository distant. Utilisé avec git reset –hard, il permet de revenir dans un état connu à tout moment. Pour me sauver de la dernière situation, voici ce que me dit la commande git reflog :
69f904b HEAD@{0}: 69f904b3a3226fd36722dedd88d57539163db3ff: updating HEAD 00fccf7 HEAD@{1}: checkout: moving from bb0b66b3b9b4b0e4b238d76700d9dc6b39c19615 to master
HEAD@{0} est la position courante. Effectivement, je suis sur 69f904b. Mais juste avant master était positionné sur 00fccf7. Il me suffit donc de refaire la commande suivante :
git reset --hard 00fccf7
Et j’ai retrouvé la situation initiale.
Le guide du voyageur intergalactique
Comme sur la quatrième de couverture de ce fameux guide, si vous avez un problème avec Git, surtout "DON’T PANIC".
Les forums sont assez fournis en trucs et astuces. Vous retrouverez par exemple ici une "cheat sheet" bien pratique.
Conclusion
Nous l’avons vu, Git est un outil puissant. Entre autre, il permet de retravailler finement ses commits pour un livrable propre. Mais cette puissance a un prix. On peut vite faire des dégâts si l’on ne maitrise pas ce que l’on fait. Si vous souhaitez travailler à nouveau certains commits, voici quelques règles d’or :
- Ne touchez qu’à des commits qui ne sont pas encore sur un repository distant (postérieur aux tags origin/XXXX) ;
- Utilisez le git reflog et reset –hard seulement en cas d’urgence.
Pour bénéficier de toutes ces possibilités, je vous conseille fortement d’activer le mode rebase lors des pull avec la configuration suivante :
git config branch.autosetuprebase always
Cela n’est valable que sur des branches pour lesquelles les durées de vie sont courtes (quelques jours à peine). Sinon, cela risque d’être la chasse au conflit à chaque rebase.
A vous de choisir le mode de merge le plus adapté à votre situation. Mais vaut-il mieux perdre cinq minutes à retravailler ses commits avant de pousser ou alors laisser son prochain se débrouiller en maintenance avec des logs inutilisables ?
Commentaire
4 réponses pour " Craftsman Recipes: Refactorez votre commit log avec Git "
Published by Philippe Miossec , Il y a 10 ans
Une petite astuce qui peut être plus qu’utile et sur laquelle peu de personnes insistent, c’est que Git ne détruit jamais de commit (bon sauf quand le garbage collector passe).
Donc, si on est débutant ou même lorsqu’on « tente » des commandes qu’on a jamais fait, au lieu de recherche dans le reflog a postériori lorsqu’on a merdé, il est fortement de créer une branche de sauvegarde (appelée « save_tmp » par ex) sur le commit où on se trouve et d’effectuer ce que l’on veut. Si on est arrivé au résultat souhaité, on efface l’ancienne branche, sinon, on fait un reset et on peut reessayer!
Rien de mieux pour apprendre et tenter des choses que sinon on n’oserais pas….
Published by Alban , Il y a 10 ans
Illustration intéressante et claire, merci.
Si je peux me permettre une petite remarque : le refactoring de commits est à éviter (euphémisme) si ceux-ci ont déjà été poussés vers un repo distant. Sinon, le résultat risque d’être « amusant ».
Published by Xavier Bucchiotty , Il y a 10 ans
@Philippe
Effectivement, hormis les garbage de Git, rien n’est jamais perdu. On peut donc s’entrainer autant qu’on le souhaite. Qui plus est, Git est distribué donc on peut aussi créer des repository en locale pour jouer avec!
@Alban
Et oui, modifier l’historique dans l’historique avant les marqueurs origin/xxxx est dangereux. Mais tenter de pousser ces modifications sur le repository ne fonctionnera, à moins de jouer avec -f du genre
git commit -am « DEAL WITH IT » && git push -f origin master
(à ne jamais utiliser sauf pour ennuyer ces collègues un jour de départ en vacances!)
Published by Eric Bouchut , Il y a 10 ans
Merci Xavier pour ce billet intéressant.
Pour plus de details sur git reset, je ne saurais trop conseiller la lecture de cet article de Scott Chacon:
http://git-scm.com/2011/07/11/reset.html
Eric.