Übungsvideotranskript
Friedrich-Alexander-Universität Erlangen-Nürnberg  /   Technische Fakultät  /   Department Informatik

Git Crashkurs

Transkript zum Übungsvideo (Folien ).

Der primäre Zweck einer Versionsverwaltung ist das Archivieren von Dateien. Dadurch ist es möglich, die Versionsgeschichte, also wer was wann geändert hat, nachzuvollziehen. Und, bei Bedarf, auch alte Versionen wiederherzustellen. Im Bereich der Softwareentwicklung ist dies keineswegs eine neue Erfindung, sondern wird schon seit einem halben Jahrhundert eingesetzt.

Das 1972 von Bell Labs entwickelte Source Code Control System (SCCS) war das erste wirklich relevante Versionsverwaltungssystem, Da zu dieser Zeit Speicherplatz noch etwas kostbarer war, hat es die erste Version und darauf aufbauend nur die Änderungen gespeichert, und zwar in einem versteckten Unterordner.

Wenn nun aber die Versionsgeschichte länger wird, also viele Änderungen dazu kommen, dann kann das Durcharbeiten schon seine Zeit dauern, insbesondere da meist nur die neusten Versionen die interessanten sind. Jürgen Kleinöder hat da mit unserer DNS Verwaltung an der Uni auch das passende Beispiel - weshalb mit der Zeit auf das Revision Control System (RCS) umgestellt wurde. Dieses war zwar etwas einfacher gestrickt, hatte zum Beispiel keine Prüfsummen mehr, speicherte aber vor allem nur die letzte Version und Deltas zu den vorherigen Änderungen – und war damit deutlich performanter.

Und ja, RCS wird bei uns an bestimmten Orten immer noch verwendet, insbesondere um lokale Dateien, vor allem Konfigurationsdateien, zu tracken. Aber schon in den 80ern kam die Anforderung, Dateien nicht nur lokal, sondern projektbezogen über Netzwerk zu versionieren, was die Weiterentwicklung zum Concurrent Versions System (CVS) begründete. Dort konnten an einer zentralen Stelle im Netzwerk mehrere Dateien gespeichert werden, allerdings waren Revisionen weiterhin nur auf einzelne Dateien bezogen.

Dieses System erfreute sich insbesondere in der aufkommenden OpenSource-Bewegung großer Beliebtheit, wurde aber nach und nach von dem um die Jahrtausendwende entwickelten Subversion (SVN) abgelöst, welches zwar das Konzept einer zentralen Stelle adaptierte, nun aber gemeinsame Revisionen für mehrere Dateien einführte und im Gegensatz zu den Vorgängern auch beim Umbenennen und Verschieben von Dateien die Versionsgeschichte beibehielt.

Im Jahr zuvor wurde jedoch mit dem ursprünglich proprietären BitKeeper das erste bedeutende verteilte Versionsverwaltungssystem veröffentlicht. Das Konzept war nun für freie Software relativ interessant, weil man sehr einfach Forks erstellen und die Entwicklung eines Projekts in unterschiedliche Richtung vorantreiben konnte. Die dahinterstehende Frima BitMover hat das auch erkannt, und mittels einer umstrittenen Lizenz OpenSource-Projekten die kostenlose Nutzung erlaubt, jedoch unter der Bedingung, sich einige Zeit nicht an der Entwicklung konkurrierender Versionsverwaltungssysteme zu beteiligen. Aber natürlich dauerte es nicht lang, bis mit SourcePuller eine kompatible OpenSource-Alternative begonnen wurde, was dann schlussendlich dazu führte, dass 2005 alle kostenlosen Lizenzen beendet wurden, und viele OpenSource-Projekte nun ein Problem hatten.

Das traf auch Linux, dass seit 2002 auf BitKeeper aufgesetzt hatte – nach 10 Jahren komplett ohne Versionsverwaltungssystem. Obwohl zu dieser Zeit unter anderem mit Mercurial und Bazaar auch weitere freie verteilte Versionsverwaltungssystem das Licht der Welt erblickten, hat Linus Torvalds höchstpersönlich im April 2005 mit Git ein eigenes freies Tool begonnen. Für die erste Version brauchte er nur ein paar Tage, und bereits nach 3 Monaten wurde der komplette Linuxkernel damit verwaltet. Und da Git auch sehr gut auf unsere Bedürfnisse zugeschnitten ist, wollen wir es auch für die Übung verwenden.

Die Hauptmerkmale von Git sind unter anderem die nicht-lineare Entwicklung mittels verschiedener Zweige, welche normalerweise für einzelne neue Features verwendet werden, sowie die krypotografische Sicherheit des kompletten Versionsverlaufs durch einen auf SHA-1 Prüfsummen basierenden Hash-Baumes. Die Dateien eines Verzeichnisses werden dabei als Teilpakete in komprimierter Form in einem versteckten Unterordner gespeichert. Dabei wird die Entwicklung mit einem oder auch mehreren entfernten Repos unterstützt, aber nötig sind diese nicht – es kann auch komplett lokal gearbeitet werden.

Das will ich auch an einem einfachen praktischen Beispiel demonstrieren: Ich erstelle über die Kommandozeile einen Ordner beispiel, meinen Workspace. Mittels git init kann ich darin ein Git Repository initialisieren, das liegt dann im Unterordner .git. Ich lege eine leere Datei namens README.md an, und kann dann diese mittels git add hinzufügen – und damit in den Stagingbereich, auch Index genannt, kopieren, einer Sammelstelle, welche den nachfolgenden Commit vorbereitet.

Wenn ich diesen mittels git commit dann ausführe, werde ich aufgefordert, eine Nachricht einzugeben, optional auch mit ausführlichem Text, welche die Version der Dateien im Stagingbereich beschreibt. Dies wird dann aus diesem Bereich in das Repository gesichert, eindeutig identifizierbar durch eine 160bit Prüfsumme, bei welcher auch die ersten 7 Zeichen der Hexrepräsentation direkt angezeigt werden. Diese Kurzform ist in den allermeisten fällen bereits eindeutig.

Das wiederhole ich nun mit der Datei foo, die Commitnachricht kann ich mit dem Parameter -m direkt über die Kommandozeile eingeben. Dieser neue Commit wird nun fest als Nachfolger meines vorherigen Commits verankert. Die Prüfsumme wird nicht nur über die Datei foo gebildet, sondern über den gesamten Verlauf, also auch inklusive dem vorherigen Commit. Dadurch kann keine Datei oder Commit geändert oder hinzugefügt werden, ohne dass dieser Hash sich ändert – die Daten-Integrität ist gewährleistet.

Nachdem ich foo geändert und eine neue Datei bar erstellt hab, zeigt mir git status eben dies auch an. Außerdem sagt mir der Befehl noch, dass ich mich gerade auf dem Zweig master befinde.

Ich füge beide zum Stagingbereich hinzu, und ändere – ohne Commit – eine der Dateien. Nun verfolgt Git auch den Verlauf der Datei bar, und merkt, dass es hier Änderungen in der Datei im Workspace gab. Änderungen kann man sich auch anzeigen lassen, dabei zeigt git diff die Änderungen vom Workspace verglichen mit dem Stagingbereich an, ein Hinzufügen des Parameters --staged vergleicht nun die Staging Area mit dem neusten Commit.

Ich füge nun die neuste Änderung wieder in den Stagingbereich, und erstelle einen weiteren Commit, welcher zwei Dateien gleichzeitig umfasst. Dadurch kann ich Commits logisch so zusammenfassen, dass sie zum Beispiel immer ein volles Feature enthalten, auch wenn dies über viele Dateien verteilt ist.

Wenn ich mir die ganze Versionsgeschichte anschauen will, kann ich dies über git log tun, welcher mir Ersteller, Erstellungszeit und Nachricht anzeigt, inklusive dem vollständigen Commithash. Aber es geht mit git shortlog auch deutlich kompakter, hier wird nur die Kopfzeile einer jeden Commitnachricht ausgegeben. Soweit so einfach.

Aber wir wollen nun die nicht lineare Entwicklung genauer beleuchten. Dazu will ich direkt nach dem ersten Commit abzweigen. Mit git branch kann ich dann den Namen des neu zu erstellenden Zweigs angeben, gefolgt von dem Commithash, bei dem dieser Zweig beginnen soll – wenn ich das weglasse, beginnt er nach dem aktuellsten Commit.

Der Zweig temp ist erstellt, aber ich befinde mich noch auf master. Ich muss noch mit git checkout wechseln. Nun verschwinden in meinem Arbeitsverzeichnis die Dateien foo und bar, und ein git shortlog zeigt mir an, dass ich nur einen Commit habe.

Dann legen wir mal eine neue Datei pi an, und fügen eine Erklärung in der README.md hinzu. git add übergebe ich mit dem Punkt einfach schlicht diesen Ordner, damit sind dann beide Dateien im Stagingbereich, und mittels git commit werden diese versioniert. Und zwar als Nachfolger vom ersten Commit. Allerdings habe ich hier einen Fehler gemacht, die Zahl π ist natürlich grob falsch, sie beginnt mit 3.141, das bessere ich nun aus, und möchte den Commit richtigstellen, und das erlaubt mir der Parameter --amend. Dazu auch noch das -a, welches automatisch alle geänderten Dateien staged, so das ich mir hier das manuelle git add sparen kann. Wenn ich das ausführe, ändert sich natürlich der Commithash, da der Inhalt nun ein anderer ist.

Aber wir waren eigentlich bei Zweigen. Wenn ich jetzt durch das ganze Wirrwarr vergessen hab, worauf ich arbeite, und welche Zweige es so noch gibt, so hilft mir git branch. Um wieder zum master zurückzukehren, brauch ich nur wieder ein git checkout.

Wie kann nun hier die Änderungen aus dem anderen Zweig in master einfließen lassen? Eine Möglichkeit ist ein Merge, dazu muss ich dem Befehl git merge noch den Namen des anderen Zweigs mit angeben, und eine Nachricht spezifizieren, welche aber schon vorausgefüllt ist, und er erstellt dafür einen sogenannten Mergecommit. Wenn ich nun keine Konflikte hab, also nicht die gleichen Dateien in unterschiedlichen Zweigen geändert habe, klappt das auch ziemlich gut. Das Log zeigt mir die Geschichte an, Ersteller, Zeit und auch Commithashes haben sich nicht geändert, es gibt nur einen neuen Commit dazu.

Aber das reicht aus, um über die Ästhetik zu streiten, weshalb man manchmal ein anderes Vorgehen bevorzugt, und lieber die Geschichte neu schreiben will. Wir gehen zurück, direkt nach dem Korrekturcommit auf den Zweig temp. Mittels git rebase werden nun die anderen Commits von master geholt, und unser Commit ans Ende angehängt. Natürlich ändert sich dadurch der Commithash, aber dafür habe ich nun wieder eine lineare Geschichte und alle Änderungen, wie beim Merge. Nur in der Logdatei gibt es keinen extra Mergecommit, und wir befinden uns gerade auch noch auf dem Zweig temp.

Obwohl nun die meisten Git-Veteranen viele ihrer liebgewonnenen Befehle oder Parameter vermissen dürften, waren es doch im Beispiel bereits genug, um als Einsteiger erst einmal überfordert zu sein. Deshalb hier ein Überblick über die wichtigsten Operationen um Dateien ein- bzw. auszuchecken. Mittels git add kann eine Datei aus dem aktuellen Arbeitsverzeichnis nach Staging übernommen werden, ein git commit versioniert dann alle Dateien aus Staging. Mit git commit -a werden automatisch geänderte und gelöschte Dateien übernommen. Der Befehl git checkout ist etwas überladen, im Beispiel wurde er verwendet, um den Zweig zu wechseln, aber mit ihm können auch lokale Änderungen an Dateien mit der Version von Staging, oder der des letzten Commits bei Angabe von HEAD, ersetzt werden.

Aber wie anfangs erwähnt ist Git auch dezentral, neben dem lokalen Repo kann man auch noch ein entferntes Repo, zum Beispiel auf einem Server, haben. Initial kann man sich dieses entfernte Repo komplett klonen, also lokal spiegeln. Und lokale Commits können mittels git push in das entfernte Repo kopiert werden. Sofern nun da von anderen Nutzern neue Commits vorliegen, kann die Geschichte zuerst mit git fetch ins lokale Repo und dann mit git checkout in den Workspace geladen werden. Oder man nimmt die Abkürzung git pull, die das in einem Befehl macht.

Für solche entfernten Repos gibt es mittlerweile viele cloudbasierte Anbieter, der bekannteste dürfte GitHub sein, die alle zusätzlich mit einer Weboberfläche viele Einstellungen erlauben und schlussendlich dazu dienen, die Kollaboration von mehreren Entwicklern zu vereinfachen. Die CIP Admins betreiben im Auftrag des Departments seit Jahren eine GitLab Instanz, welche für die Lehre verwendet werden kann, und viele sonst kostenpflichtigen Optionen für uns bereitstellt. Ihr müsst euch dafür nicht extra registrieren, sondern es ist mit dem Identitätsmanagement der Uni verbunden, ihr könnt euch also direkt über SSO anmelden.

Und hier bekommt ihr auch automatisch nach der Anmeldung zur Betriebssystemübung ein Repo von uns erstellt. Dieses ist ein Fork von unserer Vorlage, welchen ihr einfach lokal klonen könnt. Allerdings ist der master Zweig im entfernten Repo für euch nicht schreibbar. Ihr müsst also für jede Aufgabe einen Branch anlegen, auf den ihr dann arbeiten könnt. Dieser Zweig soll natürlich von euch auch auf Gitlab gepusht werden, und, nachdem ihr die Aufgabe gelöst habt, könnt ihr über die Weboberfläche einen Merge-Request erstellen.

Dadurch werden wir informiert, und können eure Abgabe korrigieren. Sofern alles passt, werden wir sowohl eure Commits als auch die Vorgaben für die nachfolgende Aufgabe in den master-Branch mergen. Und ihr könnt dann mit der nächsten Aufgabe beginnen, ebenfalls wieder in einem eigenen, neuen Zweig.

Dieser Ablauf am konkreten Beispiel: Zuerst gehe ich auf die GitLab Webseite, in das Übungsrepo, und hole mir die URL. Ich habe bereits einen passwortlosen SSH-Zugang eingerichtet, kann also SSH bequem verwenden. Bei git clone kann man hinter die URL noch einen Ordner angeben, für mich soll das alles in den Ordner mpstubs wandern. Dort befinden sich nun die Vorgabedateien. Ich erstelle mit git checkout -b einen neuen Zweig aufgabe-0 und wechsle auch gleich hinein.

Wo fangen wir an? Das wichtigste zuerst, ich gehe in test-stream und bearbeite die Makefile so, dass sie mir im Zweifelsfall einen kleinen Motivationsschub gibt. Speichern, ausprobieren, funktioniert, ich bin voller Zuversicht, und füge die Änderungen hinzu, -u erwirkt bei git add, dass die geänderten Dateien nach Staging hinzugefügt werden, hier primär als Demonstration wie viele Varianten man bei Git hat.

Der neue Commit ist erstellt, und das will ich nun auf Gitlab pushen. Allerdings haut mir Git auf die Finger, da ich noch keinen Upstream-Branch hab – sagt mir allerdings auch gleich, wie ich diesen erstellen kann. In der Meldung wird mir nun auf der Kommandozeile gleich von Gitlab ein Link für den Merge-Request angezeigt. In der Weboberfläche kann ich nun auch den neuen Zweig auswählen, und auch meine Änderungen dort begutachten.

Gut, dann fange ich mal an die Aufgabe ernsthaft zu lösen, wechsle in den Ordner object um dort die stringbuffer-Headerdatei zu bearbeiten. Als absoluter Vim-Anfänger schaffe ich es mit nur drei Testen meine Datei zu leeren, und speichere und beende aus Schreck. Ein git diff bestätigt mir, dass alle Zeilen in der Datei gelöscht wurden, Aber ich hab ja den vorhin erwähnten befehl git checkout, und kann damit die Datei wiederherstellen.

Neuer Versuch, und nun bearbeite ich zielsicher den Konstruktor, initialisiere die Membervariable pos mit 0. Speichern, Commiten, mit sinnvoller Meldung… pushen – und Fehler. Die Fehlermeldung ist zum Glück nicht kryptisch, sondern erklärt direkt, das wohl jemand anderes in der Zwischenzeit in dieses Repo commited hat. Zur Sicherheit schaue ich mal auf die Weboberfläche, und in der Tat, meine Gruppenpartnerin Laura hat sich (natürlich vollkommen zufällig) exakt die gleiche Datei vorgenommen, war nur schneller als ich. Was mach ich nun? Es kann ja sein, dass ich schon andere Änderungen habe… Oder über die Weboberfläche gar nicht gesehen hab, dass es Änderungen gab…

Ich führe einfach ein git pull aus, und mir wird bereits erzählt, dass es Änderungen im entfernten Repo gab, die mit meinem lokalen Änderungen in Konflikt stehen, konkret in der Datei stringbuffer.h. In dieser Datei fügt Git nun auch eine spezielle Kennzeichnung ein, bestehend aus Größer-, Kleiner- sowie Ist-gleich-Zeichen – ich kann diese nun entweder händisch entfernen, oder ich verwende ein dafür vorgesehenes Mergewerkzeug, wie z.B. das Programm meld. Hier haben wir einen 3-Wege-Vergleich, bei dem ich meine Version links, Lauras Version rechts, und den gemeinsamen Vorgänger beider Versionen in der Mitte sehen kann. Da Lauras Version mit der initializer list die Schönere ist, übernehme ich ihre Änderung und speichere.

Nun gibt es keine weiteren Konflikte mehr, stringbuffer.h wurde geändert und es liegt noch eine Sicherheitskopie mit dem .orig-Suffix herum. Ich commite mit der vorhergesehen Nachricht und kann weiterarbeiten, zum Beispiel indem ich das wieder pushe.

Wenn ich nun der Meinung bin, dass die Aufgabe fertig ist, dann erstelle ich einen Merge-Request, zum Beispiel direkt über den angezeigten Link, alternativ kann ich das auch über die Weboberfläche auswählen. Bei Bedarf noch eine kurze Beschreibung. Zuweisen braucht ihr niemand, darum kümmern wir uns. stellt nur sicher das ihr alle relevanten commits im zweig auf GitLab habt, wenn ihr ihn den MergeRequest erstellt. Er ist dann so lange offen, bis ein Übungsleiter Zeit hat ihn zu reviewen, ihr könnt ihn selbst natürlich nicht akzeptieren. Sobald wir fertig sind, bekommt ihr eine Mail. In diesem Fall hat Chris ihn angeschaut. Mit unserer Änderung in der Makefile ist er einverstanden, aber ihm ist aufgefallen, dass noch einige Stücke fehlen, die müssen wir noch nachliefern, bevor er den Merge-Request annimmt und damit die Aufgabe abgeschlossen ist.

Dabei haben wir mit git clone, git push und git pull, sowie dem git mergetool für den Konflikt nur eine handvoll zusätzlicher Befehle gebraucht, um mit dem entfernten GitLab Repo zu arbeiten.

Allerdings solltet ihr vor der ersten Benutzung noch Git konfigurieren. insbesondere euren Namen und Email, damit die Commits richtig zugeordnet werden können. Insgesamt lässt sich git sehr gut an die eigenen Bedürfnisse anpassen, zum Beispiel Lieblingseditor oder Standard Mergetool. Mit --list kann man sich die Konfiguration anzeigen lassen. Außerdem erlaubt Git auch aliase, ich habe bei mir zum Beispiel ein git word-diff erstellt, welches mir statt einer gesamten Zeile nur die geänderten Zeichen direkt farbig markiert. Damit man Gitlab vernünftig verwenden kann, muss man dort seinen öffentlichen SSH Schlüssel eintragen. Wie man diesen erzeugt, erkläre ich in der ersten Aufgabe im Video Entwicklungsumgebung.

Git ist vom Konzept her sehr gut durchdacht, allerdings auch eine sehr mächtige und komplexe Anwendung, welche Anfänger schnell mit der Vielzahl von Befehlen und Parametern überfordert. Es dauert etwas bis man reinkommt, aber für eine grundlegende Bedienung reichen einige wenige Befehle aus. Wir haben am Ende zu dem zum Video gehörigen Folien ein kurzes Cheatsheet eingefügt, das euch hoffentlich beim Einstieg hilft. An sonst findet man auch auf Webseiten wie StackOverflow oftmals eine Lösung zu den häufigsten Problemen.