Versionieren mit unterschiedlichen Zweigen (Branches)

https://bildung.social/@oerinformatik/110326723919640390

https://oer-informatik.de/git02-versionieren_mit_branches

tl/dr; (ca. 30 min Lesezeit): Ein großer Vorteil von git ist, dass wir zeitgleich an unterschiedlichen Komponenten eines Projekts arbeiten können, und diese Entwicklungen zu definierten Zeitpunkten wieder zusammenführen können. Diese Entwicklung in Branches minimiert Seiteneffekte, fordert aber etwas Grips… Neben diesem Teil gibt es noch die beiden Tutorialfolgen Ein git-Repository anlegen und lokal commiten und Git Remote nutzen. (Zuletzt geändert am 27.05.2023)

Teile und herrsche: Entwickeln in Zweigen

Bislang haben wir immer im selben Zweig (main / früher oft: master) unserer Entwicklung gearbeitet.

On branch main [...]

Das ist jedoch nicht praktikabel, wenn wir zeitgleich an unterschiedlichen Features arbeiten (oder unterschiedliche Entwickler am gleichen Projekt) und wir beständig mit den Seiteneffekten der anderen Entwicklungen kämpfen müssen. Daher ist es sinnvoll, zu definierten Zeitpunkten Schnittstellen zu schaffen - und zwischen diesen Schnittstellen relativ frei von Abhängigkeiten zu anderen Features/Projektbeteiligten arbeiten zu können.

Einen neuen Zweig/Branch anlegen

Man zweigt also mit git an einem bestimmten Punkt die Entwicklung ab und erstellt einen neuen Zweig (Branch) mit dem Befehl git branch branchname. In diesem Branch können wir eine neue Funktionalität entwickeln und diese erst wenn sie fertiggestellt ist wieder in das Hauptprojket integrieren. Wir wollen die Begrüßung der hallo.py als Feature in eine Python-Funktion auslagern.

Allerdings haben wir den Branch bislang nur erzeugt, als aktiven Branch müssen wir ihn noch auswählen (wie sich schnell mit git status überprüfen lässt).

Switched to branch 'feature/Begruessung'

Abkürzung: einen Branch erzeugen und direkt auswählen kann man mit git checkout -b branchName

Als neues Feature wird jetzt die Methode implementiert:

… und die Änderungen ins Repository übernommen:

Gut. Aber der Name sollte als Parameter an die neue Methode übergeben werden, damit er auch zur Verabschiedung genutzt werden kann.

O.k. So könnte es klappen. Die neuste Anpassung muss noch ins Repository übernommen werden:

Gut. Wenn das geklappt hat, sollten wir im Log die beiden neuen Commits als Bestandteil des Zweigs feature/Begruessung sehen:

46a682f (HEAD -> feature/Begruessung) refactor: add param name for func begruessung()
fb45c39 refactor: extract func begruessung()
896a1da (main) docs: add readme
7a910bf new feature: say Good-bye
d04664e new feature: say 'Hello NAME'

Das scheint geklappt zu haben: HEAD zeigt auf den neusten Commit (der dem Zweig feature/Begruessung zugeordnet ist). der main-Zweig endet momentan bei Commit 896a.

Ein zweites Feature anlegen

Parallel zur Entwicklung der Begrüßung soll auch die Verabschiedung angepasst werden. Ausgangspunkt ist auch hier der main-Zweig. Zunächst müssen wir diesen Zweig wieder auswählen:

Switched to branch 'main'

Aber: Hoppla- was ist jetzt passiert? Die Änderungen mit der neuen Funktion sind auf einmal weg?!? Offensichtlich passt git beim Checkout auch den Workspace an.

Wir erstellen zunächst einen neuen Branch und wählen ihn auch direkt als aktiven Commit aus (Kurzschreibweise):

Jetzt müssen wir noch das neue Feature implementieren: eine Funktion zur Verabschiedung in der hallo.py:

Die Änderungen müssen noch ins Repository übertragen werden:

Zwei Branches mergen

Wir haben jetzt im Ganzen drei Branches: zwei neue Featurebranches an den “Enden” des git-Baums und der main-Branch, der an der Verzweigung steht, aktiv ausgewählt ist derzeit feature/Verabschiedung (HEAD):

Der git-Baum mit den beiden Feature-Branches und main an der Verzweigung
Der git-Baum mit den beiden Feature-Branches und main an der Verzweigung

Etwas weniger plastisch lässt sich dieser git-Baum auch mit git log anzeigen.

feature/Verabschiedung ist aktuell ausgewählt (HEAD), feature/Begruessung steht bei 46a6 und main noch bei 896a.

* 529594d (HEAD -> feature/Verabschiedung) refactor: extract func tschuess()
| * 46a682f (feature/Begruessung) refactor: add param name for func begruessung()
| * fb45c39 refactor: extract func begruessung()
|/
* 896a1da (main) docs: add readme
* 7a910bf new feature: say Good-bye
* d04664e new feature: say 'Hello NAME'

Wir wollen jetzt zunächst feature/Verabschiedung und main zusammenführen. Dazu wechseln wir nach main und mergen mit dem Branch feature/Verabschiedung

Da main keine weiteren Änderungen enthält, werden einfach die Änderungen des Feature-Branches übernommen. Im Graphen kann man erkennen, dass main jetzt nicht mehr an der Abzweigung steht, sondern am Ende des Zweiges:

Merge aus main und feature/Verabschiedung - main steht jetzt am Ende des Zweigs
Merge aus main und feature/Verabschiedung - main steht jetzt am Ende des Zweigs

Beim Mergen werden die Änderungen des als Argument genannten Branches (im Beispiel: feature/Verabschiedung) in den aktuell ausgewählten Branch (im Beispiel main) übernommen. Wir bleiben dabei im aktuellen Branch (main), der gemergte Branch zeigt weiterhin auf denselben letzten Commit dieses Branches wie vor dem Mergen.

Merge-Konflikte lösen

Im nächsten Schritt mergen wir jetzt das zweite Feature. Dazu müssen wir main ausgecheckt haben (ist momentan der Fall, andernfalls: git checkout main). Danach wird analog zu oben der Branch feature/Begruessung mit dem aktuell ausgewählten (main) vereint:

Das geht nicht so reibungslos. git kann nicht eindeutig ermitteln, welche der neuen Varianten behalten werden soll:

Auto-merging hallo.py
CONFLICT (content): Merge conflict in hallo.py
Automatic merge failed; fix conflicts and then commit the result.

Das passiert häufig dann, wenn Änderungen in unterschiedlichen Zweigen die selbe Zeile betreffen, oder wenn die Änderungen bezogen auf den Gesamtumfang zu groß sind, um die Position für die Einfügung zu bestimmen. Wir müssen also händisch ran.

Git befindet sich jetzt im merge-Modus - zu erkennen u.a. an der Eingabeaufforderungsmeldung (|merge). Im merge-Modus sind alle Dateien noch gestaged, die sich problemlos mergen lassen - für alle anderen sind drei Schritte erforderlich:

  1. Der Konflikt muss behoben werden, bevor weitergearbeitet werden kann.

  2. Die Dateien, bei denen es zu Konflikten kam, müssen per git add hinzugefügt werden.

  3. Es muss per git commit der merge-commit erzeugt werden.

Dazu öffnen wir die Datei, die Konflikte verursacht hat, im Editor:

An allen Stellen, die git nicht selbst lösen konnte, werden die beiden Varianten zwischen spitzen Klammern (<<<, >>>) getrennt durch Gleichheitszeichen (===) nach dem folgenden Muster dargestellt:

Nachdem im Editor der problematische Bereich angepasst wurde, muss die geänderte Datei noch auf die Stage, um dann ins Repository übernommen zu werden.

Im neuen Graphen ist der HEAD (der aktuelle Commit) auf dem Merge-Commit des main-Branches, die anderen Branches enden jeweils mit dem letzten Commit vor der Zusammenführung:

Merge aus main und beiden Features - main steht hinter der Vereinigung der Branches
Merge aus main und beiden Features - main steht hinter der Vereinigung der Branches

Neben dieser manuellen Konfliktlösung kann auch ein merge-tool genutzt werden, dazu sind folgende Schritte erforderlich:

  • Ein Merge-Tool muss herausgesucht und installiert werden, potenzielle Kandidaten finden sich in der Liste git mergetool --tool-help. (vimdiff ist i.d.R. installiert, das ist aber gewöhnungsbedürftig).

  • Mit dem Tool der Wahl muss dann der Konflikt gelöst werden. Beispielsweise: git mergetool --tool=p4merge

  • Sofern das Tool das nicht macht, muss doch der manuelle Weg gewählt werden: Anpassen per git add ...; git commit...

  • Die Dateien mit dem Suffix .orig müssen dann ggf. noch entsorgt werden.

Falls irrtümlich ein merge begonnen wurde, kann dieser auch abgebrochen werden mit git merge --abort.

Rebase

Um in den aktiven Branch die Änderungen aus einem anderen Branch zu integrieren und einen linearen Verlauf zu erhalten, gibt es eine zweite Variante der Zusammenführung: Rebase:

Die Änderungen des zweiten Branchs werden in die lineare Struktur des aktiven Branchs integriert, dazu werden neue Commits erstellt, die die bisherigen Commits des aktiven Branchs und des zweiten Branches vereinigen. Die Commits des zweiten Branches bleiben unverändert vorhanden.

Wir ergänzen beispielhaft im Quelltext eine Funktion zur Zeitansage:

[feature/time eb2b14b] feat: add timeinfo
 1 file changed, 6 insertions(+)

Es wurden weitere Änderungen am Text vorgenommen (“Uhr” wurde ergänzt usw.). Das Ergebnis wird ebenso im Branch feature/time angefügt.

[feature/time 3a847cd] feat: correct typos
 1 file changed, 1 insertion(+), 1 deletion(-)

Anpassungen in main vornehmen

Unterdessen wurde ein Tippfehler in main entdeckt: es soll nach dem Vornamen, nicht nach dem Namen gefragt werden. Die Änderung soll direkt im Branch main erfolgen. Dazu muss dieser zunächst wieder geladen werden (Achtung: die Dateien um Dateisystem ändern sich mit diesem Befehl):

Switched to branch 'main'

Änderung in der Python-Datei vornehmen, z.B. diese Zeile anpassen:

[main 6f9bb56] fix: ask politely for surname instead name
 1 file changed, 1 insertion(+), 1 deletion(-)

Noch eine weitere Änderung in der Datei anpassen, damit der git-Graph interessanter wird, z.B. die Zeile in hallo.py ändern zu:

Danach die gespeicherte Datei über die Stage in Repository übernehmen.

Änderungen in main und feature/time als Vorbereitung für einen Rebase
Änderungen in main und feature/time als Vorbereitung für einen Rebase

Jetzt kommt das Zauberwerk: die Änderungen in C (also B und C) sollen auf den Änderungen in E (also D und E) ausgeführt werden. Dazu müssen wir zunächst in den feature/time-Branch wechseln und dort ein Verschieben der Änderungen (rebase) ausführen:

Wenn jetzt alles automatisch vereinigt werden kann, dann ist alles wunderbar. Sollte es aber zu Konflikten kommen, so müssen diese wieder (wie bei merge auch) manuell oder mit Tools gelöst werden.

Auto-merging hallo.py
CONFLICT (content): Merge conflict in hallo.py
error: could not apply eb2b14b... feat: add timeinfo
hint: Resolve all conflicts manually, mark them as resolved with
hint: "git add/rm <conflicted_files>", then run "git rebase --continue".
hint: You can instead skip this commit: run "git rebase --skip".
hint: To abort and get back to the state before "git rebase", run "git rebase --abort".
Could not apply eb2b14b... feat: add timeinfo

Analog zu merge können hier nicht alle Probleme automatisch gelöst werden, hallo.py macht Probleme. Wir öffnen die Datei im Editor: die beiden Varianten sind wie oben beim merge- Konflikt zwischen <<<<<<<, ======= und >>>>>>> gekennzeichnet.

Im Editor löschen wir die überflüssigen Zeilen und Markierungen, speichern die Datei und melden Sie auf der Stage an:

Ein Editor öffnet sich für die Commit-Nachricht. Wenn der Editor geschlossen ist, wird der Rebase abgeschlossen und es erscheint folgende Nachricht:

[detached HEAD 2741736] feat: add timeinfo
 1 file changed, 8 insertions(+), 1 deletion(-)
Successfully rebased and updated refs/heads/feature/time.

Hätte es beim Rebasen keine Konflikte gegeben, dann wäre in etwa eine solche Nachricht erschienen:

First, rewinding head to replay your work on top of it...
Fast-forwarded main to feature/time.

Das Ergebnis ist schon gar nicht schlecht, aber eben noch nicht fertig:

Der git-Graph nach dem Rebase: die feature-Commits sind dem main angehängt, die beiden feature/time-commits werden mit git log eigentlich nicht mehr angezeigt
Der git-Graph nach dem Rebase: die feature-Commits sind dem main angehängt, die beiden feature/time-commits werden mit git log eigentlich nicht mehr angezeigt

Die Änderungen der Commits B und C aus dem feature/time-Branch wurden auf den letzten Commit von main angewendet. Die dadurch entstehenden neuen Commits wurden hier oben als B' und C' genannt, da sie ja die gleichen Änderungen wie B und C enthalten. C' ist der aktuelle Commit im Workspace, B' und weitere Vorgänger von C' aus dem feature/time-Branch haben bisher niemals auf irgendeinem System existiert - sind also ggf. nicht lauffähig, weil dieser Commit auch nie getestet wurde. Das ist eine Besonderheit bei der Nutzung von rebase. Die beiden vorigen Commits B und C werden von git nicht mehr angezeigt und aus dem Commit-Graphen entfernt:

Der git-Graph nach dem Rebase: die feature-Commits sind dem main angehängt, die beiden feature/time-commits werden mit git log eigentlich nicht mehr angezeigt
Der git-Graph nach dem Rebase: die feature-Commits sind dem main angehängt, die beiden feature/time-commits werden mit git log eigentlich nicht mehr angezeigt

Wenn man genau hinsieht, erkennt man, das bisher main noch auf den Commit E zeigt. Wir müssen das mit einem fast forward merge noch ändern. Wir wählen main aus:

Switched to branch 'main'

und mergen main mit feature/time:

Updating 3a9a2e5..20f901f
Fast-forward
 hallo.py | 9 ++++++++-
 1 file changed, 8 insertions(+), 1 deletion(-)

Im Ergebnis erhalten wir eine wirklich lineare Historie:

Der git-Graph nach dem Rebase: der main wurde wieder ans Ende gestellt per fast forward
Der git-Graph nach dem Rebase: der main wurde wieder ans Ende gestellt per fast forward

Beim Rebasen werden die Änderungen des aktuellen Branches (im Beispiel: feature/time) an den als Argument genannten Branch (im Beispiel main) angehängt. Wir bleiben dabei im aktuellen Branch (feature/time). Der als Argument genannte Branch zeigt weiterhin auf den letzten Commit dieses Branches und muss ggf. noch mit einem Fast-Forward-Merge nachgezogen werden.

Gezielt einzelne Commits als aktuell auswählen

In der Regel wird jeder neue Commit an das Ende des jeweiligen Branches gehängt. Man nennt den Zeiger auf den jeweils aktuellen Commit den HEAD. Analog zum Auswählen eines aktuellen Branches mit git checkout branchname (was HEAD auf den jeweils aktuellsten Commit des Branches setzt) können wir auch HEAD auf einzelne Commits setzen, in dem wir diese über deren Hashcode ansprechen.

Wo befindet sich aktuell der HEAD? git log verrät es uns (hier mit gekürzten Hashes):

20f901f (HEAD -> main, feature/time) feat: correct typos
2741736 feat: add timeinfo
3a9a2e5 ask very politely for surname instead name
6f9bb56 fix: ask politely for surname instead name
2ba714d feat: integrate two features

HEAD hängt also derzeit am main und würde mit ihm wandern.

Den HEAD umsetzen auf einen anderen Commit setzen erfolgt über den (ggf. gekürzten) Hashwert:

Note: switching to '3a9a2e5'.

You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by switching back to a branch.

If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -c with the switch command. Example:

  git switch -c <new-branch-name>

Or undo this operation with:

  git switch -

Turn off this advice by setting config variable advice.detachedHead to false

HEAD is now at 3a9a2e5 ask very politely for surname instead name

Git warnt explizit, dass jetzt die Dinge etwas anders funktionieren…

Die per git log sichtbare Historie hat sich stark verjüngt, außerdem zeigt HEAD nicht mehr auf den main, sondern ist nur dem Commit angehängt:

3a9a2e5 (HEAD) ask very politely for surname instead name
6f9bb56 fix: ask politely for surname instead name
2ba714d feat: integrate two features

Keine Angst, die anderen Commits sind nicht weg. Mit der Option --all zeigt git log die auch wieder an.

Der HEAD kann jederzeit (und sollte) wieder auf das Ende eines Branches gelegt werden.

Previous HEAD position was 3a9a2e5 ask very politely for surname instead name
Switched to branch 'main'

HEAD zeigt damit wieder auf den main

20f901f (HEAD -> main, feature/time) feat: correct typos
2741736 feat: add timeinfo
3a9a2e5 ask very politely for surname instead name
6f9bb56 fix: ask politely for surname instead name
2ba714d feat: integrate two features

In git kann man sich im Versionsbaum relativ und absolut bewegen. Mit dem Caret-Zeichen ^ (Zirkumflex) wird der direkte Vorgänger adressiert:

Für eine bestimmte Anzahl, die der Commit-Tree Richtung Wurzel durchschritten werden soll, kann die Tilde kombiniert mit einer Zahl angegeben werden. Drei Schritte zurück also z.B. mit:

Branches über relative Referenzen bewegen

Sofern ein Branch verschoben werden soll, kann dies erfolgen über:

Änderungen rückgängig machen

Um lokal den Vorgänger wieder herzustellen und den Branch auf den Vorgänger zu legen tippen wir ein:

Sofern aber andere bereits mit dem so gelöschten Commit arbeiten ist dieser nicht aus der Historie verschwunden und wird ggf. später wieder referenziert. Besser geeignet ist hier die Methode, mit einem weiteren Commit die Änderungen des/der vorigen Commits ungeschehen zu machen:

Cherry Picking

Um Änderungen aus einem anderen branch zu übernehmen, kann man gezielt eine Reihe von Commits auswählen, und deren Änderungen in den aktuellen Branch übernehmen. Hierfür ist natürlich wichtig, dass in den Commits sauber gearbeitet wurden und die Commit-Messages aussagekräftig sind (und alles beschreiben, was der Commit macht), damit nicht irgendwelche unvorhergesehenen Seiteneffekte auftreten.

Fazit

Zugegeben: Branches sind ein relativ knackiger Teil der git-Welt, gerade als Anfänger*in verrennt man sich da häufig etwas. Hinzu kommt, dass jedes Unternehmen seinen eigenen Workflow hat - und es somit keine einheitliche Aufteilung der Branches gibt. Wer aber den Unterschied zwischen merge und rebase schonmal verstanden hat ist gut gewappnet, auch die weiteren Irrungen und Wirrungen zu meistern, die die Versionsverwaltung bereithält.

Teile dieses Tutorials

Weitere Literatur zur Versionsverwaltung git

  • Zentrale Seite für Übungen zum Branching: Learn git branching: animiertes Tutorial v.a. gut, um die Navigation im git-Tree zu verstehen.

  • Wie würde git klingen, wenn es Musik wäre? re:bass von Dylan Beattie

  • als Primärquelle: die git-Dokumentation - lässt sich im Terminal mit git --help aufrufen oder unter https://git-scm.com/doc

  • Als Info v.a. zu zentralen Workflows mit git: René Preißel / Bjørn Stachmann: Git (dPunkt Verlag, ISBN Print: 978-3-86490-649-7

  • Aus der “von Kopf bis Fuß”-Serie von O’Reilly - erfrischend anders: “Head First Git” von Raju Gandhi (englisch), ISBN: 9781492092513

  • Zahllose Tutorials im Internet wie z.B. dieses von Atlassian


Hinweis zur Nachnutzung als Open Educational Resource (OER)

Dieser Artikel und seine Texte, Bilder, Grafiken, Code und sonstiger Inhalt sind - sofern nicht anders angegeben - lizenziert unter CC BY 4.0. Nennung gemäß TULLU-Regel bitte wie folgt: Versionieren mit unterschiedlichen Zweigen (Branches)” von oer-informatik.de (H. Stein), Lizenz: CC BY 4.0. Der Artikel wurde unter https://oer-informatik.de/git02-versionieren_mit_branches veröffentlicht, die Quelltexte sind in weiterverarbeitbarer Form verfügbar im Repository unter https://gitlab.com/oer-informatik/devoptools/git. Stand: 27.05.2023.

[Kommentare zum Artikel lesen, schreiben] / [Artikel teilen] / [gitlab-Issue zum Artikel schreiben]

Kommentare gerne per Mastodon, Verbesserungsvorschläge per gitlab issue (siehe oben). Beitrag teilen: