Externals internalisieren – svn:externals in Git einbinden


de KaffeeKlatsch git svn

Erschienen im KaffeeKlatsch, Juni 2017 (06/2017)


Team A arbeitet mit Git. Team A muss im Subversion-Repository von Team B immer wieder API-Änderungen oder Ähnliches nachziehen. Das SVN-Repository von B aber bindet über svn:externals ein weiteres Repository C ein, ohne das man den Code in B nicht verwenden kann. Wie kann Team A in dieser Dreiecksbeziehung mit Git arbeiten?

Ohne die Einbindung des Repository C ist die Lösung einfach: Team A spiegelt das SVN-Repository B nach Git und bereitet dort die nötigen Änderungen in einem oder mehreren Featurebranches vor. Im Rahmen der Integration von A und B fließen die angesammelten Änderungen zurück nach Subversion. Dieses Verfahren unterstützt Git direkt mit dem Befehl git svn 1. Leider ignoriert git svn den svn:externals-Link komplett – die entsprechenden Verzeichnisse sind im Git-Spiegel leer.

Die hier vorgestellte Lösung verwendet git svn zum Erstellen des Spiegels. Wie üblich übernimmt git svn rebase Neuerungen aus SVN und spielt sie auf dem Remote-Tracking-Branch remotes/git-svn ein. Der lokale Git-Branch master folgt diesem Branch und übernimmt sie ebenfalls. Bevor aber diese Änderungen die Basis eigener Features oder Anpassungen werden, ergänzt ein Skript2 die Historie um die Änderungen aus dem Repository C. Jeder Git-Commit seit dem letzten Lauf des Skriptes wird so umgeschrieben, dass er auch den korrekten Inhalt des svn:externals-Verzeichnisses widerspiegelt. Diese neue Historie wird nun als master bzw. remotes/git-svn veröffentlicht.

Dieses Verfahren funktioniert nur aufgrund einiger Rahmenbedingungen verlässlich:

Veränderliche Vergangenheit

Im Gegensatz zu Subversion kann man die Vergangenheit in Git ändern. Typische Befehle dafür sind – mit zunehmenden Nebenwirkungen: git commit --amend 3 zum Ändern des letzten Commits, git rebase 4 um z. B. einen Featurebranch zu verpflanzen und das Schweizer Taschenmesser git filter-branch 5. Letzteres kann die gesamte Historie umschreiben und dabei angefangen von den Autoren- bzw. Comitter-Namen und -E-Mails, den Commit-Kommentaren über die Dateien des Commits und den Vorgängern des Commits faktisch alles ändern. Dabei übergibt man filter-branch neben den betroffenen Commits verschiedene Skript-Schnipsel in Unix-Sh Syntax. Diese werden innerhalb von filter-branch für jeden Commit ausgeführt. Das Ergebnis verpackt filter-branch wieder in einen neuen Commit und verbindet ihn mit seinen – vermutlich – ebenfalls umgeschriebenen Vorgängern.

Da git filter-branch die übergebenen Schnipsel direkt mit eval ausführt, können diese sehr einfach Informationen austauschen: Geänderte Environment-Variablen beeinflussen sowohl git filter-branch selbst als auch die anderen Schnipsel. Shell-Funktionen lassen sich in einem Schnipsel definieren und in einem anderen verwenden. Folgendes Beispiel löscht die Email-Adressen der Autoren und Committer und fügt sie mittels eines Hashes pseudonymisiert im Commit-Kommentar wieder an:

> git filter-branch --env-filter '
	pseudo() { echo "$1" | sha1sum | sed -r "s/ +-$//"; }
	author_pseudo=$(pseudo "$GIT_AUTHOR_EMAIL")
	committer_pseudo=$(pseudo "$GIT_COMMITTER_EMAIL")
	GIT_AUTHOR_EMAIL=
	GIT_COMMITTER_EMAIL=
' --msg-filter '
	git interpret-trailers \
		--trailer "pseudo-author: $author_pseudo" \
		--trailer "pseudo-committer: $committer_pseudo"
'

Ein Commit sieht nach dem Filtern wie folgt aus:

> git log -n1
commit 3d3f847456466418b991a0f5817effc5c4fed248 (HEAD -> master)
Author: Andreas Heiduk <>
Date:   Sun Apr 9 23:09:05 2017 +0200

	Etwas Wundervolles!
	
	pseudo-author: e951b92784b0229de320d755a692aa406a0b9044
	pseudo-committer: e951b92784b0229de320d755a692aa406a0b9044

Es gibt zwei mögliche Filter-Schritte zum Ändern von Dateien in einem Commit: --tree-filter und --index-filter.

Zuerst führt git filter-branch den Tree-Filter aus, wenn er angegeben wurde. Dazu legt Git ein temporäres Arbeitsverzeichnis an, checkt alle Dateien des jeweiligen Commits aus und führt dann den Filter aus. Änderungen, die dieser Filter im Arbeitsverzeichnis vornimmt, werden automatisch in den temporären Index übernommen.

Anschließend führt git filter-branch den Schritt --index-filter aus. Dieser Filter kann den temporären Index direkt ohne Umweg über ein Arbeitsverzeichnis manipulieren, bevor dieser Index zum nächsten Commit wird. Dadurch ist --index-filter effizienter als --tree-filter und wird für die Einbettung der svn:externals verwendet.

Aber wie manipuliert man nur den Index? Die Brot und Butter-Befehle git add, git checkout, git reset, git rm gleichen normalerweise ein Arbeitsverzeichnis mit dem Index ab. Für reine Index-Manipulation gibt es unter anderem die Befehle git read-tree, git write-tree, git ls-files und auch git rm --cached.

Das Skript aktualisiert das svn:externals-Verzeichnis im temporären Index von git filter-branch, indem es einfach das bestehende Verzeichnis mit git rm --cached löscht und den gewünschten Stand (im Beispiel CUR_EXT_REV) mit git read-tree einfügt.

> git filter-branch --index-filter '
	CUR_EXT_REV=...
	EXTERNALS_PATH=subdir/external-content
	git rm -r --cached --ignore-unmatch --quiet "$EXTERNALS_PATH"
	git read-tree --prefix "$EXTERNALS_PATH" "$CUR_EXT_REV^{tree}"
'

Gegebenenfalls erkennt Git keine Änderung, aber folgende drei Fälle können so einheitlich behandelt werden:

  1. svn:externals hat sich nicht geändert und im Commit war schon der korrekte Inhalt enthalten.

  2. svn:externals hat sich nicht geändert, im Commit war aber das Zielverzeichnis noch nicht eingebunden.

  3. svn:externals hat sich geändert, das Zielverzeichnis muss sowieso ersetzt werden.

Im Beispiel bezeichnet $CUR_EXT_REV einen Commit, dessen Tree (ermittelt mit ^{tree}) direkt den Inhalt des svn:externals-Verzeichnisses enthält. Woher kommt aber dieser Commit? Wie wird seine Hash-ID ermittelt?

Die erste Frage ist einfach beantwortet: Im lokalen Git-Repository spiegelt man mit git svn nicht nur den Subversion-Branch von Projekt B, sondern auch den von Projekt C. Dabei sind die beiden Historien in Git nicht miteinander verbunden, sondern stehen nebeneinander. Die gesuchten Verzeichnisstrukturen liegen also schon im passenden Git-Objektformat vor und können einfach von git read-tree referenziert werden.

Das Ermitteln dieser Commit-ID ist aber etwas aufwendiger.

git svn schreibt beim Synchronisieren mit dem Subversion-Repository zusätzliche Informationen in den Commit-Kommentar. Ein Kommentar aus dem Projekt-B-Branch enthält z.B.

> git cat-file commit 563d9c8b78528cf2dd11f826bef6fc185b496c26

tree 85dfa95634821f758c7d5967302394ae1d109582
parent 6800e3ef97f01aae6701e8b8f68a115101512567
author Foo <Bar@example.com> 1496242671 +0000
committer Foo <Bar@example.com> 1496242671 +0000

Ein netter Kommentar für die Story XYZ.

git-svn-id: http://svn.example.com/svn/team-b/trunk@4711 8807db94-6cca-42b9-8cfd-733f22d5d49

Aus dem “Trailer” git-svn-id im Kommentar geht hervor, dass dieser Git-Commit dem Subversion-Commit r4711 an der angegebenen URL entspricht. Im Beispiel wird das svn:externals-Verzeichnis aus Projekt C unter subdir/external-content eingebunden. Daher liefert folgende Anfrage an den SVN-Server den dazugehörigen Inhalt der svn:externals-Property:

> svn propget svn:externals http://svn.example.com/svn/team-b/trunk/subdir@4711

^/team-c/trunk@3280 external-content

Das heißt ein Checkout der Version 4711 von Projekt B holt automatisch die Version 3280 des svn:externals-Verzeichnisses und bindet es unter subdir/external-content ein. Da das entsprechende Verzeichnis von Projekt C auch mit git svn gespiegelt wurde, muss es also einen Git-Commit mit diesem Trailer geben:

git-svn-id: http://svn.example.com/svn/team-c/trunk@3280 8807db94-6cca-42b9-8cfd-733f22d5d49

Dieser Commit lässt sich auf diese Weise finden:

> git rev-list -n1 --all --grep "git-svn-id: http://svn.example.com/svn/team-c/trunk@3280\b"

f406aca0cc490be33ee8787f431c1392591304ed

Und schon ist die gewünschte CUR_EXT_REV bekannt.

Dieses Mapping von der git-svn-id des ursprünglichen Commits über die Angabe des svn:externals-Property in Subversion hin zur richtigen git-svn-id des Commits von Team C ist der Kern des git filter-branch-Aufrufes.

Spuren im Sand

Jede Ausführung des Skriptes würde den kompletten Branch – vom ersten bis zum letzten Commit – erneut umschreiben.

Bei einem regelmäßigen Einsatz sollten aber möglichst nur Commits umgeschrieben werden, die neu aus Subversion gespiegelt wurden. Ansonsten besteht die Gefahr, dass Commits umgeschrieben werden, die Team A schon als Grundlage von Feature-Branches verwendet.

Eine einfache Möglichkeit ist, einen zweiten Trailer an die Commit-Kommentare anzuhängen, bei denen der entsprechende SVN-Commit eine Änderung von svn:externals enthält. Als Wert des Trailers bietet sich die Ziel-URL des svn:externals an. Für das Beispiel lautet der neue Trailer:

externals-update-from: http://svn.example.com/svn/team-c/trunk@3280

Das Vorgehen im Überblick ist also:

Dabei wird der Aufruf von filter-branch an zwei Stellen erweitert: Der --index-filter berechnet den aktuellen svn:externals-Commit-Hash nicht in CUR_EXT_REV, sondern in einer lokalen Variable. CUR_EXT_REV enthält jetzt den Wert des letzten Durchlaufes. Sind beide Werte unterschiedlich, wird die URL des Trailers in einer Variable TRAILER_VALUE vermerkt.

# message to --msg-filter
if [ "$CUR_EXT_REV" = "$new_ext_rev" ]
then
		unset TRAILER_VALUE
else
		TRAILER_VALUE="$externals_url"
fi

# remember for next iteration
CUR_EXT_REV=$new_ext_rev

Der zusätzliche --msg-filter-Schritt bei git filter-branch greift diese URL auf und fügt sie dem Commit-Kommentar hinzu.

if [ -z "$TRAILER_VALUE" ]
then
		cat
else
		git -c trailers.ifexists=addIfDifferent interpret-trailers \
				--trailer "$TRAILER_TOKEN: $TRAILER_VALUE"
fi

Da nicht jeder Commit-Kommentar den neuen Trailer enthält, sondern nur bei Änderungen der svn:externals-Property in Subversion, kann man diese Änderungen im Git-Log gut nachvollziehen.

Teile und herrsche

Inzwischen sind die Parameter für git filter-branch keine kleinen Schnipsel mehr, sondern haben einen veritablen Umfang. Daher fällt auch das korrekte Quoting dieser Parameter immer schwerer. Sie können aber nicht in eigene Shell-Skripte ausgelagert werden, da sie dann keine Variablen innerhalb von git filter-branch mehr verändern können. Ein Ausweg ist der Umbau des Skriptes2 nach folgendem Muster:

#!/bin/bash

# Definition von Shell-Funktionen

implant_externals (){ ... }

add_externals_trailer (){ ... }

# Hauptteil - Suche vorhandene Trailer

last_update=$(git rev-list ...)
if [ "$last_update" ]; then
	...
fi

# Aufruf git-filter-branch

PATH="$(git --exec-path):$PATH"
source git-filter-branch \
	--index-filter implant_externals \
	--msg-filter add_externals_trailer \
	"$filter_range"

git filter-branch wird also nicht als eigenständiger Befehl (mit eigener Shell) aufgerufen – der Code von git-filter-branch wird mit source direkt in der eigenen Shell zur Ausführung gebracht. Das hat mehrere Konsequenzen:

Im Großen und Ganzen

Das Skript2 kümmert sich nur um das Umschreiben der Historie, es sind aber zusätzliche Vor- und Nacharbeiten nötig.

Der erste Schritt ist das Anlegen eines Git-Repositories für den Spiegel.

# Git Repository initialisieren
git init svn-mirror
cd svn-mirror

In dieses Git-Repository werden die SVN-Repositories von Team B und Team C gespiegelt. Die Wurzel-URL beider Repositories liegt in $SVN_REPO.

# Subversion Mirrors anlegen
SVN_REPO=https://...
git svn clone $SVN_REPO/team-b/trunk .
git svn clone -R svn-ext -i git-ext $SVN_REPO/team-c/trunk .

Dabei verwaltet git svn das Repository für Team B in einem SVN-Remote svn und verwendet einen Remote-Tracking-Branch refs/remotes/git-svn. Für Team C werden diese Standardwerte überschrieben, der Name des Remote ist svn-ext, der Tracking-Branch refs/remotes/git-ext.

Im Moment ist der master-Branch (trunk von Team B) ausgecheckt, enthält aber noch keine svn:externals-Verzeichnisse. Das Skript bindet diese jetzt an der richtgen Stelle ein.

# svn:externals einbauen
git-svn-internalize

Dabei schreibt git filter-branch den kompletten master-Branch um und legt dabei ein Backup des alten Branch-Heads an. Dieses wird nun gelöscht.

# Backup von `master` löschen
git update-ref -d refs/original/refs/heads/master

Nach dem Umschreiben des master-Branch steht der Remote-Tracking-Branch git-svn natürlich noch auf der originalen Version und muss umgebogen werden. Neben dem Tracking-Branch cacht git svn eine Zuordnung von SVN-Revisionsnummern zu Git-Commit-Hashes sowie einen eigenen Index des letzten Standes. Beide sind durch die neuen Commit-Hashes und das geänderte Verzeichnis nicht mehr gültig. Nach dem Löschen kann git svn fetch beides problemlos anhand der git-svn-id-Trailer wiederherstellen.

# git-svn auf neue Historie umstellen
git update-ref refs/remotes/git-svn HEAD
rm -rf .git/svn/refs/remotes/git-svn
git svn fetch

Der konvertierte Stand kann nun veröffentlicht werden.

# Für Team A als Basis bereitstellen
git push ...

Team A kann nun endlich mit der Arbeit beginnen und z. B. von einer beliebigen “guten” Version einen Featurebranch abzweigen.

Diese Befehle bisher sind nur für den ersten Lauf nötig, für weitere Abgleiche ändert sich das Verfahren nur wenig:

# Updates von SVN holen
git svn fetch --all
git svn rebase -l

# svn:externals einbauen
git-svn-internalize

# Backup beseitigen
git update-ref -d refs/original/refs/heads/master

# git-svn auf neue Historie umstellen
git update-ref refs/remotes/git-svn HEAD
rm -rf .git/svn/refs/remotes/git-svn
git svn fetch

# Für Team A als Basis bereitstellen
git push ...

Es bietet sich natürlich an, diese Sequenz automatisch durch einen Jenkins-Job auszuführen.

Fazit

Der Umstieg von einem SCM-System auf ein anderes ist immer schwierig. Die Philosophie und die Features eines Systems lassen sich nie genau auf ein anderes System übertragen. Eine einmalige Konvertierung fällt in der Regel leichter, da die Umsetzung nur in einer Richtung erfolgt und dabei Vereinfachungen getroffen werden können. Bei einem Parallelbetrieb fällt dies deutlich schwerer, da auch der Rückweg korrekt sein muss.

Zum Glück ist Git extrem flexibel, lässt sich skripten und erlaubt dabei einen direkten Zugriff auf interne Strukturen. Das Ergebnis ist zwar an etlichen Stellen – sagen wir – mehr pragmatisch als schön aber immerhin möglich.

Kurzbiografie

Andreas Heiduk ist als Senior Consultant für MATHEMA Software GmbH tätig. Seine Themenschwerpunkte umfassen die Java Standard Edition (JSE) und die Java Enterprise Edition (JEE). Daneben findet er die unterschiedlichsten Themen von hardwarenaher Programmierung bis hin zu verteilten Anwendungen interessant.