Es muss nicht immer grafisch und klickbar sein

Grundlagen von Git auf der Kommandozeile

In dieser Kolumne geht es um Git. Genauer gesagt die Benutzung von Git auf der Kommandozeile. Neben Grundeinstellungen und vielen Kommandos wird hier und da auch ein Trick vorgestellt, um die Benutzung zu erleichtern.

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.

$ git config --global user.name 'Vorname Nachname'
$ git config --global user.email meineemail@meinedomain.tld
Listing 1: Globales Setzen des Benutzernamens und der E-Mail-Adresse

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.

[includeIf "gitdir:~/Development/innoq/"]
  Path = ~/.gitconfig_innoq
...
Listing 2: Einbinden von Konfigurationen in spezifischen Verzeichnissen

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.

...
[alias]
  br = branch
  co = checkout
  cob = checkout -b
...
Listing 3: Aliase zum Umgang mit Branches

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

...
[alias]
  ...
  unstage = reset HEAD --
  ...
...
Listing 4: Alias zum Entfernen aus der Staging Area

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.

...
[alias]
  ...
  di = diff
  ds = diff --staged
  st = status
  ...
...
Listing 5: Aliase für diff und status

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

...
[pager]
  diff = diff-so-fancy | less --tabs=4 -R --pattern '^(Date|added|deleted|modified|renamed): '
...
Listing 6: Pager

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

...
[push]
  default = simple
...
[alias]
  ...
  branch-name = "!git rev-parse --abbrev-ref HEAD"
  publish = "!git push -u origin $(git branch-name)"
  ...
...
Listing 7: Einstellung und Aliase für git push

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.

...
[pull]
  rebase = merges
[fetch]
  prune = true
[rebase]
  autostash = true
...
Listing 8: Einstellungen für git pull

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

...
[alias]
  ...
  log-fancy = log --graph --date=human --pretty=format:'%Cred%h %C(yellow)%d%Creset %s %Cgreen(%ad) %C(cyan)<%an>%Creset' --all
  ...
...
Listing 9: Mein git log-Alias

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.

Abb. 1: Log-Ausgabe meines log-fancy Aliases

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

Abb. 2: Anzeige einer Datei mit git blame
Abb. 3: Anzeige der Historie einer Datei

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.

...
[alias]
  ...
  history = log --follow --patch
  praise = blame
  ...
...
Listing 10: Aliase zur Anzeige von Historie

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.

TAGS

Kommentare

Um die Kommentare zu sehen, bitte unserer Cookie-Vereinbarung zustimmen. Mehr lesen