Git bundle, bisect et rebase
Motivation
Ce article vient du besoin d'exercices aussi simples que possible pour démystifier certaines commandes a priori intimidantes de Git.
Chaque commande exposée l'est avec une utilisation typique, après quelques instructions construisant un exemple élémentaire sans rien télécharger.
Les exemples ont été testés dans un environnement Linux pour Git 2.9.3; écrits pour le shell bash, ils s’adapteront sans peine aux autres shells.
Git bundle
Un «bundle» git est simplement un fichier récapitulant les différences entre certains commits. Il s’utilise comme un dépôt réseau accessible en lecture, avec git remote; les commandes créant et vérifiant un bundle sont fournies par git bundle;
Situation de départ
Créons un petit dépôt Git «git-distant» avec un bref historique:
mkdir git-distant cd git-distant git init . ; touch fichier; git add fichier echo A > fichier ; git commit -a -m 'msg1' ; git tag 'tag1' echo B > fichier ; git commit -a -m 'msg2' ; git tag 'tag2'
Clonons ce dépôt dans «git-local»:
$ cd .. $ git clone git-distant git-local
Ajoutons quelques «commit» au dépôt original
$ cd git-distant $ echo C > fichier ; git commit -a -m 'msg3' ; git tag 'tag3' $ echo D > fichier ; git commit -a -m 'msg4' ; git tag 'tag4' $ echo E > fichier ; git commit -a -m 'msg5' ; git tag 'tag5'
Création et vérification du bundle
$ git bundle create msg2-a-5.bundle tag2..tag5
Copions ce fichier vers le clone:
$ cp msg2-a-5.bundle ../git-local/ $ cd ../git-local
Vérifions que le clone sait appliquer le contenu du bundle (et qu’il possède bien les révisions sur lesquelles se base le bundle):
$ git bundle verify msg2-a-5.bundle The bundle contains this ref: ca32b0dcdf1e067b3cdab6e32bb9d9e0caacf489 refs/tags/tag5 The bundle requires this ref: 1e41dff3f89d2f070f768b0e8867fa38503f46ad msg2-a-5.bundle is okay
Import des modifications à partir du bundle
Importons les modifications
$ git fetch msg2-a-5.bundle master fatal: Couldn't find remote ref master
Pourquoi ce refus ? En fait git ne connaît que l’emplacement d’où a été fait le git clone. Il ignore d’où sort msg2-a-5.bundle et ne lui fait pas confiance.
Ajoutons donc le bundle à la liste des sources connues, sous le nom bundle-file
$ git remote add bundle-file msg2-a-5.bundle
Récupérons les modifications
$ git fetch bundle-file From msg2-a-5.bundle * [new tag] tag5 -> tag5
Et pointons la modification la plus récente:
$ git checkout tag5
Git bisect
Arborescence d’exemple minimale
Créons une arborescence git avec quelques versions d’un fichier fichier.txt, pour lequel le «bug» (i.e. la disparition de la chaîne «ok») arrive un peu avant la fin:
git init . touch fichier.txt git add fichier.txt echo ok1 > fichier.txt; git commit -a -m 'tag1'; git tag tag1 echo ok2 > fichier.txt; git commit -a -m 'tag2'; git tag tag2 echo ok3 > fichier.txt; git commit -a -m 'tag3'; git tag tag3 echo KO4 > fichier.txt; git commit -a -m 'tag4'; git tag tag4 echo KO5 > fichier.txt; git commit -a -m 'tag5'; git tag tag5
Voici le résultat attendu. Le «ok» disparaît à partir de «tag4».
$ git log --oneline --decorate 61c5ed3 (HEAD -> master, tag: tag5) tag5 ac0d8f4 (tag: tag4) tag4 1ca6943 (tag: tag3) tag3 1949280 (tag: tag2) tag2 08f705a (tag: tag1) tag1
Recherche manuelle par git bisect good/bad
Commençons par indiquer une version sans bug, et une version avec bug:
$ git bisect start Previous HEAD position was 94fcc40... tag4 Switched to branch 'master' $ git bisect good tag1 $ git bisect bad tag5 Bisecting: 1 revision left to test after this (roughly 1 step)
Ensuite, cherchons ok dans le fichier, et indiquons good ou bad à git bisect pour affiner la recherche:
$ grep ok fichier.txt ok3 $ git bisect good Bisecting: 0 revisions left to test after this (roughly 0 steps) $ grep ok fichier.txt $ git bisect bad 94fcc..... is the first bad commit
C’est tout !
Recherche automatique par git bisect run
On remarquera que si Git avait utilisé directement le statut de la commande grep, nous aurions évité quelques git bisect.
Eh bien justement la commande git bisect run est faite pour cela:
$ git bisect start Previous HEAD position was 94fcc40... tag4 Switched to branch 'master' $ git bisect good tag1 $ git bisect bad tag5 Bisecting: 1 revision left to test after this (roughly 1 step) [3a957e2139145af9e08f6fc6ff4177d3ecfad5a1] tag3 $ git bisect run grep ok fichier.txt running grep ok fichier.txt ok3 ... running grep ok fichier.txt 94fcc..... is the first bad commit ... bisect run success
En pratique, il suffit de remplacer la commande grep ok par n’importe quel script dont le statut de sortie révèle la présence du bug.
Retour à la situation de départ
Après la découverte de l’origine du bug, on peut revenir à la situation de départ: Git était passé dans un état detached HEADpour mener sa recherche, et la commande git bisect reset revient au commit précédant le git bisect start:
$ git status HEAD detached at 94fcc40 You are currently bisecting, started from branch 'master'. (use "git bisect reset" to get back to the original branch) $ git bisect reset Previous HEAD position was 94fcc40... tag4 Switched to branch 'master' $ git status On branch master
Pour aller plus loin
Des exemples plus variés, plus évolués et néanmoins encore simples sont donnés par git help bisect.
Enquiquinements avec rebase
L’opération git rebase <avant> <branche> --onto <depart> rejoue les commits de avant à branche à partir de depart, puis fait pointer branche sur le résultat.
Elle semble donc un outil tout indiqué pour un intégrateur en herbe
Mais son application naïve peut réserver des surprises, en particulier pour le cas exposé ici, où on veut simplement reporter, dans une branche master, les modifications issues d’une branche work qui avait déjà fait des fusions avec master.
A-----B (master) \ \ C--D--E--F (work)
Construction de l’exemple
Conformément au schéma de l’exemple, créons des commits dont la lettre associée se trouve dans le commentaire, dans un tag, et dans un fichier T:
git init . function fcommit () { echo $1>T; git add T; git commit -m "$1"; git tag $1; } fcommit A; fcommit B; git branch work master^ git checkout work fcommit C; fcommit D; git merge master #Conflit résolu dans la ligne la ligne d’après fcommit E; fcommit F;
Première tentative de rebase
Pour ramener les commits E et F de work sur une branche rebase partant de master, on pourrait faire naïvement:
git checkout work git checkout -b rebase git rebase -i master
et s’attendre au résultat suivant:
A-----B(master)--E'--F' (rebase) \ \ C--D--E-------F (work)
Hélas un un conflit apparaît! Le rebase a pris C,D,F alors que le plus court chemin était E,F, et que C,D était déjà intégré dans E. Avant d’aller plus loin, on annule tout:
git rebase --abort
Explication et rattrapage
Qu’est-ce que n’allait pas dans cette façon d’utiliser rebase?
- Premièrement rebase cherche par ordre de premier parent; or E a pour premier parent D, pas B, (comme le montre git log --graph --oneline work ou plus rigoureusement git rev-parse E^1), donc rebase est remonté jusqu’à C;
- Ensuite rebase est mal à l’aise avec les commits de merge, qu’il ignore par défaut;
On peut résoudre le problème en créant une branche temp, reproduisant work, mais dont la remontée par premier parent mène à B;
Pour cela, rejouons E sur temp, avec cherry-pick, pour obtenir un nouveau commit E2 semblable à E, mais avec B pour premier parent; B étant le second parent de E (comme le montre git rev-parse E^2), il faut indiquer notre choix par -m 2 à cherry-pick;
gb temp master git checkout temp git cherry-pick -m 2 E; git tag E2;
A-----B(master)--E2 (temp) \ \ C--D--E-------F (work,rebase)
À partir de là, rejouons les commits qui suivaient E sur la branche rebase:
git rebase -i E rebase --onto E2; git tag F2; git branch -d temp
A-----B(master)--E2--F2 (rebase) \ \ C--D--E-------F (work)
Le dernier rebase a fait pointer la branche du même nom sur E2 et F2, semblables aux E et F originaux de la branche work.
Et comme cette fois-ci B est ancêtre de F par ordre de premier parent, le rebase se passe comme prévu:
git rebase -i master rebase; git merge rebase get branch -d rebase
A-----B--E2--F2 (master) \ \ C--D--E-------F (work)
Ce qu’il aurait fallu faire
Notre situation initiale pose un problème à cause du merge de master vers work; Reprenons la création de l’exemple jusqu’au point qui précède la fusion:
git init . function fcommit () { echo $1>T; git add T; git commit -m "$1"; git tag $1; } fcommit A; fcommit B; git branch work master^ git checkout work fcommit C; fcommit D;
A-----B (master) \ C--D (work)
À ce stade nous avions appelé merge, qui avait causé nos problèmes. Que faire à la place? Eh bien revenir à l’idée sous-jacente: fusionner B et D peut être vu comme à rejouer, depuis B, les modifications apportées en C et D ... ce qui est précisément le rôle de rebase:
git rebase master work --onto master
Bon, on se retrouve avec un conflit, comme pour «merge»:
Applying: C ... CONFLICT (content): Merge conflict in T
echo C > T git add T
Ce qui permet de continuer la fusion:
$ git rebase --continue Applying: C Applying: D
Dans le résultat, le commit D' est en fait l’équivalent du commit E issu de la fusion dans l’exemple initial:
A-----B (master) \ C'--D' (work*)
L’histoire est redevenue linéaire: la branche work peut alors ajouter son commit F, et un rebase vers master se fera facilement:
fcommit F
A-----B (master) \ C'--D'--F (work*)
Du point de vue de master, il est cohérent d’avoir un seul commit à partir de C',D' et F. On peut le préparer sur une branche temporaire:
git checkout -b rebase git rebase -i master
Si l’on indique au rebase l’action squash pour les commits D' et F, le résultat est:
A-----B---------. (master) \ \ \ F' (rebase*) \ C'--D'--F (work)
Et la fusion se fait enfin vers master
git checkout master git merge rebase #Fast-forward grâce à la linéarité git branch -d rebase
A-----B---------F' (master*) \ C'--D'--F (work)
Voir l’article de fond sur git-attitude.fr
Git diff et patch
La vénérable commande patch applique une liste de différences à certains fichiers de texte. A moins d’être masochiste, on ne produit pas les fichiers pour patch manuellement, mais par des commandes comme:
- git diff pour comparer deux versions d’un fichier ou d’un répertoire;
- diff qui compare des fichiers de texte.
En fait, et selon les options, diff et patch comprennent plusieurs formats; Nous nous limiterons à celui que produit par défaut la commande git diff.
Commençons par un historique simple: création de contenu dans un fichier, création d’un fichier, suppression d’un fichier.
git init . touch f1 git add f1 ; git commit -a -m 'msg1' ; git tag 'tag1' echo A>f1 ; git commit -a -m 'msg2' ; git tag 'tag2' echo B>f2 ; git add f2; git commit -a -m 'msg3'; git tag 'tag3' git rm f1; git commit -a -m 'msg4'; git tag 'tag4' echo B>f2 echo BnCnDnEnB | tr 'n' '\n' >f3
La consultation des logs devrait alors ressembler à ceci:
$ git log --oneline --decorate 00c3fd4 (HEAD -> master, tag: tag4) msg4 0581a77 (tag: tag3) msg3 530fdc2 (tag: tag2) msg2 bf97e7d (tag: tag1) msg1
Créons les patches concernant chaque changement:
git diff tag1 tag2 >patch-1-2 git diff tag2 tag3 >patch-2-3 git diff tag3 tag4 >patch-3-4
Examinons le contenu des patches:
$ cat patch-1-2 diff --git a/f1 b/f1 index e69de29..f70f10e 100644 --- a/f1 +++ b/f1 @@ -0,0 +1 @@ +A
On constate en particulier que
- La version originale est notée --- a/f1 et la version modifiée +++ b/f1, comme si elles appartenaient à deux répertoires fictifs a et b
- La ligne diff, qui répète le nom des fichiers comparés, et la ligne index, qui ne concerne que Git, sont ignorées par patch (qui ne s’intéresse qu’à ce qui suit ---)
$ cat patch-2-3 diff --git a/f2 b/f2 new file mode 100644 index 0000000..223b783 --- /dev/null +++ b/f2 @@ -0,0 +1 @@ +B
- Les créations de fichiers sont donc signalées comme si l’original était /dev/null
- La mention new file ne concerne que Git et est en fait ignorée par patch
$ cat patch-3-4 cat patch-3-4 diff --git a/f1 b/f1 deleted file mode 100644 index f70f10e..0000000 --- a/f1 +++ /dev/null @@ -1 +0,0 @@ -A
Même chose pour une suppression totale de fichier, sauf que c’est la cible qui est prétendument /dev/null.
$ diff --unified f2 f3 --- f2 2016-12-19 00:05:17.872794543 +0100 +++ f3 2016-12-19 00:23:35.684238038 +0100 @@ -1,2 +1,5 @@ B +C +D +E B
Les lignes précédant --- sont cette fois-ci absentes: en effet le patch a cette fois-ci été produit par la commande diff, l’option --unified servant à s’assurer que le format est celui compris par Git.
Cette comparaison complète nos renseignements en éclaicissant le format de la ligne @@: Le format est @@ -la,La +lb,Lb @@ où
- la et lb indiquent la première ligne de la zone modifiée, dans f2 puis dans f3;
- La et Lb sont les longueurs de la zone modifée, dans f2 puis dans L3;
- Les valeurs La ou Lb sont escamotées si la longueur est 0 (création ou suppression du fichier entier);
Un recoupement avec les fichiers de patch précédents éclaircit la façon d’écrire les lignes concernées:
- un espace précède celles communes à f2 et f3
- un - précède celles qui ne sont que dans f2
- un + précède celles qui ne sont que dans f3
- Compter les lignes précédées d’un espace ou d’un - donne La
- Compter les lignes précédées d’un espace ou d’un + donne Lb