Einleitung

Was ist Git

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.

Anwendungsfälle

Typische “Projekte”, die man mit einem Git-Repository verwaltet, sind

Vorteile

Vorteile, Git zu benutzen, sind:

Einfachste sinnvolle Anwendung

Grundlagen

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.

Repository anlegen

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.)

Zustand des Repositories betrachten

$ git status

zeigt Informationen über den aktuellen Zustand des Repositories an, z.B.:

  • Wurden Dateien geändert oder gelöscht?
  • Wenn ja, welche?
  • Gibt es Dateien, die noch nicht zum Repository hinzugefügt wurden?

Neue Commits erzeugen

Um einen Commit zu erzeugen, d.h. eine Version des Projekts im Respository zu speichern, sind zwei Schritte notwendig:

  1. Die Änderungen, die an einer Datei gemacht wurden, oder eine Datei, die noch garnicht im Repository gespeichert ist, werden für einen Commit vorgemerkt.

  2. Anschließend wird ein Commit erzeugt, der alle dafür vorgemerkten Änderungen enthält.

Ein paar Begriffe in diesem Zusammenhang:

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

Index bearbeiten

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).

Änderungen betrachten

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

Commit erzeugen

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.

Versionsgeschichte betrachten

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.

Änderungen in der Arbeitskopie verwerfen

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.

Zusammenfassung

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:

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!

Vorführung der bis hierher gezeigten Befehle

(in einem anderen Fenster)

Datenmodell von Git

Objekte

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:

Blobs

Ein blob-Objekt enthält nur den Inhalt einer Datei, die man im Repository gespeichert hat.

Trees

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.

Commits

Ein commit-Objekt enthält die Metadaten, die zu einem Commit gehören, also

  • den Zeitpunkt des Commits
  • den Autor des Commits
  • die Beschreibung des Commits (Commit-Message)

und weiterhin

  • einen Verweis auf ein tree-Objekt

und schließlich

  • einen (oder mehrere) Verweise auf andere commit-Objekte.

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.

Hashes

Git benutzt das Konzept der Hash-Funktion, um die Objekte eindeutig zu identifizieren. Das hat einige bemerkenswerte Folgen.

Hash-Funktion

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

  • Es ist keine Möglichkeit bekannt, durch Wahl der Eingabedaten einen bestimmten Hash-Wert zu erhalten, oder umgekehrt formuliert, vom Hash-Wert auf die Eingabedaten zu schließen.

Anwendung in Git

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.

Vorteile

  • 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.)

Beispiel

In einem Repository mit drei Commits könnte es z.B. die Objekte geben, wie hier gezeigt:

Objekte in einem Git-Repository.
(Bildquelle)

Weitere Vorteile

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).

Referenzen

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.

Branches

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.

Beispiel

Das Repository aus dem Bild von oben könnte mit zwei Branches “master” und “test” so aussehen:

Objekte mit Referenzen.
(Bildquelle)

Vorführung

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.

Komplexere Anwendungsfälle

Genauere Kontrolle über den Index

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:

Im 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.

Vorführung

(Ich versuche mein Bestes in einem anderen Fenster, siehe Übung)

Tipps und Erklärungen hierzu gibt es z.B. hier und hier.

Merge

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>

Einfacher Fall,

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


(Bildquelle)

Allgemeiner Fall,

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

  • den bisher neuesten Commit im aktuellen Branch, und
  • den neuesten Commit im anderen Branch

als Vorgänger verweist.


(Bildquelle)

  • Der aktuelle Branch wird auf den neuen Commit vorgerückt,
  • der andere Branch bleibt von dem ganzen Vorgang unberührt.

Konflikte

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.

Vorführung

(im Terminal)

Kopien eines Repositories

Eine Kopie erzeugen

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.

Remote-Referenzen

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.

Repositories synchronisieren

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>

Vorführung

(im Terminal)

Übung

Die Übungsaufgaben sind hier.