Chapitre 8. Les secrets révélés

Nous allons jeter un œil sous le capot pour comprendre comment Git réalise ses miracles. Je passerai sous silence la plupart des détails. Pour des explications plus détaillées, référez-vous au manuel utilisateur.

L’invisibilité

Comment fait Git pour être si discret ? Mis à part lorsque vous faites des commits et des fusions, vous pouvez travailler comme si la gestion de versions n’existait pas. Et c’est lorsque vous en avez besoin que vous êtes content de voir que Git veillait sur vous tout le temps.

D’autres systèmes de gestion de versions vous mettent constamment aux prises avec de la paperasserie et de la bureaucratie. Les fichiers sont en lecture seule jusqu'à l’obtention depuis un serveur central du droit d'édition de tel ou tel fichier. Les commandes les plus basiques voient leurs performances s'écrouler au fur et à mesure que le nombre d’utilisateurs augmente. Le travail s’arrête dès lors que le réseau ou le serveur central est en panne.

À l’inverse, Git conserve tout l’historique de votre projet dans le sous-dossier .git de votre dossier de travail. C’est votre propre copie de l’historique et vous pouvez donc rester déconnecté tant que vous ne voulez pas communiquer avec les autres. Vous conservez un contrôle total sur le sort de vos fichiers puisque Git peut aisément les recréer à tout moment à partir de l’un des états enregistrés dans .git.

L’intégrité

La plupart des gens associent la cryptographie à la conservation du secret des informations mais l’un de ses buts tout aussi important est de conserver l’intégrité de ces informations. Un usage approprié des fonctions de hachage cryptographiques (celles qui calculent l’empreinte d’un document) permet d’empêcher la corruption accidentelle ou malicieuse des données.

Une empreinte SHA1 peut être vue comme un nombre de 160 bits identifiant de manière unique n’importe quelle suite d’octets que vous rencontrerez dans votre vie. On peut même aller plus loin : c’est vrai pour toutes les suites d’octets que les humains utiliseront sur plusieurs générations.

Comme une empreinte SHA1 est elle-même une suite d’octets, nous pouvons calculer l’empreinte d’une suite de caractères contenant d’autres empreintes. Cette simple observation est étonnamment utile (cherchez par exemple hash chain). Nous verrons plus tard comment Git utilise cela pour garantir efficacement l’intégrité des données.

En bref, Git conserve vos données dans le sous-dossier .git/objects mais à la place des noms de fichiers normaux, vous n’y trouverez que des ID. En utilisant ces ID comme noms de fichiers et grâce à quelques astucieux fichiers de verrouillage et d’horodatage, Git transforme un simple système de fichiers en une base de données efficace et robuste.

L’intelligence

Comment fait Git pour savoir que vous avez renommé un fichier même si vous ne lui avez pas dit explicitement ? Bien sûr, vous pouvez utiliser git mv mais c’est exactement la même chose que de faire git rm suivi par git add.

Git a des heuristiques pour débusquer les changements de noms et les copies entre les versions successives. En fait, il peut même détecter les bouts de code qui ont été déplacés ou copiés d’un fichier à un autre ! Bien que ne couvrant pas tous les cas, cela marche déjà très bien et cette fonctionnalité est encore en cours d’amélioration. Si cela échoue pour vous, essayez les options activant des méthodes de détection de copie plus coûteuses et envisager de faire une mise à jour.

L’indexation

Pour chaque fichier suivi, Git mémorise des informations, telles que sa taille et ses dates de création et de dernières modifications, dans un fichier appelé index. Pour déterminer si un fichier a changé, Git compare son état courant avec ce qu’il a mémorisé dans l’index. Si cela correspond alors Git n’a pas besoin de relire le fichier.

Puisque les appels à stat sont considérablement plus rapides que la lecture des fichiers, si vous n’avez modifié que quelques fichiers, Git peut déterminer son état en très peu de temps.

Nous avons dit plus tôt que l’index était une aire d’assemblage. Comment se peut-il qu’un simple fichier contant quelques informations sur les fichiers soit une aire d’assemblage ? Parce que la commande add ajoute les fichiers à la base de données de Git et met à jour l’index avec leurs informations alors que la commande commit, sans option, crée une nouvelle version basée uniquement sur cet index et les fichiers déjà inclus dans la base de données.

Les origines de Git

Ce message de la Mailing List du noyau Linux décrit l’enchaînement des évènements ayant mené à Git. L’ensemble de l’enfilade est un site archéologique fascinant pour les historiens de Git.

La base d’objets

Chacune des versions de vos données est conservée dans la base d’objets (object database) qui réside dans le sous-dossier .git/objects ; le reste du contenu du dossier .git représente moins de données : l’index, le nom des branches, les tags, les options de configuration, les logs, l’emplacement actuel de HEAD, et ainsi de suite. La base d’objets est simple mais élégante et constitue la source de la puissance de Git.

Chaque fichier dans .git/objects est un objet. Il y a trois sortes d’objets qui nous concerne : les blobs, les arbres (trees) et les commits.

Les blobs

Tout d’abord, faisons un peu de magie. Choisissez un nom de fichier… n’importe quel nom de fichier ! Puis dans un dossier vide, faites (en remplaçant VOTRE_NOM_DE_FICHIER par le nom que vous avez choisi) :

$ echo joli > VOTRE_NOM_DE_FICHIER
$ git init
$ git add .
$ find .git/objects -type f

Vous verrez .git/objects/06/80f15d4cb13a09f600a25b84eae36506167970.

Comment puis-je le savoir sans connaître le nom de fichier que vous avez choisi ? Tout simplement parce que l’empreinte SHA1 de :

"blob" SP "5" NUL "joli" LF

est 0680f15d4cb13a09f600a25b84eae36506167970. Où SP est un espace, NUL est l’octet de valeur nulle et LF est un passage à la ligne. Vous pouvez vérifier cela en tapant :

$ printf "blob 5\000joli\n" | sha1sum

Git utilise un classement par contenu : les fichiers ne sont pas stockés selon leur nom mais selon l’empreinte des données qu’ils contiennent, dans un fichier que nous appelons un objet blob. Nous pouvons considérer l’empreinte comme un ID unique du contenu d’un fichier. Donc nous pouvons retrouver un fichier par son contenu. La chaîne initiale blob 5 est simplement un entête indiquant le type de l’objet et sa longueur en octets ; cela simplifie le classement interne.

Je peux donc aisément prédire ce que vous voyez. Le nom du fichier ne compte pas : pour construire l’objet blob, seules comptent les données stockées dans le fichier.

Peut-être vous demandez-vous ce qui se produit pour des fichiers ayant le même contenu. Essayez en créant des copies de votre premier fichier, avec des noms quelconques. Le contenu de .git/objects reste le même quel que soit le nombre de copies que vous avez ajoutées. Git ne stocke le contenu qu’une seule fois.

À propos, les fichiers dans .git/objects sont compressés par zlib et, par conséquent, vous ne pouvez pas en consulter le contenu directement. Passez-les au travers du filtre zpipe -d ou tapez :

$ git cat-file -p 0680f15d4cb13a09f600a25b84eae36506167970

qui affiche proprement l’objet choisi.

Les arbres (trees)

Mais que deviennent les noms des fichiers ? Ils doivent bien être stockés quelque part à un moment. Git se préoccupe des noms de fichiers lors d’un commit :

$ git commit  # Tapez un message
$ find .git/objects -type f

Vous devriez voir maintenant trois objets. Mais là, je ne peux plus prédire le nom des deux nouveaux fichiers puisqu’ils dépendent en partie du nom de fichier que vous avez choisi. Nous continuerons en supposant que vous avez choisi «rose». Si ce n’est pas le cas, vous pouvez réécrire l’histoire pour que ce soit le cas :

$ git filter-branch --tree-filter 'mv VOTRE_NOM_DE_FICHIER rose'
$ find .git/objects -type f

Le fichier .git/objects/9a/6a950c3b14eb1a3fb540a2749514a1cb81e206 devrait maintenant apparaître puisque c’est l’empreinte SHA1 du contenu suivant :

"tree" SP "32" NUL "100644 rose" NUL 0x9a6a950c3b14eb1a3fb540a2749514a1cb81e206

Vérifiez que ce contenu est le bon en tapant :

$ echo 9a6a950c3b14eb1a3fb540a2749514a1cb81e206 | git cat-file --batch

Avec zpipe, il est plus simple de vérifier l’empreinte :

$ zpipe -d < .git/objects/9a/6a950c3b14eb1a3fb540a2749514a1cb81e206 | sha1sum

La vérification de l’empreinte est plus difficile via cat-file puisque cette commande n’affiche pas que le contenu brut du fichier après décompression.

Cette fichier est un objet arbre (tree) : une liste de tuples constitués d’un type, d’un nom de fichier et d’une empreinte. Dans notre exemple, le type est 100644 qui indique que rose est un fichier normal et l’empreinte est celle de l’objet de type blob contenant le contenu de rose. Les autres types possibles pour un fichier sont exécutable, lien symbolique ou dossier. Dans ce dernier cas, l’empreinte représente un autre objet de type arbre.

Si vous faites appel à la commande filter-branch, vous verrez apparaître de vieux objets dont vous n’avez pas besoin. Même s’ils disparaîtront automatiquement une fois expirée la période de rétention, nous allons les effacer dès maintenant pour rendre notre petit exemple plus facile à suivre :

$ rm -r .git/refs/original
$ git reflog expire --expire=now --all
$ git prune

Sur de vrais projets, vous devriez éviter de telles commandes puisqu’elles détruisent les sauvegardes. Si vous voulez un dossier propre, il est conseillé de faire un tout nouveau clone. Faites aussi attention si vous manipulez directement le contenu de .git : que se passera-t-il si une commande Git s’effectue au même moment ou si le courant est soudainement coupé ?

De manière générale, les refs devraient toujours être effacées via git update-ref -d même si on considère comme sans risque la suppression manuelle de refs/original.

Les commits

Nous avons expliqué 2 des 3 types d’objets. Le troisième est l’objet commit. Son contenu dépend du message de commit ainsi que de la date et l’heure auxquelles il a été créé. Pour que vous obteniez la même chose qu’ici, nous devons bidouiller un peu :

$ git commit --amend -m Shakespeare  # Changement de message de commit
$ git filter-branch --env-filter 'export
    GIT_AUTHOR_DATE="Fri 13 Feb 2009 15:31:30 -0800"
    GIT_AUTHOR_NAME="Alice"
    GIT_AUTHOR_EMAIL="alice@example.com"
    GIT_COMMITTER_DATE="Fri, 13 Feb 2009 15:31:30 -0800"
    GIT_COMMITTER_NAME="Bob"
    GIT_COMMITTER_EMAIL="bob@example.com"'  # Trucage de la date, l'heure et l'auteur.
$ find .git/objects -type f

Le fichier .git/objects/ae/9d1241b2b6eea90529149a065f6bc444365c2a devrait maintenant exister puisque c’est l’empreinte SHA1 du contenu suivant :

"commit 158" NUL
"tree 9a6a950c3b14eb1a3fb540a2749514a1cb81e206" LF
"author Alice <alice@example.com> 1234567890 -0800" LF
"committer Bob <bob@example.com> 1234567890 -0800" LF
LF
"Shakespeare" LF

Comme précédemment, vous pouvez utiliser zpipe ou cat-file pour vérifier par vous-même.

C’est le premier commit, ce qui explique pourquoi il n’y a pas de commit parent. Mais les commits suivants contiendront toujours au moins une ligne identifiant un commit parent.

Indiscernable de la magie

Les secrets de Git semblent trop simples. On imagine qu’il suffit de mélanger quelques scripts shell et d’y ajouter une pincée de code C pour mitonner un tel système en quelques heures : un assemblage d’opérations basiques sur les fichiers et de calcul d’empreintes SHA1 garni de quelques fichiers verrou et d’appels à fsync pour la robustesse. En fait, nous venons précisément de décrire les premières versions de Git. Malgré tout, mis à part quelques techniques astucieuses de compression pour gagner de la place et d’indexation pour gagner du temps, nous savons maintenant comment Git transforme adroitement un système de fichiers en une base de données parfaitement adaptée à de la gestion de versions.

Par exemple, si un fichier quelconque de la base d’objets vient à être corrompu par une erreur disque alors son empreinte ne correspond plus et nous sommes alertés du problème. En calculant l’empreinte des empreintes d’autres objets, nous maintenons l’intégrité à tous les niveaux. Les commits sont atomiques puisque ils ne peuvent jamais mémoriser des modifications partiellement stockées : nous ne pouvons calculer l’empreinte d’un commit et le stocker dans la base d’objets qu’après y avoir déjà stocké tous les arbres, blobs et parents relatifs à ce commit. La base d’objets est immunisée contre les interruptions inattendues telles que les coupures de courant.

Nous faisons même échouer les tentatives d’attaque les plus sournoises. Supposez que quelqu’un tente de modifier discrètement le contenu d’un fichier dans l’une des anciennes versions du projet. Pour rendre cohérent le contenu de la base d’objets, il lui faut changer l’empreinte de l’objet blob correspondant puisque elle doit maintenant représenter une chaîne d’octets différente. Cela signifie qu’il doit aussi changer l’empreinte de tous les arbres référençant ce blob et donc changer l’empreinte de tous les commits impliquant ces arbres ainsi que de tous les descendants de ces commits. Cela implique que l’empreinte du HEAD officiel diffère de celle du HEAD d’un dépôt corrompu. En remontant la suite d’empreintes erronées nous pouvons localiser avec précision le fichier corrompu ainsi que le premier commit où il l’a été.

En résumé, tant que nous sommes sûrs des 20 octets représentant le dernier commit, il est impossible d’altérer un dépôt Git.

Qu’en est-il des fameuses fonctionnalités de Git ? Des branchements ? Des fusions ? Des tags ? De simples détails. La tête courante est conservée dans le fichier .git/HEAD qui contient l’empreinte d’un objet commit. Cette empreinte sera tenue à jour durant un commit ainsi que durant de nombreuses autres commandes. Les branches fonctionnent de manière similaire : ce sont des fichiers dans .git/refs/heads. Et les tags aussi : ils sont dans .git/refs/tags mais ils sont mis à jour par un ensemble différent de commandes.