Fit mit Git

Fragestellungen aus der Praxis mit Git, die zunächst verwirren, aber doch relativ leicht zu lösen sind… wenn man weiß wie.

Rebase (fast) ohne Schmerzen

Oha, während ich an meiner Story gearbeitet habe, hat die Kollegin in demselben Projekt gewerkelt… das wird nicht einfach, die zwei Branches zusammenzuführen. Ich will jetzt meinen Branch auf ihre Änderungen rebasen, da hole ich mir lieber noch einen großen Becher Kaffee.

Gibt es vielleicht Wege, das Zusammenführen zu vereinfachen? Klaro.

Wurzeln

Wer faul ist, squashed

Meine Kollegin und ich beginnen mit demselben Stand

$ mkdir one && cd one && git init
$ echo 0 > file.txt && git add file.txt && git commit -am "init"

Wir erzeugen auf diesem Stand zwei Branches:

$ git branch us
$ git branch them

Meine Kollegin ist fleißig auf dem Branch “them”

$ git checkout them
$ echo 1 > file.txt && git commit -am "them 1"
$ echo 2 > file.txt && git commit -am "them 2"
$ echo 3 > file.txt && git commit -am "them 3"
$ echo 4 > file.txt && git commit -am "them 4"

Ich arbeite auf dem Branch “us”

$ git checkout us
$ echo A > file.txt && git commit -am "us A"
$ echo B > file.txt && git commit -am "us B"
$ echo C > file.txt && git commit -am "us C"

Jetzt ist es Zeit, die Branches zusammenzuführen:

$ git checkout us
$ git rebase them

Auto-merging file.txt
CONFLICT (content): Merge conflict in file.txt
[...]
(REBASE 1/3)
$ cat file.txt 
<<<<<<< HEAD
4
=======
A
>>>>>>>

Wie wir an dem “REBASE 1/3” sehen, macht Git für jeden meiner commits einen eigenen Rebase-Schritt. Von “them” wird hingegen nur der letzte Stand verwendet. Also: Je mehr Commits ich auf meinem Branch gemacht habe, desto länger dauert der Rebase - deshalb spart es Arbeit, vorher die eigenen Commits zu squashen:

$ git rebase --abort
$ git rebase -i HEAD~3  # interactive rebase um meine commits zu squashen
pick a3389db us A
squash c817f69 us B
squash 9759ae3 us C

Da ich jetzt nur noch einen Commit mit allen Änderungen habe, hat auch der Rebase nur noch einen Schritt:

$ git rebase them
Auto-merging file.txt
CONFLICT (content): Merge conflict in file.txt
[...]
(REBASE 1/1)
$ cat file.txt 
<<<<<<< HEAD
4
=======
C
>>>>>>>

Deine Vergangenheit… hat sich geändert?

Diesmal hat meine Kollegin bereits etwas auf dem Branch entwickelt, und ich baue darauf meinen Branch auf.

Dasselbe Setup:

$ cd .. && mkdir two && cd two && git init
$ echo 0 > file.txt && git add file.txt && git commit -am "init"

Meine Kollegin ist fleißig auf dem Branch “them”:

$ git checkout -b them
$ echo 1 > file.txt && git commit -am "them 1"
$ echo 2 > file.txt && git commit -am "them 2"

Auf diesem Stand erzeuge ich mir einen eigenen Branch, d.h. ihre beiden Commits nehme ich mit. Dann ändere ich eine ganz andere Datei.

$ git checkout -b us
$ echo 1 > file2.txt && git add file2.txt && git commit -am "us added file2"

Derweil modifiziert meine Kollegin unsere gemeinsamen Commits - sie squashed sie zusammen. Grundsätzlich ist das ein Antipattern, “public history” zu squashen; gleichzeitig ist es in vielen Projekten gängige Praxis, am Ende der Arbeit einen Merge-Request zu squashen, wenn er auf Main gemerged wird (um die History auf Main überschaubar zu halten). Wir sehen gleich, welche Nachteile das haben kann.

$ git checkout them
$ git rebase -i HEAD~2
pick 585fd0c them 1
squash a64b3d4 them 2

Zusammengefasst: Ich habe beide Commits meiner Kollegin übernommen, und nur an einer ganz anderen Datei etwas geändert. Da die Commits bei ihr aber modifiziert wurden, bekomme ich trotzdem Probleme beim Rebase:

$ git checkout us
$ git rebase them 
Auto-merging file.txt
CONFLICT (content): Merge conflict in file.txt
[...]
(REBASE 1/3)

Schlau ist es, vor dem Rebase auf meiner Seite die Commits zu droppen, die sowieso schon auf ihrer Seite vorhanden sind.

$ git rebase --abort
$ git rebase -i HEAD~3
drop 442c393 them 1 
drop 5bf73c4 them 2
pick a3c7ae9 us added file2

Danach kann ich ohne Schmerzen meine Änderungen hinzufügen:

$ git rebase them
Successfully rebased and updated refs/heads/us.

Die verschwundenen Historie

Bahngleis ohne Vergangenheit

Wieder ein schönes Refactoring, ein paar Klassen umbenannt, hier und da Code geändert, Tests sind grün, alles fertig zum Commit… doch was ist das?

Anstatt zu erkennen, dass ich eine Datei umbenannt habe, denkt Git, ich hätte eine Datei gelöscht und eine andere hinzugefügt. Blöd: Dadurch geht die gesamte Datei-Historie verloren, weil der Zusammenhang zu den früheren Versionen fehlt. Ein Diff der Versionen ist nicht mehr möglich.

Was ist hier passiert, und was kann man tun? Schauen wir es uns an.

Eine Datei mit etwas Code:

$ cd .. && mkdir three && cd three && git init
$ echo "some code" > file.txt 
$ git add -A  # alle Änderungen stagen
$ git commit -am "inital setup"

Wir benennen die Datei um, noch ist alles gut:

$ mv file.txt renamed_file.txt
$ git add -A && git status
  renamed:    file.txt -> renamed_file.txt

Wenn wir jetzt noch den Code ändern, beginnt das Schlamassel:

$ echo "some other code" > renamed_file.txt 
$ git add -A && git status
  new file:   renamed_file.txt
  deleted:    file.txt

Git benutzt eine Heuristik, die die Inhalte vergleicht, und wenn sich zuviel ändert, greift diese nicht mehr: dann erkennt Git nicht, dass es sich um dieselbe Datei handelt.

Abhilfe schafft das Aufteilen der Änderungen in zwei Commits. Die etwas hemdsärmelige Lösung, das geht zum Glück auch nachträglich ganz gut:

Wir machen die Umbenennungen rückgängig (aber nicht die Inhalte) und committen das:

$ mv renamed_file.txt file.txt
$ git add -A && git status
  modified:   file.txt
$ git commit -am "Refactoring Part I"

Als zweites committen wir nur die Umbenennungen:

$ mv file.txt renamed_file.txt
$ git add -A && git status
  renamed:    file.txt -> renamed_file.txt
$ git commit -am "Refactoring Part II (renamed)"

Dadurch, dass wir die Änderungen in zwei Commits geteilt haben, behält Git die Versionshistorie. Achtung: Falls die Commits später ge-squashed werden - etwa beim Mergen auf Main - solltet ihr noch einmal kontrollieren, dass trotz des Squashens die History erhalten bleibt.

TL;DR

  • Vor dem Rebase die eigenen Commits squashen, um den Rebase zu verkürzen.
  • Vor dem Rebase im eigenen Branch Commits droppen, die im anderen Branch schon enthalten (und eventuell modifiziert) sind.
  • Bei Verlust der Historie: Umbenennungen von Dateien getrennt commiten von Änderungen am Datei-Inhalt
  • Wir haben gesehen: Squashen ist ein zweischneidiges Schwert

Kommentare