Git Submodules als Package Manager

Vorige week voegde ik een worktree toe aan een repository om een branch naast de hoofdcheckout te testen. Ik voerde git submodule update --init uit omdat de build de vendored dependencies nodig had. Toen ik klaar was en probeerde op te ruimen met git worktree remove, weigerde Git. Volgens de handleiding kunnen alleen schone worktrees worden verwijderd; "onschone worktrees of worktrees met submodules" vereisen de --force vlag. Submodules krijgen in die zin een eigen clausule, apart van de dirty state. git worktree move is nog strikter en weigert categorisch elke worktree die submodules bevat. Ik had de week ervoor juist gecatalogiseerd hoe command-line tools hun --force vlaggen beveiligen, en hier vereiste Git er een omdat twee van zijn eigen functies met elkaar botsten.

De aankondiging van GitHub over Git 2.5 in juli 2015 introduceerde git worktree met een korte waarschuwing: "Het wordt niet aanbevolen om git worktree te gebruiken met een repository die submodules bevat." Elf jaar later vereist Git nog steeds --force om een worktree met submodules te verwijderen en weigert deze te verplaatsen. In de tussentijd moest worktree add worden gepatcht om submodule.recurse te negeren, omdat het naleven hiervan ervoor zorgde dat de interne reset --hard recurseerde in submodule-paden die in de nieuwe worktree nog leeg waren.

Dit zette me aan het denken over submodules als package manager. De meeste onderdelen zijn aanwezig en het gedrag komt ongeveer overeen, maar ze sluiten niet volledig op elkaar aan en de gebruikerservaring is in bijna elke stap slechter. Genoeg projecten hebben ze geadopteerd om vervolgens weer terug te stappen, waardoor "waarom zijn git submodules zo slecht" een terugkerend thema is.

De gitlink in de boom van het superproject — een commit SHA geregistreerd op een pad met mode 160000 — is de lockfile entry, en het .gitmodules bestand, dat paden koppelt aan fetch-URL's, is het manifest. git submodule update leest beide en vult de working tree aan, wat in feite de installatiestap is. De fixatie (pin) zelf is net zo precies als die van elke package manager: een exacte commit, geïdentificeerd door een object-ID.

Resolutie

De gitlink registreert alleen welke commit moet worden uitgecheckt. Daarom bevat .gitmodules per submodule een URL en kloneert update vanaf die locatie; dit is het enige resolutiemechanisme. Als de upstream-repository wordt hernoemd, wordt overgeplaatst naar een andere host of privé wordt gemaakt, breekt elke downstream-fixatie, ook al is de SHA ongewijzigd en bestaan de objecten nog in elke kloon die ze al had. Het manifest hardcodeert een host, omdat Git geen lookup heeft van een commit-ID naar servers die deze bevatten.

Git kopieert bovendien elke URL de eerste keer dat git submodule init wordt uitgevoerd naar de .git/config van het superproject, onder submodule.<name>.url. Latere commando's lezen de URL daarvandaan en negeren .gitmodules. Het bewerken van het committed .gitmodules bestand om naar een mirror of fork te wijzen, laat een reeds geïnitialiseerde kloon ongewijzigd, totdat git submodule sync de nieuwe waarde kopieert.

De gebruikelijke workaround in CI is de globale Git-configuratie url.<base>.insteadOf, die elke URL met een overeenkomend prefix herschrijft voordat er wordt gefetched, inclusief submodule-URL's. Veelvoorkomende gevallen zijn het herschrijven van https://github.com/ naar git@github.com: zodat een SSH-deploy key kan worden toegepast, of het omleiden van een interne hostname naar een mirror.

Installatie

Een gewone git clone schrijft de gitlink in de index, zodat de submodule-directory bestaat, maar laat deze leeg totdat git submodule update --init wordt uitgevoerd of de kloon is gemaakt met --recurse-submodules. De configuratie-instelling submodule.recurse zorgt ervoor dat checkout, fetch, pull, grep en verschillende andere commando's automatisch recurseren; standaard staat dit uit.

Standaard checkt update de gitlink-commit uit in een detached staat. Twee onafhankelijke vlaggen wijzigen dit:

  • --init: kopieert eerst ontbrekende .gitmodules-vermeldingen naar .git/config. Dit is vereist bij de eerste run en is daarna een no-op.
  • --remote: checkt de tip van de geconfigureerde remote-tracking branch van de submodule uit in plaats van de gitlink-commit (de HEAD van de remote als submodule.<name>.branch niet is ingesteld).

De commandoreferentie documenteert beide, hoewel de naam update "installeer wat is vastgezet" verwart met "update naar de nieuwste versie". Het wisselen van branches in het superproject verandert de gitlink in de index, maar laat de working tree van de submodule staan waar deze was, waardoor git status de submodule onmiddellijk als gewijzigd markeert. Door --recurse-submodules mee te geven aan checkout, of door submodule.recurse in te stellen, beweegt de submodule working tree mee met de branch-switch.

Het Rust-project, dat beschrijft waarom ze compiler-subprojecten hebben weggehaald bij submodules, noemt deze problemen vanuit ervaring: checkouts die leeg bleven of op de verkeerde commit stonden na het klonen, niet-gerelateerde submodule-updates die in pull requests belandden omdat een branch-switch de submodule dirty achterliet, en aangepaste logica in de bootstrap build-tool om elke submodule naar de juiste commit te checken voordat er gebouwd werd.

Opslag

De git-directory van een submodule wordt opgeslagen onder $GIT_DIR/modules/<name>/ van het superproject, met een .git bestand in de working tree van de submodule dat een gitdir: pointer terug bevat en een core.worktree instelling die de andere kant op wijst. git submodule absorbgitdirs migreert oudere klonen die nog een geneste .git/ directory hebben. Elke vermelding onder modules/ is een git-directory met eigen refs, HEAD, index, config, hooks en standaard zijn eigen object store.

Het verwijderen van een submodule is verdeeld over drie plaatsen:

  1. git rm <path> verwijdert de gitlink en de vermelding in .gitmodules.
  2. git submodule deinit <path> wist de working tree en de vermelding in .git/config.
  3. De geabsorbeerde $GIT_DIR/modules/<name> directory moet volgens de documentatie handmatig met rm -rf worden verwijderd.

Worktrees en submodules botsen door deze lay-out, omdat een gekoppelde worktree de $GITDIR van het superproject deelt, maar zijn eigen working tree, HEAD en index heeft onder $GITDIR/worktrees/<id>/. Als je twee worktrees op verschillende branches van het superproject zet, refereren ze naar dezelfde submodule op twee verschillende commits. Elke worktree heeft zijn eigen submodule checkout en index nodig, gekoppeld aan opslag die gedeeltelijk per worktree is en gedeeltelijk gedeeld wordt. worktree remove vereist daarom de override (--force) in plaats van te controleren of die staat weggooibaar is, en worktree move weigert omdat de benodigde rewrite van het pointer-bestand niet is geïmplementeerd.

Xavier Morel vroeg in maart op de Git-lijst of een submodule-checkout zelf een worktree van een bestaande gedeelde kloon kon zijn. Hij merkte dat bare repositories plus worktrees goed werkten voor een set gerelateerde projecten, maar dat het toevoegen van submodules daarop altijd een nieuwe kloon forceerde. In april volgde een RFC en een serie van drie patches die --recurse-submodules voor git worktree add voorstelden. Dit geeft elke gekoppelde worktree zijn eigen submodule git-directory onder $GITCOMMONDIR/worktrees/<id>/modules/ en deelt de objectopslag tussen hen via hardlinks.

Hetzelfde effect treedt op in een kloon met één enkele worktree wanneer twee submodules beiden afhankelijk zijn van een derde repository. Elk pad in het superproject krijgt zijn eigen modules/ vermelding, zijn eigen object store (tenzij alternates handmatig zijn geconfigureerd) en zijn eigen gitlink. De twee pins kunnen naar verschillende commits van dezelfde repository wijzen, en Git behandelt ze als niet-gerelateerde checkouts. Package managers met een gedeelde cache (zoals de registry cache van Cargo, de content-addressable store van pnpm, of de Go module cache) slaan de bytes één keer op en checken ze uit per locatie.

Bijwerken

De gitlink bevat één commit SHA. Een submodule naar voren bewegen betekent dus: de submodule betreden, fetchen, de nieuwe commit uitchecken, terugkeren en git add <path> uitvoeren in het superproject om de nieuwe gitlink te registreren.

git submodule update --remote fetched de tip van de geconfigureerde branch en checkt deze uit in plaats van de geregistreerde gitlink; het commiten van dit resultaat in het superproject is wat de pin verplaatst. .gitmodules kan per submodule een branch benoemen voor --remote en update-bots om te volgen. Een gewone update negeert dit veld en checkt de gitlink SHA uit, ongeacht de branch. Er is geen syntaxis voor een versiebereik, een tag-patroon of een minimale commit; de enige zwevende referentie in het manifest is een branch-naam, en de gitlink is de enige echte pin.

Zowel Dependabot als Renovate kunnen pull requests openen om een gitlink te verhogen. Het gitsubmodule ecosysteem van Dependabot stelt een nieuwe gitlink SHA voor wanneer de geconfigureerde branch van de submodule beweegt, en de git-submodules manager van Renovate doet hetzelfde (standaard uitgeschakeld). Beiden volgen branch-tips omdat een branch-naam de enige referentie is die het manifest blootstelt.

Beveiliging

.gitmodules wordt gecommit in de repository, dus een vijandige upstream beheert de inhoud ervan. Git parseert dit bestand tijdens clone --recurse-submodules voordat de gebruiker enige van de gefetched bestanden heeft gezien. Deze combinatie heeft herhaaldelijk geleid tot remote code execution.

  • CVE-2018-11235: Gebruikte ../ in de naam van een submodule, waardoor de git-directory (inclusief hooks) buiten $GIT_DIR/modules/ werd geschreven en een post-checkout hook werd uitgevoerd tijdens het klonen.
  • CVE-2018-17456: De submodule-URL begon met een -, waardoor de child git-clone dit als een optie parseerde. Dit is de klasse fout waar de --end-of-options delimiter van Git tegen beschermt.
  • CVE-2022-39253: Een disclosure-bug waarbij een symlink in de object-directory van een submodule ervoor zorgde dat een local-transport kloon willekeurige bestanden van de schijf van het slachtoffer kopieerde. De fix veranderde de protocol.file.allow standaard naar user, waardoor lokale-pad submodules nu een expliciete opt-in vereisen.
  • CVE-2024-32002: Combineerde een symlink met een case-insensitive bestandssysteem om een hook in .git/ te schrijven tijdens een recursieve kloon.

Abstractie

Submodules leggen de internals van Git direct bloot: object-ID's als pin, detached HEADs na een update, de $GIT_DIR/modules/ lay-out en transport-URL's in het manifest. Een package manager verpakt deze equivalenten achter een manifest-formaat, een resolver en een lokale cache; submodules presenteren ze onbewerkt.

De meeste tekortkomingen komen overeen met zaken die package managers allang hebben opgelost: een gedeelde object-cache, standaard recursie in afhankelijkheden bij klonen en checkout, één enkele levenscyclus voor het toevoegen en verwijderen van een afhankelijkheid, en range-constraints in het manifest.

De patch-serie uit april die --recurse-submodules toevoegt aan git worktree add pakt één aspect van het opslagprobleem aan door elke worktree zijn eigen submodule-checkout te geven over hardlinked gedeelde opslag. De resolutie is echter het moeilijkere probleem: een commit SHA is een host-onafhankelijke identiteit voor het object, en de URL in .gitmodules is de enige mapping die Git heeft van die identiteit naar een server die het object bevat.