Git 🌙
Chapters ▾ 2nd Edition

7.7 Git Tools - Reset entzaubert

Reset entzaubert

Bevor wir zu spezialisierteren Werkzeugen übergehen, sollten wir über die Befehle reset und checkout sprechen. Diese Befehle sind, wenn man ihnen zum ersten Mal begegnet, die beiden verwirrendsten Teile von Git. Sie erledigen so viele Aufgaben, dass es aussichtslos erscheint, sie wirklich zu verstehen und richtig anzuwenden. Deshalb empfehlen wir eine einfache Metapher.

Die drei Bäume

Eine bessere Methode, um über reset und checkout zu reflektieren, ist der gedankliche Ansatz, dass Git ein Inhaltsmanager von drei verschiedenen Bäumen ist. Mit „Baum“ meinen wir hier in Wahrheit eine „Sammlung von Dateien“, nicht speziell die Datenstruktur. Es gibt ein paar Fälle, in denen sich der Inhalt nicht genau wie ein Baum verhält, aber für unsere Zwecke ist es vorerst einfacher, auf diese Weise darüber nachzudenken.

Als System verwaltet Git im regulären Modus drei Bäume:

Baum Rolle

HEAD

letzter Commit-Snapshot, nächstes Elternteil

Index (Staging-Area)

nächster, geplanter Commit-Snapshot

Arbeitsverzeichnis

Sandbox

Der HEAD

HEAD ist der Verweis auf die aktuelle Branch-Referenz, die wiederum ein Pointer zu dem letzten Commit auf dem aktuellen Branch ist. Das bedeutet, dass HEAD das Elternteil des nächsten Commits ist, der erzeugt wird. Es ist generell am einfachsten, sich HEAD als den Schnappschuss deines letzten Commits auf dem aktuellen Branch vorzustellen.

Es ist ziemlich einfach zu erkennen, wie dieser Schnappschuss aussieht. Hier ist ein Beispiel, wie man die aktuelle Verzeichnisliste und die SHA-1-Prüfsummen für jede Datei im HEAD-Snapshot erhält:

$ git cat-file -p HEAD
tree cfda3bf379e4f8dba8717dee55aab78aef7f4daf
author Scott Chacon  1301511835 -0700
committer Scott Chacon  1301511835 -0700

initial commit

$ git ls-tree -r HEAD
100644 blob a906cb2a4a904a152...   README
100644 blob 8f94139338f9404f2...   Rakefile
040000 tree 99f1a6d12cb4b6f19...   lib

Die Git-Befehle cat-file und ls-tree sind „Basisbefehle“, die für Aufgaben auf low-level Ebene verwendet werden und nicht wirklich in der täglichen Arbeit eingesetzt werden. Es hilft jedoch sie zu verstehen, was sie genau tun.

Der Index

Index ist dein nächster, geplanter Commit. Wir haben diesen Konzept auch als Git’s „Staging-Area“ bezeichnet, da Git darauf schaut, wenn du git commit ausführst.

Git füllt den Index mit allen Dateiinhalten, die du zuletzt in dein Arbeitsverzeichnis ausgecheckt hast und zeigt dir, wie sie beim letzten Auschecken ausgesehen haben. Du tauschst dann einige dieser Dateien mit neueren Versionen aus, und git commit konvertiert diese in den Baum für einen neuen Commit.

$ git ls-files -s
100644 a906cb2a4a904a152e80877d4088654daad0c859 0	README
100644 8f94139338f9404f26296befa88755fc2598c289 0	Rakefile
100644 47c6340d6459e05787f644c2447d2595f5d3a54b 0	lib/simplegit.rb

Nochmals, wir verwenden hier git ls-files, ein Kommando, das eher ein Basisbefehl ist, welcher dir anzeigt, wie dein Index derzeit aussieht.

Der Index ist technisch gesehen keine hierarchische Struktur. Er ist eigentlich als flaches Manifest umgesetzt, aber für unsere Zwecke ist das ausreichend genau.

Das Working Directory oder Arbeitsverzeichnis

Abschließend gibt es dein Arbeitsverzeichnis (engl. „working directory“ oder „working tree“). Die beiden anderen Bäume speichern deinen Inhalt auf effiziente, aber unpraktische Weise innerhalb des .git Ordners. Das Arbeitsverzeichnis entpackt sie in echte Dateien, was es wesentlich einfacher macht, sie zu bearbeiten. Stelle dir das Arbeitsverzeichnis wie einen Sandkasten (engl. sandbox) vor, in der du Änderungen ausprobieren kannst, bevor du sie in deiner Staging-Area und dann in den Verlauf überträgst.

$ tree
.
├── README
├── Rakefile
└── lib
    └── simplegit.rb

1 directory, 3 files

Der Workflow

Der typische Arbeitsablauf von Git sieht vor, dass du durch die Bearbeitung dieser drei Bäume nach und nach bessere Momentaufnahmen deines Projekts erzeugst.

reset workflow
Abbildung 137. Git’s standard workflow

Stellen wir uns folgenden Ablauf vor: Angenommen, du wechselst in ein neues Verzeichnis, in dem sich eine einzige Datei befindet. Wir nennen das die v1 der Datei und kennzeichnen sie in blau. Nun führen wir git init aus, das ein Git-Repository mit einer HEAD-Referenz erzeugt, die auf den noch nicht existierenden master Branch zeigt.

reset ex1
Abbildung 138. Neu-Instaziertes Git Repository mit unstaged Datei im Arbeitsverzeichnis

Zu diesem Zeitpunkt hat nur der Verzeichnisbaum (engl working tree) des Arbeitsverzeichnisses (engl. working directory) irgendeinen Inhalt.

Nun wollen wir diese Datei committen, also benutzen wir git add, um den Inhalt im Arbeitsverzeichnis zu übernehmen und in den Index zu kopieren.

reset ex2
Abbildung 139. Datei wird bei git add auf den Index kopiert

Dann führen wir git commit aus, das den Inhalt der Staging-Area (oder Index) als endgültigen Snapshot speichert, ein Commit-Objekt erzeugt, das auf diesen Snapshot zeigt, und den Branch master aktualisiert, um auf diesen Commit zu zeigen.

reset ex3
Abbildung 140. Der git commit Schritt

Wenn wir jetzt git status ausführen, werden wir keine Änderungen sehen, weil alle drei Bäume gleich sind.

Nun wollen wir eine Änderung an dieser Datei vornehmen und sie übertragen. Wir führen den gleichen Vorgang durch. Zuerst ändern wir die Datei in unserem Arbeitsverzeichnis. Wir nennen sie v2 dieser Datei und markieren sie in rot.

reset ex4
Abbildung 141. Git Repository mit geänderten Dateien im Arbeitsverzeichnis

Wenn wir jetzt den Befehl git status aufrufen, sehen wir die Datei in rot als „Changes not staged for commit“ (dt. Änderungen nicht zum Commit vorgemerkt), weil sich dieser Eintrag im Index zu dem im Arbeitsverzeichnis unterscheidet. Als nächstes führen wir git add aus, um sie in unseren Index zu übernehmen, d.h zur Staging-Area hinzuzufügen.

reset ex5
Abbildung 142. Staging Änderungen am Index

Wenn wir zu diesem Zeitpunkt git status ausführen, sehen wir die Datei in grün unter „Changes to be committed“ (dt. Änderungen zum Commit vorgemerkt), weil sich der Index und der HEAD unterscheiden – d.h. unser geplanter nächster Commit unterscheidet sich nun von unserem letzten Commit. Schließlich führen wir git commit aus, um die Daten zu übertragen.

reset ex6
Abbildung 143. Der git commit Schritt mit geänderter Datei

Nun wird uns git status keine Ergebnisse liefern, weil alle drei Bäume wieder gleich sind.

Das Wechseln von Branches oder das Klonen geht ähnlich vor sich. Wenn du einen Branch auscheckst, ändert er HEAD so, dass er auf den neuen Branch-Ref zeigt, füllt deine Staging-Area (bzw. Index) mit dem aktuellen Schnappschuss dieses Commits und kopiert dann den Inhalt des Index in dein Arbeitsverzeichnis.

Die Bedeutung von Reset

Der Befehl reset macht mehr Sinn, wenn wir folgenden Fall betrachten.

Für diesen Zweck nehmen wir an, dass wir file.txt erneut modifiziert und ein drittes Mal committet hätten. Nun sieht unser Verlauf so aus:

reset start
Abbildung 144. Git Repository mit drei Commits

Lass uns nun genau untersuchen, was reset bewirkt, wenn du es aufrufst. Es manipuliert die drei Bäume auf einfache und kalkulierbare Weise direkt. Es führt bis zu drei einfache Operationen aus.

Step 1: Den HEAD verschieben

Als erstes wird reset das verschieben, worauf HEAD zeigt. Das ist nicht dasselbe wie HEAD selbst zu ändern (was checkout macht). reset verschiebt den Branch, auf den HEAD zeigt. Das bedeutet, wenn HEAD auf den Branch master gesetzt ist (d.h. du befindest dich gerade auf dem master Branch), wird die Ausführung von git reset 9e5e6a4 damit starten, dass master auf 9e5e6a4 zeigt.

reset soft
Abbildung 145. Soft Reset

Egal, mit welcher Methode du reset bei einem Commit aufrufst. Dies ist immer die erste Aktion, die versucht wird auszuführen. Mit reset --soft wird es dort einfach stoppen.

Nimm dir nun eine Minute Zeit, um dir diese Abbildung anzusehen und dich zu fragen, was da passiert ist. Es hat im Wesentlichen den letzten git commit Befehl rückgängig gemacht. Wenn du git commit ausführst, erzeugt Git einen neuen Commit und verschiebt den Branch, auf den HEAD zeigt, dorthin. Wenn du auf HEAD~ (das Elternteil von HEAD) zurücksetzt, verschiebst du den Branch wieder an seine ursprüngliche Stelle, ohne den Index oder das Arbeitsverzeichnis zu ändern. Du kannst nun den Index aktualisieren und git commit erneut ausführen, um das zu erreichen, was git commit --amend getan hätte (siehe auch Den letzten Commit ändern).

Step 2: Den Index aktualisieren (--mixed)

Bitte berücksichtige, dass du bei Ausführung von git status in grün den Unterschied zwischen dem Index und dem neuen HEAD sehen wirst.

Als nächstes wird reset den Index mit dem Inhalt des Schnappschusses aktualisieren, auf den HEAD jetzt zeigt.

reset mixed
Abbildung 146. Mixed Reset

Wenn du die Option --mixed angibst, wird reset an dieser Stelle beendet. Das ist auch die Voreinstellung, wenn du überhaupt keine Option angibst (in diesem Fall nur git reset HEAD~), wird der Befehl dort enden.

Nir noch noch eine Minute Zeit, um dir jetzt diese Abbildung anzuschauen und zu erkennen, was passiert ist: Es hat deinen letzten commit rückgängig gemacht, aber auch alles auf unstaged gesetzt. Du wurdest auf den Stand zurück versetzt, bevor du alle deine git add und git commit Befehle ausgeführt hast.

Step 3: Das Working Directory (Arbeitsverzeichnis) aktualisieren (--hard)

Als Drittes wird das Arbeitsverzeichnis durch reset zurückgesetzt, damit es dem Index entspricht. Wenn du die Option --hard verwendest, wird es bis zu diesem Schritt fortgesetzt.

reset hard
Abbildung 147. Hard Reset

Denken wir also darüber nach, was gerade passiert ist. Du hast deinen letzten Commit rückgängig gemacht, die Befehle git add und git commit und dazu noch die gesamte Arbeit, die di in deinem Arbeitsverzeichnis geleistet hast.

Es ist sehr wichtig zu wissen, dass das Flag (--hard) die einzige Möglichkeit ist, den Befehl reset gefährlich zu machen und einer der wenigen Fälle, in denen Git tatsächlich Daten vernichtet. Jeder andere Aufruf von reset kann ziemlich leicht rückgängig gemacht werden, aber nicht die Option --hard, da sie Dateien im Arbeitsverzeichnis zwingend überschreibt. In diesem speziellen Fall haben wir noch immer die v3 Version unserer Datei in einem Commit in unserer Git-Datenbank. Wir könnten sie durch einen Blick auf unser reflog zurückholen. Hätten wir sie aber nicht committet, dann hätte Git die Datei überschrieben und sie wäre nicht wiederherstellbar.

Zusammenfassung

Der Befehl reset überschreibt diese drei Bäume in einer bestimmten Reihenfolge und stoppt, wann du es willst:

  1. Verschiebe den Branch-HEAD und (stoppt hier, wenn --soft).

  2. Lasse den Index wie HEAD erscheinen (hier stoppen, wenn nicht --hard).

  3. Lasse das Arbeitsverzeichnis wie den Index erscheinen.

Zurücksetzen (reset) mit Pfadangabe

Das deckt das Verhalten von reset in seiner Basisform ab, aber du kannst ihm auch einen Pfad angeben, auf dem er aktiv werden soll. Wenn du einen Pfad festlegst, überspringt reset Step 1 und beschränkt die restlichen Aktionen auf eine bestimmte Datei oder eine Gruppe von Dateien. Das macht tatsächlich Sinn. HEAD ist nur ein Pointer. Du kannst nicht auf den einen Teil eines Commits und auf einen Teil eines anderen zeigen. Der Index und das Arbeitsverzeichnis können jedoch teilweise aktualisiert werden, so dass das Zurücksetzen mit den Schritten 2 und 3 fortgesetzt wird.

Nehmen wir also an, wir führen ein git reset file.txt aus. Da du hier keinen Commit-SHA-1 oder -Branch angegeben hast und auch nicht die Optionen --soft oder --hard verwendet hast, ist das die Kurzform für git reset --mixed HEAD file.txt. Der Befehl wird Folgendes bewirken:

  1. Verschiebt den Branch, HEAD zeigt auf (übersprungen).

  2. Passt den Index an HEAD an (stopt hier).

Er kopiert also im Endeffekt nur file.txt von HEAD in den Index.

reset path1
Abbildung 148. Mixed Reset mit einem Pfad

Das hat den praktischen Effekt, dass die Datei aus der Staging-Area entfernt wird (engl. unstage). Wenn wir uns die Abbildung für diesen Befehl ansehen und überlegen, was git add macht, sind die beiden Befehle genau gegensätzlich.

reset path2
Abbildung 149. Datei wird auf den Index gestaged

Deshalb schlägt die Anzeige des Befehls git status vor, dass du den Befehl git reset ausführst, um eine Datei aus der Staging-Area zu entfernen. Siehe auch Kapitel 2 Eine Datei aus der Staging-Area entfernen für weitere Informationen.

Wir könnten ebenso einfach, Git nicht annehmen lassen, dass wir damit meinen, es soll „die Daten aus dem HEAD pullen“, indem wir einen bestimmten Commit angeben, aus dem diese Dateiversion gezogen werden soll. Stattdessen würden wir einfach etwas wie git reset eb43bf file.txt ausführen.

reset path3
Abbildung 150. Soft Reset mit Pfad auf einen spezifischen Commit

Das macht effektiv dasselbe, als ob wir den Inhalt der Datei im Arbeitsverzeichnis auf v1 geändert, git add darauf ausgeführt und dann wieder auf v3 zurückgewandelt hätten (ohne wirklich alle diese Schritte zu durchlaufen). Wenn wir jetzt git commit aufrufen, wird er eine Modifikation registrieren, die diese Datei wieder auf v1 zurücksetzt, obwohl wir sie nie wieder in unserem Arbeitsverzeichnis hatten.

Interessant ist auch, dass der reset Befehl wie auch git add die Option --patch akzeptiert, um Inhalte schrittweise zu entfernen. Du kannst also selektiv Inhalte aufheben oder zurücksetzen.

Squashing (Zusammenfassen)

Schauen wir uns an, was wir mit dieser neu entdeckten Möglichkeit machen können – das Zusammenfassen von Commits.

Angenommen, du hast eine Reihe von Commits mit Nachrichten wie „Ups“, „WIP“ und „Diese Datei vergessen“. Du kannst reset verwenden, um diese schnell und einfach in einem einzigen Commit zusammenzufassen, der dich wirklich clever aussehen lässt. Commits zusammenfassen zeigt dir eine andere Möglichkeit auf, aber in diesem Fall ist es einfacher reset zu verwenden.

Stellen wir uns vor, du hast ein Projekt, bei dem der erste Commit eine Datei enthält, der zweite Commit eine neue Datei hinzufügt und die erste ändert, und der dritte Commit die erste Datei erneut ändert. Der zweite Commit war eine unfertige Arbeit und du willst diese zusammenfassen.

reset squash r1
Abbildung 151. Git Repository

Du kannst git reset --soft HEAD~2 ausführen, um den HEAD-Branch zurück zu einem älteren Commit (dem neuesten Commit, den du behalten willst) zu verschieben:

reset squash r2
Abbildung 152. HEAD Verschiebung mit Soft Reset

Danach einfach erneut git commit ausführen:

reset squash r3
Abbildung 153. Git Repository mit Squashed Commit

Jetzt kannst du sehen, dass dein gewünschter Verlauf, der Verlauf, den du pushen möchtest, jetzt so aussieht, als hättest du einen Commit mit file-a.txt v1 gemacht. Anschließend hättest du einen zweiten gemacht, der sowohl file-a.txt zu v3 modifiziert als auch file-b.txt hinzugefügt hat. Der Commit mit der Version v2 der Datei ist nicht mehr im Verlauf enthalten.

Auschecken (checkout)

Zum Schluss wirst du dich vielleicht fragen, was der Unterschied zwischen checkout und reset ist. Wie reset manipuliert checkout die drei Bäume. Es ist ein bisschen unterschiedlich, je nachdem, ob du dem Befehl einen Dateipfad mitgibst oder nicht.

Ohne Pfadangabe

Das Benutzen von git checkout [branch] ist dem Ausführen von git reset --hard [branch] ziemlich ähnlich, da es alle drei Bäume aktualisiert, damit sie wie [branch] aussehen, aber es gibt zwei wichtige Unterschiede.

Erstens, anders als bei reset --hard, ist bei checkout das Arbeitsverzeichnis sicher. Es wird geprüft, ob Dateien, die Änderungen enthalten, nicht gelöscht werden. Eigentlich ist es noch etwas intelligenter. Es versucht, eine triviales Merge im Arbeitsverzeichnis durchzuführen, so dass alle Dateien, die du nicht geändert hast, aktualisiert werden. reset --hard hingegen, wird alles ohne Überprüfung einfach ersetzen.

Der zweite wichtige Unterschied ist die Frage, wie checkout den HEAD aktualisiert. Während reset den Branch verschiebt, auf den HEAD zeigt, so bewegt checkout den HEAD selbst, um auf einen anderen Branch zu zeigen.

Angenommen, wir haben master und develop Branches, die zu verschiedenen Commits zeigen und wir befinden uns gerade im develop Branch (also weist HEAD dorthin). Sollten wir git reset master ausführen, wird develop selbst nun auf den gleichen Commit zeigen, den master durchführt. Wenn wir stattdessen git checkout master ausführen, ändert sich develop nicht, HEAD selbst bewegt sich. HEAD zeigt nun auf master.

In beiden Fällen verschieben wir also HEAD, um auf Commit A zu zeigen, aber die Methode ist sehr unterschiedlich. reset verschiebt den Branch zum HEAD, checkout dagegen verschiebt den HEAD selbst (nicht den Branch).

reset checkout
Abbildung 154. git checkout und git reset

Mit Pfadangabe

Die andere Möglichkeit, das Auschecken (checkout) auszuführen, ist inkl. der Angabe eines Dateipfades, der, wie bei reset, den HEAD nicht verschiebt. Es ist genau wie bei git reset [branch] Datei, indem es den Index mit dieser Datei beim Commit aktualisiert, aber es überschreibt auch die Datei im Arbeitsverzeichnis. Es wäre genau wie git reset --hard [branch] Datei (wenn reset dich das ausführen lassen würde). Das Arbeitsverzeichnis ist nicht sicher und der Befehl verschiebt den HEAD nicht.

Ebenso wie git reset und git add akzeptiert checkout die Option --patch, die es dir erlaubt, den Inhalt von Dateien auf Basis von einzelnen Teilen selektiv zurückzusetzen.

Zusammenfassung

Wir hoffen, dass du jetzt den Befehl reset besser kennen und anwenden kannst. Wahrscheinlich bist du aber immer noch etwas unsicher, wie genau er sich von checkout unterscheidet. Du kannst dir vermutlich nicht alle Regeln der verschiedenen Aufrufe merken.

Hier ist eine Tabelle, die zeigt, welche Befehle sich auf welche Bäume auswirken. In der Spalte „HEAD“ bedeutet „REF“, dass dieser Befehl die Referenz (den Branch) verschiebt, auf die HEAD zeigt. „HEAD“ signalisiert, dass er HEAD selbst verschiebt. Achte besonders auf die Spalte „WD sicher?“. Wenn dort „NO“ steht, überlege dir genau, ob du diesen Befehl ausführen willst.

HEAD Index Workdir WD sicher?

Commit Level

reset --soft [commit]

REF

NO

NO

YES

reset [commit]

REF

YES

NO

YES

reset --hard [commit]

REF

YES

YES

NO

checkout <commit>

HEAD

YES

YES

YES

File Level

reset [commit] <paths>

NO

YES

NO

YES

checkout [commit] <paths>

NO

YES

YES

NO

scroll-to-top