Externals internalisieren – svn:externals in Git einbinden
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:
-
Weder Team A noch Team B müssen Änderungen im eingebundenen Verzeichnis durchführen.
-
Im
svn:externals
-Property steht immer eine feste Revisionsnummer von C, die verwendet werden soll. Es gibt keine Angaben der Form “nimm die jeweils letzte Version von …”. -
Es gibt nur ein mit
svn:externals
angebundenes Verzeichnis, dessen Koordinaten außerdem bekannt und unveränderlich sind.
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:
-
svn:externals
hat sich nicht geändert und im Commit war schon der korrekte Inhalt enthalten. -
svn:externals
hat sich nicht geändert, im Commit war aber das Zielverzeichnis noch nicht eingebunden. -
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:
-
Suche von HEAD ausgehend rückwärts den letzten Commit mit einem
externals-update-from
Trailer. -
Wenn ein Commit gefunden wird:
- Merke die URL des Trailers als “aktuell gültiger Externals-Wert”.
- Führe
git filter-branch
nur ab diesem Commit bis zu HEAD aus.
-
Wenn kein Commit gefunden wurde:
- Führe
git filter-branch
auf dem kompletten Branch bis zu HEAD aus.
- Führe
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:
-
Beide Teile – das eigene Skript und
git-filter-branch
werden mit Bash ausgeführt. Dadurch kann man auch in den Schnipseln selbst Bash-Syntax statt reiner/bin/sh
Syntax verwenden. -
git-filter-branch
kennt Variablen und Funktionen, die im eigenen Teil definiert wurden. Daher sind die Filter-Parameter nur noch Aufrufe von Shell-Funktionen. Utility-Funktionen können sowohl in den Filter-Schritten als auch im Hauptteil verwendet werden. -
Der Aufruf erfolgt nicht über den
git
-Befehlsverteiler, daher muss vorherPATH
entsprechend erweitert werden. -
git-filter-branch
beendet seine Ausführung mitexit
, daher wird Code nachsource
ignoriert.
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
-
Das vollständige Skript zum Herunterladen. ↩︎