Git
Chapters ▾ 2nd Edition

7.6 Git Tools - Den Verlauf umschreiben

Den Verlauf umschreiben

Bei der Arbeit mit Git möchten Sie vielleicht manchmal Ihren lokalen Commit-Verlauf überarbeiten. Eine der genialen Eigenschaften von Git ist, dass es einem ermöglicht, Entscheidungen im letztmöglichen Moment zu treffen. Sie können bestimmen, welche Dateien in welche Commits gehen, kurz bevor Sie mit der Staging-Area committen. Sie können mit git stash festlegen, dass Sie jetzt noch nicht an etwas arbeiten wollen und Sie können 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 Sie Ihre Arbeit mit anderen teilen.

In diesem Abschnitt zeigen wir, wie Sie diese Aufgaben erledigen können, damit Sie Ihre Commit-Historie so aussehen lassen können, wie Sie es wünschen, bevor Sie sie mit anderen teilen.

Note
Sie sollten Ihre Arbeit nicht pushen, solange Sie damit nicht zufrieden sind.

Eine der wichtigsten Eigenschaften von Git ist die Möglichkeit die Verlaufshistorie, innerhalb Ihres lokalen Klons, nach Ihren Wünschen umzuschreiben, weil der größte Teil der Arbeit vor Ort geschieht. Wenn Sie Ihre Arbeit jedoch einmal gepusht haben, ist das eine ganz andere Geschichte und Sie sollten die gepushte Arbeit als endgültig betrachten – es sei denn, Sie haben gute Gründe, diese zu ändern. Um es kurz zu machen: Vermeiden Sie es, Ihre Arbeit so lange zu pushen, bis Sie mit ihr zufrieden sind und bereit sind, 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 Neufassung der Versionsgeschichte. Sie werden oft zwei wesentliche Änderungen an Ihrem letzten Commit vornehmen wollen: einfach die Commit-Beschreibung ändern oder den eigentlichen Inhalt des Commits ändern, indem Sie Dateien hinzufügen, entfernen oder modifizieren.

Wenn Sie lediglich die letzte Commit-Beschreibung ändern wollen, ist das einfach:

$ git commit --amend

Der obige Befehl lädt die vorherige Commit-Beschreibung in eine Editorsitzung, in der Sie Änderungen an der Meldung vornehmen, diese Änderungen speichern und die Sitzung beenden können. Wenn Sie die Nachricht speichern und schließen, schreibt der Editor einen neuen Commit, der diese aktualisierte Commit-Beschreibung enthält, und macht ihn zu Ihrer neuen letzten Commit-Beschreibung.

Wenn Sie andererseits den eigentlichen Inhalt Ihrer letzten Übertragung ändern wollen, funktioniert der Prozess im Prinzip auf die gleiche Weise – machen Sie zuerst die Änderungen, die Sie glauben, vergessen zu haben, stagen Sie diese Änderungen und der anschließende git commit --amend ersetzt diesen letzten Commit durch Ihren neuen, verbesserten Commit.

Sie müssen mit dieser Technik vorsichtig sein, da die Änderung den SHA-1 des Commits ändert. Es ist wie ein sehr kleiner Rebase – ändern Sie Ihren letzten Commit nicht, wenn Sie ihn bereits gepusht haben.

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

Wenn Sie einen Commit ändern, haben Sie die Möglichkeit, sowohl die Commit-Beschreibung als auch den Inhalt des Commits zu ändern. Wenn Sie den Inhalt des Commits maßgeblich ändern, sollten Sie die Commit-Beschreibung mit Bestimmtheit aktualisieren, um den geänderten Inhalt widerzuspiegeln.

Wenn Ihre Änderungen andererseits trivial sind (ein dummer Tippfehler wurde korrigiert oder eine Datei hinzugefügt, die Sie vergessen haben zu stagen) und die frühere Commit-Beschreibung ist in Ordnung, dann können Sie 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, müssen Sie zu komplexeren Werkzeugen wechseln. Git hat kein Tool zum Ändern der Historie, aber Sie können 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 können Sie dann nach jedem Commit pausieren und die Beschreibung ändern, Dateien hinzufügen oder was immer Sie wollen. Sie können Rebase interaktiv ausführen, indem Sie die Option -i mit git rebase verwenden. Sie müssen angeben, wie weit Sie die Commits umschreiben wollen, indem Sie dem Kommando den Commit nennen, auf den Sie umbasen wollen.

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

$ git rebase -i HEAD~3

Bitte vergessen Sie 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ügen Sie keinen Commit ein, den Sie bereits auf einen zentralen Server gepusht haben – das wird andere Entwickler verwirren, weil sie eine neue Version der gleichen Änderung übermitteln.

Wenn Sie diesen Befehl ausführen, erhalten Sie eine Liste von Commits in Ihrem 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 Sie sie normalerweise mit dem log Befehl sehen. Wenn Sie ein log ausführen, sehen Sie 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

Beachten Sie die entgegengesetzte Reihenfolge. Das interaktive Rebase stellt Ihnen ein Skript zur Verfügung, das es ausführen wird. Es beginnt mit dem Commit, den Sie auf der Kommandozeile angeben (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.

Sie müssen das Skript so bearbeiten, dass es bei dem Commit anhält, den Sie bearbeiten wollen. Ändern Sie 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, ändern Sie 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 Sie speichern und den Editor verlassen, springt Git zum letzten Commit in dieser Liste zurück und zeigt Ihnen die folgende Meldung an der Kommandozeile an:

$ git rebase -i HEAD~3
Stopped at f7f3f6d... changed 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 Ihnen genau, was zu tun ist. Schreiben Sie

$ git commit --amend

ändern Sie die Commit-Beschreibung und verlassen Sie den Editor. Dann rufen Sie folgenden Befehl auf:

$ git rebase --continue

Damit setzen Sie die anderen beiden Commits automatisch fort und Sie sind fertig. Falls Sie „pick“ zum Bearbeiten in mehreren Zeilen zu „edit“ ändern, können Sie diese Schritte für jede zu bearbeitenden Commit wiederholen. Jedes Mal hält Git an, lässt Sie den Commit ändern und fährt fort, sobald Sie fertig sind.

Commits umsortieren

Sie können interaktive Rebases auch verwenden, um Commits neu anzuordnen oder ganz zu entfernen. Wenn Sie unten den „added cat-file“ Commit entfernen und die Reihenfolge ändern wollen, in der die anderen beiden Commits aufgeführt werden, können Sie 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 Sie gespeichert und den Editor verlassen haben, blättert Git Ihren Branch zum Elternteil dieser Commits zurück, wendet 310154e und dann f7f3f6d an und stoppt dann. Sie ändern effektiv die Reihenfolge dieser Commits und entfernen den „added cat-file“ Commit komplett.

Commits zusammenfassen

Es ist auch möglich, eine Reihe von Commits zu erfassen und sie mit dem interaktiven Rebasing-Werkzeug zu einem einzigen Commit zusammenzufassen. 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 Sie statt „pick“ oder „edit“ „squash“ angeben, wendet Git sowohl diese Änderung als auch die Änderung direkt davor an und lässt Sie die Commit-Beschreibungen zusammenfügen. Wenn Sie also einen einzelnen Commit aus diesen drei Commits machen wollen, müssen Sie 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 Sie speichern und den Editor schließen, 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 Sie das speichern, haben Sie 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 Sie am Ende haben wollen. Nehmen wir beispielsweise an, Sie wollten den mittleren Commit Ihrer drei Commits teilen. Statt „Update README formatting and add blame“ wollen Sie ihn in zwei Commits aufteilen: „Update README formatting“ für die erste und „Add blame“ für die zweite. Sie können das mit dem rebase -i Skript tun, indem Sie die Anweisung für den Commit, den Sie aufteilen wollen, in „edit“ ändern:

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

Wenn das Skript Sie dann auf die Befehlszeile zurückführt, setzen Sie diesen Commit zurück, übernehmen die zurückgesetzten Änderungen und erstellen daraus mehrere Commits. Wenn Sie speichern und den Editor verlassen, springt Git zum Elternteil des ersten Commits in Ihrer Liste zurück, wendet den ersten Commit an (f7f3f6d), wendet den zweiten an (310154e) und lässt Sie auf der Konsole stehen. Dort können Sie ein kombiniertes Zurücksetzen dieses Commits mit git reset HEAD^ durchführen, was praktisch den Commit rückgängig macht und die modifizierten Dateien unberührt (engl. unstaged) lässt. Jetzt können Sie Dateien so lange stagen und committen, bis Sie mehrere Commits ausgeführt haben, und danach, wenn Sie fertig sind, 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 Ihr 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 Ihrer Liste, stellen Sie also sicher, dass kein geänderter Commit in dieser Liste auftaucht, den Sie bereits in ein gemeinschaftliches Repository verschoben haben. Beachten Sie, 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.

Note

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

Die Nuklear-Option: filter-branch

Es gibt noch eine weitere Option zum Überschreiben der Historie, wenn Sie eine größere Anzahl von Commits auf eine skriptfähige Art und Weise umschreiben müssen – wenn Sie, zum Beispiel, Ihre E-Mail-Adresse global ändern oder eine Datei aus jedem Commit entfernen wollen. Der Befehl heißt filter-branch und kann große Teile Ihres Verlaufs neu schreiben. Sie sollten ihn deshalb besser nicht verwenden. Es sei denn, Ihr Projekt ist noch nicht veröffentlicht und andere Leute haben noch keine Arbeiten an den Commits durchgeführt, die Sie gerade neu schreiben wollen. Wie auch immer, er kann sehr nützlich sein. Sie werden ein paar der häufigsten Verwendungszwecke kennen lernen, damit Sie eine Vorstellung gewinnen können, wofür er geeignet ist.

Caution

git filter-branch hat viele Fallstricke und wird nicht mehr empfohlen, um die Chronik umzuschreiben. Stattdessen sollten Sie 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 Sie normalerweise auf filter-branch zurückgreifen würden. Die zugehörige Dokumentation und den Quellcode finden Sie 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 Sie wollen sie überall entfernen. Vielleicht haben Sie versehentlich eine Datei übergeben, die ein Passwort enthält und Sie wollen Ihr Projekt zu Open Source machen. filter-branch ist das Mittel der Wahl, um Ihren gesamten Verlauf zu säubern. Um eine Datei namens passwords.txt aus Ihrem gesamten Verlauf zu entfernen, können Sie 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 entfernen Sie die Datei passwords.txt aus jedem Schnappschuss, unabhängig davon, ob sie existiert oder nicht. Wenn Sie alle versehentlich übertragenen Editor-Backup-Dateien entfernen möchten, können Sie beispielsweise git filter-branch --tree-filter 'rm -f *~' HEAD ausführen.

Sie werden in der Lage sein, Git beim Umschreiben der Bäume und Commits zu beobachten und am Ende den Branch-Pointer 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 Sie es erwartet haben. Um filter-branch auf allen Ihren Branches auszuführen, können Sie die Option --all an den Befehl übergeben.

Ein Unterverzeichnis zur neuen Root machen

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

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

Jetzt ist Ihr 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 Sie vergessen haben, git config auszuführen, um Ihren Namen und Ihre E-Mail-Adresse vor Beginn der Arbeit festzulegen oder vielleicht wollen Sie ein Open-Source-Projekt eröffnen und alle Ihre Arbeits-E-Mail-Adressen auf Ihre persönliche Adresse ändern. In jedem Fall können Sie die E-Mail-Adressen in mehreren Commits in einem Batch mit filter-branch ebenfalls ändern. Sie müssen darauf achten, nur die E-Mail-Adressen zu ändern, die Ihnen gehören, deshalb sollten Sie --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 Ihre neue Adresse zu erhalten. Da die Commits die SHA-1-Werte ihrer Eltern enthalten, ändert dieser Befehl jeden Commit SHA-1 in Ihrem Verlauf, nicht nur diejenigen, die die passende E-Mail-Adresse haben.