Skip to main content

Git als inhaltsadressierte Datenbank 🔐

Git unterscheidet sich fundamental von anderen Versionskontrollsystemen durch seine Architektur als inhaltsadressierte Datenbank (content-addressable storage). Dieses Konzept ist der Schlüssel, um zu verstehen, warum Git so effizient, zuverlässig und schnell ist – und warum bestimmte Operationen überhaupt möglich sind.


Was „inhaltsadressiert" bedeutet

Bei einer inhaltsadressierten Datenbank wird der Speicherort eines Objekts nicht durch einen Namen, einen Pfad oder eine fortlaufende Nummer bestimmt, sondern direkt durch den Inhalt selbst. Genauer gesagt: Git berechnet aus dem Inhalt einer Datei (oder eines anderen Objekts) einen eindeutigen kryptografischen Hash und verwendet diesen Hash als „Adresse" oder Identifikator.

Das steht im Gegensatz zu herkömmlichen Dateisystemen:

Eigenschaft Klassisches Dateisystem Git (inhaltsadressiert)
Identifikation Pfad und Dateiname Hash des Inhalts
Umbenennung Ändert den Identifikator Hash bleibt gleich
Gleicher Inhalt, zwei Orte Zwei separate Speicherungen Nur einmal gespeichert
Inhalt geändert Gleicher Name, neuer Inhalt Neuer Hash = neues Objekt

Der entscheidende Gedanke ist: Der Inhalt ist die Adresse. Wenn du den Inhalt kennst, kennst du auch, wo und wie er gespeichert ist. Und umgekehrt: Wenn du den Hash hast, weißt du exakt, welcher Inhalt dahintersteckt.


Das SHA-1-Hash-System im Detail

Was ist ein Hash?

Ein Hash (auch „Prüfsumme" oder „Fingerabdruck") ist das Ergebnis einer mathematischen Funktion, die eine beliebig große Eingabe auf eine Ausgabe fester Länge abbildet. Git verwendet dafür den SHA-1-Algorithmus (Secure Hash Algorithm 1), der einen 160-Bit-Hash erzeugt, dargestellt als 40-stellige Hexadezimalzahl.

Beispiel eines Git-Hash:

a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0

Eigenschaften von SHA-1

SHA-1 hat mehrere wichtige Eigenschaften, die es für Git ideal machen:

  1. Deterministisch: Derselbe Inhalt erzeugt immer denselben Hash – auf jedem Computer, zu jeder Zeit, in jedem Repository weltweit.

  2. Kollisionsresistent: Es ist praktisch unmöglich, zwei verschiedene Inhalte zu finden, die denselben Hash erzeugen. Bei 160 Bit gibt es $2^{160}$ mögliche Hashes, das sind etwa $1{,}46 \times 10^{48}$ verschiedene Werte.

  3. Einweg-Funktion: Aus dem Hash kann man den ursprünglichen Inhalt nicht rekonstruieren. Der Hash verrät nichts über den Inhalt außer seiner Identität.

  4. Lawineneffekt: Selbst kleinste Änderungen am Inhalt führen zu einem völlig anderen Hash:

    Inhalt: "Hello World"
    Hash:   0a4d55a8d778e5022fab701977c5d840bbc486d0
    
    Inhalt: "Hello World!" (nur ein Ausrufezeichen hinzugefügt)
    Hash:   2ef7bde608ce5404e97d5f042f95f89f1c232871
    

Wie Git den Hash berechnet

Git berechnet den Hash nicht einfach nur vom reinen Dateiinhalt, sondern fügt einen Header hinzu, der den Objekttyp und die Größe enthält:

<typ> <größe>\0<inhalt>

Für eine Datei mit dem Inhalt „Hello World" (11 Bytes) würde Git also hashen:

blob 11\0Hello World

Du kannst das selbst überprüfen:

# Manuell den Hash berechnen
echo -n "Hello World" | git hash-object --stdin
# Ergebnis: 5e1c309dae7f45e0f39b1bf3ac3cd9db12e7d689

# Zum Vergleich mit Unix-Tools
printf "blob 11\0Hello World" | sha1sum
# Gleiches Ergebnis!

Der Übergang zu SHA-256

Git arbeitet seit einiger Zeit an einer Migration zu SHA-256, da SHA-1 theoretische Schwächen zeigt (2017 wurde die erste praktische Kollision demonstriert). Für die allermeisten Anwendungsfälle ist SHA-1 aber weiterhin sicher, und das Konzept der Inhaltsadressierung bleibt identisch.


Warum identische Dateien denselben Hash haben

Hier liegt die Magie des Systems: Da der Hash ausschließlich vom Inhalt abhängt, erzeugen identische Inhalte garantiert identische Hashes – unabhängig davon:

  • Wer den Hash berechnet
  • Wann der Hash berechnet wird
  • Wo (in welchem Repository) der Hash berechnet wird
  • Wie die Datei heißt
  • In welchem Verzeichnis die Datei liegt

Ein konkretes Beispiel

Stell dir vor, du und ein Kollege arbeiten an verschiedenen Projekten auf verschiedenen Kontinenten, ohne voneinander zu wissen:

# Dein Repository in Berlin
projekt-alpha/
└── src/
    └── utils.php    # Inhalt: "<?php echo 'Hello';"

# Repository deines Kollegen in Tokyo
projekt-beta/
└── lib/
    └── helper.php   # Inhalt: "<?php echo 'Hello';"

Obwohl die Dateien unterschiedliche Namen haben und in komplett unverbundenen Projekten liegen, haben sie exakt denselben Git-Hash, weil ihr Inhalt identisch ist.

Du kannst das selbst testen:

# In deinem Repository
echo "<?php echo 'Hello';" > test1.php
git hash-object test1.php
# Ergebnis: z.B. 3b18e512dba79e4c8300dd08aeb37f8e728b8dad

# In einem komplett anderen Repository auf einem anderen Computer
echo "<?php echo 'Hello';" > andere_datei.php
git hash-object andere_datei.php
# Gleiches Ergebnis: 3b18e512dba79e4c8300dd08aeb37f8e728b8dad

Was nicht in den Hash einfließt

Es ist wichtig zu verstehen, was alles keinen Einfluss auf den Hash einer Datei hat:

  • ❌ Der Dateiname
  • ❌ Der Verzeichnispfad
  • ❌ Die Dateiberechtigungen (außer dem Executable-Bit)
  • ❌ Der Zeitstempel der Datei
  • ❌ Der Autor oder Committer
  • ❌ Das Repository, in dem die Datei liegt
  • ❌ Die Git-Version

Nur der reine Dateiinhalt (Byte für Byte) bestimmt den Hash.


Praktische Auswirkungen

Die Inhaltsadressierung hat weitreichende Konsequenzen für die tägliche Arbeit mit Git:

1. Automatische Deduplizierung 💾

Git speichert identischen Inhalt nur einmal, egal wie oft er vorkommt. Wenn du dieselbe Datei in 100 verschiedenen Commits hast und sie sich nie ändert, existiert sie physisch nur einmal im .git/objects-Verzeichnis.

# Beispiel: Eine Datei in 100 Commits
# Naiver Ansatz: 100 × 10 KB = 1 MB Speicher
# Git: 1 × 10 KB = 10 KB Speicher

Das gilt auch für Dateien, die kopiert oder umbenannt werden: Solange der Inhalt gleich bleibt, wird nichts doppelt gespeichert.

2. Extrem effizientes Klonen und Fetching 🚀

Wenn du ein Repository klonst oder Änderungen fetchst, muss Git nur die Objekte übertragen, die du noch nicht hast. Durch den Hash kann Git sofort feststellen: „Dieses Objekt habe ich schon" – ohne den Inhalt vergleichen zu müssen.

# Beim git fetch:
# Dein lokales Repo: "Ich habe Objekte a1b2c3, d4e5f6, g7h8i9"
# Remote antwortet: "Der neue Commit braucht a1b2c3, x9y8z7"
# Git: "a1b2c3 habe ich schon, nur x9y8z7 muss übertragen werden"

3. Integrität und Datenvalidierung ✅

Der Hash dient als kryptografische Prüfsumme. Wenn auch nur ein einziges Bit in einem Objekt verändert wird (durch Festplattenfehler, Übertragungsprobleme oder absichtliche Manipulation), stimmt der Hash nicht mehr. Git kann das sofort erkennen:

# Git prüft automatisch bei vielen Operationen
# Du kannst es auch manuell auslösen:
git fsck
# Überprüft die Integrität aller Objekte

# Mögliche Ausgabe bei Problemen:
# error: sha1 mismatch for objects/a1/b2c3...
# fatal: loose object a1b2c3... is corrupt

Diese Eigenschaft macht Git extrem robust gegen:

  • Festplattenfehler
  • Netzwerkprobleme beim Push/Pull
  • Versehentliche oder absichtliche Manipulation
  • Bitrot (schleichende Datenkorruption)

4. Verteilte Zusammenarbeit ohne zentrale Autorität 🌍

Da Hashes deterministisch sind, braucht Git keine zentrale Instanz, die Objekte nummeriert oder IDs vergibt. Zwei Entwickler können unabhängig voneinander arbeiten, und wenn sie später ihre Repositories zusammenführen, sind identische Objekte automatisch „dieselben" – ohne Konflikte.

flowchart TD
    subgraph Alice["Alice's Repository"]
        A1[("Commit abc123")]
        A2[("Blob def456")]
    end
    
    subgraph Bob["Bob's Repository"]
        B1[("Commit xyz789")]
        B2[("Blob def456")]
    end
    
    subgraph Merged["Nach dem Merge"]
        M1[("Commit abc123")]
        M2[("Commit xyz789")]
        M3[("Blob def456\nnur einmal!")]
    end
    
    Alice --> Merged
    Bob --> Merged
    
    style A2 fill:#90EE90,color:#000000
    style B2 fill:#90EE90,color:#000000
    style M3 fill:#90EE90,color:#000000

5. Sichere Referenzierung von History 🔒

Da jeder Commit einen Hash hat, der von seinem Inhalt und von seinen Parent-Commits abhängt, ist die gesamte Git-History kryptografisch verkettet – ähnlich wie eine Blockchain:

flowchart LR
    C1["Commit A\nhash: a1b2c3"]
    C2["Commit B\nhash: d4e5f6\nparent: a1b2c3"]
    C3["Commit C\nhash: g7h8i9\nparent: d4e5f6"]
    
    C1 --> C2 --> C3

Wenn jemand versucht, einen alten Commit zu verändern, ändert sich sein Hash. Da der nächste Commit auf diesen Hash verweist, müsste auch dieser Commit neu gehasht werden – und so weiter bis zum aktuellen Commit. Eine nachträgliche Manipulation der History ist sofort erkennbar.

6. Commits sind global eindeutig 🆔

Die Wahrscheinlichkeit einer Hash-Kollision (dass zwei verschiedene Inhalte denselben Hash erzeugen) ist astronomisch gering:

$$
P(\text{Kollision}) \approx \frac{n^2}{2 \times 2^{160}}
$$

Selbst bei einer Milliarde Objekte ($n = 10^9$) ist die Wahrscheinlichkeit etwa:

$$
P \approx \frac{(10^9)^2}{2 \times 2^{160}} \approx 3{,}4 \times 10^{-31}
$$

Das bedeutet: Commit-Hashes sind praktisch weltweit eindeutig. Du kannst einen Hash wie a1b2c3d4 als absoluten Identifikator verwenden, der niemals mit einem anderen Commit kollidieren wird.

7. Effizientes Branching und Tagging 🏷️

Branches und Tags in Git sind extrem „billig", weil sie nur 40-Byte-Zeiger auf einen Commit-Hash sind:

# Ein Branch ist nur eine Datei mit einem Hash
cat .git/refs/heads/main
# Ausgabe: a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0

# Ein Tag ist ähnlich simpel
cat .git/refs/tags/v1.0.0
# Ausgabe: f1e2d3c4b5a6978... (ein Hash)

Das Erstellen eines neuen Branches ist daher eine O(1)-Operation – es wird nur eine 40-Byte-Datei geschrieben, egal wie groß das Repository ist.

8. Rename Detection funktioniert automatisch 📁

Git speichert keine „Rename"-Operationen explizit. Wenn du eine Datei umbenennst, erkennt Git das beim Betrachten der History automatisch, weil der Blob-Hash identisch bleibt:

# Datei umbenennen
mv old_name.php new_name.php
git add -A
git commit -m "Rename file"

# Git erkennt das Rename anhand des identischen Hashes
git log --follow --stat new_name.php
# Zeigt die komplette History, auch vor dem Rename!

Praktische Demonstration 🧪

Lass uns das Konzept in der Praxis erleben:

Schritt 1: Zwei Dateien mit gleichem Inhalt erstellen

# Neues Test-Repository
mkdir hash-demo && cd hash-demo
git init

# Zwei Dateien mit identischem Inhalt, aber verschiedenen Namen
echo "Identischer Inhalt" > datei1.txt
echo "Identischer Inhalt" > datei2.txt

# Hashes anzeigen (ohne zu committen)
git hash-object datei1.txt
# Ausgabe: z.B. 9c59e24b8b0c4c2a8d3e5f6a7b8c9d0e1f2a3b4c

git hash-object datei2.txt
# Ausgabe: 9c59e24b8b0c4c2a8d3e5f6a7b8c9d0e1f2a3b4c  ← Identisch!

Schritt 2: Änderung demonstrieren

# Winzige Änderung an datei2
echo "Identischer Inhalt!" > datei2.txt  # Ausrufezeichen hinzugefügt

git hash-object datei2.txt
# Ausgabe: 7f8e9d0c1b2a3456...  ← Komplett anderer Hash!

Schritt 3: Objekte im Repository inspizieren

# Dateien zum Staging hinzufügen
git add datei1.txt
git add datei2.txt
git commit -m "Initial commit"

# Blobs im Repository anschauen
find .git/objects -type f
# Zeigt die gespeicherten Objekte

# Einen Blob inspizieren
git cat-file -t 9c59e24  # Typ anzeigen → "blob"
git cat-file -p 9c59e24  # Inhalt anzeigen → "Identischer Inhalt"

Zusammenfassung 📋

Die Inhaltsadressierung ist das architektonische Fundament von Git:

Aspekt Bedeutung
Identität Inhalt = Adresse (Hash)
Effizienz Automatische Deduplizierung
Integrität Kryptografische Prüfsummen
Verteilung Keine zentrale ID-Vergabe nötig
Performance Schnelles Vergleichen via Hash
Zuverlässigkeit Manipulation sofort erkennbar

Wenn du dieses Konzept verinnerlicht hast, werden viele Git-Mechanismen plötzlich logisch: Warum Branches so billig sind, warum git gc Objekte aufräumen kann, warum Rebasing die History „umschreibt" und warum das Ändern alter Commits problematisch ist. Der Hash ist der Schlüssel zu allem. 🔑