Best Practices mit Git
Ein Handwerker muss seine Werkzeuge kennen. Dabei reicht es aber bei Weitem nicht, nur theoretisch zu wissen wie man beispielsweise einen Hammer verwenden könnte. Oft sind es einige spezielle Kniffe, die ein Werkzeug erst so richtig effektiv machen. Einige dieser Kniffe für das Werkzeug Git möchte dieser Artikel vorstellen. Eine kleine Anmerkung vorweg: Grundlegende Git-Kenntnisse werden vorausgesetzt.
Versetzen wir uns in die Lage von M.. Er ist Java-Entwickler und Mitglied eines Scrum-Teams irgendwo in Nürnberg. Das Problem: M. stammt aus München und hat Familie. Das macht es unmöglich, die Woche in Nürnberg zu verbringen und so bleibt nur Pendeln. Das bedeutet, M. ist täglich unterwegs, und zwar 1,5 Stunden nach Nürnberg und abends wieder 1,5 Stunden zurück. Zeit, die effektiv genutzt werden will. M. hat Glück, denn in seinem Projekt wird seit Neuestem das dezentrale Versionskontrollsystem Git eingesetzt. Durch Git hat M. das komplette Repository immer dabei und so die Möglichkeit, auch von unterwegs und ohne Internetverbindung effektiv zu arbeiten, zu commiten oder die Historie zu betrachten. Im Folgenden begleiten wir M. durch einen typischen Arbeitstag und erhalten so einen Einblick in die effektive Arbeitsweise mit Git.
06:45 – Hauptbahnhof München
M. steigt am Hauptbahnhof in den ICE nach Nürnberg. Er hat sich am Abend zuvor noch einen wichtigen Produktions-Bug für die Fahrt mitgenommen, der dringend heute noch ausgeliefert werden muss. Hier kommt der Vorteil eines dezentralen Versionskontrollsystems voll zum Tragen, denn M. hat das komplette Projekt-Repository lokal auf seinem Laptop zur Verfügung.
Die Software, die von M. und seinem Team entwickelt wird, ist Swing-basiert und unterstützt die Personalabteilung bei der Berechnung des zu überweisenden monatlichen Gehalts für festangestellte Mitarbeiter (Abb. 1).
Laut Bug-Beschreibung ist die Bedingung für die Anzeige eines Fehlertextes falsch, da die Fehlermeldung für korrekte Berechnungen angezeigt wird, im Fehlerfall jedoch nicht. Eine kleine Anmerkung des Autors: Was genau M. gemacht hat, um den Bug zu beheben, kann problemlos in der Historie im Git Repository nachvollzogen werden.
Als sich der Zug in Bewegung setzt ist M. bereits dabei, den Bug zu fixen.
Ein mögliches Branching-Modell
Zunächst verschafft sich M. einen Überblick über die aktuelle Repository-Struktur und die vorhandenen Branches.
git branch * master stable-release next
Die größte Stärke von Git liegt in der einfachen und schnellen Möglichkeit, Branches zu erstellen und zwischen diesen zu mergen. Es gibt verschiedene Modelle, die eine kontinuierliche und stabile Lieferung in das Produktivsystem garantieren. Im Folgenden wird das Modell vorgestellt, mit dem das Team von M. sehr erfolgreich arbeitet.
Das Projekt ist in drei Branches unterteilt. Der master ist der aktuelle Entwicklungszweig, auf dem neue Features implementiert werden. Der stable-release zweigt vom master ab. Nur vom stable-release wird in das Produktivsystem geliefert. Auf dem next-Branch werden Features entwickelt, die zukünftig verfügbar sein, aber noch nicht in das Produktivsystem geliefert werden sollen.
Da es sich bei M.s aktuellem Task um einen Bugfix handelt, erstellt er zunächst einen Branch vom stable-release (Abb. 2, Nr. 4). Vom (und nur vom) stable-release wird in das Produktivsystem geliefert:
#checkout stable-release branch git checkout stable-release #erstellen eines neuen bugfix-branches git checkout -b bugfix-4711 # branch liste git branch * bugfix-4711 master stable-release next
Die Ursache des Bugs ist schnell gefunden und behoben. Die Kondition für die Sichtbarkeit der Fehlermeldung ist einfach falsch gesetzt. M. commitet sehr (sehr) häufig. Für den Bugfix erzeugt er beispielsweise zwei Commits:
#print summary git shortlog initial project commit Bugfix-4711 - correct condition for error label Improved Javadocs a bit
Die beiden unteren Commits sind zwar physisch, aber nicht logisch getrennt. Besser wäre es, statt zweien nur einen Commit zu erzeugen. Das sollte bestenfalls geschehen, bevor der Bugfix Branch zurückgeführt wird. Git bietet hier ein hervorragendes Tool, und zwar den interactive rebase, der einfach mit git rebase -i gestartet wird. Der interactive rebase öffnet automatisch den voreingestellten Editor (in M.s Fall unter Ubuntu den vim, siehe Listing 1).
#start interactive rebase git rebase -i HEAD~2 # in vim pick fe8b38f Bugfix-4711 - correct condition for error label pick fa227fb Improved Javadocs a bit # Rebase 9950f83..fa227fb onto 9950f83 # # Commands: # p, pick = use commit # r, reword = use commit, but edit the commit message # e, edit = use commit, but stop for amending # s, squash = use commit, but meld into previous commit # f, fixup = like "squash", but discard this commit's log message # x, exec = run command (the rest of the line) using shell
Über einen interactive rebase hat M. die Möglichkeit, die lokale Historie zu ändern, solange noch nichts in das zentrale Repository eingecheckt wurde. Die Möglichkeiten, die sich M. hier bieten, sind u. a. das Ändern einer Commit Message im Nachhinein (Option r – reword) oder mehrere Commits zusammenzulegen (in git auch als squashen bezeichnet):
git rebase -i HEAD~2
Die Syntax des interactive rebase ist sehr sprechend, beispielsweise besagt der verwendete Ausdruck HEAD~2, dass vom obersten Commit aus die letzten beiden (~2) Commits bearbeitet werden sollen. Die exakte Beschreibung des mächtigen rebase-Befehls würde den Artikel sprengen, ist aber ausführlich in [1] dokumentiert.
Da M. einige Commits zusammenfassen möchte, ist der interactive rebase das Tool der Wahl. Hierzu editiert er lediglich die Zeilen der betroffenen Commits und macht beispielsweise direkt im Editor aus dem pick am Anfang der Zeile ein f für fixup. Hierdurch werden die beiden Commits „gesquashed“ und ein neuer Commit entsteht, wobei die Commit Message des früheren Commits erhalten bleibt:
pick fe8b38f Bugfix-4711 - correct condition for error label f fa227fb Improved Javadocs a bit #now check the logs git shortlog initial project commit Bugfix-4711 - correct condition for error label
Achtung, über einen interactive rebase wird die Historie verändert, da ggf. Commits verschwinden oder sich ändern können. Dieses Tool sollte also mit Bedacht verwendet werden und auch nur bei Commits, die nicht schon in einem öffentlichen Repository verfügbar sind.
[ header = Seite 2: Git Tags – Machen wirs gleich fix ]
Git Tags – Machen wirs gleich fix
Als Best Practice hat sich in M.s Team eingebürgert, dass sowohl Bugfix als auch Feature-Branches vor dem Zurückführen getaggt werden. Git verfügt über zwei Arten von Tags, Annotated- undLightweight-Tags. Der Unterschied ist marginal, ein Lightweight-Tag ist lediglich eine Referenz auf einen Commit in der Historie, ein Annotated-Tag ist ein eigenes Objekt, welches auf einen Commit in der Historie referenziert. M. arbeitet grundsätzlich mit Annotated-Tags (Option -a), da diese beispielsweise signiert werden können:
#create a tag with name Bugfix-4711 git tag -a -m "Bugfix-4711" Bugfix-4711
Durch den Tag lässt sich später sehr einfach nachvollziehen, ob ein bestimmter Bugfix oder ein bestimmtes Feature bereits reintegriert wurde, indem die vorhandenen Tags betrachtet werden:
#list all tags git tag Bugfix-4711
Der nächste Schritt besteht für M. nun darin, den Bugfix-Branch sowohl auf den stable-release als auch den master zurückzuführen:
#reintegrate to stable-release git checkout stable-release git merge bugfix-4711 #reintegrate to master git checkout master git merge bugfix-4711
Warum aber auf beide Branches? Damit kommt exakt der Vorteil des dedizierten stable-release Branches zum Tragen. M. kann ein Bugfix-Release in das Produktivsystem veranlassen, der nur einen bestimmten Bugfix enthält, selbst wenn auf dem master eventuell schon weitere Features entwickelt wurden. Es ist sehr wichtig, dass der Bugfix sowohl auf den stable-release als auch denmaster zurückgeführt wird, da der stable-release bei jedem Major-Release gelöscht und neu vom master gezogen wird. Alternativ hätte M. den Bugfix auch nur auf den master zurückführen und diesen anschließend auf den stable-release mergen können (Abb. 2, Nr. 5), somit würden aber alle Features vom master mit ausgeliefert.
Der letzte Schritt besteht für M. nun darin, den master auf den next Branch zu mergen, sodass der Bugfix auch in zukünftigen Versionen verfügbar ist. (Abb. 2, Nr. 3).
#reintegrate to stable-release git checkout next git merge master
Damit hat M. alles vorbereitet und kann im Prinzip, sobald er im Büro ist, direkt eine neue Lieferung in das Produktivsystem veranlassen. Wohlgemerkt, M. sitzt noch immer im ICE in Richtung Nürnberg.
08:15 – Büro, Nürnberg
M. ist mittlerweile im Büro angekommen. Nach dem obligatorischen Plausch mit den Kollegen und nachdem er sich seine erste Tasse Kaffee geholt hat, fährt M. seinen Laptop zum zweiten Mal an diesem Tag hoch. Obwohl Git ein dezentrales Versionskontrollsystem ist, hat sich das Team von M. entschieden, ein zentrales Repository zu verwalten, auf das alle Entwickler einchecken und von dem aus der Continuous-Integration-Server (in M.’s Fall Jenkins) baut und die entsprechenden Artefakte ausliefert. Der Zugriff auf das zentrale Repository erfolgt ganz einfach über SSH. M. freut sich, dass er heute Morgen bereits so produktiv war und möchte am liebsten sofort in das zentrale Repository einchecken:
#Push commits to Remote Master git push origin master ! [rejected] master -> master (non-fast-forward)
Was ist nun genau passiert?
git push origin master
bedeutet übersetzt: „Git, bitte übertrage die Commits auf meinem lokalen Branch, auf dem ich gerade bin, auf das Remote Repository mit dem Namen ‚origin‘, und dort bitte auf den Branch mit dem Namen master“. ‚origin‘ ist lediglich der Name des Remote Repositories. Das wird sogar noch klarer, wenn man sich die Datei config im Verzeichnis .git im lokalen git-Repository betrachtet. Hier steht u. a.:
[remote "origin"] url = ../central_git_repo/ fetch = +refs/heads/*:refs/remotes/origin/*
Was aber bedeutet folgender Satz, den M. auf seinen misslungenen Push-Versuch bekommen hat?
! [rejected] master -> master (non-fast-forward)
Git erlaubt grundsätzlich nur Fast Forward Pushes auf entfernte Repositories. Das Prinzip eines Fast Forward Pushes ist in Abb. 3 dargestellt.
Bei einem Fast Forward Push sind bereits alle Commits des Remote Repositories im lokalen Repository enthalten (blaue Kreise). Das macht die Arbeit für Git natürlich extrem einfach. Es müssen lediglich die neuen Commits (weiße Kreise) entgegengeommen und ein Pointer vom alten HEAD (der Commit, auf dem der Branch aktuell steht) auf den neuesten Commit gesetzt werden. Dieser wird dann zum neuen HEAD. Die ganze Arbeit (wie z. B. Merge-Konflikte auflösen) muss bereits vorher lokal beim Entwickler erfolgt sein. Somit ist sichergestellt, dass das zentrale Repository immer einen „sauberen“ Stand hat.
Kurz gesagt, Git hat den Push verweigert, weil noch nicht alle Commits aus dem Remote Repository im lokalen Repository vorhanden sind. M. bringt also zunächst sein lokales Repository auf den neuesten Stand:
#pull from remote git pull --rebase origin Unpacking objects: 100% (9/9), done. From ../central_git_repo 9950f83..b3b6f11 master -> origin/master #check whats new git shortlog -s 1 Markus [..]
Offensichtlich war auch M.s Kollege Markus fleißig und hat ebenfalls schon eine Textänderung umgesetzt. Da M. jetzt alle Commits aus dem Remote Repository lokal zur Verfügung hat, versucht er erneut zu pushen (Listing 2).
#push to remote git push --all --tags origin Counting objects: 34, done. Delta compression using up to 2 threads. Compressing objects: 100% (10/10), done. Writing objects: 100% (18/18), 1.33 KiB, done. Total 18 (delta 2), reused 0 (delta 0) Unpacking objects: 100% (18/18), done. To ../central_git_repo/ * [new branch] bugfix-4711 -> bugfix-4711 * [new branch] stable-release -> stable-release * [new branch] next -> next
Mit der Option –all pusht M. alle lokalen auf ihre entsprechenden Remote Branches. Die Option –tags stellt sicher, dass auch die gesetzten Tags übertragen werden, was standardmäßig nicht der Fall ist. Der Push war also erfolgreich, und alle Teammitglieder können auf den von M. gelieferten Bugfix zugreifen. Ein Release vom stable-release Branch kann in das Produktivsystem veranlasst werden. Zuletzt fällt M. jedoch noch auf, dass der Bugfix Branch ebenfalls unnötigerweise übertragen wurde. Da der Branch bereits reintegriert wurde, kann dieser beruhigt gelöscht werden, um eine Branch-Müllhalde zu vermeiden.
#delete remote branch git push origin :bugfix-4711 To ../central_git_repo/ [deleted] bugfix-4711
Der Befehl zum Löschen eines Remote Branches ist ein wenig gewöhnungsbedürftig, macht aber Sinn, wenn man sich die Syntax genauer betrachtet. Die Syntax des push-Befehls sieht (vereinfacht) folgendermaßen aus:
git push <repository> <refspec>
Der Parameter <repository> wurde schon zuvor definiert und gibt an, auf welches Repository gepusht werden soll (in M.s Fall das Repository mit dem Namen origin). Der Parameter <refspec> ist schon interessanter und hat (ebenfalls vereinfacht) das Format <src-branch>: <dest-branch>. Hiermit kann angegeben werden, welcher lokale Branch (src-branch) auf welchen entfernten Branch (dest-branch) gepusht werden soll:
git push origin master:master
Daraus lässt sich jetzt auch sehr einfach herleiten, wieso die Refspec für das Löschen des Bugfix Branches das Format :Bugfix-4711 hat. Vereinfacht könnte man interpretieren, M. schiebt einen „leeren“ Branch auf das entfernte Repository und „überschreibt“ einfach den dort vorhandenen Branch.
Nachdem M. den entfernten Branch gelöscht hat, zeigt ein kurzer Check aber, dass der Branch lokal immer noch vorhanden ist:
git branch bugfix-4711 master * stable-release next
Um diesen zu löschen macht M. Folgendes:
git branch -D bugfix-4711 Deleted branch bugfix-4711 (was 039b68c)
Ein kleiner praktischer Hinweis, hätte jemand anders den entfernten Branch gelöscht, hätte M. diesen immer noch in der Liste der Remote Branches gesehen. Diese werden nicht automatisch entfernt. Um aufzuräumen und alle Referenzen auf nicht mehr vorhandenen Remote Branches lokal zu löschen, macht M. regelmäßig Folgendes:
git remote prune origin
[ header = Seite 3: Fazit ]
11:30 Kaffeeküche, Nürnberg
M. und Michael, ein weiteres Mitglied des Scrum-Teams, unterhalten sich gerade über die Resultate aus der letzten Retrospektive. Ein Punkt, der mehreren Teammitgliedern aufgefallen war, ist die relative Häufung von Flüchen und Kraftausdrücken in Commit Messages. Das Projekt ist schon weit fortgeschritten und alle Entwickler stehen unter Druck, also ein scheinbar typisches Phänomen [2]. M. verspricht Michael, sich eine Lösung einfallen zu lassen, denn aus rein professionellen Gesichtspunkten ist die Situation nicht tragbar.
Zurück an seinem Arbeitsplatz überprüft M. die Liste an Commits der letzten Zeit und wird tatsächlich sofort fündig:
git log --oneline 1334204 Provide some better comment shit for this crappy class […]
Natürlich könnte M. versuchen, das Problem organisatorisch zu lösen, dieser Ansatz erscheint aber wenig erfolgversprechend. Viel besser scheint eine technische Lösung. Git bietet für derartige Fälle so genannte Hooks. Das sind Skripte, die bei bestimmten Ereignissen (wie beispielsweise Commits, Rebases oder Pushes) ausgeführt werden. Die möglichen Hook-Skripte befinden sich im Git-Repository unter .git/hooks.
applypatch-msg.sample post-update.sample pre-rebase.sample commit-msg.sample pre-applypatch.sample update.sample post-commit.sample pre-commit.sample post-receive.sample prepare-commit-msg.sample
Hook-Skripte können sowohl client- als auch serverseitig aktiviert werden. Um ein bestimmtes Skript zu aktivieren, muss lediglich das .sample aus dem Dateinamen entfernt werden.
Für M.s Problem bietet sich die Aktivierung des update-Hook im zentralen Repository an. Dieser Hook wird immer dann aktiviert, wenn ein Entwickler versucht, seine lokalen Änderungen mit git push in das zentrale Repository einzuchecken.
M. (dessen bash-Kenntnisse in etwa so rudimentär sind wie die des Autors) investiert einige Minuten und schreibt ein entsprechendes bash-Skript, welches die Commit Message auf Kraftausdrücke (aktuell die Ausdrücke shit, damn, ugly und crap) prüft:
#!/bin/bash commit_msg=$(git log --pretty=%s $2..$3) #list of expressions to deny for string in shit damn ugly crap do if [[ $commit_msg == *$string* ]] then echo "Keine Kraftausdrücke!!"; exit 1; fi done exit 0;
Das Update-Hook wird von Git mit folgenden Parametern aufgerufen: refname sha1-old sha1-new. Interessant für M. sind die Parameter sha1-old und sha1-new, was den Hash-Werten des Commit-Objektes vor- bzw. nach dem Commit entspricht. Über folgende Expression kann M. die Commit Message auslesen.
commit_msg=$(git log --pretty=%s $2..$3)
Git akzeptiert einen Push nur dann, wenn das Update-Skript einen Wert zurückliefert, der nicht 1 ist. Zuletzt benennt M. das Update-Skript von update.sample in update um, um das Skript zu aktivieren.
Um das Skript zu testen erstellt M. einen Dummy-Commit mit folgender Commit Message „A crappy Dummy Commit“ und versucht diesen anschließend zu pushen (Listing 3).
git log --oneline eef9feb A crappy Dummy Commit 1334204 Provide some better comment shit for this crappy class #push to central repository git push origin master remote: Keine Kraftausdrücke!! remote: error: hook declined to update refs/heads/master To ../central_git_repo/ ! [remote rejected] master -> master (hook declined) error: failed to push some refs to '../central_git_repo/'
Das Skript scheint zu funktionieren, das zentrale Repository verbietet den Commit. M. ist stolz, denn damit ist ein weiterer Schritt in Richtung „sauberer“ Arbeit getan.
Vielleicht noch ein kleiner Hinweis. Git Hooks eignen sich auch ideal, um lokale Entwicklungsrichtlinien zu erzwingen. Der Phantasie eines Teams sind hier keine Grenzen gesetzt, beispielsweise könnte ein kompletter Build des Projektes angestoßen werden und ein Commit nur erlaubt, wenn dieser erfolgreich war. Eine Art minimalistisches Continuous-Integration-System. Ein anderes interessantes Beispiel wäre die automatische Verlinkung von Commits mit Jira-Issues [3].
17:00 Auf dem Weg zum Hauptbahnhof, Nürnberg
M. befindet sich jetzt auf dem Weg in den verdienten Feierabend. Zuvor hat er jedoch noch die Fahrt nach München vor sich. Hierfür hat sich M. einen weiteren wichtigen Produktions-Bug mitgenommen, der unbedingt morgen in das Produktivsystem geliefert werden muss. Als sich der Zug in Bewegung setzt ist M. bereits dabei, den Bug zu fixen.
Noch ein letzter Hinweis des Autors: Die Repositories von allen beteiligten Teammitgliedern befinden sich auf der Java-Magazin-Webseite, sodass die einzelnen Schritte sehr einfach nachvollzogen werden können. Ich hoffe, das Lesen des Artikels hat Ihnen genauso viel Freude gemacht wie mir das Schreiben und dass der Artikel Ihren Erwartungen entspricht. Ich freue mich über Fragen, Anregungen und konstruktive Kritik.
Hinterlasse einen Kommentar