Jan

Après quelques années à faire passer des entretiens techniques et à accompagner de jeunes développeurs sur la technique, mon constat est sans appel : Git, l’outil de versionning le plus utilisé du marché, n’est toujours pas compris par une bonne partie de ses pratiquants. Alors pour ce début d’année, je laisse le testing et autres pratiques de la mouvance craft de côté pour décrypter et démystifier un peu les mécaniques que cet outil met en œuvre. Mais puisque nous ne pouvons pas tout balayer sur un article, nous allons aujourd’hui voir en détail à quoi correspond un commit !

Pour faire les manipulations de cet article avec moi, pensez à installer git et avoir un outil de ligne de commandes linux (vous pouvez utiliser le gitbash fourni avec l’installation de git sur windows pour les fans de Cortana).

Des objets dans notre base de données locale

Un commit est un snapshot du projet, associé à un timestamp, un auteur, un commit parent et un message. C’est pour cela qu’à chaque fois que vous faites des modifications dans votre projet, vous enregistrez la nouvelle version du projet avec un commit. Attention, Git n’enregistre pas les delta*, il enregistre un nouvel état… C’est juste qu’il le fait de façon super efficace !


Voici l’exemple reprenant 4 commits sur lequel nous allons nous baser pour nos explications :

Git a beau ne pas stocker les deltas*, il ne va pas pour autant se lancer dans la duplication des fichiers inchangés d’un commit à l’autre… Alors que se passe-t-il sous le capot ? 

Il faut voir Git comme une base de données locale située dans le dossier .git de votre projet et qui contient notamment plusieurs types d’objets. Ici, nous allons nous attarder sur les objets de type commit, tree et blob.

Pour voir concrètement ce qu’il se passe, créons notre projet git avec les commandes suivantes :

$ mkdir gitlearning; cd "$_"
$ git init

Nous nous retrouvons alors avec le dossier .git qui va contenir toutes les données permettant d’utiliser notre outil de versionning sur le projet gitlearning :

$ ls .git -a
./  ../  HEAD  config  description  hooks/  info/  objects/  refs/

Nous allons ici surtout nous pencher sur le dossier objects qui va contenir nos différents types d’objets (commit, tree, blob) associés à des hashs.

Commençons par créer l’arborescence de notre exemple avec 3 fichiers dispatchés dans 2 répertoires :

$ mkdir -p src conf
$ echo -n "first content A" > conf/fileA.txt
$ echo -n "first content B" > src/fileB.txt
$ echo -n "first content C" > src/fileC.txt

Si nous regardons les objets créés dans git :

$ find .git/objects/ -type f

Notre dossier .git/objects ne contient effectivement aucune donnée. Et c’est logique puisque ces modifications ne sont que dans le working directory.

Mais si nous préparons notre commit en ajoutant cette arborescence à l’index :

$ git add src/ conf/

Alors le statut de notre repo local change :

$ git status
On branch master

No commits yet

Changes to be committed:
  (use "git rm --cached ..." to unstage)
        new file:   conf/fileA.txt
        new file:   src/fileB.txt
        new file:   src/fileC.txt

Et nous pouvons voir que le dossier objects contient 3 nouveaux fichiers dans 3 répertoires distincts :

$ find .git/objects/ -type f
.git/objects/24/4ecfc79b82adadc926d3409d22bfaa4f1f4938
.git/objects/28/a3d34cf482fab0655efe5d4010e01b0620dfbe
.git/objects/85/83cd25271d252695d2f7606c0740149ea1274c

Si nous concaténons maintenant le nom de dossier et le nom de fichier pour les 3 objets créés, nous obtenons 3 hashs de 40 caractères :

$ find .git/objects/ -type f | cut -c14- | tr -d /
244ecfc79b82adadc926d3409d22bfaa4f1f4938
28a3d34cf482fab0655efe5d4010e01b0620dfbe
8583cd25271d252695d2f7606c0740149ea1274c

Nous pouvons alors interroger git sur la nature de ces objets grâce à ces hashs : 

$ git cat-file -t 244ecfc79b82adadc926d3409d22bfaa4f1f4938
blob

Mais aussi leur contenu :

$ git cat-file -p 244ecfc79b82adadc926d3409d22bfaa4f1f4938
first content A

On peut donc déjà en conclure une chose à cette étape : ajouter des fichiers à l’index implique de créer des nœuds de type blob pour ces fichiers !

Si maintenant nous commitons pour la première fois : 

$ git commit -m "first commit"
[master (root-commit) f5e463e] first commit
 3 files changed, 3 insertions(+)
 create mode 100644 conf/fileA.txt
 create mode 100644 src/fileB.txt
 create mode 100644 src/fileC.txt

Alors nous pouvons voir que nous avons 4 nouveaux objets créés (dossiers 3a, 8a, 8d, f5) :

$ find .git/objects/ -type f
.git/objects/24/4ecfc79b82adadc926d3409d22bfaa4f1f4938
.git/objects/28/a3d34cf482fab0655efe5d4010e01b0620dfbe
.git/objects/3a/c4d6ce59a6466507d45bb914eb6658b3ff2b0c   <<<
.git/objects/85/83cd25271d252695d2f7606c0740149ea1274c
.git/objects/8a/f01647c79778b1fd73badd285beec1d4f802f7   <<<
.git/objects/8d/18ccf1ef116db3089d7614cf8594caef920298   <<<
.git/objects/f5/e463e48ba9dc6b36b072ca3cd03d9c2a0bb70b   <<<

Par soucis de lecture, je privilégierai la version abrégée des hash pour les commandes suivantes.

En utilisant encore la commande cat-file pour connaître les types de ces 4 objets, on obtient :

git cat-file -t 3ac4d6c   => tree

git cat-file -t 8af0164  => tree

git cat-file -t  8d18ccf  => tree

git cat-file -t  f5e463e => commit

Alors pourquoi 4 nouveaux objets ?

Commençons par regarder le contenu de notre objet de commit :

$ git cat-file -p f5e463e
tree 8d18ccf1ef116db3089d7614cf8594caef920298
author MarvinElRhumAdorator <marvingilly@gmail.com> 1685611832 +0200
committer MarvinElRhumAdorator <marvingilly@gmail.com> 1685611832 +0200

first commit

Sans surprise, nous avons toutes les informations de commit (auteur, timestamp, message) et un lien vers l’objet 8d18ccf … qui est l’un des nouveaux objets que nous avons de type tree ! Inspectons cet objet :

$ git cat-file -p 8d18ccf
040000 tree 3ac4d6ce59a6466507d45bb914eb6658b3ff2b0c    conf
040000 tree 8af01647c79778b1fd73badd285beec1d4f802f7    src

Et ici nous avons un tree qui pointe vers les 2 autres nouveaux objets de type tree que nous avons créés : un tree pour le répertoire conf et un tree pour le répertoire src.

Pour faciliter les explications suivantes, nous parlerons de l’objet tree qui est directement associé à l’objet de commit en tant que tree-root.

Il est possible de faire référence au tree-root du commit courant à l’aide de la notation HEAD^{tree} .

Et en répétant l’opération sur ces 2 répertoires :

$ git cat-file -p 3ac4d6c
100644 blob 244ecfc79b82adadc926d3409d22bfaa4f1f4938    fileA.txt

$ git cat-file -p 8af0164
100644 blob 8583cd25271d252695d2f7606c0740149ea1274c    fileB.txt
100644 blob 28a3d34cf482fab0655efe5d4010e01b0620dfbe    fileC.txt

Il est clair que ces répertoires pointent vers des objets de type blob, et à y regarder de plus près, ces objets ne sont nuls autres que les 3 blob générés lors de l’ajout de nos fichiers dans l’index. 

Ainsi nous avons pu voir que :

  • Lorsque l’on ajoute des fichiers à l’index, git crée déjà des objets dans sa base de données et leur attribue des hash. Ces objets ne contiennent que le contenu des fichiers et rien d’autre.
  • Lorsque l’on commit, git crée un objet de type commit, qui va se lier à un objet de type tree correspondant à la racine de notre projet, qui lui-même va se lier de façon transitive à nos blobs en passant par des objets de type tree. Les objets de type tree contiennent les informations concernant l’arborescence (nom des fichiers et des dossiers).

 

Pour afficher l’ensemble des tree, sub-tree et blobs associés au tree-root de master, il suffit donc de taper la commande suivante :

$ git ls-tree -rt master^{tree} --abbrev=7
040000 tree 3ac4d6c     conf
100644 blob 244ecfc     conf/fileA.txt
040000 tree 8af0164     src
100644 blob 8583cd2     src/fileB.txt
100644 blob 28a3d34     src/fileC.txt

A l’issue de ce premier commit, voici à quoi ressemble notre grappe d’objets git :

Les plus attentifs d’entre vous auront remarqué que j’ai lié la branche master au commit C1 sur l’illustration ci-dessus alors que nous n’avons pas du tout parlé de branches ! Nous ne développerons pas en profondeur le concept de branches dans cet article puisque ce n’est pas le sujet, mais sachez que les branches sont ni plus ni moins que des références à des commits. Et même si dans notre imaginaire, on peut associer une branche à un ensemble de commits, la réalité est seulement que depuis une branche, nous pouvons accéder à tous les commits ascendants du commit associé à cette branche. Pour vous en convaincre, je vous invite à regarder le contenu du fichier HEAD :

$ cat .git/HEAD
ref: refs/heads/master

Ce fichier contient une référence vers un autre fichier (une autre référence) car nous sommes actuellement attachés à la branche master. Si nous n’étions attachés à aucune branche (état DETACHED), comme par exemple après avoir fait un checkout sur un numéro de commit, alors nous aurions dans ce fichier le numéro de commit en question. Si nous regardons maintenant le contenu du fichier pointé par le HEAD, nous retrouvons bien la référence vers notre commit pointé par master :

$ cat .git/refs/heads/master
f5e463e48ba9dc6b36b072ca3cd03d9c2a0bb70b

Nous avons vu ce qu’il se passait avec un seul commit, mais la réelle force de git réside dans sa façon de versionner les fichiers à travers plusieurs commits et c’est ce qui va nous intéresser à présent.

Les hashes, si simples et pourtant si efficaces !

Maintenant nous allons nous focaliser sur la façon dont git utilise le hash pour ne pas à chaque fois réenregistrer chaque nœud de notre arbre pour les commits qui ne concernent qu’une partie de nos fichiers.

Git va tout simplement se servir du hash pour identifier les nœuds à changer ou à conserver…

Modifions le fichier B avec la commande suivante :

$ echo -n "second content" > src/fileB.txt

On se retrouve avec notre fichier changé dans le working directory, mais rien n’a changé dans l’index :

$ git show :src/fileB.txt
first content B

Nous l’ajoutons donc à l’index :

$ git add src/fileB.txt

$ git status
On branch master
Changes to be committed:
  (use "git restore --staged ..." to unstage)
        modified:   src/fileB.txt

Et on peut facilement constater qu’un nouvel objet cc4d3a7 a été créé :

$ ls .git/objects/ -tR

.git/objects/:
cc/  f5/  8d/  8a/  3a/  28/  85/  24/  info/  pack/

.git/objects/cc:
4d3a7d961604cd89d41a5086f46abb80222775

…

Et dans ce fichier nous avons le contenu du fichier qui a été indexé :

$ git cat-file -p cc4d3a7
second content

Pourtant l’ancien objet blob associé au fileB n’a pas bougé et l’arbre associé au commit de master non plus :

$ git ls-tree -rt master^{tree} --abbrev=7
040000 tree 3ac4d6c     conf
100644 blob 244ecfc     conf/fileA.txt
040000 tree 8af0164     src
100644 blob 8583cd2     src/fileB.txt
100644 blob 28a3d34     src/fileC.txt

Mais si nous procédons au commit :

$ git commit -m "second commit"
[master e627f45] second commit
 1 file changed, 1 insertion(+), 1 deletion(-)

Nous avons maintenant un nouvel objet de type commit avec un commit parent pointant vers le précédent commit et un tree-root qui n’a plus le même hash (précédemment 8d18ccf) : 

$ git cat-file -p master
tree 7fcb01a509aaae39537b8dd3977818927fbd2846
parent f5e463e48ba9dc6b36b072ca3cd03d9c2a0bb70b
author MarvinElRhumAdorator <marvingilly@gmail.com> 1685633244 +0200
committer MarvinElRhumAdorator <marvingilly@gmail.com> 1685633244 +0200

Maintenant, si nous regardons l’état de notre arbre à partir du tree-root, on peut voir qu’il y a un nouveau blob pour le fichier B et un nouvel objet tree pour le dossier src : 

$ git ls-tree -rt master^{tree} --abbrev=7
040000 tree 3ac4d6c     conf
100644 blob 244ecfc     conf/fileA.txt
040000 tree a530b86     src
100644 blob cc4d3a7     src/fileB.txt
100644 blob 28a3d34     src/fileC.txt

$ git log
* e627f45 (HEAD -> master) second commit
* f5e463e first commit

Ainsi notre graphe d’objets ressemble maintenant à ceci :

Et nous pouvons en déduire que lorsqu’on commit un nouveau fichier, il y a un nouveau blob pour ce fichier, ainsi que de nouveaux objets de type tree créés entre le nœud blob de ce fichier et le nœud du nouveau commit. L’avantage de ce système est qu’il ne recrée pas une nouvelle version de toute l’arborescence à chaque fois que l’on commit, mais seulement une nouvelle version des nœuds impactés.

En visualisant le schéma de nos 2 arbres de commit avec les hashes de chaque nœud…

On constate que d’un point de vue mémoire, il correspond davantage à celui-ci :

Voici la première couche d’optimisation mémoire que nous avons sur notre versioning git. Parlons maintenant de la deuxième.

La 2è couche d’optimisation de git sur nos commits

Ok, je dois vous faire une confession… J’ai peut-être légèrement menti par souci de simplification en début d’article lorsque j’ai avancé que git ne stockait pas de deltas.

Avec le système de hash, nous ne recréons pas toute l’arborescence à chaque commit, mais avec les explications que nous avons jusque-là, nous comprenons qu’à chaque modification d’un fichier, git crée une toute nouvelle version de ce fichier. Dans la terminologie git, nous parlons de ce type d’objet en tant que loose object. 

Pour des fichiers de petite taille, cette recréation n’est pas vraiment un problème. Par contre imaginez un cas, dans lequel on créerait un objet assez volumineux et sur lequel nous effectuerions beaucoup de petites modifications que l’on enregistrerait dans des commits. En fait n’imaginez pas, essayez par vous-même !

Pour ce test, nous avons généré un fichier bigFile.txt dans lequel avec une quantité importante de code généré (avec https://fr.lipsum.com/).

$ ls -1sS src
total 66
64 bigFile.txt
 1 fileC.txt
 1 fileB.txt

Commitons notre fichier de 64Ko puis procédons à notre expérience :

$ git add src/bigFile.txt

$ git commit -m "create big file"
[master 79ae8c2] create big file
committer MarvinElRhumAdorator <marvingilly@gmail.com> 1685611832

 1 file changed, 199 insertions(+)
 create mode 100644 src/bigFile.txt


$ git cat-file -p master
tree f38fdd779adbe2da2a04e245fad09c72ff37283d
parent e627f4576d551679ad1d28386150026b12c8e364
author mgilly <marvin.gilly@cpexterne.org> 1701632034 +0100
committer mgilly <marvin.gilly@cpexterne.org> 1701632034 +0100

create big file

$ git ls-tree -rt master^{tree} --abbrev=7
040000 tree 3ac4d6c     conf
100644 blob 244ecfc     conf/fileA.txt
040000 tree 8b854f7     src
100644 blob afe2ff7     src/bigFile.txt
100644 blob cc4d3a7     src/fileB.txt
100644 blob 28a3d34     src/fileC.txt

$ git cat-file -t afe2ff7
blob

$ git cat-file -s afe2ff7
63093

Comme prévu, nous avons bien notre fichier de presque 64Ko.

Pourtant, lorsqu’on regarde dans le dossier .git/objects/af/ on ne peut voir qu’un fichier de 20 Ko…

$ ls -s .git/objects/af/
total 20
20 e2ff73b88635f1ea6d2468d82b3cc6470dd85b

Eh bien cela s’explique tout simplement par le fait que git utilise une librairie de compression (https://www.zlib.net/) pour stocker tous nos objets. Mais ce n’est pas l’optimisation que nous cherchons à révéler ici. 

Je vous propose pour notre expérience de faire 2 commits consistant à ajouter des petites modifications à la fin de notre fichier volumineux.

$ echo "une meilleure fin" >> src/bigFile.txt

$ git add src/bigFile.txt


$ git commit -m "la meilleure fin"
[master d5aa1eb] la meilleure fin
 Committer: mgilly <marvin.gilly@cpexterne.org>

 1 file changed, 1 insertion(+), 1 deletion(-)



$ git ls-tree -rt master^{tree} --abbrev=7
040000 tree 3ac4d6c     conf
100644 blob 244ecfc     conf/fileA.txt
040000 tree 6f1bb7d     src                 << nouveau tree
100644 blob d0bb954     src/bigFile.txt     << nouveau fichier 
100644 blob cc4d3a7 src/fileB.txt 
100644 blob 28a3d34 src/fileC.txt 

$ echo " qui n'était pas vraiment une fin" >> src/bigFile.txt


$ git commit -am "une autre fin"
[master 2493172] une autre fin
 Committer: mgilly <marvin.gilly@cpexterne.org>
 1 file changed, 1 insertion(+)

$ git ls-tree -rt master^{tree} --abbrev=7
040000 tree 3ac4d6c     conf
100644 blob 244ecfc     conf/fileA.txt
040000 tree b634e49     src                << nouveau tree
100644 blob c5c46ea     src/bigFile.txt    << nouveau fichier
100644 blob cc4d3a7     src/fileB.txt
100644 blob 28a3d34     src/fileC.txt

L’expérience nous montre que git nous a créé 2 nouveaux blobs du fichiers (d0bb954 et c5c46ea) ainsi que les tree associés.

On vérifie que les 3 fichiers correspondants à afe2ff73, d0bb954 et c5c46ea existent toujours avec environ la même taille : 

$ find .git/objects/ -type f | grep '/d0\|/af\|/c5' | xargs ls -s
20 .git/objects/af/e2ff73b88635f1ea6d2468d82b3cc6470dd85b
20 .git/objects/c5/c46ea43f623538f1c9255660f5b367d01617ce
20 .git/objects/d0/bb954504fbfbcdec2a2e0841a6d121a109d621

Voilà c’est drôlement dommage, il semblerait que git ne soit pas optimisé et que nous puissions lancer un nouvel outil de versionning en open source… Ou pas !

En fait il ne s’est rien passé car l’optimisation que nous souhaitons voir se produire repose sur la commande git gc. Cette commande nettoie et optimise le repo local mais n’est déclenchée que lorsque git détecte que le dépôt a grandi de façon substantielle” depuis son dernier déclenchement. Personnellement je n’ai pas envie de voir au bout de combien de commits git estimera que le repo a assez grandi pour déclencher notre nettoyage de printemps, alors je vous propose que nous le déclenchions manuellement !

Affichons d’abord tous les objets présents avant le déclenchement de notre commande :

$ git rev-list --all --objects
2493172
d5aa1eb
79ae8c2
e627f45
f5e463e
9216001
3ac4d6c conf
244ecfc conf/fileA.txt
b634e49 src
c5c46ea src/bigFile.txt
cc4d3a7 src/fileB.txt
28a3d34 src/fileC.txt
d3d78ef
6f1bb7d src
d0bb954 src/bigFile.txt
f38fdd7
8b854f7 src
afe2ff7 src/bigFile.txt
7fcb01a
a530b86 src
8d18ccf
8af0164 src
8583cd2 src/fileB.txt

Et maintenant déclenchons notre gc :

$ git gc
Enumerating objects: 23, done.
Counting objects: 100% (23/23), done.
Delta compression using up to 12 threads
Compressing objects: 100% (18/18), done.
Writing objects: 100% (23/23), done.
Total 23 (delta 2), reused 0 (delta 0), pack-reused 0

Plusieurs choses intéressantes sont à relever ici : 

  • Il y a 23 objets traités dans le gc
  • 18 sont compressés
  • 2 sont construits par delta

Étant donné que l’on a 5 commits, il est facile de comprendre qu’on a compressé 23 – 5 objets.

Vérifions si nos anciens blobs existent encore :

$ find .git/objects/ -type f | grep '/d0\|/af\|/c5'

Les fichiers n’existent plus… Pourtant quand on regarde à quoi sont rattachés nos 2 derniers commits, nous avons toujours les mêmes références vers c5c46ea et d0bb954 :

$ git ls-tree -rt master^{tree} --abbrev=7
040000 tree 3ac4d6c     conf
100644 blob 244ecfc     conf/fileA.txt
040000 tree b634e49     src
100644 blob c5c46ea     src/bigFile.txt
100644 blob cc4d3a7     src/fileB.txt
100644 blob 28a3d34     src/fileC.txt

$ git ls-tree -rt master^^{tree} --abbrev=7
040000 tree 3ac4d6c     conf
100644 blob 244ecfc     conf/fileA.txt
040000 tree 6f1bb7d     src
100644 blob d0bb954     src/bigFile.txt
100644 blob cc4d3a7     src/fileB.txt
100644 blob 28a3d34     src/fileC.txt

Les seules choses que nous avons dans le dossiers .git/objects sont des infos concernant notre nouveau packfile :

$ find .git/objects/ -type f
.git/objects/info/commit-graph
.git/objects/info/packs
.git/objects/pack/pack-b437ade7134f34da0d842375918dcc1fce88154d.idx
.git/objects/pack/pack-b437ade7134f34da0d842375918dcc1fce88154d.pack
.git/objects/pack/pack-b437ade7134f34da0d842375918dcc1fce88154d.rev

La commande gc que nous avons faite a rassemblé les nœuds rattachés à des commits dans notre packfile (et simplement supprimé les noeuds rattachés à aucun commit).

Nous pouvons visualiser le contenu de notre packfile pour essayer de comprendre où sont passés nos différents noeuds : 

$ git verify-pack -v .git/objects/pack/pack-b437ade7134f34da0d842375918dcc1fce88154d.idx -v
2493172 commit 232 155 12
d5aa1eb commit 235 158 167
79ae8c2 commit 234 155 325
e627f45 commit 248 165 480
f5e463e commit 199 133 645
244ecfc blob   15 24 778
c5c46ea blob   63146 17562 802
cc4d3a7 blob   14 21 18364
28a3d34 blob   15 24 18385
9216001 tree   61 69 18409
3ac4d6c tree   37 48 18478
b634e49 tree   113 106 18526
d3d78ef tree   61 68 18632
6f1bb7d tree   113 106 18700
d0bb954 blob   9 21 18806 1 c5c46ea
f38fdd7 tree   61 68 18827
8b854f7 tree   113 106 18895
afe2ff7 blob   9 21 19001 1 c5c46ea
7fcb01a tree   61 68 19022
a530b86 tree   74 75 19090
8d18ccf tree   61 69 19165
8af0164 tree   74 75 19234
8583cd2 blob   15 24 19309
non delta: 21 objects
chain length = 1: 2 objects
.git/objects/pack/pack-b437ade7134f34da0d842375918dcc1fce88154d.pack: ok

On peut voir qu’il y a 21 noeuds qui ont été repris dans le packfile tels qu’ils étaient avant et 2 nœuds qui ont été construits par delta. En filtrant et abrégeant les hashes on obtient bien le hash de notre première version de bigFile.txt avec une taille de 63Ko ainsi que les 2 autres versions du bigFile qui font respectivement 9 octets chacun. Ici nul doute que l’optimisation est au rendez-vous !

Le petit mot de la fin

Bravo à toi cher lecteur, tu as su braver toutes ces commandes git bas niveau et maintenant le système de commits n’a plus de secrets pour toi ! Pour autant, dans ta vie de tous les jours tu seras sans doute plus souvent amené à utiliser des commandes de porcelaine (haut niveau) que des commandes de plomberie (cat-file, rev-list, …). C’est pourquoi, je t’invite à découvrir les bases de git avec ce tuto git, avant peut-être de creuser sur des pratiques plus avancées comme le reflog, le bissect, et tant d’autres.

A bientôt pour un prochain article qui nous ramènera sur un thème plus craft.

Bonus

Sur les terminaux linux, vous pouvez à travers la librairie openssl, utiliser zlib sur les fichiers de votre répertoire .git/objects/ pour en visualiser le contenu, comme ici : 

openssl zlib -d -in .git/objects/45/6090b9ef17b58856f25b59e96bcae03914590a

 

*pour simplifier nous omettons les optimisations faites dans les packs

 

Marvin Gilly – El Rhum Adorator

Related Posts

Leave A Comment