Bei Git stehen die Commits im Mittelpunkt: Mit vielen verschiedenen Git-Befehlen verschiebst du Commits in die Staging-Umgebung, erstellst Commits, siehst alte Commit an und übermittelst Commits zwischen Repositorys. Die meisten dieser Befehle wirken sich in irgendeiner Weise auf einen Commit aus und viele von ihnen akzeptieren eine Commit-Referenz als Parameter. Du kannst z. B. mit git checkout einen alten Commit ansehen, indem du einen Commit-Hash einfügst, oder du kannst damit zwischen Branches wechseln, indem du einen Branch-Namen einfügst.

Viele verschiedene Methoden zur Referenzierung eines Commits

Wenn du die vielen Methoden zur Referenzierung eines Commits kennst, gewinnst du einiges mehr an Möglichkeiten bei der Nutzung dieser Befehle. In diesem Kapitel beleuchten wir häufig genutzte Befehle wie git checkout, git branch und git push genauer, indem wir du vielen Methoden zur Referenzierung eines Commits durchgehen.

Außerdem lernen wir, wie anscheinend "verlorene" Commits wiederbelebt werden, indem man über den Reflog-Mechanismus von Git auf sie zugreift.

Hashes

Die direkteste Methode zur Referenzierung eines Commits führt über seinen SHA-1-Hash. Dieser fungiert als unverwechselbare ID für jeden Commit. Den Hash eines Commits findest du in der Ausgabe von git log.

commit 0c708fdec272bc4446c6cabea4f0022c2b616eba
Author: Mary Johnson <mary@example.com>
Date: Wed Jul 9 16:37:42 2014 -0500
Some commit message

Wenn du einen Commit in einem Git-Befehl angibst, müssen lediglich ausreichend Zeichen vorhanden sein, um den Commit eindeutig zu identifizieren. Den obigen Commit könntest du z. B. mit git show untersuchen, indem du den folgenden Befehl ausführst:

git show 0c708f

Manchmal muss ein Branch, Tag oder eine andere indirekte Referenz in den entsprechenden Commit-Hash umgewandelt werden. Hierfür kannst du den Befehl git rev-parse verwenden. Der folgende Befehl gibt den Hash des Commits zurück, auf den der master Branch verweist:

git rev-parse master

Dies ist besonders hilfreich beim Schreiben benutzerdefinierter Skripte, die eine Commit-Referenz akzeptieren. Statt die Commit-Referenz manuell zu parsen, kann git rev-parse die Eingabe für dich normalisieren.

Referenzen

Eine Referenz bzw. ref ist eine indirekte Methode zur Referenzierung eines Commits. Du kannst sie als einen benutzerfreundlichen Alias für einen Commit-Hash betrachten. Sie sind der interne Mechanismus von Git zur Repräsentierung von Branches und Tags.

Refs werden als normale Textdateien im .git/refs-Verzeichnis gespeichert, in dem .git normalerweise .git heißt. Zum Durchsuchen der Refs in einem deiner Repositorys gehst du zu .git/refs. Dort solltest du die folgende Struktur sehen, wobei natürlich je nach den in deinem Repo vorhandenen Branches, Tags und Remotes andere Dateien enthalten sein werden.

.git/refs/
heads/
master
some-feature
remotes/
origin/
master
tags/
v0.9

Das Verzeichnis heads definiert alle lokalen Branches in deinem Repository. Jeder Dateiname entspricht dem Namen des jeweiligen Branches. In der Datei selbst gibt es einen Commit-Hash. Dieser Commit-Hash gibt an, wo sich die Branch-Spitze befindet. Das kannst du überprüfen, indem du die folgenden beiden Befehle aus dem Root-Verzeichnis des Git-Repositorys ausführst:

# Output the contents of `refs/heads/master` file:
cat .git/refs/heads/master
# Inspect the commit at the tip of the `master` branch:
git log -1 master

Der Commit-Hash, der vom Befehl cat zurückgegeben wird, sollte mit der Commit-ID übereinstimmen, die mit git log angezeigt werden kann.

Um den Ort des master Branch zu ändern, muss Git dies lediglich in der refs/heads/master-Datei ändern. Genauso lässt sich ein neuer Branch einfach erstellen, indem ein Commit-Hash in eine neue Datei eingetragen wird. Dies ist einer der Gründe, warum das Branch-Konzept von Git so viel schlanker als das SVN-Modell ist.

Das tags-Verzeichnis funktioniert auf dieselbe Weise, enthält jedoch Tags statt Branches. Das remotes-Verzeichnis listet alle Remote-Repositorys, die du mit git remote erstellt hast, als separate Unterverzeichnisse auf. In jedem einzelnen findest du alle Remote Branches, die du in dein Repository gezogen hast.

Spezifizieren von Referenzen

Wenn du einem Git-Befehl eine Referenz anfügst, kannst du entweder den vollen Namen der Referenz oder einen Kurznamen verwenden, anhand dessen Git nach einer übereinstimmenden Referenz sucht. Du solltest bereits mit Kurznamen für Referenzen vertraut sein, da du diese jedes Mal verwendest, wenn du einen Branch beim Namen nennst.

git show some-feature

Das Argument some-feature im obigen Befehl ist eigentlich ein Kurzname für den Branch. Git wandelt dies vor der Verwendung in refs/heads/some-feature um. Du kannst in der Befehlszeile auch die komplette Ref angeben:

git show refs/heads/some-feature

Auf diese Weise werden Unklarheiten bezüglich des Orts der Ref vermieden. Dies ist z. B. dann erforderlich, wenn sowohl ein Branch und ein Tag namens some-feature vorhanden sind. Sofern du dich jedoch an die korrekten Benennungskonventionen hältst, sollten eigentlich keine Zweideutigkeiten aufkommen.

Wir werden im Abschnitt zu den Refspecs noch weitere vollständige Referenznamen sehen.

Gebündelte Referenzen

In großen Repositorys entfernt Git im Rahmen einer Speicherbereinigung in regelmäßigen Abständen überflüssige Objekte und komprimiert Referenzen in eine einzige Datei für eine Verbesserung der Performance. Diese Komprimierung während der Bereinigung kannst du mit dem folgenden Befehl erzwingen:

git gc

Hiermit werden alle Branch- und Tag-Dateien im refs-Ordner in eine einzige Datei namens packed-refs verschoben. Diese befindet sich ganz oben im .git-Verzeichnis. Wenn du diese Datei öffnest, wird dir eine Zuordnung von Commit-Hashes zu Referenzen angezeigt.

00f54250cf4e549fdfcafe2cf9a2c90bc3800285 refs/heads/feature
0e25143693cfe9d5c2e83944bbaf6d3c4505eb17 refs/heads/master
bb883e4c91c870b5fed88fd36696e752fb6cf8e6 refs/tags/v0.9

Äußerlich wirkt sich dies überhaupt nicht auf die normale Git-Funktionalität aus. Aber wenn du dich gewundert hast, warum dein .git/refs-Ordner leer ist, weißt du jetzt, wo deine Referenzen sich nun befinden.

Spezielle Referenzen

Neben dem refs-Verzeichnis gibt es noch einige spezielle Refs, die sich im .git-Verzeichnis der obersten Ebene befinden. Diese sind im Folgenden aufgelistet:

  • HEAD: Der aktuell ausgecheckte Commit/Branch
  • FETCH_HEAD: Der letzte von einem Remote-Repo abgerufene Branch
  • ORIG_HEAD: Eine Backup-Referenz für den HEAD, bevor drastische Änderungen daran vorgenommen werden
  • MERGE_HEAD: Die Commits, die du mit git merge in den aktuellen Branch mergst
  • CHERRY_PICK_HEAD: Ein gezielt ausgewählter Commit

Diese Referenzen werden alle bei Bedarf von Git erstellt und aktualisiert. Der Befehl git pull führt z. B. zuerst git fetch aus, wodurch die FETCH_HEAD-Referenz aktualisiert wird. Anschließend wird git merge FETCH_HEAD ausgeführt, um den Pull der abgerufenen Branches in das Repository abzuschließen. Natürlich kannst du diese speziellen Refs wie alle anderen Refs auch verwenden, genauso wie du dies sicherlich auch mit HEAD gemacht hast.

Diese Dateien enthalten je nach Dateityp und Status in deinem Repository unterschiedliche Inhalte. Die HEAD-Ref kann entweder eine symbolische Ref, die einfach eine Referenz zu einer anderen Ref anstatt von einem Commit-Hash ist, oder einen Commit-Hash enthalten. Wirf z. B. einmal einen Blick auf die Inhalte von HEAD, wenn du dich im master Branch befindest:

git checkout master
cat .git/HEAD

Daraus erfolgt die Ausgabe ref: refs/heads/master, d. h. HEAD verweist auf die Referenz refs/heads/master. Hierdurch weiß Git, dass der Master Branch derzeit ausgecheckt ist. Solltest du zu einem anderen Branch wechseln, würden die Inhalte von HEAD aktualisiert, um den neuen Branch widerzuspiegeln. Solltest du jedoch einen Commit statt eines Branch auschecken, würde HEAD ein Commit-Hash statt einer symbolischen Referenz enthalten. Hierdurch weiß Git, dass es sich in einem Zustand mit losgelöstem HEAD befindet.

Die meiste Zeit wird HEAD die einzige Referenz sein, die du direkt verwendest. Die anderen sind in der Regel nur dann von Nutzen, wenn du Low-Level-Skripte schreibst, die einen Zugriff auf die tieferliegende Funktionsweise von Git benötigen.

Refspecs

Eine Refspec ordnet einen Branch im lokalen Repository einem Branch in einem Remote-Repository zu. Dies ermöglicht das Management von Remote Branches mit Git-Befehlen und die Konfiguration von erweitertem git push- und git fetch-Verhalten.

Eine Refspec wird mit [+]<src>:<dst> angegeben. Der Parameter <src> steht für den Quell-Branch im lokalen Repository und der Parameter <dst> steht für den Ziel-Branch im Remote-Repository. Das optionale +-Zeichen zwingt das Remote-Repository zu einem Nicht-Fast-Forward-Update.

Refspecs können zusammen mit git push dazu genutzt werden, dem Remote Branch einen anderen Namen zu geben. Der folgende Befehl pusht z. B. den master Branch zum origin-Remote-Repo, genauso wie dies mit einem einfachen git push-Befehl geschehen würde, aber in diesem Fall wird qa-master als Branch-Name für den Branch im origin-Repo verwendet. Dies ist für QS-Teams, die ihre eigenen Branches in ein Remote-Repository pushen müssen, hilfreich.

git push origin master:refs/heads/qa-master

Außerdem kannst du mit Refspecs Remote Branches löschen. Dies ist in Feature Branch Workflows, bei denen die Feature Branches (z. B. zu Backup-Zwecken) in ein Remote-Repo gepusht werden eine häufige Situation. Die Remote Feature Branches befinden sich immer noch im Remote-Repo, nachdem sie vom lokalen Repo gelöscht wurden, sodass sich mit Fortschreiten des Projekts erledigte Feature Branches anhäufen. Du kannst sie löschen, indem du eine Refspec pushst, die über ein leeres <src>-Parameter verfügt, wie z. B.:

git push origin :some-feature

Das ist äußerst praktisch, da du dich nicht in dein Remote-Repository einloggen und den Remote Branch manuell löschen musst. Hinweis: Ab Git v1.7.0 kannst du die Option --delete statt der oben beschriebenen Methode anwenden. Der folgende Befehl hat denselben Effekt wie der obige:

git push origin --delete some-feature

Du kannst das Verhalten von git fetch mit Refspecs ändern, indem du deiner Git-Konfigurationsdatei ein paar Zeilen hinzufügst. In den Standardeinstellungen ruft git fetch alle Branches im Remote-Repository ab. Dies geschieht aufgrund des folgenden Abschnitts in der .git/config-Datei:

[remote "origin"]
url = https://git@github.com:mary/example-repo.git
fetch = +refs/heads/*:refs/remotes/origin/*

Die fetch-Zeile weist git fetch an, alle Branches vom origin-Repo herunterzuladen. Aber manche Workflows benötigen nicht alle Branches. In vielen Continuous Integration Workflows ist beispielsweise nur der master Branch von Bedeutung. Um nur den Master Branch abzurufen, änderst du die fetch-Zeile folgendermaßen:

[remote "origin"]
url = https://git@github.com:mary/example-repo.git
fetch = +refs/heads/master:refs/remotes/origin/master

Du kannst auch git push auf ähnliche Weise konfigurieren. Wenn du z. B. den master Branch zum qa-master im origin-Remote pushen möchtest (wie wir dies oben gemacht haben), änderst du die Konfigurationsdatei folgendermaßen:

[remote "origin"]
url = https://git@github.com:mary/example-repo.git
fetch = +refs/heads/master:refs/remotes/origin/master
push = refs/heads/master:refs/heads/qa-master

Mithilfe der Refspecs erhältst du die absolute Kontrolle darüber, wie die verschiedenen Git-Befehle Branches zwischen Repositorys übertragen. Über sie kannst du Branches umbenennen und von deinem lokalen Repository löschen, Branches mit anderen Namen abrufen/pushen und git push sowie git fetch so konfigurieren, dass die Befehle nur auf die gewünschten Branches angewendet werden.

Relative Referenzen

Du kannst auch Commits im Verhältnis zu einem anderen Commit referenzieren. Mit dem Zeichen ~ erreichst du Parent-Commits. Folgendermaßen wird beispielsweise der Grandparent von HEAD angezeigt:

git show HEAD~2

Doch wenn du mit Merge-Commits arbeitest, wird es ein wenig komplizierter. Da Merge-Commits mehr als einen Parent haben, kannst du auch mehrere Pfaden folgen. Bei einem 3-Wege-Merge stammt der erste Parent von dem Branch, in dem du dich befandst, als du den Merge durchgeführt hast, und der zweite Parent stammt von dem Branch, den du in deinem git merge-Befehl angegeben hast.

Das Zeichen ~ folgt immer dem ersten Parent eines Merge-Commits. Wenn du dem Pfad eines anderen Parents folgen möchtest, musst du diesen mit dem Zeichen ^ bestimmen. Ist beispielsweise HEAD ein Merge-Commit, gibt Folgendes den zweiten Parent von HEAD zurück.

git show HEAD^2

Du kannst das Zeichen ^ mehrmals verwenden, wenn du mehr als eine Generation zurückgehen möchtest. Beim folgenden Beispiel wir der Grandparent vom HEAD (angenommen, es handelt sich um einen Merge-Commit), der sich im zweiten Parent befindet, angezeigt.

git show HEAD^2^1

Um zu verdeutlichen, wie ~ und ^ funktionieren, ist in der folgenden Abbildung dargestellt, wie ein Commit von A aus mit relativen Referenzen erreicht wird. In manchen Fällen gibt es mehrere Wege zum Erreichen eines Commits.

Zugriff auf Commits mithilfe von relativen Referenzen

Relative Referenzen können mit demselben Befehl wie eine normale Referenz verwendet werden. Die folgenden Befehle nutzen z. B. alle eine relative Referenz:

# Only list commits that are parent of the second parent of a merge commit
git log HEAD^2
# Remove the last 3 commits from the current branch
git reset HEAD~3
# Interactively rebase the last 3 commits on the current branch
git rebase -i HEAD~3

Das Reflog

The reflog is Git’s safety net. It records almost every change you make in your repository, regardless of whether you committed a snapshot or not. You can think of it as a chronological history of everything you’ve done in your local repo. To view the reflog, run the git reflog command. It should output something that looks like the following:

400e4b7 HEAD@{0}: checkout: moving from master to HEAD~2
0e25143 HEAD@{1}: commit (amend): Integrate some awesome feature into `master`
00f5425 HEAD@{2}: commit (merge): Merge branch ';feature';
ad8621a HEAD@{3}: commit: Das Feature beenden

Dies kann folgendermaßen übersetzt werden:

  • Du hast soeben HEAD~2 ausgecheckt.
  • Davor hast du eine Commit-Nachricht geändert.
  • Davor hast du den feature Branch in den master gemergt.
  • Davor hast du einen Snapshot committet.

Über die Syntax HEAD{<n>} kannst du im Reflog gespeicherte Commits referenzieren. Dies funktioniert so ähnlich wie mit den HEAD~<n>-Referenzen aus dem vorigen Abschnitt, aber <n> bezieht sich hier auf einen Eintrag im Reflog anstatt auf den Commit-Verlauf.

Du kannst auf diese Weise einen Commit auf einen andernfalls verloren gegangenen Status zurücksetzen. Nehmen wir an, du hast gerade ein neues Feature mit git reset verworfen. Dein Reflog könnte in etwa so aussehen:

ad8621a HEAD@{0}: reset: moving to HEAD~3
298eb9f HEAD@{1}: commit: Some other commit message
bbe9012 HEAD@{2}: commit: Continue the feature
9cb79fa HEAD@{3}: commit: Ein neues Feature beginnen

Die drei Commits vor dem git reset sind nun defekt, d. h. sie können nicht referenziert werden – außer über das Reflog. Nehmen wir an, du stellst fest, dass du nicht deine ganze Arbeit hättest verwerfen sollen. In diesem Fall musst du lediglich den HEAD@{1}-Commit auschecken, um zurück zu dem Zustand deines Repositorys vor dem Ausführen von git reset zu gelangen.

git checkout HEAD@{1}

Dadurch wechselst du in einen Zustand mit losgelöstem HEAD. Von hier aus kannst du einen neuen Branch erstellen und an deinem Feature weiterarbeiten.

Summary

Nun solltest du in der Lage sein, problemlos Commits in einem Git-Repository referenzieren. Wir haben gelernt, wie Branches und Tags als Referenzen im .git-Unterverzeichnis gespeichert werden, wie eine packed-refs-Datei gelesen wird, wie HEAD dargestellt wird, wie Refspecs für erweiterte Push- und Fetch-Vorgänge genutzt wird und wie mit den relativen Operatoren ~ und ^ eine Branch-Hierarchie durchkämmt werden kann.

Außerdem haben wir uns das Reflog angesehen, das zur Referenzierung von Commits dient, die durch andere Mittel nicht verfügbar sind. Dies ist eine hervorragende Möglichkeit zur Wiederherstellung in Situationen, in denen wir denken: "Ups, das hätte ich besser nicht machen sollen."

Der Zweck dieser Übung war, zu lernen, wie du in jedem erdenklichen Entwicklungsszenario genau den Commit auswählst, den du benötigst. Diese Kenntnisse kannst du leicht mit deinem bereits vorhandenen Wissen zu Git kombinieren, da die gebräuchlichsten Befehle Refs als Argument akzeptieren. Hierzu zählen git log, git show, git checkout, git reset, git revert, git rebase und noch viele andere Befehle.