14 Merging-Strategien

Nachdem wir verstanden haben, wie Branches organisatorisch genutzt werden, betrachten wir nun die technischen Mechanismen, mit denen Änderungen zwischen Branches integriert werden. Die Wahl der Merge-Strategie hat fundamentale Auswirkungen auf die Struktur der Repository-Historie – und damit auf die Lesbarkeit, Wartbarkeit und praktische Nutzbarkeit des Projekts.

Es gibt drei primäre Merge-Strategien: Fast-Forward, Merge Commit und Squash Merge. Dazu kommt Rebase als alternative Integration-Methode. Jede dieser Strategien erzeugt eine andere Commit-Graph-Struktur, und die Entscheidung zwischen ihnen ist nicht nur technisch, sondern auch philosophisch: Wie wichtig ist eine vollständige, unveränderliche Historie versus eine saubere, lesbare Darstellung der Entwicklung?

Bevor wir in die Strategien eintauchen, ein fundamentales Konzept, das in allen Git-Operationen relevant ist: Wie adressiert man Commits?

14.1 Commit-Addressierung: Das fundamentale Navigationssystem

In Git existieren viele Wege, auf einen Commit zu verweisen. Diese Addressierungsmechanismen sind nicht spezifisch für Merge, Rebase oder andere Operationen – sie sind ein grundlegendes Feature, das überall in Git funktioniert.

14.1.1 Absolute Addressierung: Hashes, Branches, Tags

SHA-1 Hash (vollständig): Die eindeutigste Referenz.

git show 7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6

SHA-1 Hash (verkürzt): Git akzeptiert Präfixe, solange sie eindeutig sind (meist 7-8 Zeichen).

git show 7e8f9a0

Branch-Name: Verweist auf den Commit, auf den der Branch zeigt.

git show main
git log feature/new-ui

Tag-Name: Verweist auf den getaggten Commit.

git show v1.2.0
git diff v1.1.0 v1.2.0

HEAD: Der aktuelle Commit (bzw. der Commit, auf den der aktuelle Branch zeigt).

git show HEAD
git diff HEAD

14.1.2 Relative Addressierung: Navigation im Graph

Git bietet zwei Operatoren für relative Referenzen: ^ (Caret) und ~ (Tilde). Sie navigieren durch den Commit-Graph, haben aber unterschiedliche Semantik.

14.1.2.1 Der ^ Operator: Parent-Selektion

^ wählt Parents aus. Bei Commits mit einem Parent ist ^ simpel:

HEAD^   # Der Parent von HEAD (ein Commit zurück)
HEAD^^  # Der Parent des Parents (zwei Commits zurück)
HEAD^^^ # Drei Commits zurück

Bei Merge-Commits (mit mehreren Parents) wird ^ interessant:

HEAD^1  # Erster Parent (explizit)
HEAD^2  # Zweiter Parent
HEAD^3  # Dritter Parent (bei Octopus-Merges)

Ohne Zahl bedeutet ^ immer den ersten Parent: HEAD^ = HEAD^1.

Beispiel:

# Zeige den Commit, der gemerged wurde (zweiter Parent)
git show HEAD^2

# Zeige den Commit vor dem gemergten Commit
git show HEAD^2^

# Kombinationen sind möglich
git show HEAD^1^2  # Erster Parent, dann dessen zweiter Parent

14.1.2.2 Der ~ Operator: Lineare Generationen

~ folgt immer dem ersten Parent und zählt Generationen:

HEAD~1  # Ein Commit zurück (= HEAD^)
HEAD~2  # Zwei Commits zurück (= HEAD^^)
HEAD~5  # Fünf Commits zurück (= HEAD^^^^^)

~ ist praktischer für “N Commits zurück” ohne sich um Merge-Struktur zu kümmern:

# Letzten 3 Commits anzeigen
git log HEAD~3..HEAD

# Diff zwischen vor 5 Commits und jetzt
git diff HEAD~5 HEAD

14.1.2.3 Praktische Beispiele

# Letzten Commit rückgängig machen
git reset HEAD~1

# Letzten 3 Commits interaktiv rebasen
git rebase -i HEAD~3

# Diff zwischen aktuellem Branch und main
git diff main..HEAD

# Log der Commits, die in feature sind, aber nicht in main
git log main..feature

# Zeige Änderungen im gemergten Branch
git log HEAD^2

# Checkout eines spezifischen Commits relativ
git checkout HEAD~5

# Cherry-pick eines Commits aus anderem Branch
git cherry-pick feature~2

14.1.2.4 Kombinationen und komplexe Referenzen

Die Operatoren können kombiniert werden:

HEAD~2^2    # Zwei Commits zurück, dann zweiter Parent
main^^2^    # main, zwei erste Parents zurück, dann zweiter Parent, dann noch ein Parent
HEAD~3^2~2  # Drei zurück, zweiter Parent, zwei weitere zurück

Diese Syntax ist universell und funktioniert mit jedem Git-Befehl: - git show, git log, git diff - git reset, git revert, git cherry-pick - git rebase, git merge, git checkout - Jedes Tool, das Commit-Referenzen akzeptiert

Diese Addressierungsmechanismen sind nicht rebase-spezifisch, merge-spezifisch oder sonst wie spezialisiert. Sie sind fundamentale Navigation im Commit-Graph – so grundlegend wie cd .. im Dateisystem.

14.1.3 Weitere Spezialreferenzen

@           # Synonym für HEAD
@{-1}       # Vorheriger Branch (vor checkout)
@{-2}       # Zwei Branches zuvor

HEAD@{2.days.ago}       # HEAD vor 2 Tagen (via reflog)
main@{yesterday}        # main gestern
HEAD@{5}                # HEAD vor 5 Operationen (reflog)

branchname@{upstream}   # Upstream-Branch (tracking branch)
branchname@{push}       # Push-Destination

Diese Addressierung ist mächtig und wird in fortgeschrittenen Workflows unverzichtbar. Sie ist die Sprache, in der wir mit dem Commit-Graph kommunizieren.

14.2 Fast-Forward Merge: Die triviale Integration

Ein Fast-Forward Merge tritt auf, wenn der Ziel-Branch keine neuen Commits seit der Erstellung des Feature-Branches hat. Technisch: Es existiert ein direkter Pfad durch Parent-Referenzen vom Ziel-Branch zum Feature-Branch.

14.2.1 Die Mechanik

Ausgangssituation:

main     feature
  ↓        ↓
  C1 → C2 → C3

main zeigt auf C1, feature auf C3. C3 ist direkt von C1 erreichbar (C3 → C2 → C1). Es gibt keine Divergenz, keine parallele Entwicklung.

Bei git merge feature (von main aus): 1. Git prüft: Ist C3 von C1 erreichbar? Ja. 2. Git verschiebt einfach den main-Pointer auf C3. 3. Kein neuer Commit wird erstellt.

$ git checkout main
$ git merge feature
Updating a1b2c3d..7e8f9a0
Fast-forward
 file.txt | 10 ++++++++++
 1 file changed, 10 insertions(+)

$ cat .git/refs/heads/main
7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6

$ cat .git/refs/heads/feature
7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6

Beide Branches zeigen auf denselben Commit. Die Historie bleibt linear:

14.2.2 Eigenschaften

Keine neuen Commits: Die Commit-Objekte bleiben unverändert. Nur Branch-Pointer bewegen sich.

Lineare Historie: git log zeigt eine gerade Linie. Keine Merge-Commits, keine Verzweigungen.

Vollständige Informationsbewahrung: Alle Original-Commits bleiben erhalten, mit ihren Messages, Timestamps, Authoren.

Keine Merge-Signatur: Man sieht nicht, dass hier ein Merge stattfand. Die Historie sieht aus, als wäre alles sequenziell auf einem Branch entwickelt worden.

14.2.3 Erzwingen oder Verhindern

Fast-Forward ist Gits Default-Verhalten, wenn möglich. Man kann es aber steuern:

# Fast-Forward erzwingen (Merge schlägt fehl, wenn FF nicht möglich)
git merge --ff-only feature

# Fast-Forward verhindern (immer Merge-Commit erstellen)
git merge --no-ff feature

Warum --no-ff? Um explizit zu markieren, dass hier ein Feature-Branch integriert wurde, auch wenn technisch ein Fast-Forward möglich wäre. Dies erhält die Information “dieser Bereich war ein Feature”.

14.3 Merge Commit: Die explizite Vereinigung

Wenn beide Branches neue Commits haben, ist Fast-Forward unmöglich. Git erstellt einen Merge-Commit – ein Commit mit zwei Parents.

14.3.1 Die Mechanik

Ausgangssituation:

        C3 (feature)
       /
C1 → C2
       \
        C4 (main)

main hat C4 (von C2 abgezweigt), feature hat C3 (ebenfalls von C2). Common ancestor: C2.

Bei git merge feature (von main aus): 1. Git findet common ancestor (C2) 2. Git erstellt Three-Way-Merge: Base (C2), Ours (C4), Theirs (C3) 3. Git erstellt neuen Commit (C5) mit: - parent1: C4 (aktueller main) - parent2: C3 (gemerged feature) - tree: Kombinierte Änderungen aus beiden Branches

$ git merge feature
Merge made by the 'ort' strategy.
 file.txt | 5 +++++
 1 file changed, 5 insertions(+)

$ git cat-file -p HEAD
tree 9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b2a1f0
parent a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0  # C4 (main)
parent 7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6  # C3 (feature)
author John Doe <john@example.com> 1704122400 +0100
committer John Doe <john@example.com> 1704122400 +0100

Merge branch 'feature'

Der Merge-Commit C5 hat zwei Parents:

Die Struktur im Graph:

          C3 (feature)
         /  \
C1 → C2      C5 (main, Merge Commit)
         \  /
          C4

14.3.2 Eigenschaften

Zwei Parents: Der Merge-Commit verbindet beide Historien. Von C5 aus sind sowohl C3 als auch C4 erreichbar.

Verzweigte Historie: git log --graph zeigt die Verzweigung deutlich. Die parallele Entwicklung ist sichtbar.

Explizite Integration-Punkte: Jeder Merge-Commit markiert einen Punkt, wo Branches zusammengeführt wurden. Dies ist nachvollziehbar.

Vollständige Historie: Alle Commits beider Branches bleiben erhalten, in ihrer originalen Reihenfolge und Struktur.

14.3.3 Der Merge Commit als Dokumentation

Die Commit-Message eines Merge-Commits dokumentiert die Integration:

Merge branch 'feature/new-login'

Adds new authentication system with:
- OAuth2 integration
- Password reset flow
- 2FA support

Closes #123

Diese Message ist wertvoll: Sie fasst zusammen, was integriert wurde, ohne dass man alle einzelnen Commits durchgehen muss.

14.4 Squash Merge: Die komprimierende Integration

Squash Merge nimmt alle Commits eines Feature-Branches und kondensiert sie zu einem einzigen Commit auf dem Ziel-Branch.

14.4.1 Die Mechanik

Ausgangssituation:

        C3 → C4 → C5 (feature)
       /
C1 → C2 (main)

Der Feature-Branch hat drei Commits (C3, C4, C5), jeder mit kleinen Schritten.

Bei git merge --squash feature (von main aus): 1. Git berechnet die kumulative Änderung von C2 zu C5 2. Git wendet diese Änderung auf main an (Working Directory + Index) 3. Git erstellt keinen Commit automatisch 4. Entwickler muss committen:

$ git merge --squash feature
Squash commit -- not updating HEAD
Automatic merge went well; stopped before committing as requested

$ git status
On branch main
Changes to be committed:
  (all changes from feature branch)

$ git commit -m "Add new login system

Implements OAuth2, password reset, and 2FA.
Closes #123"
[main 8c7d6e5] Add new login system

Das Ergebnis:

C1 → C2 → C6 (main)

C6 ist ein normaler Commit (ein Parent: C2) mit allen Änderungen aus C3, C4, C5 kombiniert. Die Original-Commits C3-C5 bleiben im Graph, aber sind nicht mehr von main erreichbar:

14.4.2 Eigenschaften

Ein Commit: Die gesamte Feature-Arbeit wird als atomare Unit dargestellt.

Lineare Historie auf main: Der Haupt-Branch bleibt eine gerade Linie. Keine Verzweigungen.

Feature-Historie im Feature-Branch: Die Detail-Historie bleibt im Feature-Branch erhalten (bis dieser gelöscht wird).

Kein Merge-Commit: C6 ist ein normaler Commit mit einem Parent. Git “weiß” nicht, dass hier ein Branch integriert wurde.

Verlust von Zwischenschritten: Auf main sind die einzelnen Schritte C3-C5 nicht sichtbar. Nur das Endergebnis.

14.4.3 Wann Squash?

Squash ist ideal, wenn der Feature-Branch “dirty” ist – viele kleine Commits wie:

Fix typo
WIP - testing approach
Revert "testing approach"
Actually fix the thing
Fix lint errors
Respond to review comments

Diese Granularität ist während der Entwicklung nützlich, aber in der main-Historie Noise. Ein squashed Commit:

Add user authentication

Implements login, logout, and session management.
Includes unit tests and documentation.

ist klarer und informativer.

14.5 Die Historie-Debatte: Linear vs. Verzweigt

Es gibt zwei Philosophien zur Git-Historie, die sich fundamental unterscheiden.

14.5.1 Philosophie 1: Historie als unveränderliches Protokoll

Perspektive: Git-Historie sollte die wahre Entwicklungsgeschichte abbilden. Jeder Commit, jede Verzweigung, jeder Merge zeigt, wie das Projekt wirklich entstand. Historie ist ein Audit-Trail, der nicht verändert werden sollte.

Praktiken: - Merge Commits bevorzugen (keine Rebases) - Alle Feature-Branches bleiben sichtbar - Auch “schlechte” Commits bleiben erhalten (Teil der Realität)

Argument: “Was passiert ist, ist passiert. Historie zu ändern verfälscht die Wahrheit.”

Beispiel-Historie:

Diese Historie zeigt genau, wann welcher Branch erstellt, wie lange daran gearbeitet und wann gemerged wurde. Vollständige Transparenz.

14.5.2 Philosophie 2: Historie als Kommunikationsmittel

Perspektive: Git-Historie sollte klar und lesbar sein. Sie dient zukünftigen Entwicklern, die verstehen wollen, wie das System aufgebaut ist. Historie ist Dokumentation, die kuratiert werden sollte.

Praktiken: - Rebase bevorzugen (lineare Historie) - Squash Merge für Feature-Branches - “Schlechte” Commits werden umgeschrieben - Interactive Rebase für Cleanup

Argument: “Die rohe Entwicklungsgeschichte ist Noise. Was zählt, ist eine verständliche Darstellung.”

Beispiel-Historie:

Diese Historie ist eine klare, lineare Story. Jeder Commit ist ein sinnvolles, in sich geschlossenes Feature. Lesbar wie ein Buch.

14.5.3 Die Realität: Ein Spektrum, kein Binär

Die meisten Teams liegen irgendwo dazwischen. Einige pragmatische Perspektiven:

Argument für lineare Historie:

  1. Lesbarkeit: git log --oneline zeigt eine Story, keine Spaghetti.
  2. Bisect funktioniert besser: Binäre Suche in linearer Historie ist einfacher.
  3. Cherry-Pick ist einfacher: Features zu isolieren ist trivial bei linearer Historie.
  4. Review-Freundlichkeit: Ein Feature = ein Commit ist einfacher zu reviewen als 30 kleine Commits.
  5. Weniger Merge-Commits: Die Historie wird nicht durch hunderte “Merge branch ‘feature-xyz’” Commits zugespamt.

Argument für vollständige Historie:

  1. Nachvollziehbarkeit: Man kann sehen, wie ein Feature wirklich entstand.
  2. Fehlersuche: Manchmal ist wichtig, wann ein spezifischer Zwischenschritt eingeführt wurde.
  3. Ehrlichkeit: Entwicklung ist messy. Das zu verbergen, ist Schönfärberei.
  4. Collaboration-History: Merge-Commits zeigen, wann verschiedene Entwickler ihre Arbeit integriert haben.

14.5.4 Die pragmatische Empfehlung

Für Feature-Branches vor Integration: Alles ist erlaubt. Committe häufig, sei messy, experimentiere. Diese Historie ist privat.

Bei Integration in main: Kuratiere. Entweder: - Interactive Rebase, um Commits zu cleanen, zu squashen, Messages zu verbessern - Squash Merge, um ein sauberes Commit zu erstellen - Merge Commit mit aussagekräftiger Message

Für main selbst: Halte es sauber. Jeder Commit auf main sollte: - In sich geschlossen sein (funktioniert, testet, buildet) - Eine klare Message haben - Ideally ein Feature/Fix repräsentieren, nicht “WIP” oder “fix typo”

Die “Angst vor Rebase”:

Viele Entwickler haben unbegründete Angst vor Rebase, basierend auf Missverständnissen:

Mythos: “Rebase löscht Commits und verliert Historie.” Realität: Rebase erstellt neue Commits mit anderen Hashes. Die alten Commits bleiben in .git/objects/ (bis GC). Via git reflog sind sie wiederherstellbar. Nichts ist “verloren”, nur nicht mehr von Branches referenziert.

Mythos: “Rebase ist gefährlich und kann alles zerstören.” Realität: Rebase ist gefährlich, wenn bereits gepushte und von anderen genutzte Commits rebased werden. Für lokale Commits oder private Feature-Branches ist Rebase harmlos und empfohlen.

Mythos: “Merge-Commits sind wichtig für die Historie.” Realität: Merge-Commits dokumentieren Integration-Punkte. Aber bei trivialen Features ist diese Information Noise. Ein gut geschriebenes Feature-Commit dokumentiert besser als ein generischer “Merge branch ‘feature-123’”.

Die Scheu vor Rebase ist oft institutionelles Wissen, basierend auf schlechten Erfahrungen (jemand hat mal production rebased und chaos verursacht). Aber korrekt angewendet, ist Rebase ein mächtiges Tool für lesbare Historie.

14.5.5 Konkrete Empfehlung nach Kontext

Kontext Empfohlene Strategie Begründung
Feature-Branch → main (small team) Rebase + FF oder Squash Saubere, lineare Historie
Feature-Branch → main (large team) Merge Commit Zeigt parallele Arbeit, Credit für alle
Hotfix → main Fast-Forward (wenn möglich) Dringend, sollte linear sein
Release-Branch → main Merge Commit mit Tag Markiert wichtigen Integration-Punkt
Long-Running Feature Regelmäßig Rebase auf main Verhindert massive Konflikte später
Experiment-Branch Squash oder gar nicht mergen Experimentelle Historie ist irrelevant

14.6 Praktische Anwendung der Strategien

14.6.1 Workflow-Beispiel: Feature-Branch mit Cleanup

# Feature-Branch erstellen und arbeiten
git checkout -b feature/new-api
# ... viele Commits, experimentieren, WIP commits ...
git commit -m "WIP - trying approach A"
git commit -m "Revert - approach A doesn't work"
git commit -m "Implement approach B"
git commit -m "Fix typo"
git commit -m "Respond to linter"

# Vor dem Merge: Historie aufräumen
git rebase -i main

# Interactive Rebase Editor öffnet sich:
# pick abc123 Implement approach B
# squash def456 Fix typo
# squash ghi789 Respond to linter
# drop earlier WIP commits

# Resultat: Ein sauberer Commit
git log --oneline
abc123 Implement new REST API endpoint

# Merge (Fast-Forward möglich, da rebased auf main)
git checkout main
git merge feature/new-api

Die finale main-Historie ist sauber. Die messy Entwicklungs-Historie ist weg (aber via git reflog noch erreichbar, wenn nötig).

14.6.2 Workflow-Beispiel: Merge Commit für größeres Feature

# Feature-Branch mit mehreren logischen Commits
git checkout -b feature/authentication
git commit -m "Add user model and database schema"
git commit -m "Implement password hashing"
git commit -m "Add login endpoint"
git commit -m "Add session management"

# Diese Commits sind alle wertvoll, behalten
git checkout main
git merge --no-ff feature/authentication -m "Add user authentication system

Implements:
- User model with secure password storage
- Login/logout endpoints
- Session-based authentication

Closes #234"

Die main-Historie zeigt den Merge-Commit mit Kontext, aber die Detail-Historie ist erreichbar via git log --first-parent (zeigt nur Merge-Commits) oder git log feature/authentication.

14.7 Zusammenfassung: Werkzeuge, keine Dogmen

Git bietet verschiedene Merge-Strategien als Werkzeuge. Keine ist inhärent “besser”. Die Wahl hängt ab von: - Team-Größe und -Erfahrung - Projekt-Komplexität - Release-Prozessen - Persönlichen Präferenzen

Fast-Forward: Simpel, sauber, aber nur bei linearer Entwicklung möglich.

Merge Commit: Explizit, zeigt parallele Arbeit, kann historie unübersichtlicher machen.

Squash Merge: Komprimiert, ideal für messy Feature-Branches, verliert Detail-Historie.

Rebase: Reorganisiert Historie, macht sie linear, sollte nur für nicht-gepushte Commits verwendet werden.

Die “richtige” Strategie ist die, die für dein Team funktioniert. Aber bitte: Hab keine irrationale Angst vor Rebase oder Squash. Sie sind mächtige Tools für lesbare Historie. Und lesbare Historie macht Code-Maintainance, Debugging und Onboarding neuer Entwickler drastisch einfacher.

Git-Historie ist nicht heilig. Sie ist ein Tool, das uns dient. Wie ein gutes Buch sollte sie eine klare Story erzählen, nicht ein ungefiltertes Protokoll jeden Tippfehlers sein. Kuratiere deine Historie. Dein zukünftiges Selbst (und deine Kollegen) werden es dir danken.

Und: Nutze die Commit-Addressierung. Sie ist fundamental und mächtig. HEAD~5, main^2, feature@{yesterday} – diese Mechanismen funktionieren überall in Git. Sie sind nicht spezifisch für Merge oder Rebase. Sie sind die Sprache, in der wir mit dem Commit-Graph reden. Beherrsche sie, und Git wird von kryptisch zu intuitiv.