Git verstehen: Datenstruktur und Code-Wiederherstellung

Wer die grundlegende Funktionsweise und Datenstruktur von Git kennt, kann verloren geglaubten Code einfach wieder zurückholen.

In der Softwareentwicklung ist Git eigentlich nicht mehr wegzudenken. Mit Hilfe von Git können mehrere Entwicklerinnen an der gleichen Codebasis arbeiten, ohne in Gefahr zu geraten, sich gegenseitig den Code zu überschreiben. Git ist ein Versionskontrollsystem, das ermöglicht, bequem durch die Codehistorie zu surfen. Notfalls kann auch auf einen bestimmten Zeitpunkt zurückgesetzt werden. Und nicht zuletzt kann man mit Hilfe von Branches an verschiedenen Themen gleichzeitig arbeiten.

All diese Features haben Git zu einem unverzichtbaren Tool in der professionellen Softwareentwicklung gemacht. Zugleich ist es aber auch ein komplexes Werkzeug. Doch kennt man erst einmal ein wenig die grundlegende Funktionsweise, wird auch die Handhabung dieses Werkzeuges einfacher.

Während des Wordcamps Stuttgart 2019 habe ich deshalb einen Talk zu dem Thema “Understanding Git. What it going on in .git/?” gehalten. Hier erkläre ich was eigentlich im .git/-Verzeichnis von statten geht, während Dateien unter Versionskontrolle gestellt werden und neue Commits erzeugt werden.

Click on the button to load the content from wordpress.tv.

Load content

Im ersten Teil des Vortrags stelle ich also das grundlegende Datenkonzept vor, mit dem Git seine Versionskontrolle erzeugt. Im zweiten widme ich mich dem gefürchteten git reset --hard und zeige, wie wir – jetzt mit einem besseren Verständnis der zugrunde liegenden Datenstruktur – Code wieder zurückholen können, den wir eigentlich verloren glaubten.

Was passiert eigentlich im .git-Verzeichnis? Die Datenstruktur von Git

Die zwei wesentlichen Kommandos von Git sind git add und git commit. Mit Hilfe dieser beiden Befehle werden die Commits angelegt. Aber wo und in welcher Form werden diese Informationen eigentlich gespeichert?

Jedes Git Repository besitzt ein Verzeichnis mit dem Namen .git/.

"$

Erstellen wir einen ersten Commit

Wie wir sehen, finden sich in diesem Verzeichnis mehrere Dateien und Verzeichnisse. Die von uns erstellten Commits werden in .git/objects/ als sogenannte Objekte hinterlegt.

"$

Wie wir sehen, wurden durch unsere Aktion drei Objekte angelegt. Mit Hilfe von git cat-file können wir diese Objekte öffnen und deren Inhalt einsehen.

"$

Das erste Objekt, das wir öffnen ist das ed28db7. Hierbei handelt es sich um das “Commit”-Objekt, welches mit git commit erstellt wurde. Dieses enthält alle Informationen zu dem Commit: also die Autoren, die Commitnachricht sowie einen Verweis auf das sogenannte “Tree”-Objekt. Sobald wir einen zweiten Commit erstellen, wird dieses Objekt auch die Information enthalten, welcher Commit der vorangegangene Commit (parent) war ‒ womit wir dann eine Historie erhalten. Doch sehen wir uns zunächst das “Tree”-Objekt an:

$ git cat-file -p fc9cf54476c82bd8e4b99276ab4908bea7d77033 100644 blob 01b2d72fa7adf9c144566181bb3a2715edb834b1code.txt
Das “Tree”-Onjekt

Dieses Objekt ist eine Liste. Sie verweist auf die sogenannten “Blob”-Objekte. In diesen sind die Inhalte der Dateien gespeichert, welche wir committed haben. Letztlich gibt uns das “Tree”-Objekt also Auskunft darüber, wo wir die Inhalte von unserer code.txt finden, nämlich in 01b2d72fa7adf9c144566181bb3a2715edb834b1:

$ git cat-file -p 01b2d72fa7adf9c144566181bb3a2715edb834b1 code
Das “Blob”-Objekt

Woher kommen die Dateinamen der Objekte?

Die Dateinamen sind ein Hashwert des Dateiinhalts. Unsere code.txt enthielt den Text “code”. Der Hashwert dieses Strings ist “01b2d72fa7adf9c144566181bb3a2715edb834b1”. Der Hashwert von 100644 blob 01b2d72fa7adf9c144566181bb3a2715edb834b1code.txt dagegen ist “fc9cf54476c82bd8e4b99276ab4908bea7d77033”.

Erstellen wir einen zweiten Commit

Sehen wir uns nun an, was passiert, wenn wir einen zweiten Commit erstellen. Das neue “Commit”-Objekt enthält nun eben auch den Verweis auf den vorangegangenen Commit:

"$

Das neue “Tree”-Objekt enthält nun zwei Einträge. Wir sehen zum einen unsere neue Datei code-2.txt, daneben aber noch immer die Referenz zu unserer code.txt. Das “Tree”-Objekt verweist also auf den kompletten Code zum Zeitpunkt des Commits.

$ git cat-file -p bc1bcbde4b4a281195fb4eb0440108959837c522 100644 blob 223da8e9b94ba2c62267639cb0ac4adc4c122b27code-2.txt 100644 blob 01b2d72fa7adf9c144566181bb3a2715edb834b1 code.txt
Das zweite “Tree”-Objekt

Damit haben wir das zentrale Datenmodell von Git herausgearbeitet:

"Das

Was sind Branches in Git?

Mit git checkout {branch-name} kann man zwischen Branches wechseln und mit git checkout -b {branch-name} kann man neue Branches erstellen. Der nächste Teil meines Vortrags beschäftigte sich damit, wie diese Informationen im .git/-Verzeichnis hinterlegt werden. Branches sind dabei nichts weiter als Textdateien, die auf ein “Commit”-Objekt referenzieren. Diese finden sich unter .git/refs/heads/. Auch Tags sind nichts anderes und finden sich in .git/refs/tags/.

$ find .git/refs/heads .git/refs/heads .git/refs/heads/master $ cat .git/refs/heads/master 9478d31861c6c18bf24305a225d5ea4782aaf21b
Branches

Öffnen wir also die master-Datei, so sehen wir hier einfach eine Referenz auf unseren letzten Commit “9478d31861c6c18bf24305a225d5ea4782aaf21b”. Erstelle ich nun einen neuen Commit in diesem Branch, wird diemaster-Datei entsprechend aktualisiert und, wenn wir einen neuen Branch erstellen, so legen wir nur eine neue Datei in .git/refs/heads mit einem Verweis auf das aktuelle “Commit”-Objekt an:

$ git checkout -b "feature" Switched to a new branch 'feature' $ find .git/refs/heads .git/refs/heads .git/refs/heads/master .git/refs/heads/feature
Das heads-Verzeichnis mit zwei Branches

So bleibt zum Schluss nur noch die Frage, wo wir uns eigentlich aktuell befinden: die Frage nach unserem HEAD. Und auch hierbei handelt es sich einfach um eine Textdatei, welche (normalerweise) auf die Branch-Datei verweist

$ cat .git/HEAD ref: refs/heads/feature $ git checkout master Switched to branch 'master' $ cat .git/HEAD ref: refs/heads/master
Der Git Head

Über diese Referenzierungen erhalten wir also folgende Struktur:

Branches Datenmodel
Branches Datenmodel

Git Anwendungsbeispiel

Mit Hilfe dieses neuen Verständnisses der grundlegenden Datenstruktur von Git können wir nun ein besseres Verständnis dafür entwickeln, was einzelne Git-Befehle eigentlich tatsächlich machen. In meinem Vortrag arbeite ich deshalb am Beispiel der fiktiven Themeschmiede “TipTopThemes” die Funktionsweise von git reset --hard heraus. Gerade, wenn man mit Git erst beginnt, ist dieser Befehl durchaus gefürchtet. Die Angst: Macht man etwas falsch, dann ist der eigene Code plötzlich unwiderruflich weg.

"$

Nehmen wir das obige Beispiel. Wie wir sehen hat git reset --hard tatsächlich die bar.txt aus unserem Arbeitsbereich gelöscht. Wenn wir uns die aktuelle master-Referenz anschauen, sehen wir auch, dass sich diese geändert hat:

$ cat .git/refs/heads/master 473a19246c21402527da4f7a8acce09228638cb7
Die aktuelle master Referenz

Doch interessant ist nun die Ausgabe des folgenden Befehls:

"$

Obwohl wir resettet haben können wir nach wie vor das “Commit”-Objekt einsehen, mit dem wir die bar.txt hinzugefügt hatten. Auch das “Tree”- und das “Blob”-Objekt der bar.txt wurden durch git reset nicht gelöscht! Das heißt unser Code ist nach wie vor in diesem Universum von Objekten vorhanden!

Git reflog is your friend

Wenn man nur irgendwie sinnvoll an den entsprechenden Commit käme! Keiner merkt sich doch diese Hashwerte. Doch; git reflog:

"$

Git erstellt im .git/logs-Verzeichnis Log-Dateien, die nachvollziehen, wie sich der HEAD über den Zeitraum der letzten 90 Tage bewegt hat. Hier sehen wir nun, dass wir zunächst auf dem Commit 473a192, dann auf bdbf6e1 und schließlich wieder auf 473a192 waren.

Da wir nun unseren Commit mit den verloren gegangenen Code kennen, können wir ganz einfach, beispielsweise mit git reset bdbf6e1 --hard den Code wiederherstellen und weiterarbeiten.

Allerdings sollte einem klar sein, dass Git über einen internen Garbage Collector verfügt. Dieser beschneidet die Log-Dateien, damit sie nicht endlos groß werden und löscht auch verwaiste Objekte nach zwei Wochen. Mit git reset 473a192 --hard verwaiste der Commit bdbf6e1. Wenn wir diesen nicht rechtzeitig wieder in irgendeine Branch-Kette bringen oder ihn taggen, so wird dieser Commit, sein Tree und Blobs, die nicht mehr in anderen Trees referenziert werden, gelöscht.

Danke

Am Ende nochmal vielen Dank an die Organisatorinnen und Organisatoren und die Freiwilligen des WordCamps Stuttgart. Es war ein wirklich angenehmes Wochenende mit vielen interessanten Gesprächen und Vorträgen in einer tollen Location.