Kapitel ▾ 2. Auflage

7.6 Git Tools - Verlaufsbearbeitung

Verlaufsbearbeitung

Oftmals möchten Sie bei der Arbeit mit Git Ihren lokalen Commit-Verlauf überarbeiten. Eine der großartigen Eigenschaften von Git ist, dass es Ihnen erlaubt, Entscheidungen im letzten möglichen Moment zu treffen. Sie können entscheiden, welche Dateien in welche Commits gehören, kurz bevor Sie mit der Staging Area committen; Sie können entscheiden, dass Sie etwas noch nicht bearbeiten wollten, mit git stash; und Sie können bereits getätigte Commits umschreiben, sodass sie so aussehen, als wären sie auf andere Weise passiert. Dies kann das Ändern der Reihenfolge von Commits, das Ändern von Nachrichten oder das Modifizieren von Dateien in einem Commit, das Zusammenfassen oder Aufteilen von Commits oder das vollständige Entfernen von Commits beinhalten – all das, bevor Sie Ihre Arbeit mit anderen teilen.

In diesem Abschnitt erfahren Sie, wie Sie diese Aufgaben erledigen können, damit Ihr Commit-Verlauf so aussieht, wie Sie ihn haben möchten, bevor Sie ihn mit anderen teilen.

Hinweis
Pushen Sie Ihre Arbeit erst, wenn Sie damit zufrieden sind

Eine der Hauptregeln von Git besagt, dass Sie, da so viel Arbeit lokal in Ihrem Klon liegt, eine große Freiheit haben, Ihren Verlauf *lokal* zu bearbeiten. Sobald Sie jedoch Ihre Arbeit pushen, sieht die Sache ganz anders aus, und Sie sollten gepushte Arbeit als endgültig betrachten, es sei denn, Sie haben guten Grund, sie zu ändern. Kurz gesagt, Sie sollten vermeiden, Ihre Arbeit zu pushen, bis Sie damit zufrieden sind und bereit sind, sie mit dem Rest der Welt zu teilen.

Ändern des letzten Commits

Das Ändern Ihres letzten Commits ist wahrscheinlich die häufigste Verlaufsbearbeitung, die Sie vornehmen werden. Oftmals möchten Sie zwei grundlegende Dinge mit Ihrem letzten Commit tun: einfach die Commit-Nachricht ändern oder den tatsächlichen Inhalt des Commits ändern, indem Sie Dateien hinzufügen, entfernen und modifizieren.

Wenn Sie einfach die Nachricht Ihres letzten Commits ändern möchten, ist das ganz einfach

$ git commit --amend

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

Wenn Sie hingegen den tatsächlichen *Inhalt* Ihres letzten Commits ändern möchten, funktioniert der Prozess im Grunde gleich – machen Sie zuerst die Änderungen, die Sie vergessen zu haben glauben, stagen Sie diese Änderungen, und der nachfolgende Befehl git commit --amend *ersetzt* den letzten Commit durch Ihren neuen, verbesserten Commit.

Sie müssen bei dieser Technik vorsichtig sein, da das Anwenden von Änderungen die SHA-1 des Commits verändert. Es ist wie ein sehr kleines Rebase – ändern Sie nicht Ihren letzten Commit, wenn Sie ihn bereits gepusht haben.

Tipp
Ein geänderter Commit erfordert möglicherweise (oder auch nicht) eine geänderte Commit-Nachricht

Wenn Sie einen Commit ändern, haben Sie die Möglichkeit, sowohl die Commit-Nachricht als auch den Inhalt des Commits zu ändern. Wenn Sie den Inhalt des Commits erheblich ändern, sollten Sie fast sicher die Commit-Nachricht aktualisieren, um diesen geänderten Inhalt widerzuspiegeln.

Wenn Ihre Änderungen jedoch ausreichend trivial sind (einen dummen Tippfehler beheben oder eine vergessene Datei hinzufügen), sodass die frühere Commit-Nachricht völlig in Ordnung ist, können Sie einfach die Änderungen vornehmen, sie stagen und die unnötige Editor-Sitzung mit folgendem Befehl vermeiden:

$ git commit --amend --no-edit

Ändern mehrerer Commit-Nachrichten

Um einen Commit zu ändern, der weiter zurück in Ihrem Verlauf liegt, müssen Sie zu komplexeren Werkzeugen greifen. Git verfügt nicht über ein Werkzeug zur Verlaufsbearbeitung, aber Sie können das Rebase-Werkzeug verwenden, um eine Reihe von Commits auf den HEAD zu rebasen, auf dem sie ursprünglich basierten, anstatt sie auf einen anderen zu verschieben. Mit dem interaktiven Rebase-Werkzeug können Sie dann nach jedem zu ändernden Commit anhalten und die Nachricht ändern, Dateien hinzufügen oder tun, was immer Sie möchten. Sie können Rebase interaktiv ausführen, indem Sie die Option -i zu git rebase hinzufügen. Sie müssen angeben, wie weit zurück Sie Commits umschreiben möchten, indem Sie dem Befehl mitteilen, auf welchen Commit rebased werden soll.

Wenn Sie beispielsweise die letzten drei Commit-Nachrichten oder eine der Commit-Nachrichten in dieser Gruppe ändern möchten, übergeben Sie als Argument an git rebase -i den Eltern-Commit des letzten zu bearbeitenden Commits, der HEAD~2^ oder HEAD~3 ist. Es ist möglicherweise einfacher, sich ~3 zu merken, da Sie die letzten drei Commits bearbeiten möchten, aber denken Sie daran, dass Sie tatsächlich vier Commits zurück angeben, nämlich den Eltern-Commit des letzten zu bearbeitenden Commits.

$ git rebase -i HEAD~3

Denken Sie nochmals daran, dass dies ein Rebase-Befehl ist – jeder Commit im Bereich HEAD~3..HEAD mit einer geänderten Nachricht *und alle seine Nachfolger* werden umgeschrieben. Schließen Sie keine Commits ein, die Sie bereits auf einen zentralen Server gepusht haben – dies wird andere Entwickler verwirren, indem es eine alternative Version derselben Änderung bereitstellt.

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 beachten, dass diese Commits in umgekehrter Reihenfolge aufgeführt sind, als Sie sie normalerweise mit dem Befehl log sehen. Wenn Sie einen log ausführen, sehen Sie ungefähr Folgendes:

$ 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 umgekehrte Reihenfolge. Der interaktive Rebase gibt Ihnen ein Skript, das er ausführen wird. Er beginnt beim von Ihnen auf der Befehlszeile angegebenen Commit (HEAD~3) und spielt die Änderungen jedes dieser Commits von oben nach unten ab. Er listet den ältesten oben auf, nicht den neuesten, weil das der erste ist, den er abspielen wird.

Sie müssen das Skript bearbeiten, sodass es bei dem Commit stoppt, den Sie bearbeiten möchten. Ändern Sie dazu das Wort "pick" in das Wort "edit" für jeden der Commits, nach denen das Skript stoppen soll. Um beispielsweise nur die dritte Commit-Nachricht 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 den Editor speichern und beenden, wickelt Git Sie zurück zum letzten Commit in dieser Liste und setzt Sie auf der Befehlszeile mit der folgenden Meldung ab:

$ 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 Anweisungen sagen Ihnen genau, was zu tun ist. Geben Sie ein:

$ git commit --amend

Ändern Sie die Commit-Nachricht und beenden Sie den Editor. Führen Sie dann aus:

$ git rebase --continue

Dieser Befehl wendet die beiden anderen Commits automatisch an, und dann sind Sie fertig. Wenn Sie pick in edit auf mehreren Zeilen ändern, können Sie diese Schritte für jeden Commit wiederholen, den Sie in edit geändert haben. Jedes Mal stoppt Git, lässt Sie den Commit ändern und fährt fort, wenn Sie fertig sind.

Reihenfolge von Commits ändern

Sie können interaktive Rebase auch verwenden, um Commits neu anzuordnen oder ganz zu entfernen. Wenn Sie den Commit "Add cat-file" entfernen und die Reihenfolge ändern möchten, in der die anderen beiden Commits eingeführt werden, können Sie das Rebase-Skript von diesem ändern:

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

in dieses:

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

Wenn Sie den Editor speichern und beenden, setzt Git Ihren Branch auf den Eltern-Commit dieser Commits zurück, wendet 310154e und dann f7f3f6d an und stoppt. Sie ändern effektiv die Reihenfolge dieser Commits und entfernen den Commit "Add cat-file" vollständig.

Commits zusammenfassen (Squashing)

Es ist auch möglich, eine Reihe von Commits zu nehmen und sie mit dem interaktiven Rebase-Werkzeug zu einem einzigen Commit zusammenzufassen. Das Skript fügt hilfreiche Anweisungen in die Rebase-Nachricht 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 anstelle von "pick" oder "edit" "squash" angeben, wendet Git sowohl diese Änderung als auch die unmittelbar davorliegende an und lässt Sie die Commit-Nachrichten zusammenführen. Wenn Sie also aus diesen drei Commits einen einzigen Commit machen möchten, ändern Sie das Skript so, dass es wie folgt aussieht:

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

Wenn Sie den Editor speichern und beenden, wendet Git alle drei Änderungen an und setzt Sie dann wieder in den Editor, um die drei Commit-Nachrichten 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 einführt.

Einen Commit aufteilen

Das Aufteilen eines Commits macht einen Commit rückgängig und staget und committet ihn dann teilweise so oft, wie Sie am Ende Commits haben möchten. Angenommen, Sie möchten den mittleren Commit Ihrer drei Commits aufteilen. Anstatt "Update README formatting and add blame" möchten Sie ihn in zwei Commits aufteilen: "Update README formatting" für den ersten und "Add blame" für den zweiten. Sie können dies im rebase -i Skript tun, indem Sie die Anweisung für den zu teilenden Commit auf "edit" ändern:

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

Dann, wenn das Skript Sie auf die Befehlszeile zurückführt, setzen Sie diesen Commit zurück, nehmen die zurückgesetzten Änderungen und erstellen daraus mehrere Commits. Wenn Sie den Editor speichern und beenden, setzt Git auf den Eltern-Commit des ersten Commits in Ihrer Liste zurück, wendet den ersten Commit (f7f3f6d) an, wendet den zweiten (310154e) an und setzt Sie auf der Konsole ab. Dort können Sie einen gemischten Reset dieses Commits mit git reset HEAD^ durchführen, was diesen Commit effektiv rückgängig macht und die geänderten Dateien ungestaged lässt. Jetzt können Sie Dateien stagen und committen, bis Sie mehrere Commits haben, und git rebase --continue ausführen, wenn Sie fertig sind.

$ 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 wie folgt 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-1-Werte der drei letzten Commits in Ihrer Liste. Stellen Sie also sicher, dass keine geänderte Commit-Nachricht in dieser Liste erscheint, die Sie bereits in ein gemeinsames Repository gepusht haben. Beachten Sie, dass der letzte Commit (f7f3f6d) in der Liste unverändert ist. Obwohl dieser Commit im Skript angezeigt wurde, weil er als "pick" markiert war und vor jeglichen Rebase-Änderungen angewendet wurde, belässt Git den Commit unverändert.

Einen Commit löschen

Wenn Sie einen Commit loswerden möchten, können Sie ihn mit dem rebase -i Skript löschen. Setzen Sie in der Liste der Commits das Wort "drop" vor den zu löschenden Commit (oder löschen Sie 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, führt das Löschen oder Ändern eines Commits zur Neuschreibung aller nachfolgenden Commits. Je weiter Sie in der Historie Ihres Repos gehen, desto mehr Commits müssen neu erstellt werden. Dies kann zu vielen Merge-Konflikten führen, wenn Sie viele Commits später in der Sequenz haben, die von dem gerade gelöschten abhängen.

Wenn Sie während eines Rebase wie diesem auf halbem Weg feststellen, dass es keine gute Idee war, können Sie jederzeit abbrechen. Geben Sie git rebase --abort ein, und Ihr Repository wird in den Zustand zurückversetzt, in dem es sich befand, bevor Sie mit dem Rebase begonnen haben.

Wenn Sie ein Rebase beenden und feststellen, dass es nicht das ist, was Sie wollen, können Sie git reflog verwenden, um eine frühere Version Ihres Branches wiederherzustellen. Weitere Informationen zum Befehl reflog finden Sie unter Datenwiederherstellung.

Hinweis

Drew DeVault hat einen praktischen Leitfaden mit Übungen erstellt, um den Umgang mit git rebase zu lernen. Sie finden ihn unter: https://git-rebase.io/

Die atomare Option: filter-branch

Es gibt eine weitere Option zur Verlaufsbearbeitung, die Sie verwenden können, wenn Sie eine größere Anzahl von Commits auf eine skriptfähige Weise umschreiben müssen – zum Beispiel, um Ihre E-Mail-Adresse global zu ändern oder eine Datei aus jedem Commit zu entfernen. Der Befehl ist filter-branch, und er kann riesige Teile Ihrer Historie umschreiben, daher sollten Sie ihn wahrscheinlich nicht verwenden, es sei denn, Ihr Projekt ist noch nicht öffentlich und andere Personen haben ihre Arbeit nicht auf den Commits aufgebaut, die Sie umschreiben werden. Er kann jedoch sehr nützlich sein. Sie lernen einige der häufigen Anwendungsfälle kennen, um eine Vorstellung davon zu bekommen, wozu er fähig ist.

Vorsicht

git filter-branch birgt viele Fallstricke und ist nicht mehr die empfohlene Methode, um den Verlauf zu bearbeiten. Ziehen Sie stattdessen git-filter-repo in Betracht, ein Python-Skript, das für die meisten Anwendungen, für die Sie normalerweise filter-branch verwenden würden, eine bessere Leistung erbringt. Seine Dokumentation und sein Quellcode sind unter https://github.com/newren/git-filter-repo zu finden.

Eine Datei aus jedem Commit entfernen

Dies kommt recht häufig vor. Jemand committet versehentlich eine riesige Binärdatei mit einem gedankenlosen git add ., und Sie möchten sie überall entfernen. Vielleicht haben Sie versehentlich eine Datei mit einem Passwort committet und möchten Ihr Projekt quelloffen machen. filter-branch ist das Werkzeug, das Sie wahrscheinlich verwenden möchten, um Ihre gesamte Historie zu bereinigen. Um eine Datei namens passwords.txt aus Ihrer gesamten Historie zu entfernen, können Sie die Option --tree-filter für 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 Auschecken des Projekts aus und committet dann die Ergebnisse erneut. In diesem Fall entfernen Sie eine Datei namens passwords.txt aus jedem Snapshot, unabhängig davon, ob sie existiert oder nicht. Wenn Sie alle versehentlich committeten Editor-Backup-Dateien entfernen möchten, können Sie etwas wie git filter-branch --tree-filter 'rm -f *~' HEAD ausführen.

Sie können beobachten, wie Git Bäume und Commits neu schreibt, und dann am Ende den Branch-Zeiger verschieben. Es ist im Allgemeinen ratsam, dies in einem Test-Branch zu tun und dann Ihren master-Branch hart zurückzusetzen, nachdem Sie das Ergebnis als das bestimmt haben, was Sie wirklich wollen. Um filter-branch auf allen Ihren Branches auszuführen, können Sie --all an den Befehl übergeben.

Ein Unterverzeichnis zum neuen Stamm machen

Angenommen, Sie haben einen Import aus einem anderen Quellcodeverwaltungssystem durchgeführt und haben Unterverzeichnisse, die keinen Sinn ergeben (trunk, tags usw.). Wenn Sie das trunk-Unterverzeichnis zum neuen Projektstamm für jeden Commit machen möchten, kann Ihnen filter-branch auch dabei helfen:

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

Nun ist Ihr neuer Projektstamm das, was sich jedes Mal im trunk-Unterverzeichnis befand. Git entfernt auch automatisch Commits, die das Unterverzeichnis nicht beeinflusst haben.

E-Mail-Adressen global ändern

Ein weiterer häufiger Fall ist, dass Sie vergessen haben, git config auszuführen, um Ihren Namen und Ihre E-Mail-Adresse festzulegen, bevor Sie mit der Arbeit begonnen haben, oder vielleicht möchten Sie ein Projekt bei der Arbeit quelloffen machen und alle Ihre Arbeits-E-Mail-Adressen in Ihre persönliche Adresse ändern. In jedem Fall können Sie E-Mail-Adressen in mehreren Commits stapelweise mit filter-branch ändern. Sie müssen vorsichtig sein, nur die E-Mail-Adressen zu ändern, die Ihnen gehören, daher verwenden Sie --commit-filter:

$ 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

Dies geht durch und schreibt jeden Commit neu, um Ihre neue Adresse zu haben. Da Commits die SHA-1-Werte ihrer Eltern enthalten, ändert dieser Befehl jeden Commit-SHA-1 in Ihrem Verlauf, nicht nur diejenigen, die die übereinstimmende E-Mail-Adresse haben.