Wann sich das Vendoring per Submodule wirklich lohnt
Git Submodules versprechen, mehrere Repositories sauber zu verzahnen, doch in der Praxis sorgen vergessene Updates, ein verwirrender Detached HEAD Zustand und divergierende Commit Pins im Team regelmäßig für Frust. Dieser Artikel erklärt, wie Submodules technisch funktionieren, vergleicht sie mit git subtree, Composer basierten Abhängigkeiten und Monorepos, und zeigt, welches Werkzeug für typische Magento Multi Repo Setups tatsächlich passt.
Inhaltsverzeichnis
- 1. Wie Git-Submodules technisch funktionieren
- 2. .gitmodules und Gitlink: die Datenstruktur hinter Submodules
- 3. Submodules einbinden, klonen und aktualisieren
- 4. Das Detached-HEAD-Problem bei Submodules
- 5. Divergierende Commit-Pins im Team und CI-Komplexität
- 6. git subtree: Historie verschmelzen statt referenzieren
- 7. Composer-Abhängigkeiten: private Packagist, Satis und VCS-Repositories
- 8. Monorepo: ein Repository für alles
- 9. Submodules, Subtree, Composer und Monorepo im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Wie Git-Submodules technisch funktionieren
Ein Git-Submodule ist im Kern nichts anderes als ein vollständig eigenständiges Repository mit eigener Historie, eigenen Branches und eigenem Remote, das an einem bestimmten Pfad in den Working Tree eines übergeordneten (Parent-)Repositories eingehängt wird. Das Parent-Repository speichert dabei weder den Inhalt noch die Commits des Submodules selbst. Stattdessen legt Git im Verzeichnisbaum des Parent-Repos einen speziellen Tree-Eintrag vom Modus 160000 an, den sogenannten Gitlink. Dieser Eintrag verweist nicht auf einen Blob oder Tree, sondern direkt auf eine einzige, konkrete Commit-SHA im Submodule-Repository.
Ergänzt wird das durch die Datei .gitmodules im Wurzelverzeichnis des Parent-Repos, eine ganz normale, versionierte Textdatei im INI-Format, die für jeden Submodule-Pfad die Remote-URL und optional einen Branch hinterlegt. Wichtig ist die Trennung der Zuständigkeiten: .gitmodules sagt Git, woher ein Submodule stammt, während der Gitlink-Eintrag im Tree sagt, welcher exakte Commit dort aktuell ausgecheckt sein soll. Genau diese Kombination aus externem Repository plus fixiertem Commit-Zeiger macht Submodules zum nativen Git-Werkzeug für Vendoring, ist aber gleichzeitig die Wurzel fast aller im Folgenden beschriebenen Stolperfallen.
2. .gitmodules und Gitlink: die Datenstruktur hinter Submodules
Wer git ls-tree HEAD in einem Repository mit Submodules ausführt, sieht neben normalen Blob- und Tree-Einträgen eine Zeile wie 160000 commit a1b2c3d... vendor/module-x. Der Modus 160000 existiert nur für Submodule-Einträge und markiert sie explizit als "commit", nicht als Datei oder Verzeichnis. Genau deshalb zeigt git diff bei einer Änderung im Submodule nur eine einzelne Zeile mit altem und neuem Commit-Hash, niemals den eigentlichen Dateiinhalt, denn Git betrachtet das gesamte Submodule als atomaren, undurchsichtigen Zeiger.
Die Datei .gitmodules ergänzt diese Struktur um die Herkunftsinformation. Sie wird selbst normal versioniert und committet, ist aber strikt von der eigentlichen Versionsfixierung getrennt. Ein häufiges Missverständnis: Ein git pull im Parent-Repository aktualisiert weder den Gitlink noch den Inhalt des Submodule-Verzeichnisses automatisch auf den neuesten Stand des Submodule-Remotes. Es holt lediglich die neue Historie des Parent-Repos inklusive eines möglicherweise geänderten Gitlink-Zeigers, der aber erst durch einen expliziten Submodule-Update-Befehl im Working Tree wirksam wird.
# .gitmodules: tracked in the parent repo, maps path to remote URL
[submodule "vendor/module-x"]
path = vendor/module-x
url = git@github.com:example-vendor/module-x.git
branch = main
[submodule "deploy-config"]
path = config/deploy-config
url = git@gitlab.mironsoft.internal:ops/deploy-config.git
# Shallow clone keeps checkout fast for a config-only repo
shallow = true
3. Submodules einbinden, klonen und aktualisieren
Ein neues Submodule wird mit git submodule add <url> <pfad> eingebunden. Der Befehl klont das fremde Repository in den angegebenen Pfad, schreibt den passenden Eintrag in .gitmodules, erzeugt den Gitlink-Tree-Eintrag und staged beides für den nächsten Commit im Parent-Repository. Bis zu diesem Punkt ist noch nichts geteilt, außer der Referenz. Andere Teammitglieder, die den Parent-Repo klonen, erhalten zunächst nur einen leeren Ordner am Submodule-Pfad, denn ein normales git clone initialisiert Submodules standardmäßig nicht.
Genau hier liegt der mit Abstand häufigste praktische Stolperstein: Ohne git clone --recurse-submodules beim initialen Klonen oder git submodule update --init --recursive danach bleibt das Submodule-Verzeichnis leer, während Build-Skripte oder Composer-Autoloader auf Dateien verweisen, die schlicht nicht existieren. Dasselbe Problem tritt nach jedem git pull erneut auf, sobald sich der Gitlink-Zeiger im Parent-Repo geändert hat: Der neue Commit-Hash ist zwar lokal bekannt, das Submodule-Arbeitsverzeichnis zeigt aber weiterhin auf den alten Stand, bis erneut aktualisiert wird.
# Clone a parent repo including all submodules in one step
$ git clone --recurse-submodules git@github.com:mironsoft/magento-shop.git
# Forgot --recurse-submodules? Initialize and fetch afterwards
$ git submodule update --init --recursive
# Pull parent changes AND move submodules to their newly pinned commits
$ git pull --recurse-submodules
# Make git pull always recurse into submodules for this repo
$ git config submodule.recurse true
# Check status of all submodules including nested ones
$ git submodule status --recursive
a1b2c3d vendor/module-x (heads/main)
+e4f5a6b config/deploy-config (heads/main)
# leading "+" means the checked-out commit differs from the pinned SHA
4. Das Detached-HEAD-Problem bei Submodules
Nach jedem git submodule update befindet sich das Submodule standardmäßig nicht auf einem Branch, sondern im Detached-HEAD-Zustand, direkt auf dem gepinnten Commit. Das ist technisch korrekt und beabsichtigt, denn Git muss exakt den referenzierten Commit auschecken, unabhängig davon, welcher Branch dort gerade zeigt. Für Entwickler, die es gewohnt sind, dass jeder Checkout automatisch auf einem Branch landet, fühlt sich das jedoch wie ein Fehler an, besonders wenn sie versehentlich direkt im Submodule-Verzeichnis arbeiten.
Das eigentliche Risiko entsteht, wenn im Detached-HEAD-Zustand committet wird: Die neuen Commits existieren zwar im lokalen Objektspeicher des Submodules, hängen aber an keinem Branch. Wird anschließend im Submodule ein anderer Branch ausgecheckt oder erneut git submodule update im Parent-Repo ausgeführt, werden diese Commits für Git faktisch unerreichbar und laufen Gefahr, von der nächsten Garbage Collection entfernt zu werden. Die Faustregel lautet deshalb: Vor jeder Änderung im Submodule zuerst explizit git checkout -b feature/x oder zumindest auf einen bekannten Branch wechseln, niemals direkt im Detached-HEAD-Zustand weiterarbeiten.
5. Divergierende Commit-Pins im Team und CI-Komplexität
Weil jeder Gitlink einen exakten Commit fixiert, driften Submodule-Stände im Team schnell auseinander, sobald nicht jeder Entwickler diszipliniert nach jedem Pull aktualisiert. Ein Entwickler arbeitet gegen Commit A, ein anderer noch gegen Commit B, ein dritter hat lokal sogar unbewusst einen neueren Commit C ausgecheckt, ohne ihn im Parent-Repo zu committen. Tests laufen dann scheinbar zufällig unterschiedlich durch, obwohl der Code im Parent-Repository für alle identisch aussieht, weil die tatsächliche Ursache unsichtbar im Submodule-Zeiger liegt und von git status im Parent-Repo nur als vage "modified content" Zeile angezeigt wird.
In CI-Pipelines potenziert sich das Problem: Ein Runner, der frisch aus dem Parent-Repository klont, benötigt zwingend einen expliziten Schritt für Submodule-Initialisierung, sonst schlagen Build oder Tests mit kryptischen "file not found" Fehlern fehl, die nichts mit dem eigentlichen Code zu tun haben. Private Submodule-Repositories erfordern zusätzlich, dass der CI-Runner über eigene Zugangsdaten oder SSH-Deploy-Keys verfügt, getrennt von denen des Parent-Repos, was Secret-Management und Berechtigungskonzepte in mehrstufigen Pipelines spürbar verkompliziert.
# .gitlab-ci.yml: explicit submodule handling for a Magento CI pipeline
variables:
GIT_SUBMODULE_STRATEGY: recursive
# Separate deploy token, submodule repo is private and not covered
# by the parent repo's default CI credentials
GIT_SUBMODULE_FORCE_HTTPS: "true"
build:
stage: build
before_script:
- git submodule sync --recursive
- git submodule update --init --recursive
script:
- bin/composer install --no-dev --optimize-autoloader
- bin/magento setup:di:compile
6. git subtree: Historie verschmelzen statt referenzieren
git subtree löst dasselbe Grundproblem, Fremdcode in ein Repository integrieren, mit einem fundamental anderen Ansatz: Statt eine Referenz auf ein externes Repository zu speichern, kopiert und verschmilzt Subtree die komplette Historie des fremden Projekts direkt in einen Unterordner des Parent-Repos. Nach einem git subtree add ist der Code einfach da, als wäre er von Anfang an Teil des Repositories gewesen. Es gibt kein .gitmodules, keinen Gitlink, kein separates Update-Kommando, keinen Detached-HEAD-Zustand, ein frischer git clone reicht vollständig aus.
Der Preis dafür ist eine deutlich unübersichtlichere Commit-Historie: Jeder Subtree-Merge fügt potenziell hunderte fremde Commits in den git log des Parent-Repos ein, was git blame und Bisect-Sessions erschwert. Aktualisierungen erfolgen über git subtree pull, das erneut den kompletten aktuellen Stand des fremden Repos hereinmerged, und eigene Änderungen lassen sich mit git subtree push zurück in das Original-Repository schieben, allerdings nur, wenn dessen Historie kompatibel bleibt. Für Teams, die kein zusätzliches Init-Kommando nach dem Klonen tolerieren wollen, ist Subtree oft die pragmatischere Wahl als Submodules.
# Add a third-party repo as a subtree, squashing its history into one commit
$ git subtree add --prefix=vendor/module-x \
git@github.com:example-vendor/module-x.git main --squash
# Pull upstream changes into the subtree later on
$ git subtree pull --prefix=vendor/module-x \
git@github.com:example-vendor/module-x.git main --squash
# Push local changes made inside vendor/module-x back upstream
$ git subtree push --prefix=vendor/module-x \
git@github.com:example-vendor/module-x.git contribution-branch
# No .gitmodules, no init step: a plain clone already has everything
$ git clone git@github.com:mironsoft/magento-shop.git
7. Composer-Abhängigkeiten: private Packagist, Satis und VCS-Repositories
Für PHP- und Magento-Projekte ist die naheliegendste Alternative zu Submodules oft gar keine Git-Technik, sondern der Paketmanager, der ohnehin schon im Einsatz ist. Statt einen rohen Commit-Hash über einen Gitlink zu fixieren, referenziert Composer versionierte Releases über Semantic-Versioning-Constraints wie ^2.3. Ein privates Magento-Modul lässt sich über ein vcs-Repository direkt aus dem Git-Remote laden, über eine selbstgehostete Satis-Instanz als eigenes Paketarchiv bereitstellen, oder über privates Packagist mit vollem Release-Management und Team-Zugriffsrechten verwalten.
Der entscheidende Vorteil gegenüber Submodules: composer install ist bereits Teil jedes Magento-Deployments und jeder CI-Pipeline, es gibt also keinen zusätzlichen Init-Schritt und kein Risiko eines vergessenen Updates. Versionen werden explizit in composer.lock fixiert, was reproduzierbare Builds genauso garantiert wie ein Gitlink, aber mit lesbaren Versionsnummern statt kryptischer Hashes und mit den vertrauten Werkzeugen composer update, composer why und composer outdated für Abhängigkeitsanalyse. Der Nachteil: Jede Änderung am privaten Modul erfordert einen echten Release-Tag, was den Iterationszyklus während aktiver Parallelentwicklung spürbar verlangsamt.
{
"repositories": [
{
"type": "vcs",
"url": "git@github.com:mironsoft/module-seosuite.git"
},
{
"type": "composer",
"url": "https://satis.mironsoft.internal"
}
],
"require": {
"mironsoft/module-seosuite": "^2.3",
"mironsoft/module-core": "^1.8"
},
"config": {
"allow-plugins": {
"magento/*": true
}
}
}
8. Monorepo: ein Repository für alles
Die radikalste Alternative verzichtet komplett auf mehrere Repositories: Im Monorepo-Ansatz leben Shop, gemeinsame Module und Deployment-Konfiguration in genau einem Git-Repository, typischerweise in getrennten Verzeichnissen. Das eliminiert Submodules, Gitlinks und Versionsfixierung als Konzept vollständig, weil es schlicht nur eine einzige, gemeinsame Historie gibt. Ein Commit kann gleichzeitig eine Änderung an einem Modul und die dazu passende Anpassung im Shop-Code enthalten, atomar und ohne Koordination über mehrere Repository-Grenzen hinweg. Genau dieser Vorteil macht Monorepos besonders attraktiv für eng gekoppelten, gemeinsam entwickelten Code.
Der Preis ist strukturell: Mit wachsender Anzahl an Projekten wächst auch die Repo-Größe, Clone-Zeiten steigen, und ohne zusätzliches Tooling wie Sparse-Checkout oder Partial-Clone laden Entwickler oft mehr Code herunter, als sie für ihre eigentliche Aufgabe brauchen. CI-Pipelines müssen zudem lernen, selektiv nur die tatsächlich betroffenen Teilbereiche zu bauen und zu testen, sonst wächst die Pipeline-Laufzeit linear mit der Gesamtgröße des Repositories, selbst wenn nur eine einzelne Zeile in einem kleinen Modul geändert wurde.
9. Submodules, Subtree, Composer und Monorepo im Vergleich
Keine der vier Optionen ist pauschal die "richtige" Lösung, jede löst ein anderes Grundproblem unterschiedlich gut. Die folgende Tabelle stellt sie entlang der praxisrelevantesten Kriterien gegenüber.
| Kriterium | Submodules | Subtree | Composer | Monorepo |
|---|---|---|---|---|
| Setup-Komplexität | Init-Schritt nach jedem Clone nötig | Kein Init-Schritt, normaler Clone reicht | composer install, ohnehin Standard | Kein zusätzlicher Schritt |
| Historien-Klarheit | Sauber getrennt, eigene Historie | Fremde Commits vermischen sich mit eigenen | Sauber getrennt über Pakete | Eine Historie, thematisch vermischt |
| Lernkurve im Team | Hoch, Detached HEAD verwirrt Einsteiger | Mittel, wenige Zusatzbefehle | Niedrig, Standardworkflow aller PHP-Devs | Niedrig, aber Tooling-Reife nötig |
| CI-Aufwand | Zusätzliche Init- und Auth-Konfiguration | Keine Extra-Konfiguration nötig | Bereits im Standard-Build enthalten | Selektive Build-Strategie erforderlich |
| Passt gut für | Fixierte Vendor-Forks, Config-Repos | Einmalige Vendoring-Situationen | Versionierte, wiederverwendbare Module | Eng gekoppelte Teams, ein Produkt |
Für die meisten Magento-Multi-Repo-Setups gilt eine einfache Faustregel: Sobald ein Stück Code als eigenständiges, wiederverwendbares Modul mit klaren Versionsgrenzen gedacht ist, etwa eine SEO-Extension, die in mehreren Kundenprojekten läuft, ist Composer über ein privates VCS-Repository oder Satis fast immer die bessere Wahl. Submodules bleiben sinnvoll für Sonderfälle wie einen gepinnten Fork eines Drittanbieter-Moduls, der bewusst nicht über Composer aktualisiert werden soll, oder ein zentrales, git-verwaltetes Deployment-Config-Repository, das mehrere Projekte referenzieren, aber niemals automatisch aktualisieren sollen. Monorepos wiederum lohnen sich vor allem dann, wenn Shop und Module ohnehin nur von einem einzigen, eng abgestimmten Team weiterentwickelt werden.
Mironsoft
Repo-Strategien, Composer-Migrationen und CI/CD-Pipelines für PHP- und Magento-Teams
Multi-Repo-Setup professionell strukturieren?
Wir analysieren euer aktuelles Submodule- oder Multi-Repo-Setup, zeigen konkrete Alternativen auf und begleiten die Migration zu Composer, Subtree oder Monorepo, ohne Build- und Deployment-Prozesse zu unterbrechen.
Repo-Strategie-Audit
Bestehende Submodule- und Multi-Repo-Setups analysieren und bewerten
Composer-Migration
Private Packagist, Satis oder VCS-Repositories für Magento-Module aufsetzen
CI/CD für Multi-Repo
Pipelines für Submodule-, Composer- oder Monorepo-Workflows zuverlässig aufsetzen
10. Zusammenfassung
Git Submodules fixieren ein externes Repository über einen Gitlink-Tree-Eintrag und die Datei .gitmodules auf einen exakten Commit. Das ist technisch sauber, bringt aber praktische Fallstricke mit sich: vergessene git submodule update --init --recursive-Aufrufe, ein verwirrender Detached-HEAD-Zustand nach jedem Update, divergierende Commit-Pins im Team und zusätzlicher Konfigurationsaufwand in jeder CI-Pipeline. git subtree vermeidet den Init-Schritt und den Detached-HEAD-Zustand vollständig, erkauft das aber mit einer unübersichtlicheren, vermischten Commit-Historie.
Für die meisten PHP- und Magento-Projekte ist Composer über private Packagist-, Satis- oder VCS-Repositories die pragmatischere Wahl, weil Versionsverwaltung und Installation bereits Teil des Standard-Workflows sind. Ein Monorepo eliminiert das Problem strukturell, indem es mehrere Repositories komplett überflüssig macht, verlangt dafür aber wachsende Investition in Tooling und selektive CI-Strategien. Submodules bleiben die richtige Wahl für klar abgegrenzte Sonderfälle wie gepinnte Vendor-Forks oder geteilte Deployment-Config-Repositories, die bewusst nicht automatisch aktualisiert werden sollen.
Git Submodules vs. Alternativen: Das Wichtigste auf einen Blick
Submodules
Gitlink + .gitmodules fixieren einen exakten Commit. Init-Schritt und Detached HEAD sind die größten Stolperfallen.
Subtree
Verschmilzt fremde Historie direkt ins Repo. Kein Init-Schritt, dafür unübersichtlicherer git log.
Composer
Versionierte Releases über VCS-Repository, Satis oder privates Packagist statt rohem Commit-Pin.
Monorepo
Ein Repository, atomare Commits über Modulgrenzen hinweg, dafür wachsende Repo-Größe und CI-Komplexität.