Kapitel ▾ 2. Auflage

10.2 Git Internals - Git-Objekte

Git-Objekte

Git ist ein inhaltsadressierbares Dateisystem. Großartig. Was bedeutet das? Es bedeutet, dass im Kern von Git ein einfacher Key-Value-Datenspeicher liegt. Das bedeutet, Sie können beliebige Inhalte in ein Git-Repository einfügen, wofür Git Ihnen einen eindeutigen Schlüssel zurückgibt, den Sie später verwenden können, um diese Inhalte abzurufen.

Zur Demonstration betrachten wir den Plumbing-Befehl git hash-object, der Daten entgegennimmt, sie in Ihrem .git/objects-Verzeichnis (der Objektdatenbank) speichert und Ihnen den eindeutigen Schlüssel zurückgibt, der nun auf dieses Datenobjekt verweist.

Zuerst initialisieren Sie ein neues Git-Repository und verifizieren, dass sich (vorhersehbar) nichts im objects-Verzeichnis befindet

$ git init test
Initialized empty Git repository in /tmp/test/.git/
$ cd test
$ find .git/objects
.git/objects
.git/objects/info
.git/objects/pack
$ find .git/objects -type f

Git hat das objects-Verzeichnis initialisiert und darin die Unterverzeichnisse pack und info erstellt, aber es gibt keine regulären Dateien. Nun verwenden wir git hash-object, um ein neues Datenobjekt zu erstellen und es manuell in Ihrer neuen Git-Datenbank zu speichern

$ echo 'test content' | git hash-object -w --stdin
d670460b4b4aece5915caf5c68d12f560a9fe3e4

In seiner einfachsten Form würde git hash-object den von Ihnen übergebenen Inhalt entgegennehmen und lediglich den eindeutigen Schlüssel zurückgeben, der verwendet *würde*, um ihn in Ihrer Git-Datenbank zu speichern. Die Option -w weist den Befehl dann an, nicht nur den Schlüssel zurückzugeben, sondern das Objekt auch in die Datenbank zu schreiben. Schließlich teilt die Option --stdin git hash-object mit, den zu verarbeitenden Inhalt von stdin zu beziehen; andernfalls würde der Befehl am Ende des Befehls ein Dateinamenargument erwarten, das den zu verwendenden Inhalt enthält.

Die Ausgabe des obigen Befehls ist ein 40-stelliger Checksummen-Hash. Dies ist der SHA-1-Hash – eine Prüfsumme des Inhalts, den Sie speichern, plus ein Header, über den Sie in Kürze mehr erfahren werden. Jetzt können Sie sehen, wie Git Ihre Daten gespeichert hat

$ find .git/objects -type f
.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4

Wenn Sie Ihr objects-Verzeichnis erneut untersuchen, sehen Sie, dass es nun eine Datei für diesen neuen Inhalt enthält. So speichert Git den Inhalt initial – als einzelne Datei pro Inhaltseinheit, benannt mit der SHA-1-Prüfsumme des Inhalts und seines Headers. Das Unterverzeichnis wird mit den ersten 2 Zeichen der SHA-1 benannt, und der Dateiname sind die verbleibenden 38 Zeichen.

Sobald Sie Inhalte in Ihrer Objektdatenbank haben, können Sie diese Inhalte mit dem Befehl git cat-file untersuchen. Dieser Befehl ist sozusagen ein Schweizer Taschenmesser zur Inspektion von Git-Objekten. Die Übergabe von -p an cat-file weist den Befehl an, zuerst den Inhaltstyp zu ermitteln und ihn dann entsprechend anzuzeigen

$ git cat-file -p d670460b4b4aece5915caf5c68d12f560a9fe3e4
test content

Jetzt können Sie Inhalte zu Git hinzufügen und sie wieder abrufen. Sie können dies auch mit Inhalten in Dateien tun. Zum Beispiel können Sie eine einfache Versionierung für eine Datei durchführen. Erstellen Sie zuerst eine neue Datei und speichern Sie deren Inhalt in Ihrer Datenbank

$ echo 'version 1' > test.txt
$ git hash-object -w test.txt
83baae61804e65cc73a7201a7252750c76066a30

Schreiben Sie dann neue Inhalte in die Datei und speichern Sie sie erneut

$ echo 'version 2' > test.txt
$ git hash-object -w test.txt
1f7a7a472abf3dd9643fd615f6da379c4acb3e3a

Ihre Objektdatenbank enthält nun beide Versionen dieser neuen Datei (sowie den ersten dort gespeicherten Inhalt)

$ find .git/objects -type f
.git/objects/1f/7a7a472abf3dd9643fd615f6da379c4acb3e3a
.git/objects/83/baae61804e65cc73a7201a7252750c76066a30
.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4

An diesem Punkt können Sie Ihre lokale Kopie der Datei test.txt löschen und dann Git verwenden, um entweder die erste gespeicherte Version aus der Objektdatenbank abzurufen

$ git cat-file -p 83baae61804e65cc73a7201a7252750c76066a30 > test.txt
$ cat test.txt
version 1

oder die zweite Version

$ git cat-file -p 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a > test.txt
$ cat test.txt
version 2

Aber sich den SHA-1-Schlüssel für jede Version Ihrer Datei zu merken, ist nicht praktikabel; außerdem speichern Sie den Dateinamen nicht in Ihrem System – nur den Inhalt. Dieser Objekttyp wird als Blob bezeichnet. Sie können Git den Objekttyp eines beliebigen Objekts in Git mitteilen, indem Sie dessen SHA-1-Schlüssel mit git cat-file -t angeben

$ git cat-file -t 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a
blob

Tree-Objekte

Der nächste Git-Objekttyp, den wir untersuchen werden, ist der Tree (Baum), der das Problem der Speicherung des Dateinamens löst und es Ihnen ermöglicht, eine Gruppe von Dateien zusammen zu speichern. Git speichert Inhalte ähnlich wie ein UNIX-Dateisystem, aber etwas vereinfacht. Alle Inhalte werden als Tree- und Blob-Objekte gespeichert, wobei Trees UNIX-Verzeichniseinträgen entsprechen und Blobs mehr oder weniger Inodes oder Dateiinhalten. Ein einzelnes Tree-Objekt enthält einen oder mehrere Einträge, von denen jeder der SHA-1-Hash eines Blobs oder Subtrees mit seinem zugehörigen Modus, Typ und Dateinamen ist. Nehmen wir beispielsweise an, Sie haben ein Projekt, bei dem der aktuellste Tree wie folgt aussieht

$ git cat-file -p master^{tree}
100644 blob a906cb2a4a904a152e80877d4088654daad0c859      README
100644 blob 8f94139338f9404f26296befa88755fc2598c289      Rakefile
040000 tree 99f1a6d12cb4b6f19c8655fca46c3ecf317074e0      lib

Die Syntax master^{tree} gibt das Tree-Objekt an, auf das der letzte Commit Ihres master-Branches zeigt. Beachten Sie, dass das Unterverzeichnis lib kein Blob ist, sondern ein Zeiger auf einen anderen Tree

$ git cat-file -p 99f1a6d12cb4b6f19c8655fca46c3ecf317074e0
100644 blob 47c6340d6459e05787f644c2447d2595f5d3a54b      simplegit.rb
Hinweis

Abhängig von Ihrer Shell können Fehler auftreten, wenn Sie die Syntax master^{tree} verwenden.

In CMD unter Windows wird das Zeichen ^ zum Escapen verwendet, daher müssen Sie es verdoppeln, um dies zu vermeiden: git cat-file -p master^^{tree}. Bei der Verwendung von PowerShell müssen Parameter, die {} verwenden, in Anführungszeichen gesetzt werden, um zu verhindern, dass der Parameter falsch interpretiert wird: git cat-file -p 'master^{tree}'.

Wenn Sie ZSH verwenden, wird das Zeichen ^ für Globbing verwendet, daher müssen Sie den gesamten Ausdruck in Anführungszeichen setzen: git cat-file -p "master^{tree}".

Konzeptionell sehen die von Git gespeicherten Daten wie folgt aus

Simple version of the Git data model
Abbildung 173. Einfache Version des Git-Datenmodells

Sie können Ihre eigenen Trees relativ einfach erstellen. Git erstellt normalerweise einen Tree, indem es den Zustand Ihrer Staging-Area oder Ihres Index nimmt und daraus eine Reihe von Tree-Objekten schreibt. Um also ein Tree-Objekt zu erstellen, müssen Sie zuerst einen Index einrichten, indem Sie einige Dateien staged. Um einen Index mit einem einzigen Eintrag zu erstellen – der ersten Version Ihrer Datei test.txt –, können Sie den Plumbing-Befehl git update-index verwenden. Sie verwenden diesen Befehl, um die frühere Version der Datei test.txt künstlich zu einer neuen Staging-Area hinzuzufügen. Sie müssen die Option --add übergeben, da die Datei noch nicht in Ihrer Staging-Area vorhanden ist (Sie haben noch keine Staging-Area eingerichtet) und --cacheinfo, da sich die hinzuzufügende Datei nicht in Ihrem Verzeichnis, sondern in Ihrer Datenbank befindet. Dann geben Sie den Modus, die SHA-1 und den Dateinamen an

$ git update-index --add --cacheinfo 100644 \
  83baae61804e65cc73a7201a7252750c76066a30 test.txt

In diesem Fall geben Sie einen Modus von 100644 an, was bedeutet, dass es sich um eine normale Datei handelt. Andere Optionen sind 100755, was bedeutet, dass es sich um eine ausführbare Datei handelt; und 120000, was einen symbolischen Link angibt. Der Modus stammt von normalen UNIX-Modi, ist aber viel weniger flexibel – diese drei Modi sind die einzigen, die für Dateien (Blobs) in Git gültig sind (obwohl andere Modi für Verzeichnisse und Submodule verwendet werden).

Nun können Sie git write-tree verwenden, um die Staging-Area in ein Tree-Objekt zu schreiben. Es ist keine Option -w erforderlich – das Aufrufen dieses Befehls erstellt automatisch ein Tree-Objekt aus dem Zustand des Index, falls dieser Tree noch nicht existiert

$ git write-tree
d8329fc1cc938780ffdd9f94e0d364e0ea74f579
$ git cat-file -p d8329fc1cc938780ffdd9f94e0d364e0ea74f579
100644 blob 83baae61804e65cc73a7201a7252750c76066a30      test.txt

Sie können auch mit demselben git cat-file-Befehl, den Sie zuvor gesehen haben, verifizieren, dass es sich um ein Tree-Objekt handelt

$ git cat-file -t d8329fc1cc938780ffdd9f94e0d364e0ea74f579
tree

Sie werden nun einen neuen Tree mit der zweiten Version von test.txt und einer neuen Datei erstellen

$ echo 'new file' > new.txt
$ git update-index --cacheinfo 100644 \
  1f7a7a472abf3dd9643fd615f6da379c4acb3e3a test.txt
$ git update-index --add new.txt

Ihre Staging-Area enthält nun die neue Version von test.txt sowie die neue Datei new.txt. Schreiben Sie diesen Tree aus (der den Zustand der Staging-Area oder des Index in einem Tree-Objekt aufzeichnet) und sehen Sie sich an, wie er aussieht

$ git write-tree
0155eb4229851634a0f03eb265b69f5a2d56f341
$ git cat-file -p 0155eb4229851634a0f03eb265b69f5a2d56f341
100644 blob fa49b077972391ad58037050f2a75f74e3671e92      new.txt
100644 blob 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a      test.txt

Beachten Sie, dass dieser Tree sowohl Dateieinträge hat als auch dass die SHA-1 für test.txt die "Version 2" SHA-1 von früher ist (1f7a7a). Nur zum Spaß werden Sie den ersten Tree als Unterverzeichnis in diesen einfügen. Sie können Trees mit git read-tree in Ihre Staging-Area lesen. In diesem Fall können Sie einen vorhandenen Tree als Subtree in Ihre Staging-Area lesen, indem Sie die Option --prefix mit diesem Befehl verwenden

$ git read-tree --prefix=bak d8329fc1cc938780ffdd9f94e0d364e0ea74f579
$ git write-tree
3c4e9cd789d88d8d89c1073707c3585e41b0e614
$ git cat-file -p 3c4e9cd789d88d8d89c1073707c3585e41b0e614
040000 tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579      bak
100644 blob fa49b077972391ad58037050f2a75f74e3671e92      new.txt
100644 blob 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a      test.txt

Wenn Sie ein Arbeitsverzeichnis aus dem gerade geschriebenen neuen Tree erstellen würden, erhielten Sie die beiden Dateien im obersten Level des Arbeitsverzeichnisses und ein Unterverzeichnis namens bak, das die erste Version der Datei test.txt enthielt. Sie können sich die von Git für diese Strukturen enthaltenen Daten wie folgt vorstellen

The content structure of your current Git data
Abbildung 174. Die Inhaltsstruktur Ihrer aktuellen Git-Daten

Commit-Objekte

Wenn Sie alles bisherige durchgeführt haben, verfügen Sie nun über drei Trees, die die verschiedenen Snapshots Ihres Projekts darstellen, das Sie verfolgen möchten, aber das frühere Problem bleibt bestehen: Sie müssen sich alle drei SHA-1-Werte merken, um die Snapshots abrufen zu können. Außerdem haben Sie keine Informationen darüber, wer die Snapshots gespeichert hat, wann sie gespeichert wurden oder warum sie gespeichert wurden. Dies sind die grundlegenden Informationen, die das Commit-Objekt für Sie speichert.

Um ein Commit-Objekt zu erstellen, rufen Sie commit-tree auf und geben einen einzelnen Tree-SHA-1 und die Commit-Objekte an, die ihm direkt vorausgingen, falls vorhanden. Beginnen Sie mit dem ersten Tree, den Sie geschrieben haben

$ echo 'First commit' | git commit-tree d8329f
fdf4fc3344e67ab068f836878b6c4951e3b15f3d
Hinweis

Sie erhalten einen anderen Hash-Wert aufgrund unterschiedlicher Erstellungszeiten und Autoren. Darüber hinaus, während im Prinzip jedes Commit-Objekt angesichts dieser Daten präzise reproduziert werden kann, könnten historische Details der Erstellung dieses Buches dazu führen, dass die gedruckten Commit-Hashes nicht den gegebenen Commits entsprechen. Ersetzen Sie die Commit- und Tag-Hashes in diesem Kapitel durch Ihre eigenen Prüfsummen.

Nun können Sie Ihr neues Commit-Objekt mit git cat-file betrachten

$ git cat-file -p fdf4fc3
tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579
author Scott Chacon <schacon@gmail.com> 1243040974 -0700
committer Scott Chacon <schacon@gmail.com> 1243040974 -0700

First commit

Das Format eines Commit-Objekts ist einfach: Es gibt den Top-Level-Tree für den Snapshot des Projekts zu diesem Zeitpunkt an; die übergeordneten Commits, falls vorhanden (das oben beschriebene Commit-Objekt hat keine Eltern); die Autor-/Commiter-Informationen (die Ihre user.name- und user.email-Konfigurationseinstellungen und einen Zeitstempel verwenden); eine Leerzeile und dann die Commit-Nachricht.

Als Nächstes schreiben Sie die anderen beiden Commit-Objekte, die jeweils auf den direkt vorangegangenen Commit verweisen

$ echo 'Second commit' | git commit-tree 0155eb -p fdf4fc3
cac0cab538b970a37ea1e769cbbde608743bc96d
$ echo 'Third commit'  | git commit-tree 3c4e9c -p cac0cab
1a410efbd13591db07496601ebc7a059dd55cfe9

Jedes der drei Commit-Objekte verweist auf einen der drei erstellten Snapshot-Trees. Seltsamerweise haben Sie jetzt eine echte Git-Historie, die Sie mit dem Befehl git log anzeigen können, wenn Sie ihn auf den letzten Commit-SHA-1 anwenden

$ git log --stat 1a410e
commit 1a410efbd13591db07496601ebc7a059dd55cfe9
Author: Scott Chacon <schacon@gmail.com>
Date:   Fri May 22 18:15:24 2009 -0700

	Third commit

 bak/test.txt | 1 +
 1 file changed, 1 insertion(+)

commit cac0cab538b970a37ea1e769cbbde608743bc96d
Author: Scott Chacon <schacon@gmail.com>
Date:   Fri May 22 18:14:29 2009 -0700

	Second commit

 new.txt  | 1 +
 test.txt | 2 +-
 2 files changed, 2 insertions(+), 1 deletion(-)

commit fdf4fc3344e67ab068f836878b6c4951e3b15f3d
Author: Scott Chacon <schacon@gmail.com>
Date:   Fri May 22 18:09:34 2009 -0700

    First commit

 test.txt | 1 +
 1 file changed, 1 insertion(+)

Erstaunlich. Sie haben gerade die Low-Level-Operationen ausgeführt, um eine Git-Historie aufzubauen, ohne die Front-End-Befehle zu verwenden. Dies ist im Wesentlichen das, was Git tut, wenn Sie die Befehle git add und git commit ausführen – es speichert Blobs für die geänderten Dateien, aktualisiert den Index, schreibt Trees aus und schreibt Commit-Objekte, die auf die Top-Level-Trees und die unmittelbar vorangegangenen Commits verweisen. Diese drei Haupt-Git-Objekte – der Blob, der Tree und der Commit – werden zunächst als separate Dateien in Ihrem .git/objects-Verzeichnis gespeichert. Hier sind alle Objekte im Beispielverzeichnis jetzt, mit Kommentaren, was sie speichern

$ find .git/objects -type f
.git/objects/01/55eb4229851634a0f03eb265b69f5a2d56f341 # tree 2
.git/objects/1a/410efbd13591db07496601ebc7a059dd55cfe9 # commit 3
.git/objects/1f/7a7a472abf3dd9643fd615f6da379c4acb3e3a # test.txt v2
.git/objects/3c/4e9cd789d88d8d89c1073707c3585e41b0e614 # tree 3
.git/objects/83/baae61804e65cc73a7201a7252750c76066a30 # test.txt v1
.git/objects/ca/c0cab538b970a37ea1e769cbbde608743bc96d # commit 2
.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4 # 'test content'
.git/objects/d8/329fc1cc938780ffdd9f94e0d364e0ea74f579 # tree 1
.git/objects/fa/49b077972391ad58037050f2a75f74e3671e92 # new.txt
.git/objects/fd/f4fc3344e67ab068f836878b6c4951e3b15f3d # commit 1

Wenn Sie allen internen Zeigern folgen, erhalten Sie einen Objektgraphen, der etwa so aussieht

All the reachable objects in your Git directory
Abbildung 175. Alle erreichbaren Objekte in Ihrem Git-Verzeichnis

Objektspeicherung

Wir haben bereits erwähnt, dass mit jedem Objekt, das Sie in Ihre Git-Objektdatenbank committen, ein Header gespeichert wird. Nehmen wir uns eine Minute Zeit, um zu sehen, wie Git seine Objekte speichert. Sie werden sehen, wie Sie interaktiv in der Ruby-Skriptsprache ein Blob-Objekt speichern – in diesem Fall den String "what is up, doc?".

Sie können den interaktiven Ruby-Modus mit dem Befehl irb starten

$ irb
>> content = "what is up, doc?"
=> "what is up, doc?"

Git erstellt zuerst einen Header, der damit beginnt, den Objekttyp zu identifizieren – in diesem Fall ein Blob. Zu diesem ersten Teil des Headers fügt Git ein Leerzeichen, gefolgt von der Größe des Inhalts in Bytes, und ein abschließendes Nullbyte hinzu

>> header = "blob #{content.bytesize}\0"
=> "blob 16\u0000"

Git verkettet den Header und den ursprünglichen Inhalt und berechnet dann die SHA-1-Prüfsumme dieses neuen Inhalts. Sie können den SHA-1-Wert eines Strings in Ruby berechnen, indem Sie die SHA1-Digest-Bibliothek mit dem Befehl require einschließen und dann Digest::SHA1.hexdigest() mit dem String aufrufen

>> store = header + content
=> "blob 16\u0000what is up, doc?"
>> require 'digest/sha1'
=> true
>> sha1 = Digest::SHA1.hexdigest(store)
=> "bd9dbf5aae1a3862dd1526723246b20206e5fc37"

Vergleichen wir das mit der Ausgabe von git hash-object. Hier verwenden wir echo -n, um das Hinzufügen eines Zeilenumbruchs zur Eingabe zu verhindern.

$ echo -n "what is up, doc?" | git hash-object --stdin
bd9dbf5aae1a3862dd1526723246b20206e5fc37

Git komprimiert den neuen Inhalt mit zlib, was Sie in Ruby mit der zlib-Bibliothek tun können. Zuerst müssen Sie die Bibliothek anfordern und dann Zlib::Deflate.deflate() auf den Inhalt anwenden

>> require 'zlib'
=> true
>> zlib_content = Zlib::Deflate.deflate(store)
=> "x\x9CK\xCA\xC9OR04c(\xCFH,Q\xC8,V(-\xD0QH\xC9O\xB6\a\x00_\x1C\a\x9D"

Schließlich schreiben Sie Ihren zlib-deflatierten Inhalt in ein Objekt auf der Festplatte. Sie ermitteln den Pfad des zu schreibenden Objekts (die ersten beiden Zeichen des SHA-1-Wertes als Unterverzeichnisname und die letzten 38 Zeichen als Dateiname innerhalb dieses Verzeichnisses). In Ruby können Sie die Funktion FileUtils.mkdir_p() verwenden, um das Unterverzeichnis zu erstellen, falls es noch nicht existiert. Öffnen Sie dann die Datei mit File.open() und schreiben Sie den zuvor zlib-komprimierten Inhalt mit einem write()-Aufruf auf dem resultierenden Dateihandle in die Datei

>> path = '.git/objects/' + sha1[0,2] + '/' + sha1[2,38]
=> ".git/objects/bd/9dbf5aae1a3862dd1526723246b20206e5fc37"
>> require 'fileutils'
=> true
>> FileUtils.mkdir_p(File.dirname(path))
=> ".git/objects/bd"
>> File.open(path, 'w') { |f| f.write zlib_content }
=> 32

Überprüfen wir den Inhalt des Objekts mit git cat-file

---
$ git cat-file -p bd9dbf5aae1a3862dd1526723246b20206e5fc37
what is up, doc?
---

Das war's – Sie haben ein gültiges Git-Blob-Objekt erstellt.

Alle Git-Objekte werden auf die gleiche Weise gespeichert, nur mit unterschiedlichen Typen – anstelle des Strings blob beginnt der Header mit commit oder tree. Auch wenn der Blob-Inhalt fast alles sein kann, sind die Commit- und Tree-Inhalte sehr spezifisch formatiert.