Git bundle, bisect et rebase

Retour à la page Systèmes

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

Supposons qu’il soit impossible au dépôt «git-local» de récupérer ces modifications par un «git pull» comme il le ferait habituellement.
À la place, créons un fichier récapitulant les modifications survenues de tag2 à tag5:
$ 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

La commande git bisect aide à trouver dans quel commit un bug est apparu.
Dans cet exemple minimal, le bug cherché est simplement la disparition d’une chaîne dans un fichier.

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.

En fait, faire ainsi n’est pas recommandé (vous allez voir pourquoi): l’exemple utilise simplement cette situation problématique pour montrer les commandes qui rattrapent le coup.
Cette situation initiale est:
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
La différence ici est que le conflit concerne précisément B et C, alors que pour «merge» c’était le cumul de C et D qui causait le problème.
Mieux vaut prendre le conflit à la racine, n’est-ce pas? Pour cette fois arbitrons en faveur de C:
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