Dies ist der erste Teil einer zweteiligen Serie. Hier geht es zu Teil 2.
Versionskontrollsysteme sind aus der Kollaboration von Softwareentwicklungsteams heute nicht mehr wegzudenken. Aktuell scheint dabei, aus meiner Erfahrung heraus, Git das verbreitetste System zu sein.
Um Git zu nutzen, gibt es eine Reihe an Alternativen. Neben dedizierten grafischen Git-Clients oder der Integration in die gängigen Entwicklungsumgebungen und Editoren kann Git natürlich auch auf der Kommandozeile verwendet werden. Da ich bereits während meines Studiums, als Werkstudent, in einem erfahrenen Team arbeiten durfte, das nahezu alles auf der Kommandozeile erledigte, sagt mir diese letzte Art der Nutzung von Git besonders zu.
Heute stoße ich dabei immer wieder auf Menschen, die sich für einen der grafischen Clients entschieden haben. Wenn diese, zum Beispiel beim Screensharing, meine Arbeitsweise sehen, sind sie verwundert und können ab und zu nicht folgen. Das liegt vor allem daran, dass ich über die meisten Dinge bei der Nutzung von Git nicht mehr nachdenken muss. Ich mache diese Dinge intuitiv. Das hängt natürlich zum einen an der Erfahrung und der langen Zeit, die ich Git bereits nutze. Allerdings glaube ich auch daran, dass ich durch die Nutzung von Git auf der Kommandozeile dazu gezwungen war, mich tiefer mit dem Tool auseinanderzusetzen, als dies, meiner Meinung nach, mit grafischen Clients passiert.
Mit diesem Artikel möchte ich niemanden „missionieren“. Auch werde ich nicht behaupten, dass die Nutzung von Git auf der Kommandozeile den anderen Arten überlegen ist. Ziel dieses Artikels ist es, Grundeinstellungen und grundlegende Kommandos, angereichert durch kleinere Tipps, zu erklären.
Grundeinstellungen
Die wichtigste Grundeinstellung von Git besteht in der Konfiguration des Benutzernamens und der E-Mail-Adresse. Beides wird von Git beim Speichern und Übertragen von Änderungen verwendet, damit später nachverfolgt werden kann, wer dies getan hat. Müssen wir später etwas in der Historie des Repositories suchen, werden beide Werte bei fast jedem Kommando angezeigt. Die Pflege der beiden Werte macht es einem Suchenden demnach deutlich einfacher, an sein Ziel zu gelangen.
Deshalb sollten nach der Installation von Git die beiden Befehle aus Listing 1 ausgeführt werden. Diese globalen Einstellungen speichert Git anschließend, menschenlesbar, in der Datei .gitconfig im Home-Verzeichnis des aktuellen Benutzers. Wie nahezu alle Einstellungen in Git können wir diese beiden Werte auch pro Projekt einstellen. Innerhalb dieses Repositories werden diese anschließend verwendet und überschreiben somit die global gesetzten Werte. So kann beispielsweise die E-Mail-Adresse für ein arbeitsrelevantes Projekt auf einen anderen Wert als für das private Projekt gesetzt werden.
Diese Strategie neigt leider, zumindest bei mir, dazu, dies zu vergessen. Um dies zu verhindern, kann in der Datei .gitconfig eingestellt werden, dass für alle Projekte unterhalb eines spezifischen Pfads eine weitere .gitconfig-Datei geladen wird (s. Listing 2). Git nutzt von nun an für alle Repositories unterhalb von ~/Development/innoq die Werte aus der Datei ~/.gitconfig_innoq. In dieser kann nun als E-Mail-Adresse meine Arbeitsadresse eingetragen werden, wohingegen in allen anderen Repositories meine private Adresse verwendet wird, da diese als Standard in der globalen Konfiguration eingetragen ist.
Da grafische Git-Tools in der Regel auch diese Konfigurationsdateien beachten, kann diese Einstellung auch verwendet werden, wenn Git nicht auf der Kommandozeile genutzt wird.
Wege zum lokalen Repository
Um mit einem lokalen Git-Repository arbeiten zu können, muss man dieses entweder
neu erzeugen oder ein bereits anderswo existierendes zu sich holen. Um ein neues
Repository anzulegen, wird der Befehl git init
verwendet. Anschließend ist das
aktuelle Verzeichnis ein Repository und die Dateien können per Git verwaltet
werden. Zu erkennen ist dies auch daran, dass ein verstecktes Verzeichnis .git
existiert, in dem Git seine Daten und Repository spezifische Einstellungen
ablegt.
Um ein bereits vorhandenes Repository lokal zur Verfügung zu haben, kann mittels
git clone <pfad>
eine lokale Kopie erzeugt werden. Standardmäßig wird dabei
der letzte Teil des Pfads auch als Name des lokalen Verzeichnisses genutzt.
Im Rahmen der „Black Lives Matter”-Demonstrationen wurden auch in der IT erneut
Diskussionen um einige Begriffe geführt. Eine dieser Diskussionen geht um den
Namen des standardmäßig von Git erzeugen Branches: master. Da ich in den
letzten Jahren immer wieder gelernt habe, wie wichtig Worte in unserer Welt
sind, bin ich der Meinung, es lohnt sich, den Namen des Standard-Branches zu
ändern. Damit das nicht nach jedem git init
manuell erfolgen muss, kann mit
git config --global init.defaultBranch <branchname>
ein anderer Name
eingestellt werden. Aktuell scheint sich hier der
Name main durchzusetzen. Dieser hat den
Vorteil, dass er auch mit ma
beginnt und dadurch das Muskelgedächtnis genutzt
wird, das zum Beispiel beim Wechseln auf den Hauptbranch nach git checkout ma
spätestens die Tab-Taste drückt, um von der Autovervollständigung der
Kommandozeile zu profitieren. Für Projekte, die kontinuierlich deployen, kann
allerdings auch der Name production
sinnvoll sein, um zu zeigen, dass dies der
aktuelle Stand ist.
Umgang mit Branches
Änderungen in Git werden grundsätzlich auf Branches gemacht. Um lokal einen
neuen Branch zu erzeugen, kann entweder git checkout-b <branchname>
oder, seit
Version 2.23, das modernere git switch -c <branchname>
verwendet
werden. Wird kein weiteres Argument übergeben, beginnt der Branch an der Stelle
der Historie, an der wir uns aktuell befinden.
Um uns alle vorhandenen Branches anzeigen zu lassen, können wir das Kommando
git branch
verwenden. Dieses listet standardmäßig allerdings nur die lokal
vorhandenen Branches auf. Daher nutze ich es häufig in Verbindung mit der Option
-a
, um mir alle Branches, Lokal und Remote, anzeigen zu lassen. Alternativ
kann auch die Option -r
verwendet werden, um nur die Remote vorhandenen
Branches aufzulisten.
Für den Wechsel von Branches wird, wie für das Erzeugen, entweder
git checkout <branchname>
oder git switch <branchname>
verwendet. Existiert
der eingegebene Name Lokal nicht, ist aber Remote vorhanden, wird automatisch
der Stand des Remote Branches genommen und Git merkt sich, dass diese beiden
Branches zusammengehören. Theoretisch ist es nämlich auch möglich, einen anderen
Namen für den lokalen Branch zu nutzen. In meiner Praxis ist mir dies jedoch so
gut wie noch nie begegnet.
Da ich aus meiner Vergangenheit mit Subversion gewohnt bin, nur
sehr kurze Kommandonamen eingeben zu müssen, benutze ich für die oben genannten
Kommandos eine Reihe von Aliasen (s. Listing 3). Dadurch muss ich zum Anlegen
eines neuen Branches lediglich git cob <branchname>
tippen.
Um einen Branch wieder zu löschen, nutzen wir die -d
Option von git branch
.
Sollte Git dabei feststellen, dass dieser noch in keinen anderen Branch gemergt
worden ist, müssen wir allerdings -D
nutzen, um diesen wirklich zu entfernen.
Dieser Schutzmechanismus hat mir schon Einiges an Arbeit erspart.
Lokale Änderungen
Nachdem wir Änderungen an den Dateien im lokalen Repository durchgeführt haben,
möchten wir diese natürlich auch in Git speichern. Auf der Kommandozeile sind
hierzu zwei Schritte notwendig. Im ersten Schritt müssen wir diese Änderungen in
die Staging Area übertragen. Hierzu nutzen wir git add <pfad>
, um einzelne
Dateien oder ganze Verzeichnisse hinzuzufügen.
Haben wir alle Änderungen, die wir speichern wollen, hinzugefügt, können wir per
git commit
einen Commit erzeugen. Standardmäßig geht dabei der in der
Variablen EDITOR
eingetragene Editor auf, um uns das Schreiben einer
Commitmessage zu erlauben. Alternativ lässt sich mit
git config --global core.editor <editorbefehl>
auch ein alternativer Editor
einstellen oder bei einzeiligen, kurzen Messages der Befehl
git commit -m <commitmessage>
nutzen. Möchten wir alle lokalen Änderungen
committen, lässt sich die Option -a
nutzen. Diese sorgt dafür, dass
automatisch alle lokalen Änderungen gespeichert werden, ohne dass wir diese
extra vorher in die Staging Area übertragen müssen.
Git ermöglicht auch das teilweise Hinzufügen von Änderungen an einer Datei.
Hierzu wird git add -p
genutzt. Anschließend zeigt Git den ersten Block von
Änderungen an und fragt nach, was mit diesem Block geschehen soll. Innerhalb
dieser Abfragen nutze ich eigentlich nur die folgenden vier Optionen:
- Mit
y
wird der aktuell angezeigte Block in die Staging Area übertragen und springt zum nächsten Block. -
n
überträgt den aktuellen Block nicht und springt zur nächsten Änderung. - Wenn möglich kann mit
s
versucht werden, den aktuellen Block zu verkleinern. Das ist praktisch, wenn Git einen Block anzeigt, der aus mehreren Teilen besteht, von denen wir nicht alle übernehmen wollen. - Wenn das automatische Verkleinern nicht funktioniert oder wirklich nur Teile
der Änderung übernommen werden sollen, bringt uns
e
in einen Editor, in dem wir den aktuellen Block frei editieren können. Dabei bearbeiten wir direkt das Diff-Format. Das heißt, um eine Zeile, die hinzugefügt wurde, zu ignorieren, löschen wir sie ganz. Um eine entfernte Zeile doch zu behalten, können wir das-
am Anfang der Zeile durch ein Leerzeichen ersetzen. Ansonsten steht es uns frei, in den mit+
markierten Zeilen noch weitere Änderungen zu machen, die anschließend übertragen werden.
Wir können Dateien natürlich auch wieder aus der Staging Area entfernen. Hierzu
nutzen wir git reset HEAD
. Da mir dieses Kommando zu sperrig und wenig
intuitiv ist, nutze ich hierfür den Alias unstage
(s. Listing 4).
Das Kommando endet in der Konfiguration mit einem --
, damit Git alle
Argumente, die an git alias
übergeben werden, als Datei oder Verzeichnis
interpretiert. So entfernt bei mir git unstage pom.xml
nur die vorher in die
Staging Area hinzugefügten Änderungen in der Datei pom.xml. Alle anderen
Änderungen bleiben weiterhin in der Staging Area vorhanden.
Unterschiede anzeigen
Bevor wir Änderungen zur Staging Area hinzufügen oder speichern, sollten wir uns noch anschauen, was wir eigentlich geändert haben und ob wir dies wirklich tun wollen.
Das Kommando git status
zeigt uns eine Übersicht aller Änderungen. Dabei gibt
es mehrere Blöcke, die uns angezeigt werden, damit wir zwischen Änderungen in
der Staging Area, lokalen Änderungen an bereits bekannten Dateien und neuen
Dateien unterscheiden können. Auch sehen wir hier, ob die Datei geändert,
hinzugefügt, entfernt oder umbenannt wurde.
Um uns die konkreten Änderungen anzuzeigen, wird primär der Befehl git diff
in
verschiedenen Varianten verwendet. Ohne weitere Optionen und Argumente werden
uns alle Änderungen, die wir vorgenommen haben und noch nicht in die Staging
Area übertragen haben, angezeigt. Möchten wir nur Änderungen an bestimmten
Dateien sehen, können wir dem Kommando Dateien oder Verzeichnisse als Argumente
übergeben.
Um uns die Änderungen aus der Staging Area anzuzeigen, verwenden wir die Option
--staged
. Auch hier können wir anschließend durch Übergabe von Argumenten noch
auf bestimmte Dateien oder Verzeichnisse einschränken. Da ich die oben
vorgestellten Kommandos sehr häufig nutze, git status
vermutlich Hunderte
Male, habe ich auch hierfür Aliase (s. Listing 5) angelegt.
Neben unseren lokalen Änderungen kann uns git diff
jedoch auch die Änderungen
zwischen zwei Commits anzeigen. Hierzu übergeben wir zwei Commithashes oder
Branch-/Tagnamen als Argumente. Das erste Argument sollte dabei in der Regel der
neuere Stand sein, damit die Differenz korrekt angezeigt wird. Anstatt direkte
Referenzen können auch ^
und ~
in Verbindung mit einer Referenz verwendet
werden. Beide sagen Git, wie viele Commits es rückwärts von der angegebenen
Referenz gehen soll. Sie unterscheiden sich primär darin, welchen Pfad sie bei
einem Merge-Commit verfolgen. So zeigt beispielsweise main~5
auf den fünften
Commit vor dem Commit, auf den der Branch main
zeigt. Neben einer Zahl hinter
dem ~
oder ^
kann das Zeichen auch wiederholt werden. main^^
zeigt demnach
auf den zweiten Commit vor main
.
Um uns die Änderung an genau einem Commit anzuzeigen, lässt sich in Verbindung
mit ^
und einem !
eine kürzere Variante zu
git diff <commithash>^ <commithash>
schreiben, indem wir
git diff <commithash>^!
nutzen. Da mir beide Varianten zu kompliziert sind,
nutze ich als Trick git show <commithash>
. Neben den eigentlichen Änderungen
zeigt uns dieses Kommando auch noch Metadaten an und funktioniert nicht für
Merge-Commits. Für mich reicht das aber in 99 Prozent meiner Anwendungsfälle und
ist einfacher zu tippen und zu merken.
Da es in der Realität auch oft größere Änderungen gibt, die nicht komplett auf einen Bildschirm passen, nutzt Git beim Anzeigen von Änderungen ein frei konfigurierbares Tool zum Paginieren. Nutzen heißt in diesem Fall, dass Git den Inhalt, den es ausgeben möchte, auf seiner Standardausgabe ausgibt und mittels einer Pipe den Inhalt in die Standardeingabe des angegebenen Tools hineingibt. Somit kann praktisch jedes Tool, das die Standardeingabe liest, verwendet werden. Ich persönlich nutze eine Kombination von diff-so-fancy und less (s. Listing 6).
diff-so-fancy
zeigt, dass Diff deutlich schöner und lesbarer an. Anschließend
wird das so formatierte Diff noch in less gepiped. Less sorgt nun noch dafür,
dass ein Tab als 4 Leerzeichen dargestellt wird. Zudem erlaubt es mir durch das
spezifizierte Muster, mittels n
und N
zwischen den einzelnen geänderten
Dateien im angezeigten Diff zu springen. -R
ist notwendig, damit less keine
Kontrollzeichen verschluckt. Diese werden von diff-so-fancy
zur Formatierung
genutzt und müssen deswegen durchgeleitet werden.
Änderungen mit anderen synchronisieren
Zwar kann ich Git auch alleine zur Verwaltung von meinen Dateien nutzen, häufig wird es allerdings zur Kollaboration eingesetzt. Hierzu kann ich meine lokal gemachten Änderungen zu einem anderen, Remote, Repository schicken oder von dort neue Änderungen, die andere durchgeführt haben, abholen.
Haben wir beim Anlegen git clone
verwendet, hat Git dieses Remote Repository,
es kann auch mehrere geben, bereits unter dem Namen origin
konfiguriert. Wurde
hingegen git init
verwendet, müssen wir dies vor unserem ersten push
selber
erledigen. Hierzu verwenden wir git remote add <name> <pfad>
. Anschließend
können wir per git push <name> <branch>
alle Änderungen, die wir auf dem
angegebenen Branch gemacht haben, dorthin übertragen.
Um mir hierbei das Leben noch etwas zu erleichtern, nutze ich zwei kleine
Tricks. Zum einen habe ich die
Standardstrategie für git push
auf den Wert simple
gestellt. Das bedeutet, dass ich in fast allen Fällen die Branch-Spezifikation
und den Namen des Remote Repositories weglassen kann und Git das tut, was ich
erwarte. Vor allem, solange es nur ein einziges Remote Repository gibt und ich
ausschließlich lokal dieselben Branch-Namen wie im Remote Repository haben
möchte.
Zum anderen habe ich einen Alias, der dafür sorgt, dass Git, nachdem ich einen
bei mir neu angelegten Branch pushe, diesen mit dem Remote Branch verbindet.
Außerdem muss ich mit diesem Alias den Namen des lokalen Branches nicht noch
einmal angeben, sondern es wird automatisch der Branch genommen, auf dem ich
mich gerade befinde (s. Listing 7).
Die beiden Aliase unterscheiden sich vom vorherigen dadurch, dass sie mit einem
!
beginnen. Das sorgt dafür, dass der Alias nicht direkt als Argument an den
git
-Befehl übergeben wird. Das angegebene Kommando wird direkt so in der
Befehlszeile ausgeführt.
Um die Änderungen von anderen zu mir zu holen, wird der Befehl git pull
genutzt. Technisch gesehen nutzt dies dabei eine Kombination der beiden Befehle
git fetch
und git merge
. Für mehr Kontrolle könnten wir also beide Befehle
auch manuell nutzen und auf pull
verzichten. Der Gewinn ist jedoch begrenzt.
In Verbindung mit pull
habe ich noch drei Einstellungen vorgenommen (siehe
Listing 8). Die Einstellung für fetch.prune
sorgt dafür, dass Referenzen auf
Branches im Remote Repository lokal gelöscht werden, wenn diese Remote entfernt
wurden. Dadurch reduziert sich die Anzahl der Branches, die mir in der
Autovervollständigung vorgeschlagen werden, deutlich.
Mit pull.rebase = merges
verhindere ich Merge-Commits bei parallelen Commits,
wenn ich git pull
verwende. So bleibt die Historie linearer und es gibt
weniger Merge-Commits. Git manipuliert hierbei technisch gesehen die Historie.
So lange meine lokalen Commits jedoch noch nie gepusht wurden, ist das kein
Problem.
Letztlich erlaubt mir rebase.autostash = true
, dass ich git pull
auch
ausführen kann, wenn ich noch lokale Änderungen habe. Diese werden dabei von Git
zurückgesetzt, dann wird der pull ausgeführt und anschließend werden die
Änderungen erneut angewandt, um meinen vorherigen Stand, nun auf Basis der neu
gepullten Commits, wiederherzustellen.
Geschichte verfolgen
Die in Git gespeicherten Änderungen ergeben eine Historie und zeigen, wann wer was gemacht hat. Häufig möchte ich wissen, wer den Code in dieser Form eingebaut hat, um Fragen zu stellen. Oder ich möchte wissen, seit wann der Code in dieser Form vorhanden ist, weil ich einen Bug fixe. Die Historie kann uns dabei helfen, vor allem wenn die Commitmessages sinnvoll gefüllt wurden. Einen guten Post hierzu hat Chris Beams verfasst.
Um uns nun eine Liste aller Änderungen anzuzeigen, verwenden wir git log
.
Standardmäßig zeigt uns dieser Befehl alle Änderungen ab dem Commit, auf dem wir
gerade stehen, an. Dabei wird uns neben dem Commithash und dem Branch-Namen auch
der Autor, das Datum und die Commitmessage angezeigt.
Mir persönlich bietet diese Darstellung nicht genug Informationen. Vor allem eine visuelle Darstellung der Commits und Branches zueinander haben mir hier immer gefehlt. Lange habe ich deswegen für solche Aufgaben ein separates grafisches Tool genutzt. Mittlerweile habe ich jedoch einen Alias gefunden, der mir die Historie so anzeigt, wie ich sie brauche (s. Listing 9).
Durch die Verwendung von --all
zeigt mir git log sämtliche Commits an und
nicht nur die, die zu meinem aktuellen Commit geführt haben. Zudem sorgt
--graph
dafür, dass die einzelnen angezeigten Commits mit Linien verbunden
werden und ich einen visuellen Eindruck meiner Historie erhalte.
Die beiden Optionen --date
und --pretty
passen zudem das Datumsformat und
die Darstellung der Informationen zu einem Commit noch so an, dass jeder Commit
in eine Zeile passt und ich auf einen Blick den Branch-Namen, die Commitmessage,
das Datum der Änderung und den Namen des Autors erfassen kann. Abbildung 1 zeigt
beispielhaft, wie ein Log mit diesen Optionen bei mir aussieht.
Um mir in einer Datei anzuzeigen, wer welche Zeile in welchem Commit das letzte
Mal geändert hat, wird git blame <datei>
verwendet. Anschließend wird mir die
Datei zeilenweise mit zu sätzlichen Informationen angezeigt (s. Abb. 2). Da, wie
bereits erwähnt, Worte einen Unterschied machen können, nutze ich für blame
den Alias praise
. Möchte ich die gesamte Historie einer Datei angezeigt
bekommen, nutze ich einen Alias, git history
, der mir alle Commits inklusive
der Differenz in der angegebenen Datei anzeigt (s. Abb. 3).
Somit kann ich genau sehen, wie sich die Datei über die Zeit verändert hat.
Diesen Weg nutze ich häufig, um zu schauen, wann eine bestimmte Codepassage
hinzugefügt wurde. praise
zeigt schließlich nur die letzte Änderung an, die in
vielen Fällen nur in einer Umformatierung oder Ähnlichem bestanden haben kann.
Die beiden Aliase zeigt Listing 10.
Fazit
In diesem Artikel haben wir uns für den Umgang mit Git auf der Kommandozeile drei Dinge angeschaut: Grundeinstellungen, die notwendigsten Kommandos und hier und da einen Trick, um den Umgang zu erleichtern. Mit diesem Wissen sollte es möglich sein, grundlegend auf der Kommandozeile mit Git umgehen zu können.
Dabei ist mir wichtig, dass ich niemanden überreden möchte, Git auf der Kommandozeile zu nutzen. Ich glaube allerdings, dass ein grundlegender Überblick über die Kommandos auch den Umgang mit Git in einem grafischen Tool vereinfacht. Zudem kommt, zumindest bei mir ab und an die Situation auf, dass Git beispielsweise auf einem Server per SSH bedient werden muss. Spätestens hier ist es gut, die Grundlagen zu kennen.
Es gibt natürlich noch viele weitere spannende Themen, wie das interaktive Rebasen oder Signieren von Commits. Vielleicht werden diese Themen einmal Inhalt einer weiteren Kolumne.