git subtree: Eine Alternative für git submodule?

Für Software-Projekte werden häufiger mehrere Repositorys benötigt, in denen getrennt und zusammen voneinander entwickelt wird. Dies trifft insbesondere dann zu, wenn etwa eine Bibliothek in mehreren Projekten benötigt wird, aber unabhängig voneinander in den anderen Projekten genutzt wird. Hier ergibt es Sinn, ein separates Git-Repository für die Bibliothek zu nutzen und dieses in die anderen Projekte einzubinden.

git submodule

Eine Möglichkeit solche Unter-Repositorys einzubinden ist die Nutzung von git submodule. Diejenigen die es schon nutzen, dürften wissen, dass die Arbeit mit Submodulen häufig sehr anstrengend und mühselig ist – und das aus verschiedenen UX-Gründen.

Submodule müssen in einem Git-Repository mit git submodule add $REPO_URL zunächst hinzugefügt werden. Dadurch wird im entsprechenden Haupt-Repository die Datei .gitmodules angelegt, die direkt zum Staging-Bereich hinzugefügt wird. Nach einem Commit ist das Submodule korrekt eingebunden. Wenn ihr das Log anschaut, dann seht ihr das Log des Submodules nicht. Wenn ihr in das Unterverzeichnis rein wechselt und euch von dort das Log anschaut, dann seht ihr das Log des Submodule-Repositorys. Sobald ihr nun Commits im Submodule macht, müsst ihr von dort aus die Commits pushen und zurück in das Haupt-Repository wechseln. Von dort muss man dann erneut ein Commit tätigen, um das die Änderungen aus dem Submodule im Haupt-Repository ebenfalls korrekt verwenden zu können. Eine Änderung im Submodule führt also zu mindestens zwei Commits: ein Commit im Submodule und ein Commit im Haupt-Repository.

Komplizierter ist das Verhalten, wenn jemand das Haupt-Repository neuklont. Beim Klonen muss ein rekursiver Clone mit git clone --recursive notwendig oder ihr klont „normal“ und führt dann git submodule init in dem jeweiligen Submodule Verzeichnis aus. Wenn ihr nun noch eine Änderung machen wollt, dann müsst ihr im Submodule-Repository zunächst den richtigen Branch auschecken. Letzteres ist häufig verwirrend, weil man das überhaupt nicht gewohnt ist. Die übrigen Schritte, die zuvor erklärt wurden, kommen dann nochmal obendrauf.

Wer jetzt denkt „Hä?“ oder es ziemlich umständlich findet, denen kann ich jetzt zu Recht sagen: Stimmt! Angenehm ist was anderes. Ganz allgemein lohnen sich Submodule besonders dann, wenn die Versionen in Submodulen selten aktualisiert werden müssen und es demnach keine hohe Entwicklungsaktivität stattfindet.

git subtree

Eine Alternative zu git submodule ist git subtree. Es ist zwar auch nicht die perfekte Lösung, bietet aber einige Vor- und Nachteile im Vergleich zu git submodule.

In produktiven Umgebungen habe ich noch nicht mit git subtree gearbeitet, sondern habe es für diesen Blogpost erst angeschaut. In meinem Git-Repository für meinen Blog, den ihr gerade lest, hatte ich bis gerade eben auch ein Repository als Submodule eingebunden, was dem Theme der Webseite entspricht.

Im Folgenden gehe ich direkt mal in die Praxis und zeige, wie git subtree an einem praktischen Beispiel verwendet werden kann.

Zu Beginn muss das Repository als weiteres Remote-Repository hinzugefügt werden. In der Regel besitzt man Remote-Repositorys origin und vielleicht upstream. In dem Fall hat man zwei Remote-Repositorys, um nach origin seine eigenen Änderungen pushen zu dürfen (bei einem Fork etwa) und upstream, was dem Upstream-Repository des Projektes entspricht.

Das Remote-Repository muss wie folgt angelegt werden:

$ git remote add -f $REMOTE_NAME $REPOSITORY_URL

Der Befehl muss, wie zu sehen ist, mit -f aufgerufen werden. -f ist die kurze Form von --force. Aber warum --force? Das Remote-Repository, was als Subtree hinzugefügt werden soll, hat keine gemeinsame Historie, da die Historie bisher komplett getrennt war. Es ist schlicht kein verwandtes Repository.

In der Praxis sah es bei mir dann so aus:

$ git remote add -f AllinOne git@git.svij.org:svij/hugo_allinone_theme.git
Aktualisiere AllinOne
warning: keine gemeinsamen Commits
remote: Enumerating objects: 968, done.
remote: Counting objects: 100% (968/968), done.
remote: Compressing objects: 100% (574/574), done.
remote: Total 968 (delta 362), reused 947 (delta 351)
Empfange Objekte: 100% (968/968), 24.54 MiB | 11.15 MiB/s, Fertig.
Löse Unterschiede auf: 100% (362/362), Fertig.
Von git.svij.org:svij/hugo_allinone_theme
* [neuer Branch]    master     -> AllinOne/master
* [neues Tag]       v1.0       -> v1.0
* [neues Tag]       v1.1       -> v1.1
* [neues Tag]       v1.2       -> v1.2
* [neues Tag]       v1.3       -> v1.3
* [neues Tag]       v1.4       -> v1.4

Wie ihr sehen könnt, erfolgt eine Warnung, dass es keine gemeinsamen Commits gibt. Davon abgesehen wird das Remote-Repository wie jedes andere Remote-Repository auch gefetcht, also die Objekte heruntergeladen. Bis zu diesem Zeitpunkt haben wir noch nichts spezifisches für git subtree gemacht, das kommt aber jetzt.

Das Hinzufügen mit git subtree benötigt einige Parameter:

$ git subtree add --prefix $PATH_IN_REPO $REMOTE_NAME $BRANCH --squash

Wie ihr seht, ruft man git subtree add mit dem Parameter --prefix auf. Hier kann auch die Kurzform -P genutzt werden. Der Prefix ist der Pfad im Repository, wo der Subtree landen soll. Zudem muss noch der Name des zuvor hinzugefügten Remotes angegeben werden, sowie der Branch. In diesem Fall habe ich auch noch --squash hinzugefügt, damit die bisherigen Historie des Subtree-Repositorys nicht im Haupt-Repository landen soll, sondern nur als einzigen Commit in der Historie der Haupt-Repositorys erscheint.

In meinem Fall sah das Ganze in der Praxis dann so aus:

$ git subtree add --prefix themes/AllinOne AllinOne master --squash
git fetch AllinOne master
Von git.svij.org:svij/hugo_allinone_theme
* branch            master     -> FETCH_HEAD
Added dir 'themes/AllinOne'

Wenn wir uns aber nun die Historie ansehen, sehen wir zwei neue Commits:

$ git log -2 --oneline
e82400c (HEAD -> master) Merge commit '59be897c12c39412755cd93996d85590ef53fdc9' as 'themes/AllinOne'
59be897 Squashed 'themes/AllinOne/' content from commit 88c7956

In dem älteren Commit wurde das angesprochene Squashing des Repositorys durchgeführt, und in dem darauffolgenden Commit ist ein Merge durchgeführt worden. Im konkreten Fall wurde ein Merge von einem Branch durchgeführt, der nicht denselben Ursprung hat.

Falls sich im Subtree-Repository etwas unabhängig vom Haupt-Repository verändert, dann können die Änderungen wie folgt heruntergeladen werden:

$ git fetch AllinOne master
Von git.svij.org:svij/hugo_allinone_theme
* branch            master     -> FETCH_HEAD

Diese sind dann aber noch nicht (!) im Haupt-Repository gemergt. Dazu muss mit folgendem Befehl durchgeführt werden:

$ git subtree pull --prefix themes/AllinOne AllinOne master --squash

Der Parameter sind äquivalent zu den vorherigen Befehlen. Auch hier muss man die vielen Parameter mit angeben.

Anders sieht es allerdings aus, wenn man einen Commit im Haupt-Repository macht, wo der Inhalt des Subtree-Repositorys angefasst wird. In meinem Beispiel will ich etwa das Verzeichnis themes/AllinOne/exampleSite aus dem Haupt-Repository und dem Subtree-Repository entfernen. Die ersten Schritte sind soweit ganz normal:

$ rm -rf themes/AllinOne/exampleSite
$ git add themes/AllinOne
$ git commit -m "Remove exampleSite from AllinOne"
$ git push origin master

Mit diesem Befehl wurde im Haupt-Repository das Verzeichnis entfernt, ein Commit erstellt und gepusht. Dass es sich um ein Subtree-Repository handelt, ist von der Handhabung her nicht zu erkennen.

Was jetzt noch fehlt, ist das Pushen des Commits in das Subtree-Repository. Das funktioniert ähnlich wie auch beim Pull:

$ git subtree push --prefix=themes/AllinOne AllinOne master

Wenn man nun in die Historie des Subtree-Repositorys schaut, sieht man genau einen neuen Commit mit der Commit-Message Remove exampleSite from AllinOne.

Und das war es auch schon. Theoretisch und auch praktisch ist es auch möglich das ganze ohne das git subtree Kommando zu erledigen, was die Handhabung der Subtree-Funktion allerdings nicht erleichtert.

Fazit

Mit git subtree hat man einige Vor- und Nachteile im Vergleich zu git submodule. Die perfekte Lösung ist es auch nicht, da man zwangsläufig ein paar neue Befehle lernen muss und nicht vergessen darf, damit in beiden Repositorys die Änderungen landen.

Welche Vorteile hat also git subtree im Vergleich zu git submodule?:

  • Bei bestehenden Repositorys mit Subtree ist kein git clone --recursive notwendig.
  • Der Workflow um Änderungen im Haupt-Repositorys zu veröffentlichen ist vergleichsweise einfach.
  • Nutzer von Repositorys mit Subtree müssen nicht wesentlich was Neues lernen.

Die Nachteile sind:

  • Ein neuer Befehl subtree mit zahlreichen Parametern wird benötigt.
  • Das Veröffentlichen von Änderungen im Subtree-Repository kann vergessen werden.

Es gibt sicherlich noch den ein oder anderen Vor- und Nachteil in der Praxis, der mir nicht bekannt ist. Da ich es bisher nicht produktiv eingesetzt habe, kann ich dazu noch nicht so viel sagen. Es ist jedenfalls auf Anhieb deutlich angenehmer zu nutzen, als git submodule und das ist dann ja schon mal etwas.