12 Branching und Merging

Nachdem wir die technischen Grundlagen von Git-Objekten, Commits und der Staging Area verstanden haben, können wir nun das Konzept der Branches auf fundierter Basis erschließen. Dabei müssen wir eine wichtige begriffliche Unterscheidung treffen: Das Wort “Branch” hat in Git zwei verwandte, aber unterschiedliche Bedeutungen.

12.1 Der polymorphe Branch-Begriff: Zwei Bedeutungen, ein Wort

12.1.1 Branch als Label: DNS für Commits

Ein Branch als Referenz ist nichts anderes als ein lesbarer Name für einen Commit-Hash – vergleichbar damit, wie DNS-Namen (wie example.com) auf IP-Adressen (wie 192.0.2.1) zeigen. Statt mit kryptischen SHA-1-Hashes wie 7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6 zu arbeiten, nutzen wir menschenlesbare Namen wie main, feature oder bugfix-123.

Technisch ist ein Branch eine Textdatei unter .git/refs/heads/, die einen einzigen SHA-1-Hash enthält:

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

Diese Datei ist ein Label – eine symbolische Referenz auf einen Commit. Genau wie ein DNS-Name ist ein Branch-Name einfacher zu merken und zu kommunizieren als der Hash selbst.

12.1.2 Tags: Labels für die Ewigkeit

Tags funktionieren nach demselben Prinzip. Eine Textdatei unter .git/refs/tags/ enthält einen Commit-Hash:

$ cat .git/refs/tags/v1.0.0
a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0

Der fundamentale Unterschied zwischen Branches und Tags liegt in ihrer Mobilität:

Eigenschaft Branch Tag
Mobilität Bewegt sich mit neuen Commits Bleibt fix am Commit
Verwendung Aktive Entwicklungslinie Markierung historischer Punkte
Analogie Beweglicher Zeiger Ortsmarke
Typisch für main, feature-x, develop v1.0.0, release-2023-12

Ein Branch ist mobil: Wenn ein neues Commit erstellt wird, während dieser Branch aktiv ist, wird die Branch-Datei automatisch aktualisiert, um auf das neue Commit zu zeigen. Der Branch “wandert” mit der Entwicklung.

Ein Tag ist ortstreu: Einmal gesetzt, bleibt ein Tag am selben Commit. Er markiert einen festen Punkt in der Historie – typischerweise ein Release oder einen wichtigen Meilenstein.

12.1.3 Branch als Zweig: Die Historie im Graph

Die zweite Bedeutung von “Branch” ist der Entwicklungszweig selbst – die Menge aller Commits, die von einem bestimmten Commit aus durch Parent-Beziehungen rückwärts erreichbar sind.

Wir haben bereits gesehen, dass jedes Commit-Objekt eine parent-Referenz enthält, die auf das vorherige Commit zeigt. Diese Parent-Beziehungen bilden einen Directed Acyclic Graph (DAG) – einen gerichteten, azyklischen Graphen:

Der “Branch” als Zweig umfasst alle Commits, die man durch Folgen der Parent-Kette vom Branch-Label aus erreichen kann:

$ git log main --oneline
9d8c7b6 (HEAD -> main) Main commit
7e8f9a0 Second commit
a1b2c3d Initial commit

Wenn wir sagen “der main-Branch hat drei Commits”, meinen wir den Zweig im Graph – nicht das Label. Das Label main zeigt nur auf 9d8c7b6, aber der Zweig umfasst die gesamte erreichbare Historie: 9d8c7b6 → 7e8f9a0 → a1b2c3d.

Diese Unterscheidung ist essentiell: - Branch-Label: Ein Name/Pointer (die Textdatei) - Branch-Zweig: Eine Historie (die Menge erreichbarer Commits)

Im Alltag vermischen wir diese Begriffe (“checkout den feature-Branch”, “merge main in feature”), aber technisch sind sie unterschiedlich. Das Label ist ein einzelner Pointer, der Zweig ist eine ganze Kette von Commits.

12.2 Der Directed Acyclic Graph: Fundament der Git-Historie

Bevor wir tiefer in Branching-Operationen einsteigen, betrachten wir die Grundstruktur, auf der alles aufbaut: den Commit-Graph.

12.2.1 Commits bilden einen azyklischen Graphen

Jedes Commit kennt seine Parents (ein oder mehrere bei Merges), aber kein Commit kennt seine Children. Die Beziehungen zeigen nur rückwärts in der Zeit. Dies bildet einen gerichteten Graphen: Kanten haben eine Richtung (Commit → Parent).

Der Graph ist azyklisch: Ein Commit kann niemals – direkt oder indirekt – auf sich selbst als Parent zeigen. Zeit fließt nur in eine Richtung. Es gibt keine Zyklen, keine Schleifen, keine Zeitparadoxe.

Diese Struktur ist fundamental: Alle Git-Operationen basieren darauf, diesem Graphen zu folgen, ihn zu erweitern oder ihn zu reorganisieren.

12.2.2 Reachability: Welche Commits gehören zusammen?

Ein Commit A ist erreichbar (reachable) von Commit B, wenn man durch Folgen der Parent-Kette von B zu A gelangen kann. Dies definiert Zugehörigkeit:

# Alle Commits erreichbar von main
$ git log main --oneline
9d8c7b6 Main commit
7e8f9a0 Second commit  
a1b2c3d Initial commit

# Commit a1b2c3d ist erreichbar von main
# Commit 7e8f9a0 ist erreichbar von main
# Commit 9d8c7b6 ist erreichbar von main (trivial - der Startpunkt selbst)

Wenn wir sagen “dieser Commit ist auf dem main-Branch”, meinen wir technisch: “Dieser Commit ist von dem Commit aus erreichbar, auf den das Branch-Label main zeigt.”

12.2.3 Divergenz: Wenn Zweige entstehen

Bei paralleler Entwicklung divergiert der Graph. Zwei Commits können denselben Parent haben:

        C4 (feature)     C5 (main)
              \             /
               \           /
                \         /
                 C3 (common ancestor)
                   |
                  C2
                   |
                  C1

Beide Commits, C4 und C5, haben C3 als Parent. Von C3 aus verzweigt sich die Historie in zwei Zweige. Dies ist der Kern von paralleler Entwicklung: Unterschiedliche Commit-Ketten teilen einen gemeinsamen Ancestor.

Die Branches als Labels (feature und main) zeigen auf unterschiedliche Commits (C4 und C5). Die Branches als Zweige umfassen unterschiedliche Histories: - Branch-Zweig feature: C4 → C3 → C2 → C1 - Branch-Zweig main: C5 → C3 → C2 → C1

Beide teilen die gemeinsame Historie C3 → C2 → C1, divergieren aber ab C3.

12.2.4 Merge-Commits: Wenn Zweige konvergieren

Ein Merge-Commit hat zwei (oder mehr) Parents. Dies vereint divergierte Zweige:

            C6 (Merge-Commit)
            /                \
           /                  \
      C4 (feature)        C5 (main)
           \                  /
            \                /
             C3 (common ancestor)

Der Merge-Commit C6 hat sowohl C5 als auch C4 als Parents. Durch Folgen beider Parent-Ketten werden beide Zweige Teil der Historie:

$ git cat-file -p <merge-commit>
tree ...
parent <C5-hash>   # Erster Parent (main)
parent <C4-hash>   # Zweiter Parent (feature)
author ...
committer ...

Merge branch 'feature'

Von C6 aus sind sowohl C4 als auch C5 erreichbar. Der Merge-Commit vereint die Historien im Graph.

Diese DAG-Struktur ist der Grund, warum Git so flexibel ist: Commits können sich verzweigen, parallel entwickeln, wieder zusammengeführt werden – alles durch simple Parent-Beziehungen.

12.2.5 Branch-Labels navigieren im Graph

Branch-Labels sind Einstiegspunkte in den Graphen. Sie sagen: “Starte hier und folge den Parents rückwärts.”

Wenn wir git log feature ausführen, startet Git beim Commit, auf den feature zeigt, und folgt der Parent-Kette. Wenn wir git merge feature ausführen, findet Git den gemeinsamen Ancestor beider Branches, indem es beide Parent-Ketten zurückverfolgt, bis sie sich treffen.

Das Label ist mobil – es bewegt sich zu neuen Commits. Der Zweig (die Historie) wächst durch neue Commits, die sich in die bestehende Kette einfügen. Beides sind unterschiedliche Aspekte derselben Struktur.

12.3 Branch-Labels in der Praxis: Die beweglichen Pointer

Nachdem wir den zugrundeliegenden Graphen verstanden haben, schauen wir uns an, wie Branch-Labels technisch funktionieren.

12.3.1 Praktische Exploration: Was ist ein Branch-Label?

Erstellen wir ein Repository und untersuchen die Branch-Struktur:

$ mkdir branch-demo && cd branch-demo
$ git init
$ echo "Initial content" > file.txt
$ git add file.txt
$ git commit -m "Initial commit"
[main (root-commit) a1b2c3d] Initial commit

# Was ist "main" technisch?
$ cat .git/refs/heads/main
a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0

# Eine Textdatei mit einem Commit-Hash!
$ git cat-file -t a1b2c3d
commit

$ git cat-file -p a1b2c3d
tree 9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b2a1f0
author John Doe <john@example.com> 1704110400 +0100
committer John Doe <john@example.com> 1704110400 +0100

Initial commit

Der Branch main ist buchstäblich eine Datei, die den Hash a1b2c3d... enthält. Das ist alles. Wenn wir ein neues Commit erstellen, wird diese Datei aktualisiert:

$ echo "Second line" >> file.txt
$ git add file.txt
$ git commit -m "Second commit"
[main 7e8f9a0] Second commit

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

# Der Hash hat sich geändert - der Branch zeigt auf das neue Commit
$ git cat-file -p 7e8f9a0
tree 3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c
parent a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0
author John Doe <john@example.com> 1704114000 +0100
committer John Doe <john@example.com> 1704114000 +0100

Second commit

Beachte die parent-Zeile: Sie verweist auf den Hash des vorherigen Commits (a1b2c3d...). Die Kette ist:

main → 7e8f9a0 (Second commit)
           ↓ parent
        a1b2c3d (Initial commit)

12.3.2 Die Leichtgewichtigkeit von Branch-Labels

Ein Branch-Label zu erstellen bedeutet, eine neue Datei in .git/refs/heads/ anzulegen:

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

$ ls -lh .git/refs/heads/
total 16
-rw-r--r--  1 user  staff    41B Jan  1 12:30 feature
-rw-r--r--  1 user  staff    41B Jan  1 12:25 main

Zwei Branch-Labels, beide zeigen auf denselben Commit. Jede Datei ist 41 Bytes groß (40 Zeichen Hash + Newline). Das Erstellen eines Branch-Labels ist eine triviale Dateioperation – kein Kopieren von Code, keine aufwändige Strukturierung. Dies erklärt, warum Git-Branches so schnell sind.

Wichtig: Der Branch-Zweig (die Historie) wird nicht dupliziert. Beide Labels zeigen auf denselben Commit, und damit auf dieselbe Historie. Die Commits existieren nur einmal in .git/objects/. Die Labels sind nur verschiedene Einstiegspunkte in denselben Graphen.

12.3.3 Eigenschaften von Branch-Labels

Aus dieser technischen Realität ergeben sich die charakteristischen Eigenschaften:

Beweglichkeit: Ein Branch ist nicht fix. Jedes Mal, wenn ein Commit erstellt wird, während dieser Branch aktiv ist (d.h. HEAD zeigt darauf), wird die Branch-Datei aktualisiert, um auf das neue Commit zu zeigen. Der Branch “bewegt” sich automatisch mit der Entwicklung.

Isolation: Wenn wir zu feature wechseln und committen, bewegt sich nur feature. Der Branch main bleibt unverändert:

$ git checkout feature
Switched to branch 'feature'

$ echo "Feature content" >> file.txt
$ git add file.txt
$ git commit -m "Feature commit"
[feature 3f4a5b6] Feature commit

$ cat .git/refs/heads/feature
3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2

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

# main zeigt immer noch auf 7e8f9a0, feature auf das neue Commit 3f4a5b6

Die Commit-Struktur:

main → 7e8f9a0
         ↓ parent
       a1b2c3d

feature → 3f4a5b6
            ↓ parent
          7e8f9a0
            ↓ parent
          a1b2c3d

Gemeinsame Historie: Der Branch-Zweig feature (die Menge erreichbarer Commits) enthält 3f4a5b6 → 7e8f9a0 → a1b2c3d. Der Branch-Zweig main enthält 7e8f9a0 → a1b2c3d. Sie teilen die Commits 7e8f9a0 und a1b2c3d – diese existieren nur einmal in .git/objects/. Es gibt keine Duplikation der Commit-Objekte. Die Branch-Labels sind nur unterschiedliche Namen für Einstiegspunkte in denselben Graphen.

12.3.4 Branch-Labels vs. Branch-Zweige: Die Unterscheidung in der Praxis

Diese duale Bedeutung von “Branch” führt zu sprachlichen Mehrdeutigkeiten im Alltag:

Diese Mehrdeutigkeit ist normalerweise unproblematisch, weil der Kontext klar macht, was gemeint ist. Technisch sind sie aber unterschiedlich: Das Label ist ein Pointer, der Zweig ist eine Historie. Das Label ist 41 Bytes, der Zweig kann Gigabytes sein.

12.3.5 HEAD: Der aktuelle Kontext

Wir haben bereits HEAD im Kontext von Commits erwähnt. Im Branching-Kontext ist HEAD die Referenz, die bestimmt, welcher Branch aktiv ist:

$ cat .git/HEAD
ref: refs/heads/feature

# HEAD zeigt auf den feature-Branch
# Das bedeutet: Neue Commits bewegen feature, nicht main

Wenn wir git checkout main ausführen, ändert sich HEAD:

$ git checkout main
Switched to branch 'main'

$ cat .git/HEAD
ref: refs/heads/main

Diese indirekte Referenzierung (HEAD → Branch → Commit) ermöglicht die Beweglichkeit: Ein neues Commit wird als Child des aktuellen Commits erstellt (das, auf das der aktuelle Branch zeigt), und dann wird der Branch (nicht HEAD direkt) aktualisiert.

12.3.6 Divergierende Histories: Parallele Entwicklung

Wenn beide Branches jeweils neue Commits erhalten, entsteht ein verzweigter Graph:

# Auf main
$ git checkout main
$ echo "Main changes" >> file.txt
$ git add file.txt
$ git commit -m "Main commit"
[main 9d8c7b6] Main commit

# Struktur prüfen
$ git log --all --graph --oneline
* 9d8c7b6 (HEAD -> main) Main commit
| * 3f4a5b6 (feature) Feature commit
|/  
* 7e8f9a0 Second commit
* a1b2c3d Initial commit

Die Commit-Objekte:

$ git cat-file -p 9d8c7b6
tree ...
parent 7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6
author John Doe <john@example.com> 1704118000 +0100
committer John Doe <john@example.com> 1704118000 +0100

Main commit

$ git cat-file -p 3f4a5b6
tree ...
parent 7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6
author John Doe <john@example.com> 1704114600 +0100
committer John Doe <john@example.com> 1704114600 +0100

Feature commit

Beide haben denselben Parent: 7e8f9a0. Von diesem gemeinsamen Ancestor divergiert die Historie:

Die Parent-Struktur bildet einen Baum:

        9d8c7b6 (main)     3f4a5b6 (feature)
              \               /
               \             /
                7e8f9a0 (common ancestor)
                    |
                a1b2c3d

12.4 Merging: Histories vereinen

Merging ist der Prozess, zwei divergierte Histories wieder zusammenzuführen. Technisch bedeutet dies, ein neues Commit zu erstellen, das zwei (oder mehr) Parents hat.

12.4.1 Fast-Forward Merge: Die triviale Integration

Der einfachste Fall tritt auf, wenn ein Branch keine neuen Commits hat, seit der andere Branch davon abzweigte. Technisch: Es existiert ein direkter Pfad durch Parent-Referenzen.

Beispiel: feature hat Commits, main nicht:

main → 7e8f9a0
         ↓
       a1b2c3d

feature → 3f4a5b6 → 7e8f9a0 → a1b2c3d

Ein Merge von feature in main ist trivial:

# Auf main (zeigt auf 7e8f9a0)
$ git checkout main
$ git merge feature
Updating 7e8f9a0..3f4a5b6
Fast-forward
 file.txt | 1 +
 1 file changed, 1 insertion(+)

$ cat .git/refs/heads/main
3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2

Git hat einfach den main-Pointer auf 3f4a5b6 verschoben. Kein neues Commit wurde erstellt. “Fast-forward” bedeutet: Der Branch wird vorwärts bewegt, ohne Merge-Commit.

Nach dem Fast-Forward zeigen beide Branches auf dasselbe Commit. Die Historie bleibt linear.

12.4.2 Three-Way Merge: Echte Integration

Wenn beide Branches neue Commits haben, ist ein Fast-Forward unmöglich. Git muss die Änderungen beider Branches kombinieren und ein neues Commit erstellen – einen Merge-Commit mit zwei Parents.

Ausgangssituation:

main → 9d8c7b6 → 7e8f9a0
feature → 3f4a5b6 → 7e8f9a0

Common ancestor: 7e8f9a0
$ git checkout main
$ git merge feature
Merge made by the 'ort' strategy.
 file.txt | 1 +
 1 file changed, 1 insertion(+)

$ git log --oneline --graph
*   5e6f7a8 (HEAD -> main) Merge branch 'feature'
|\  
| * 3f4a5b6 (feature) Feature commit
* | 9d8c7b6 Main commit
|/  
* 7e8f9a0 Second commit
* a1b2c3d Initial commit

Der Merge-Commit 5e6f7a8 hat zwei Parents:

$ git cat-file -p 5e6f7a8
tree 1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0
parent 9d8c7b6a5f4e3d2c1b0a9f8e7d6c5b4a3f2e1d0
parent 3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2
author John Doe <john@example.com> 1704122000 +0100
committer John Doe <john@example.com> 1704122000 +0100

Merge branch 'feature'

Zwei parent-Zeilen! Der erste Parent ist der Commit, auf dem wir standen (main), der zweite ist der Commit des gemergten Branches (feature).

Die Commit-Struktur nach dem Merge:

main → 5e6f7a8 (Merge commit)
         ↓ parent1    ↓ parent2
       9d8c7b6      3f4a5b6
         ↓              ↓
        7e8f9a0 ←-------┘
         ↓
       a1b2c3d

Dieser Merge-Commit vereint beide Histories. Git kann die gesamte Historie rekonstruieren, indem es beiden Parent-Pfaden folgt.

12.4.3 Der Three-Way-Merge-Algorithmus

Der Name “Three-Way Merge” kommt von den drei Commits, die beteiligt sind:

  1. Base (Common Ancestor): 7e8f9a0 – der letzte gemeinsame Commit beider Branches
  2. Ours: 9d8c7b6 – der aktuelle Branch (main)
  3. Theirs: 3f4a5b6 – der zu mergende Branch (feature)

Git vergleicht alle drei: - Änderungen in main seit base: Diff zwischen 7e8f9a0 und 9d8c7b6 - Änderungen in feature seit base: Diff zwischen 7e8f9a0 und 3f4a5b6

Wenn Änderungen nicht kollidieren (verschiedene Dateien oder verschiedene Bereiche derselben Datei), kombiniert Git sie automatisch. Das Ergebnis ist der Tree des Merge-Commits.

12.4.4 Merge-Konflikte: Wenn Automatisierung scheitert

Konflikte treten auf, wenn beide Branches denselben Bereich derselben Datei ändern. Git kann nicht entscheiden, welche Änderung korrekt ist.

Simulieren wir einen Konflikt:

# In main
$ git checkout main
$ echo "Version from main" > conflict.txt
$ git add conflict.txt
$ git commit -m "Add conflict.txt in main"

# In feature
$ git checkout feature
$ echo "Version from feature" > conflict.txt
$ git add conflict.txt
$ git commit -m "Add conflict.txt in feature"

# Merge versuchen
$ git checkout main
$ git merge feature
Auto-merging conflict.txt
CONFLICT (content): Merge conflict in conflict.txt
Automatic merge failed; fix conflicts and then commit the result.

Der Merge schlägt fehl. Der Index zeigt jetzt drei Versionen (wie im Staging-Area-Kapitel erklärt):

$ git ls-files -s
100644 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 1   conflict.txt
100644 9c1f8b2e3a7d6c5f4a8b3e9d7c2a1f5e4d8c6b3a 2   conflict.txt
100644 4f3e2d1c0b9a8f7e6d5c4b3a2f1e0d9c8b7a6f5e 3   conflict.txt

Die Datei im Working Directory enthält Konfliktmarker:

$ cat conflict.txt
<<<<<<< HEAD
Version from main
=======
Version from feature
>>>>>>> feature

Nach manueller Konfliktlösung:

$ echo "Resolved version" > conflict.txt
$ git add conflict.txt

# Index ist jetzt normal (nur Stage 0)
$ git ls-files -s
100644 7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6 0    conflict.txt

$ git commit -m "Resolve merge conflict"
[main a7b8c9d] Resolve merge conflict

Das resultierende Commit ist ein normaler Merge-Commit mit zwei Parents – Git weiß nicht, dass Konflikte existierten; das ist nur im Prozess relevant.

12.5 Rebasing: Historie neu schreiben

Rebasing ist eine Alternative zu Merging, die eine lineare Historie erzeugt. Technisch bedeutet Rebase: Commits werden neu erstellt mit anderen Parents.

12.5.1 Die Mechanik von Rebase

Ausgangssituation:

main → 9d8c7b6 → 7e8f9a0
feature → 3f4a5b6 → 7e8f9a0

Ein Rebase von feature auf main:

$ git checkout feature
$ git rebase main
First, rewinding head to replay your work on top of it...
Applying: Feature commit

$ git log --oneline --graph
* 8c7d6e5 (HEAD -> feature) Feature commit
* 9d8c7b6 (main) Main commit
* 7e8f9a0 Second commit
* a1b2c3d Initial commit

Was ist passiert? Der Commit 3f4a5b6 wurde “neu erstellt” als 8c7d6e5, aber mit einem anderen Parent:

# Original feature commit
$ git cat-file -p 3f4a5b6
tree ...
parent 7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6
...

# Rebasierter feature commit
$ git cat-file -p 8c7d6e5
tree ...
parent 9d8c7b6a5f4e3d2c1b0a9f8e7d6c5b4a3f2e1d0
...

Der Parent änderte sich von 7e8f9a0 zu 9d8c7b6! Git hat: 1. Die Änderungen aus 3f4a5b6 als Patch extrahiert 2. Diesen Patch auf 9d8c7b6 angewendet 3. Ein neues Commit mit diesem Tree erstellt 4. Den feature-Branch auf das neue Commit verschoben

Die Historie ist jetzt linear: feature ist ein direkter Descendant von main. Ein Fast-Forward-Merge ist möglich:

$ git checkout main
$ git merge feature
Updating 9d8c7b6..8c7d6e5
Fast-forward
 file.txt | 1 +
 1 file changed, 1 insertion(+)

12.5.2 Warum Rebase gefährlich sein kann

Der alte Commit 3f4a5b6 existiert noch in .git/objects/, wird aber von keinem Branch mehr referenziert. Wenn dieser Commit bereits gepusht und von anderen genutzt wurde, haben wir ein Problem: Ihre Historie enthält 3f4a5b6, unsere 8c7d6e5 – unterschiedliche Hashes für konzeptuell dasselbe.

Goldene Regel: Rebase niemals Commits, die bereits gepusht und von anderen genutzt wurden. Rebase ist für lokale Commits oder private Branches akzeptabel, nicht für geteilte Historie.

12.5.3 Interaktives Rebase: Historie bearbeiten

Interaktives Rebase erlaubt das Editieren der Commit-Historie:

$ git rebase -i HEAD~3

Git öffnet einen Editor mit:

pick a1b2c3d Initial commit
pick 7e8f9a0 Second commit
pick 9d8c7b6 Main commit

# Commands:
# p, pick = use commit
# r, reword = use commit, but edit message
# e, edit = use commit, but stop for amending
# s, squash = combine with previous commit
# f, fixup = like squash, but discard message
# d, drop = remove commit

Änderungen dieser Liste ermöglichen: - Squash: Mehrere Commits zu einem kombinieren - Reword: Commit-Message ändern - Drop: Commit entfernen - Reorder: Commits umordnen

Technisch führt Git dieselben Operationen wie bei normalem Rebase aus: Commits werden neu erstellt mit modifizierten Inhalten/Messages/Reihenfolgen.

12.6 Merge vs. Rebase: Philosophische und praktische Unterschiede

Aspekt Merge Rebase
Historie Verzweigt, zeigt wahre Entwicklung Linear, “gesäubert”
Commits Behält alle Original-Commits Erstellt neue Commits
Merge-Commits Ja, explizit Nein (bei Fast-Forward danach)
Kollaboration Sicher für geteilte Branches Gefährlich für geteilte Branches
Konfliktlösung Einmalig beim Merge Potenziell mehrfach (pro Commit)
Readability Komplexer Graph Einfache Linie
Traceability Hohe Nachvollziehbarkeit Features schwerer zu isolieren

Merge bewahrt die wahre Historie: Wann wurde was entwickelt, wann integriert. Der Graph zeigt parallele Entwicklungen.

Rebase erzeugt eine “saubere” Historie, als ob alle Commits sequenziell entwickelt wurden. Einfacher zu lesen, aber historisch ungenau.

Beide haben legitime Anwendungsfälle. Viele Teams nutzen eine Kombination: - Rebase für lokale Feature-Branches (vor dem Push) - Merge für Integration in geteilte Branches (nach Code Review)

12.7 Fortgeschrittene Branch-Operationen

12.7.1 Cherry-Pick: Einzelne Commits übernehmen

Cherry-Pick nimmt einen Commit und wendet ihn auf einen anderen Branch an:

$ git checkout main
$ git cherry-pick 3f4a5b6

Technisch extrahiert Git die Änderungen aus 3f4a5b6 (als Patch) und wendet sie auf main an, erstellt ein neues Commit. Ähnlich wie Rebase, aber für einzelne Commits.

12.7.2 Branch-Löschung: Label entfernen, nicht die Historie

$ git branch -d feature
Deleted branch feature (was 8c7d6e5).

$ ls .git/refs/heads/
main

Die Datei .git/refs/heads/feature wurde gelöscht – das Branch-Label ist weg. Der Commit 8c7d6e5 existiert aber weiterhin in .git/objects/, und auch der gesamte Branch-Zweig (die Historie) bleibt erhalten, solange der Commit von einem anderen Label aus erreichbar ist.

In unserem Fall ist 8c7d6e5 von main aus erreichbar (wir hatten gemergt), daher bleibt die Historie bestehen. Die Commits sind nur nicht mehr über den Namen “feature” auffindbar.

Würden wir einen ungemergten Branch löschen, würde Git warnen:

$ git branch -d unmerged-feature
error: The branch 'unmerged-feature' is not fully merged.
If you are sure you want to delete it, run 'git branch -D unmerged-feature'.

Git schützt uns vor versehentlichem Verlust von Historie. Das Label kann gelöscht werden (mit -D), aber wenn der Commit dann von keinem Label mehr referenziert wird, ist er nur noch via git reflog auffindbar, bis git gc (Garbage Collection) ihn später löscht.

Merke: Branch löschen = Label entfernen. Die Commits bleiben, solange sie erreichbar sind.

12.7.3 Detached HEAD: Branching ohne Branch

Wir können zu einem spezifischen Commit wechseln, ohne auf einem Branch zu sein:

$ git checkout a1b2c3d
Note: switching to 'a1b2c3d'.

You are in 'detached HEAD' state...

$ cat .git/HEAD
a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0

HEAD zeigt direkt auf einen Commit, nicht auf einen Branch. Commits, die in diesem Zustand erstellt werden, haben keinen Branch-Pointer und können “verloren” gehen, wenn man wegnavigiert (außer via git reflog).

12.8 Praktische Implikationen für Workflows

Das technische Verständnis von Branches informiert Best Practices:

Feature-Branches: Jedes Feature in einem separaten Branch entwickeln. Billig zu erstellen, einfach zu mergen oder zu verwerfen.

Short-Lived Branches: Da Branches nur Pointer sind, kostet es nichts, sie nach Merge zu löschen. Viele kurze Branches sind besser als wenige lange.

Branch-Protection: Production-Branches (wie main) sollten geschützt sein – Merges nur via Pull/Merge Requests mit Reviews. Dies verhindert direkte Pushes, die die Historie durcheinanderbringen könnten.

CI/CD Integration: Jeder Branch kann eine eigene Pipeline haben. Commit-Hashes in Pipelines garantieren, dass exakt der getestete Code deployed wird.

Git-Flow vs. Trunk-Based: Unterschiedliche Branching-Strategien sind nur Konventionen über die Nutzung derselben technischen Primitiven. Git selbst erzwingt keine bestimmte Strategie.

12.9 Zusammenfassung: Die Eleganz der dualen Bedeutung

Branches in Git sind elegant, weil sie auf zwei Ebenen funktionieren:

Als Labels: Branches sind 41-Byte-Textdateien – menschenlesbare Namen für Commit-Hashes. Wie DNS-Namen für IP-Adressen. Leichtgewichtig, schnell, mobil. Ein Branch-Label zu erstellen ist eine Millisekunden-Operation. Hunderte Branches zu haben kostet praktisch nichts.

Als Zweige: Branches sind Teilgraphen der Commit-Historie – Mengen von Commits, die durch Parent-Beziehungen verbunden sind. Der Branch-Zweig ist die Entwicklungsgeschichte, die durch Folgen der Parent-Kette entsteht. Diese Historie kann Megabytes oder Gigabytes sein, mit tausenden Commits und Jahren Geschichte.

Diese duale Natur ist kein Bug, sondern ein Feature: - Das Label ist der Einstiegspunkt, einfach zu benennen und zu verschieben - Der Zweig ist die Substanz, die tatsächliche Entwicklungsarbeit - Das Label bewegt sich (mobil), der Zweig wächst (durch neue Commits) - Ein Tag ist ein Label, das sich nicht bewegt (ortstreu)

Der zugrundeliegende Directed Acyclic Graph macht alles möglich: - Gerichtet: Parent-Beziehungen zeigen rückwärts in der Zeit - Azyklisch: Keine Zyklen, keine Zeitparadoxe - Flexibel: Branches können divergieren, konvergieren, reorganisiert werden

Merge-Commits mit mehreren Parents vereinen Zweige. Rebase erstellt neue Commits mit anderen Parents. Cherry-Pick nimmt einzelne Commits und wendet sie woanders an. Alles sind Operationen auf demselben Graphen, gesteuert durch Labels.

Die Parent-Beziehungen in Commits bilden den Graphen der Historie. Branch-Labels sind bewegliche Einstiegspunkte in diesen Graphen. Merging erstellt neue Knoten mit mehreren Parents. Rebasing erstellt neue Knoten an anderen Positionen. Alles basiert auf denselben fundamentalen Primitiven: Content-Addressable Objekte, verkettete durch SHA-1-Hashes.

Wenn du git branch feature ausführst, erstellst du ein Label – eine Datei mit 41 Bytes. Wenn du auf diesem Branch arbeitest, erweiterst du einen Zweig – die Historie wächst durch neue Commits. Wenn du git merge feature ausführst, vereinst du Zweige – ein neues Commit mit zwei Parents entsteht. Diese simplen Operationen ermöglichen komplexe Workflows – das ist die Eleganz von Git’s Design.

Die Unterscheidung zwischen Label und Zweig ist nicht pedantisch – sie ist fundamental für das Verständnis, wie Git funktioniert: - Branch erstellen: Schnell, weil nur ein Label entsteht - Branch löschen: Löscht nur das Label, nicht die Historie - Branch mergen: Vereint Historien, nicht Labels - Branch hat X Commits: Spricht vom Zweig, nicht vom Label

Diese Klarheit macht Git beherrschbar. Die scheinbare Komplexität von Git – hunderte Branches, verschachtelte Merges, rebasierte Histories – ist nur die Oberfläche. Darunter liegt ein elegantes System: Ein Graph aus verketteten Commits, navigierbar durch leichtgewichtige Labels. Simpel, mächtig, elegant.