git sous le capot

git sous le capot : ce qui se passe quand on change de branche

En tant que développeurs, on utilise quasiment tous git. Et on sait tous comment l’utiliser : add, commit, push, checkout… ces commandes font partie de notre quotidien.

Comme la plupart des développeurs, je ne savais pas comment git fonctionne en interne, ce qui se passe réellement sous le capot.

J’ai pour ma part voulu en apprendre plus, et je l’ai fait en lisant le chapitre git Internals du git Book. J’aimerais partager avec vous ce que j’ai appris.

Je vous propose d’explorer ça en partant d’un cas très simple et très commun : un changement de branche. On va tirer le fil de tout ce qui se passe sous le capot pour que la magie opère.

Parce que c’est un peu magique, non ? Quand on switch entre deux branches très différentes, git arrive à modifier le contenu de tout notre répertoire de travail quasi instantanément, en nous garantissant mathématiquement que l’état de l’intégralité du projet est exactement le même que pour n’importe qui d’autre sur cette même branche. Comment c’est possible ?

Le scénario : git switch main

Tout part de HEAD

Je suis sur ma branche ma_feature et je bascule sur main. Qu’est-ce qui se passe concrètement ?

Premier maillon de la chaîne : un petit fichier nommé HEAD change de valeur. Ce fichier se situe à la racine de mon dossier .git. C’est un fichier tout simple, qui ne comporte qu’une ligne et ne stocke qu’une seule chose : l’emplacement du fichier qui symbolise ma branche locale courante.

Dans notre cas, la valeur de HEAD passe simplement de :

ref: refs/heads/ma_feature

à :

ref: refs/heads/main

C’est tout. Changer de branche, du point de vue de HEAD, c’est réécrire la seule ligne contenue dans un fichier texte. Le contenu est le chemin du fichier qui représente notre branche.

(Il existe un cas particulier, le detached HEAD, où HEAD contient directement l’empreinte d’un commit au lieu d’une référence de branche. On le laisse de côté ici.)

TL;DR

HEAD pointe sur une branche.

Une branche n’est qu’un pointeur

Et maintenant, comme promis, on va tirer le fil et voir ensemble ce que contient le fichier qui se situe à refs/heads/main.

Le fichier qui symbolise ma branche (refs/heads/main) est, à son tour, un fichier texte qui ne comporte qu’une ligne et ne stocke qu’une seule valeur : l’empreinte d’un commit, qui ressemble à quelque chose comme ça :

9370f3ba267552ccca4a5d8870793fe8f6b6e7d2

Hé oui : dans git, une branche n’est qu’un pointeur vers un commit. Rien de plus, rien de moins. C’est d’ailleurs pour ça que créer une branche est instantané : il s’agit juste d’écrire 40 caractères dans un nouveau fichier.

TL;DR

Une branche pointe sur un commit.

Un commit, c’est quoi ?

On va voir maintenant d’où vient cette empreinte et surtout à partir de quoi elle est générée.

Un commit, c’est… un fichier texte. Un objet tout simple qui contient :

  • le message de commit (parfois en deux parties : titre puis description) ;
  • l’auteur, avec son mail et un timestamp ;
  • le committer, avec son mail et un timestamp ;
  • l’empreinte d’un objet tree “racine”, qui représente le dossier racine de mon projet ;
  • une ou plusieurs empreintes de commits parents.

Ça ressemble à ça :

tree af4a73f4f11f01ccd3528098bd0b7d1fe9887c20
parent 3783b39390accada85eb019477888af0d086ed54
author jeromeschwaederle <un.mail@gmail.com> 1770672003 +0100
committer jeromeschwaederle <un.mail@gmail.com> 1770672003 +0100

Un message de commit

On peut aussi rajouter une description plus longue.

On peut le voir soi-même avec une commande “plomberie” de git, git cat-file :

$ git cat-file -p 9370f3ba267552ccca4a5d8870793fe8f6b6e7d2

Point crucial : l’empreinte est calculée à partir du contenu de ce fichier texte. En conséquence, si un seul caractère devait changer (une virgule dans le message ou une seconde dans le timestamp) l’empreinte serait complètement différente.

TL;DR

Le commit est un petit fichier texte qui associe des métadonnées avec un “tree” racine.

Le tree : l’instantané de votre projet

On continue de tirer le fil. Le commit pointe vers un tree, mais c’est quoi ?

Un objet tree est, comme un commit, un petit fichier texte.

Il résout le problème du stockage des noms de fichiers et du regroupement de plusieurs fichiers ensemble.

Un tree contient une ou plusieurs entrées. Chaque entrée associe un mode, un type, une empreinte SHA-1, et un nom de fichier (ou de dossier) :

$ git cat-file -p af4a73f4f11f01ccd3528098bd0b7d1fe9887c20
100644 blob a906cb2a4a904a152e80877d4088654daad0c859    README.md
100644 blob 8f94139338f9404f26296befa88755fc2598c289    unFichier
040000 tree 99f1a6d12cb4b6f19c8655fca46c3ecf317074e0    unDossier

On y voit deux choses intéressantes :

  1. Le mode ressemble à des permissions UNIX, mais git n’en utilise qu’une poignée : 100644 pour un fichier normal, 100755 pour un exécutable, 120000 pour un lien symbolique, et 040000 pour… un sous-dossier.
  2. La dernière entrée, unDossier, n’est pas un blob mais un pointeur vers un autre tree. Autrement dit, un tree peut contenir des sous-trees. C’est récursif, exactement comme une arborescence de dossiers.

Donc un commit pointe vers un tree racine, qui pointe vers des sous-trees et des blobs. On tient une arborescence complète de l’état du projet à un instant T. Un commit, c’est donc un instantané (snapshot) de tout votre projet, pas un “diff”.

TL;DR

Le tree est un petit fichier texte qui symbolise un dossier. Il associe un nom à un contenu.

Le blob : du contenu, et rien que du contenu

Reste un dernier maillon : c’est quoi un blob ?

Le blob ne stocke qu’une seule chose : le contenu d’un fichier. Pas son nom (c’est le tree qui s’en charge), pas sa date, pas ses permissions. Juste les octets.

$ git cat-file -p a906cb2a4a904a152e80877d4088654daad0c859
README
C'est le contenu du fichier README.md

Comment git fabrique-t-il l’empreinte d’un blob ? Il prend le contenu, lui ajoute un petit en-tête (blob, suivi de la taille en octets, suivi d’un octet nul), puis calcule le SHA-1 de l’ensemble :

header                  = "blob 17\0"
tout ce qui sera haché  = "blob 17\0git sous le capot"
sha1                    = 803946aac6b8e8d7a390f1b618c31ae6544d87bb
echo -en "blob 17\0git sous le capot" | sha1sum
803946aac6b8e8d7a390f1b618c31ae6544d87bb  -

produit la même signature que

echo -n "git sous le capot" | git hash-object --stdin
803946aac6b8e8d7a390f1b618c31ae6544d87bb

Le résultat est ensuite compressé avec zlib et écrit sur le disque dans .git/objects/. Le nom du sous-dossier correspond aux 2 premiers caractères de l’empreinte, et le nom du fichier aux 38 restants :

.git/objects/80/3946aac6b8e8d7a390f1b618c31ae6544d87bb

Et, détail important, tous les objets de git (blobs, trees, commits) sont stockés exactement de la même façon. Seul l’en-tête change : il commence par blob, tree ou commit selon le cas.

On remonte le fil : git est un système de fichiers adressable par contenu

On est descendus jusqu’au bout. Récapitulons la chaîne qu’on vient de parcourir :

HEAD → refs/heads/main → commit → tree → (sous-trees) → blobs

git est, fondamentalement, un système de fichiers adressable par contenu (content-addressable filesystem), avec une interface de gestion de versions construite par-dessus.

Qu’est-ce que ça veut dire concrètement ? Que le cœur de git est un simple magasin clé-valeur. Vous lui donnez du contenu, il vous rend une clé (l’empreinte) que vous pourrez utiliser plus tard pour récupérer ce contenu. La clé est dérivée du contenu.

C’est aussi pour ça qu’on parle de repository (dépôt, entrepôt) : ce n’est rien d’autre qu’un magasin de contenu. Le répertoire .git/objects est la base de données de votre projet.

Cette idée toute simple a des conséquences énormes :

  • La déduplication est automatique. Deux fichiers au contenu identique, même avec des noms différents et dans des dossiers différents, produisent le même blob et ne sont stockés qu’une seule fois. Idem entre deux commits : si un fichier ne change pas, son blob est réutilisé tel quel.
  • L’intégrité est garantie par construction. L’empreinte d’un commit dépend de son tree, qui dépend de ses sous-trees, qui dépendent de leurs blobs. Tout est haché en cascade. Impossible de modifier un seul octet dans l’historique sans changer toutes les empreintes en remontant jusqu’au commit. C’est un arbre de Merkle. Quand deux personnes ont le même hash de commit, elles ont mathématiquement le même projet, octet pour octet.

La magie d’un changement de branche en résumé

On peut enfin répondre à la question du début. Quand je fais git switch main, git :

  1. réécrit HEAD pour pointer vers refs/heads/main ;
  2. lit l’empreinte du commit stockée dans ce fichier ;
  3. lit le tree racine de ce commit ;
  4. met à jour l’index (la staging area) et matérialise cette arborescence dans mon répertoire de travail.

Et c’est rapide parce que git n’a presque rien à “calculer” : tout est déjà stocké, prêt à l’emploi, indexé par son empreinte. Il n’a qu’à suivre les pointeurs et recopier les blobs concernés. La garantie d’intégrité, elle, est gratuite : elle découle directement du fait que chaque objet est identifié par le hash de son contenu.

Conclusion

git est simplement un magasin de contenu adressable par empreinte, sur lequel on a posé quelques pointeurs (les branches, HEAD) et un peu de métadonnées (les commits).

Ce n’est pas pour rien que dans la toute première version de git, écrit par Linus Torvalds, il le décrit lui-même comme un “stupid content tracker”, un traqueur de contenu bête. “Bête” au sens noble : il ne cherche pas à être malin, il se contente de stocker du contenu et de le retrouver par son empreinte. Et c’est précisément cette simplicité qui le rend si robuste.

La prochaine fois que vous changerez de branche, vous saurez exactement quel fil vous êtes en train de tirer.