Kapitel 8. Aufgedeckte Geheimnisse

Wir werfen einen Blick unter die Motorhaube und erklären, wie Git seine Wunder vollbringt. Ich werde nicht ins Detail gehen. Für tiefer gehende Erklärungen verweise ich auf das englischsprachige Benutzerhandbuch.

Unsichtbarkeit

Wie kann Git so unauffällig sein? Abgesehen von gelegentlichen Commits und Merges kannst Du arbeiten, als würde die Versionsverwaltung nicht existieren. Das heißt, bis Du sie brauchst. Und das ist, wenn Du froh bist, dass Git die ganze Zeit über Dich gewacht hat.

Andere Versionsverwaltungssysteme zwingen Dich ständig, Dich mit Verwaltungskram und Bürokratie herumzuschlagen. Dateien können schreibgeschützt sein, bis Du einem zentralen Server mitteilst, welche Dateien Du gerne bearbeiten möchtest. Die einfachsten Befehle werden bis zum Schneckentempo verlangsamt, wenn die Anzahl der Anwender steigt. Deine Arbeit kommt zum Stillstand, wenn das Netzwerk oder der zentrale Server weg sind.

Im Gegensatz dazu hält Git seinen Verlauf einfach im .git Verzeichnis von Deinem Arbeitsverzeichnis. Das ist Deine eigene Kopie der Versionsgeschichte, damit kannst Du so lange offline bleiben, bis Du mit anderen kommunizieren willst. Du hast die absolute Kontrolle über das Schicksal Deiner Dateien, denn Git kann jederzeit einfach einen gesicherten Stand aus .git wiederherstellen.

Integrität

Die meisten Leute verbinden mit Kryptographie die Geheimhaltung von Informationen, aber ein genau so wichtiges Ziel ist es, Informationen zu sichern. Die richtige Anwendung von kryptographischen Hash-Funktionen kann einen versehentlichen oder bösartigen Datenverlust verhindern.

Einen SHA1-Hash-Wert kann man sich als eindeutige 160-Bit Identitätsnummer für jegliche Zeichenkette vorstellen, welche Dir in Deinem ganzen Leben begegnen wird. Sogar mehr als das: jegliche Zeichenfolge, die alle Menschen über mehrere Generationen verwenden.

Ein SHA1-Hash-Wert selbst ist eine Zeichenfolge von Bytes. Wir können SHA1-Hash-Werte aus Zeichenfolgen generieren, die selbst SHA1-Hash-Werte enthalten. Diese einfache Beobachtung ist überraschend nützlich: suche nach hash chains. Wir werden später sehen, wie Git diese nutzt um effizient die Datenintegrität zu garantieren.

Kurz gesagt, Git hält Deine Daten in dem .git/objects Unterverzeichnis, wo Du anstelle von normalen Dateinamen nur Identitätsnummern findest. Durch die Verwendung von Identitätsnummern als Dateiname, zusammen mit ein paar Sperrdateien und Zeitstempeltricks, macht Git aus einem einfachen Dateisystem eine effiziente und robuste Datenbank.

Intelligenz

Woher weiß Git, dass Du eine Datei umbenannt hast, obwohl Du es ihm niemals explizit mitgeteilt hast? Sicher, Du hast vielleicht git mv benutzt, aber das ist exakt das selbe wie git rm gefolgt von git add.

Git stöbert Umbenennungen und Kopien zwischen aufeinander folgenden Versionen heuristisch auf. Vielmehr kann es sogar Codeblöcke erkennen, die zwischen Dateien hin und her kopiert oder verschoben wurden! Jedoch kann es nicht alle Fälle abdecken, aber es leistet ordentliche Arbeit und diese Eigenschaft wird immer besser. Wenn es bei Dir nicht funktioniert, versuche Optionen zur aufwendigeren Erkennung von Kopien oder erwäge einen Upgrade.

Indizierung

Für jede überwachte Datei speichert Git Informationen wie deren Größe, ihren Erstellzeitpunkt und den Zeitpunkt der letzten Bearbeitung in einer Datei die wir als Index kennen. Um zu ermitteln, ob eine Datei verändert wurde, vergleicht Git den aktuellen Status mit dem im Index gespeicherten. Stimmen diese Daten überein, kann Git das Lesen des Dateiinhalts überspringen.

Da das Abfragen des Dateistatus erheblich schneller ist als das Lesen der Datei, kann Git, wenn Du nur ein paar Dateien verändert hast, seinen Status im Nu aktualisieren.

Wir haben früher festgestellt, dass der Index ein Bereitstellungsraum ist. Warum kann ein Haufen von Dateistatusinformationen ein Bereitstellungsraum sein? Weil die add Anweisung Dateien in die Git Datenbank befördert und die Dateistatusinformationen aktualisiert, während die commit Anweisung, ohne Optionen, einen Commit nur auf Basis der Dateistatusinformationen erzeugt, weil die Dateien ja schon in der Datenbank sind.

Git’s Wurzeln

Dieser Linux Kernel Mailing List Beitrag beschreibt die Kette von Ereignissen, die zu Git geführt haben. Der ganze Beitrag ist eine faszinierende archäologische Seite für Git Historiker.

Die Objektdatenbank

Jegliche Versionen Deiner Daten wird in der Objektdatenbank gehalten, welche im Unterverzeichnis .git/objects liegt; Die anderen Orte in .git/ enthalten weniger wichtige Daten: den Index, Branch Namen, Bezeichner (tags), Konfigurationsoptionen, Logdateien, die Position des aktuellen HEAD Commit und so weiter. Die Objektdatenbank ist einfach aber trotzdem elegant, und sie ist die Quelle von Git’s Macht.

Jede Datei in .git/objects ist ein Objekt. Es gibt drei Arten von Objekten, die uns betreffen: Blob-, Tree- und Commit-Objekte.

Blobs

Zuerst ein Zaubertrick. Suche Dir irgendeinen Dateinamen aus. In einem leeren Verzeichnis:

$ echo sweet > DEIN_DATEINAME
$ git init
$ git add .
$ find .git/objects -type f

Du wirst folgendes sehen: .git/objects/aa/823728ea7d592acc69b36875a482cdf3fd5c8d.

Wie konnte ich das wissen, ohne den Dateiname zu kennen? Weil der SHA1-Hash-Wert von:

"blob" SP "6" NUL "sweet" LF

aa823728ea7d592acc69b36875a482cdf3fd5c8d ist. Wobei SP ein Leerzeichen ist, NUL ist ein Nullbyte und LF ist ein Zeilenumbruch. Das kannst Du durch die Eingabe von

$ printf "blob 6\000sweet\n" | sha1sum

kontrollieren.

Git ist assoziativ: Dateien werden nicht nach Ihren Namen gespeichert, sondern eher nach dem SHA1-Hash-Wert der Daten, welche sie enthalten, in einer Datei, die wir als Blob-Objekt bezeichnen. Wir können uns den SHA1-Hash-Wert als eindeutige Identnummer des Dateiinhalts vorstellen, was sinngemäß bedeutet, dass die Dateien über ihren Inhalt adressiert werden. Das führende blob 6 ist lediglich ein Vermerk, der sich aus dem Objekttyp und seiner Länge in Bytes zusammensetzt; er vereinfacht die interne Verwaltung.

So konnte ich einfach vorhersagen, was Du sehen wirst. Der Dateiname ist irrelevant: nur der Dateiinhalt wird zum Erstellen des Blob-Objekt verwendet.

Du wirst Dich fragen, was mit identischen Dateien ist. Versuche Kopien Deiner Datei hinzuzufügen, mit beliebigen Dateinamen. Der Inhalt von .git/objects bleibt der selbe, ganz egal wieviele Dateien Du hinzufügst. Git speichert den Dateiinhalt nur ein einziges Mal.

Übrigens, die Dateien in .git/objects sind mit zlib komprimiert, Du solltest sie also nicht direkt anschauen. Filtere sie durch zpipe -d, oder gib ein:

$ git cat-file -p aa823728ea7d592acc69b36875a482cdf3fd5c8d

was Dir das Objekt im Klartext anzeigt.

Trees

Aber wo sind die Dateinamen? Sie müssen irgendwo gespeichert sein. Git kommt beim Commit dazu, sich um die Dateinamen zu kümmern:

$ git commit  # Schreibe eine Bemerkung.
$ find .git/objects -type f

Du solltest nun drei Objekte sehen. Dieses mal kann ich Dir nicht sagen, wie die zwei neuen Dateien heißen, weil es zum Teil vom gewählten Dateiname abhängt, den Du ausgesucht hast. Fahren wir fort mit der Annahme, Du hast eine Datei „rose“ genannt. Wenn nicht, kannst Du den Verlauf so umschreiben, dass es so aussieht, als hättest Du es:

$ git filter-branch --tree-filter 'mv DEIN_DATEINAME rose'
$ find .git/objects -type f

Nun müsstest Du die Datei .git/objects/05/b217bb859794d08bb9e4f7f04cbda4b207fbe9 sehen, denn das ist der SHA1-Hash-Wert ihres Inhalts:

"tree" SP "32" NUL "100644 rose" NUL 0xaa823728ea7d592acc69b36875a482cdf3fd5c8d

Prüfe, ob diese Datei tatsächlich dem obigen Inhalt entspricht, durch Eingabe von:

$ echo 05b217bb859794d08bb9e4f7f04cbda4b207fbe9 | git cat-file --batch

Mit zpipe ist es einfach, den SHA1-Hash-Wert zu prüfen:

$ zpipe -d < .git/objects/05/b217bb859794d08bb9e4f7f04cbda4b207fbe9 | sha1sum

Die SHA1-Hash-Wert Prüfung mit cat-file ist etwas kniffliger, da dessen Ausgabe mehr als die rohe unkomprimierte Objektdatei enthält.

Diese Datei ist ein Tree-Objekt: eine Liste von Datensätzen, bestehend aus dem Dateityp, dem Dateinamen und einem SHA1-Hash-Wert. In unserem Beispiel ist der Dateityp 100644, was bedeutet, dass rose eine normale Datei ist, und der SHA1-Hash-Wert entspricht dem Blob-Objekt, welches den Inhalt von rose enthält. Andere mögliche Dateitypen sind ausführbare Programmdateien, symbolische Links oder Verzeichnisse. Im letzten Fall zeigt der SHA1-Hash-Wert auf ein Tree-Objekt.

Wenn Du filter-branch aufrufst, bekommst Du alte Objekte, welche nicht länger benötigt werden. Obwohl sie automatisch über Bord geworfen werden, wenn ihre Gnadenfrist abgelaufen ist, wollen wir sie nun löschen, damit wir unserem Beispiel besser folgen können.

$ rm -r .git/refs/original
$ git reflog expire --expire=now --all
$ git prune

Für reale Projekte solltest Du solche Anweisungen üblicherweise vermeiden, da Du dadurch Datensicherungen zerstörst. Wenn Du ein sauberes Repository willst, ist es am besten, einen neuen Klon anzulegen. Sei auch vorsichtig, wenn Du .git direkt manipulierst: was, wenn zeitgleich ein Git Kommando ausgeführt wird oder plötzlich der Strom ausfällt? Generell sollten Referenzen mit git update-ref -d gelöscht werden, auch wenn es gewöhnlich sicher ist refs/original von Hand zu löschen.

Commits

Wir haben nun zwei von drei Objekten erklärt. Das dritte ist ein Commit-Objekt. Sein Inhalt hängt von der Commit-Beschreibung ab, wie auch vom Zeitpunkt der Erstellung. Damit alles zu unserem Beispiel passt, müssen wir ein wenig tricksen:

$ git commit --amend -m Shakespeare  # Ändere die Bemerkung.
$ git filter-branch --env-filter 'export
    GIT_AUTHOR_DATE="Fri 13 Feb 2009 15:31:30 -0800"
    GIT_AUTHOR_NAME="Alice"
    GIT_AUTHOR_EMAIL="alice@example.com"
    GIT_COMMITTER_DATE="Fri, 13 Feb 2009 15:31:30 -0800"
    GIT_COMMITTER_NAME="Bob"
    GIT_COMMITTER_EMAIL="bob@example.com"'  # Manipuliere Zeitstempel und Autor.
$ find .git/objects -type f

Du solltest nun .git/objects/49/993fe130c4b3bf24857a15d7969c396b7bc187 finden, was dem SHA1-Hash-Wert seines Inhalts entspricht:

"commit 158" NUL
"tree 05b217bb859794d08bb9e4f7f04cbda4b207fbe9" LF
"author Alice <alice@example.com> 1234567890 -0800" LF
"committer Bob <bob@example.com> 1234567890 -0800" LF
LF
"Shakespeare" LF

Wie vorhin kannst Du zpipe oder cat-file benutzen, um es für Dich zu überprüfen.

Das ist der erste Commit gewesen, deshalb gibt es keine Eltern-Commits. Aber spätere Commits werden immer mindestens eine Zeile enthalten, die den Eltern-Commit identifiziert.

Von Magie nicht zu unterscheiden

Git’s Geheimnisse scheinen zu einfach. Es sieht so aus, als müsste man nur ein paar Kommandozeilenskripte zusammenmixen, einen Schuß C-Code hinzufügen und innerhalb ein paar Stunden ist man fertig: eine Mischung von grundlegenden Dateisystemoperationen und SHA1-Hash-Berechnungen, garniert mit Sperrdateien und Synchronisation für Stabilität. Tatsächlich beschreibt dies die früheste Version von Git. Nichtsdestotrotz, abgesehen von geschickten Verpackungstricks, um Speicherplatz zu sparen, und geschickten Indizierungstricks, um Zeit zu sparen, wissen wir nun, wie Git gewandt ein Dateisystem in eine Datenbank verwandelt, das perfekt für eine Versionsverwaltung geeignet ist.

Angenommen, wenn irgendeine Datei in der Objektdatenbank durch einen Laufwerksfehler zerstört wird, dann wird sein SHA1-Hash-Wert nicht mehr mit seinem Inhalt übereinstimmen und uns sagen, wo das Problem liegt. Durch Bilden von SHA1-Hash-Werten aus den SHA1-Hash-Werten anderer Objekte, erreichen wir Integrität auf allen Ebenen. Commits sind elementar, das heißt, ein Commit kann niemals nur Teile einer Änderung speichern: wir können den SHA1-Hash-Wert eines Commits erst dann berechnen und speichern, nachdem wir bereits alle relevanten Tree-Objekte, Blob-Objekte und Eltern-Commits gespeichert haben. Die Objektdatenbank ist immun gegen unerwartete Unterbrechungen wie zum Beispiel einen Stromausfall.

Wir können sogar den hinterhältigsten Gegnern widerstehen. Stell Dir vor, jemand will den Inhalt einer Datei ändern, die in einer älteren Version eines Projekt liegt. Um die Objektdatenbank intakt aussehen zu lassen, müssten sie außerdem den SHA1-Hash-Wert des korrespondierenden Blob-Objekt ändern, da die Datei nun eine geänderte Zeichenfolge enthält. Das heißt auch, dass sie jeden SHA1-Hash-Wert der Tree-Objekte ändern müssen, welche dieses Objekt referenzieren und demzufolge alle SHA1-Hash-Werte der Commit-Objekte, welche diese Tree-Objekte beinhalten, zusätzlich zu allen Abkömmlingen dieses Commits. Das bedeutet auch, dass sich der SHA1-Hash-Wert des offiziellen HEAD von dem des manipulierten Repository unterscheidet. Folgen wir dem Pfad der differierenden SHA1-Hash-Werte, finden wir die verstümmelte Datei, wie auch den Commit, in dem sie erstmals auftauchte.

Kurz gesagt, so lange die 20 Byte, welche den SHA1-Hash-Wert des letzen Commit repräsentieren sicher sind, ist es unmöglich ein Git Repository zu fälschen.

Was ist mit Git’s berühmten Fähigkeiten? Branching? Merging? Tags? Nur Kleinigkeiten. Der aktuelle HEAD wird in der Datei .git/HEAD gehalten, welche den SHA1-Hash-Wert eines Commit-Objekts enthält. Der SHA1-Hash-Wert wird während eines Commit aktualisiert, genauso bei vielen anderen Anweisungen. Branches sind fast das selbe: sie sind Dateien in .git/refs/heads. Tags ebenso: sie stehen in .git/refs/tags aber sie werden durch einen Satz anderer Anweisungen aktualisiert.