Les objets Git : blob, tree, commitish
Par Maxime Bréhin • Publié le 20 mai 2022
• 7 min
Git traite des fichiers, des répertoires et leurs évolutions lors de la vie d’un projet. Sa conception est calquée sur ce principe : nos fichiers sont représentés par des fichiers blobs, nos répertoires par des fichiers trees, et un instantané / une version du projet par un fichier commit. Voyons ensemble comment tout ça est structuré.
Cet article fait partie de notre série sur le glossaire Git.
L’essentiel du contenu de cet article est une redite de l’article décrivant l’anatomie d’un commit. Si tu as déjà bien intégré ces concepts, tu peux le survoler pour te concentrer sur la fin (chaînage et purge). Je prends cependant ici une approche différente, du bas vers le haut (des blobs aux commits), à l’envers de l’autre article.
Tu préfères une vidéo ?
Si tu es du genre à préférer regarder que lire pour apprendre, on a pensé à toi :
C’est bien tout ça, mais ça m’apporte quoi ?
On pourrait croire que regarder sous le capot et comprendre les rouages de Git n’a pas d’utilité particulière. Pourtant, après des années à former des gens à Git, je me suis aperçu que ça facilitait énormément la compréhension de tout un tas de mécanismes et commandes, en plus de démystifier cet outil qui paraît parfois très complexe.
Par exemple, savoir que Git fonctionne avec des références facilite, pour un commit, la perception du « annuler » (pointer sur la référence précédente, « d’avant ») ou « refaire » (repointer vers la référence initiale, « d’après »).
On commence donc avec l’analyse des objets dans l’ordre de création et avec ce qui représente nos fichiers.
Pour décrire en détail certains points techniques je vais utiliser la commande de plomberie cat-file
pour voir le type et le contenu d’un fichier technique Git (ne t’embête pas à la retenir, c’est pour mes démos 🙂).
Nom / identifiant des objets Git
Git emploie un procédé unique pour nommer et identifier ses objets : leur nom est défini par le contenu qu’ils représentent et que Git passe à un algorithme dit de « hachage » (SHA-1 / SHA-2). J’insiste sur ce dernier point : le nom / l’identifiant d’un fichier technique Git est intrinsèquement lié à SON CONTENU. On obtient ainsi un identifiant sous forme d’une clé hexadécimale de 40 ou 64 caractères. On dit ainsi de Git qu’il est un système de fichiers adressables par contenu.
Je peux lui demander de m’afficher le contenu d’un de ses objets connus en lui passant son identifiant.
# L’option `-t` me permet de connaître le type de fichier analysé
> git cat-file -t 283758e1b6a31850f0307abb1ac5f039da53e17f
commit
# L’option `-p` me permet d’afficher le contenu de ce fichier
> git cat-file -p 283758e1b6a31850f0307abb1ac5f039da53e17f
tree 2968978c13e40b69c56fd4fe53fab6c7e834918b
parent 77153aaa5df1846b56d1a4b063c61cad265cc787
author Maxime Bréhin <maxime@comprendre-git.com> 1651593940 +0200
committer Maxime Bréhin <maxime@comprendre-git.com> 1651593940 +0200
Ceci est le message de mon commit
Ce procédé s’appliquant aussi bien aux blobs qu’aux trees et commitishs. Voyons ces derniers dans le détail.
Blob
Lorsqu’on crée un fichier dans un projet et qu’on l’ajoute à Git, il est stocké sous forme d’un blob (Binary Large OBject). Pour le constater il suffit d’observer le répertoire .git/objects/
de notre projet et de faire un ajout (git add …
) :
Il s’agit d’un instantané (copie intégrale du contenu du fichier) qui subit des optimisations sur le disque (notamment une compression via zlib). Le fait que le nom du blob soit défini par son contenu implique 2 choses importantes :
- si je modifie mon fichier dans mon espace de travail (son contenu, indépendamment de son nom et son emplacement) et que je l’ajoute à nouveau à Git, j’obtiendrai un autre blob avec un autre identifiant ;
- si je copie mon fichier ailleurs dans mon projet (ou au même endroit avec un autre nom) et que j’ajoute ce nouveau fichier, aucun nouveau blob ne sera créé, car le contenu sera strictement identique.
Pour les « puristes », Git ajoute le type de fichier, sa taille et un séparateur au contenu avant de calculer l’identifiant (checksum SHA-1/SHA-2). Ainsi, pas de risque d’ambiguïté.
# Affiche le checksum SHA-1 du contenu 'blob 16\0Coucou le monde'
# Type : blob
# Taille : 16
# Séparateur : \0 (octet nul)
$ echo -e 'blob 16\0Coucou le monde' | shasum
# Les 40 caractères hexadécimaux obtenus représentent ce qui
# servira d’identifiant technique / nom de fichier au blob.
e3cd3e70fa447a4ecf59946d6e8e176bcb67fc2c -
Enfin, voyons notre blob pour vous prouver que le contenu stocké est bien celui attendu :
# Cette commande permet d’afficher le contenu décompressé de l’objet Git
# dont on passe l’identifiant en argument.
$ git cat-file -p e3cd3e70fa447a4ecf59946d6e8e176bcb67fc2c
# On retrouve bien le texte de notre fichier initial « coucou.txt »
Coucou le monde
Astuce : Git nous permet de désigner des objets dès les 5 premiers caractères de leur identifiant (sauf en cas d’ambiguïté avec un autre objet démarrant par la même chaîne). Ainsi dans l’exemple précédent j’aurais pu faire git cat-file -p e3cd3
. D’ailleurs on peut configurer le log (et par ricochet diverses autres commandes) pour nous présenter seulement les 7 premiers caractères par défaut : git config --global log.abbrevCommit true
.
Reprenons notre exemple précédent et voyons ce que donne la copie puis la modification :
On observe bien qu’ajouter une copie du fichier ne crée pas de nouveau blob. En revanche, ajouter une version modifiée en crée bien un.
Des fichiers, pas des répertoires
Git se préoccupe principalement des fichiers. Il ne permet pas de gérer directement des répertoires. En d’autres termes, tu ne peux pas ajouter un répertoire vide à un projet.
Pourtant, représenter la structure d’un projet intégrant des répertoires vides est un besoin fréquent. N’as-tu jamais de répertoire log
ou config
nécessaire à ton projet mais dont les fichiers ne doivent pas être versionnés ?
L’astuce commune pour faire face à cette situation est alors de créer un fichier vide dans le répertoire et donc d’ajouter ce fichier pour enregistrer le chemin du répertoire dans Git. Pour le nom du fichier, c’est à ta convenance : on trouve le plus souvent des fichiers .keep
ou .gitkeep
, mais c’est juste de la convention, Git n’impose rien sur ce nom.
Tree
Un objet tree représente un répertoire et a pour rôle de lister les fichiers et éventuels sous-répertoires qu’il contient. Il décrit chacun par une ligne comprenant :
- l’information technique décrivant son type de nœud et ses droits d’accès (fichier standard, exécutable, lien symbolique ou répertoire) ;
- le type de fichier décrit ;
- sa référence / son identifiant ;
- son nom.
Un objet Git n’enregistre pas le nom du fichier / répertoire qu’il désigne, seulement son contenu. Ce sont par conséquent les objets trees qui détiennent les noms liés aux blobs et sub-trees qu’ils listent.
$ git cat-file -p 20c8cece7643c301f9864c918e16d486c0f2194b
100644 blob da3ede37150500a0d3e9b88688f8a46cd332e263 coucou.txt
040000 tree 9b0d74cf3d99f38c7c57049fe7d1a004bb247a95 src
Tu noteras que les fichiers trees ne sont créés qu’au commit, pas lors du git add …
.
Commitish
Quel était l’état du projet à cet instant précis ? Qui a produit cette évolution ?
Dans mon dernier exemple j’ai créé un commit. Il s’agit d’un objet Git dont le rôle est d’enregistrer un instantané du projet avec des informations permettant son suivi et son analyse. Il va pour cela enregistrer plusieurs informations :
- la référence de l’objet tree de niveau racine désigné par l’index au moment de l’action de commit ;
- le message qu’on a renseigné pour décrire le contenu du commit ;
- l’identité de la personne ayant réalisé le commit ;
- l’horodatage au moment du commit ;
- la référence vers le commit parent (ou les commits parents dans le cadre des fusions, voire aucun commit dans des cas particuliers, comme le root commit initial ou les commits orphelins).
Pour l’identité et l’horodatage on trouve en réalité 2 informations :
- l’identité de l’auteur·e du commit et l’heure de création initiale ;
- l’identité de la personne ayant intégré le commit dans l’historique et l’horodatage associé.
Tu te demandes peut-être à quoi sert ce distingo ? Ça ne saute aux yeux que lorsqu’on « rejoue » un commit d’un endroit à un autre (parfois au même endroit, par exemple avec un git commit --amend
). On peut alors déterminer qui est à l’origine du travail du commit (l’auteur·e) et qui l’a « copié/collé » (le committeur). On distingue alors le responsable du contenu du responsable de l’application du commit.
Voyons donc à quoi tout ça ressemble dans l’objet technique associé.
$ git cat-file -p 377758e1b6a31850f0307abb1ac5f039da53e17f
tree 20c8cece7643c301f9864c918e16d486c0f2194b
author Maxime Bréhin <maxime@demo.git> 1646912429 +0100
committer Augsta Ada King <ada@da.git> 1646951214 +0100
demo commit
Si j’analyse ligne à ligne je trouve :
- la référence de mon objet tree de tout à l’heure
tree 20c8c…
; - l’auteur, son e-mail et l’horodatage (timestamp Unix)
author Maxime Bréhin <maxime@demo.git> 1646912429 +0100
; - le commiteur, son e-mail et l’horodatage (timestamp Unix)
committer Augsta Ada King <ada@da.git> 1646951214 +0100
; - un saut de ligne ;
- le message du commit.
Tu noteras que dans ce commit je n’ai pas de référence vers un commit parent. C’est normal car je me trouvais dans la situation particulière de création du tout premier commit de mon projet de démonstration (root commit).
Chaînage des objets
Pour ce qui concerne l’état de mon projet enregistré par le commit (ce que j’appelais plus haut « l’instantané »), il est renseigné par la cascade de références vers les objets partant du répertoire racine, donc du tree 20c8c…
. Nul besoin donc de lister l’ensemble des objets du projet dans l’objet commit, seule la racine suffit.
Tout n’est donc que référence dans Git :
- les blobs sont référencés par les trees ;
- les sub-trees (qui sont des trees aussi) sont référencés par les trees ;
- les trees racines sont référencés par les commits ;
- les commits sont référencés par d’autres commits et produisent ainsi notre historique Git.
Nettoyage/purge des objets
Si tu as bien suivi, tu as probablement compris que toute modification d’un fichier produira un nouvel objet Git.
Prenons un exemple concret : admettons que j’aie oublié d’ajouter un fichier src/oubli.txt
dans mon dernier commit (par exemple un untracked suite à un git commit -am'…'
). J’aimerais donc changer ce dernier commit pour qu’il soit « atomique », c’est-à-dire qu’il représente un ensemble fini, homogène. Pour cela je vais utiliser la commande git commit --amend …
après avoir ajouté au stage le fichier oublié. Voici ce qui se produit :
- l’ajout crée un nouveau blob ;
- un nouveau tree représentant le répertoire
src/
est généré (il liste le nouveau blob) ; - on remonte jusqu’au niveau tree racine qui référence le nouvel objet tree pour le répertoire
src/
, il est donc lui aussi regénéré ; - on obtient enfin un nouveau commit qui référence ce nouvel objet tree racine.
En définitive j’obtiens donc un nouvel objet commit en remplacement du dernier.
Que deviennent alors les objets remplacés (et non-référencés) ?
Git possède un mécanisme de purge appelé Garbage Collector. Ce mécanisme ne tourne pas en tâche de fond mais uniquement lorsque nous réalisons des opérations de modifications de notre historique (principalement via les commandes commit
, merge
, reset
, rebase
, pull
).
Il analyse alors l’âge des objets qui ne sont plus référencés (plus accessibles dans la lignée des branches, des tags ou du HEAD) et décide le cas échéant de les supprimer. L’âge minimal par défaut est de 2 semaines (modifiable via la configuration gc.pruneExpire
).
Si je devais expliquer ça en une phrase…
Toute modification que nous réalisons sur un projet et souhaitons ajouter à l’historique entraîne la création de nouveaux objets techniques Git qui se référencent en cascade. Nous allons voir dans les prochains articles de cette série que cette cascade de références ne s’arrête pas là, elle concerne également les branches, les tags et HEAD.