Git ist ein Programm, mit dem man Speicherpunkte mit dem Inhalt eines Verzeichnisses anlegen kann.
(Analogie Computerspiele: Spielstand)
Die Speicherpunkte heißen Commits. Jeder Commit ist eine “Version” des Verzeichnisinhalts.
Zu jedem Commit werden zusätzliche Daten gespeichert:
Diese beiden Informationen fügt Git automatisch ein. Zusätzlich kann man selbst noch eine Beschreibung des Commits hinzufügen (Commit-Message).
Die Gesamtheit aller Commits und zugehörigen Informationen wird Repository genannt.
Typische “Projekte”, die man mit einem Git-Repository verwaltet, sind
Vorteile, Git zu benutzen, sind:
Schutz vor versehentlich veränderten oder gelöschten Dateien
“mentale Entlastung”: Sicherheit, dass keine Dateien verändert oder gelöscht wurden (Analogie: Wohnungseinbruch)
Zwar erstmal kein Schutz vor Diebstahl oder Defekt des Computers, aber einfache Möglichkeit, gleichwertige Kopien von Repositories auf anderen Rechnern anzulegen (inkl. der ganzen Versionsgeschichte!)
Möglichkeit, gefahrlos Dinge auszuprobieren (z.B. Umformulierungen in der Bachelorarbeit, Einfügen von Debug-Code in Programmen → Rückkehr zur ursprünglichen Version problemlos möglich, auch ohne die Experimente zu verwerfen)
Dokumentation des Arbeitsfortschritts, dadurch ebenfalls “mentale Entlastung”: nach längerer Unterbrechung (Wochenende, Urlaub) einfacheres Fortsetzen der Arbeit
Git benutzt man durch Eingabe von Befehlen im Terminal.
Für die meisten Befehle (die sich auf ein bestimmtes Repository beziehen), muss das Arbeitsverzeichnis innerhalb des Repositories sein.
Die Befehle haben normalerweise die Form
$ git <Unterbefehl> <Argumente>
Die Dokumentation der Unterbefehle erhält man mit
$ man git <Unterbefehl>
oder auf https://git-scm.com/docs.
Mit dem Befehl
$ git init
wird im Arbeitsverzeichnis ein neues Git-Repository angelegt.
Das kann man daran erkennen, dass ein Unterverzeichnis namens .git
erzeugt wurde. Es enthält alle Daten, die zu dem Repository gehören.
(Falls man git init
in einem schon bestehenden Repository ausführt, wird dadurch kein Schaden angerichtet.)
$ git status
zeigt Informationen über den aktuellen Zustand des Repositories an, z.B.:
Um einen Commit zu erzeugen, d.h. eine Version des Projekts im Respository zu speichern, sind zwei Schritte notwendig:
Die Änderungen, die an einer Datei gemacht wurden, oder eine Datei, die noch garnicht im Repository gespeichert ist, werden für einen Commit vorgemerkt.
Anschließend wird ein Commit erzeugt, der alle dafür vorgemerkten Änderungen enthält.
Ein paar Begriffe in diesem Zusammenhang:
Der tatsächliche Zustand der Dateien wird Arbeitskopie genannt.
Der Zustand der Dateien, wie sie für den nächsten Commit vorgemerkt sind, wird Index genannt.
Der Sinn der Aufteilung in diese beiden Schritte besteht darin, dass es dadurch möglich ist, nur einen Teil der Änderungen in der Arbeitskopie in den nächsten Commit aufzunehmen.
Das möchte man zum Beispiel machen, weil
die Änderungen inhaltlich nicht zusammengehören und man sie deshalb in getrennten Commits haben möchte, oder
ein Teil der Änderungen nur testweise gemacht wurde und am Ende in gar keinem Commit enthalten sein soll.
Dateien werden mit dem Befehl
$ git add <Dateiname>
zum Index hinzugefügt (für den Commit vorgemerkt).
git status
zeigt auch an, welche Dateien bereits zum Index hinzugefügt wurden und welche nicht.
Mit dem Befehl
$ git reset <Dateiname>
werden Dateien wieder aus dem Index entfernt (nicht mehr für den Commit vorgemerkt).
Die genauen Änderungen (im Vergleich zum letzten Commit), die man an den Dateien gemacht hat, kann man mit verschiedenen Variationen von git diff
sehen:
$ git diff
zeigt die Änderungen in der Arbeitskopie, die noch nicht im Index sind.
$ git diff --cached
zeigt die Änderungen, die schon im Index sind, die also in den nächsten Commit aufgenommen würden.
Die Darstellung der Änderungen geschieht zeilenweise:
Zeilen, die vorher da waren und jetzt nicht mehr, wird ein -
vorangestellt.
Zeilen, die vorher nicht da waren, und jetzt neu sind, wird ein +
vorangestellt.
Wenn die Ausgabe der Änderungen länger als eine Bildschirmseite ist, verwendet Git automatisch less
, d.h. man kann wie gewohnt darin navigieren und kommt mit q
wieder heraus.
Nützliche Optionen für git diff
:
-w
: ignoriert geänderte Leerzeichen
--color-words
: zeigt Änderungen wortweise anstatt zeilenweise
Ist man mit dem Zustand des Index zufrieden (sprich: git diff --cached
zeigt genau die Änderungen an, die man im nächsten Commit haben möchte), wird mit
$ git commit
der Inhalt des Index zu einem neuen Commit gemacht. Git öffnet einen Texteditor, in dem man aufgefordert wird, die Commit-Message als Beschreibung der gemachten Änderungen einzugeben. (Welcher Texteditor das ist, kann man konfigurieren.)
Um die eingegebene Beschreibung zu bestätigen, muss man die im Texteditor geöffnete Datei (namens COMMIT_EDITMSG
) speichern und den Editor danach beenden. Erst dann schließt Git das Anlegen des Commits ab.
Mit der Option -m
kann man die Message (in Anführungszeichen) auch direkt an den Befehl übergeben.
Der Befehl
$ git show
zeigt den letzten Commit an.
Eine Liste aller Commits mit Beschreibung bekommt man mit
$ git log
Dabei steht der neueste Commit ganz oben.
Außerdem kann man mit dem Programm gitk
das Repository in einer grafischen Oberfläche betrachten.
Gibt es Änderungen in der Arbeitskopie (z.B. mit git status
oder git diff
festzustellen), die man wieder rückgängig machen möchte, kann Git mit dem Befehl
$ git checkout -- <Dateiname>
die angegebene Datei wieder auf den Zustand des letzten Commits zurücksetzen.
Hat man die Datei gleichzeitig z.B. in einem Texteditor geöffnet, muss man dort ggf. “neu laden”, um die wiederhergestellte Version der Datei zu sehen.
Es lohnt sich, bereits für kleine “Projekte” ein Git-Repository anzulegen. Das kann schon eine einzelne Datei sein, die man für länger als ein paar Minuten behalten möchte.
Sobald Dateien in einem Commit in einem Repository enthalten sind, sind sie erstmal “sicher”.
Folgende drei Befehle sollte man mindestens kennen und regelmäßig benutzen, um “Speicherpunkte” anzulegen:
git init
git add <Datei>
git commit
Alles weitere kann man sich auch noch hinterher überlegen!
Zum Beispiel: Wie komme ich wieder an eine alte Version des Projekts?
Diese Frage wird aber erst nach dem folgenden Theorieteil beantwortet!
(in einem anderen Fenster)
Die Daten, die zu einem Repository gehören, sind in Form von Objekten gespeichert, die sich im Verzeichnis .git/objects
befinden.
Es gibt vier Arten von Objekten:
Ein blob-Objekt enthält nur den Inhalt einer Datei, die man im Repository gespeichert hat.
Ein tree-Objekt enthält eine Auflistung von Dateinamen mit je einem Verweis auf ein blob-Objekt, also eine Zuordnung von Dateinamen zu deren Inhalten.
Ein commit-Objekt enthält die Metadaten, die zu einem Commit gehören, also
und weiterhin
und schließlich
Durch den Verweis auf ein tree-Objekt stellt Git dar, welche Dateien in welchen jeweiligen Versionen zu einem Commit gehören.
Durch den Verweis auf andere commit-Objekte wird die Reihenfolge der Commits dargestellt: Jeder Commit kennt seinen Vorgänger.
Wie schon angedeutet ist es auch möglich, dass ein Commit mehrere Vorgänger hat. Dazu später mehr.
Git benutzt das Konzept der Hash-Funktion, um die Objekte eindeutig zu identifizieren. Das hat einige bemerkenswerte Folgen.
Eine Hash-Funktion erzeugt aus einer Menge von Daten (z.B. der Inhalt einer Datei), einen Hash-Wert, oder einfach Hash.
Das besondere dabei ist (sofern die Hash-Funktion gut entworfen ist):
Der Hash ist viel kleiner als die Eingabedaten (genauergesagt ist er immer gleich groß, unabhängig von der Menge der Eingabedaten).
Zwei beliebige verschiedene Eingabedaten ergeben mit verschwindend geringer Wahrscheinlichkeit den gleichen Hash (das nennt man eine Kollision), oder umgekehrt formuliert:
Wenn zwei Eingabedaten den gleichen Hash ergeben, waren es mit ziemlicher Sicherheit die gleichen Daten.
Außerdem gibt es noch das Konzept einer kryptographischen Hashfunktion, die eine zusätzliche Eigenschaft hat, nämlich
Git verwendet die Hashfunktion SHA-1. Sie erzeugt aus einer beliebigen Menge von Daten einen 160-Bit Hash (d.h. es gibt 2160≈1048 mögliche Hash-Werte).
Ein SHA-1-Hash wird meistens als 40-stellige Hexadezimalzahl dargestellt.
Zum Beispiel:
$ echo -n "The quick brown fox jumps over the lazy dog" | sha1sum
2fd4e1c67a2d28fced849ee1bb76e7391b93eb12
Der Trick, den Git anwendet, besteht darin, den Hash-Wert eines Objekts als seinen Namen zu benutzen.
Bestimmte Berechnungen können sehr effizient durchgeführt werden.
Beispiel: Will man mit git diff
den Unterschied zwischen der Arbeitskopie einer Datei (f) und der im letzten Commit gespeicherten Version (g) wissen, berechnet Git zunächst den Hash (des Inhalts) von f.
Den Hash von g kennt es bereits (denn es ist der Name des entsprechenden Blobs). Stimmen die beiden Hashes überein, kann Git davon ausgehen, dass die beiden Dateien identisch sind und die genaue Untersuchung auf Unterschiede überspringen.
Das Speichern der Objekte des Repositories ist platzsparend.
Obwohl mit jedem Commit direkt der Inhalt aller Dateien abgespeichert wird, gibt es von jeder Version einer Datei nur ein zugehöriges blob-Objekt im Repository.
Erstellt man z.B. einen Commit, in dem sich eine Datei im Vergleich zum vorherigen Commit nicht geändert hat, kann Git schnell feststellen, dass es für die Datei kein neues blob-Objekt anlegen muss, weil es schon ein Objekt mit demselben Namen (dem Hash) gibt, in dem sich mit ziemlicher Sicherheit schon der Inhalt der Datei befinden muss.
(Da sich bei größeren Projekten mit vielen Dateien in jedem Commit meistens nur wenige Dateien ändern, kommt dieser Effekt dort stark zur Geltung.)
In einem Repository mit drei Commits könnte es z.B. die Objekte geben, wie hier gezeigt:
Dadurch, dass die einzelnen Objekte alle aufeinander verweisen, indem im Inhalt eines Objekts der Name (= Hash) anderer Objekte vorkommt, würde eine Änderung in einem einzelnen Objekt o dazu führen, dass sich die Namen aller anderen Objekte, die direkt oder indirekt auf o verweisen ändern würden.
Das bedeutet wiederum:
Kennt man den Hash eines Commits in einem eigenen Repository, kann man sich sicher sein, dass in einem anderen Repository, in dem es einen Commit mit demselben Hash gibt, die gesamte Versionsgeschichte die zu diesem Commit führt, inklusive aller Commit-Messages und aller Versionen aller Dateien mit dem eigenen Repository übereinstimmt
Sollte ein Objekt beschädigt sein (es ist ja selbst auch nur eine Datei auf der Festplatte), kann Git das feststellen, da die Verweise auf dieses Objekt nicht mehr funktionieren.
Das bedeutet zwar nicht unbedingt, dass man die Daten wiederherstellen kann, aber falls es Kopien des Repositories gibt, weiß man, dass man diese verwenden muss.
Die Wahrscheinlichkeit, dass es jemals zwei Git-Commits mit dem gleichen Hash gibt, wird z.B. hier diskutiert (sie ist sehr klein).
Die Objekte in einem Repository, also insbesondere die Commits, stehen nun zwar untereinander in Beziehungen, aber es gibt z.B. keine besondere Kennzeichnung des “neuesten Commits” (von dem aus man dann alle anderen erreichen kann).
Deswegen gibt es verschiedene Arten von Referenzen, die wie Lesezeichen auf einen bestimmten Commit sind.
Referenzen sind Dateien im Verzeichnis .git/refs/
, die jeweils den Hash eines Commits enthalten.
Die gebräuchlichste Art von Referenz heißt Branch, sie liegen im Verzeichnis .git/refs/heads/
.
In einem neu angelegten Repository gibt es standardmäßig einen Branch namens “master”.
Einen neuen Branch kann man mit dem Befehl
$ git branch <Name> <Hash>
erzeugen.
(Oder in gitk im Kontextmenü durch Rechtsklick auf einen Commit.)
Eine Liste aller Branches bekommt man mit
$ git branch
Einen Branch kann man mit
$ git branch -d <Branch>
löschen. Dabei wird aber wirklich nur die Referenz auf einen Commit gelöscht, alle Objekte, die über den Branch erreichbar waren, gibt es weiterhin (um wieder an sie zu gelangen, muss man deren Hash kennen).
Git löscht “verwaiste” Objekte erst, wenn sie älter als zwei Wochen sind.
Falls es mehrere Branches gibt, ist die Frage, welcher davon gerade “aktiv” ist.
Dazu gibt es wiederum eine symbolische Referenz, die nicht direkt auf einen Commit, sondern auf eine andere Referenz, z.B. einen Branch, verweist.
Sie heißt HEAD und ist in der Datei .git/HEAD
gespeichert.
Durch den Befehl git commit
werden folgende Dinge ausgelöst:
eingetragen wird.
Der Branch wird aktualisiert, so dass er auf den neu angelegten Commit anstatt den vorherigen verweist.
Mit dem Befehl
$ git checkout <Branch>
wird HEAD auf den angegeben Branch gesetzt und die Arbeitskopien der Dateien des Projekts auf die Versionen zurückgesetzt, die dem neuesten Commit des Branchs entsprechen.
Das Repository aus dem Bild von oben könnte mit zwei Branches “master” und “test” so aussehen:
Bereits durch git add
werden blob-Objekte erzeugt.
Die Objekte kann man mit
$ git cat-file -t <Hash>
$ git cat-file -p <Hash>
betrachten. (-t
zeigt blob, tree, oder commit, -p
zeigt den Inhalt des Objekts).
$ git show <Hash>
zeigt eine “menschenlesbare” Form der Objekte.
Die Befehle git add
, git reset
und git checkout
haben die Option -p
.
Damit kann man jeweils Teile der Änderungen in einer Datei interaktiv
Die Änderungen werden in sogenannte “Hunks” aufgeteilt, und man bekommt für jeden Hunk die Möglichkeit, die o.g. Aktion durchzuführen (y
) oder nicht (n
). Durch die Antwort q
bricht man die interaktive Abfrage ab.
Die Angabe eines Dateinamens (wie bei git add <Datei>
ohne -p
) ist hier möglich, um die Änderungen auf diese Datei zu beschränken, aber nicht nötig:
$ git add -p <Datei>
fragt nach den Änderungen in der Datei.
$ git add -p
fragt nach den Änderungen in allen Dateien.
Man kann die Hunks manchmal noch weiter aufteilen lassen, indem man die Antwort s
gibt.
Falls das immer noch nicht ausreicht, kann man mit der Auswahl e
die Hunks manuell bearbeiten. Es öffnet sich ein Texteditor, in dem man die entsprechenden Änderungen machen kann, und dann durch Speichern und Beenden zur Abfrage der restlichen Hunks zurückkehrt.
Das Bearbeiten eines Hunks beinhaltet (im Fall von git add
) sind im Wesentlichen Vorgänge möglich:
+
am Anfang verändern, löschen oder neu hinzufügen-
am Anfang der Zeile durch ein Leerzeichen ersetzen oder umgekehrtIm unteren Bereich der von Git automatisch geöffneten Datei ist eine Hilfestellung.
Um das Bearbeiten abzubrechen, muss man eine leere Datei abspeichern.
Vorwarnung:
Das Editieren der Hunks ist zwar sehr nützlich, erfordert aber viel Übung und auch genaues Vorgehen. Insbesondere im Fall von git reset
und git checkout
ist es etwas für Hartgesottene, da man zusätzlich noch “umgekehrt” denken muss.
Man sollte sich nicht entmutigen lassen, wenn Git die vorgeschlagene Bearbeitung eines Hunks nicht akzeptiert, sondern es immer wieder probieren!
Zur Erinnerung:
Das ganze dient dazu, inhaltlich unabhängige Änderungen, die man in der Arbeitskopie der Dateien gemacht hat, voneinander zu trennen, so dass jeder Commit für sich betrachtet möglichst viel Sinn ergibt.
Ein Commit kann mehr als einen Vorgänger haben.
Wie kommt man zu so einem Commit? Durch git commit
wird immer nur ein Commit erstellt, der einen Vorgänger hat.
Einen Commit mit mehr als einem Vorgänger (in der Praxis: zwei) bekommt man unter Umständen mit dem Befehl
$ git merge <anderer Branch>
in dem kein neuer Commit erzeugt wird:
Wenn der aktuelle Branch ein Vorfahre des anderen Branchs ist, kann der aktuelle Branch einfach auf den neuesten Commit im anderen Branch vorgerückt werden (“fast-forward”):
Situation vorher, “master” ist der aktive Branch:
Wird durch
$ git merge hotfix
zu
der Eintritt, wenn der aktuelle Branch kein Vorfahre des anderen Branchs ist, wie z.B. (“master” ist aktiver Branch):
Dann wird mit
$ git merge iss53
ein neuer Commit erstellt, der auf
als Vorgänger verweist.
Git versucht, die Änderungen, die in den Commits des anderen Branch seit dem gemeinsamen Vorfahren (im Beispiel: C3, C5) gemacht wurden, in den eigenen Branch zu übernehmen.
Wenn die Änderungen in beiden Branches sich nicht überlappen, klappt das normalerweise sehr gut.
Wenn in beiden Branches Änderungen an der selben Stelle in einer Datei gemacht worden sind, kann Git nicht wissen, welche Version die “richtige” sein soll.
Das nennt man einen Merge-Konflikt. Man muss als Benutzer selbst den Konflikt auflösen. Sieht man sich dazu nicht in der Lage, kann man mit
$ git merge --abort
den Merge-Vorgang abbrechen. Der Zustand des Repositories ist dann wieder wie vor dem versuchten Merge.
Die Auflösung eines Konflikts läuft so ab:
In git status
ist zu sehen, welche Dateien von einem Konflikt betroffen sind.
Die betroffenen Dateien wurden von Git modifiziert, in dem die Stelle, an der die Datei in beiden Branches seit dem gemeinsamen Vorfahren verändert wurde, markiert ist:
<<<<<<<
Inhalt der Datei im aktuellen Branch
=======
Inhalt der Datei im anderen Branch
>>>>>>>
Man muss die markierte Stelle durch den Inhalt ersetzen, den man als “gemeinsame Version” erachtet.
Mit git add
fügt man den neuen Inhalt zum Index hinzu und teilt Git dadurch mit, dass der Konflikt aufgelöst ist.
Jetzt sollte man sich mit git status
oder git diff --cached
vergewissern, dass die Änderungen aus beiden Branches wie gewünscht im Index sind.
Mit git commit
schließt man den Merge-Vorgang ab.
Hier ist es hilfreich, sich mit gitk das Ergebnis des Merges zu veranschaulichen.
(im Terminal)
Der Befehl
$ git clone <Ort> <Verzeichnis>
erzeugt eine Kopie des Repositories, dass in <Ort>
zu finden ist, im angegebenen Verzeichnis.
<Ort>
kann ein Pfad im Dateisystem des eigenen Rechners sein (z.B. ein USB-Stick), oder eine Adresse im Netzwerk.
Beim Klonen werden alle Objekte kopiert, so dass die gesamte Versionsgeschichte übertragen wird. Die beiden Repositories sind dann gleichwertig.
Das ursprüngliche Repository wird im neuen Repository als ein Remote eingetragen. Es hat standardmäßig den Namen “origin”.
Mit dem Befehl
$ git remote -v
bekommt man die Liste aller eingetragenen Remotes und der zugehörigen Ursprungsorte.
In diesem Zusammenhang gibt es eine weitere Art von Referenzen (neben Branches):
Die Branches im Originalrepository (dort im Verzeichnis .git/refs/heads
) werden im geklonten Repository als Remote-Referenzen im Verzeichnis .git/refs/remotes/<Name>
eingetragen.
Hat z.B. ein Branch namens master
im Remote namens origin
auf einen Commit gezeigt, dann gibt es im geklonten Repository eine Remote-Referenz mit dem Namen origin/master
auf denselben Commit.
Wenn es bereits eine Kopie des eigenen Repositories gibt, die noch nicht als Remote eingetragen ist, kann man mit
$ git remote add <Name> <Ort>
Das andere Repository, das an dem angegebenen Ort zu finden ist, als ein Remote mit dem angegebenen Namen anlegen.
Mit dem Befehl
$ git fetch <Remote>
werden alle Objekte aus dem Remote, die es noch nicht im eigenen Repository gibt, geholt, und die Remote-Referenzen aktualisiert. Das ist z.B. der Fall, wenn im Remotes neue Commits erstellt wurden.
Die eigenen Branches und die Arbeitskopie werden dadurch noch nicht geändert!
Das geschieht erst durch
$ git merge <Remote-Referenz>
(im Terminal)
Die Übungsaufgaben sind hier.