Git
Chapters ▾ 2nd Edition

7.6 Git Tools - Den Verlauf umschreiben

Den Verlauf umschreiben

Bei der Arbeit mit Git möchtest du manchmal deinen lokalen Commit-Verlauf überarbeiten. Eine der genialen Eigenschaften von Git ist, dass es einem ermöglicht, Entscheidungen im letztmöglichen Moment zu treffen. Du kannst bestimmen, welche Dateien in welche Commits gehen, kurz bevor du in die Staging-Area committest. Du kannst mit git stash festlegen, dass du jetzt noch nicht an etwas arbeiten willst und du keine Commits, die bereits durchgeführt wurden, so umschreiben, dass es so aussieht, als wären sie auf eine ganz andere Art und Weise erfolgt. Das kann eine Änderung der Reihenfolge der Commits umfassen, das Ändern von Nachrichten oder das Modifizieren von Dateien in einem Commit, das Zusammenfügen oder Aufteilen von Commits, oder das komplette Entfernen von Commits. Alles bevor du deine Arbeit mit anderen teilst.

In diesem Abschnitt zeigen wir, wie du diese Aufgaben erledigen kannst, damit du deine Commit-Historie so aussehen lassen kannst, wie du es wünschst, bevor du sie mit anderen teilst.

Anmerkung
Du solltest deine Arbeit nicht pushen, solange du damit nicht zufrieden bist.

Eine der wichtigsten Eigenschaften von Git ist die Möglichkeit die Verlaufshistorie, innerhalb deines lokalen Klons, nach deinen Wünschen umzuschreiben, weil der größte Teil der Arbeit vor Ort geschieht. Wenn du deine Arbeit jedoch einmal gepusht hast, ist das eine ganz andere Geschichte und du solltest die gepushte Arbeit als endgültig betrachten. Es sei denn, du hast gute Gründe, diese zu ändern. Um es kurz zu machen: Vermeide es, deine Arbeit so lange zu pushen, bis du mit ihr zufrieden bist und bereit bist, sie mit dem Rest der Welt zu teilen.

Den letzten Commit ändern

Das Ändern des letzten Commits ist vermutlich der häufigste Grund für die Änderung der Versionshistorie. Du wirst oft zwei wesentliche Änderungen an deinem letzten Commit vornehmen wollen: die Commit-Nachricht oder den eigentlichen Inhalt des Commits ändern, indem du Dateien hinzufügst, entfernst oder modifizierst.

Wenn du lediglich die letzte Commit-Beschreibung ändern willst, ist das einfach:

$ git commit --amend

Der obige Befehl lädt die vorherige Commit-Beschreibung in eine Editorsitzung, in der du Änderungen an der Meldung vornehmen, diese Änderungen speichern und die Sitzung beenden kannst. Wenn du die Nachricht speicherst und schließt, schreibt der Editor einen neuen Commit, der diese aktualisierte Commit-Beschreibung enthält, und macht ihn zu deiner neuen letzten Commit-Beschreibung.

Wenn du andererseits den eigentlichen Inhalt deines letzten Commits ändern willst, funktioniert der Prozess im Prinzip auf die gleiche Weise. Mache zuerst die Änderungen, die du glaubst, vergessen zu haben, stage diese Änderungen und der anschließende git commit --amend ersetzt diesen letzten Commit durch deinen neuen, verbesserten Commit.

Du musst mit dieser Technik vorsichtig sein, da die Änderung den SHA-1 des Commits ändert. Es ist wie ein sehr kleiner Rebase – ändere deinen letzten Commit nicht, wenn du ihn bereits gepusht hast.

Hinweis
Ein geänderter Commit kann (eventuell) eine geänderte Commit-Beschreibung benötigen

Wenn du einen Commit änderst, hast du die Möglichkeit, sowohl die Commit-Beschreibung als auch den Inhalt des Commits zu ändern. Wenn du den Inhalt des Commits maßgeblich änderst, solltest du die Commit-Beschreibung aktualisieren, um den geänderten Inhalt widerzuspiegeln.

Wenn deine Änderungen andererseits trivial ist (ein dummer Tippfehler wurde korrigiert oder eine Datei hinzugefügt, die du vergessen hast zu stagen) und die frühere Commit-Beschreibung ist in Ordnung, dann kannst du einfach die Änderungen vornehmen, sie stagen und die unnötige Editorsitzung vermeiden:

$ git commit --amend --no-edit

Ändern mehrerer Commit-Beschreibungen

Um einen Commit zu ändern, der weiter zurückliegt, musst du komplexeren Werkzeuge nutzen. Git hat kein Tool zum Ändern der Historie, aber du kannst das Rebase-Werkzeug verwenden, um eine Reihe von Commits auf den HEAD zu übertragen, auf dem sie ursprünglich basieren, anstatt sie auf einen anderen zu verschieben. Mit dem interaktiven Rebase-Werkzeug kannst du dann nach jedem Commit pausieren und die Beschreibung ändern, Dateien hinzufügen oder was immer du willst. Du kannst Rebase interaktiv ausführen, indem du die Option -i mit git rebase verwendest. Du musst angeben, wie weit du die Commits umschreiben willst, indem du dem Kommando den Commit nennst, auf den du rebasen willst.

Wenn du zum Beispiel die letzten drei Commit-Beschreibungen oder eine der Commit-Beschreibungen in dieser Gruppe ändern willst, gebe als Argument für git rebase -i das Elternteil der letzten Commit-Beschreibung, die du bearbeiten willst, an (HEAD~2^ oder HEAD~3). Es ist vielleicht einfacher, sich die ~3 zu merken, weil du versuchst, die letzten drei Commits zu bearbeiten. Bedenke aber, dass du eigentlich vier Commits angeben musst, den Elternteil des letzten Commits, den du bearbeiten willst:

$ git rebase -i HEAD~3

Bitte vergiss nicht, dass es sich hierbei um einen Rebasing-Befehl handelt. Jeder Commit im Bereich HEAD~3..HEAD mit einer geänderten Beschreibung und allen seinen Nachfolgern wird neu geschrieben. Füge keinen Commit ein, den du bereits auf einen zentralen Server gepusht hast. Das wird andere Entwickler verwirren, weil sie eine neue Version der gleichen Änderung übermitteln.

Wenn du diesen Befehl ausführst, erhältst du eine Liste von Commits in deinem Texteditor, die ungefähr so aussieht:

pick f7f3f6d Change my name a bit
pick 310154e Update README formatting and add blame
pick a5f4a0d Add cat-file

# Rebase 710f0f8..a5f4a0d onto 710f0f8
#
# Commands:
# p, pick <commit> = use commit
# r, reword <commit> = use commit, but edit the commit message
# e, edit <commit> = use commit, but stop for amending
# s, squash <commit> = use commit, but meld into previous commit
# f, fixup <commit> = like "squash", but discard this commit's log message
# x, exec <command> = run command (the rest of the line) using shell
# b, break = stop here (continue rebase later with 'git rebase --continue')
# d, drop <commit> = remove commit
# l, label <label> = label current HEAD with a name
# t, reset <label> = reset HEAD to a label
# m, merge [-C <commit> | -c <commit>] <label> [# <oneline>]
# .       create a merge commit using the original merge commit's
# .       message (or the oneline, if no original merge commit was
# .       specified). Use -c <commit> to reword the commit message.
#
# These lines can be re-ordered; they are executed from top to bottom.
#
# If you remove a line here THAT COMMIT WILL BE LOST.
#
# However, if you remove everything, the rebase will be aborted.
#
# Note that empty commits are commented out

Es ist wichtig zu erwähnen, dass diese Commits in der umgekehrten Reihenfolge aufgelistet werden, als du sie normalerweise mit dem log Befehl siehst. Wenn du ein log ausführst, siehst du etwas wie das hier:

$ git log --pretty=format:"%h %s" HEAD~3..HEAD
a5f4a0d Add cat-file
310154e Update README formatting and add blame
f7f3f6d Change my name a bit

Beachte die entgegengesetzte Reihenfolge. Der interaktive Rebase stellt dir ein Skript zur Verfügung, den er ausführen wird. Es beginnt mit dem Commit, den du auf der Kommandozeile angibst (HEAD~3) und gibt die Änderungen, die in jedem dieser Commits eingeführt wurden, von oben nach unten wieder. Es listet die ältesten oben auf, nicht die neuesten, weil es die ersten sind, die es wiedergibt.

Du musst das Skript so bearbeiten, dass es bei dem Commit anhält, den du bearbeiten willst. Ändere dazu das Wort ‚pick‘ in das Wort ‚edit‘ für jeden Commit, nach dem das Skript anhalten soll. Um beispielsweise nur die dritte Commit-Beschreibung zu ändern, ändere die Datei so, dass sie wie folgt aussieht:

edit f7f3f6d Change my name a bit
pick 310154e Update README formatting and add blame
pick a5f4a0d Add cat-file

Wenn du speicherst und den Editor verlässt, springt Git zum letzten Commit in dieser Liste zurück und zeigt dir die folgende Meldung an der Kommandozeile an:

$ git rebase -i HEAD~3
Stopped at f7f3f6d... Change my name a bit
You can amend the commit now, with

       git commit --amend

Once you're satisfied with your changes, run

       git rebase --continue

Diese Hinweise sagen dir genau, was zu tun ist. Schreibe:

$ git commit --amend

ändere die Commit-Beschreibung und verlasse den Editor. Dann rufe folgenden Befehl auf:

$ git rebase --continue

Damit setzt du die anderen beiden Commits automatisch fort und du bist fertig. Falls du „pick“ zum Bearbeiten in mehreren Zeilen zu „edit“ änderst, kannst du diese Schritte für jeden zu bearbeitenden Commit wiederholen. Jedes Mal hält Git an, lässt dich den Commit ändern und fährt fort, sobald du fertig bist.

Commits umsortieren

Du kannst interaktive Rebases auch verwenden, um Commits neu anzuordnen oder ganz zu entfernen. Wenn du unten den „Add cat-file“ Commit entfernst und die Reihenfolge ändern willst, in der die anderen beiden Commits aufgeführt werden, kannst du das Rebase-Skript so anpassen (vorher):

pick f7f3f6d Change my name a bit
pick 310154e Update README formatting and add blame
pick a5f4a0d Add cat-file

nachher:

pick 310154e Update README formatting and add blame
pick f7f3f6d Change my name a bit

Wenn du gespeichert und den Editor verlassen hast, spult Git deinen Branch zum Elternteil dieser Commits zurück, wendet 310154e und dann f7f3f6d an und stoppt dann. Du änderst effektiv die Reihenfolge dieser Commits und entfernst den „Add cat-file“ Commit komplett.

Commits zusammenfassen

Es ist auch möglich, eine Reihe von Commits zu mit dem interaktiven Rebasing-Werkzeug zu einem einzigen Commit zusammenzufassen (engl. to squash). Das Skript fügt hilfreiche Anweisungen in die Rebasemeldung ein:

#
# Commands:
# p, pick <commit> = use commit
# r, reword <commit> = use commit, but edit the commit message
# e, edit <commit> = use commit, but stop for amending
# s, squash <commit> = use commit, but meld into previous commit
# f, fixup <commit> = like "squash", but discard this commit's log message
# x, exec <command> = run command (the rest of the line) using shell
# b, break = stop here (continue rebase later with 'git rebase --continue')
# d, drop <commit> = remove commit
# l, label <label> = label current HEAD with a name
# t, reset <label> = reset HEAD to a label
# m, merge [-C <commit> | -c <commit>] <label> [# <oneline>]
# .       create a merge commit using the original merge commit's
# .       message (or the oneline, if no original merge commit was
# .       specified). Use -c <commit> to reword the commit message.
#
# These lines can be re-ordered; they are executed from top to bottom.
#
# If you remove a line here THAT COMMIT WILL BE LOST.
#
# However, if you remove everything, the rebase will be aborted.
#
# Note that empty commits are commented out

Wenn du statt „pick“ oder „edit“ „squash“ angibst, wendet Git sowohl diese Änderung als auch die Änderung direkt davor an und lässt dich die Commit-Beschreibungen zusammenfügen. Wenn du also einen einzelnen Commit aus diesen drei Commits machen willst, musst du das Skript wie folgt anpassen:

pick f7f3f6d Change my name a bit
squash 310154e Update README formatting and add blame
squash a5f4a0d Add cat-file

Wenn du speicherst und den Editor schließt, wendet Git alle drei Änderungen an und öffnet dann wieder den Editor, um die drei Commit-Beschreibungen zusammenzuführen:

# This is a combination of 3 commits.
# The first commit's message is:
Change my name a bit

# This is the 2nd commit message:

Update README formatting and add blame

# This is the 3rd commit message:

Add cat-file

Wenn du das speicherst, hast du einen einzigen Commit, der die Änderungen aller drei vorherigen Commits einbringt.

Aufspalten eines Commits

Das Aufteilen eines Commits macht einen Commit rückgängig und stagt dann partiell so viele Commits, wie du am Ende haben willst. Nehmen wir beispielsweise an, du willst den mittleren Commit deiner drei Commits teilen. Statt „Update README formatting and add blame“ willst du ihn in zwei Commits aufteilen: „Update README formatting“ für die erste und „Add blame“ für die zweite. Du kannst das mit dem rebase -i Skript tun, indem du die Anweisung für den Commit, den du aufteilen willst, in „edit“ änderst:

pick f7f3f6d Change my name a bit
edit 310154e Update README formatting and add blame
pick a5f4a0d Add cat-file

Wenn das Skript dich dann auf die Befehlszeile zurückführt, setze diesen Commit zurück, übernehmen die zurückgesetzten Änderungen und erstelle daraus mehrere Commits. Wenn du speichern und den Editor verlässt, springt Git zum Elternteil des ersten Commits in deiner Liste zurück, wendet den ersten Commit an (f7f3f6d), wendet den zweiten an (310154e) und führt dich zurück auf die Konsole. Dort kannst du ein kombiniertes Zurücksetzen dieses Commits mit git reset HEAD^ durchführen, was praktisch den Commit rückgängig macht und die modifizierten Dateien unstaged lässt. Jetzt kannst du Dateien so lange stagen und committen, bis du mehrere Commits ausgeführt hast, und danach, wenn du fertig bist, git rebase --continue starten:

$ git reset HEAD^
$ git add README
$ git commit -m 'Update README formatting'
$ git add lib/simplegit.rb
$ git commit -m 'Add blame'
$ git rebase --continue

Git wendet den letzten Commit (a5f4a0d) im Skript an, und dein Verlauf sieht dann so aus:

$ git log -4 --pretty=format:"%h %s"
1c002dd Add cat-file
9b29157 Add blame
35cfb2b Update README formatting
f7f3f6d Change my name a bit

Dies ändert die SHA-1s der drei jüngsten Commits in deiner Liste. Stelle also sicher, dass kein geänderter Commit in dieser Liste auftaucht, den du bereits in ein gemeinschaftliches Repository verschoben hast. Beachte, dass der letzte Commit (f7f3f6d) in der Liste nicht geändert wurde. Trotzdem wird dieser Commit im Skript angezeigt, da er als „pick“ markiert war und vor jeglichen Rebase-Änderungen angewendet wurde. Git lässt den Commit unverändert.

Commit löschen

Wenn du einen Commit entfernen möchtest, kannst du ihn mit dem Skript rebase -i löschen. Füge in der Liste der Commits das Wort „drop“ vor dem Commit ein, den du löschen möchtest (oder lösche einfach diese Zeile aus dem Rebase-Skript):

pick 461cb2a This commit is OK
drop 5aecc10 This commit is broken

Aufgrund der Art und Weise, wie Git Commit-Objekte erstellt, werden beim Löschen oder Ändern eines Commits alle darauf folgenden Commits neu geschrieben. Je weiter du in der Historie deines Repos zurück gehst, desto mehr Commits müssen neu erstellt werden. Dies kann zu vielen Mergekonflikten führen, wenn es viele Commits in der Historie gibt, die von dem gerade gelöschten abhängen.

Wenn du ein solches Rebase teilweise durchläufst und feststellst, dass dies keine gute Idee ist, kannst du jederzeit damit aufhören. Gib git rebase --abort ein und dein Repo wird in den Zustand zurückversetzt, in dem es sich befand, bevor du das Rebase gestartet hast.

Wenn du ein Rebase beendest und feststellst, dass es nicht das ist, was du wolltest, kannst du git reflog verwenden, um eine frühere Version deines Branches wiederherzustellen. Weitere Informationen zum Befehl reflog findest du unter Datenwiederherstellung.

Anmerkung

Drew DeVault hat einen praktischen Leitfaden mit Übungen erstellt, um die Verwendung von git rebase zu erlernen. Er ist unter https://git-rebase.io/ zu finden.

Die Nuklear-Option: filter-branch

Es gibt noch eine weitere Option zum Überschreiben der Historie, wenn du eine größere Anzahl von Commits auf eine skriptfähige Art und Weise umschreiben musst. Wenn du, zum Beispiel, deine E-Mail-Adresse global änderst oder eine Datei aus jedem Commit entfernen willst. Der Befehl heißt filter-branch und kann große Teile deines Verlaufs neu schreiben. Du solltest ihn deshalb besser nicht verwenden. Es sei denn, dein Projekt ist noch nicht veröffentlicht und andere Leute haben noch keine Arbeiten an den Commits durchgeführt, die du gerade neu schreiben willst. Wie auch immer, er kann sehr nützlich sein. Du wirst ein paar der häufigsten Verwendungszwecke kennenlernen, damit du eine Vorstellung gewinnen kannst, wofür er geeignet ist.

Achtung

git filter-branch hat viele Fallstricke und wird nicht mehr empfohlen, um die Historie umzuschreiben. Stattdessen solltest du die Verwendung von git-filter-repo in Betracht ziehen. Das ist ein Python-Skript, das für die meisten Aufgaben besser geeignet ist, bei denen du normalerweise auf filter-branch zurückgreifen würdest. Die zugehörige Dokumentation und den Quellcode findest du unter https://github.com/newren/git-filter-repo.

Eine Datei aus jedem Commit entfernen

Das kommt relativ häufig vor. Jemand übergibt versehentlich eine riesige Binärdatei mit einem gedankenlosen git add . und du willst sie überall entfernen. Vielleicht hast du versehentlich eine Datei übergeben, die ein Passwort enthält und du willst dein Projekt zu Open Source machen. filter-branch ist das Mittel der Wahl, um deinen gesamten Verlauf zu säubern. Um eine Datei namens passwords.txt aus deinem gesamten Verlauf zu entfernen, kannst du die Option --tree-filter mit filter-branch verwenden:

$ git filter-branch --tree-filter 'rm -f passwords.txt' HEAD
Rewrite 6b9b3cf04e7c5686a9cb838c3f36a8cb6a0fc2bd (21/21)
Ref 'refs/heads/master' was rewritten

Die Option --tree-filter führt den angegebenen Befehl nach jedem Checkout des Projekts aus und überträgt die Ergebnisse erneut. In diesem Fall entfernst du die Datei passwords.txt aus jedem Schnappschuss, unabhängig davon, ob sie existiert oder nicht. Wenn du alle versehentlich übertragene Editor-Backup-Dateien entfernen möchtest, kannst du beispielsweise git filter-branch --tree-filter 'rm -f *~' HEAD ausführen.

Du wirst in der Lage sein, Git beim Umschreiben der Bäume und Commits zu beobachten und am Ende den Branch-Zeiger zu bewegen. Generell ist es ratsam, das in einem Test-Branch zu tun und den master Branch hart zurückzusetzen, wenn das Ergebnis so ist, wie du es erwartet hast. Um filter-branch auf allen deinen Branches auszuführen, kannst du die Option --all an den Befehl übergeben.

Ein Unterverzeichnis zum neuen Root machen

Nehmen wir an, du hast einen Import aus einem anderen Versionsverwaltungssystem durchgeführt und verfügst über Unterverzeichnisse, die keinen Sinn machen (trunk, tags usw.). Wenn du das trunk Unterverzeichnis zum neuen Stamm-Verzeichnis des Projekts für jeden Commit machen willst, kann dir filter-branch auch dabei helfen:

$ git filter-branch --subdirectory-filter trunk HEAD
Rewrite 856f0bf61e41a27326cdae8f09fe708d679f596f (12/12)
Ref 'refs/heads/master' was rewritten

Jetzt ist dein neues Projekt-Stammverzeichnis das, was sich vorher im Unterverzeichnis trunk befand. Git wird automatisch Commits entfernen, die sich nicht auf das Unterverzeichnis auswirken.

Globales Ändern von E-Mail-Adressen

Ein weiterer häufiger Fall ist, dass du vergessen hast, git config auszuführen, um deinen Namen und deine E-Mail-Adresse vor Beginn der Arbeit festzulegen oder vielleicht willst du ein Open-Source-Projekt eröffnen und alle deine Arbeits-E-Mail-Adressen auf deine persönliche Adresse ändern. In jedem Fall kannst du die E-Mail-Adressen in mehreren Commits in einem Batch mit filter-branch ebenfalls ändern. Du musst darauf achten, nur die E-Mail-Adressen zu ändern, die dir gehören. Deshalb solltest du --commit-filter verwenden:

$ git filter-branch --commit-filter '
        if [ "$GIT_AUTHOR_EMAIL" = "schacon@localhost" ];
        then
                GIT_AUTHOR_NAME="Scott Chacon";
                GIT_AUTHOR_EMAIL="schacon@example.com";
                git commit-tree "$@";
        else
                git commit-tree "$@";
        fi' HEAD

Dadurch wird jeder Commit umgeschrieben, um deine neue Adresse zu erhalten. Da die Commits die SHA-1-Werte ihrer Eltern enthalten, ändert dieser Befehl jeden Commit SHA-1 in deinem Verlauf, nicht nur diejenigen, die die passende E-Mail-Adresse haben.

scroll-to-top