Einführung

Willkommen zum Software Engineering Workshop für Kognitionswissenschaftler! In diesem Workshop erwerbt ihr die theoretischen und praktische Kenntnisse um das Teamprojekt im Rahmen des Informatik-Moduls erfolgreich zu meistern.

Workshop-Ziele

Wir konzentrieren uns auf folgende Schlüsselkomponenten:

  • Git: Lerne effektiv mit anderen Entwicklern an einem Softwareprojekt mittles Git zu arbeiten. Ziel ist das Verständnis des zugrundeliegenden Datenmodells, sowie die sichere Anwendung der wichtigsten Befehle.

  • SOLID Design Prinzipien: Lerne mittles den SOLID-Prinzipien und den darin enthaltenen Abstraktionsmechanismen qualitativ hochwertigen Code zu schreiben. SOLID ist ein Akronym für fünf Designprinzipien, die die Erstellung von Software erleichtern und die Wartbarkeit und Erweiterbarkeit des Codes verbessern.

  • Code-Smells: Lerne gängige "Code Smells" zu erkennen und zu beheben. "Code Smells" sind Anzeichen im Quellcode, die möglicherweise tiefer liegende Probleme signalisieren. Identifiziert und überarbeite schlechten Code, um die Qualität und Lesbarkeit eures Projekts zu verbessern.

  • SCRUM: Lerne die grundlagen von Agilem Projektmanagement und Scrum. Die Methodik ist weitverbreitet und unterstützt Teams dabei, in iterativen Sprints effizeint und produktiv zu arbeiten.

Zielgruppe und Vorraussetzungen

Dieser Workshop richtet sich explizit an Kognitionswissenschaftler der Universität Tübigen als Vorbereitung für das Informatik-Teamprojekt. Dementsprechend sind keine spezifischen Vorkenntnisse in der Softwareentwicklung erforderlich. Grundkenntnisse in einer oder mehreren Programmiersprachen jedoch von Vorteil.

Terminal / Shell

💡Wenn du dich schon mit dem Terminal bzw. einer Shell auskennst kannst du diese Seiter überspringen.

Was ist das Terminal?

Computer haben heutzutage eine Vielzahl von Möglichkeiten bedient zu werden. Sei es über die grafische Benutzeroberfläche, Sprachsteuerung oder sogar AR/VR. Diese sind für 80% der Anwendungsfälle großartig, aber sie sind oft in ihren Möglichkeiten grundlegend eingeschränkt. Man kann nichts tun, was nicht vorher programmiert wurde. Grade in der Softwareentwicklung stoßen wir hier an die Grenzen des Möglichen. Um die Möglichkeiten des Computers voll ausschöpfen zu können, müssen wir zurück zu den Anfängen des Computers und eine Textschnittstelle verwenden: Das Terminal.

Terminal und Shell werden oft synonym verwndet auch wenn sie nicht ganz das gleiche sind. Das Terminal ist ein Programm, das es ermöglicht, mit einer Shell zu interagieren. Die Shell ist das eigentliche Programm, das die Befehle ausführt.

In unserem Workshop liegt der Fokus auf der Bourne Again SHell, kurz "bash". Sie ist eine der am weitesten verbreiteten Shells und ihre Syntax ist ähnlich zu vielen anderern Shells. Um eine Shell-Prompt zu öffnen (in der du Befehle eingeben kannst), benötigst du zunächst ein Terminal. Auf deinem Gerät ist wahrscheinlich bereits eines installiert. (Nutzte auf Windows das Programm "Git Bash")

Die Shell nutzen

Wenn du dein Terminal öffnest, siehst du eine prompt die so oder ähnlich aussieht:

arch:~$

"arch" ist der Name des Computers, "~" ist das cwd (current working directory) bzw. aktuelles Verzeichnis und "$" signalisiert, dass du als normaler Benutzer angemeldet bist. (Adminastrator hat ein "#"). Das "~" ist eine Abkürzung für dein Home-Verzeichnis. auf Unix-Systemen ist das /home/deinBenutzername/ und auf Windows C:\Users\deinBenutzername\.

Im Laufe des Workshops werden immer wieder Befehle gezeigt, die du in deinem Terminal ausführen kannst. Sie haben immer die gleiche Form:

$ cat datei.txt
> Ich bin der Inhalt der Datei!

Wenn du den Befehl bei dir ausführen möchtest, must du das $ nicht mit eingeben. Es wird nur verwendet um zu zeigen, dass es sich um einen Befehl in der Shell handelt. Hinter dem > steht die Ausgabe des Befehls. In diesem Fall der Inhalt der Datei datei.txt.

Aber woher weiß das Terminal welches Programm es ausführen soll, wenn du den Behelt cat eingibst? Ähnlich zu Programmiersprachen hat auch die Shell ein Environment, das Kontext für die Ausführung von Befehlen bereitstellt. In diesem Environment gibt es eine PATH-Variable, die eine Liste von Verzeichnissen enthält, in denen die Shell nach Programmen suchen soll. Wenn du also den Befehl cat eingibst, sucht die Shell in den Verzeichnissen, die in der PATH-Variable aufgelistet sind, nach einer Datei namens cat und führt sie aus. Solltest du Probleme haben ein Programm im Terminal auszuführen, auch wenn du dir sicher bist, dass es installiert ist, wurde meistens das Verzeichnis, in dem das Programm liegt, nicht zur PATH-Variable hinzugefügt.

Du kannst die PATH und sämtliche anderen Environment-Variablen mit folgendem Befehlt anzeigen lassen:

  $ echo $PATH
  > /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

Die Ausgabe schaut bei euch vermutlich ähnlich aus.

Die Shell liegen die Identischen Verzeichnisse, die auch im Finder / Explorer zu finden sind, zugrunde. Navigation zwischen den einzelnen Verzeichnissen funktioniert über die Befehle cd (change directory) und ls (list). Mit cd kannst du in ein anderes Verzeichnis wechseln und mit ls kannst du dir den Inhalt eines Verzeichnisses anzeigen lassen.

Angenommen du hast folgende Ordnerstruktur:

 +-- ~
     +-- Dokumente
     |   +-- Texte
     |   |   +-- text1.txt
     |   |   +-- text2.txt
     |   +-- Bilder
     |   |   +-- bild1.jpg
     |   |   +-- bild2.jpg
     +-- Downloads
     |   +-- download1.zip

und du befindest dich in deinem Home-Verzeichnis (~). Wenn du nun sehen willst was sich dort befindet, kannst du den Befehl ls verwenden:

~$ ls
> Dokumente Downloads

Wenn du hinter ls einen Pfad angibst, wird der Inhalt dieses Verzeichnisses angezeigt. So kannst du zum Beipiel den Ordner Texte anzeigen lassen:

~$ ls Dokumente/Texte
> text1.txt text2.txt

Um in andere Ordner zu wechseln wir der Beffehl cd verwendet. Wenn du in den Ordner Texte wechseln möchtest, gibst du folgendes ein:

~$ cd Dokumente/Texte
~/Dokumente/Texte$ ls
> text1.txt text2.txt

Absolute und relative Pfade

In unseren vorherigen Beispielen haben wir alle Pfade relativ zu deinem aktuellen Verzeichnis angegeben. Das heißt, dass der Bezugspunkt immer dein aktuelles Verzeichnis ist. Es gibt aber auch absolute Pfade, die immer von der Wurzel des Dateisystems ausgehen. Auf Unix-Systemen ist das / und auf Windows C:\. Wenn du also in deinem Home-Verzeichnis bist und in den Ordner Texte wechseln möchtest, kannst du auch den absoluten Pfad angeben:

~$ cd /home/deinBenutzername/Dokumente/Texte

Um den absoluten Pfad zu deinem aktuellen Verzeichnis zu sehen, kannst du den Befehl pwd (print working directory) verwenden.

Um mit einem relativ zu deinem aktuellen Ordner nach oben zu gehen, kann der Pfad .. verwendet werden.

~/Dokumente/Texte$ cd ..
~/Dokumente$

.. kann auch in Kombination mit dem bereits bekannten relativen Pfad Syntax verwendet werden. Ein wechsel von Dokumente zu Downloads wird somit recht einfach.

~/Dokumente$ cd ../Downloads
~/Downloads$

Skripte

Wir werden in unserem Workshop nicht näher darauf eingehen, jedoch ist es wichtig wenigstens den Begriff zu kennen.

Hinter dem recht unscheinbaren bereits bekannten Shell-Syntax versteckt sich eine Shell-Sprache die viele Features einer vollständigen Programmiersprache beinhaltet. So existieren Schleifen, if-Bedingungen, etc. Bündlet man mehrere Shell-Befehle spricht man von einem Shell-Skript. Diese ermöglichen leichte reproduzierbarkeit von komplizierteren Tätigkeiten und ein einfaches Teilen von Befehlen mit anderen Personen.

Git?!? - Was ist das eigentlich?

Stell dir vor, du arbeitest gemeinsam mit einem Team an einem Softwareprojekt. Jeder arbeitet selbstständig an seinen Aufgaben und Datein, aber immer wieder muss alles zusammengeführt werden, damit alle auf dem neusten Stand sind.

In unserem Beispiel tauscht sich dein Team per E-Mail aus und schickt immer wieder E-Mails mit Dateianhängen hin und her. Bei 2-3 Datein mag das noch gehen, aber sobald es mehr wird, wird es schnell unübersichtlich, kompliziert und fehleranfällig.

Was sind Versionsverwaltungssysteme?

Versionierungssysteme (VCS = Version Control System) sind Werkzeuge, die es ermöglichen Änderungen an Quellcode (oder anderen Sammlungen von Dateien und Ordnern) zu verfolgen. Darüber hinaus erleichtern sie die Zusammenarbeit mit anderen Entwicklern. VCSs verfolgen Datein und Sub-Ordner in einem ausgewählten Ordner und speichern von diesen eine Serie von Snapshots. Diese enthalten neben den Datein auch Metadaten, wie zum Beispiel Autoren und assoziierte Nachrichten.

Aber warum ist das nützlich? Auch wenn du alleine arbeitest, ermöglichen dir VCSs, alte Zustände eines Projekts anzusehen, Protokoll zu führen, warum bestimmte Änderungen gemacht wurden, parallel an verschiedenen Features zu arbeiten, zu früheren Zuständen zurückzuspringen und vieles mehr. Bei der Arbeit mit anderen sind sie ein unschätzbares Werkzeug, um zu sehen, was andere geändert haben, sowie um Konflikte beim parallelen Bearbeiten von Datein zu minimieren.

Moderne VCSs ermöglichen es dir auch leicht (und oft automatisch) Fragen zu beantworten wie:

  • Wer hat dieses Modul geschrieben?
  • Wann wurde diese bestimmte Zeile dieser speziellen Datei bearbeitet? Von wem? Warum wurde sie bearbeitet?
  • Im Laufe der letzten 1000 Änderungen, wann und warum hat ein bestimmter Unit-Test aufgehört zu funktionieren?

Alternativen zu Git

Während Git heute der de fakto Standard ist, gibt es auch andere Systeme, die ähnliche Funktionen bieten:

  • Mercurial
  • Subversion (SVN)
  • Bazaar

Jedes dieser Systeme ermöglicht ähnliche Funktionen wie Git, ist aber deutlich weniger verbreitet und wird in der Regel nur in speziellen Anwendungsfällen eingesetzt.

Installation und Konfiguration von Git

Folgen den Platform-spezifischen Anweisungen um Git auf deinem System zu installieren und komme danach zurück um die Konfiguration abzuschließen.

Konfiguration

Die restliche Konfiguration läuft analog zu der auf Linux.

$ git config --global user.name "Dein Name"
$ git config --global user.email "deine@email.de"
$ git config --global init.defaultBranch main
$ git config --global color.ui auto

Verwende hier die Mail-Adresse, die du auch für deinen Github-Account verwenden wirst / verwendet hast.

Github Account

Github ist eine Plattform, auf der Git-Repositories hosted werden können. Was genau das ist lernst du später. Hier sei nur gesagt, dass Github nicht das Gleiche wie Git ist und Git auch mit einem anderen Anbieter wie Gitlab oder Bitbucket verwendet werden kann. In unserem Workshop verwenden wir Github.

Erstelle dir nun einen Account auf Github. Verwende die Mail-Adresse, die du auch in der Konfiguration von Git verwendet hast.

SSH-Key

Um Git mit Github zu verbinden, benötigst du einen SSH-Key. Dieser wird verwendet um dich zu authentifizieren und eine sichere Verbindung zu Github herzustellen. Github stellt hier sehr gute Anleitungen zur Verfügung. Folge den Schritten hier um einen SSH-Key zu erstellen und den Schritten hier um diesen zu Github hinzuzufügen.

Verknüfung testen

Beim ersten Verbinden mit einem neuen Server (z.B. Github) bittet euch ssh, den Schlüssel dieses Servers zu überprüfen. Am besten macht ihr das nun für Github: Führt den Befehl

ssh -T git@github.com

aus und akzeptiert den Schüssel mit yes

Installation auf Windows

1. Git installieren

Ladet Euch hier den passenden Installer für euer Windows-Gerät herunter und folgt den Anweisungen im Installationsfenster. An den Konfigurationen müsst Ihr nichts ändern.

2. Konfiguration

Folge nun der restlichen Konfiguartion hier

Tipps

Wenn ihr Windows benutzt und euch gewundert habt, warum ihr keine Dateien mit "."-Präfix seht (z.B. ".git", ".gitconfig", etc.) bzw. mit "."-Suffix (z.B. ".txt", ".mp4", etc.), dann könnt ihr das folgendermaßen ändern:

  1. Sucht nach den "Explorer-Optionen" (entweder über das Suchfeld in den Einstellungen oder indem ihr mit dem Rechtsklick auf den Explorer klickt und auf "Eigenschaften" klickt).

  2. Klickt in dem neu geöffneten Fenster auf den Reiter "Ansicht"

  3. Deaktiviert "Erweiterungen bei bekannten Dateitypen ausblenden"

  4. Wechselt die Option von "Ausgeblendete Dateien, Ordner oder Laufwerke nicht anzeigen" zu "Ausgeblendete Dateien, Ordner oder Laufwerke anzeigen"

Installation auf MacOS

Homebrew

Falls noch nicht der inoffiziellen (aber sehr verbreitete) Paketmanager homebrew auf deinem MacBook installiert ist, ist jetzt der Zeitpunkt. brew ermöglicht die einfache Installation von Software und wird dir noch oft in der Entwicklung auf MacOS begegnen.j

Die Installation ist sehr simple. Kopiere den folgenden Befehl in dein Terminal und folgen den angezeigten Anweisungen.

$ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

Das $ Zeichen wird verwendet um zu signalisieren, dass der folgende Befehl im Terminal auszuführen ist und ist nicht Teil des Befehls.

git

  1. Intalliere nun git mittels brew:

    $ brew install git
    
  2. Folge nun der restlichen Konfiguartion hier

Installation auf Linux

💡 In Linux (Ubuntu) öffnet man am schnellsten ein Terminal mit Strg + Alt + T. Auf andern Distributionen kann das abweichen.

1. Git installieren

Git kann über den Paketmanager der jeweiligen Distribution installiert werden. Der Befehl lautet also auf Debian-basierten Systemen wie Ubuntu oder Linux Mint:

$ sudo apt-get install git

💡 Das $ Zeichen wird verwendet um zu signalisieren, dass der folgende Befehl im Terminal auszuführen ist und ist nicht Teil des Befehls.

Eine Liste mit dem Installationsbefehl für eine große Menge an anderen Distributionen findet sich hier auf der offiziellen Website von Git.

2. Konfiguration

Folge nun der restlichen Konfiguartion hier

How to git

Git wird oftmals als ein unschönes aber leider notwendiges Tool zur Kollaboration mit anderen gesehen. Viele unterstellen git extreme Komplexität und ein schwieriges User Interface. Das führt manchmal zu der unten Dargestellten bizzaren Situation.

Meme

Ziel unseres Workshops ist es, dass dir diese Situation nicht passiert. Du wirst nicht nur Shell-Befehle lernen, sondern vielmehr die zugrundeliegenden Mechanismen verstehen. Wenn du die inneren Mechansimen verstanden hast, wird auch das Interface einfacher und deutlich intuitiver zu verwenden.

Datenmodel

Es gibt viele verschieden Ansätze für die ein VCS zu implementieren. Git verwendet ein sehr einfaches aber durchdachtes Modell, dass uns ermöglicht Features wie Branches, Kollaboration und eine Dateihistorie zu nutzten

Snapshots / Commits

Unser Modell beinhaltet einer Sammlung von Ordnern und Datein in einem "top-level" Ordner - ein Snapshot - auch Commit genannt. Von git wird eine Datei auch als blob bezeichnet und ist letzendlich nur eine Sammlung aus Bytes. Das Dateiformat spielt somit keine Rolle. Ein Ordner wird als tree bezeichnet und ordnet einem Namen einen oder mehrere blobs und andere trees zu. Ordner können dementsprechend andere Ordner oder Datein enthalten und sind identisch vom Aufbau zu unserem Dateisystem. Zum Beispiel:

<root> (tree)
|
+- beispiel (tree)
|  |
|  + hello.txt (blob, contents = "hello world")
|
+- inhalt.txt (blob, contents = "git ist super")

Der top-level tree referenziert den tree "beispiel" (der wiederum einen blob enhält) und den blob "inhalt.txt".

Snapshots in Relation

Snapshots alleine bringen uns noch keinen Mehrwert. Erst wenn wir mehrere Snapshots in Relation setzten, kommen wir unserem Ziel eines VCS näher.

Version history (Versionsgeschichte)

Widmen wir uns zuerst der wichtigsten Annforderung an unser Modell - der Versionsgeschichte. Zu jedem Zeitpunkt wollen wir Änderungen an unseren Dateinen nachverfolgen und falls nötig Rückgängig machen können. Das einfachste Modell ist die zeitliche Anneinanderreihung unserer Snapshots. Wir erhalten Modell mit einer rein linearen Relation.

o <--- o <--- o <--- o <--- o

Jedes o symbolisiert hier einen Snapshot und jeder <--- eine Parent-Child-Relation. Das Child zeigt hierbei immer auf den Parent.

Sollten wir nun eine Änderung vorgenommen haben, die wir Rückgängig machen wollen. Suchen wir vom letzten (rechten) Snapshot in den Parent Snapshots (von rechts nach links), bis wir einem Snapshot gefunden haben, der unsere Datei im gewünschten Zustand enthält. Den Inhalt können wir kopieren und in unserem aktuellen Snapshot einfügen.

Eine Version History ist somit möglich.

Branches

Softwareprojekte werden selten in Isolation geschrieben. Während Person A an dem einen Feature arbeitet, schreibt Person B vielleicht an einem anderen. Dies bringt Probleme mit sich, die wir in unserem aktuellen Modell nicht lösen können. Schauen wir uns ein Beispiel an:

Am Angang haben wir einen Commit (Snapshot) der leeren Datei src/datei.txt. Dann arbeiten Alice und Bob parallel an dieser:

Alice in src/datei.txt:

fn hello_world() {
   print("Hello World")
}

fn main() {
   hello_world()
}

Bob in src/datei.txt:

fn hello(name: String) {
   print("Hello" + name)
}

fn main() {
   hello("Bob")
}

srcist unser top-level-Ordner. Beide Personen schreiben an der selben Datei. Nur die Inhalte sind leicht unterschiedlich. Alice will das klassiche "Hello World" ausgegeben, während Bob lieber den eine Begrüßung mit Namen ausgeben möchte. Wir gehen davon aus dass sich nur diese Datei in dem Ordner src befindet. Wenn somit beide einen Commit (=Snapshot) erstellen haben diese die Form:

src (tree)
|
+- datei.txt (blob, contents = ...)

Aber wie sollen wir beide in Relation setzten? Unser bisheriges Modell hat sich an die zeitliche Reihenfolge gehalten. Der Commit mit der leeren Datei kommt somit zuerst, aber was ist mit den anderen beiden? Wenn Bob nach Alice seine Commit erstellt, wird Alice ihre Arbeit überschrieben. Wenn Alice nach Bob commited ist es genau anders herum. Paralleles Arbeiten ist somit noch nicht möglich.

Paralleles Modell

Die Zeit war bis jetzt unsere x-Achse und wenn wir eine sinnvolle Version History behalten wollen, sollten wir das auch nicht ändern. Alice und Bob arbeiten aber parallel an ihren Features. Wir wollen also wenn möglich eine zeitlich äquivalente Einordnung von beiden Commits. Warum nicht einfach übereinander?

o <--- o (Alice)
  ^
   \
    -- o (Bob)

Der erste Commit bleibt unverändert. Alice und Bobs Commits werden aber nicht mehr in eine Parent-Child Relation gebracht, sondern sind vielmehr in einer Sibling Relation. Beide haben als Parent den gleichen ersten Commit.

Die Struktur unseres Modells ändert sich also von einer reinen linearen Aneinanderreihung zu einem Baum. Paralleles Arbeiten wird somit möglich und Alice und Bob kommen sich nicht mehr in die Quere. Diese unterschiedlichen Stränge werden in git als Branches bezeichnet.

Wen du schon Algorithmen gehört hast, wird dir das bekannt vorkommen. Falls nicht ist auch nicht schlimm. Ein Baum besteht aus lauter Knoten (hier: Commits) die genau einen Parent haben. Ein Knoten kann aber mehrere Kinder haben. Also wie ein Baum in der Natur, nur dass wir ihn meistens um 90° oder 180° gedreht darstellen.

Aktuell haben wir unsere Branches nach den Namen der Personen benannt, aber in der Praxis benennen wir sie nach den Features an denen wir arbeiten. Also lass uns die Namen ändern:

o-1 <--- o-2 (feature_a)
  ^
   \
    -- o-3 (feature_b)

Damit wir einfacher Commits referenzieren können, haben wir ihnen eine Nummer gegeben. Die Reihenfolge der Nummern spielt dabei keine Rolle, sondern ist nur zur Unterscheidung gedacht. Git verwendet statt Nummern Hashes mehr dazu aber später.

Aus Perspektive von Branch feature_a existieren nur zwei Commits o-1 und o-2. Von Branch feature_b existier Commit o-1 und o-3.

‼️ Commits können nur existieren, wenn sie von einem Branch direkt oder indirekt referenziert werden. Ein Commit der von keinem Branch referenziert wird, wird von git als unreachable bezeichnet und nach einer gewissen Zeit gelöscht.

Branch Namen müssen eindeutig sein und dürfen keine Leerzeichen enthalten. Die Namen müssen keinen Konventionen folgen, es ist aber ratsam sich auf eine gemeinsame Konvention im Team zu einigen.

Merging Branches

Wir haben nun zwei Branches die parallel an ihren Features arbeiten. Aber wie können wir den Fortschritt von beiden zusammenführen? Warum nicht einfach die Commits und damit auch Änderungen von beiden Branches in einen Commit zusammenfassen?

o-1 <--- o-2 <--- o-4 (feature_a, feature_b)
  ^              /
   \            /
    -- o-3 <---

Hier erstellen wir einen neuen Commit (o-4) der zwei Parents besitzt: o-2 und o-3. Dieser Commit enthält somit die Änderungen von beiden Branches. Diese Möglichkeit Branches zusammenzuführen wird in git als Merge bezeichnet.

Unser Modell müssen wir somit erweitern. Ein Knoten / Commit in einem Baum kann immer nur ein Parent erhalten. Unser Merge-Commit o-4 hat aber zwei Parents. In der Graphentheorie nennen wir diese neue Struktur einen DAG (Directed Asyclic Graph). Es hat nur eine wichtige Eigenschaft: Es gibt keine Zyklen. Das bedeutet, wenn wir den Verbindungen (Relations / Pointern) folgen, werden wir nie wieder an den selben Knoten zurückkommen.

So schön und unkompliziert das Merging in der Theorie klingt, ist es in der Praxis leider nicht immer. Auch wenn git recht intelligent mit dem Zusammenführen von Änderungen umgeht, kann es immer noch zu Konflikten kommen, die dann manuell gelöst werden müssen. Hier fokusieren wir uns erstmal auf die Theorie und lernen später wie wir mit Konflikten umgehen.

Rebasing Branches

Das einfache Zusammenführen von Branches ist nicht die einzige Möglichkeit Änderungen von der einen Branch mit der andern zu kombinieren. Git erlaubt uns auch das einfache "anhängen" von Commits aus einem Branch an einen anderen. Der Vorgang wird als Rebase bezeichnet.

Wenn wir nun also die Commits von Branch feature_b an Branch feature_a anhängen wollen, verändert sich unser Commit-Graph wie folgt:

o-1 <--- o-2 (feature_a)
  ^
   \
    -- o-3 (feature_b)

== (rebase feature_a feature_b) ==

o-1 <--- o-2 (feature_a)
           ^
            \
             -- o-3 (feature_b)

feature_a besteht nun aus den Commits o-1 und o-2, während feature_b nun aus o-1, o-2 und o-3 besteht. feature_a verändert sich somit nicht. Auch hier kann es selbstverständlich zu Konflikten kommen, die manuell gelöst werden müssen. Dazu später mehr.

Commits

Du hast Commits bereits als "Snapshots" deiner Datein kennengelernt, dass ist nicht falsch, aber auch nicht ganz vollständig. Zusätzlich zu dem Zustand deiner Datein, speichert ein Commit noch Metainformationen wie:

  • Autor
  • Nachricht
  • Zeitstempel

Commits sind immutable. Das bedeutet, dass Änderungen an alten Commits immer zwangsweise einen neuen Commit erstellen. Ohne diese Eigenschaft hätten wir keine klar nachfolziehbare Version History.

Jedes Mal den vollständingen Datei-Tree zu speichern ist nicht nur ineffizient, sondern auch unnötig. Um das zu verhindern, werden gleiche Dateien und Verzeichnissse nur einmal gespeichert und sind dann von mehreren Commits referenzierbar. Eine einfache Implementierung dieser Optimierung ist durch die Immutablilty der Commits möglich.

Datenmodell in Git

Commit Hashes

Unser Datenmodell von der letzten Seite ist schon recht fortschrittlich, jedoch gibt es im Bezug zu git noch ein paar kleine Unterschiede. Bisher haben wir Commits der Einfachkeit halbar mit o-x bezeichnet. In git werden Commits jedoch mit Hashes referenziert. Ein Hash ist eine Art Fingerabdruck eines Objekts. Der Hash eines Objekts ist immer gleich, solange sich das Objekt nicht verändert. Git hat sich für einen SHA-1 Hash entschieden, der 40 Hexadezimalzeichen lang ist. Da diese jedoch extrem unterschiedlich sind, reicht es meistens nur die ersten 7 Zeichen zu verwenden.

So schaut zum Beispiel der Hash des eines Commits von diesem Buch aus:

$ git rev-parse HEAD
> 9af8a075faebfc5391a1caf52116fc2cd45b2a13

Es reichen aber die ersten 7 Zeichen:

$ git rev-parse --short HEAD
> 9af8a07

So können wir das Beispiel von vorhin auch mit Hashes darstellen:

o-1 <--- o-2 (feature_a)
  ^
   \
    -- o-3 (feature_b)

== wird zu ==
9af8a07 <--- 3b2e1a2 (feature_a)
  ^
   \
    -- 1a2b3c4 (feature_b)

Die Hashes sind hierbei komplett zufällig und dienen nur zur Veranschaulichung.

HEAD

Bisher haben wir uns immer nur das große ganze angeschaut, uns aber noch nicht überlegt wie wir auf die unterschiedlichen Commits bzw. Branches zugreifen können. Hier kommt der HEAD ins Spiel. Der HEAD ist ein Pointer auf einen beliebigen Commit oder Branch. Wenn wir also den Inhalt von feature_a anschauen wollen, setzten wir den HEAD auf feature_a und können dann den Inhalt von feature_a betrachten. Selbiges funktioniert mit feature_b oder anderen beliegibigen Branches. Da die meisten Operationen in git relativ zu HEAD arbeiten ist es wichtig zu wissen, wo dieser sich grade befindet. Wir fügen also noch HEAD in unser Datenmodell ein:

9af8a07 <--- 3b2e1a2 (feature_a)
  ^                     ^
   \                    HEAD
    -- 1a2b3c4 (feature_b)

Hier ist HEAD auf feature_a gesetzt.

Besonderheiten von Branches

Bisher haben wir Branches nur sehr intuitiv definiert gehabt. Jeder Ast hat einfach einen Namen bekommen. Aber was genau ist ein Branch?

Ein Branch ist ähnlich wie HEAD ein Pointer auf einen Commit. Der Unterschied lässt sich gut an einem Beispiel festmachen:

Wir nehmen an du wanderst gerne durch die Alpen. Auf deiner heutigen Wanderung plannst du zwei Gipfel zu besteigen. Du startest also am Parkplatz und wanderst zum ersten Gipfel. Dort angekommen, machst du eine Pause und genießt die Pause bevor es weiter zum zweiten Gipfel geht.

Die Gipfelkreuze mit ihren Bergnamen sind die Branches und du als Wanderer bist der HEAD. Auch HEAD sieht immer nur aus seiner eigenen Perspektive und führt alle Aktionen relativ dazu aus.

Der Berg selber sind die Commits. Berge wachsen über die Zeit und werden immer ein bischen höher. Das Gipfelkreuz bleibt aber immer oben auf dem Berg. Wenn du also einen neuen Commit unserem Branch hinzufügst, wächst der Berg und das Gipfelkreuz wird ein Stück höher.

Tags

Schauen wir uns nochmal unsere Wanderung an. Wir folgen unserem Weg und kommen an einem besonders schönen Aussichtspunkt vorbei. Hier machen wir ein Foto mit unserem Handy. Die Geodaten von dem Punkt werden gleich mitgespeichert. Wenn wir also später das Foto anschauen, können wir genau sehen wo wir waren und mit einer Karte auch einfach wieder zurückfinden. Tags in git sind äquivalent zu diesem Foto. Sie sind ein Pointer auf einen bestimmten Commit damit wir diesen später einfach wieder finden können. Wir markieren damit zum Beispiel wichtige Releases oder Meilensteine. Da Tags namen besitzten können sind sie deutlich einfachere Referenzen als die komplizierten Hashes der Commits.

Dein erstes Repository

Jetzt haben wir genügend Theorie für den Anfang. Lass uns ein Repository erstellen und damit arbeiten. Repositroy nennt man ein Verzeichnis, das von Git getrackt wird. Hier sind alle Funktionen des VCS verfügbar.

Repository erstellen

  1. Erstelle zunächst ein neues Verzeichnis für dein Repository. Navigiere mit cd in das Verzeichnis, wo du das Repository erstellen möchtest.

     $ cd ~/dev
    

    Bei mir ist das der Ordner dev in meinem Home-Verzeichnis. erstelle ein neues Verzeichnis mit mkdir.

     $ mkdir my_repository
    

    mit ls kannst du überprüfen, ob das Verzeichnis erstellt wurde.

     $ ls
     > my_repository ...
    
  2. Jetzt machen wir aus dem Verzeichnis ein Git-Repository. Navigiere jetzt in das Verzeichnis.

     $ cd my_repository
    

    und führe den Befehl git init aus. Das initialisiert das Repository.

      $ git init
      > Initialized empty Git repository in ~/dev/my_repository/.git/
    

    Achte auf das Ende das Ausgabe. Es zeigt an, dass ein neues verstecktes Verzeichnis .git erstellt wurde. Das ist das Verzeichnis, in dem Git alle Informationen über das Repository speichert. Wenn du dieses Verzeichnis löscht, sind auch alle Informationen über das Repository weg und nur dein aktueller HEAD Zustand bleibt erhalten.

    Wenn es dich interessiert, wie das Verzeichnis aussieht und wie genau Git deine Daten speichert, kannst du hier mehr darüber lesen.

    Wir haben jetzt erfolgreich ein neues Repository erstellt. Um zu überprüfen, ob alles geklappt hat, führe den Befehl ls -a aus. Das zeigt dir alle versteckten Dateien und Verzeichnisse an.

     $ ls -a
     > . .. .git
    

Repository clonen

Ein neues Repository erstellen ist oftmals hilfreich, ist aber nicht der normalfall. Meistens arbeiten wir an Projekten die von anderen bereits begonnen wurden. Um ein Repository von einem Remote zu kopieren, verwenden wir die git clone Funktion. Was genau ein Remote ist, lernst du später. Hier sei nur gesagt, dass ein Remote ein anderes Repository ist, das auf einem anderen Server liegt. Das kann z.B. Github sein.

Wir klonen nun ein bereits bestehendes Repository von Github. Dazu suchen wir uns erst ein passendes Repository raus. Ich nehme hier ein für unseren Workshop erstelltes Repository. Wir haben immer die wahl zwischen einen SSH und einem HTTP clone. Solltest du selber an einem Projekt arbeiten wollen, empfehle ich dir den SSH clone. Ansonsten wird es später schwierig, wenn du deine Änderungen wieder zurück an den Server schicken möchstet.

 $ git clone git@github.com:leonfuss/se_workshop_example.git
 > Cloning into 'se_workshop_example'...
 remote: Enumerating objects: 25, done.
 remote: Counting objects: 100% (25/25), done.
 remote: Compressing objects: 100% (13/13), done.
 Receiving objects: 100% (25/25), 6.34 KiB | 6.34 MiB/s, done.
 Resolving deltas: 100% (6/6), done.
 remote: Total 25 (delta 6), reused 22 (delta 6), pack-reused 0

Danach haben wir einen Ordner se_workshop_example erstellt, der das Repository enthält. Solltest du das Repository woanders haben wollen, kannst du alternativ auch den Pfad angeben.

 $ git clone git@github.com:leonfuss/se_workshop_example.git /pfad/zu/deinem/ordner

Staging und Working Area

Bis jetzt hat unser Datenmodell sowie unser frisch erstelltes Repository noch keinen Bezug zu unseren "normalen" Dateien, so wie wir sie aus unserem Finder oder Explorer kennen. Wir kennen unseren Commit Tree und unser Dateisystem, aber wie stehen diese miteinander in Interaktion?

Working Area (Working Tree)

Das ganz normale Dateisystem in git wird auch als Working Area bezeichnet. Wie der Name bereits indikiert, ist dies der Bereich, in dem wir wie gewohnt arbeiten. Wir erstellen Datein, löschen Datein, ändern Datein, etc. Hier ist also nahezu alles gleich - mit oder ohne git.

Staging Area

Jetzt könnten wir, um unser Commit Tree zu integrieren einfach alle Datein in unserem Working Directory zu bestimmten Zeitpunkten als Commit unserem Datenmodell hinzufügen. An sich ist daran nichts falsch, jedoch ist es nicht besonders flexibel. Was wenn wir nur Teile unserer Datein committen also zu einem bestimmten Zeitpunkt festhalten wollen. Oder nur bestimmte Änderungen in einer Datei festgehalten werden sollen. Hier kommt die Staging Area ins Spiel. Die Staging Area ist wie ein Warenkorb beim Onlineshopping. Wir können Sachen rein legen und wieder rausnehme, wenn wir uns doch unsicher sind. Und erst wenn wir sicher sind, dass wir alles haben was wir wollen, gehen wir mit unserem Warenkorb zur Kasse (dem finalem Commit erstellen). Wir haben also die Möglichkeit nochmals zu überprüfen, ob wir genau das wollen was wir haben.

Um unseren Datein aus der Working Area in den Warenkorb - die Staging Area - zu legen, verwenden wir den Befehlt git add.

Schauen wir uns das an einem Beispiel an:

+-- my_git_repo
    +-- .git/...
    +-- src
    |   +-- foo
    |   |   +-- foo.txt
    |   |   +-- bar.txt
    +-- config.toml

Der .git Ordner ist schon vorhanden. Unser Ordner ist allso schon ein git Repository. Wir haben bereits ein paar leere Datein erstellt und bis jetzt noch nichts unserem commit tree hinzugefügt. Wir können das sehr schön überprüfen, indem wir uns den aktuellen Status des Repositories anzeigen lassen:

$ git status
> On branch main

  No commits yet

  Untracked files:
    (use "git add <file>..." to include in what will be committed)
	  config.toml
	  src/

  nothing added to commit but untracked files present (use "git add" to track)

Was sehen wir hier? Es existiert der Branch main und unser HEAD befindet sich akutell dort. Jedoch gibt es noch keinen Commits in unserem Repository.

Git zeigt uns aber schon an, dass es Datein gibt, die noch nicht getrackt - noch nie in einem Commit festgehalten - wurden

Die Empfehlungen die git uns gibt, finden sich in nahe zu jeder Ausgabe von git und sind oft genau dass was wir machen wollen. Es lohnt sich also, diese zu lesen.

Um unsere Datein zu tracken, müssen wir sie zuerst unserer Staging Area hinzufügen:

  $ git add config.toml
  $ git add src/

Beim ersten add fügen wir nur config.toml hinzu. Beim zweiten add fügen wir den gesamten Ordner src/ sammt aller Datein hinzu. Alternativ können wir das auch als ein Befehl schreiben:

  $ git add config.toml src/

Theoretisch können wir auch sehr simple alle Änderungen und Datein der Staging Area hinzufügen:

  $ git add .

Auch wenn das kurzfristig sehr praktisch aussieht, würde ich davon abraten. Sehr oft geraten dabei Datein in die Staging Area und in den darauffolgenden Commit, die nicht da sein sollten. Was genau in einen Commit sollte schauen wir uns später im Kontekt der Softwareentwicklung an.

Wenn wir jetzt nochmal den Status unseres Repositories abfragen schaut das ein wenig anders aus:

$ git status
> On branch main

  No commits yet

  Changes to be committed:
    (use "git rm --cached <file>..." to unstage)
	  new file:   config.toml
	  new file:   src/foo/bar.txt
	  new file:   src/foo/foo.txt

Unter Changes to be committed sehen wir jetzt die Datein, die wir gerade der Staging Area hinzugefügt haben.

Commit

Der einzige Schritt der jetzt noch fehlt ist alle Änderungen von der Staging Area in einen Commit zu überführen. Das machen wir mit dem Befehl git commit. Sei dabei sicher, dass du nur Datein in der Staging Area haben möchtest, die auch wirklich in den Commit sollen. In unserem Fall möchten wir alle neu erstellten Datein in den Commit packen.

$ git commit
> [main (root-commit) 3adf051] Initial commit
 3 files changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 config.toml
 create mode 100644 src/foo/bar.txt
 create mode 100644 src/foo/foo.txt

Nachdem ihr den Befehl ausgeführt habt, öffnet sich ein Editor. Hier könnt ihr eine Commit Message schreiben. Diese sollte kurz und prägnant sein und beschreiben, was ihr in diesem Commit gemacht habt. Wenn ihr den Editor schließt, wird der Commit erstellt. Solltet ihr nur eine kurze Nachricht haben können wir diese auch direkt in der Shell hinzufügen:

  $ git commit -m "Initial commit"
  > [main (root-commit) 3adf051] Initial commit
   3 files changed, 0 insertions(+), 0 deletions(-)
   create mode 100644 config.toml
   create mode 100644 src/foo/bar.txt
   create mode 100644 src/foo/foo.txt

Wenn wir jetzt den Status unseres Repositories abfragen ist die Ausgabe deutlich kürzer:

$ git status
> On branch main
  nothing to commit, working tree clean

Unsere Staging Area ist leer und in unserer Working Area (working tree) gibt es keine Änderungen im Vergleich zum letzten Commit.

sequenceDiagram
   Working Tree ->> Staging Area: git add <file>
   Staging Area ->> Commit Tree: git commit
   Commit Tree -->> Working Tree: git checkout
   Staging Area -->>  Working Tree: git reset (--mixed)

Die ersten Schritte vom Working Tree zum Commit Tree kennen wir ja schon. Aber was passiert, wenn wir einen Fehler gemacht haben und die Datei wieder aus der Staging Area entfernen wollen? Oder wir auf unseren letzten Commit zurück wollen? Das und viels mehr schauen wir uns auf der nächsten Seite an.

Was machen wir mit Datein, die wir niemals Tracken wollen. Zum Beispiel weil sie sensible Informationen enthalten oder viel zu groß sind? Dafür gibt es die .gitignore Datei. In dieser Datei können wir Datein oder Ordner angeben, die git vollständig ignorieren soll. Einfach die Datei im top-level Ordner des Repositories erstellen und die Datein oder Ordner angeben, die ignoriert werden sollen. Mehr dazu in der Dokumentation.

Checkout und Reset

Vorhin haben wir bereits gesehen wie wir git clone funktioniert und wie wir unser im Workshop erstelltes Beispiel clonen können. In diesem Kapitel sehen wir wie die Version History von Git in der Praxis funktioniert, wie wir uns in der History bewegen können und wie wir Änderungen rückgängig machen können. Aber bevor wir irgendwelche Änderungen an unserem Repository vornehme, sollten wir uns erstmal einen Überblick über das Repository verschaffen.

Den Überblick behalten

Vorhin haben wir einfach den Status mit git status abgefragt und waren glücklich damit. Schließlich gab es ja noch keine Commits in unserem Repository. Was aber wenn wir aber schon welche haben? Hier kommt git log ins Spiel.

Wechseln wir in unser geclontes Repository und schauen uns die Version History von Git an:

$ cd my_git_repo
$ git log
> commit ade5234ac06311dff9f6cf5fc988bcc056533e57 (HEAD -> main)
  Author: Leon Fuss <leon.fuss@icloud.com>
  Date:   Tue Apr 16 15:58:23 2024 +0200

      added call to hello_user

  commit e0a79308cb8dd4937585c70ed462fc9a40977e55
  Merge: f239baa 656d544
  Author: Leon Fuss <leon.fuss@icloud.com>
  Date:   Tue Apr 16 15:08:28 2024 +0200

      Merge branch 'feature/hello_user'
 ...

Hier sehen wir die letzten Commits. Jeder commit beginnt mit dem Keyword commit gefolgt von seinem Commit Hash. Danach folgt das Autor, das Datum und die Commit Message. Am Obersten Commit wird immer HEAD gesetzt sein und zeigt an wie und worauf er gesetzt ist. In unserem Fall ist HEAD auf den Branch main gesetzt - erkennbar an (HEAD -> main).

Meist gibt dieser Überblick schon genug Informationen um sich zumindest zu erinnern an was man als letztes gearbeitet hat. Für einen besseren Überblick gibt es aber bessere Optionen:

  $ git log --oneline --graph --all
  > * 90aeab1 (feature/loop) adapted for repeated echo calls
  | * ade5234 (HEAD -> main) added call to hello_user
  |/
  *   e0a7930 Merge branch 'feature/hello_user'
  |\
  | * 656d544 (feature/hello_user) added hello_user
  * | f239baa added echo functionality
  |/
  * 13db86d added ussage description to README
  * 38333e6 init rust repo
  * 85cbf15 Initial commit

Hier haben wir viele Optionen auf einmal verwendet. Was bewirken wir damit?

  • --oneline zeigt die Commits in einer Zeile an. Auch der Hash wird auf die minimale eindeutige Länge gekürzt
  • --graph zeigt die Commits in einer Grahen Darstellung. Damit können wir die Branch struktur erkennen
  • --all zeigt alle Branches an. Ohne diese sehen wir nur die Commits die vor unserem aktuellen HEAD liegen

Die Optionen kannst du Kombineren wie du willst. Ich persönlich präferenziere die Graphen Darstellung und die einzeilige Anzeige. Das gibt mir einen guten Überblick über die Branch Struktur und die Commit Historie.

Damit wir auch wirlich verstehen was hier steht können stellen wir das ganze hier einmal in unseren bereit bekannten Graphen dar. Unser Head liegt aktuell auf dem letzten Commit von main (umrandet):

---
title:  Commit Graph
---
gitGraph
   commit id: "Initial commit"
   commit id: "init rust repo"
   commit id: "added ussage description to README"
   branch feature/hello_user
   checkout main
   commit id: "added echo functionality"
   checkout feature/hello_user
   commit id: "added hello_user"
   checkout main
   merge feature/hello_user
   branch feature/loop
   checkout main
   commit id: "added call to hello_user" type: HIGHLIGHT
   checkout feature/loop
   commit id: "adapted for repeated echo calls"
   checkout main

Hier haben wir der Klarheit halber die Commit Messages als Commit ID verwendet. Das sollte dir helfen zu verstehen wie die Commits in der Historie zueinander stehen. Normalerweise würden wir hier die Commit Hashes verwenden.

Wollen wir nur die Commits eines bestimmten Files sehen, können wir das mit git log <file_name> tun.

Checkout

Jetzt wo wir unsere Karte haben, können wir uns im Commit Graph bewegen. Aber was meint das eigentlich?

Im folgenden setzten wir ein cleanes Repository voraus. Das heißt, dass der Working Tree mit dem letzten Commit übereinstimmt und die Staging Area leer ist. git log sollte dann so auschauen:

$ git log
> On branch main
  nothing to commit, working tree clean

Was wir machen können, wenn das nicht der Fall ist siehst du hier

Hast du noch unser Diagramm zu Working und Commit Tree sowie der Stashing Area im Kopf?:

sequenceDiagram
   Working Tree ->> Staging Area: git add <file>
   Staging Area ->> Commit Tree: git commit
   Commit Tree -->> Working Tree: git checkout

Hier gab es schon einen kleinen Hinweis darauf was passiert, wenn wir um Commit Tree herumwandern. Jedes Mal wenn wir uns einen vergangen Commit ansehen, wird der Inhalt des Working Tree auf den Stand des ausgewählten Commits gebracht. In der git Welt nennen wir das checkout. Diese Überschreibung unseres Working Trees ist auch der Grund warum wir immer darauf achten sollten, dass unser Working Tree sauber ist bevor wir uns in der Historie bewegen. Sollten wir ungespeicherte (nicht commited) Änderungen haben, werden diese überschrieben. Aber keine Sorge: git lässt uns nicht einfach in den Abgrund laufen. Sollten wir ungespeicherte Änderungen haben, wird uns einen wechsel verweigern bis wir einen cleanen Working Tree haben.

Commit oder doch Branch? Der Unterschied wird jetzt wichtig für uns. Falls du nicht mehr ganz sicher bist, lies nochmal hier

Bei einem Checkout passieren zwei Dinge:

  1. Der HEAD wird auf den ausgewählten Branch/Commit/Tag gesetzt
  2. Der Working Tree wird auf den Stand des ausgewählten Branch/Commit/Tag gebracht

Erinnerst du dich wie Braches immer auf den aktuellsten Branch ihres Zweiges zeigen? Wenn wir auf dem Branch Commiten wir auch der Branch Pointer auf den letzen Commit verschoben. Ähnlich zum Branch ist auch der HEAD nur ein Pointer. Setzen wir den Pointer auf einen Branch und der Branch bewegt sich, wird auch der HEAD auf den neuen Commit gesetzt. Wenn hingegen der HEAD direkt auf einen Commit zeigt, bleibt er dort stehen.

Lass uns erstmal den HEAD auf einen Branch setzten. Das ist der weitaus häufigere Anwendungsfall:

---
title:  Commit Graph - HEAD auf main
---
gitGraph
   commit id: "85cbf1"
   commit id: "38333e"
   branch feature/1
   commit id: "90aeab"
   commit id: "25fe3a"
   checkout main
   commit id: "656d54"
   commit id: "de5234" type: HIGHLIGHT
$ git checkout feature/1
---
title:  Commit Graph - HEAD auf feature/1
---
gitGraph
  commit id: "85cbf1"
  commit id: "38333e"
  branch feature/1
  commit id: "90aeab"
  commit id: "25fe3a" type: HIGHLIGHT
  checkout main
  commit id: "656d54"
  commit id: "de5234"

Passend zu unserem HEAD wir auch immer das Working Directory auf den Stand des HEADs gebracht. Das bedeutet, dass wir jetzt die Dateien aus dem Commit feature/1 im Working Directory haben.

Was passiert wenn wir den HEAD direkt auf einen Commit setzten?

Bewegen wir uns doch auf einen Commit:

  $ git checkout 656d54
  > Note: switching to '656d54'.

    You are in 'detached HEAD' state. You can look around, make experimental
    changes and commit them, and you can discard any commits you make in this
    state without impacting any branches by switching back to a branch.


    HEAD is now at 656d54
---
title:  Commit Graph - HEAD auf feature/1
---
gitGraph
  commit id: "85cbf1"
  commit id: "38333e"
  branch feature/1
  commit id: "90aeab"
  commit id: "25fe3a"
  checkout main
  commit id: "656d54" type: HIGHLIGHT
  commit id: "de5234"

An sich kein Problem. Der HEAD zeigt jetzt auf den Commit 656d54 und unser Working Tree ist auf dem passenden Stand gebracht. Aber warum werden wir darauf hingeweisen dass wir jetzt in einem "detached HEAD" Zustand sind? Und was ist das überhaupt?

Braches zeigen immer auf den letzten Commit ihres Zweiges. Und Branches sind die einzigen Orte an denen wir neue (erreichbare) Commits erstellen können. "detached HEAD" beschreibt also den Zustand indem wir nicht mehr direkt auf einen Branch verweisen und somit auch keinen neuen Commit erstellen sollten.

‼️ Achtung: Commits sind nur über Branches erreichbar. Im "detached HEAD" können wir zwar Commits erstellen, aber sie sind nicht erreichbar. Um sie dauerhaft erreichen zu können müssen wir sie einen neuen Branch erstellen (git branch <branch_name>). Solltet ihr das vergessen wird euch git beim nächsten Checkout auf einen Branch darauf hinweisen. Falls ihr das vergesst ist es Zeit die Seite Help! I fucked up zu lesen.

Solltet ihr noch einen ältere Version von git haben, ist der Hinweis noch deutlich dramatischer und warnt deutlich agressiver, dass Commits verloren gehen können. "detached HEAD" ist aber ein normaler Zustand und kein Grund zur Panik. Wenn ihr ihn wieder verlassen wollt, könnt ihr einfach auf einen Branch wechseln. Zum Beispiel mit git checkout main.

Relative Commits

Navigation mit Commit Hashes ist nicht immer das angenehmste. Warum sollte man eine komische Buchstaben und Zahlenkombination erst im Log raussuchen und dann eingeben um einfach einen Commit zurück zu gehen. Hier kommen Relative Pfade ins Spiel.

Mit <branch/commit>~ bzw. <branch/commit>~n kannst du einen bzw n-Schritte dem Commit Graphen folgen.

Schauen wir uns ein Beispiel an.

---
title:  Commit Graph - HEAD auf main
---
gitGraph
  commit id: "85cbf1"
  commit id: "38333e"
  branch feature/1
  commit id: "90aeab"
  commit id: "25fe3a"
  checkout main
  commit id: "656d54"
  commit id: "de5234"
  merge feature/1
  commit id: "a1b2c3" type: HIGHLIGHT
$ git checkout main~3
---
title:  Commit Graph - HEAD auf 656d54
---
gitGraph
  commit id: "85cbf1"
  commit id: "38333e"
  branch feature/1
  commit id: "90aeab"
  commit id: "25fe3a"
  checkout main
  commit id: "656d54" type: HIGHLIGHT
  commit id: "de5234"
  merge feature/1
  commit id: "a1b2c3"

Aber wie kommen wir bei einem Merge Commit auf den richtigen Commit? Schließlich führt ein Merge Commit auf zwei verschiedene Commits zurück. Unser bereits bekannter ~-Operator wählt hier immer den Hauptstrang - auf den gemergt wurde - aus. Wenn wir den anderen Zweit auswählen wollen, können wir den ^-Operator verwenden.

---
title:  Commit Graph - HEAD auf main
---
gitGraph
  commit id: "85cbf1"
  commit id: "38333e"
  branch feature/1
  commit id: "90aeab"
  commit id: "25fe3a"
  checkout main
  commit id: "656d54"
  commit id: "de5234"
  merge feature/1
  commit id: "a1b2c3" type: HIGHLIGHT
$ git checkout main~1^~

~ folgt dem Hauptstrang, ^ folgt dem Nebenstrang Strang. Aber ^n folgt nicht solange dem Hauptstrang bis es einen Nebenstrang gibt, sondern versucht den n-ten Elternknoten zu finden. Das spielt erst eine Rolle wenn ein merge mehr als zwei Eltern hat.

---
title:  Commit Graph - HEAD auf main
---
gitGraph
  commit id: "85cbf1"
  commit id: "38333e"
  branch feature/1
  commit id: "90aeab" type: HIGHLIGHT
  commit id: "25fe3a"
  checkout main
  commit id: "656d54"
  commit id: "de5234"
  merge feature/1
  commit id: "a1b2c3"

Reset

Du hast Änderungen commited die du nicht commiten wolltest? Der Staging Area zu viel hinzugefügt? Euer Softwareprojekt läuft seit deinen letzten Änderungen gar nicht mehr? Dann bist du hier richtig. Alles was du gemacht hast lässt sich auch wieder Rückgängig machen.

Reset gibt es in verschiedenen Formen:

  • soft
  • mixed
  • hard

Ähnlich zu Checkout verändert Reset den Working Tree sowie die Staging Area. Aber im Gegensatz zu Checkout scheint Reset nur den HEAD zu verändern. In Wirklichkeit verschiebt Reset nur den zugrundeliegenden Branch und damit indirekt auch den HEAD. Dieser Unterschied ist wichtig. Während wir einen Checkout durch einen anderen Checkout sehr einfach rückgängig machen können, ist das beim Reset komplizierter, da wir keine Branch mehr haben die auf die alten Commits zeigt. Sollte dir das aus Versehen passieren, lies dir die Seite Help! I fucked up durch.

Soft Reset

Ein Soft Reset verändert nur den Branch Pointer und hat ansonsten keine Auswirkungen. Somit setzt es nur den letzten Commit Befehl zurück. Die Staging Area enthält alles was in dem Commit enthalten war. Wir schauen uns das ganze an einem Beispiel an.

gitGraph

commit id: "v1"
commit id: "v2"
commit id: "v3" type: HIGHLIGHT
stateDiagram-v2
    HEAD --> v3
    Staging --> v3
    Working --> v3
$ git reset --soft HEAD~
gitGraph

commit id: "v1"
commit id: "v2" type: HIGHLIGHT
commit id: "v3" type: REVERSE
stateDiagram-v2
    HEAD --> v2
    Staging --> v3
    Working --> v3

Alle git Befehle können auch immer mit relativen Commit Referenzen (~ und ^) aufgerufen werden. Grade bei Reset bietet sich das an, weil man meist nur wenige Commits zurück will. Zum Beispiel git reset --soft HEAD~

Mixed Reset

Bei einem Mixed Reset wird wie zurvor auch beim Soft Reset zunächst der HEAD auf den angegebenen Commit zurückgesetzt. Danach wird der neue HEAD in die Staging Area geladen. Somit wird der letzte Commit rückgängig gemacht und auch die Staging Area wird geleert. Alle Änderungen im Working Tree bleiben erhalten.

gitGraph

commit id: "v1"
commit id: "v2"
commit id: "v3" type: HIGHLIGHT
stateDiagram-v2
    HEAD --> v3
    Staging --> v3
    Working --> v3
$ git reset --mixed HEAD~
# oder
$ git reset HEAD~
gitGraph

commit id: "v1"
commit id: "v2" type: HIGHLIGHT
commit id: "v3" type: REVERSE
stateDiagram-v2
    HEAD --> v2
    Staging --> v2
    Working --> v3

Hard Reset

Ein Hard Reset ist die weitgehendste Form des Resets. Hier wird der HEAD auf den angegebenen Commit gesetzt und die Staging Area, sowie der Working Tree mit diesem Commit überschrieben.

Achtung: Uncommitede Änderungen im Workiung Directory gehen vollständig verloren. Dies ist der einzige Befehl mit dem git Dateien unwiederbringlich löscht. Bitte gehe mit Option --hard vorsichtig um.

gitGraph

commit id: "v1"
commit id: "v2"
commit id: "v3" type: HIGHLIGHT
stateDiagram-v2
    HEAD --> v3
    Staging --> v3
    Working --> v3
$ git reset --hard HEAD~
gitGraph

commit id: "v1"
commit id: "v2" type: HIGHLIGHT
commit id: "v3" type: REVERSE
stateDiagram-v2
    HEAD --> v2
    Staging --> v2
    Working --> v2

In keinem der Fälle wird ein Commit direkt gelöscht. Wie du einen reseteten Commit wiederherstellen kannst, erfährst du hier.

Reset mit Dateipfad

Reset kann zusätzlich zu dem Commit auch mit einem Dateipfad aufgerufen werden. Der Syntax dafür schaut wie folgt aus:

$ git reset <commit> <file>

Aber was passiert hier eigentlich? HEAD bzw. der Branch der zugrundeliegt sind Pointer und können somit immer nur auf einen Commit zeigen. Sie können also nicht mehrere Commits für unterschiedliche Datein referenzieren. Dementsprechend wird bei einem Reset mit Dateipfad die Branch nicht verändert. Die Staging Area und der Working Tree hingegen können ganz unterschiedliche Datein enthalten. Der Reset Befehl setzt also nur die angegebene Datei auf den spezifizierten Commit zurück. Ob auch der Working Tree vom Reset betroffen ist, hängt von der gewählten Reset Art ab.

git reset HEAD <file> nimmt die Datei aus der Staging Area bzw. überschreibt die Datei in der Staging Area mit der Datei aus dem letzten Commit. Der Working Tree bleibt unverändert. Wer es noch kürzer mag, kann auch das HEAD weglassen. git reset <file> hat den gleichen Effekt.

In unserem Beispiel setzten mir die Datei file.txt auf den Stand des letzten Commits zurück.

gitGraph

commit id: "v1"
commit id: "v2"
commit id: "v3" type: HIGHLIGHT
stateDiagram-v2
    HEAD --> v3
    Staging --> v3
    Working --> v3
$ git reset HEAD~ foo.txt
gitGraph

commit id: "v1"
commit id: "v2" type: HIGHLIGHT
commit id: "v3" type: REVERSE
stateDiagram-v2

    x: v3
    y: v3
    z: v2 - foo.txt \n v3 - rest

    HEAD --> y
    Staging --> z
    Working --> x

Soft Reset macht mit Dateinamen keinen Sinn, da der Pointer auf den Branch bleibt.

Squashing

Oftmals schreibt man auf einer Branch mehrere Commits die man vor einem Merge der Übersicht halber lieber zusammenfassen möchte. Das Zusammenfassen mehrerer Commits in einen Commit nennt man Squashing. Meistens führt man Squashing mittels eines interakiven Rebase durch. Wie das funktioniert schauen wir uns auch später an. Aber für denn Fall, dass wir die letzten Commits zusammenfassen wollen, können wir auch einen einfachen Reset verwenden.

Wir gehen wie folgt vor:

  1. Soft Reset auf den Commit vor den Commits die wir zusammenfassen wollen
  2. Neuer Commit erstellen

In unserem Beispiel wollen wir die letzten beiden Commits zusammenfassen.

gitGraph

commit id: "v1"
commit id: "v2"
commit id: "v3" type: HIGHLIGHT
stateDiagram-v2
    HEAD --> v3
    Staging --> v3
    Working --> v3
$ git reset --soft HEAD~2
gitGraph

commit id: "v1" type: HIGHLIGHT
commit id: "v2" type: REVERSE
commit id: "v3" type: REVERSE
stateDiagram-v2
    HEAD --> v1
    Staging --> v3
    Working --> v3

In der Staging Area und im Working Tree befinden sich nun die Änderungen aller Commits die wir zusammenfassen wollen. Jetzt müssen wir nur noch einen neuen Commit erstellen.

$ git commit -m "Squashed commit"
gitGraph

commit id: "v1"
commit id: "v4" type: HIGHLIGHT

Wir haben erfolgreich die letzten beiden Commits in einen Commit zusammengefasst.

Stashing

  • Checkout funktioniert nur wenn die Staging Area und der Working Tree auf dem Stand vom letzten Commit sind
  • Manchmal nehmen wir änderungen vor die noch nicht commit-bereit sind, müssen aber an was anderem arbeiten / branch wechseln
  • Stashing speichert die Änderungen in einem temporären Commit der unabhängig vom Commit Tree ist und setzt Working Directory und Staging Area zurück

Beispiel:

$ git status
> Changes to be committed:
    (use "git reset HEAD <file>..." to unstage)

	  modified:   bar.txt

  Changes not staged for commit:
    (use "git add <file>..." to update what will be committed)
    (use "git checkout -- <file>..." to discard changes in working directory)

	  modified:   src/foo.txt

Wir wollen jetzt Branch wechseln, aber die Änderungen in src/foo.txt sind noch nicht commit-bereit. Mit git-stash können wir die Änderungen temporär speichern.

$ git stash
> Saved working directory and index state \
   "WIP on master: 049d078 Create index file"
  HEAD is now at 049d078 Create index file
  (To restore them type "git stash apply")

Working Tree und Staging Area sind jetzt wieder auf dem Stand vom letzten Commit und damit bereit den Branch zu wechseln. Wir können das mit git status überprüfen.

$ git status
> On branch main
  nothing to commit, working directory clean

Wir können jetzt nach belieben Branch wechseln und wo anders weiterarbeiten.

Um unsere akutellen Stashes anzuzeigen könnnen wir git stash list verwenden.

$ git stash list
> stash@{0}: WIP on master: 049d078 Create index file
> stash@{1}: WIP on master: 3923d03 Revert add file size

Hier sehen wir das wir zwei Stashes haben. Den oberen haben wir grade erstellt. Um die Stashes unterscheiden zu können fügt git immer die Commit Message des letzten Commits hinzu. Wahlweise kann sie auch bei der Erstellung mit der Option -m angegeben werden (z.B. git stash -m "Meine Nachricht"). Um einen Stash kann auf einer beliebigen Branch wiederhergestellt werden, jedoch kann es bei der Anwendung zu Konfliken kommen. Wie diese gelöst werden schauen wir uns im Kapitel Branching und Merging an.

Um unseren Stash auf den aktuellen Working Tree und Staging Area anzuwenden verwenden wir git stash apply. Sollten wir nicht den letzten Stash anwenden wollen müssen wir die ID mit git stash apply stash@{1} angeben.

$ git stash apply stash@{0}
> On branch master
  Changes not staged for commit:
    (use "git add <file>..." to update what will be committed)
    (use "git checkout -- <file>..." to discard changes in working directory)

	  modified:   bar.txt
	  modified:   src/foo.txt

  no changes added to commit (use "git add" and/or "git commit -a")

Wenn wir den Stash nicht mehr benötigen können wir ihn mit git stash drop stash@{0} löschen. Für alle Stashes funktioniert das mit git stash clear.

Help! I fucked up

Git kann praktisch alle Operationen rückgängig machen. Eine der wenigen Ausnahme haben wir im letzten Kapitel gesehen: Wenn ein Hard Reset durchgeführt wird, ohne dass Änderungen commited wurden, gehen diese unwiederbringlich verloren.

Reflog

Abgesehen davon speichert git alle Änderungen von Commits und Branches im Repository die wir vorgenommen haben. Wir können all diese im Reflog anschauen:

$ git reflog
> e0a7930 HEAD@{0}: merge feature/hello_user: Merge made by the 'ort' strategy.
  f239baa HEAD@{1}: checkout: moving from feature/hello_user to main
  656d544 (feature/hello_user) HEAD@{2}: commit: added hello_user
  f239baa HEAD@{3}: commit: added echo functionality

Back in Time

Um in der Zeit zurückzugehen bevor wir etwas versehentlich kaputt gemacht haben, müssen wir nur einen reset in Kombination mit unserer HEAD@{index} Referenz durchführen. Git setzt daraufhin alle Änderungen bis zu diesem Zeitpunkt zurück.

$ git reset HEAD@{index}

Oben haben wir gesehen, dass wir als letztes einen Merge durchgeführt hatten. Sollten wir diesen rückgängig machen wollen, können wir das mit folgendem Befehlt tun:

$ git reset HEAD@{1}

Schauen wir uns daraufhin das Reflog erneut an:

$ git reflog
> 1533063 (HEAD ->  main) HEAD@{0}: reset: moving to HEAD@{1}
  e0a7930 HEAD@{1}: merge feature/hello_user: Merge made by the 'ort' strategy.
  f239baa HEAD@{2}: checkout: moving from feature/hello_user to main
  656d544 (feature/hello_user) HEAD@{3}: commit: added hello_user
  f239baa HEAD@{4}: commit: added echo functionality

Auch unser Reset wurde im Reflog festgehalten. Wir können also auch diesen also jederzeit wieder rückgängig machen.

Auch wenn wir all diese Änderungen mit dem bereits bekannten git reset durchführen können, ist es oft deutlich einfacher und sicherer, wenn wir die Reflog Referenzen als Adressierung verwenden. Grade bei Resets von Merges können wir sonst schnell auf die falsche Referenz zurücksetzten. Reflog in Kombination mit git reset ist also so etwas wie unsere Time Machine.

Branching und Merging

Branching

Wir haben bereits viele Commit Trees gesehen und meistens waren diese nicht nur linear, sondern hatten bereits mehrere Branches. Was Branches sind haben wir bereits gesehen, aber wie können wir diese erstellen.

Wir begeben uns in unserem Repository mittel checkout an den Commit, von dem wir aus einen neuen Branch erstellen wollen. Das kann ein Branch wie main sein oder auch ein Commit, der schon länger zurückliegt. Dort angekommen erstellen wir einen neuen Branch mit dem Befehl git branch <branch_name>. Wollen wir also den Branch feature/1 von main erstellen, machen wir folgendes:

$ git checkout main
$ git branch feature/1

Bevor wir nun Commits unserm neunen Branch hinzufügen können, müssen wir noch auf unseren neu erstellten Branch wechseln:

$ git checkout feature/1

Wahlweise können wir auch das Erstellen und Wechseln in einem Befehl durchführen:

$ git checkout -b feature/1

Die -b Flag steht hierbei für branch.

In git gilt der Grundsatz: "Branch early, branch often". Das heißt du wirst in der Regel mit vielen Branches konfrontiert sein. Diese alle über das Log zu verfolgen ist nicht immer sinnvoll. Solltest du einfach nur wissen wollen, welche Branches es gibt, kannst du das mit dem Befehl git branch tun.

$ git branch
> * feature/1
  main

Der aktuelle Branch (HEAD) ist mit einem * markiert.

Nach einem Merge oder Rebase, wenn alle Änderungen auf einem anderen Branch übernommen worden sind, können wir den alten Branch löschen. Das machen wir mit dem Befehl git branch -d <branch_name>.

Sollten wir also feature/1 wieder löschen wollen, schaut das wie folgt aus:

    $ git branch -d feature/1

Wie das Löschen wieder Rückgängig gemacht werden kann findest du hier

Merging

Merging hatten wir bereits ganz am Anfang in Kombination mit dem Datenmodell angesprochen. Beim Merging wollen wir die Änderungen aus zwei unterscheidlichen Branches zusammenführen. Dabei wird die Änderungen des einen Branches mit einem Merge-Commit auf den anderen übernommen. Der Merge-Commit hat zwei oder mehrere Parent-Commits ist ansonsten aber identisch zu einem normalen Commit.

Wichtig: Nur der Pointer des Zielbranches wird auf den Merge-Commit verschoben. Der Pointer vom Quellbranch bleibt unverändert.

In git mergen wir mit dem Befehl git merge <quell_branch> der Zielbranch ist dabei immer der Branch auf dem sich grade HEAD befindet.

Schauen wir uns das denkbar einfachste Beispiel an:

gitGraph

  commit id: "ae3f3d" type: HIGHLIGHT
  branch feature/1
  commit id: "9ac33d"
  checkout main
$ git merge feature/1
gitGraph

commit id: "ae3f3d"
branch feature/1
commit id: "9ac33d"
checkout main
merge feature/1 type: HIGHLIGHT id: "b3f3d3"

Alle Änderungen von feature/1 wurden auf main übernommen. Auf feature/1 hat sich nichts verändert. Es befinden sich dort nur die Commits, die es auch schon vorher gab.

Fast-Forward Merges

Sollten wir jetzt auf main ein paar Commits hinzufügen und dann main in feature/1 mergen (andersherum als vorhin), wird interessanter Weise kein neuer Merge-Commit erstellt.

gitGraph

commit id: "ae3f3d"
branch feature/1
commit id: "9ac33d"
checkout main
merge feature/1  id: "b3f3d3"
commit id: "3f3f3f"
commit id: "52acd8" type: HIGHLIGHT
$ git checkout feature/1
$ git merge main
gitGraph

commit id: "ae3f3d"
branch feature/1
commit id: "9ac33d"
checkout main
merge feature/1
commit id: "3f3f3f"
commit id: "52acd8"
checkout feature/1
merge main type: HIGHLIGHT

Unser aktueller HEAD ist kein neuer Commit (kein Hash), sondern stattdessen wir der Pointer von feature/1 einfach auf den letzten Commit von main gesetzt. Git erkennt, dass es keine Änderungen auf feature/1 gibt, die nicht auch auf main sind und somit main und feature/1 identisch sind. Ein solcher Merge wird als Fast-Forward Merge bezeichnet.

Fast Forwad Merges garantieren einen konfliktfreien Merge. Das bedeutet, dass es keine Merge-Konflikte geben kann.

Merge Conflicts

Im starken Kontrast zu Fast-Forward Merges stehen Merge Konflikte. Diese entstehen, wenn Änderungen auf zwei Branches gemacht wurden, die sich gegenseitig widersprechen. Git ist dann nicht mehr in der Lage automatisch beide Dateien zusammenzuführen und ein manueller Eingriff ist erforderlich.

Unser Commit Tree schaut aktuell wie folgt aus.

gitGraph

commit id: "ae3f3d"
branch feature/1
commit id: "9ac33d"
checkout main
commit id: "782ca2" type: HIGHLIGHT

Im ersten Commit (ae3f3d) haben wir die Datei foo.txt mit dem Inhalt Hello World erstellt. Im zweiten Commit (9ac33d, feature/1) haben wir die Datei foo.txt auf Hello Alice! geändert. Und auf main haben wir die Datei foo.txt auf Hello Bob! geändert.

# == Ursprünglich ==
$ git checkout ae3f3d
$ cat foo.txt
> Hello World

# == main ==
$ git checkout main
$ cat foo.txt
> Hello Alice!

# == feature/1 ==
$ git checkout feature/1
$ cat foo.txt
> Hello Bob!

Wenn wir jetzt feature/1 in main mergen wollen, wird es zwangsweise zu einem Merge-Konflikt kommen, da nicht klar ist ob es "Hello Alice" oder "Hello Bob" heißen soll.

$ git checkout main
$ git merge feature/1
> Auto-merging foo.txt
  CONFLICT (content): Merge conflict in foo.txt
  Automatic merge failed; fix conflicts and then commit the result.

Wie wir sehen, ist es zu einem Konflikt gekommen. Git hat versucht die Datei foo.txt automatisch zusammenzuführen, ist aber gescheitert. Es wurde somit auch noch kein Merge-Commit erstellt. Jediglich der Merge-Konflikt wurde in der Datei foo.txt markiert. Deshalb zeigt uns git status auch eine Änderung an der Datei foo.txt an:

$ git status
> On branch main
  You have unmerged paths.
    (fix conflicts and run "git commit")
    (use "git merge --abort" to abort the merge)

  Unmerged paths:
    (use "git add <file>..." to mark resolution)
	  both modified:   foo.txt

  no changes added to commit (use "git add" and/or "git commit -a")

Schauen wir uns mal an wie so ein Merge-Konflikt in der Datei aussieht:

$ cat foo.txt
> <<<<<<< HEAD
  Hello Alice!
  =======
  Hello Bob!
  >>>>>>> feature/1

Die Datei wurde in zwei Teile aufgeilt. Oben befindet sich immer der Stand von HEAD also in unserem Fall main. Ein ======= trennt beide Teile. Unten befindet sich der Stand von feature/1. Um den Konflikt zu lösen, müssen wir die Datei so bearbeiten, dass sie nur noch den gewünschten Stand enthält und alle Konflikt-Markierungen entfernt sind. In unserem Fall wollen wir Hello Alice! und Hello Bob! zu Hello Alice and Bob! zusammenführen. Wir ändern das in unserem Editor und speichern die Datei. Jetzt schaut foo.txt so aus:

$ cat foo.txt
> Hello Alice and Bob!

Um git mitzuteilen, dass wir den Konflikt zu unserer Zufriedenheit gelöst haben, müssen wir die Datei foo.txt mit git add der Staging Area hinzufügen und dann einen ganz normalen Commit erstellen.

$ git add foo.txt
$ git commit -m "Merge feature/1 into main"

Solltet ihr während des Merge-Prozesses feststellen, dass ihr einen Fehler gemacht habt oder noch nicht bereit für den Merge seit, könnt ihr jederzeit den Merge mit git merge --abort abbrechen. Damit werden alle Änderungen zurückgestzt als ob der Merge nie angefangen wurde.

Unser Commit Tree sieht jetzt wie ein normaler Merge aus:

gitGraph

commit id: "ae3f3d"
branch feature/1
commit id: "9ac33d"
checkout main
commit id: "782ca2"
merge feature/1 id: "ac3e12" type: HIGHLIGHT

Egal welche Art von Konflikt ihr in git habt, die Lösung folgt immer dem gleichen Schema:

  1. Konflikt in der Datei beheben und speichern
  2. git add um die Datei in die Staging Area zu legen
    • Für einen Merge-Konflikt einen Commit mit git commit erstellen.
    • Für einen Rebase-Konflikt mit git rebase --continue fortfahren.

Im Allgemeinen lohnt es sich immer den git Status abzufragen (git status). Hier werden alle notwendigen Schritte beschrieben um den Konflikt zu lösen oder abbzubrechen.

Rebasing

Merging ist der einfachste Weg Änderungen von einen auf einen anderen Branch zu überführen. Jedoch gibt es in Git noch eine weitere Möglichkeit: Rebasing. Im Gegensatz zum Merging werden beim Rebasing alle Commits des Quellbranches auf den Zielbranch übertragen. Es wird also kein einziger Merge-Commit erstellt, sondern die Commits mitsamt deren Änderungen werden von Quellbranch auf an den Zielbranch angehängt.

gitGraph
  commit id: "A"
  branch feature/1
  commit id: "B"
  checkout main
  commit id: "C" type: HIGHLIGHT
  checkout feature/1
  commit id: "D"
$ git checkout feature/1 # wir wählen immer den Branch aus, den wir verschieben wollen
$ git rebase main
# oder
$ git rebase main feature/1
gitGraph
  commit id: "A"
  commit id: "C"
  branch feature/1
  commit id: "B'"
  commit id: "D'" type: HIGHLIGHT

Achte insbesondere auf die Commit-IDs. Der Commit B wurde nicht einfach nur verschoben sondern neu auf C angewendet. Die Meta-Daten, aber nicht der Inhalt haben sich verändert. Das bedeutet, dass der Commit B eine neue Commit-ID B' bekommt. Nachdem B erfolgreich angewendet wurde, wird Dauf B' angewendet und erhält die neue Commit-ID D'.

Sollten wir wir main auf dem gleichen zusammengeführten Stand, wie feature/1 haben wollen, können wir dies mit dem bereits bekannten Fast-Forward-Merge erreichen.

$ git checkout main
$ git merge feature/1
gitGraph
  commit id: "A"
  commit id: "C"
  branch feature/1
  commit id: "B'"
  commit id: "D'"
  checkout main
  merge feature/1 type: HIGHLIGHT

Rebase Konflikte

Wie auch beim Merging kann es beim Rebasing zu Konflikten kommen. Wie wir diese lösen können, hast du schon beim Merging gelernt. Nach Beheben und hinzufügen zur Staging Area, fahren wir mit git rebase --continue fort.

Wir sehen, dass wir keine neuen Commits erstellen (Es fehlt das git commit vom Merging). Stattdessen schreiben wir bereits vorhandene Commits um.

Wie auch beim Merging können wir den Rebase-Vorgang mit git rebase --abort jederzeit abbrechen. Der Zustand vor Begin des Rebasings wird dann wieder hergestellt

Rebase vs. Merge

Rebase vs. Merge ist in der Git-Community so etwas wie eine Grundsatzdiskussion. Das Merge-Lager argumentiert, dass Merge-Commits die Historie unverändert abbilden und dass damit der Verlauf der Entwicklung durchgehend nachvollziehbar bleibt. Im Kontrast dazu verändert schreibt Rebasing die Historie um.

Die Rebase Anhänger sehen sich eher in der Perspektive von Autoren. Nicht der erste Entwurf ist immer gut nachvollziehar. Deshalb schreiben Sie die Geschichte im Nachhinein um, um sie für andere besser verständlich zu machen.

In der Praxis ist es eine Frage des persönlichen Geschmacks und des Projekts. Für das Teamprojekt bietet sich vermutlich aber eine Kombination aus beiden an. Rebasing für den eigenen Branch bis Commits sehr einfach für andere Nachvollziehbar sind und Merging für das Zusammenführen von Feature-Branchen auf den Main-Branch.

Changing History

Sei es um die Historie besser nachvollziebar zu machen oder um Fehler zu korrigieren, es gibt immer wieder Situationen in denen wir die Historie ändern wollen.

Verändern des letzten Commits

Ein Komma vergessen, vergessen die Datei zu formatieren oder einfach eine falsche Commit-Message geschrieben? Kein Problem wir können den letzten Commit ohne Probleme ändern. Eine Möglichkeit das zu erreichen haben wir uns bereits im Kapitel Resets angeschaut. Hierbei müssen wir aber immer die Commit-Message komplett neu schreiben.

   $ git reset --soft HEAD~
   # alle notwenigen Änderungen vornehmen und der Staging Area hinzufügen
   $ git commit

Grade wenn wir nur kleine Änderungen an der Commit Message vornehmen oder die Commit Message gleich bleiben soll, ist gibt es komfortablere Möglichkeiten. Mit git commit --amend können wir den letzten Commit bearbeiten.

  1. Füge alle Änderungen am letzten Commit der Staging Area hinzu.
  2. $ git commit --amend
    
  3. Der Editor öffnet sich und wir können die Commit-Message bearbeiten.
  4. Speichern und schließen

Interaktive Rebase

Bearbeiten von älteren Commits

Wollen wir mehrere Commits oder Commits weiter aus der Vergangenheit bearbeiten müssen wir zu einem komplexeren Tool greifen. Interaktives Rebasing Eine abgespekte Form davon haben wir schon als normales Rebasing kennengelernt. Im Gegensatz zum normalen Rebasing, können wir hier aber die Reihenfolge der Commits ändern, Commits zusammenfassen oder auch Commits komplett löschen.

In unserem Beispiel wollen wir die letzten 3 Commits bearbeiten.

  $ git rebase -i HEAD~3

Wie bei jedem git Befehlt können wir wahlweise auch direkt den Commit-Hash angeben.

Sobald wir den Command ausführen öffnet sich ein Editor mit einer Liste der letzten 3 Commits.

pick 5c32ae1 Change my name a bit
pick 8723ace Update README formatting and hello world
pick a3b02e7 Add license

# Rebase 710f0f8..a5f4a0d onto 710f0f8
#
# Commands:
# p, pick <commit> = use commit
# r, reword <commit> = use commit, but edit the commit message
# e, edit <commit> = use commit, but stop for amending
# s, squash <commit> = use commit, but meld into previous commit
# f, fixup <commit> = like "squash", but discard this commit's log message
# x, exec <command> = run command (the rest of the line) using shell
# b, break = stop here (continue rebase later with 'git rebase --continue')
# d, drop <commit> = remove commit
# l, label <label> = label current HEAD with a name
# t, reset <label> = reset HEAD to a label
# m, merge [-C <commit> | -c <commit>] <label> [# <oneline>]
# .       create a merge commit using the original merge commit's
# .       message (or the oneline, if no original merge commit was
# .       specified). Use -c <commit> to reword the commit message.
#
# These lines can be re-ordered; they are executed from top to bottom.
#
# If you remove a line here THAT COMMIT WILL BE LOST.
#
# However, if you remove everything, the rebase will be aborted.
#
# Note that empty commits are commented out

Die Commits werden in inverser Reihenfolge zum Log angezeigt. Mit log würde die Reihenfolge wie folgt ausschauen:

$ git log --oneline
a3b02e7 Add license
8723ace Update README formatting and hello world
5c32ae1 Change my name a bit
...

Der Editor vom interaktiven Rebase zeigt nicht die Vergangenheit unserere Commits, sondern ist vielmehr ein Skript, das von oben nach unten von git ausgeführt wird. Um eine Änderung an einem Commit vorzunehmen, müssen wir das skript ändern, sodass es anhält und auf unsere Eingabe wartet. Um das zu erreichen, müssen wir das pick durch ein edit ersetzten.

edit 5c32ae1 Change my name a bit
pick 8723ace Update README formatting and hello world
pick a3b02e7 Add license

Wenn wir jetzt die Datei speichern und den Editor schließen. Erhalten wir folgende ausgabe.

$ git rebase -i HEAD~3
> Stopped at f7f3f6d... Change my name a bit
  You can amend the commit now, with

       git commit --amend

  Once you're satisfied with your changes, run

       git rebase --continue

Wir können jetzt unsere Änderungen stagen und wenn wir glücklich sind den Commit mit git commit --amend abschließen. Hier ist dann auch nochmal die Änderung der Commit-Message möglich. Wenn du fertig bist, kannst du den Rebase mit git rebase --continue fortsetzten.

Da wir ansonsten alles auf pick gelassen haben, wird der Rest der Commits einfach angewendet.

Grade bei größeren Rebases kann es zu Konflikten kommen. Diese müssen dann wie bereits gelern händisch gelöst werden.

💡 Auch beim interaktiven Rebase gilt: Commits können nicht verändert werden. Was wir hier eigentlich machen ist das erstellen von neuen Commits, die die alten ersetzen.

Umstellen von Commits

Du hast den Eindruck, dass die Reihenfolge von Commits fürs Verständnis nicht optional ist? Mit unserem Rebase ist das ein Katzensprung. Wie bei einem normalen Skript können wir die Reihenfolge der Befehle einfach ändern und die Reihenfolge der Commits ändert sich dementsprechend. Was als erstes kommt, wird auch als erstes angewendet.

Squashen von Commits

Mit git reset haben wir schon eine Möglichkeit gesehen, wie wir Commits zusammenfassen können. Leider ging das dabei nur mit den letzten Commits bzw. bringt einen enormen Aufwand mit sich ältere Commits zusammenzufassen. Mit dem interaktiven Rebase ist das ganz schnell und elegant erledigt.

Hier nochmal unser Anfangszustand:

pick 5c32ae1 Change my name a bit
pick 8723ace Update README formatting and hello world
pick a3b02e7 Add license

Wie unten im Editor in der hilfreichen Zusammenfassung beschrieben, können wir mit squash Commits zusammenfassen. Wichtig ist hierbei, dass wir der mit dem squash annotierte Commit mit dem Commit darüber zusammengefasst wird. In unserem Beispiel wollen wir die ersten beiden Commits zusammenfassen:

pick 5c32ae1 Change my name a bit
squash 8723ace Update README formatting and hello world
pick a3b02e7 Add license

Nachdem wir den Editor schließen, öffnet sich ein weiterer Editor, in dem wir die Commit-Message für den neuen (gesquashten) Commit eingeben können. Wenn wir fertig sind, speichern und schließen wir den Editor.

Tipps und Tricks

Reflog!!

Wir haben Reflog bereits hier ausführlich besprochen. Es ist so ein lebensrettendes Tool, von dem viel zu wenige wissen, als dass wir es hier nicht nochmal erwähnen müssen

Aliase

Git Befehle sind können schnell zu sehr langen Monstern mit vielen Optionen ausaten. Und selbst wenn sie nur mittellang sind und wir sie sehr oft eingeben müssen ist das lästig. Hier kommen Aliase ins Spiel. Mit ihnen können wir Befehle abkürzen. Wir können sie entweder global für alle Projekte oder nur für das aktuelle Projekt definieren. Hier ein paar Beispiele:

$ git config --global alias.co checkout
$ git config --global alias.br branch
$ git config --global alias.ci commit
$ git config --global alias.st status

Wirklich angenehm sind Aliase wenn wir Befehle erstellen wollen die eigentlich exisieren sollen. Warum nicht einfach git unstage statt git reset HEAD

$ git config --global alias.unstage 'reset HEAD'
$ git config --global alias.graph 'log --oneline --graph'

Unser Leben wird deutlich angenehmer wenn wir uns ein paar Aliase erstellen.

Remotes

Bis jetzt haben wir uns sehr viel mit lokaler Version History beschäftigt und du hast nahezu alles gelernt was man braucht um git zu meistern. Bis jetzt haben wir aber gits Konzept zur Kollaboration mit anderen noch nicht angesprochen. Das ist der Punkt an dem Remotes ins Spiel kommen.

Wir und der Server

Das klassische Client-Modell sollte jedem sein Begriff sein. Mehrere Clients greifen auf einen Server zu. Der Server ist der zentrale Punkt an dem alle Clients ihre Daten ablegen und abrufen. Git verfolgt hier einen ähnlichen Ansatz. Der Server ist in diesem Fall ein Remote Repository und die Clients sind unsere lokalen Repositories. Aber wie können wir diese beiden miteinander kombinieren?

Remote Branches

Branches kennen wir ja bereits sehr gut. Mit ihren können wir unsere Arbeit in verschiedene Richtungen aufteilen und später wieder zusammenführen. Grade das Zusammenführen ist für uns jetzt wichtig. Wir wollen unsere Arbeit mit der Arbeit anderer zusammenführen. Klingt vertraut? Das ist genau das was wir mit Merging und Rebasing gemacht haben. Der Server ist für uns also nichts weiter als weitere Branches auf die wir unsere Arbeit zusammenführen können.

Anders als unsere lokalen Branches arbeiten wir auf unseren Remote Branches nicht mit git commit sondern mit git push und git pull. Aber lass uns das erstmal Konzeptuell verstehen.

Am Anfang haben wir ein remote Repository (z.B. auf Github). Wir clone dieses und schaffen damit automatisch eine Verknüfung zwischen remote und local repository. Diese Verknüpfung wird standartmäßig als origin bezeichnet, kann aber einen beliebigen Namen haben. Mit git remote können wir uns alle Remote Verknüfungen anzeigen lassen.

$ git remote
> origin

Bei unserem Repsository gibt es eine Verknüpfung names origin.

Sollten wir nicht mehr wissen wo ein Remote hinzeigt, könnten wir das und noch mehr mit git remote show <remote_verknüfung> anzeigen lassen. Für origin wäre das git remote show origin.

Zusätzlich hat unser Repository zwei Branches: main und origin/main. Der Branch main ist unser lokaler Branch und origin/main ist ein Remote Branch, der den Stand des Branches main auf dem Server wiederspiegelt. Nach einem git clone sind beide Branches auf dem gleichen Stand.

Client:

gitGraph
  commit id: " "
  branch origin/main
  commit id: "A "
  commit id: "B "
  commit id: "C "

  checkout main
  merge origin/main

Server:

%%{init: { 'gitGraph': { 'mainBranchName': 'origin/main' }} }%%
gitGraph
  commit id: "A"
  commit id: "B"
  commit id: "C"

Soweit so einfach. Da Server und Client nicht immer auf dem gleichen Stand sind, kann es passieren, dass beide schnell auseinander driften. Wenn der Server einen neuen Commit bekommt wird unser Remote Branch origin/main nicht automatisch aktualisiert. Das müssen wir manuell mit git fetch machen.

Wenn der Server einen neuen Commit bekommt und wir git fetch ausführen sieht danach unser Repository so aus:

$ git fetch origin main:main

origin main:main ist ein bischen viel auf einmal. origin ist der Name der Verknüpfung auf die wir uns beziehen. Das erste main ist der Name des Branches auf dem Server den wir fetchen wollen und das zweite main ist der Name des Branches in unserem lokalen Repository in dem wir den Branch speichern wollen.

Client:

gitGraph
  commit id: " "
  branch origin/main
  commit id: "A "
  commit id: "B "
  commit id: "C "

  checkout main
  merge origin/main
  checkout origin/main
  commit id: "D"

Server:

%%{init: { 'gitGraph': { 'mainBranchName': 'origin/main' }} }%%
gitGraph
  commit id: "A"
  commit id: "B"
  commit id: "C"
  commit id: "D"

Auf main selber sind die Änderungen noch nicht angekommen. Das müssen wir manuell machen. Dafür gibt es zwei Möglichkeiten: Das klassische git merge oder das etwas komplexere git rebase. Beide Methoden kennst du bereits. Wir entscheiden uns hier für das einfache git merge

$ git checkout main
$ git merge origin/main

Client:

gitGraph
  commit id: " "
  branch origin/main
  commit id: "A "
  commit id: "B "
  commit id: "C "

  checkout main
  merge origin/main
  checkout origin/main
  commit id: "D"
  checkout main
  merge origin/main

Server:

%%{init: { 'gitGraph': { 'mainBranchName': 'origin/main' }} }%%
gitGraph
  commit id: "A"
  commit id: "B"
  commit id: "C"
  commit id: "D"

Da dieser Vorgang sehr häufig vorkommt git bietet git eine Abkürzung an: git pull. git pull führt git fetch und git merge in einem Schritt aus.

$ git pull origin main:main

So kommen wir deutlich simpler zum gleichen Ergebnis.

Sollten wir bereits einen Upstream für diesen Branch gesetzt haben, können wir auch einfach git pull ohne origin main ausführen. Zur Upstream später mehr

Nun zum Übertragen von Commits auf den Server. Erstellen wir dafür zunächst einen neuen Commit auf unserem Client.

$ git commit

Client:

gitGraph
  commit id: " "
  branch origin/main
  commit id: "A "
  commit id: "B "
  commit id: "C "

  checkout main
  merge origin/main
  checkout origin/main
  commit id: "D"
  checkout main
  merge origin/main
  commit id: "E"

Server:

%%{init: { 'gitGraph': { 'mainBranchName': 'origin/main' }} }%%
gitGraph
  commit id: "A"
  commit id: "B"
  commit id: "C"
  commit id: "D"

Wollen wir diesen Commit auf den Server übertragen, müssen wir zunächst sicherstellen, dass wir auf dem neusten Stand vom Server sind. Sollte das nicht der Fall sein, müssen wir erst einen git pull durchführen. Damit wird sichergestellt, dass wir beim Pushen keine Konflikte verursachen und ein Fast-Forward-Merge auf dem Server durchgeführt werden kann.

$ git pull origin main:main
> Already up to date.
$ git push origin main:main

git push origin main:main ist ein wenig anders als bei git fetch oder git pull. Zwar ist origin wieder der Name der Verknüpfung, aber das erste main steht hier für den lokalen Branch und das zweite main steht für den Branch auf dem Server. Von links nach rechts folgt es also der Reihenfolge der Operation. Für push : lokal -> remote für fetch und pull : remote -> lokal

Der Push-Vorgang setzt zunächst den Remote Branch auf den Stand unseres lokalen Branches und übertragt dann seine Änderungen auf den Server.

Client:

gitGraph
  commit id: " "
  branch origin/main
  commit id: "A "
  commit id: "B "
  commit id: "C "

  checkout main
  merge origin/main
  checkout origin/main
  commit id: "D"
  checkout main
  merge origin/main
  commit id: "E"
  checkout origin/main
  merge main

Server:

%%{init: { 'gitGraph': { 'mainBranchName': 'origin/main' }} }%%
gitGraph
  commit id: "A"
  commit id: "B"
  commit id: "C"
  commit id: "D"
  commit id: "E"

Upstreams

Das ganze Prozedere mit origin main:main ist ein wenig umständlich. Deshalb lässt sich dies auch einmalig für einen Branch festlegen und danach einfach ein git pull bzw. git push ausführen. Wir checken dafür zunächst den gewünschten Branch aus und setzen den Upstream mit git branch --set-upstream-to=<remote>/branch.

Für den Branch main auf dem Server sieht das so aus:

$ git checkout main
$ git branch --set-upstream-to=origin/main
# oder kürzer
$ git checkout main
$ git branch -u origin/main

Bei einem Clone wird der Upstream automatisch für den Branch gesetzt. Das heißt für ein geclontes Repository ist git pull und git push ohne weiteres für main möglich.

Tipp

Wenn ihr in eurem Repository einen neuen Branch erstellt habt und diesen auf den Server pushen wollt, könnt ihr das mit dem bereits bekannten git push origin <branchname>:<branchname> machen. Danach müsst ihr nur noch den Upstream setzten und könnt mit git pull und git push arbeiten.

Identisch aber deutlich komfortabler ist der Befehlt git push -u origin <branchname>. Dieser erstellt den Branch auf dem Server und setzt den Upstream in einem Schritt.

Github Workflow

Die nahtlose Zusammenarbeit mit deinem Team ist nicht immer einfach, aber eine Grundvorausetztung für das Schreiben von moderner Software. Softwareprojekte sind zu groß und zu komplex, als dass eine Person alleine sie bewältigen könnte. Grade weil dies Zusammenarbeit so wichtig ist, gibt es mitlerweile viele erprobte und bewährte Workflows, die uns dabei helfen können. Hier werden wir uns einen der einfachsten Workflows anschauen, der auch gut für das Teamprojekt in Frage kommt.

Review

Viel zu oft passieren beim Coden unabsichtlich kleine Fehler. Mal ein +1 vergessen, mal die falsche Variable verwendet oder einfach nur ein Tippfehler. Viele dieser Fehler werden heutzutage schnell von LSP-Servern oder dem Compiler gefunden. Aber auch wenn kleinere Tippfehler kein Problem mehr sind, können grade Logikfehler selten bis gar nicht von einem Computer gefunden werden. Der Computer weiß schließlich nicht, was wir eigentlich schreiben wollen. Selbstverständlich sollten wir Tests schreiben, die diesen Fehler auf die schliche kommen, aber auch diese sind nicht perfekt. Und wäre es nicht viel schöner, wenn die Fehler erst gar nicht den Code erreichenq würden?

Genau dafür gibt es Code Reviews. In einem Code Review überprüft ein Teammitglied den Code eines anderen Teammitglieds. Dabei wird nicht nur auf offensichtliche Fehler geachtet, sondern auch auf die Lesbarkeit und Verständlichkeit des Codes. Der Review dient aber nicht nur als Kontrolle, sondern auch als Lernmöglichkeit. Durch das Lesen des Codes eines anderen Teammitglieds können wir neue Techniken und Methoden lernen, die wir in Zukunft auch in unserem eigenen Code verwenden können.

Pull Request

Das Code Reviews Sinn machen, haben wir bereits gesehen, aber wie können wir das in unserem git Alltag einbauen. Hast du noch unser 1. Leitmotiv im Kopf? main sollte immer stabil also möglichst ohne Fehler sein. Um sich diesem Zustand anzunähern, würde sich ein Code Review vor jeder Änderungen an main anbieten. Eingangs haben wir schon erwähnt, dass wir nicht direkt auf main committen sollten. Aber wie können wir dann unsere Änderungen auf main bingen? Normalerweise würden wir einen einfachen Merge oder Rebase verenden um Änderungen aus andren Branches auf main zu bringen. Wenn wir aber vorher ein Code Review haben wollen, brauchen wir einen Möglichkeit Änderungen zu prüfen bevor sie auf main landen. Wir wollen also eine Möglichkeit den andren Teammitgliedern zu signaliseren, dass unsere Änderungen bereit für ein Review sind und ihnen unsere Änderungen einfach zugänglich machen. Genau dafür gibt es Pull Requests. Wenn ihr also eure Änderungen auf main bringen wollt, erstellt ihr einen Pull Request von eurer Branch auf main. (Im Github Repository - Reiter: Pull Request) Hier beschreibt ihr die Änderungen die ihr vorgenommen habt und wartet bis ein anderes Teammitglied eure Änderungen rewiewed hat. Solle es Änderungswünsche vom Reviewer geben, sollten diese in einem neuen Commit auf euren Branch eingearbeitet werden. Durch ein pushen auf die remote Branch wird auch der Pull Request automatisch aktuelliisiert. Wenn der Reviewer zufrieden ist, kann er den Pull Request mergen und eure Änderungen landen auf main.

Erste Schritte

Euer Teamprojekt Remote Repository wird vermutlich Github als Hosting Provider verwenden. Damit die Zusammenarbeit reibungslos funktioniert, werden wir den defacto Standard Github Workflow vorstellen.

Die Zusammenarbeit wird von zwei Leitprinzipien bestimmt:

  1. main ist immer stabil. Es erfolgen keine direkten Commits auf main.
  2. Branch early, branch often - Jede neue Funktionalität wird in einem eigenen Branch entwickelt.

Wie können wir diese Leitlinien auf den Workflow in unserem Teamprojekt anwenden?

Wir gehen davon aus, dass ihr bereits ein Repository für euer Teamprojekt erstellt habt. Falls nicht, könnt ihr dies in der Github Dokumentation nachlesen.

Da viele Programmiersprachen gleich mit einer grundlegenden Ordnerstruktur und Dateien starten, empfehlen wir euch, dass einer alles aufsetzt und dann als initial Commit direkt auf main pushed. Dies ist der einzige Fall, in dem ein direkter Commit auf main erstellt werde sollte.

Die andren Teammitglieder können jetzt das Repository clonen.

Achtet darauf gleich am Anfang alle eine .gitignore Datei zu erstellen, damit nicht unnötige Dateien (Editor-Config / Cache-Dateien / etc.) im Repository landen und bei den andern zu Konflikten führen.

Nachdem ihr diese Anfangsschritte durchgeführt habt, könnt ihr mit dem eigentlichem sich immer Wiederholenden Workflow beginnen.

Der Workflow

  1. Ihr habt eine klare Vorstellung, was ihr implementieren oder ändern wollt. Die Aufgabe sollte in sich geschlossen sein und nicht zu groß.

"nicht zu groß" ist ein sehr subjektiver Begriff. Aber für euer Teamprojekt könnt ihr euch an einer groben Faustregel orientieren: Wenn ihr vorraussichtlich mehr als 4-5 Stunden braucht um die Aufgabe zu erledigen, ist sie vermutlich zu groß. Hier solltet ihr sie in deutlich fassbare Aufgaben unterteilen.

  1. Setzt eueren main Branch auf den aktuellen Stand. Wir verwenden dafür den Befehl git pull auf main.

  2. Erstellt einen neuen Branch für eure Aufgabe. Der Name der Branch sollte aussagekräftig sein und am besten vermitteln, was ihr angehen wollt. (z.B feature/login_page). Letztendlich bleibt es euch überlassen einen Namenskonvention zu finden. Achtet aber drauf, dass alle im Team die selbe Konvention verwenden. Es erleichtert die Zusammenarbeit.

  3. Bearbeitet eure Aufgabe auf eurem Branch. Commitet regelmäßig und schreibt aussagekräftige Commit Messages - eure Teammitglieder werden es euch danken.

  4. Ihr könnt bereits euren Branch auf den Remote Server (eg. Github) pushen. Das ist besonders dann sinnvoll, wenn ihr an einem Feature arbeitet, das länger dauert. So können eure Teammitglieder sehen, dass ihr an der Aufgabe arbeitet und ggf. Feedback geben. (z.B. git push -u origin feature/login_page)

  5. Wenn ihr mit eurer Aufgabe fertig seid erstellt ihr einen Pull Request (PR) auf main. In diesem PR beschreibt ihr kurz, was ihr erledigt habt (eg. Issue verlinken) und weißt eurem Code einen Reviewer zu. Sollten eure Änderungen nicht klar sein, habt ihr hier noch die Möglichkeit mit einem interaktiven Rebase vor dem Review Ordnung zu schaffen.

  6. Ein anderes Teammitglied reviewed euren Code und gibt falls nötig Änderungswünsche. Sollt ihr diese für nicht sinnvoll halten, könnt ihr das über die Kommentarfunkionen im PR diskutieren. Wenn ihr einen Konsens gefunden habt und die Änderungen eingearbeitet sind, kann der Reviewer nach einem erneuten Review den PR mergen.

  7. Wir beginnen wieder mit 1.

Git Cheatsheet

Einen Überblick verschaffen

CommandBeschreibung
git statusZeigt den aktuellen Stand des HEAD pointers, den Stand der Staging Area sowie Änderungen am Working Tree
git logZeigt die letzten Commits die von unserem aktuellen HEAD erreichbar sind
git log --oneline --graph --allZeigt den Commit Graph aller Commits von allen Branches an

Checkout

CommandBeschreibung
git checkout <branch>Wechselt auf den angegebenen Branch.
git checkout <commit>Wechselt auf den angegebenen Commit. Du befindest dich danach im "detached HEAD" modus. (Erstelle und) Wechsle auf einen Branch, bevor du einen Commit erstellst

Staging Area

CommandBeschreibung
git add <file_name>Fügt die angegebene Datei der Staging Area hinzu.
git reset <file_name>Überschreibt die Staging Area mit dem letzten Commit. Das hat den Effekt, dass die Datei wieder aus der Staging Area entfernt wird. Der Working Tree bleibt davon unberührt.

Commit

CommandBeschreibung
git commitErstellt einen neuen Commit.
git commit -m "Text"Erstellt einen neuen Commit mit einer Nachricht

Branches

CommandBeschreibung
git branchZeigt alle Branches an.
git branch <name>Erstellt einen neuen Branch am aktuellen HEAD pointer.
git branch -m <alter_name> <name>Ändert den Namen des angegebenen Branches.

Merging

CommandBeschreibung
git merge <branch>Merged den angegebenen Branch in den aktuellen Branch.
git merge --abortBricht den Merge nach einem Konflikt ab und stellt den vorherigen Stand wieder her. Solltest du einen Merge abschließen wollen, muss nur ein neuer Commit nach dem Lösen des Konflikts erstellt werden.

Rebasing

CommandBeschreibung
git rebase <branch>Rebasen des aktuellen Branches auf den angegebenen Branch.
git rebase --abortBricht den Rebase nach einem Konflikt ab und stellt den vorherigen Stand wieder her.
git rebase --continueFührt den Rebase nach dem Lösen eines Konflikts fort.

Reset

CommandBeschreibung
git reset --soft <commit>Setzt den HEAD pointer auf den angegebenen Commit. Die Staging Area und der Working Tree bleiben unberührt.
git reset --mixed <commit>Setzt den HEAD pointer auf den angegebenen Commit. Die Staging Area wird zurückgesetzt. Der Working Tree bleibt unberührt.
git reset --hard <commit>Setzt den HEAD pointer auf den angegebenen Commit. Die Staging Area und der Working Tree werden zurückgesetzt. Nicht Commitete Änderungen im Working Tree gehen verloren

Reflog

CommandBeschreibung
git reflogZeigt die Historie aller HEAD Änderungen an. Nützlich um nach Commits ohne Branches wiederzufinden

Remotes

CommandBeschreibung
git remoteZeigt alle Remotes an, die in diesem Repository konfiguriert sind.
git remote add <name> <url>Fügt ein neues Remote Repository hinzu.
git remote remove <name>Entfernt das angegebene Remote Repository.
git remote show <name>Zeigt Informationen über das angegebene Remote Repository an.

Arbeiten mit Remotes

CommandBeschreibung
git fetch <remote>Lädt alle Änderungen aus dem angegebenen Remote Repository herunter, ohne sie zu mergen.
git pull <remote> <branch>Lädt alle Änderungen aus dem angegebenen Remote Repository herunter und merged sie in den aktuellen Branch.
git push <remote> <branch>Lädt alle Änderungen aus dem aktuellen Branch in das angegebene Remote Repository hoch.
git branch --set-upstream-to=<remote>/<branch>Setzt den Upstream Branch für den aktuellen Branch.
git push -u <remote> <branch>Lädt alle Änderungen aus dem aktuellen Branch in das angegebene Remote Repository hoch und setzt den Upstream Branch.
git pull --rebase <remote> <branch>Lädt alle Änderungen aus dem angegebenen Remote Repository herunter, merged sie in den aktuellen Branch und setzt den Upstream Branch

Softwareentwicklung

Hier geht es um das Schreiben von Software. Für Entwicklungsprozesse, siehe hier

Anforderungen

Bevor es losgeht, müssen wir uns überlegen, was genau wir eigentlich schreiben wollen. Hier reicht nicht nur eine grobe Idee, sondern es sollten ganz explizit die Use-Cases festgelegt werden.

Beispiel

"Wir schreiben eine App die es ermöglicht Rezepte zu verwalten."

Das ist eine grobe Idee und sicher nicht genug um loszulegen. Arbeitet ganz explizite Use-Cases aus

Use-Cases könnten sein:

  • Ein Arzt kann für Patienten Rezepte anlegen und Folgerezepte ausstellen
  • Ein Patient kann Rezepte einsehen
  • Ein Patient kann Folgerezepte anfordern
  • Rezepteänderungen sind vom Patienten klar nachvollziehbar
  • Die Apotheke kann Rezepte einsehen und als abgeholt markieren

Dies Use-Cases sind schon deutlich konkreter und ermöglichen es uns, konkrete Anforderungen abzuleiten. Diese Anforderungen sind dann die Grundlage für unsere Software:

  • Es gibt drei Rollen in der App: Arzt, Patient, Apotheke
    • ein Artzt kann Rezepte anlegen und Folgerezepte ausstellen
    • ein Patient kann Rezepte einsehen und Folgerezepte anfordern
    • die Apotheke kann Rezepte einsehen und als abgeholt markieren
  • Es gibt ein Rechte-System geben, dass die Rollen voneinander trennt
  • Es gibt ein zentrales Login-System, dass Patienen authentifiziert
  • ...

Diese Anfangsphase ist grade im Teamprojekt sehr wichtig. Hier wird genau festgelegt, was und was nicht euer Projekt erreichen soll. Dokumentiert diesen Prozess klar und für alle Parteien verständlich. Missverständnisse in dieser Phase können später schnell zu größeren Problemem führen.

Architektur

Bevor wir Anfangen Code zu schreiben, sollten wir uns überlegen wie wir den Code strukturieren wollen. Oftmals kommt es sonst zu einem Wildwuchs an einzelnen Teilen, die nur schwer zusammenpassen. Behaltet hier im Hinterkopf, dass unterschiedliche Personen parallel zueinander an dem Projekt arbeiten werden. Eine modulare Struktur macht euch euer Leben mit sehr viel weniger Merge-Konflikten leichter.

Grade in größeren Softwareprojekten folgt die Architektur oft der Organisationsstruktur der Teams. Dies ist zwar nicht immer optimal im Sinner der Software. Einfaches parallels Arbeiten ist aber meistens wichtiger.

Zum Design bieten sich meist UML-Klassendiagramme-Diagramme (eher objektorientiert) oder Flowchart-Diagramme (eher funktional) zur Visualisierung an. Das kann zum Beispiel für einen lexer so aussehen:

flowchart LR
 id1{input}-->id2[tokenize by whitespace] --> id3[filter keywords] --> id4[desugar] --> id6{output}
 id3 --| add keywords to index |--> id5[[Keyword-Index]] --> id6

Implementierung

Nachdem die Anforderungen und die Architektur festgelegt sind, kann es an die Implementierung gehen. Die gorben Züge des Projekts sollten soweit feststehen, dass möglichst wenig böse Überraschungen warten.

Tendenziell gibt es zwei Arten Software zu implementieren:

  • Top-Down: Vom groben Konzept zum Detail
  • Bootom-UP: Von den Details zum groben Konzept

Beide Ansätze und ihre Mischformen sind in der Praxis zu finden. Im Teamprojekt würde ich dir einen Top-Down Ansatz deutlich ans Herz legen. Grade in Sprachen, die einem noch nicht so bekannt sind, werden sonst Details fein säuberlich ausgearbeitet, die später leider nicht zusammenfassen.

Wie also gehen wir vor? Wir die allgemeinsten Funktionen / Klassen als erstes und setzen platzhalter an die Stellen, die noch mehr in die Tiefe gehen. So arbeiten wir uns Schritt für Schritt in die Tiefe.

Hier ein sehr triviales Beispiel:

#![allow(unused)]
fn main() {
struct Record {
    first_name: String,
    last_name: String,
    age: u32,
    address: String,
}

// bekommt die den Dateipfad und gibt eine Liste an gelesenen Records zurück.
// Pro Zeile steht genau ein Record
// gibt eine leere List zurück, wenn die Datei nicht existiert
fn read_data(input: &str) -> Vec<Record> {
    if !is_file() {
        // gibt eine leere Liste zurück
        return vec![];
    }
    let file = read_file(input)
    let lines = split_lines(file);
    // wandelt die Zeilen in Records um
    // wandelt die Zeilen in Records um
    let records = lines.iter().map(read_record());
    // gibt die records zurück
    records
}
}

Wir starten mit einer sehr groben Struktur und rufen bei allem was komplizierter scheint eine Funktion auf, die sich dieses Problem löst. Für diese Funktionen schreiben wir im nächsten Schritt die Implementierung..

#![allow(unused)]
fn main() {
struct Record {
    first_name: String,
    last_name: String,
    age: u32,
    address: String,
}

// bekommt die den Dateipfad und gibt eine Liste an gelesenen Records zurück.
// Pro Zeile steht genau ein Record
// gibt eine leere List zurück, wenn die Datei nicht existiert
fn read_data(input: &str) -> Vec<Record> {
    if !is_file() {
        // gibt eine leere Liste zurück
        return vec![];
    }
    let file = read_file(input)
    let lines = split_lines(file);
    // wandelt die Zeilen in Records um
    // wandelt die Zeilen in Records um
    let records = lines.iter().map(read_record());
    // gibt die records zurück
    records
}

// checke ob der Dateipfad existiert und eine Datei ist
fn is_file() -> bool {
    match std::fs::metadata("file.txt") {
       Ok(it) => it.is_file(),
       Err(_) => false,
    }
}

// lese die Datei als String ein
fn read_file(input: &str) -> String {
    std::fs::read_to_string(input).unwrap()
}

// teile den String in ein einzelne Zeilen auf
fn split_lines(input: &str) -> Vec<&str> {
    input.split_lines().collect()
}

fn read_record(input: &str) -> Record {
    // deligiere die Arbeit an den Record. Diese Funktion muss erst noch erstellt werden.
    Record::from_str(input)
}


}

Diesem Prozess folgend, arbeitet wir uns immer Tiefer bis alle Probleme gelöst sind und wir ein funktionierendes Feature haben.

SOLID Design Prinzipien

Alle Wege führen zum Ziel. Das gilt leider auch für die Softwareentwicklung. Es gibt viele Wege Software zu schreiben. Jede dieser Lösungungen hat Möglicherweise das gleiche Verhalten, kann sich aber signifikant in der Wartbarkeit, Erweiterbarkeit und Lesbarkeit - kurz: internen Qualität - unterscheiden.

Die SOLID Prinzipien versuchen uns dabei zu helfen, einen Weg mit möglichst hoher interner Qualität zu beschreiten.

SOLID

SOLID ist ein Akonymn für fünf Design Prinzipien von Robert C. Martin. Diese Prinzipien sind:

  • Single Responsibility Principle - Eine Komponente (Klasse, Funktion, Modul) sollte nur eine Aufgabe erfüllen
  • Open/Closed Principle - Eine Komponente sollte offen für Erweiterungen aber geschlossen für Modifikationen sein
  • Liskov Substitution Principle - Subtypen müssen in ihrem Verhalten den Basistypen entsprechen
  • Interface Segregation Principle - Nutzer sollten nicht gezwungen sein Methoden zu implementieren, die sie nicht benötigen
  • Dependency Inversion Principle - Module sollten nicht von konkreten Implementierungen abhängen, sondern von abstrakten Schnittstellen

Sie formulieren alle wünschenswerte Eigenschaften eines Software Designs. Dabei gelten sie mehr als Heuristiken die dir helfen sollen unterschiedliche Ansätze abzuwägen und nicht als strenge Regeln die es strikt einzuhalten gilt.

SRP Single Responsibility Principle

Eine Komponente (Klasse, Funktion, Modul) sollte nur eine Aufgabe erfüllen. Oder anders ausgedrückt: Eine Komponente sollte nur einen Grund haben verändert zu werden.

Am Einfachsten lässt sich das das an einem Beispiel sehen, dass das Single Responsibility Principle verletzt. Wir betrachten folgenden Java Code:

public class User {
    private String name;
    private String email;
    private String password;

    public void save() {
        // Speichere den User in der Datenbank
    }

    public void sendEmail() {
        // Sende eine E-Mail an den User
    }
}

Die Klasse User hat zwei Methoden saveToDB und sendEmail. Die Methode saveToDB speichert den User in der Datenbank und die Methode sendEmail sendet eine E-Mail an den User. DasProblem hierbei ist, dass die Klasse User zwei Gründe hat verändert zu werden. Zum einen, wenn sich die Weise ändert, wie ein User in der Datenbank gespeichert wird und zum anderen, wenn sich die Art ändert, wie eine E-Mail an den User gesendet wird.

Zusätzlich zum SRP verletzt unser Code auf das DRY Prinzip, sollte es wo anders noch eine Methode geben, die E-Mails sendet.

Wie könnten wir das ganze besser machen? Wir könnten die methode sendEmail in eine eigene Klasse EmailService auslagern. Die Klasse User würde dann nur noch die Daten des Users verwalten und die Methode save enthalten.

class User {
    private String name;
    private String email;
    private String password;

    public void saveToDb() {
        // Speichere den User in der Datenbank
    }
}

class EmailService {
    public void sendEmail(User user) {
        // Sende eine E-Mail an den User
    }
}

Open Closed

Eine Komponente sollte offen für Erweiterungen aber geschlossen für Modifikationen sein. Das heißt Erweiterungen der Funktionalität sollten möglichst keinen bestehenden Code verändern.

Dies Prinzip ziehlt vor allem darauf einmal geschriebenen und getesteten Code nicht zu verändern und somit die Möglichkeits von Bugs zu minimieren

Beispiel

class UserDao {
    private User user;

    public UserDao(User user) {
        this.user = User;
    }

    public void saveToDb() {
        // save to database implementation
    }
}

User Dao hat eine einzige Aufgabe. Es speicher einen User in der Datenbank. Kommt nun aber die Anforderung, dass der User auch in einer Textdatei gespeichert werden soll, so müsste die Methode saveToDb angepasst werden. Das ist ein Verstoß gegen das Open/Closed Prinzip. Wir können den Code umschreiben, sodass die Methode saveToDb nicht mehr verändert werden muss.

interface UserSaver {
    void save(User user);
}

class DbUserSaver implements UserSaver {
    public void save(User user) {
        // save to database implementation
    }
}

class FileUserSaver implements UserSaver {
    public void save(User user) {
        // save to file implementation
    }
}

Liskov Substitution

Subtypen müssen in ihrem Verhalten den Basistypen entsprechen.

Betrachten wir das ganze anhand eines Beispiels:

interface Bike {
    void turnOnEngine();

    void accelerate();
}

Wir haben das Interface Bike, welches zwei Methoden definiert: turnOnEngine und accelerate. Zwei Klassen implementieren dieses Interace Motorbike und Bicycle.

class Motorbike implements Bike {

    boolean isEngineOn;
    int speed;

    @Override
    public void turnOnEngine() {
        isEngineOn = true;
    }

    @Override
    public void accelerate() {
        speed += 5;
    }
}

Motorbike hat für beide Methoden eine korrekte Implementierung. turnOnEngine setzt den Wert von isEngineOn auf true und accelerate erhöht die Geschwindigkeit um 5km/h.

class Bicycle implements Bike {

    boolean isEngineOn;
    int speed;

    @Override
    public void turnOnEngine() {
        throw new AssertionError("There is no engine!");
    }

    @Override
    public void accelerate() {
        speed += 5;
    }
}

Im Gegensatz dazu hat Bicycle eine fehlerhafte Implementierung von turnOnEngine. Da ein Fahrrad keinen Motor hat, sollte die Methode turnOnEngine nicht aufgerufen werden. In diesem Fall wird eine AssertionError geworfen. Das ist ein Verstoß gegen das Liskov Substitution Prinzip. Sollte eine Klasse ein Interface implementieren, sollte alle Instanzen des Interfaces das gleiche Verhalten haben. In diesem Fall sollte turnOnEngine für alle Klassen, die das Interface Bike implementieren, den Motor anschalten. Die Implmentation von Bicycle ist also fehlerhaft.

Interface Segregation

Nutzer sollten nicht gezwungen sein Methoden zu implementieren, die sie nicht benötigen

Dieses Prinzip zielt darauf ab, Interfaces mit möglichst wenigen Methoden zu definieren, anstatt Interfaces mit vielen Methoden zu definieren, um alle Möglichen Fälle abzudecken.

Beispiel

interface Vehicle {
    void startEngine();
    void stopEngine();
    void drive();
    void fly();
}

class Car implements Vehicle {

    @Override
    public void startEngine() {
        // implementation
    }

    @Override
    public void stopEngine() {
        // implementation
    }

    @Override
    public void drive() {
        // implementation
    }

    @Override
    public void fly() {
        throw new UnsupportedOperationException("This vehicle cannot fly.");
    }
}

In diesem Beispiel implementiert die Klasse Car das Interface Vehicle. Da ein Auto nicht fliegen kann, wird die Methode fly mit einer UnsupportedOperationException geworfen. Das ist ein Verstoß gegen das Interface Segregation Prinzip, da das Interface unnötig viele Methoden fasst. Wir können das Interface in kleinere Interfaces aufteilen.

interface Engine {
    void startEngine();
    void stopEngine();
}

interface Drivable {
    void drive();
}

interface Flyable {
    void fly();
}

Wir teilen unser Interface in drei kleinere Interfaces auf: Engine, Drivable und Flyable. Durch die kleineren Interfaces ist es deutlich einfacher für Klassen, sich nur Methoden bzw. Interfaces zu implementieren, die sie wirklich benötigen. Hier können wir nun die Klasse Car und Airplane ohne Probleme implementieren.

class Car implements Engine, Drivable {

    @Override
    public void startEngine() {
        // implementation
    }

    @Override
    public void stopEngine() {
        // implementation
    }

    @Override
    public void drive() {
        // implementation
    }
}

class Airplane implements Engine, Drivable, Flyable {

    @Override
    public void startEngine() {
        // implementation
    }

    @Override
    public void stopEngine() {
        // implementation
    }

    @Override
    public void drive() {
        // implementation
    }

    @Override
    public void fly() {
        // implementation
    }
}

Dependency Inversion

Module sollten nicht von konkreten Implementierungen abhängen, sondern von abstrakten Schnittstellen.

Dieses Prinzip zielt darauf ab, die Kopplung zwischen Modulen zu verringern, die Modularität zu erhöhen und den Code leichter wartbar, testbar und erweiterbar zu machen.

Betrachten wir zum Beispiel ein Szenario, in dem wir eine Klasse haben, die eine Instanz einer anderen Klasse verwenden muss. Normalerweise würde die erste Klasse direkt eine Instanz der zweiten Klasse erstellen, was zu einer engen Kopplung zwischen ihnen führt. Dies erschwert es, die Implementierung der zweiten Klasse zu ändern oder die erste Klasse unabhängig zu testen.

Wenn wir jedoch das DIP anwenden, würde die erste Klasse stattdessen von einer Abstraktion der zweiten Klasse abhängen und nicht von der Implementierung. Dies würde es ermöglichen, die Implementierung leicht zu ändern und die erste Klasse unabhängig zu testen.

class WeatherTracker {
    private String currentConditions;
    private Emailer emailer;

    public WeatherTracker() {
        this.emailer = new Emailer();
    }

    public void setCurrentConditions(String weatherDescription) {
        this.currentConditions = weatherDescription;
        if (weatherDescription == "rainy") {
            emailer.sendEmail("It is rainy");
        }
    }
}

In diesem Beispiel erstellt die Klasse WetterTracker direkt eine Instanz der Klasse Emailer, was zu einer engen Kopplung an die Implementierung führt. Dies erschwert es, die Implementierung der Klasse Emailer zu ändern oder die Klasse WetterTracker unabhängig zu testen.

Wir können unser Beispiel umschreiben, damit die Klassen nicht mehr so stark voneinander abhängen.

interface Notifier {
    public void alertWeatherConditions(String weatherDescription);
}

class WeatherTracker {
    private String currentConditions;
    private Notifier notifier;

    public WeatherTracker(Notifier notifier) {
        this.notifier = notifier;
    }

    public void setCurrentConditions(String weatherDescription) {
        this.currentConditions = weatherDescription;
        if (weatherDescription == "rainy") {
            notifier.alertWeatherConditions("It is rainy");
        }
    }
}

class Emailer implements Notifier {
    public void alertWeatherConditions(String weatherDescription) {
        System.out.println("Email sent: " + weatherDescription);
    }
}

class SMS implements Notifier {
    public void alertWeatherConditions(String weatherDescription) {
        System.out.println("SMS sent: " + weatherDescription);
    }
}

Code Smells

SCRUM

SCRUM ist ein agiles Framework, das Teams dabei unterstützt, Softwareprojekte in iterativen und inkrementellen Zyklen, den so genannten Sprints, zu bearbeiten. Ziel ist es, den Entwicklungsprozess flexibel zu gestalten, um schnell auf Änderungen reagieren zu können und das Endprodukt Schritt für Schritt zu verbessern. Vermutlich werdet ihr so im Teamprojekt euere einzelnen Aufgaben priorisieren und abarbeiten. Wir schauen uns hier den Prozess im Detail an:

  1. Produkt-Backlog erstellen: Der Product Owner (PO) ist zuständig für die Erstellung und Priorisierung des Produkt-Backlogs. Das Backlog umfasst alle Features, Funktionen, Anforderungen und Verbesserungen, die das Endprodukt enthalten soll. Der PO ist die Schlüsselverbindung zwischen dem Entwicklungsteam und den Stakeholdern (euer Teamleiter) und stellt sicher, dass die Arbeit des Teams maximalen Wert schafft.

  2. Sprint-Planung: Zu Beginn jedes Sprints führt der Scrum Master, der als Facilitator des Prozesses dient, das Team durch eine Planungssitzung. Hierbei entscheidet das Entwicklungsteam, welche Aufgaben aus dem Produkt-Backlog im kommenden Sprint bearbeitet werden sollen. Diese Aufgaben werden dann in das Sprint-Backlog überführt. Die Aufgabenmenge wird so gewählt, dass sie das Team realistisch in einem Sprint, der in eurem Teamprojekt wahrscheinlich 1-2 Wochen dauert, abschließen kann.

  3. Der Sprint beginnt: Während des Sprints arbeitet das Entwicklungsteam eigenständig an den Aufgaben, um die Sprint-Ziele zu erreichen. Der Scrum Master unterstützt das Team, indem er hilft, Hindernisse zu beseitigen und sicherstellt, dass das Team ungestört arbeiten kann.

  4. Tägliches SCRUM-Meeting: Der Scrum Master moderiert das tägliche Stand-up-Meeting, in dem jedes Teammitglied Fortschritte, geplante nächste Schritte und mögliche Hindernisse diskutiert. Diese Meetings fördern die Kommunikation und schnelle Problemlösung.

Täglich ist vermutlich nicht bei jedem Teamprojekt realisitisch. 2-3 mal pro Woche wäre aber auf jeden Fall sinvoll.

  1. Sprint-Review: Am Ende des Sprints organisiert der Scrum Master ein Review-Meeting, bei dem das Team seine Ergebnisse dem PO und den Stakeholdern präsentiert. Dies ist ein wichtiger Moment, um Feedback zu sammeln und den Wert des Geleisteten zu demonstrieren.

  2. Sprint-Retrospektive: Die Retrospektive ist eine Sitzung, in der das Team (geleitet vom Scrum Master) reflektiert, was gut lief und was in Zukunft verbessert werden könnte. Ziel ist es, den Entwicklungsprozess kontinuierlich zu optimieren.

  3. Der nächste Sprint beginnt: Unter Berücksichtigung des Feedbacks und der Erkenntnisse aus der Retrospektive startet die Planung des nächsten Sprints, wobei der Zyklus von vorne beginnt.

Im Teamprojekt werdet ihr vermutlich nicht alle Rollen aus SCRUM besetzen können. Der PO wird vermutlich euer Teamleiter sein, der Scrum Master könnte ein Teammitglied sein, das sich um die Organisation kümmert aber trotzdem noch als Entwickler fundiert. Der Rest des Teams sind die Entwickler.

graph TD
    A(Produkt-Backlog erstellen) -->|Priorisierung durch PO| B(Sprint-Planung)
    B --> C{Sprint startet}
    C -->|Täglich| D(Tägliches SCRUM-Meeting)
    D --> E{Arbeit am Sprint}
    E --> |Ende des Sprints| F(Sprint-Review)
    F --> G(Sprint-Retrospektive)
    G --> H{Feedback & Verbesserung}
    H --> A