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.
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
7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6Diese 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.
Tags funktionieren nach demselben Prinzip. Eine Textdatei unter
.git/refs/tags/ enthält einen Commit-Hash:
$ cat .git/refs/tags/v1.0.0
a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0Der 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.
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 commitWenn 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.
Bevor wir tiefer in Branching-Operationen einsteigen, betrachten wir die Grundstruktur, auf der alles aufbaut: den Commit-Graph.
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.
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.”
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.
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.
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.
Nachdem wir den zugrundeliegenden Graphen verstanden haben, schauen wir uns an, wie Branch-Labels technisch funktionieren.
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 commitDer 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 commitBeachte die parent-Zeile: Sie verweist auf den Hash des
vorherigen Commits (a1b2c3d...). Die Kette ist:
main → 7e8f9a0 (Second commit)
↓ parent
a1b2c3d (Initial commit)
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 mainZwei 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.
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 3f4a5b6Die 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.
Diese duale Bedeutung von “Branch” führt zu sprachlichen Mehrdeutigkeiten im Alltag:
.git/refs/heads/)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.
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 mainWenn wir git checkout main ausführen, ändert sich
HEAD:
$ git checkout main
Switched to branch 'main'
$ cat .git/HEAD
ref: refs/heads/mainDiese 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.
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 commitDie 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 commitBeide 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
Merging ist der Prozess, zwei divergierte Histories wieder zusammenzuführen. Technisch bedeutet dies, ein neues Commit zu erstellen, das zwei (oder mehr) Parents hat.
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
3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2Git 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.
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 commitDer 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.
Der Name “Three-Way Merge” kommt von den drei Commits, die beteiligt sind:
7e8f9a0 – der
letzte gemeinsame Commit beider Branches9d8c7b6 – der aktuelle Branch
(main)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.
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.txtconflict.txt in beiden Branches neu erstellt
wurdemain) – “Version from
main”feature) – “Version
from feature”Die Datei im Working Directory enthält Konfliktmarker:
$ cat conflict.txt
<<<<<<< HEAD
Version from main
=======
Version from feature
>>>>>>> featureNach 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 conflictDas resultierende Commit ist ein normaler Merge-Commit mit zwei Parents – Git weiß nicht, dass Konflikte existierten; das ist nur im Prozess relevant.
Rebasing ist eine Alternative zu Merging, die eine lineare Historie erzeugt. Technisch bedeutet Rebase: Commits werden neu erstellt mit anderen Parents.
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 commitWas 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(+)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.
Interaktives Rebase erlaubt das Editieren der Commit-Historie:
$ git rebase -i HEAD~3Git ö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.
| 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)
Cherry-Pick nimmt einen Commit und wendet ihn auf einen anderen Branch an:
$ git checkout main
$ git cherry-pick 3f4a5b6Technisch 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.
$ git branch -d feature
Deleted branch feature (was 8c7d6e5).
$ ls .git/refs/heads/
mainDie 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.
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
a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0HEAD 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).
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.
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.