27 juin 2020

Git: quelques commandes avancées.

Dans l’article précédent, j’ai expliqué rapidement les bases de Git. Dans cet article, je montrerai quelques commandes avancées.

git reset

La commande git reset permet de modifier votre espace de travail local pour le faire pointer sur le commit (ou une référence de commit) de votre choix.

Reprenons notre projet Git créé précédemment, et créons un nouveau commit sur master:

$ echo "f3" > f3 && git add f3 && git commit -m "f3"
[master 15b0637] f3
 1 file changed, 1 insertion(+)
 create mode 100644 f3

Voici l’output de git log montrant les deux derniers commits, dont celui que nous avons fait:

git log
commit 82cd1525631214e33ddbebf852bc4dbfb8431154 (HEAD -> master)
Author: mcorbin <corbin.math@gmail.com>
Date:   Sat Jun 27 11:05:39 2020 +0200

    f3

commit 67f675e9b9a3a34df6b6bd20e474980498de6332
Author: mcorbin <corbin.math@gmail.com>
Date:   Sat Jun 27 10:12:06 2020 +0200

    f2

Imaginons maintenant qu’en fait, nous ne voulons plus voir ce commit f3 (par exemple, nous avons fait une erreur dans f3 et voulons modifier son contenu mais nous ne voulons pas créer un second commit contenant les modifications, nous voulons un seul commit). git reset permet de réaliser cela de deux façons:

  • git reset <reference>, comme par exemple git reset 67f675e9b9a3a34df6b6bd20e474980498de6332. Ici, le commit f3 est supprimé (non visible dans git log), mais ses modifications sont conservées:

$ git status
Sur la branche master
Votre branche est en avance sur 'origin/master' de 3 commits.
  (utilisez "git push" pour publier vos commits locaux)

Fichiers non suivis:
  (utilisez "git add <fichier>..." pour inclure dans ce qui sera validé)

	f3

aucune modification ajoutée à la validation mais des fichiers non suivis sont présents (utilisez "git add" pour les suivre)

On voit ici que notre fichier f3 est toujours présent mais hors de l’index. Vous pouvez maintenant le modifier et refaire un nouveau commit.

  • git reset --hard <reference>, par exemple git reset --hard 67f675e9b9a3a34df6b6bd20e474980498de6332. Ici, le commit est supprimé, mais les modifications de ce commit sont également supprimées. Cela est utile si les modifications du commit supprimé ne vous intéressent plus.

Il est bien sûr possible d’utiliser git reset pour supprimer les modifications de plusieurs commits.
La syntaxe git reset HEAD~<nombre>, comme git reset HEAD~2 permet de faire un git reset sur les 2 derniers commits (git reset HEAD~1 serait donc équivalent aux commandes d’exemples montrées précédemment, car lançant la commande sur le dernier commit). Cette syntaxe permet d’éviter de devoir connaître le hash d’un commit pour revenir en arrière.

De manière générale, git reset est super utile dès que vous voulez revenir sur un commit spécifique, que ce soit sur votre travail local ou un dépôt distant (remote).

Imaginez par exemple que vous avez fait n’importe quoi sur votre branche "master" local, et que vous voulez revenir à l’état du "master" remote (le remote étant nommé origin). Vous pouvez exécutez:

  • git fetch origin: récupère l’état du remote origin, mais sans modifier votre dépôt local.

  • git reset --hard origin/master: modifie votre état local pour refléter exactement l’état de la branche master sur le remote origin.

Cela m’arrive d’utiliser git reset de cette façon quand une branche locale et distante ont trop divergées, et où je veux juste retrouver localement l’état de mon remote.

git rebase

Une autre commande indispensable est git rebase, qui permet de faire plusieurs choses.

Fusionner des commits, modifier des messages de commit…​

Créons 3 nouveaux commits:

$ echo "f4" > f4 && git add f4 && git commit -m "f4"
$ echo "f5" > f5 && git add f5 && git commit -m "f5"
$ echo "f6" > f6 && git add f6 && git commit -m "f6"

Nous pouvons utiliser la commande git rebase -i <reference> pour réaliser plusieurs actions. Exécutons par exemple git rebase -i HEAD~3.

Git devrait ouvrir un éditeur, et va vous demander ce que vous voulez faire. Par exemple, Git m’a ouvert Emacs avec un contenu que je résume ici:

pick c6eeda9 f4
pick 1996397 f5
pick c5c7447 f6

On voit ici nos 3 commits, avec le mot-clé pick devant. Si vous ne changez rien, et fermez votre éditeur, votre branche n’aura pas changée. En effet, si un commit est préfixé par pick, il sera conservé sans aucun changement.

Il existe d’autres préfixes possibles pour réaliser des actions. Voici un extrait de la documentation (il existe d’autres options que vous pouvez trouver dans la documentation, mais celles présentées ici sont selon moi ceux les plus utiles):

  • pick <commit>: utiliser le commit

  • reword <commit>: utiliser le commit, mais reformuler son message

  • edit <commit>: utiliser le commit, mais s’arrêter pour le modifier

  • squash <commit>: utiliser le commit, mais le fusionner avec le précédent

  • fixup <commit>: comme "squash", mais en éliminant son message

Pour chaque commit, vous pouvez donc modifier pick par une autre action si nécessaire.

Imaginons que je veuille fusionner le commit f5 dans f4 (donc les modifications de f5 seront présentes dans f4), sans changer le message de f4, et que je veuille conserver f6 mais en modifiant son message. Je vais éditer le fichier avec:

pick c6eeda9 f4
fixup 1996397 f5
reword c5c7447 f6

Une fois le fichier fermé, Git m’ouvrira une nouvelle fenêtre pour éditer le message de f6, que je vais changer en "f6: nouveau message".

Une fois la modification terminée, j’exécute git log:

commit 01eb150ad654cddaee9bececef2f29c64023a4d9 (HEAD -> master)
Author: mcorbin <corbin.math@gmail.com>
Date:   Sat Jun 27 11:17:50 2020 +0200

    f6: nouveau message

commit ebc389fcc34ef6cd5c56dca080461b13a497d594
Author: mcorbin <corbin.math@gmail.com>
Date:   Sat Jun 27 11:17:29 2020 +0200

    f4

On voit que f5 a disparu (car fusionné par f4 qui contiendra ses modifications), et le message de f6 a été modifié.

Il est possible à tout moment d’arrêter un rebase avec git rebase --abort.

En résumé, git rebase permet entre autre de modifier votre historique de commit.

git rebase pour récupérer des modifications

La commande git rebase permet aussi de facilement récupérer des modifications d’une branche (présente en local ou sur un remote) à une autre. Commençons par créer une nouvelle branche, ajoutons un commit dessus, puis ajoutons 2 nouveaux commits sur master:

# création d'une nouvelle branche nommée "new-branch"
git checkout -b "new-branch"

# création d'un commit sur cette branche
echo "new-branch" > new-branch && git add new-branch && git commit -m "new-branch"

# retour sur la brance master
git checkout master

# créations d'un premier commit sur master
echo "master1" > master1 && git add master1 && git commit -m "master1"

# créations d'un second commit sur master
echo "master2" > master2 && git add master2 && git commit -m "master2"

L’état de votre projet local est donc maintenant:

Etat du projet après ajout des commits

Imaginons maintenant que je souhaite récupérer sur ma branche "new-branch" les modifications de "master", pour pouvoir par exemple tester que ma branche fonctionne toujours avec ces nouveaux ajouts. Je peux utiliser git rebase pour cela:

# on se déplace sur la branche "new-branch"
$ git checkout new-branch

# on lance git rebase pour réappliquer les changements de master sur notre branche
$ git rebase master
Rembobinage préalable de head pour pouvoir rejouer votre travail par-dessus...
Application de  new-branch

git rebase master va prendre les commits ajoutés sur master depuis la création de la branche "new-branch", et les réappliquer un par un sur "new-branch". l’arbre ressemble maintenant à ça:

Etat du projet après git rebase

On voit que notre branche contient maintenant les commits de "master", comme si vous veniez de la créer depuis le dernier commit présent sur "master".

Bien sûr, git rebase fonctionne dans de nombreux contextes. Si je suis sur une branche et que je veux réappliquer les nouveaux changements de mon dépôt distant origin, je peux faire par exemple:

  • git fetch origin: récupère l’état du remote origin, mais sans modifier votre dépôt local.

  • git rebase origin/master: réapplique les changements de la branche "master" du remote origin sur ma branche.

je montre ici des exemles avec "master", mais la même chose est faisable pour vos autres branches.

A noter que si vous avez des conflicts entre commits, le rebase s’arrêtera pour vous laisser les corriger. Une fois le conflit corrigé, vous devrez parfois (selon votre correction) rajouter les fichiers corrigés dans l’index avec git add, et puis exécuter git rebase --continue pour continuer le rebase.

L’avantage de récupérer des modifications avec git rebase par rapport à git merge (voir mon article précédent sur Git pour un exemple de merge) est que vous ne polluerez pas votre arbre Git avec des commits de merge, vous arbre Git sera toujours propre.
Comme dit dans mon article pécédent, il est d’ailleurs possible de configurer git pull pour que la commande récupère les modifications distantes en faisant des rebase et non des merge.

Connaître git rebase est indispensable selon moi lorsque l’on utilise Git.

Fork

D’ailleurs, si vous créeez un fork (par exemple sur Github) d’un projet Git, vous utiliserez probablement rebase pour récupérer les modifications du projet original.

En effet, le projet original ne sera qu’un dépôt distant (remote) supplémentaire (généralement appelé upstream). Donc pour récupérer par exemple les modifications de upstream sur votre fork, vous pouvez faire:

  • git fetch upstream: récupère les changements du remote upstream.

  • git rebase upstream/master: applique les nouveaux commits de la branche "master" du remote upstream sur mon projet local (mon fork).

git push --force

Les commandes git reset et git rebase ont one chose en commun: elles peuvent modifier l’historique Git. Cela peut être problématique quand votre historique local est différent de l’historique remote. Un exemple:

# poussons tout d'abord nos derniers changements sur master
$ git push origin master

# création d'un nouveau commit
$ echo "nouveau fichier" > nouveau_fichier && git add nouveau_fichier && git commit -m "nouveau_fichier"

# on repousse notre nouveau commit sur master
$ git push origin master

# on utilise `git reset` pour supprimer le dernier commit de notre travail local
$ git reset --hard HEAD~1

# on essaye de repousser notre travail local sans ce commit sur le dépôt distant

$ git push origin master
To github.com:mcorbin/test1.git
 ! [rejected]        master -> master (non-fast-forward)
error: impossible de pousser des références vers 'git@github.com:mcorbin/test1.git'
astuce: Les mises à jour ont été rejetées car la pointe de la branche courante est derrière
astuce: son homologue distant. Intégrez les changements distants (par exemple 'git pull ...')
astuce: avant de pousser à nouveau.
astuce: Voir la 'Note à propos des avances rapides' dans 'git push --help' pour plus d'information.

Comme vous pouvez le voir, il est impossible de pousser notre travail car à cause du git reset, les deux historiques ont divergés. Une solution est de faire un push avec --force:

$ git push origin master --force
Total 0 (delta 0), réutilisés 0 (delta 0)
To github.com:mcorbin/test1.git
 + eddbc49...8d076bb master -> master (forced update)

Ici, notre dépôt local a écrasé le dépôt distant (donc si certaines modifications étaient seulement présentes sur le dépôt distant, elles seront perdues).

Les gens ont parfois peur d’utiliser git push --force, car ce serait une mauvaise pratique. Je ne suis pas d’accord avec cette affirmation.

Modifier l’historique de la branche "master" avec --force (ou votre branche contenant votre code stables, releases…​) est une mauvaise pratique car ces branches doivent rester immuables.
Mais rien n’empêche de faire un git push --force sur une branche de travail, après un rebase par exemple. Vérifiez juste avant que vous n’allez pas écraser les modifications du voisin si vous êtes plusieurs à travailler sur la même branche, mais sinon il n’y a aucun soucis à utiliser cette option.

git stash

Le concept de git stash est simple: les modifications locales sont sauvegardées par git et ne seront plus visibles dans git status. Vous pouvez ensuite les réappliquer sur une autre branche, ou plus tard sur la même branche avec git stash pop. Un exemple tout de suite:

# création et commit d'un nouveau fichier appelé "stash"
$ echo "stash" > stash && git add stash && git commit -m "stash"

# édition de ce fichier
$ echo "edition" >> stash

# on voit dans git status que ce fichier a été modifié
$ git status
Sur la branche master
Votre branche est en avance sur 'origin/master' de 1 commit.
  (utilisez "git push" pour publier vos commits locaux)

Modifications qui ne seront pas validées :
  (utilisez "git add <fichier>..." pour mettre à jour ce qui sera validé)
  (utilisez "git checkout -- <fichier>..." pour annuler les modifications dans la copie de travail)

	modifié :         stash

aucune modification n a été ajoutée à la validation (utilisez "git add" ou "git commit -a")

# sauvegarde de la modification dans git stash
$ git stash
Copie de travail et état de l index sauvegardés dans WIP on master: 7c11f18 stash

# "git status" ne voit plus aucun changement
$ git status
Sur la branche master
Votre branche est en avance sur 'origin/master' de 1 commit.
  (utilisez "git push" pour publier vos commits locaux)

rien à valider, la copie de travail est propre

Un fois des modifications sauvegardées avec git stash, vous pouvez les réappliquer ailleurs. Appliquons les par exemple sur une autre branche:

# création d'une branche nommée "branch-stash-example"
$ git checkout -b branch-stash-example

# utilisation de "git stash pop" pour appliquer les changements sauvegardés à la branche
$ git stash pop
Sur la branche branch-stash-example
Modifications qui ne seront pas validées :
  (utilisez "git add <fichier>..." pour mettre à jour ce qui sera validé)
  (utilisez "git checkout -- <fichier>..." pour annuler les modifications dans la copie de travail)

	modifié :         stash

aucune modification n a été ajoutée à la validation (utilisez "git add" ou "git commit -a")
refs/stash@{0} supprimé (443e3b9ac712a6a13ed6e0abab8cf45a520d6d9b)

En conclusion, git stash est très utile pour déplacer des changements d’une branche à une autre.
Vous pouvez même la combiner avec git reset déplacer un commit d’une branche à une autre. Par exemple, commitons notre changement dans branch-stash-example, puis utilisons git reset pour l’annuler, puis git stash pour redéplacer sur master les modifications (donc l’inverse de ce que nous avons fait précédemment):

# ajout à l'index et commit de notre modification sur le fichier "stash"
$ git add stash && git commit -m "stash"
[branch-stash-example 4fda3ae] stash
 1 file changed, 1 insertion(+)

# utilisation de "git reset" pour annuler le commit mais en conservant les changements appliqués au fichier stash
$ git reset HEAD~1
Modifications non indexées après reset :
M	stash

# utilisation de "git stash" pour sauvegarder la modification
$ git stash
Copie de travail et état de l index sauvegardés dans WIP on branch-stash-example: 7c11f18 stash

# basculement sur la branche master
$ git checkout master

# utilisation de `git stash pop` pour réappliquer la modification.
$ git stash pop
Sur la branche master
Votre branche est en avance sur 'origin/master' de 1 commit.
  (utilisez "git push" pour publier vos commits locaux)

Modifications qui ne seront pas validées :
  (utilisez "git add <fichier>..." pour mettre à jour ce qui sera validé)
  (utilisez "git checkout -- <fichier>..." pour annuler les modifications dans la copie de travail)

	modifié :         stash

aucune modification n a été ajoutée à la validation (utilisez "git add" ou "git commit -a")
refs/stash@{0} supprimé (6a864df4969964e25d1193b7e45958ce870e4c6d)

Et voilà ;)

git cherry-pick

Cette commande permet de prendre un commit (qui peut être par exemple dans une autre branche) et de l’appliquer à votre branche courante. Par exemple:

# création d'une branche nommée "cherry-pick-example"
$ git checkout -b cherry-pick-example

# création d'un commit sur cette branche
$ echo "pick" > pick && git add pick && git commit -m "pick"

# "git log" nous montre ce commit et son hash
$ git log
commit bdb371a661586357607c1b787a1c1f39afc0b033 (HEAD -> cherry-pick-example)
Author: mcorbin <corbin.math@gmail.com>
Date:   Sat Jun 27 12:13:37 2020 +0200

    pick

# retour sur la branche "master"
$ git checkout master

# on prend le commit créé précédemment et on l'applique sur master avec cherry-pick
$ git cherry-pick bdb371a661586357607c1b787a1c1f39afc0b033

# on voit miantenant notre modification sur master.
$ git log
commit a14f9920e601d4de3e972b3fd24e9be8a7957c4e (HEAD -> master)
Author: mcorbin <corbin.math@gmail.com>
Date:   Sat Jun 27 12:13:37 2020 +0200

    pick

On voit ici que le commit ajouté sur master a un hash différent que celui de la branche. C’est en effet un comportement de cherry-pick.

Combiner les commandes

Une fois qu’on connait toute ces commandes, il faut savoir quand les utiliser et comment les combiner. Cela vient avec l’expérience.

On a vu par exemple que combiner git reset et git stash peut être utile. Pour sortir de situations difficiles, ces commandes doivent généralement être utilisées ensemble.

Toutes ces commandes peuvent prendre des options, vous pouvez donc lire leurs pages de documentation pour connaître tout ce qu’il est possible de faire avec.

Conclusion

Il existe un grand nombre de commandes Git très utiles. Par exemple, jetez un coup d’oeil à git bisect (qui mériterait un article dédié), qui permet de facilement trouver l’origine d’un bug en vous déplaçant de manière optimisée dans un historique git.

Je voulais surtout dans cet article présenter celles indispensables selon moi.

Tags: programming
Top of page