Nachdem wir uns in der letzten Kolumne mit Grundlagen von Git auf der Kommandozeile beschäftigt haben, wollen wir uns nun einige erweiterte Konzepte, vor allem rund um die nachträgliche Manipulation der Historie, anschauen.
Auch wenn es in dieser Kolumne zum Versionskontrollsystem Git vor allem um die Manipulation von Historie geht, wollen wir mit zwei weiteren kleinen Tipps starten und uns zum Abschluss noch ein Kommando anschauen, das uns bei der Fehlersuche unterstützen kann.
Wie im ersten Teil lässt sich mit Sicherheit auch alles hier Gezeigte mit einem grafischen Git-Client bewerkstelligen. Da ich jedoch Git primär auf der Kommandozeile nutze, basieren die Tipps hier auf den dort zur Verfügung gestellten Befehlen und können im ein oder anderen Client gar nicht vorhanden oder anders benannt sein. Aber starten wir doch ohne weitere Umschweife mit dem ersten Tipp.
Anzeige der Änderungen beim Schreiben der Commit-Message
Grafische Oberflächen für Git geben uns während eines Commits noch einmal die Möglichkeit, während wir über die Commit-Message nachdenken alle im Commit enthaltenen Änderungen zu sehen. Standardmäßig zeigt uns Git auf der Kommandozeile zwar auch im Editor, in dem wir die Commit-Message schreiben, weiter unten die geänderten Dateien an, die eigentlichen Änderungen sind aber nicht mehr sichtbar.
Wenn wir auch die eigentlichen Änderungen sehen möchten, können wir dies
erreichen, indem wir git commit
die Option --verbose
mitgeben. Wir sehen nun
nicht mehr nur, welche Dateien geändert wurden, sondern auch die eigentlichen
Änderungen für jede Datei.
Um nicht jedes Mal daran denken zu müssen, diese Option zu verwenden, können wir
mit dem Befehl git config --global commit.verbose true
auch dafür sorgen, dass
diese standardmäßig gesetzt ist.
Commits signieren
Als erste Grundlage der letzten Kolumne haben wir kennengelernt, wie
Benutzername und E-Mail-Adresse in Git konfiguriert werden. Git nutzt diese
anschließend standardmäßig bei jedem Commit, um den Autor und Commiter
einzutragen. Den Autor können wir allerdings noch für jeden Commit mit der
Option --author
individuell ändern, und auch die Angabe von mehreren Autoren
für einen Commit ist möglich.
Git selbst überprüft allerdings zu keiner Zeit, ob die Daten, die ich eingetragen habe, auch wirklich mir gehören. Je nach eingesetztem Server lassen sich hierzu jedoch Hooks aktivieren, die Commits ablehnen, die nicht zum aktuell eingeloggten Nutzer passen. In GitLab beispielsweise gibt es hierzu die beiden Push Rules „Check whether author is a GitLab user” und „Committer restriction“.
Zusätzlich, und dieses Mal auch direkt in Git eingebaut, können wir unsere Commits auch mittels PGP signieren. Idealerweise erzeugen wir hierzu einen neuen PGP Key, den wir nur zum Signieren von Commits verwenden. Das verringert den Schaden zumindest ein bisschen, sollte dieser Key einmal abhandenkommen oder geklaut werden. Von GitHub gibt es eine Anleitung zur Erzeugung eines PGP Key, und für ein erweitertes Setup mit mehreren E-Mail-Adressen und Sub-Keys hat mir ein Blog Post von Gerhard Steinbeis sehr geholfen.
Nachdem wir nun einen PGP Key besitzen, können wir in Git die beiden
Konfigurationsoptionen user.signingkey
und commit.gpgsign
setzen. Ab jetzt
wird jeder Commit von uns automatisch signiert. Überprüfen können wir dies,
indem wir uns einen signierten Commit mit dem Befehl
git show <COMMIT> --show-signature
anschauen. Hier wird nun angezeigt, mit
welchem Key signiert wurde, und sofern der Public Key bei uns vorhanden ist,
wird auch direkt geprüft, ob die Signatur gültig ist.
Zusätzlich können wir bei den gängigen Git-Servern auch unseren Public-Teil des PGP Key hinterlegen. Dort werden signierte Commits dann visuell hervorgehoben. Abbildung 1 zeigt exemplarisch, wie dies bei GitHub aussieht.
Nach diesen beiden Tipps kommen wir nun zum Hauptteil dieses Artikels, der Manipulation von Historie.
Historie manipulieren
Eine der Eigenschaften von Git, die das Versionskontrollsystem zumindest von seinen Vorläufern Subversion und CVS abhebt, ist, dass vorhandene Commits und damit die Historie eines Repositories auch im Nachhinein von Clients geändert werden können. Zwar war dies beispielsweise auch mit Subversion möglich, allerdings mussten hierzu Befehle direkt auf dem Repository-Server ausgeführt werden.
Was kann ich nun also mit Git im Nachhinein ändern? Im Grunde so gut wie alles. Neben dem Inhalt von Commits lassen sich auch Metadaten, wie beispielsweise wer den Commit erzeugt hat, ändern. Und auch der Commit, der dem zu ändernden vorhergeht, ist änderbar. Da in Git Commits allerdings unveränderliche Objekte sind, wird technisch gesehen nicht der Commit selbst geändert, sondern auf Basis des Commits ein neuer erzeugt, der auch die gewählten Manipulationen beinhaltet. In anderen Artikeln wird deswegen häufig, in Anlehnung an Ableitungen in der Mathematik, der auf Basis von Commit A geänderte Commit A‘ genannt.
Solange diese Manipulationen der Historie nur lokal geschehen, gibt es, abgesehen davon, Fehler dabei zu machen, keine Probleme. Nach einer erfolgreichen Manipulation sehe ich nur noch den neuen Stand und es ist, als hätte es den vorherigen nie gegeben. Komplizierter wird es, sobald wir mit anderen kollaborieren und ein Remote Repository haben, über das wir uns synchronisieren.
Diese Problematik wollen wir uns anhand eines Beispiels anschauen. Auf unserem
Remote Repository besteht der Stand aus Abbildung 2. Sowohl Alice als auch Bob
arbeiten nun parallel bei sich lokal auf dem Zweig some_branch
und erzeugen
jeder einen neuen Commit. Dadurch ergibt sich bei Alice der Stand aus Abbildung
3 und bei Bob der aus Abbildung 4.
Alice entscheidet sich nun dazu, die Historie zu manipulieren, und ändert für
den Commit D
den vorhergehenden Commit von B
auf E
. Lokal entsteht dabei
bei ihr die Historie aus Abbildung 5. Würde sie nun diesen Stand zum Remote
Repository übertragen und Bob würde anschließend synchronisieren, entsteht eine
Historie wie in Abbildung 6.
Abgesehen davon, dass nun die beiden Commits D
und F
sowohl in ihrer
originalen als auch in der geänderten Version vorkommen, besteht die Gefahr,
dass Bob eine Menge von Merge-Konflikten zu lösen hat. Darunter auch einige, die
Alice bereits während ihrer Manipulation gelöst hat. Vor allem aus diesem Grund
lehnt Git standardmäßig ein Übertragen von manipulierter Historie ab. Möchten
wir dies dennoch erzwingen, können wir Git beim push
eine der beiden Optionen
--force
oder --force-with-lease
übergeben. Der Unterschied zwischen beiden
Varianten ist subtil, aber entscheidend.
Hätte in unserem Beispiel Bob seinen Stand vor Alice übertragen, hätte die
Nutzung von --force
bei Alice dazu geführt, dass der Commit H
von Bob
einfach verschwunden wäre. --force-with-lease
hingegen hätte sich in diesem
Szenario geweigert, die Änderungen zu übertragen. Alice müsste nun entweder
ihren lokalen Stand erneut synchronisieren oder mit --force
bewusst
entscheiden, alle bei ihr nicht vorhandenen Änderungen auch im Remote Repository
zu löschen. Um nicht aus Versehen, oder Bequemlichkeit, --force
zu nutzen,
habe ich den Alias git please
für push --force-with-lease
angelegt und nutze
ausschließlich diesen.
Grundsätzlich halte ich mich jedoch daran, dass ich die Historie nur manipuliere, solange ich alleine an einem Branch arbeite. Das gilt für nur lokal vorhandene Branches immer und gilt häufig auch für Feature-Branches. In Fällen, in denen ich gemeinsam mit anderen an einem Branch arbeite, vermeide ich es grundsätzlich, Historie von bereits gepushten Ständen zu manipulieren. Sollte es in Ausnahmefällen doch einmal notwendig sein, achte ich darauf, dies klar und zeitnah zu kommunizieren und anderen zu helfen, ihren lokalen Stand wieder auf den aktuellen Stand zu bekommen.
Nachdem wir nun gesehen haben, wie sich manipulierte Historie zum Remote Repository übertragen lässt und welche Probleme hierdurch entstehen können, wollen wir uns nun ein paar der typischen Anwendungsfälle anschauen, die überhaupt dazu führen können, dass wir die Historie nachträglich ändern wollen.
Änderungen zum letzten Commit hinzufügen
Bevor ich einen Commit erzeuge, schaue ich mir zwar grundsätzlich mit
git diff --staged
noch einmal in Ruhe alle Änderungen an, die in diesem Commit
landen werden. Und trotzdem passiert es mir häufig, dass ich wenige Sekunden,
nachdem ich den Commit erzeugt habe, bemerke, dass ich doch etwas vergessen habe.
Zum Glück ist es möglich, weitere Änderungen auch noch nachdem der Commit
erzeugt wurde hinzuzufügen. Hierzu werden diese Änderungen zuerst wie gehabt mit
git add
in die Staging Area übertragen. Anschließend können wir den Befehl
git commit --amend
nutzen, um einen Commit zu erzeugen. Als Commit-Message
wird uns bereits die des vorherigen Commits angezeigt. Wir können diese nun noch
einmal ändern und nachdem wir diesen Commit erzeugt haben können wir in der
Historie sehen, dass es unseren letzten Commit nicht mehr gibt, dafür aber den
mit der neuen Message, welcher auch die nachträglichen Änderungen beinhaltet.
Da ich in der Regel bei diesem Anwendungsfall die Message nicht ändern muss,
habe ich hierzu den Alias fix
konfiguriert, der commit
neben --amend
noch
um -C HEAD
ergänzt und somit ohne Nachfrage die vorherige Commit-Message für
den geänderten Commit verwendet.
Reihenfolge von Commits ändern
Mir passiert es häufig, dass ich während der Entwicklung auf einem Branch mehrere Dinge erledige. Ich fange mit einem Feature an, merke, dass ich noch etwas Refaktorisieren muss, und ziehe zwischendurch ggf. noch eine Abhängigkeit auf den aktuellen Stand. Bevor ich einen solchen Branch pushe, und ab und an auch danach, fällt mir dann auf, dass die Reihenfolge der Commits für eine spätere Nachverfolgung ggf. besser eine andere Reihenfolge gehabt hätten.
Zum Glück lässt uns Git die gewünschte Reihenfolge, auch im Nachhinein,
herstellen. Hierzu nutzen wir einen interaktiven Rebase. Um dieses zu starten,
wird git rebase -i <COMMIT>
genutzt. Anschließend haben wir die Möglichkeit,
alle Commits zwischen COMMIT
und dem Stand, an dem wir vor der Eingabe des
Befehls stehen, zu manipulieren.
Hierzu öffnet sich, ähnlich wie beim Erzeugen eines Commits, unser
konfigurierter Texteditor (s. Abb. 7). Für eine Änderung der Reihenfolge können
wir die Zeilen 1 bis 6 aus dem Screenshot umsortieren. Wenn wir nun den
Texteditor beenden und vorher oder dabei speichern, wird Git die Commits in die
von uns gewählte Reihenfolge bringen. Treten dabei Konflikte auf, wird der
Vorgang unterbrochen und wir werden aufgefordert, diese zu lösen. Haben wir dies
getan, können wir mit git rebase --continue
den Vorgang fortsetzen.
Stellen wir bei einem der Konflikte fest, dass wir den Rebase doch nicht
durchführen wollen, lässt sich dieser jederzeit mittels git rebase --abort
abbrechen und wir landen wieder beim Stand vor dem Aufruf von git rebase -i
.
Commit-Messages nachträglich ändern
Bei der Kontrolle vor dem Pushen kontrolliere ich neben der Reihenfolge auch noch einmal die Commit-Messages. Häufig entdecke ich dabei dann doch noch einen Tippfehler oder eine Nachricht, die mir im Nachhinein nicht gefällt.
Auch das lässt sich mit einem interaktiven Rebase lösen. Hierzu müssen wir im
durch den rebase
-Befehl geöffneten Texteditor nicht die Reihenfolge der
Commits ändern, sondern ändern das erste Wort der Zeile eines Commits von pick
in reword
, oder kurz r
. Nachdem wir den Rebase nun durch Speichern und
Schließen des Texteditors fortsetzen, geht der Texteditor erneut bei den
markierten Commits auf und wir können die Nachricht zu diesem Commit beliebig
ändern.
Commits zusammenführen oder trennen
Für das Zusammenführen oder Trennen von Commits gibt es bei mir zwei Gründe. Der erste liegt darin, dass ich ab und zu auch für Zwischenstände einen Commit erzeuge, um zu diesem als Sicherungspunkt zurückspringen zu können. Das mache ich beispielsweise, wenn ich noch mal einen ganz anderen Weg ausprobieren möchte. Der zweite Grund besteht wiederum im Review der Commits vor der Übertragung zum Remote Repository.
Fangen wir mit dem Zusammenführen von Commits an. Auch hierzu wird wieder ein
interaktiver Rebase verwendet. Wie beim Ändern von Commit-Nachrichten müssen wir
für die Commits, die zusammengeführt werden sollen, das erste Wort in der zum
Commit gehörenden Zeile ändern. Hierzu stehen uns die beiden Optionen
squash (s)
und fixup (f)
zur Verfügung. Da die Commits von oben nach unten
geordnet werden, bedeutet die Nutzung einer der beiden Möglichkeiten, dass der
Inhalt des markierten Commits beim Rebase in den darüberstehenden Commit
integriert wird. Die Nutzung von squash
führt dazu, dass bei jedem
Zusammenführen der Texteditor aufgeht und uns die Commit-Messages beider Commits
anzeigt und wir nun eine neue Message schreiben können. fixup
hingegen
verwirft die Message des markierten Commits und nutzt ohne Nachfrage die
Nachricht des vorhergehenden Commits.
Das Trennen von Commits ist ein wenig komplizierter. Wir starten hierzu jedoch
erneut mit einem interaktiven Rebase. Dieses Mal wählen wir als Option am zu
trennenden Commit edit (e)
. Während Git nun den Rebase durchführt, wird es
beim markierten Commit stoppen und wir finden uns auf der Kommandozeile wieder.
Um den Commit trennen zu können, müssen wir diesen nun erst mal entfernen ohne
dabei auch die Änderungen, die durch diesen erzeugt wurden, zu verlieren. Hierzu
nutzen wir git reset HEAD^
. Nun befinden wir uns auf dem Stand des Commits vor
dem zu trennenden und alle vom Commit gemachten Änderungen sind lokal vorhanden,
werden also bei Nutzung von git status
als Änderungen angezeigt. Wir können
nun selektiv per git add
diese Änderungen wieder hinzufügen und auch andere
zusätzliche Änderungen durchführen. Diese Änderungen können nun auch per
git commit
in einen oder mehrere Commits übertragen werden.
Möchten wir dabei für einen dieser Commits die ursprüngliche Commit-Message
verwenden, können wir git commit -c ORIG_HEAD
nutzen. Die Option -c
führt
dazu, dass die Commit-Message des spezifizieren Commits verwendet werden soll,
und ORIG_HEAD
zeigt in diesem Fall auf den Commit, den wir zum Ändern markiert
haben. Generell setzt Git den Zeiger ORIG_HEAD
bei fast allen Operationen, die
es als gefährlich einstuft, auf einen sicheren Stand, damit wir im Zweifel
dorthin zurückspringen können, ohne Schaden anzurichten.
Nachdem wir mit unseren neuen Commits zufrieden sind, lässt sich der Rebase mit
dem Befehl git rebase --continue
fortsetzen.
Bisect
Neben all den vorherigen Manipulationsmöglichkeiten wollen wir uns nun mit
bisect
noch ein Kommando anschauen, das hilfreich ist, ohne die Historie zu
manipulieren.
Auch wenn ich es gerne verneinen würde, gehören Fehler zur Softwareentwicklung dazu. Da Fehler dabei nicht immer sofort gefunden werden, kann es sinnvoll sein, herauszufinden, seit wann ein Fehler bestand bzw. um den Grund für den Fehler zu erfahren, mit welchem Commit er eingebaut wurde.
Um den Commit zu finden, können wir uns natürlich jeden möglichen Commit anschauen. Einschränken lassen sich die Möglichkeiten, wenn wir neben einem Commit, in dem der Fehler definitiv vorhanden ist, einen zweiten kennen, an dem der Fehler noch nicht vorhanden war. Wir müssen uns nun nur noch die Commits zwischen diesen beiden anschauen.
Um uns nicht wirklich jeden Commit anschauen zu müssen, können wir ein Suchverfahren anwenden, beispielsweise die Binärsuche. Bei dieser suchen wir uns jedes Mal die Mitte und prüfen, ob der Fehler dort vorhanden ist. Ist er an dieser Stelle vorhanden, muss er in der Hälfte zwischen dem aktuellen Punkt und dem identifizierten guten Stand eingebaut worden sein, wenn nicht in der anderen. Anschließend können wir in der halbierten Menge erneut die Mitte nehmen und nähern uns somit dem Fehler immer weiter an.
Um uns genau bei diesem Workflow zu unterstützen, besitzt Git das Kommando
bisect
. Die Verwendung kann, meiner Meinung nach, am besten anhand eines
Beispiels verstanden werden. Hierzu generieren wir uns mit dem Skript aus
Listing 1 über den Aufruf bash generate.sh bisect-example 42
ein
Git-Repository mit 101 Commits. Bis zum Commit mit der Nummer 43 enthält die
Datei a
dabei den Text a
. Im Commit mit der Nummer 43 wird der Text dann
allerdings auf b
geändert. Dies simuliert für unseren Fall den Bug.
Nachdem das Skript fertig ist, wechseln wir in das Verzeichnis bisect-example
und können nun git bisect
nutzen, um zu beweisen, dass in der Tat der Commit
mit der Nummer 43 den Fehler eingebaut hat. Wir wissen dabei, dass unser
aktueller Stand HEAD
den Bug enthält und der erste Commit des Repositories,
markiert mit dem Tag first-commit
, nicht. Genau dies teilen wir Git nun beim
Starten von bisect mit, indem wir git bisect start HEAD first-commit
aufrufen.
Git wechselt nun direkt zum mittleren Commit, damit wir unsere Suche starten können.
Ab jetzt können wir mit den Befehlen git bisect good
und git bisect bad
Git
jeweils mitteilen, ob der aktuelle Commit gut oder schlecht ist. Anschließend
wechselt Git sofort zum nächsten Kandidaten und wir müssen uns wieder
entscheiden. Listing 2 zeigt für unser Beispiel eine mögliche Session. Dabei
schauen wir uns mit cat a
den Inhalt der Datei an und teilen Git anschließend
mit, ob der Commit gut oder schlecht ist. Nach sechs Schritten teilt uns Git
mit, dass in der Tat der Commit mit der Nummer 43 den Fehler verursacht hat.
Können wir automatisiert feststellen, ob ein Commit fehlerhaft oder korrekt ist,
kann die gesamte Prozedur mit git bisect run
beschleunigt werden. Listing 3
zeigt, wie dies für diesen Fall mit einem Bash-Skript aussieht. Wie erwartet
kommt Git auch auf diesem Weg zum selben Ergebnis. Der manuelle Aufwand, den wir
haben, reduziert sich hierbei jedoch auf ein Minimum. Dieser Weg ist somit
deutlich schneller.
Fazit
Nachdem wir uns in der letzten Kolumne mit dem Einstieg in Git auf der Kommandozeile beschäftigt haben, knüpft diese Kolumne daran an und zeigt einige erweiterte Konzepte.
Ich hoffe, auch dieses Mal war für jeden etwas dabei, und freue mich, wie immer, über jegliches Feedback. Mit Sicherheit gibt es weitere faszinierende und hilfreiche Dinge für und mit Git, egal ob auf der Kommandozeile oder über ein grafisches Tool. Ich persönlich komme aber mit allen in dieser, und der letzten, Kolumne beschriebenen Dingen gut aus und vermisse nichts.