Du schreibst eine Java Klasse, speicherst sie und arbeitest weiter. Vielleicht ergänzt du eine Methode, benennst Variablen um oder baust einen Fehler ein, den du erst zwei Stunden später bemerkst. Solange alles funktioniert, ist das kein Problem. Interessant wird es erst dann, wenn du plötzlich wissen möchtest, wie eine Datei vorher aussah oder welche Änderung dafür gesorgt hat, dass etwas nicht mehr funktioniert.
Natürlich könntest du regelmäßig Kopien deines Projekts anlegen und Ordner wie mein-projekt-neu, mein-projekt-final oder mein-projekt-final-wirklich erzeugen. Technisch würde das sogar funktionieren, nur wird es sehr schnell unübersichtlich. Du weißt irgendwann nicht mehr, welche Version tatsächlich die aktuelle ist, welche Änderung in welchem Ordner steckt und wie du zwei unterschiedliche Entwicklungsstände wieder zusammenbekommst.
Genau für dieses Problem gibt es Versionsverwaltung. Git ist ein solches Versionsverwaltungssystem. Es kann bestimmte Zustände deines Projekts speichern, miteinander vergleichen und später wiederherstellen. Außerdem ermöglicht Git, parallel an unterschiedlichen Entwicklungsständen zu arbeiten und diese anschließend wieder zusammenzuführen.
Wichtig ist dabei zunächst eine Sache: Git ist nicht GitHub. Git ist das eigentliche Werkzeug und funktioniert vollständig auf deinem Rechner. GitHub ist lediglich ein Dienst, auf dem Git Repositories gespeichert und mit anderen geteilt werden können. Du kannst Git also problemlos verwenden, ohne jemals ein Repository auf GitHub anzulegen.
Für diesen Artikel nehmen wir ein sehr kleines Java Projekt als Beispiel. Es enthält zunächst nur eine Klasse:
public class Calculator {
public int add(int a, int b) {
return a + b;
}
}
An diesem kleinen Projekt schauen wir uns Schritt für Schritt an, was Git mit deinen Dateien macht und was Begriffe wie Repository, Commit, Branch, Staging Area oder HEAD eigentlich bedeuten.
Vom Projektordner zum Repository
Am Anfang ist dein Java Projekt einfach nur ein Verzeichnis auf deiner Festplatte. Darin liegen deine Java Dateien, vielleicht eine pom.xml, einige Tests und alles andere, was zu deinem Projekt gehört. Git weiß zu diesem Zeitpunkt noch nichts von diesem Projekt und überwacht auch keine Dateien.
Wenn du innerhalb dieses Projektordners
git init
ausführst, initialisiert Git dort ein Repository. Dabei entsteht ein verstecktes Verzeichnis namens .git. In diesem Verzeichnis speichert Git seine eigenen Informationen, unter anderem die Historie deines Projekts, die vorhandenen Branches und weitere interne Daten.
Deine eigentlichen Projektdateien bleiben dabei ganz normale Dateien. Git verschiebt sie nicht in eine Datenbank und verändert auch nicht ihre Struktur. Der Ordner mit den Dateien, an denen du gerade arbeitest, wird als Working Tree oder Working Directory bezeichnet.
Wenn du jetzt unseren Calculator erweiterst und beispielsweise eine zweite Methode hinzufügst,
public int subtract(int a, int b) {
return a - b;
}
dann befindet sich diese Änderung zunächst nur in deinem Working Tree. Git erkennt zwar, dass sich Calculator.java verändert hat, speichert diesen neuen Zustand aber nicht automatisch in der Projektgeschichte.
Mit
git status
kannst du dir jederzeit anzeigen lassen, welche Dateien verändert wurden. Das ist einer der wichtigsten Git Befehle überhaupt, weil er dir zeigt, in welchem Zustand sich dein Repository gerade befindet.
Bevor aus deiner Änderung ein gespeicherter Stand wird, gibt es bei Git noch einen Zwischenschritt: die Staging Area. Mit
git add Calculator.java
übernimmst du die aktuelle Version dieser Datei in diesen Bereich. Du kannst dir die Staging Area als Vorbereitung für den nächsten gespeicherten Projektstand vorstellen. Mit git add sagst du Git also nicht "Speichere diese Änderung dauerhaft", sondern vielmehr "Diese Änderung soll Bestandteil meines nächsten Commits werden".
Erst anschließend erzeugst du mit
git commit -m "Add subtract method"
einen neuen Commit. Damit speichert Git den zuvor vorbereiteten Zustand in der Historie des Repositorys.
Der grundlegende Ablauf sieht deshalb so aus:
Dateien bearbeiten
|
v
Working Tree
|
git add
|
v
Staging Area
|
git commit
|
v
Repository
Diese Trennung wirkt anfangs vielleicht unnötig kompliziert, hat aber einen praktischen Vorteil. Du kannst mehrere Dateien verändern und anschließend genau auswählen, welche dieser Änderungen gemeinsam in einem Commit landen sollen. Wenn du beispielsweise gleichzeitig einen Fehler behebst und nebenbei noch eine README Datei überarbeitest, musst du daraus nicht zwangsläufig einen einzigen Commit machen.
Commits und die Geschichte deines Projekts
Ein Commit wird häufig einfach als Änderung bezeichnet. Für den Alltag ist das verständlich, für das eigentliche Git Modell ist eine andere Vorstellung aber hilfreicher: Ein Commit beschreibt einen gespeicherten Zustand deines Projekts.
Nehmen wir an, dein Calculator enthält im ersten Commit nur die Methode add(). Im nächsten Commit ergänzt du subtract() und danach kommt noch multiply() hinzu. Git kennt anschließend mehrere gespeicherte Zustände deines Projekts und verbindet diese miteinander.
Vereinfacht könnte diese Historie so aussehen:
A <- B <- C
A ist der erste Commit. B wurde danach erstellt und verweist auf A als seinen Vorgänger. C verweist wiederum auf B. Aus diesen Verbindungen entsteht die Projektgeschichte, die du später mit
git log
anzeigen kannst.
Jeder Commit besitzt eine eindeutige Kennung, den sogenannten Commit Hash. In Ausgaben von Git sieht dieser ungefähr so aus:
7a4c21f
Die vollständige Kennung ist länger, im Alltag werden aber häufig nur die ersten Zeichen angezeigt. Über diesen Hash kann Git einen bestimmten Commit eindeutig identifizieren.
Neben dem Hash enthält ein Commit noch weitere Informationen. Dazu gehören beispielsweise der Autor, der Zeitpunkt, die Commit Nachricht und die Verbindung zum vorherigen Commit. Git kann dadurch nicht nur unterschiedliche Projektstände speichern, sondern auch nachvollziehen, wie diese Zustände miteinander zusammenhängen.
Vielleicht fragst du dich an dieser Stelle, warum Git überhaupt von Commits und nicht einfach von Versionen spricht. Der Grund ist, dass du mit Git nicht nur eine lineare Reihe von Versionen erzeugen kannst. Du kannst Entwicklungswege aufteilen, unabhängig voneinander weiterführen und später wieder zusammenbringen. Genau dafür gibt es Branches.
Bevor wir dazu kommen, fehlt aber noch ein Begriff, der in Git Ausgaben sehr häufig auftaucht: HEAD.
HEAD beschreibt vereinfacht gesagt deine aktuelle Position innerhalb des Repositorys. Wenn dein aktueller Entwicklungsstand beim Commit C liegt, könnte das zunächst ungefähr so aussehen:
A <- B <- C
^
HEAD
In der Praxis zeigt HEAD meistens nicht direkt auf einen Commit, sondern auf einen Branch. Das klingt zunächst nach einem zusätzlichen Umweg, ergibt aber sofort Sinn, sobald du verstanden hast, was ein Branch tatsächlich ist.
Ein Branch ist einfacher, als er klingt
Das Wort Branch bedeutet Zweig. Genau dieses Bild wird häufig verwendet, um Git zu erklären: Eine Entwicklung läuft in eine Richtung und irgendwann entsteht ein zweiter Zweig, auf dem unabhängig weitergearbeitet wird.
Das Bild passt grundsätzlich, technisch ist ein Branch aber überraschend simpel. Ein Branch ist im Wesentlichen nur ein Name, der auf einen bestimmten Commit zeigt.
Wenn dein Repository momentan drei Commits enthält, könnte der Standard Branch main beispielsweise so aussehen:
A <- B <- C
^
main
main zeigt hier auf Commit C. Erstellst du anschließend einen neuen Commit D, bewegt sich dieser Zeiger automatisch weiter:
A <- B <- C <- D
^
main
Der Branch selbst enthält also keine eigenen Dateien und auch keine Kopie deines Projekts. Er zeigt lediglich auf den aktuellsten Commit dieser Entwicklungslinie.
Jetzt möchtest du unseren Calculator um eine Division erweitern, diese Änderung aber zunächst getrennt vom bisherigen Stand entwickeln. Dafür kannst du einen neuen Branch erstellen:
git switch -c feature-division
Direkt nach dem Erstellen zeigen beide Branches noch auf denselben Commit:
A <- B <- C
^
main
^
feature-division
Git legt dabei keine zweite Kopie deines Projektordners an. Du arbeitest weiterhin im selben Repository. Der Unterschied besteht lediglich darin, dass HEAD nun auf feature-division zeigt. Git weiß dadurch, dass dein nächster Commit auf diesem Branch entstehen soll.
Du ergänzt nun die neue Methode:
public int divide(int a, int b) {
return a / b;
}
Nachdem du die Änderung mit git add vorbereitet und anschließend committed hast, entsteht ein neuer Commit. Nur feature-division bewegt sich dabei weiter:
A <- B <- C
^
main
\
D
^
feature-division
main zeigt weiterhin auf Commit C, während feature-division inzwischen auf D zeigt. Damit existieren jetzt zwei unterschiedliche Entwicklungsstände innerhalb desselben Repositorys.
Wenn du anschließend
git switch main
ausführst, stellt Git deinen Working Tree wieder so her, wie das Projekt bei Commit C aussah. Die Methode divide() verschwindet aus deiner aktuellen Datei, wurde aber nicht gelöscht. Sie befindet sich weiterhin im Commit D und ist über den Branch feature-division erreichbar.
Mit
git switch feature-division
wechselst du wieder zu diesem Entwicklungsstand zurück. Git stellt die dazugehörigen Dateien erneut in deinem Working Tree bereit.
Auch HEAD lässt sich jetzt besser einordnen. HEAD zeigt normalerweise auf den Branch, auf dem du gerade arbeitest. Wenn du auf main stehst, gilt vereinfacht:
HEAD -> main -> C
Wechselst du auf feature-division, zeigt HEAD entsprechend auf diesen Branch. Dadurch weiß Git nicht nur, welchen Projektstand es in deinem Working Tree bereitstellen soll, sondern auch, welcher Branch beim nächsten Commit weiterbewegt werden muss.
Wenn deine neue Funktion fertig ist, möchtest du sie irgendwann wieder in main übernehmen. Dafür gibt es einen Merge. Du wechselst zunächst auf den Branch, in den die Änderung übernommen werden soll:
git switch main
Danach führst du
git merge feature-division
aus.
Git versucht nun, die beiden Entwicklungsstände miteinander zu verbinden. Wenn sich main seit der Erstellung von feature-division nicht verändert hat, kann Git den Zeiger häufig einfach auf den neueren Commit verschieben. Dieser einfache Fall wird Fast Forward genannt.
Sind dagegen auf beiden Branches neue Commits entstanden, muss Git die unterschiedlichen Entwicklungswege miteinander kombinieren. Das funktioniert in vielen Fällen automatisch. Erst wenn dieselbe Stelle auf beiden Seiten unterschiedlich verändert wurde, kann Git nicht selbst entscheiden, welche Variante richtig ist. Dann entsteht ein Merge Conflict.
Ein Merge Conflict bedeutet deshalb nicht, dass Git kaputt ist oder dein Repository beschädigt wurde. Git teilt dir lediglich mit, dass es zwei widersprüchliche Änderungen gefunden hat und nicht wissen kann, welche davon fachlich korrekt ist. Diese Entscheidung musst du selbst treffen.
Lokal, Remote und warum GitHub nur ein Teil davon ist
Bis hierhin hat alles vollständig auf deinem Rechner stattgefunden. Du hast ein Repository erstellt, Dateien verändert, Commits erzeugt und mit Branches gearbeitet. Dafür war weder eine Internetverbindung noch GitHub notwendig.
Ein Repository auf deinem eigenen Rechner bezeichnet man als lokales Repository. Zusätzlich kannst du dein Repository mit einem anderen Git Repository verbinden, das beispielsweise auf GitHub, GitLab oder einem eigenen Server liegt. Ein solches entferntes Repository nennt Git Remote Repository.
Wenn du ein bereits vorhandenes Projekt mit
git clone ...
herunterlädst, erstellt Git auf deinem Rechner ein lokales Repository und verbindet es gleichzeitig mit dem ursprünglichen Remote Repository. Dieses Remote bekommt normalerweise den Namen origin.
origin ist dabei nichts Besonderes und auch kein spezieller GitHub Begriff. Es ist lediglich der übliche Name für das Remote Repository, von dem ein Projekt ursprünglich geklont wurde.
Wenn du nun lokal einen neuen Commit erzeugst, existiert dieser zunächst nur auf deinem Rechner. Git überträgt Commits nicht automatisch auf einen Server. Erst mit
git push
sendest du deine lokalen Commits an das Remote Repository.
In die andere Richtung funktioniert es ähnlich. Mit
git fetch
fragt Git das Remote Repository nach neuen Commits und Informationen ab. Diese Daten werden heruntergeladen, dein aktueller Branch und dein Working Tree werden dabei aber zunächst nicht verändert.
Im Alltag wirst du häufiger
git pull
sehen. Ein Pull holt neue Änderungen vom Remote Repository und versucht anschließend, sie in deinen aktuellen Branch zu integrieren. Deshalb kann ein git pull beispielsweise auch einen Merge auslösen oder zu einem Merge Conflict führen.
Damit schließt sich der Kreis. Git besteht im Kern aus einigen wenigen Konzepten, die immer wieder miteinander kombiniert werden. Du hast deinen Working Tree, in dem du deine Dateien bearbeitest. Über die Staging Area bestimmst du, welche Änderungen Bestandteil des nächsten Commits werden. Commits speichern Zustände deines Projekts und bilden gemeinsam dessen Historie. Branches sind bewegliche Namen, die auf bestimmte Commits zeigen. HEAD beschreibt, auf welchem Branch du dich gerade befindest. Remotes sind zusätzliche Git Repositories, mit denen du Commits austauschen kannst.
Wenn dieses Modell sitzt, werden viele Git Befehle deutlich verständlicher. commit, switch, merge, push, pull, rebase oder reset wirken dann nicht mehr wie einzelne Kommandos, deren Verhalten du auswendig lernen musst. Sie arbeiten alle mit denselben grundlegenden Bausteinen: Dateien, Commits, Branches und Zeigern.
Und genau das ist die wichtigste Grundlage für alles, was später bei Git noch dazukommt.
