Wie erhält der neugekaufte Laptop schnellstmöglich Kopien aller aktiv bearbeiteten Git-Repositories? Ein Meta-Repo führt eine Projektliste und Perlskripts automatisieren die Aufspür- und Klonvorgänge.
Das Versionskontrollsystem Git walzt mit seiner rasanten Performance und überragenden Branch-Strategie Oldtimer wie CVS, Subversion oder Perforce so platt, dass sich so mancher nach dem Wechsel wundert, wie man vor der Erfindung dezentralisierter Versionskontrollsysteme überhaupt Software entwickelte.
Allerdings konzentriert sich ein Git-Repo meist auf die Bearbeitung eines einzigen Projektes, Sub-Projekte unterstützt Git allenfalls rudimentär. Aktive Entwickler erzeugen oder klonen deswegen im Laufe der Zeit Dutzende von Git-Repos, die auch oft auf unterschiedlichen Servern liegen. Dieses Verfahren funktioniert ohne großen Aufwand, solange man nicht den Rechner wechselt und plötzlich alles neu klonen muss. Beim Kauf eines neuen Laptops oder dem Umzug auf einen neuen Entwicklungsdesktop wäre es schon hilfreich, wenn man dort gleich eine Kopie aller aktiv bearbeiteten Projekte vorfände.
Oft lassen sich neu installierte Computer auch Gruppen zuordnen, die unterschiedliche Repositories brauchen: Auf dem Laptop verbietet sich vielleicht aus Platzgründen ein Git-Repo mit speicherfressenden Bildern, während auf dem Rechner des Arbeitgebers Repos mit privaten Inhalten nichts zu suchen haben. Eine Konfigurationsdatei, irgendwo auf dem Internet abgelegt, könnte die Zugangsdaten der gewünschten Repositories speichern. Deren Werte ändern sich naturgemäß laufend, denn neue Projekte kommen hinzu und alte fallen weg -- wer behält den Überblick über die Versionen? Natürlich ein Versionskontrollsystem der Marke Git, also ein Meta-Repository!
Abbildung 1: In der Datei gitmeta.gmf im Repository gitmeta irgendwo auf dem Internet liegen die Meta-Daten aller aktiv bearbeiteten Git-Repositories des Users. |
Als Format für die Konfigurationsdatei
bietet sich das sowohl für menschliche Wesen als auch
Computer leicht lesbare YAML-Format an. Der spezielle Dialekt soll
GMF (Git Meta Format) heißen, und die Konfigurationsdateien tragen
.gmf als Endung. Abbildung 1 zeigt ein Beispiel. Git-Repositories
lassen sich auf verschiedene Art und Weise ansprechen, der erste
Eintrag verweist auf ein privat gehostetes Repo, das auf
dem fiktiven per SSH-Zugang gesicherten Server private.server.com
liegt. Der zweite Eintrag zeigt auf das offizielle Git-Repo des
Perl5-Kerns, auf dem sich alle Check-ins finden, seit Larry Wall
im Jahre 1987 die erste Version von Perl freigab. Beide
Repo-Locators bearbeitet der Befehl git clone
direkt und
legt auf Kommando eine lokales Directory mit einer Kopie des
jeweiligen Repositories an.
Abbildung 2: Die aus der YAML-Datei gitmeta.gmf eingelesenen Daten formen eine Perl-Datenstruktur. |
Natürlich könnte die GMF-Datei einfach alle aktiv bearbeiteten Repos so als Liste aufführen, doch richtig aktive Entwicklern empfänden das dauernde manuelle Einfügen von neuen oder Löschen von abgewrackten Projekten wohl als zu mühselig. Führt jemand zum Beispiel ein Dutzend Projekte auf Github.com oder in einem Verzeichnis auf einem per SSH zugänglichen Server, ließen sich diese Eingriffe einsparen, wenn es das Meta-Repo verstünde, diese Reposammlungen automatisch zu interpretieren. "Nimm alle Repos in diesem Verzeichnis" oder "alle auf Github liegenden Repos" sollte das Meta-Repo schon verstehen.
Die den unteren zwei Bindestrichen zugeordneten YAML-Blöcke in Abbildung 1 formen jeweils zwei Hash-Strukturen (siehe Perl-Format in Abbildung 2), die im Meta-Format Repo-Sammlungen mit bestimmten Eigenschaften bezeichnen. Der erste Hash führt im Feld "type" den Wert "Github", und der Eintrag "user" weist mit "mschilli" darauf hin, dass alle Repositories, die auf Github.com dem User "mschilli" gehören, auf den lokalen Rechner zu kopieren oder zu aktualisieren sind.
Statt dutzender Einzeleinträge also nur zwei Zeilen, und falls der User auf Github.com neue Repositories anlegt, werden diese automatisch Bestandteil der Konfiguration, ohne dass der User die Konfigurationsdatei anpassen muss. Löscht der Benutzer ein Projekt auf Github, wird der Updater das lokale Projekt nicht explizit löschen, putzt der Benutzer allerdings auch noch die lokale Kopie weg, findet später auch kein Klonvorgang mehr statt.
Der YAML-Eintrag neben dem letzten Bindestrich in Abbildung 1 (oder die letzte Datenstruktur in Abbildung 2) bezeichnet hingegen eine Sammlung von Git-Repositories, die in einem Verzeichnis auf dem angegebenen Server mit SSH-Zugang liegen. Auch hier schnappt sich der Updater automatisch Neueingänge, ohne dass der User eingreifen muss, indem das verarbeitende Skript die Unterverzeichnisse des angegebenen Directories auflistet und die so gefundenen Einzelrepos eins nach dem anderen klont.
Das Skript gitmeta-update
übernimmt die Installation und späteres
Auffrischen lokaler Repositories anhand der im Meta-Repo festgelegten
Daten.
Das Meta-Repo liegt typischerweise auf einem Server mit SSH-Zugang, denn falls es sowohl auf öffentliche als auch private Repos verweist, sollte diese Meta-Information nur für ausgewiesene Nutzer zugänglich sein.
Das Skript erwartet drei Kommandozeilenparameter, die Lage des Meta-Repos, den dortigen Pfad zur .gmf-Datei und das lokale Verzeichnis, in der die gespiegelten Repos zu liegen kommen. Der Aufruf
gitmeta-update -v user@secret.server.com:git/gitmeta \ gitmeta.gmf /path/to/local/repo/dir
kontaktiert den Server secret.server.com, loggt sich per SSH
als user
ein, wechselt dort ins Verzeichnis "git/gitmeta" unter
dem Home-Verzeichnis des Users user
und spiegelt das dort liegende
Git-Repository in einem temporären Verzeichnis auf der lokalen Platte.
Es liest dann die aktuelle Version von gitmeta.gmf
ein, jagt sie durch
den YAML-Parser und arbeitet den die Einträge des Arrays
nacheinander ab.
Nebenbei gibt der Aufruf oben die Option -v vor, die für eine ausführliche
Ausgabe der gerade bearbeiteten Befehle über die Log4perl-API
auf STDERR sorgt.
01 #!/usr/local/bin/perl -w 02 use strict; 03 use GitMeta::GMF; 04 use Sysadm::Install qw(:all); 05 use File::Basename; 06 use Getopt::Std; 07 use Log::Log4perl qw(:easy); 08 09 getopts("vn", \my %opts); 10 11 if($opts{v}) { 12 Log::Log4perl->easy_init($DEBUG); 13 } else { 14 Log::Log4perl->easy_init({ 15 level => $INFO, 16 category => "main", 17 }); 18 } 19 20 my($gmf_repo, $gmf_path, 21 $local_dir) = @ARGV; 22 23 die "usage: $0 gmf-repo gmf-path local-dir" 24 unless defined $local_dir; 25 26 main(); 27 28 ########################################### 29 sub main { 30 ########################################### 31 my $gm = GitMeta::GMF->new( 32 repo => $gmf_repo, 33 gmf_path => $gmf_path ); 34 35 my @urls = $gm->expand(); 36 37 if($opts{n}) { 38 for my $url ( @urls ) { 39 print "$url\n"; 40 } 41 return 1; 42 } 43 44 cd $local_dir; 45 46 for my $url ( @urls ) { 47 INFO "Updating $url"; 48 my $repo_dir = basename $url; 49 $repo_dir =~ s/\.git$//g; 50 if(-d $repo_dir) { 51 cd $repo_dir; 52 my($stdout, $stderr, $rc) = 53 tap "git", "fetch", "origin"; 54 INFO "$stdout$stderr"; 55 cdback; 56 } else { 57 my($stdout, $stderr, $rc) = 58 tap "git", "clone", $url; 59 INFO "$stdout$stderr"; 60 } 61 } 62 return 1; 63 }
Das Einholen und Bearbeiten der YAML-Datei kapselt der Perl-Code
in der Klasse GitMeta::GMF, die später erläutert wird. Zeile 26 ruft
den Konstruktor new()
auf und übergibt ihm den Repo-Locator $gmf_repo
und den Pfad zur .gmf-Datei, $gmf_path. Die Methodenaufruf
expand()
in Zeile 30 löst die direkten und indirekten Verweise in
der YAML-Datei auf und gibt eine Liste von Repo-Locators zurück, die auf
alle zu spiegelnden Repos verweisen.
Ist die Option -n
gesetzt, läuft das Skript im Trockendurchgang und
Zeile 32 verzweigt zu einer For-Schleife, die lediglich die gefundenen
Locators zu Testzwecken ausgibt und anschließend die Bearbeitung ohne
eigentliche Spiegelung abbricht. Im Ernstfall wechselt Zeile 39 mit dem
Befehl cd
aus dem Module Sysadm::Install in das angegebene lokale
Verzeichnis. Die for-Schleife ab Zeile 41 iteriert über alle gefundenen
Repo-Locators, entfernt eine etwaige Endung .git
vom Repo-Namen und
prüft, ob das entsprechende Verzeichnis schon existiert, das Repo also
schon einmal gespiegelt wurde. Ist dies der Fall, frischt der Aufruf
von git fetch
das Repo auf, in dem es Änderungen des Originals hereinholt,
sie aber nicht (wie git pull
das täte) in den gerade bearbeiteten Zweig
hineinmischt. Letzteres könnte Konflikte auslösen, die der User erst
langwierig lösen müsste, und das Ziel von gitmeta-update
is nur die
schnelle Spiegelung, solange ein Internetanschluss vorhanden ist. Mergen
kann man mit git selbstverständlich dann Offline.
Existiert für das zu spiegelnde Repo noch kein lokales Verzeichnis,
legt der Befehl git clone
in Zeile 49 eines an und zieht die
Daten des Remote-Repos herein, damit ein vollständiger Klon entsteht.
Soweit liegt die ganze Magie des Skripts in der in Zeile 30 aufgerufenen
Methode expand()
der Klasse GitMeta::GMF, die nicht nur eine .gmf-Datei
einholt, sondern auch deren Einträge rekursiv interpretiert.
Listing GMF.pm implementiert die von der Basisklasse GitMeta.pm abgeleitete
Klasse GitMeta::GMF. Ihre expand()
-Methode erwartet zwei benannte
Parameter, den Repo-Locator repo
und den relativen Pfad gmf_path
zur .gmf-Datei im Repo. Die beinahe virtuelle Basisklasse in Listing
GitMeta.pm stellt den Standard-Konstruktor new()
zur Verfügung,
den abgeleitete Klassen erben und damit nicht selbst definieren
müssen. Das spart Platz.
Weiter definiert die Basisklasse GitMeta.pm die Methode param_check()
,
die in den Subklassen prüft, ob deren Konstruktor auch die erwarteten
Parameter überreicht bekam und bricht das Programm ab, falls dies nicht
der Fall ist. Die Klassenhierarchie, also die Tatsache,
dass GitMeta::GMF von GitMeta abgeleitet ist, bringt der Befehl
use base qw(GitMeta)
in Zeile 8 von GMF.pm zum Ausdruck.
Die in
der Basisklasse definierte Version der expand()
-Methode in Zeile
12 von GitMeta.pm
enthält lediglich eine Anweisung, das Programm zu unterbrechen und
wird niemals ausgeführt, falls die Subklasse ihre eigene expand()
-Methode
definiert. Die die
-Anweisung dient nur als Erinnerung an Programmierer
von Subklassen, diese virtuelle Methode der Basisklasse tatsächlich
in der abgeleiteten Klasse zu implementieren.
01 ########################################### 02 package GitMeta; 03 ########################################### 04 # 2010, Mike Schilli <m@perlmeister.com> 05 ########################################### 06 07 ########################################### 08 sub new { 09 ########################################### 10 my($class, %options) = @_; 11 12 my $self = { %options }; 13 bless $self, $class; 14 } 15 16 ########################################### 17 sub expand { 18 ########################################### 19 die "You need to implement 'expand'"; 20 } 21 22 ########################################### 23 sub param_check { 24 ########################################### 25 my($self, @params) = @_; 26 27 for my $param (@param) { 28 if(! exists $self->{ $param }) { 29 die "Parameter $param missing"; 30 } 31 } 32 } 33 34 1;
Die ab Zeile 48 in GMF.pm definierte Methode _fetch
klont das
angegebene Git-Repo in einem temporären Verzeichnis und schlürft die
YAML-Daten der .gmf-Datei in eine Perl-Struktur, die sie als
Ergebnis zurück gibt. Der Unterstrich im Methodennamen weist darauf hin,
dass es sich um eine interne, private Methode handelt, die nicht zum
exportierten API der Klasse gehört.
Die exportierte Methode expand()
ruft zunächst _fetch
auf und
iteriert dann in der for-Schleife ab Zeile 28 über alle in der .gmf-Datei
gefundenen Elemente des YAML-Arrays. Stehen dort normale
Repo-Locators ohne type
-Eintrag, fügt sie Zeile 33 unmodifiziert
ans Ende des @locs-Arrays an. Steht im gerade bearbeitenen
YAML-Element hingegen eine Struktur mit einem Eintrag im type
-Feld,
delegiert GMF.pm die Bearbeitung an eine Subklasse dieses Typs.
01 ########################################### 02 package GitMeta::GMF; 03 ########################################### 04 # 2010, Mike Schilli <m@perlmeister.com> 05 ########################################### 06 use strict; 07 use warnings; 08 use base qw(GitMeta); 09 use File::Temp qw(tempdir); 10 use Log::Log4perl qw(:easy); 11 use YAML qw(Load); 12 use Sysadm::Install qw(:all); 13 use File::Basename; 14 15 ########################################### 16 sub expand { 17 ########################################### 18 my($self) = @_; 19 20 $self->param_check("repo", "gmf_path"); 21 22 my $yml = $self->_fetch( 23 $self->{repo}, 24 $self->{gmf_path} ); 25 26 my @locs = (); 27 28 for my $entry ( @$yml ) { 29 my $type = ref($entry); 30 31 if($type eq "") { 32 # plain git url 33 push @locs, $entry; 34 } else { 35 my $class = "GitMeta::" . 36 ucfirst( $entry->{type} ); 37 eval "require $class;" or 38 LOGDIE "Class $class missing"; 39 my $expander = $class->new(%$entry); 40 push @locs, $expander->expand(); 41 } 42 } 43 44 return @locs; 45 } 46 47 ########################################### 48 sub _fetch { 49 ########################################### 50 my($self, $git_repo, $gmf_path) = @_; 51 52 my($tempdir) = tempdir( CLEANUP => 1 ); 53 54 cd $tempdir; 55 tap "git", "clone", $git_repo; 56 my $data = slurp(basename($git_repo) . 57 "/$gmf_path"); 58 cdback; 59 my $yml = Load( $data ); 60 return $yml; 61 } 62 63 1;
Gültige Werte für type
sind "github" und "sshdir", die die
Bearbeitung des Eintrags jeweils an die abgeleiteten Klassen
GitMeta::Github und GitMeta::SshDir weiterleiten. Hierzu bindet das
eval-Kommando in 37 die gesuchte Klasse in das laufende Programm
ein und Zeile 39 ruft deren Konstruktor mit den im YAML-Eintrag
gefundenen Parametern auf.
In bester Polymorphie-Tradition verfügen auch die abgeleiteten Klassen
über eine expand()
-Methode, die ebenfalls Listen von Repo-Locators
zurückliefern. Was zurückkommt, egal woher, wandert ans Ende
des @locs-Arrays und trägt zum Ergebnis bei.
Trifft das Skript beim Interpretieren einer .gmf-Datei auf einen
Eintrag des Typs "github", aktiviert es die Klasse GitMeta::Github
in Listing Github.pm. Auch sie erbt von der Basisklasse GitMeta und
überschreibt lediglich die Methode expand()
, in der sie die Namen
aller auf Github liegenden Repos eines vorgegebenen Users holt. Hierzu
nutzt sie Githubs simple XML-API, die ohne Token unter dem
Pfad /api/v1/xml/username auf github.com frei verfügbar ist. Die
Methode decoded_content()
stellt sicher, dass auch UTF8-kodierte
Projektbeschreibungen gültiges XML liefern.
Das von der Web-Anfrage zurückkommende XML schnappt sich die Funktion XMLin()
aus dem CPAN-Modul XML::Simple und wandelt es in eine tief verschachtelte
Hash-Datenstruktur um, in die Zeile 35 unter dem Schlüssel
{repositories}->{repository} hineinlangt und einen Hash bekommt, dessen
Keys die Repo-Namen repräsentieren.
01 ########################################### 02 package GitMeta::Github; 03 ########################################### 04 # 2010, Mike Schilli <m@perlmeister.com> 05 ########################################### 06 use strict; 07 use warnings; 08 use base qw(GitMeta); 09 use LWP::UserAgent; 10 use XML::Simple; 11 12 ########################################### 13 sub expand { 14 ########################################### 15 my($self) = @_; 16 17 $self->param_check("user"); 18 19 my $user = $self->{user}; 20 my @repos = (); 21 22 my $ua = LWP::UserAgent->new(); 23 my $resp = $ua->get( 24 "http://github.com/api/v1/xml/$user"); 25 26 if($resp->is_error) { 27 die "API fetch failed: ", 28 $resp->message(); 29 } 30 31 my $xml = XMLin( 32 $resp->decoded_content()); 33 34 my $by_repo = 35 $xml->{repositories}->{repository}; 36 37 for my $repo (keys %$by_repo) { 38 push @repos, 39 "git\@github.com:$user/$repo.git"; 40 } 41 42 return @repos; 43 } 44 45 1;
Zeile 39 formt aus dem Namen einen Github-typischen Repo-Locator, der dem lokalen User sowohl Lese- als auch Schreibberechtigung einräumt, vorrausgesetzt natürlich der User identifiziert sich mit einem gültigen SSH-Key.
Eine weitere spezialisierte Klasse findet sich in Listing SshDir.pm. Das dort definierte, ebenfalls von Gitmeta erbende Paket Gitmeta::SshDir zeichnet für Repos verantwortlich, die als Unterverzeichnisse in einem Directory auf einem per SSH-Zugang geschützten Server liegen. Diese eignen sich hervorragend für private Repos, da weder ihr Inhalt noch ihre Namen irgendwo öffentlich erscheinen.
01 ########################################### 02 package GitMeta::SshDir; 03 ########################################### 04 # 2010, Mike Schilli <m@perlmeister.com> 05 ########################################### 06 use strict; 07 use warnings; 08 use base qw(GitMeta); 09 use Sysadm::Install qw(:all); 10 use Log::Log4perl qw(:easy); 11 12 ########################################### 13 sub expand { 14 ########################################### 15 my($self) = @_; 16 17 $self->param_check("host", "dir"); 18 19 INFO "Retrieving repos ", 20 "from $self->{host}"; 21 22 my($stdout) = tap "ssh", $self->{host}, 23 "ls", $self->{dir}; 24 25 my @repos = (); 26 27 while( $stdout =~ /(.*)\n/g ) { 28 push @repos, 29 "$self->{host}:$self->{dir}/$1"; 30 } 31 32 return @repos; 33 } 34 35 1;
Um eine Liste dort verfügbarer Verzeichnisse einzulesen und später an
den Updater durchzureichen, setzt Zeile 22 in SshDir.pm ein ls
-Kommando
über das ssh-Protokoll auf dem Server ab und erfragt damit alle unter
dem angegebenen Verzeichnis stehenden Directories. Die Ausgabe
ist Unix-Shell-typisch durch Zeilenumbrüche getrennt. Die while
-Schleife
ab Zeile 27 trennt die Zeilen, formt aus jedem Eintrag einen
Repo-Locator für das Git-über-SSH-Protokoll und hängt ihn an den
Ergebnis-Array @repos an, den die Methode dann an den Aufrufer
zurückreicht.
Meta-Repos dürfen auch andere Meta-Repos referenzieren, wie Abbildung
3 zeigt. Der gezeigte Eintrag definiert im type
-Feld "GMF" und der
bearbeitende Code zieht darum die Klasse GitMeta::GMF
zur Bearbeitung
heran, die das Remote-Repo einholt und wiederum dessen .gmf-Datei
analysiert.
Das Skript löst dann die Einträge rekursiv auf und erzeugt eine lange Liste mit Repos, die es aufzufrischen gilt. So lassen sich Repo-Gruppen kombinieren und jedes Zielsystem erhält eine maßgeschneiderte Repo-Sammlung, ohne dass Repos doppelt und dreifach in mehreren Konfigurationen stehen müssen.
Abbildung 3: Aus einem Git-Meta-Repo lassen sich weitere Git-Meta-Repos referenzieren. |
Die in Abbildung 3 stehende Konfiguration entspricht genau dem Aufruf
gitmeta-update user@devhost.com:git/gitmeta privdev.gmf ...
nur dass dem Kommandozeilenaufruf noch ein Verzeichnis lokal gespiegelter Git-Repos folgt. Die .gmf-Dateien im Meta-Repo dürfen zur besseren Strukturierung übrigens ruhig in Unterverzeichnissen liegen, es wäre durchaus denkbar, ein Meta-Repo mit zwei GMF-Dateien priv/free.gmf und priv/commerce.gmf zu bestücken, um freie von kommerzieller Software zu trennen. Hierzu ist lediglich der gmf_path in der GMF-Konfiguration bzw der zweite Parameter von gitmeta-update auf der Kommandozeile anzupassen.
Damit die SSH-Zugriffe den User nicht dazu zwingen, dauernd sein Passwort anzugeben, ist es notwendig alle beteiligten SSH-Server mit Public Keys auszustatten. Andernfalls fragen die Server nach einem Passwort, doch diese Rückfragen bekommt der User durch die ausgabeschluckenden tap()-Befehle nicht zu Gesicht und wundert sich, warum der Zugriff hängt. Github lässt von vornherein keine Passworteingabe bei Git-Zugriffen zu und verlangt deswegen, dass der User seinen Public Key auf der Webseite hinterlegt.
Damit das gitmeta-Skript auf einer neu eingerichteten Maschine läuft, müssen dort neben perl auch die vom Skript und seinen Modulen verwendeten CPAN-Module installiert sein. Die vier vorgestellten Klassen müssen exakt in der folgenden Verzeichnishierarchie im Filesystem unter einem Pfad finden, den das Skript findet:
GitMeta.pm GitMeta/GMF.pm GitMeta/Github.pm GitMeta/Sshdir.pm
Zum Anlegen neuer GMF-Dateien erzeugt man ein neues Gitmeta-Repo auf einem Server mit SSH-Zugang, editiert die .gmf-Datei und führt, wie in Abbildung 4 gezeigt, einen Commit durch.
Abbildung 4: Zum Anlegen neuer GMF-Dateien erzeugt man ein neues Gitmeta-Repo auf einem Server mit SSH-Zugang, editiert die .gmf-Datei und führt einen Commit durch. |
Nach dem Anlegen des Meta-Repos auf dem Server ist das Meta-Repo über den Locator
user@some.host.com/repos/gitmeta
erreichbar und einem Aufruf von gitmeta-update
mit diesem Parameter
beginnt mit dem Klonvorgang. Wer noch keinen neuen Laptop hat, um es
auszuprobieren, hat nun den perfekten Vorwand, um einen zu erwerben.
Listings zu diesem Artikel: ftp://www.linux-magazin.de/pub/listings/magazin/2010/08/Perl
Michael Schilliarbeitet als Software-Engineer bei Yahoo! in Sunnyvale, Kalifornien. Er hat "Goto Perl 5" (deutsch) und "Perl Power" (englisch) für Addison-Wesley geschrieben und ist unter mschilli@perlmeister.com zu erreichen. Seine Homepage: http://perlmeister.com. |