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.

Abb. 1: Anzeige von signierten Commits bei GitHub

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.

Abb. 2: Stand im Remote Repository
Abb. 3: Lokaler Stand von Alice
Abb. 4: Lokaler Stand von Bob

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.

Abb. 5: Stand von Alice nach der Manipulation
Abb. 6: Stand bei Bob nach der Synchronisierung

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.

Abb. 7: Editor beim Start von einem interaktiven Rebase

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.

#!/bin/bash

mkdir -p $1
cd $1

git init

for i in {0..100}; do
  if [[ i -gt $2 ]]; then
    echo "b" > a
  else
    echo "a" > a
  fi
  git add a
  git commit -m "Commit #$i" --allow-empty
  if [[ i -eq 0 ]]; then
    git tag first-commit
  fi
done
Listing 1: Skript zur Generierung eines Repositories mit vielen Commits

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.

/tmp $ ./generate.sh bisect-example 42
/tmp $ cd bisect-example
/tmp/bisect-example $ git bisect start HEAD first-commit
Bisecting: 49 revisions left to test after this (roughly 6 steps)
[b5ac6157c36b9282c12201d3582a644d4cff5512] Commit #50
/tmp/bisect-example $ cat a
b
/tmp/bisect-example $ git bisect bad
Bisecting: 24 revisions left to test after this (roughly 5 steps)
[244c06abe583498adb4e6d981d6a8c989618c534] Commit #25
/tmp/bisect-example $ cat a
a
/tmp/bisect-example $ git bisect good
Bisecting: 12 revisions left to test after this (roughly 4 steps)
[9303de267dad72b0227e6b4e18de3c886fb63df7] Commit #37
/tmp/bisect-example $ cat a
a
/tmp/bisect-example $ git bisect good
Bisecting: 6 revisions left to test after this (roughly 3 steps)
[f854fa9c4a1f2fecf0e756bcb85b454b2cb1a535] Commit #43
/tmp/bisect-example $ cat a
b
/tmp/bisect-example $ git bisect bad
Bisecting: 2 revisions left to test after this (roughly 2 steps)
[9ebb4ce02fa8d8d7ee6e4c98da6b98f6130423f1] Commit #40
/tmp/bisect-example $ cat a
a
/tmp/bisect-example $ git bisect good
Bisecting: 0 revisions left to test after this (roughly 1 step)
[0e60354a6bfcc0dfaac57c7fce7b6feb359fcb11] Commit #42
/tmp/bisect-example $ cat a
a
/tmp/bisect-example $ git bisect good
f854fa9c4a1f2fecf0e756bcb85b454b2cb1a535 is the first bad commit
commit f854fa9c4a1f2fecf0e756bcb85b454b2cb1a535
Author: Michael Vitz <[email protected]>
Date: Sun Oct 25 16:45:14 2020 +0100

    Commit #43

 a | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
Listing 2: Manuelle Fehlersuche mit git bisect

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.

/tmp/bisect-example $ cat test.sh
#!/bin/bash

test $(cat a) == 'a'
/tmp/bisect-example $ git bisect start HEAD first-commit
Bisecting: 49 revisions left to test after this (roughly 6 steps)
[b5ac6157c36b9282c12201d3582a644d4cff5512] Commit #50
/tmp/bisect-example $ git bisect run ./test.sh
running ./test.sh
Bisecting: 24 revisions left to test after this (roughly 5 steps)
[244c06abe583498adb4e6d981d6a8c989618c534] Commit #25
running ./test.sh
Bisecting: 12 revisions left to test after this (roughly 4 steps)
[9303de267dad72b0227e6b4e18de3c886fb63df7] Commit #37
running ./test.sh
Bisecting: 6 revisions left to test after this (roughly 3 steps)
[f854fa9c4a1f2fecf0e756bcb85b454b2cb1a535] Commit #43
running ./test.sh
Bisecting: 2 revisions left to test after this (roughly 2 steps)
[9ebb4ce02fa8d8d7ee6e4c98da6b98f6130423f1] Commit #40
running ./test.sh
Bisecting: 0 revisions left to test after this (roughly 1 step)
[0e60354a6bfcc0dfaac57c7fce7b6feb359fcb11] Commit #42
running ./test.sh
f854fa9c4a1f2fecf0e756bcb85b454b2cb1a535 is the first bad commit
commit f854fa9c4a1f2fecf0e756bcb85b454b2cb1a535
Author: Michael Vitz <[email protected]>
Date: Sun Oct 25 16:45:14 2020 +0100

    Commit #43
 a | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
bisect run success
Listing 3: Automatische Fehlersuche mit git bisect

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.