Le modèle objet de Git#
Git n’est pas, en son coeur, un système de contrôle de version. C’est un système de fichiers adressable par contenu (content-addressable filesystem) sur lequel on a construit une interface de versionnement. Cette distinction n’est pas anecdotique : elle explique pourquoi les branches sont si légères, pourquoi un rebase réécrit l’historique, pourquoi deux fichiers identiques ne sont stockés qu’une seule fois, et pourquoi git bisect peut parcourir des milliers de commits en quelques secondes.
Ce chapitre plonge dans le modèle objet qui sous-tend toutes les commandes Git. Comprendre ces mécanismes internes, c’est passer d’une utilisation par recettes à une maitrise réelle de l’outil. Après cette lecture, les commandes commit, branch, merge et checkout n’auront plus aucun mystère.
Les quatre types d’objets#
La base de données de Git ne contient que quatre types d’objets. Tout le reste — branches, tags, historique — est construit à partir de ces briques élémentaires.
Définition 9 (Blob (Binary Large Object))
Un blob est un objet Git qui stocke le contenu brut d’un fichier. Il ne contient ni le nom du fichier, ni ses permissions, ni aucune autre metadonnée : uniquement les octets du fichier. Deux fichiers ayant exactement le même contenu, même s’ils portent des noms differents, produisent le même blob (et donc le même hash).
Définition 10 (Tree (arbre))
Un tree est un objet Git qui représente le contenu d’un répertoire. Il contient une liste d’entrées, chacune de la forme (mode, type, hash, nom) :
mode : les permissions du fichier (ex.
100644pour un fichier normal,040000pour un sous-répertoire)type :
bloboutreehash : le SHA-1 de l’objet pointe
nom : le nom du fichier ou du sous-repertoire
Un tree peut pointer vers des blobs (fichiers) ou vers d’autres trees (sous-répertoires), formant ainsi une structure arborescente.
Définition 11 (Commit)
Un commit est un objet Git qui représente un instantané (snapshot) du projet à un moment donné. Il contient :
un pointeur vers un tree (l’état complet de l’arborescence)
zero, un ou plusieurs pointeurs vers des commits parents (le commit initial n’a pas de parent ; un commit de merge en a deux ou plus)
le nom et l’email de l”auteur (celui qui a écrit le code) et du committer (celui qui a intégré le commit)
un horodatage pour chacun
un message decrivant les changements
Définition 12 (Tag annoté)
Un tag annoté est un objet Git qui pointe vers un autre objet (généralement un commit) et y associe :
le nom du tagueur et son email
un horodatage
un message descriptif
Contrairement aux tags légers (simples références), les tags annotés sont des objets à part entière, stockés dans la base de données Git.
Remarque 9
Tous les objets Git sont immuables : une fois créé, un objet ne change jamais. Ils sont adressés par le hash SHA-1 de leur contenu. Cette propriété a deux conséquences majeures :
Déduplication : si deux fichiers (ou deux répertoires, ou deux commits) ont exactement le même contenu, ils produisent le même hash et ne sont stockés qu’une seule fois.
Intégrité : toute modification, même d’un seul octet, produit un hash complètement différent. Git détecte donc automatiquement toute corruption de données.
Visualisation : la hiérarchie des objets#
Le diagramme suivant illustre les relations entre les quatre types d’objets. Un commit pointe vers un tree racine, qui pointe vers des blobs (fichiers) et des sous-trees (répertoires).
Adressage par contenu (SHA-1)#
Définition 13 (Hash SHA-1)
Chaque objet Git est identifié par un hash SHA-1 : une chaine hexadecimale de 40 caractères (160 bits). Ce hash est calculé à partir de :
où type est blob, tree, commit ou tag, taille est la taille du contenu en octets, \0 est l’octet nul, et contenu est le contenu brut de l’objet. Ce mécanisme garantit qu’un objet est entièrement déterminé par son contenu.
Explorons concrètement les objets d’un dépôt Git. Commençons par (re)créer un dépôt de démonstration.
%%bash
# Création d'un dépôt de démonstration
rm -rf /tmp/demo-objets
mkdir -p /tmp/demo-objets
cd /tmp/demo-objets
git init
git config user.name "Alice"
git config user.email "alice@example.com"
# Premier commit
echo "# Mon projet" > README.md
echo "print('hello')" > main.py
mkdir src
echo "def add(a, b): return a + b" > src/utils.py
git add .
git commit -m "Commit initial"
# Deuxième commit
echo "## Installation" >> README.md
echo "import utils" > src/app.py
git add .
git commit -m "Ajout documentation et app"
echo "--- Dépôt créé avec succès ---"
Dépôt Git vide initialisé dans /tmp/demo-objets/.git/
[main (commit racine) 768f5d0] Commit initial
3 files changed, 3 insertions(+)
create mode 100644
README.md
create mode 100644 main.py
create mode 100644 src/utils.py
[main 629eedd] Ajout documentation et app
2 files changed, 2 insertions(+)
create mode 100644 src/
app.py
--- Dépôt créé avec succès ---
Inspecter un commit#
La commande git cat-file permet d’examiner n’importe quel objet de la base de données Git. L’option -t affiche le type, et -p affiche le contenu de manière lisible (pretty-print).
%%bash
cd /tmp/demo-objets
echo "=== Historique ==="
git log --oneline
echo ""
echo "=== Type de HEAD ==="
git cat-file -t HEAD
echo ""
echo "=== Contenu du commit HEAD ==="
git cat-file -p HEAD
=== Historique ===
629eedd Ajout documentation et app
768f5d0 Commit initial
=== Type de HEAD ===
commit
=== Contenu du commit HEAD ===
tree d874692f10c9afad9f01ee0fe2ffcb4fc2ed847d
parent 768f5d0f403040adf7df3e9cba85f67bb66fbfe1
author
Alice <alice@example.com> 1773606304 +0100
committer Alice <alice@example.com> 1773606304 +0100
gpg
sig -----BEGIN PGP SIGNATURE-----
iQIzBAABCgAdFiEE6KxXyoxVGcddTHsCvMcTZ0R7zesFAmm3FaAACgkQvMcTZ0R
7
zes+8w/+Ox4K2DTHjGgLR3nTxh57vWPRANfrvDEWz7kGru+mlrwF7ihY1i/6Yaxa
3tFdnvvpmSQmK/0e+tJ1chY4yz+r+jk
Rj6+BHw9ypnZ7xHPIFJlPzTM+C/T60xPT
6EtanhNEGdMdXFJeyiphtQhZ1xy/Y9Jw8LDD48QzvCsc8ZNrxw6kvgslFm0eZ1Y6
U/9ByudG/sZ8BOAqUwADjMwXxxH88oGXz5Vcznzh83E9wqp6txEtjbce/HJr4thr
PnTQJnkXg9Oe5a/J8xDchyPuVzN3CdDJG
3eiVHwAMQhooC7JvjBdUO4kfyJw/HRH
BcdfXFsFLrEvsw3F7bCQup8oTPm3tRkcL2fzUku5vmtz/6UEZ+HUF08hZ8pH1GRB
H
L6mkXy61T5o7KxbxGsERofFxfTjY530q8qJBjwJqXMbSTILjzUQv2E4l3LEoyet
S1Bd0qippecnuob1vhf/CWRrCmDWiFxMeq1
dhpQfghl5DOcVneAjnK6XrMcxHs1K
93k7Fv3ReF9wT2W1T6hg7877CfUhhD4KB/Yc7w+iFieoKwEVLfigRsWcXed8R8d+
KJ6
A6bZYnDzj31FCfCgAGM2R1cAlyFclZu91BxrvX2W8e4uOJSgT9l4E6A6yybtk
SoSUR3qmHKE53FzJ3+DxVhzbCCCp7A57Cmi4u
kuKTqVJFoZr4qw=
=gdGa
-----END PGP SIGNATURE-----
Ajout documentation et app
On voit que le commit contient : un pointeur tree, un pointeur parent (vers le commit précédent), les informations author et committer, et le message.
Inspecter un tree#
Récupérons le hash du tree pointé par le dernier commit et examinons son contenu.
%%bash
cd /tmp/demo-objets
echo "=== Tree racine du dernier commit ==="
TREE_HASH=$(git cat-file -p HEAD | head -1 | awk '{print $2}')
git cat-file -p $TREE_HASH
=== Tree racine du dernier commit ===
100644 blob 5bab5759015ab50a98ffea94a40c04d99da337b2 README.md
100644 blob b376c9941fda362c8d2c5c8dd
b35db3e0b003402 main.py
040000 tree d90027d9849041751d80e4d0d2a366870a3ffc30 src
Le tree liste chaque entrée avec son mode, son type (blob ou tree), son hash et son nom. On voit que src est un sous-tree, tandis que README.md et main.py sont des blobs.
Inspecter un blob#
%%bash
cd /tmp/demo-objets
echo "=== Contenu du blob README.md ==="
# Récupérer le hash du blob README.md depuis le tree racine
TREE_HASH=$(git cat-file -p HEAD | head -1 | awk '{print $2}')
BLOB_HASH=$(git cat-file -p $TREE_HASH | grep "README.md" | awk '{print $3}')
echo "Hash : $BLOB_HASH"
echo "Type : $(git cat-file -t $BLOB_HASH)"
echo "---"
git cat-file -p $BLOB_HASH
=== Contenu du blob README.md ===
Hash : 5bab5759015ab50a98ffea94a40c04d99da337b2
Type : blob
---
# Mon projet
## Installation
Le blob ne contient que le texte brut du fichier — pas de nom, pas de permissions. Ces métadonnées sont stockées dans le tree parent.
Remarque 10
SHA-256 remplace progressivement SHA-1. Depuis 2020, Git supporte expérimentalement SHA-256 comme algorithme de hash (git init --object-format=sha256). Le principe d’adressage par contenu reste strictement identique ; seule la longueur du hash change (64 caracteres au lieu de 40). La migration est progressive et transparente pour l’utilisateur.
Vérifier l’adressage par contenu#
Vérifions que le hash d’un blob est bien déterministe : deux fichiers au contenu identique produisent le même hash.
%%bash
cd /tmp/demo-objets
echo "=== Hash d'un contenu donné ==="
# git hash-object calcule le SHA-1 sans stocker l'objet
echo "hello world" | git hash-object --stdin
echo "hello world" | git hash-object --stdin
echo "(les deux hash sont identiques)"
echo ""
echo "=== Un seul octet de différence ==="
echo "hello world!" | git hash-object --stdin
echo "(hash complètement différent)"
=== Hash d'un contenu donné ===
3b18e512dba79e4c8300dd08aeb37f8e728b8dad
3b18e512dba79e4c8300dd08aeb37f8e728b8dad
(les deux hash sont identiques)
=== Un seul octet de différence ===
a0423896973644771497bdc03eb99d5281615b51
(hash complètement différent)
Le DAG des commits#
Définition 14 (DAG (Directed Acyclic Graph))
L’historique Git forme un graphe orienté acyclique (DAG). Chaque commit est un noeud du graphe, et chaque arête pointe d’un commit vers son ou ses parents. Le graphe est :
orienté : les arêtes ont un sens (du commit enfant vers le parent)
acyclique : il est impossible de revenir à un commit en suivant les arêtes — le temps ne remonte pas
Cette structure permet de représenter naturellement les historiques linéaires, les branchements et les fusions.
Historique linéaire#
Le cas le plus simple : chaque commit a exactement un parent (sauf le premier qui n’en a pas).
Le commit \(C\) pointe vers \(B\), qui pointe vers \(A\). Attention au sens des flèches : Git pointe vers le passé, pas vers le futur.
Branchement#
Quand on crée une branche et qu’on y fait des commits, le graphe diverge :
Les commits \(C\) et \(D\) ont tous deux \(B\) comme parent, mais ils appartiennent à des branches différentes.
Fusion (merge)#
Un commit de merge a deux parents : il reconcilie deux lignes de développement.
Le commit \(E\) pointe à la fois vers \(C\) et vers \(D\).
Visualisation du DAG#
Vérifions cette structure dans notre dépôt de démonstration en créant un branchement et une fusion.
%%bash
cd /tmp/demo-objets
# Créer une branche et y faire un commit
git checkout -b feature
echo "def multiply(a, b): return a * b" > src/math_ops.py
git add .
git commit -m "Ajout multiplication"
# Revenir sur main et faire un commit
git checkout main
echo "## Usage" >> README.md
git add .
git commit -m "Ajout section usage"
# Fusionner la branche feature
git merge feature -m "Merge feature dans main"
echo ""
echo "=== Graphe des commits ==="
git log --oneline --graph --all
Basculement sur la nouvelle branche 'feature'
[feature 23df57a] Ajout multiplication
1 file changed, 1 insertion(+)
create mode 100644 src/math_
ops.py
Basculement sur la branche 'main'
[main b89df90] Ajout section usage
1 file changed, 1 insertion(+)
Merge made by the 'ort' strategy.
src/math_ops.py | 1 +
1 file changed, 1 insertion(+)
create mode 100644 src/math_ops.py
=== Graphe des commits ===
* d80a756 Merge feature dans main
|\
| * 23df57a Ajout multiplication
* | b89df90 Ajout section
usage
|/
* 629eedd Ajout documentation et app
* 768f5d0 Commit initial
%%bash
cd /tmp/demo-objets
echo "=== Commit de merge : deux parents ==="
git cat-file -p HEAD
=== Commit de merge : deux parents ===
tree 4eee9ccc66bf302822a4b434eedee1e7f879e018
parent b89df90dc30f638766021674a8153e15b0339ec7
parent
23df57a2a0b8c0ad5437212d41f9f9a1815d03c3
author Alice <alice@example.com> 1773606305 +0100
committe
r Alice <alice@example.com> 1773606305 +0100
gpgsig -----BEGIN PGP SIGNATURE-----
iQIzBAABCgAdFiE
E6KxXyoxVGcddTHsCvMcTZ0R7zesFAmm3FaEACgkQvMcTZ0R7
zevAPw/8D2AMjiFIZMtbs9DXOhD1QCbjMSskmzwDI2R85cXIn
OJn1iJXqUXlQEdL
nvc/mC9AcyyDDtn+AeqQtUZEIOqyEKfRkLkA4aHOxP3Mf2eoyzthNT3K+GVUJ/A3
7R9YZF2R+RzrwxRI0
VY+xuklg6dfJcE3jEv9gORUh/EaQrqZj4rYPju7RR0PR6BI
XSjzCcsrDM6vMkWyyKHHa7QQeUEHx+42n5bkMr29SZa+g8VOxcf
9SzxcR15JiEf0
vsaDtFWA82E/sY69xM/W3nnfPJ57sJhzPB5Uq7+tpSFoEZntgnpcZh1CUY1iAgZb
1Cd6qJga8HQsFnusQYq
q79G9kWEk/go4GirihuLhPghew1c03qgatXSAlYOZVWor
IeGqS1WVmhR4rhHkgpuGaBv5fFxdedcdWUVrkHvMKaOXIhLrDnpZl
Pd1FPA//rpB
B/FjI4FvOltKMFVadcw1Bp/gLlD+9hY+zYEmefk+FA5MIkVYFrvfSP1h0W0p7b6D
XNa1Xo90Xw4tMCHLWAvfY
6rUIj22tuJJsUxKL49M/C/7mrzKZgENFFeWsgxqcKOe
JlUe5e/kbQyVT5nVxNDUwGyFa6dUdTFYrFSr9ziKOQ9putnOgJgrJDy
5W/JDyAcU
m77Kqh1KDbvaRSfEs5PIPp86yPKY0tvuDqFFiR5rNx8JKRRn9Fw=
=bMbR
-----END PGP SIGNATURE-----
Merge feature dans main
On constate que le commit de merge contient bien deux lignes parent, une pour chaque branche fusionnée.
Exemple 4
Pour visualiser le DAG complet d’un dépôt, la commande suivante est très utile :
git log --oneline --graph --all --decorate
L’option --graph dessine le graphe en ASCII, --all inclut toutes les branches, et --decorate affiche les noms de branches et tags à côté des commits.
Les références#
Si les objets sont les données, les références sont les noms qu’on leur donne. Sans références, il faudrait manipuler des hash SHA-1 de 40 caractères en permanence.
Définition 15 (Référence (ref))
Une référence est un fichier qui contient le hash SHA-1 d’un objet Git (généralement un commit). Les références sont stockées dans le répertoire .git/refs/. On distingue :
branches : dans
.git/refs/heads/tags : dans
.git/refs/tags/références distantes : dans
.git/refs/remotes/
Définition 16 (HEAD)
HEAD est une référence spéciale qui désigne le commit actuellement extrait (checked out). En fonctionnement normal, HEAD est une référence symbolique : elle pointe vers une branche (par exemple ref: refs/heads/main), et c’est cette branche qui pointe vers un commit. En état detached HEAD, HEAD pointe directement vers un commit sans passer par une branche.
Définition 17 (Branche)
Une branche est une référence mobile qui pointe vers le dernier commit d’une ligne de développement. Quand on fait un nouveau commit sur une branche, la référence avance automatiquement pour pointer vers ce nouveau commit. Techniquement, une branche n’est rien de plus qu’un fichier de 41 octets (40 caracteres hexadecimaux + un retour a la ligne) dans .git/refs/heads/.
Vérifions cela dans notre dépôt.
%%bash
cd /tmp/demo-objets
echo "=== Contenu de .git/HEAD ==="
cat .git/HEAD
echo ""
echo "=== Contenu de .git/refs/heads/main ==="
cat .git/refs/heads/main
echo ""
echo "=== Contenu de .git/refs/heads/feature ==="
cat .git/refs/heads/feature
echo ""
echo "=== Vérification : HEAD pointe bien vers main ==="
echo "Hash via la ref : $(cat .git/refs/heads/main)"
echo "Hash via git rev-parse : $(git rev-parse HEAD)"
=== Contenu de .git/HEAD ===
ref: refs/heads/main
=== Contenu de .git/refs/heads/main ===
d80a7566801505996e6801452fb15d96e0e4bb9e
=== Contenu de .git/refs/heads/feature ===
23df57a2a0b8c0ad5437212d41f9f9a1815d03c3
=== Vérification : HEAD pointe bien vers main ===
Hash via la ref : d80a7566801505996e6801452fb15d96e0e4bb9e
Hash via git rev-parse : d80a7566801505996e6801452fb15d96e0e4bb9e
%%bash
cd /tmp/demo-objets
echo "=== Taille du fichier de la branche main ==="
wc -c .git/refs/heads/main
echo ""
echo "=== Liste des références ==="
find .git/refs -type f
=== Taille du fichier de la branche main ===
41 .git/refs/heads/main
=== Liste des références ===
.git/refs/heads/main
.git/refs/heads/feature
Remarque 11
C’est précisement parce qu’une branche n’est qu’un fichier de 41 octets que les branches sont si peu coûteuses en Git. Créer une branche, c’est écrire un hash dans un fichier. Supprimer une branche, c’est supprimer ce fichier. Il n’y a aucune copie de code, aucune duplication de l’historique. C’est une différence fondamentale avec des systèmes comme SVN, ou une branche impliquait une copie physique de toute l’arborescence.
Exemple 5
On peut créer une branche « à la main » en écrivant directement dans .git/refs/heads/ :
# Equivalent de : git branch experiment
echo $(git rev-parse HEAD) > .git/refs/heads/experiment
git branch # la branche experiment apparait
Ce n’est bien sûr pas recommandé en pratique, mais cela illustre que les branches ne sont que des fichiers texte contenant un hash.
Résumé#
Le modèle objet de Git repose sur quatre types d’objets et un système de références :
Concept |
Rôle |
Stockage |
|---|---|---|
Blob |
Contenu d’un fichier |
|
Tree |
Structure d’un répertoire |
|
Commit |
Instantané + métadonnées + parent(s) |
|
Tag annoté |
Pointeur nommé vers un commit |
|
Référence |
Nom lisible pointant vers un hash |
|
HEAD |
Référence spéciale : commit courant |
|
Les objets sont immuables et adressés par contenu (SHA-1). Les références sont mutables et servent de points d’entrée lisibles dans le graphe des objets. L’historique forme un DAG où chaque commit pointe vers ses parents.
Cette architecture explique les propriétés fondamentales de Git :
Les branches sont légères : ce sont de simples pointeurs.
L’historique est un graphe, pas une liste : les merges et les branches sont des citoyens de première classe.
La déduplication est automatique : même contenu = même hash = même objet.
L’intégrité est garantie : tout objet est vérifié par son hash.