Passer au contenu principal
RM
Retour au blog

SHA, blobs, trees, commits, DAG -- la structure interne de Git, pour enfin comprendre ce qui se passe.

Radnoumane Mossabely9 min read
Git internals
Git
Version Control
Internals
0 vues

TL;DR

  • Git ne stocke pas des diffs. Il stocke des snapshots complets de ton projet a chaque commit.
  • Tout est un objet identifie par un hash SHA : blob (contenu de fichier), tree (repertoire), commit (snapshot + metadata).
  • Les branches sont juste des pointeurs vers un commit. HEAD est un pointeur vers une branche.
  • Le DAG (graphe acyclique dirige) est la structure fondamentale qui relie les commits entre eux.
  • Comprendre ces mecanismes, c'est la difference entre utiliser Git et comprendre Git.

Le malentendu fondamental

La plupart des devs utilisent Git tous les jours sans savoir comment il fonctionne. Ils connaissent les commandes -- commit, push, merge, rebase -- mais pas la mecanique sous-jacente. Quand quelque chose se casse, c'est la panique.

Le malentendu le plus repandu : "Git stocke les differences entre les fichiers." C'est ce que font SVN et les anciens VCS. Git fait exactement l'inverse : il stocke des snapshots complets. Et c'est cette decision de design qui explique pourquoi Git est si rapide et si flexible.

Tout est un objet

Le coeur de Git, c'est une base de donnees d'objets. Chaque objet est identifie par un hash SHA-1 (40 caracteres hexadecimaux) calcule a partir de son contenu. Il y a trois types d'objets fondamentaux.

Blob : le contenu d'un fichier

Un blob (binary large object) contient le contenu brut d'un fichier. Pas le nom du fichier, pas les permissions, juste le contenu.

hljs bash
# Creer un fichier et le committer
echo "Hello World" > hello.txt
git add hello.txt
git commit -m "premier commit"

# Trouver le hash du blob
git hash-object hello.txt
# => 557db03de997c86a4a028e1ebd3a1ceb225be238

# Voir le contenu du blob
git cat-file -p 557db03
# => Hello World

Point important : deux fichiers avec le meme contenu ont le meme hash. Si tu as 10 fichiers identiques dans ton projet, Git n'en stocke qu'un seul blob. C'est de la deduplication gratuite.

Tree : un repertoire

Un tree represente un repertoire. Il contient des references vers des blobs (fichiers) et d'autres trees (sous-repertoires), avec les noms et les permissions.

hljs bash
# Voir le tree du dernier commit
git cat-file -p HEAD^{tree}
# => 100644 blob 557db03...  hello.txt
# => 040000 tree a1b2c3d...  src/

Le tree fait le lien entre les noms de fichiers et les blobs. C'est pour ca qu'un blob ne contient pas son nom -- le nom est dans le tree parent.

Commit : le snapshot

Un commit relie tout ensemble. Il contient :

  • Une reference vers un tree (le snapshot du projet)
  • Une reference vers le(s) commit(s) parent(s)
  • L'auteur et le committer (avec timestamps)
  • Le message de commit
hljs bash
git cat-file -p HEAD
# => tree 8a7b3c4...
# => parent 5d6e7f8...
# => author Radnoumane <email> 1693900000 +0200
# => committer Radnoumane <email> 1693900000 +0200
# =>
# => premier commit
Un commit pointe vers un tree, qui pointe vers des blobs et d'autres trees

Le DAG : le graphe des commits

Les commits forment un DAG -- Directed Acyclic Graph. Chaque commit pointe vers son parent (ou ses parents dans le cas d'un merge). Le graphe est dirige (on va toujours du commit vers son parent) et acyclique (pas de boucles).

    E---F  feature
   /     \
  A---B---C---G  main
       \     /
        D---E  hotfix

Ce graphe est toute l'histoire de ton projet. Chaque noeud est un commit, chaque arete est une relation parent-enfant. Un merge commit a deux parents. Un premier commit n'a aucun parent.

Le DAG explique pourquoi :

  • git log peut afficher l'historique dans n'importe quel ordre (il parcourt le graphe)
  • git merge cree un commit avec deux parents (fusion de branches dans le DAG)
  • git rebase recree des commits avec de nouveaux parents (restructuration du DAG)

Les branches sont des pointeurs

C'est le concept le plus simple et le plus mal compris de Git. Une branche n'est pas une copie du code. C'est un fichier de 41 octets qui contient le hash d'un commit.

hljs bash
# Voir ce qu'est la branche main
cat .git/refs/heads/main
# => 5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e

# C'est juste un hash de 40 caracteres + newline

Creer une branche, c'est creer un fichier. Supprimer une branche, c'est supprimer un fichier. C'est pour ca que les operations de branche sont instantanees dans Git, meme sur un projet avec 10 ans d'historique.

HEAD : le pointeur vers le pointeur

HEAD est un fichier special qui indique ou tu te trouves dans le graphe.

hljs bash
# En temps normal, HEAD pointe vers une branche
cat .git/HEAD
# => ref: refs/heads/main

# En mode "detached HEAD", il pointe vers un commit
cat .git/HEAD
# => 5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e

Quand tu fais git commit, Git :

  1. Cree un nouveau blob pour chaque fichier modifie
  2. Cree un nouveau tree avec les references mises a jour
  3. Cree un nouveau commit qui pointe vers ce tree et vers le commit actuel comme parent
  4. Met a jour la branche courante pour pointer vers le nouveau commit

C'est tout. Pas de diff, pas de patch, pas de base de donnees complexe. Juste des objets et des pointeurs.

Pourquoi tout est un hash

Le hash SHA-1 n'est pas juste un identifiant -- c'est une garantie d'integrite. Le hash est calcule a partir du contenu complet de l'objet. Si un seul bit change, le hash change.

Ca a des consequences profondes :

  • Verification automatique : si le contenu et le hash ne correspondent pas, Git sait que quelque chose est corrompu
  • Deduplication : meme contenu = meme hash = un seul objet stocke
  • Immutabilite : modifier un objet change son hash, ce qui cree un nouvel objet. Les anciens restent intacts.

C'est aussi pourquoi un rebase "reecrit l'historique". Quand tu changes le parent d'un commit, le contenu du commit change (il inclut la reference au parent), donc son hash change. C'est un nouvel objet. L'ancien commit existe toujours dans la base de donnees (jusqu'au garbage collection).

hljs bash
# Avant rebase
commit abc123 (parent: 111111)

# Apres rebase (meme code, parent different)
commit def456 (parent: 222222)
# abc123 existe toujours, mais plus aucune branche ne pointe dessus

Le staging area (index)

Entre ton repertoire de travail et les commits, il y a le staging area (ou index). C'est un fichier binaire (.git/index) qui contient la liste des fichiers qui seront inclus dans le prochain commit.

hljs bash
# Voir le contenu du staging area
git ls-files --stage
# => 100644 557db03... 0    hello.txt
# => 100644 ee8f3a1... 0    src/index.ts

git add copie le contenu d'un fichier dans un blob et met a jour l'index. git commit cree un tree a partir de l'index et un commit qui pointe vers ce tree.

C'est pour ca que git add est une operation separee de git commit -- ca te donne le controle sur exactement quels changements vont dans le prochain commit.

Les packfiles : la compression

Si Git stocke des snapshots complets, la taille du repo devrait exploser a chaque commit, non ?

Non, grace aux packfiles. Periodiquement (ou quand tu fais git gc), Git compresse ses objets en "packfiles". Dans un packfile, Git applique effectivement de la compression delta -- il stocke les differences entre les objets similaires.

hljs bash
# Voir les packfiles
ls .git/objects/pack/
# => pack-abc123.idx
# => pack-abc123.pack

# Statistiques de la base d'objets
git count-objects -v
# => count: 0        (objets en vrac)
# => packs: 1        (nombre de packfiles)
# => size-pack: 1234 (taille totale en KB)

C'est le meilleur des deux mondes : le modele conceptuel est base sur des snapshots (simple a raisonner), mais le stockage utilise des deltas (efficace en espace).

Exploration pratique

Voici les commandes pour explorer la base d'objets de n'importe quel repo :

hljs bash
# Type d'un objet
git cat-file -t <hash>    # blob, tree, commit, tag

# Contenu d'un objet
git cat-file -p <hash>    # affiche le contenu de maniere lisible

# Hash d'un fichier
git hash-object <file>    # calcule le SHA sans stocker

# Parcourir le graphe
git log --graph --oneline --all  # vue du DAG

# Verifier l'integrite
git fsck                  # scanne tous les objets

Essaie sur un vrai projet. Prends un commit, regarde son tree, descends dans les blobs. Tu vas voir la structure de ton projet sous un angle completement different.

HEAD pointe vers main, qui pointe vers un commit, qui pointe vers un tree

Pourquoi ca compte

Comprendre le modele interne de Git transforme ta maniere de l'utiliser :

  • Tu n'as plus peur du rebase : tu sais que c'est juste la creation de nouveaux commits avec des parents differents
  • Tu comprends le detached HEAD : HEAD pointe vers un commit au lieu d'une branche
  • Tu sais quoi faire apres un git reset --hard : les commits existent toujours (reflog), seul le pointeur a bouge
  • Tu comprends pourquoi le merge fast-forward existe : c'est juste avancer un pointeur, pas creer un commit
  • Tu debugges les conflits plus calmement : tu sais que Git compare des trees, pas des fichiers

Git n'est pas magique. C'est une base de donnees d'objets avec des pointeurs. Une fois que tu vois ca, tout devient logique.

Ressources

Partager: