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.
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
.
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.
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.
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.
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.
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
.
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.
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
.
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.
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.