This blog post is also available in English

Git ist eines der grundlegenden Werkzeuge in jedem Software-Entwicklungsworkflow. Im Laufe der Jahre habe ich gelernt, wie ich Git so einsetzen kann, dass es meine Art zu denken und zu arbeiten mit Code unterstützt. Dabei möchte ich betonen, dass mein Workflow keineswegs der einzig richtige ist – und dass es einige Zeit gedauert hat, ihn zu entwickeln und mich damit wirklich wohlzufühlen. Git ist eine elegante Lösung für ein komplexes Problem: das Synchronisieren und Versionieren von Code über mehrere verteilte Systeme. Gleichzeitig ist Git nicht leicht zu erlernen – Fehler passieren schnell, und Frustration ebenso.

Viele der Schritte in meinem Workflow lassen sich übrigens auch mit einem Git-Client mit grafischer Oberfläche (GUI) statt über die Kommandozeile umsetzen. Ich selbst habe jahrelang mit einer GUI gearbeitet, weil es mir deutlich leichter fiel, Code interaktiv einem bestimmten Commit zuzuordnen. Erst als mir ein Kollege den Befehl git add -p zeigte, bin ich vollständig auf die Kommandozeile umgestiegen – und nutze sie seitdem für alles, was ich mit Git mache.

Kernphilosophie

Im Mittelpunkt meines Workflows steht die Idee, dass jeder einzelne Commit alle Änderungen enthalten sollte, die für eine bestimmte Aufgabe oder ein Feature notwendig sind – und dass es für mich jederzeit nachvollziehbar bleiben muss, was ich geändert habe. Das ist für mich eine Frage der Rücksicht gegenüber meinen Kolleg:innen, die meinen Code reviewen. Wenn ich selbst den von mir geschriebenen Code nicht verstehe, kann ich kaum erwarten, dass andere ihn ohne Mühe nachvollziehen.

Mein Workflow: Der Happy Path

  1. git checkout -b ticket-nr_feature-name: einen neuen Feature-Branch für die Arbeit beginnen, die ich erledigen möchte
  2. Das Feature implementieren und alle notwendigen Arbeiten durchführen
  3. git status: einen Überblick über die verschiedenen modifizierten Dateien erhalten
  4. git add -p: Änderungen interaktiv hinzufügen, indem ich mir die Frage stelle: „Gehört diese Änderung zu dem Feature, an dem ich gerade arbeite?"
  5. git diff --staged: alle Änderungen noch einmal in ihrer Gesamtheit überprüfen
  6. git commit: die Änderungen zusammen mit einer Beschreibung und meiner Begründung bündeln. Ich verwende dieselbe Beschreibung auch für meinen Merge Request, damit ich sie nur einmal schreiben muss.
  7. git fetch: holt alle Änderungen aus dem Remote-Repository
  8. git rebase origin/main: nimmt das Diff aller Änderungen, die ich in meinem Branch gemacht habe (der diesen einzelnen, in sich geschlossenen Commit enthält) und wendet sie auf den neuesten Code an
  9. git publish (ein Alias in meiner .gitconfig: publish = "!git push -u origin $(git branch-name)"): um meinen Feature-Branch im Remote-Repository zu veröffentlichen

Rebase vs. Merge

Vielleicht fällt in dieser Liste das völlige Fehlen von pull oder merge auf, die viele als Synonym für einen Git-Workflow betrachten würden. Das ist Absicht.

Ich verwende bewusst kein pull (das eine Kombination aus fetch und merge ist), weil ich es vorziehe, Änderungen abzurufen (fetch) und Änderungen in zwei separaten Schritten zu integrieren. Für die Integration meiner Änderungen habe ich zwei verschiedene Optionen: merge oder rebase.

# Ausgangspunkt (beide Branches zweigen vom gemeinsamen Vorgänger ab)

      A---B---C  (feature)
     /
D---E---F---G  (main)

# Nach git merge (erstellt einen Merge-Commit)

      A---B---C
     /         \
D---E---F---G---H  (main) ← H ist ein Merge-Commit, der C und G kombiniert

# Nach git rebase (spielt Feature-Änderungen auf main ab)

                  A'--B'--C'  (feature) ← Commits werden mit neuen Hashes abgespielt
                 /
D---E---F---G  (main)

Der Hauptunterschied zwischen den Methoden besteht darin, dass ein Merge einen zusätzlichen Commit erstellt, der die Änderungen aus den beiden verschiedenen Branches kombiniert (siehe Diagramm). Meiner Ansicht nach ist der Hauptvorteil (und möglicherweise einzige) dieses Ansatzes, dass man, wenn man zwei Branches zusammenführt, die beide mehrere Commits mit vielen verschiedenen Änderungen enthalten, alle Konflikte nur einmal lösen muss (im Merge-Commit).

In MEINEM Workflow konzentriere ich mich jedoch darauf, einen einzelnen verständlichen Commit in jedem meiner Feature-Branches zu erstellen. In diesem Fall bevorzuge ich die Verwendung von rebase, denn mit einem einzelnen Commit muss ich auch alle Konflikte nur einmal lösen und erreiche außerdem eine schöne lineare Git-Historie, die mir die Geschichte aller Änderungen in meinem Code erzählt, ohne unnötige „Merge branch into main„- oder „WIP – fix later”-Commit-Nachrichten dazwischen. Mein Fokus darauf, diesen Commit klein und verständlich zu halten, hilft auch dabei, mögliche Merge-Konflikte zu reduzieren (ich bin wahrscheinlich nicht so weit vom Main-Branch abgewichen) und sie einfacher zu beheben (da ich alle Änderungen in meinem Commit mental erfassen kann, ist es einfacher, sie in verschiedenen Kontexten erneut anzuwenden).

Ich möchte noch einmal betonen, dass dies meine persönlichen Vorlieben und mein eigener Workflow sind. In Projekten bin ich nicht dogmatisch, was gut dokumentierte Git-Commits oder eine lineare Git-Historie betrifft. Wenn das Team lieber merged, gehe ich gerne mit – auch wenn ich persönlich meine Branches immer auf main rebased. Ich hatte das Glück, bereits in Projekten mit gleichgesinnten Kolleg:innen zu arbeiten – und jedes einzelne git log dort hat mir tatsächlich Freude bereitet.

Der Not So Happy Path

Die zuvor aufgelisteten Schritte sind der Happy Path, wie ich ein Feature entwickeln würde, wenn alles genau nach Plan läuft. In der Realität passiert das selten.

Was passiert, wenn beim Rebase auf den Stand von main Merge-Konflikte auftreten? Wie bereits erwähnt, profitieren wir hier von der kleinen Commit-Größe. Weil der Commit klein ist und sein Inhalt verständlich bleibt, lässt sich in der Regel schnell erfassen, was zu tun ist, um den Code zu reparieren und wieder in Ordnung zu bringen.

Meinen lokalen Branch aktualisieren und pushen

Was mache ich, wenn mein Branch während des Code-Reviews veraltet und ich den Code aktualisieren möchte, damit er sich problemlos wieder in main mergen lässt? Hier rebase ich meinen Feature-Branch auf main und verwende dann git push --force-with-lease (mit dem praktischen git please-Alias), um ihn auf den Remote-Branch zu „force pushen". Ich force-pushe nur Branches, die ich selbst erstellt habe, denn wenn ich einen Branch force-pushe, an dem jemand anders arbeitet, muss diese Person wahrscheinlich Git-Power-Tools wie git reset, git cherry-pick oder möglicherweise sogar git reflog herausholen, um ihren Code mit main zu synchronisieren. Der Parameter --force-with-lease ist eine Sicherheitsvorkehrung, die prüft, ob es Änderungen an diesem Branch auf dem Remote-Server gibt, die ich noch nicht gesehen habe und die durch Force-Pushing überschrieben würden.

Nachträgliches Ändern eines Commits

Was passiert, wenn ich gerade alle meine Änderungen in einem schön dokumentierten Commit zusammengepackt habe, aber immer noch nicht mit dem Ergebnis zufrieden bin? In so einem Fall arbeite ich einfach weiter. Wenn ich mit dem Feintuning des Codes fertig bin, füge ich die neuen Änderungen iterativ hinzu und verwende dann git commit --amend, um sie dem vorherigen Commit hinzuzufügen und die Nachricht bei Bedarf anzupassen.

Fortschritt speichern, um Aufgaben zu wechseln

Was passiert, wenn ich beim Programmieren unterbrechen muss – zum Beispiel für ein Code Review oder ein Meeting? In diesem Fall erstelle ich normalerweise einen WIP-Commit (git commit -m "WIP") und komme später zurück. In der Praxis bedeutet das, dass ich oft einen Branch mit mehreren Commits habe, die ich dann zu einem einzigen Commit zusammenfassen möchte, wenn ich mit der Aufgabe fertig bin. Normalerweise schaue ich im Git-Log nach, um den ersten Commit in meinem Branch zu finden (ich verwende meinen Git-Alias git logf, der zu git log --graph --pretty --oneline --all aufgelöst wird, um die Git-Historie in einem hübschen Graph ähnlich einer GUI auszugeben). Sobald ich den Hash dieses Commits finde, verwende ich git rebase -i <hash>, um meine Commits interaktiv auf diesen ersten Commit zu rebasen. Bei der Verwendung des interaktiven Rebase wähle ich meistens fixup (und gelegentlich reword zum Ändern der Commit-Nachricht), um alles zu einem einzigen Commit zusammenzufassen. Wenn du das liest, fragst du dich vielleicht, warum ich mir überhaupt die Mühe mache, den Commit-Hash herauszusuchen, statt einfach direkt interaktiv auf origin/main zu rebasen. Der Grund ist, dass ich mir angewöhnt habe, meinen Feature-Branch zuerst zu squashen, bevor ich auf main rebase. Ich mache das lieber in dieser Reihenfolge, weil ich so erst das Squashing erledige und mich anschließend um mögliche Merge-Konflikte kümmern kann.

Hier ist ein Tipp, den ich selbst noch nicht oft verwendet habe: Ein Kollege hat mir gezeigt, dass man mit git commit --fixup <hash> direkt einen passenden Fixup-Commit erstellen kann, anstatt einen WIP-Commit zu verwenden. Dadurch lässt sich später mit git rebase -i --autosquash alles automatisch richtig zusammenführen, ohne die Commits manuell sortieren zu müssen. Ich finde das eine großartige Idee, habe sie bisher aber zu selten gebraucht, um mir die Syntax zu merken oder mir dafür einen Alias anzulegen. Vielleicht irgendwann.

Ein Stau von Branches

Eine Folge davon, meine Commits so klein zu halten, dass sie in meinen Kopf passen, ist, dass ich Merge Requests häufiger stellen kann. Wie bereits erwähnt, sehe ich das überwiegend positiv. Problematisch kann es allerdings werden, wenn du mit einem neuen Feature starten möchtest, das auf Code aus deinem ersten Branch basiert – bevor jemand anderes diesen ersten Branch reviewen konnte. Was tust du dann?

In so einem Fall erstelle ich in der Regel einen neuen Branch, der auf meinem bestehenden Feature-Branch statt auf main basiert. In diesem neuen Branch implementiere ich das neue Feature erneut in einem einzelnen Commit. Wenn ich in GitLab einen Merge Request anlege, richte ich ihn so ein, dass er in meinen Feature-Branch statt in main gemergt wird. So umfasst das Review nur den neuen Code, der für das zusätzliche Feature geschrieben wurde. Sobald der erste Feature-Branch gemergt ist, wechselt der Merge Request automatisch dazu, den zweiten Feature-Branch in main zu mergen.

Diese Situation ist etwas komplexer und birgt ein höheres Risiko, dass das Mergen schwieriger wird. Wenn der erste Branch noch Änderungen braucht oder rebased werden muss – was passiert dann mit dem zweiten? Wenn ein Rebase zu aufwendig wird, weil sich die Git-History zu stark verändert hat, kann ich, da der zweite Branch nur aus einem einzelnen Commit besteht, git cherry-pick verwenden, um genau diesen Commit auszuwählen und ihn in einen neuen Branch zu übernehmen, der auf main basiert. Das ist zweifellos fortgeschrittene Git-Gymnastik – aber auch hier zahlt sich wieder aus, dass die Branches klein und überschaubar bleiben.

Die Macht des Vergessens

Ein möglicher Kritikpunkt an diesem Ansatz ist, dass wir durch das Aufschieben von Commits, bis wir mit dem Ergebnis zufrieden sind, unsere Arbeitshistorie verlieren und nicht mehr so einfach zu früheren Zuständen zurückkehren können. Ich erkenne den Nutzen dieses Arguments – in der Praxis merke ich jedoch, dass es spürbaren mentalen Overhead verursacht, jede einzelne Änderung in einem eigenen Commit zu speichern. Dann muss ich mir nicht nur die Absicht hinter jeder Änderung merken, sondern auch, warum ich sie später wieder angepasst habe.

In der Praxis finde ich es sehr hilfreich, bewusst alles auszublenden, was nicht direkt mit der aktuellen Aufgabe zu tun hat. Das verschafft mir mentale Klarheit. Wenn ich später doch einen anderen Weg einschlage als ursprünglich geplant und das Ergebnis bereue, erinnere ich mich meist gut an meinen ersten Ansatz und kann ihn neu umsetzen – oft sogar besser als zuvor. Mein Gehirn scheint eine bemerkenswerte Fähigkeit zu haben, die weniger gelungenen Teile zu vergessen und die guten zu behalten.

Fazit

Wie bereits erwähnt, ist dies mein Ansatz zur Verwaltung meines persönlichen Workflows mit Git. Was die tatsächliche Implementierung betrifft, halte ich es nicht für notwendig, dass jeder ein Tool auf genau dieselbe Weise verwendet: Wichtig ist, dass du deinen Umgang mit den Tools so gestaltest, dass er zu deiner eigenen Denkweise passt. Die Verwendung von Rebase statt Merge kann zu einer schönen linearen Git-Historie führen, die leichter zu lesen ist. Wenn du darauf verzichtest, jede noch so kleine Änderung zu dokumentieren, schaffst du mentale Freiräume für die eigentliche Aufgabe. Der Fokus auf kleine, überschaubare Änderungen, die in einen einzigen Commit – und in deinen Kopf – passen, kann die Softwareentwicklung deutlich einfacher und effektiver machen.