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.
# 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.
# 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
git cat-file -p HEAD
# => tree 8a7b3c4...
# => parent 5d6e7f8...
# => author Radnoumane <email> 1693900000 +0200
# => committer Radnoumane <email> 1693900000 +0200
# =>
# => premier commit
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 logpeut afficher l'historique dans n'importe quel ordre (il parcourt le graphe)git mergecree un commit avec deux parents (fusion de branches dans le DAG)git rebaserecree 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.
# 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.
# 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 :
- Cree un nouveau blob pour chaque fichier modifie
- Cree un nouveau tree avec les references mises a jour
- Cree un nouveau commit qui pointe vers ce tree et vers le commit actuel comme parent
- 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).
# 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.
# 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.
# 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 :
# 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.
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.