Git – annexe : les internes

Les internes de git 🔬

Ce qu'il y a dans .git/, et pourquoi ça explique tout le reste

Annexe · à lire après GIT1, en 20 minutes, un terminal ouvert

Git – annexe : les internes

Pourquoi ouvrir le capot ?

Git a la réputation d'être compliqué. À l'usage, ce qui est compliqué, c'est de retenir des commandes sans savoir sur quoi elles agissent. Or le modèle en dessous est petit :

  • une base d'objets immuables, adressĂ©s par leur hash ;
  • des pointeurs (les branches, HEAD) vers ces objets ;
  • un journal des dĂ©placements de ces pointeurs (le reflog).

C'est tout. Nous allons le vérifier avec deux commandes de plomberie, git cat-file et git hash-object, dans le dépôt carnet de la séance (le vôtre fera aussi bien l'affaire).

Git – annexe : les internes

.git/ : le dépôt, c'est ce dossier

$ ls -F .git
COMMIT_EDITMSG  HEAD  ORIG_HEAD  config  description  hooks/  index  info/  logs/  objects/  refs/
Entrée Rôle
objects/ la base : tous les fichiers, arborescences et commits, pour toujours
refs/heads/ les branches (un fichier par branche) ; refs/tags/, refs/remotes/
HEAD « où suis-je » : le nom de la branche courante
index la zone de préparation, en binaire
logs/ le reflog
config remotes, options locales

Le working tree, ce sont vos fichiers à côté ; .git/ est l'historique. Supprimez .git/ et le projet redevient un simple dossier.

Git – annexe : les internes

Les objets : trois types, un hash

$ find .git/objects -type f | head -3
.git/objects/a9/bfd1f59efa6179f9a3d33fc937cee75158081e
.git/objects/98/4f5b63bb64c72453b18338a8130e0e16c10a50
.git/objects/c5/2f0cf1c8177960488734248311264a2539ee65
$ git cat-file -t a9bfd1f       # -t : le type
commit
$ git cat-file -t 984f5b6
blob
$ git cat-file -t c52f0cf
tree

Chaque objet est stocké (compressé) dans un fichier dont le nom est le SHA-1 de son contenu : 2 caractères de dossier + 38 de fichier. Même contenu, même hash, même fichier : git ne stocke jamais deux fois la même chose.

Git – annexe : les internes

Le blob : le contenu d'un fichier, sans son nom

$ git cat-file -p 984f5b6       # -p : le contenu, joliment
# CrĂŞpes

- 250 g de farine
- 3 œufs
- 50 cl de lait

Cuisson : 3 min par face, Ă  feu vif.

Un blob est le contenu brut d'un fichier. Il ne connaît ni son nom, ni ses droits, ni sa place dans l'arborescence : ce sont les trees qui portent cela. Renommer un fichier ne crée donc aucun nouveau blob ; et deux fichiers identiques dans deux dossiers partagent le même blob.

Git – annexe : les internes

Le tree : un dossier

$ git cat-file -p c52f0cf                    # le tree racine du commit a9bfd1f
100644 blob 6f64d556a15d5aea3a3d1141dd91fa3fc5be8374	README.md
040000 tree b16b1c10d65458fd4be56ce9c835a371148f266e	recettes
$ git cat-file -p b16b1c1                    # le sous-dossier recettes/
100644 blob 984f5b63bb64c72453b18338a8130e0e16c10a50	crepes.md
100644 blob bd17b34bda037f60d92fa002a2bf5c787c1743c8	tarte.md

Un tree est une liste de lignes « mode, type, hash, nom » : un blob par fichier, un tree par sous-dossier. C'est exactement un dossier, photographié. Le hash d'un tree dépend de tout ce qu'il contient : un octet changé dans tarte.md change le hash de recettes, donc celui de la racine.

Git – annexe : les internes

Le commit : une photo, son parent, un auteur

$ git cat-file -p a9bfd1f
tree c52f0cf1c8177960488734248311264a2539ee65
parent 093450a4632ed7c6f08ed84c9b912c924e418ade
author Raphael Prasquier <raphael@imagine-app.fr> 1789034400 +0200
committer Raphael Prasquier <raphael@imagine-app.fr> 1789034400 +0200

Précise la cuisson des crêpes

Quatre lignes et un message. Le commit ne contient aucun diff : il pointe vers un tree (l'état complet du projet) et vers son parent. Un commit de fusion a simplement deux lignes parent.

Le hash du commit couvre tout cela, parent inclus : modifier un commit ancien change son hash, donc celui de tous ses descendants. C'est pour cela que « réécrire l'historique » est un vrai mot.

Git – annexe : les internes

hash-object : fabriquer un blob Ă  la main

$ echo "bonjour" | git hash-object --stdin
1cd909e05d33f0f6bc4ea1caf19b5749b434ceb3
$ echo "bonjour" | git hash-object -w --stdin      # -w : et écris-le dans .git/objects
1cd909e05d33f0f6bc4ea1caf19b5749b434ceb3
$ git cat-file -p 1cd909e
bonjour

Sans -w, git calcule seulement le hash ; le même contenu donnera le même hash sur votre machine, la mienne, et celle de GitHub. Avec -w, l'objet existe dans la base sans qu'aucun commit ne le référence : c'est ce que fait git add, qui écrit le blob et note son hash dans l'index. Le commit, ensuite, ne fait qu'écrire les trees et l'objet commit.

Git – annexe : les internes

Un commit est un snapshot, pas un diff

$ git cat-file -p 093450a^{tree}          # le tree du commit précédent
100644 blob 6f64d556a15d5aea3a3d1141dd91fa3fc5be8374	README.md
040000 tree 70e5859e6a2701295669403bf8aa65d4e8a2e567	recettes
$ git cat-file -p a9bfd1f^{tree}          # le tree du commit suivant
100644 blob 6f64d556a15d5aea3a3d1141dd91fa3fc5be8374	README.md
040000 tree b16b1c10d65458fd4be56ce9c835a371148f266e	recettes

Entre les deux commits, seule crepes.md a changé : README.md a le même blob (6f64d55) dans les deux photos, alors que le tree recettes a changé. Chaque commit décrit l'état complet ; git ne paie que ce qui diffère, par partage des objets.

Le diff que montre git show est calculé à la demande, entre deux trees. En clair : git "ne sait pas" ce qu'un commit a changé, il le recalcule, et c'est instantané.

Git – annexe : les internes

Une branche : un fichier de 41 octets

$ ls .git/refs/heads
cuisson  garniture  main  sauce
$ cat .git/refs/heads/main
a9bfd1f59efa6179f9a3d33fc937cee75158081e
$ wc -c .git/refs/heads/main
41 .git/refs/heads/main                # 40 caractères de hash + un retour à la ligne

Voilà toute la branche. git branch sauce écrit un fichier ; git commit sur main remplace le hash par celui du nouveau commit ; git branch -d supprime le fichier. Les commits, eux, ne bougent pas : ils sont dans objects/.

💡 Sur un dépôt avec beaucoup de branches, git les regroupe dans .git/packed-refs ; le fichier individuel peut alors manquer, git rev-parse main reste la façon fiable de lire le hash.

Git – annexe : les internes

HEAD : un pointeur vers un pointeur

$ cat .git/HEAD
ref: refs/heads/main                   # HEAD "symbolique" : je suis sur la branche main
$ git switch --detach 7ac8892
$ cat .git/HEAD
7ac88920ed4cb2ad2a54a214062ce350d6db7522   # HEAD "détachée" : je suis sur un commit

HEAD désigne normalement une branche ; c'est pour cela qu'un commit fait avancer la branche : git écrit le nouveau hash dans le fichier que HEAD désigne. En HEAD détachée, HEAD contient directement un hash : commiter met à jour HEAD… et aucune branche.

Tout git switch se résume donc à : réécrire .git/HEAD, puis mettre le working tree et l'index en accord avec le tree du commit visé.

Git – annexe : les internes

Le reflog : le journal des pointeurs

$ head -3 .git/logs/HEAD               # (colonnes raccourcies ici)
0000000… a7d3639… Raphael <…> 1789023600 +0200  commit (initial): Crée le carnet de recettes
a7d3639… 7ac8892… Raphael <…> 1789027200 +0200  commit: Ajoute la recette des crêpes
7ac8892… 093450a… Raphael <…> 1789030800 +0200  commit: Ajoute la tarte aux pommes et le sommaire
$ git reflog                           # la même chose, lisible, du plus récent au plus ancien

Chaque ligne : ancien hash, nouveau hash, qui, quand, pourquoi. Il y a un journal pour HEAD (logs/HEAD) et un par branche (logs/refs/heads/main). C'est local : un clone ne l'emporte pas.

Un reset --hard ne détruit rien : il écrit un ancien hash dans la branche, et note l'opération ici. Les commits « perdus » restent dans objects/ tant que git gc ne les a pas jugés inaccessibles et trop vieux (30 jours par défaut).

Git – annexe : les internes

Les packfiles : quand les objets sont rangés

$ git count-objects -v
count: 29                # objets "en vrac", un fichier chacun
size: 116
$ git gc
$ git count-objects -v
count: 0
in-pack: 29              # les mĂŞmes, dans un seul fichier .pack (+ son index .idx)
$ ls .git/objects/pack
pack-957df6de77d1cddbdb2f1c97a959daca57c70847.idx
pack-957df6de77d1cddbdb2f1c97a959daca57c70847.pack

Des blobs entiers pour chaque version d'un gros fichier finiraient par coûter cher. Périodiquement (ou au push/clone), git empaquette les objets et, dans le pack seulement, stocke certains comme des deltas par rapport à d'autres. C'est un détail de stockage : le modèle reste « un commit = une photo », et cat-file ne voit pas la différence.

Git – annexe : les internes

Pourquoi c'est important

  • Le trio de GIT1 prend tout son sens : le working tree est un dossier ordinaire, l'index un fichier qui liste des hashes de blobs, HEAD un pointeur. add Ă©crit des blobs, commit Ă©crit des trees et un commit, switch réécrit HEAD.
  • Le reflog cesse d'ĂŞtre magique : c'est un fichier texte, et « perdre un commit » veut seulement dire qu'aucun pointeur ne le dĂ©signe plus.
  • Les messages d'erreur se lisent : « detached HEAD », « would be overwritten by checkout », « rejected, non-fast-forward » parlent tous de pointeurs et de trees.
  • GIT2 : git worktree extrait plusieurs commits dans plusieurs dossiers avec le mĂŞme .git/ ; rebase recrĂ©e des commits (nouveaux hashes, mĂŞme trees) ; origin/main n'est qu'un fichier de plus dans refs/remotes/.

Pour aller plus loin : Pro Git, chapitre 10 « Les tripes de Git », https://git-scm.com/book/fr/v2.