Urlaubszeit, Reisezeit! Auf Langstreckenflügen führe ich gerne den Laptop mit, schließlich kommen mir die besten Ideen für neue Artikel immer zur Unzeit. Ohne Internetanschluss funktioniert aber weder Google noch ChatGPT, und auch Github fällt für Code-Ideen aus. Auch was ich bislang so geschrieben habe, steht in Git-Repos, deren Kopien auf dem Laptop nicht immer auf dem neuesten Stand sind, und das ist ärgerlich, weil das zu versehentlich ausgeführter Doppelarbeit führt oder mindestens zu nerviger Reißverschlussintegration mit potentieller Konfliktbereinigung, wenn ich später die vorher in luftiger Höhe eingecheckten Texte wieder mit dem Cloud-Repo in Einklang bringen will.
Wäre es nicht schön, vor der Abreise kurz ein Programm auf dem Laptop laufen zu lassen, das die lokalen Kopien aller ausgecheckten Git-Repos auf den neuesten Stand bringt? Dabei sollte es nicht nur bereits existierende Klone auf dem Laptop auf den neuesten Cloud-Stand vorwärts rollen bis git
"up-to-date" meldet, sondern auch bislang nur auf meinem Heimcomputer ausgescheckte Repos auf dem Laptop klonen, sodass der Laptop alles hat, was ich daheim so griffbereit habe.
Natürlich kann das Helferlein (noch) nicht hellsehen und alles, was mir wichtig ist, aufspielen. Deswegen nutze ich eine Meta-Datei nach Abbildung 1, die angibt, welche Repos unter welchen Verzeichnisnamen ich in jeder neuen Entwicklungsumgebung vorzufinden wünsche. Sie enthält einen Array im YAML-Format, dessen Elemente unter den Stichworten "dir" und "url" jeweils das Klon-Verzeichnis und den URL der wichtigsten Repos führt. Da es sich um Metadaten der Github-Repos handelt, soll das Format aus historischen Gründen ([2]) GMF
(Github Meta Format) heißen, denn vor 15 Jahren habe ich es schon einmal in Perl geschrieben und hier vorgestellt. Nun bietet sich die Gelegenheit, es in Go umzuschreiben und gleich nebenbei Gos coole Interface-Technik zu demonstrieren.
1 - dir: bondy 2 url: mschilli@fpx1-shared-a2-01.goodhost.com:git/bondy.git 3 - type: github-user-repos 4 user: mschilli 5 - type: ssh-user-repos 6 host: fpx1-shared-a2-01.goodhost.com 7 path: git
Denn das Meta-Format kann nicht nur einzelne Repos angeben, sondern auch ganze Sammlungen in einem Rutsch definieren wie "alle Github-Repos dieses Users" oder "alle Unterverzeichnisse auf einem Host mit SSH-Anschluss". Diese Einzelmeldungen oder Kollektionen soll der GMF-Parser in Go der Reihe nach aus der GMF-Datei auslesen, entsprechende Objekte daraus erzeugen, die alle die Methode Expand()
beherrschen, aus der zu klonende Verzeichnisse mit ihren URLs herauspurzeln.
Der GMF-Parser geht also später durch alle Einträge der YAML-Datei in Listing 1 und ruft für jeden die Methode Expand()
auf. Im ersten Eintrag in den Zeilen 1 und 2 gibt es nicht viel zu tun, denn das YAML-Element gibt schon den Git-URL sowie das Klon-Verzeichnis vor, also gibt Expand()
einfach die Kombination an den anschließend laufenden Kloner weiter.
Der zweite Eintrag in Listing 1 referenziert ab Zeile 3 mit dem Typ github-user-repos
die Repos eines Github-Users, also muss Expand()
in diesem Fall auf Github nachsehen, die Repos des Users einholen und daraus das Ergebnis zimmern, eine Liste von Git-URLs und deren Klonverzeichnissen. Der dritte Eintrag in Listing 1 ab Zeile 5 ist vom Typ ssh-user-repos
und veranlasst Expand()
hingegen, sich auf dem angegebenen Host per SSH einzuloggen, dort die Git-Verzeichnisse aufzulisten und eine Liste davon als Ergebnis zu liefern.
Dreimal ruft der Parser Expand()
auf, aber jedesmal kommt dieser auf unterschiedlichen Wegen zum Ergebnis. Nun könnte der Expandierer freilich in einem Labyrinth von if-else
-Zweigen auf den aktuell richtigen Algorithmus zusteuern, aber das ist unübersichtlich, schwer auf Korrektheit zu testen, zu erweitern, und zu warten. Objektorientiert schreibt sich die Lösung des Problems besser durch verschiedene Klassen, deren Expand()
-Methoden unterschiedliche Dinge tun. Aus den Einträgen der YAML-Datei kommt eine Liste unterschiedlicher Objekte, deren Expand()
-Methoden artspezifisch das Richtige tun. Eine Schleife iteriert anschließend über alle Objekte, und ruft deren Expand()
-Methoden auf, ohne sich explizit darum zu kümmern, von welchen Typ das Objekt nun eigentlich ist.
Dieses Verfahren heißt in der OO-Programmierung Polymorphismus, vom griechischen Wort für "Vielgestaltigkeit". Ein Bezeichner, also eine Variable, die ein Objekt enthält, kann demnach Instanzen unterschiedlicher Klassen annnehmen, die aber alle diesselbe Methode beherrschen. Diese lösen zwar abhängig vom aktuell verwendeten Typ unterschiedliche Aktionen aus, heißen aber eben gleich und liefern Ergebnisse im gleichen Format.
01 package main 02 import ( 03 "errors" 04 "os" 05 ) 06 type Cloneable struct { 07 URL string 08 Dir string 09 } 10 type Gitmeta struct { 11 Cloneables []Cloneable 12 } 13 type PluginIf interface { 14 Applicable(e GitMetaEntry) bool 15 Expand(e GitMetaEntry) ([]Cloneable, error) 16 } 17 func (g *Gitmeta) FindPlugin(m GitMetaEntry) (PluginIf, error) { 18 plugins := []PluginIf{ 19 NewGMFEntry(), 20 NewGMFGithubUser(), 21 NewGMFSSH(), 22 } 23 for _, plugin := range plugins { 24 if plugin.Applicable(m) { 25 return plugin, nil 26 } 27 } 28 return nil, errors.New("No applicable plugin found") 29 } 30 func NewGitmeta() *Gitmeta { 31 return &Gitmeta{ 32 Cloneables: []Cloneable{}, 33 } 34 } 35 func (g *Gitmeta) AddGMF(f *os.File) error { 36 entries, err := g.parseGMF(f) 37 if err != nil { 38 return err 39 } 40 for _, e := range entries { 41 p, err := g.FindPlugin(e) 42 if err != nil { 43 return err 44 } 45 cloneables, err := p.Expand(e) 46 if err != nil { 47 return err 48 } 49 for _, cloneable := range cloneables { 50 g.Cloneables = append(g.Cloneables, cloneable) 51 } 52 } 53 return nil 54 } 55 func (g *Gitmeta) AllCloneables() []Cloneable { 56 return g.Cloneables 57 }
Nun hat sich Go allerdings strenge Typen auf die Fahne geschrieben und ein und diesselbe Variable kann niemals unterschiedliche Typen aufnehmen. In die Bresche springt hier Gos Interface-Typ, der festlegt, welche Funktionen eine Variable eines Typs beherrscht. Diese Variablen lassen sich dann wie andere auch in Arrays stecken oder herumreichen, und wer ihre Methoden aufruft, bekommt das gewünschte Ergebnis, egal von welchem Typ das darunter versteckte Objekt nun tatsächlich ist.
Damit nun der GMF-Parser in Listing 2 Einträge unterschiedlicher Typen alle über einen Kamm scheren kann, definiert er ab Zeile 13 den Typ PluginIf
, der drei Funktionen beherrscht. Applicable()
prüft, ob der Plugin mit dem aktuellen GMF-Eintrag aus der Meta-Datei etwas anfangen kann. Gibt er ein true
zurück, wird das Hauptprogramm die Funktion Expand()
des Plugins aufrufen, um einen Array von klonbaren Repositories zu erhalten. Kommt false
zurück, wird es das Hauptprogramm mit dem nächsten Plugin versuchen.
Was der Aufruf von Expand()
nun im einzelnen macht, hängt vom jeweiligen Plugin ab. Der Plugin für Einzeleinträge mit Repo-URL und Klon-Verzeichnis, wie ab Zeile 1 in Listing 1, gibt lediglich einen Array mit einem einzigen Element zurück, nämlich eine Variable vom Typ Cloneable
, die diese Werte enthält. Den Code für diesen simplen Plugin zeigt später Listing 4. Allen Plugins ist gemein, dass sie einen Konstruktor anbieten (zum Beispiel NewGMFEntry()
), der ein Objekt des Typs zurückgibt, mit dem das Hauptprogramm später die im Interface standartisierten Funktionen aufrufen kann.
Liest später der GMF-Parser einen YAML-Eintrag aus, kommt dieser als Variable vom Typ GMFMetaEntry
zurück, und FindPlugin()
ab Zeile 17 in Listing 2 wird nun alle bekannten Plugins abklappern, um den dafür zuständigen zu finden. Das Array-Slice plugins
ab Zeile 18 in Listing 2 enthält drei Plugin-Referenzen, die es alle durch Aufrufen deren Konstruktoren mit New...()
erzeugt.
Dann iteriert die for
-Schleife ab Zeile 23 durch alle bekannten Plugins, ruft für jeden dessen Applicable()
-Funktion auf, bis einer true
zurückgibt. Die gefundene Referenz reicht FindPlugin
in Zeile 25 an den Aufrufer zurück, oder meldet in Zeile 28 einen Fehler, falls sich kein passender Plugin fand.
Der GMF-Parser in Listing 2 kommt nun selbst objektorientiert daher, und Zeile 30 definiert dessen Konstruktor. Als Instanzdaten enthält die Struktur Gitmeta
in Zeile 11 einen Array-Slice von Cloneable
-Typen, die jeweils zu klonende Repositories mit deren Ziel-Verzeichnissen enthalten. Die Funktion AddGMF()
ab Zeile 35 nimmt einen File-Deskriptor auf eine geöffnete GMF-Datei entgegen, liest ihren YAML-Inhalt mit parseGMF()
aus (später in Listing 3), welches einen Array-Slice von GitMetaEntry
-Strukturen zurückgibt.
Für jedes Element sucht dann FindPlugin()
in Zeile 41 den richtigen Sachbearbeiter, und der Aufruf von Expand()
in Zeile 45 lässt den Plugin herausfinden, welche tatsächlichen Repos vom Typ Cloneable
denn der aktuell Eintrag tatsächlich meint.
Jeden Treffer hängt die for
-Schleife ab Zeile 49 an die Instanz-Array Cloneables
an, und die exportierte Funktion AllCloneables()
ab Zeile 55 tut nichts weiter als das bisherige Ergebnis als Array-Slice dem Aufrufer zurückzureichen.
01 package main 02 import ( 03 "gopkg.in/yaml.v2" 04 "io/ioutil" 05 "os" 06 ) 07 type GitMetaEntry struct { 08 URL string `yaml:"url"` 09 Type string `yaml:"type"` 10 Dir string `yaml:"dir"` 11 User string `yaml:"user"` 12 Path string `yaml:"path"` 13 Host string `yaml:"host"` 14 } 15 func (g *Gitmeta) parseGMF(f *os.File) ([]GitMetaEntry, error) { 16 records := []GitMetaEntry{} 17 data, err := ioutil.ReadAll(f) 18 if err != nil { 19 return records, err 20 } 21 err = yaml.Unmarshal(data, &records) 22 if err != nil { 23 return records, err 24 } 25 return records, nil 26 }
Welche Einträge als Elemente der langen Liste der YAML-Datei erlaubt sind, definiert Listing 3. Ein Eintrag kann einen Typ type
angeben, falls dieser fehlt, handelt es sich um eine einfache Repo-Definition aus URL und Zielverzeichnis.
Der Go-Typ GitMetaEntry
definiert ab Zeile 7 alle erlaubten Feldnamen der Einträge in der .gmf
-Datei. Neben dem optionalen Typ type
kann ein Eintrag die Felder URL
und Dir
für einen Einzeleintrag enthalten, oder user
für ein Github-Repo, oder host
und path
für einen ssh-Host, der Git-Repos speichert.
Abbildung 1: Für alle Github-Repositories eines Users genügt ein Gitmeta-Eintrag |
Abbildung 2: Die Clone-Aktion erledigt gitmeta automatisch für jedes neue Repository |
Die Funktion parseGMF()
ab Zeile 15 nimmt einen offenen File-Deskriptor entgegen, liest die Daten, macht aus der YAML-Konfiguration eine Liste von GitMetaEntry
-Objekten und reicht dieser an den Aufrufer zurück.
Als erstes widmet sich der Plugin in Listing 4 einfachen Einträgen, die gleich die URL und das Zielverzeichnis eines zu klonenden Repos enthalten. Auch er kommt wie seine großen Brüder später objektorientiert daher und definiert in Zeile 3 den Konstruktor NewGMFEntry()
, der, wie in Go üblich eine Struktur zurückgibt, die als Instanzdatenhalter dient. In diesem simplen Fall bleibt die Struktur einfach leer, dient aber dem Aufrufer später als Referenz zum Aufruf weiterer Plugin-Funktionen.
01 package main 02 type GMFEntry struct {} 03 func NewGMFEntry() GMFEntry { 04 return GMFEntry{} 05 } 06 func (g GMFEntry) Applicable(e GitMetaEntry) bool { 07 return e.Type == "" 08 } 09 func (g GMFEntry) Expand(e GitMetaEntry) ([]Cloneable, error) { 10 return []Cloneable{ 11 {URL: e.URL, Dir: e.Dir}, 12 }, nil 13 }
Die Funktion Applicable()
ab Zeile 6 prüft, ob der YAML-Definition des Repos ein Feld mit dem Namen Type
beiliegt. Fehlt dieses, handelt es sich um einen Fall für den Plugin, und er gibt true
zurück, weil e.Type == ""
in Zeile 7 einen wahren Wert ergibt. Aus dem YAML-Eintrag eine Liste von Strukturen zu generieren und zurückzugeben, ist in diesem simplen Fall in Expand()
ab Zeile 9 reine Formsache. So, der erste Plugin wäre geschafft!
Wie sieht's mit einer Github-Referenz auf die Repos eines Users aus? Listing 5 zeigt den nach dem selben Schema konstruierten Plugin.
01 package main 02 import ( 03 "encoding/json" 04 "fmt" 05 "net/http" 06 ) 07 type Repository struct { 08 Name string `json:"name"` 09 Fork bool `json:"fork"` 10 SSHURL string `json:"ssh_url"` 11 HTMLURL string `json:html_url"` 12 } 13 type GMFGithubUser struct {} 14 func NewGMFGithubUser() GMFGithubUser { 15 return GMFGithubUser{} 16 } 17 func (g GMFGithubUser) Applicable(e GitMetaEntry) bool { 18 return e.Type == "github-user-repos" 19 } 20 func (g GMFGithubUser) Expand(e GitMetaEntry) ([]Cloneable, error) { 21 clones := []Cloneable{} 22 res, err := githubProjectsOfUser(e.User) 23 if err != nil { 24 return clones, err 25 } 26 for _, repo := range res { 27 clones = append(clones, Cloneable{URL: repo.SSHURL, Dir: repo.Name}) 28 } 29 return clones, nil 30 } 31 func githubProjectsOfUser(user string) ([]Repository, error) { 32 results := []Repository{} 33 url := "https://api.github.com/users/" + user + "/repos" 34 resp, err := http.Get(url) 35 if err != nil { 36 return results, err 37 } 38 defer resp.Body.Close() 39 if resp.StatusCode != http.StatusOK { 40 return results, fmt.Errorf("Status %s", resp.Status) 41 } 42 var repos []Repository 43 if err := json.NewDecoder(resp.Body).Decode(&repos); err != nil { 44 return results, err 45 } 46 for _, repo := range repos { 47 if !repo.Fork { 48 results = append(results, repo) 49 } 50 } 51 return results, err 52 }
Auch er bietet in Zeile 14 einen Konstruktor an, und gibt dem Aufrufer eine leere Struktur vom Typ GMFGithubUser
zurück. Die Interface-Funktion Applicable()
ab Zeile 17 prüft das type
-Feld des Eintrags und springt auf den String github-user-repos
an. Später kontaktiert Expand()
ab Zeile 20 über die Hilfsfunktion githubProjectsOfUser()
ab Zeile 31 die Github-API und lässt sich die Repos des angegebenen Users im JSON-Format auflisten.
Abbildung 3: Der API-Call für die Repos eines Users liefert JSON. |
Zurück kommt, wie in Abbildung 1 zu sehen, ein Wust von Daten, der sämtliche Repos des Users mit allerlei Parametern auflistet, so zum Beispiel ob es sich um einen Fork eines anderen Projekts handelt. Da der Kloner nur an Original-Repos interessiert ist, verwirft Zeile 47 alle Forks. Schließlich gibt Expand()
wiederum eine Liste von Cloneable
-Strukturen an das Hauptprogramm zurück.
Den Plugin für Git-Repos, die sich auf einem Host unter einem bestimmten per ssh
erreichbaren Verzeichnis befinden, zeigt Listing 6. Auch er prüft in Applicable()
den in YAML hinterlegten Typ, und falls er den Zuschlag erhält, führt er in Expand()
ab Zeile 16 die Hilfsfunktion sshGitDirs()
ab Zeile 31 aus. Diese führt ein ssh
-Kommando auf der Kommandozeile aus, und lässt dieses ein ls
-Kommando auf dem Remote-Host ausführen, das die Git-Repos unter dem angegebenen Pfad auflistet. Es wird angenommen, dass ssh
mit einem Schlüsselpaar konfiguriert ist, und ohne manuelle Passworteingabe funktioniert.
01 package main 02 import ( 03 "bufio" 04 "bytes" 05 "fmt" 06 "os/exec" 07 "strings" 08 ) 09 type GMFSSH struct {} 10 func NewGMFSSH() GMFSSH { 11 return GMFSSH{} 12 } 13 func (g GMFSSH) Applicable(e GitMetaEntry) bool { 14 return e.Type == "ssh-user-repos" 15 } 16 func (g GMFSSH) Expand(e GitMetaEntry) ([]Cloneable, error) { 17 clones := []Cloneable{} 18 res, err := sshGitDirs(e.Host, e.Path) 19 if err != nil { 20 return clones, err 21 } 22 for _, dir := range res { 23 ldir := strings.TrimSuffix(dir, ".git") 24 clones = append(clones, Cloneable{ 25 URL: fmt.Sprintf("%s:%s/%s", e.Host, e.Path, dir), 26 Dir: ldir, 27 }) 28 } 29 return clones, nil 30 } 31 func sshGitDirs(host string, path string) ([]string, error) { 32 results := []string{} 33 cmd := exec.Command("ssh", host, "ls", path+"/*/config") 34 output, err := cmd.Output() 35 if err != nil { 36 return results, err 37 } 38 reader := bytes.NewReader(output) 39 scanner := bufio.NewScanner(reader) 40 for scanner.Scan() { 41 line := scanner.Text() 42 parts := strings.Split(line, "/") 43 if len(parts) > 2 { 44 results = append(results, parts[len(parts)-2]) 45 } 46 } 47 return results, err 48 }
Aus den Pfaden zu den config
-Dateien gefundener Repos schneidet Zeile 44 den letzten Pfadteil ab und fügt den Namen des Verzeichnisses ans Ende des String-Array-Slices results
an. Anschließend schneidet Zeile 23 noch den oft verwendeten Anhang .git
ab und macht so daraus den Namen, den der Git-Kloner für die lokale Kopie verwenden soll. Den URL zum Repo zimmert Zeile 25 aus dem Hostnamen, dem Pfad und dem Repo-Verzeichnis zusammen. Fertig ist das Ergebnis einer Liste von Cloneable
-Strukturen.
Dem Hauptprogramm in Listing 7 bleibt nun nur noch, in main()
den Namen der zu bearbeitenden GMF-Datei von der Kommandozeile einzulesen, sie zu öffnen, und mit AddGMF()
dem Parser vorzulegen. Zurück kommt im Erfolgsfall eine Liste von Cloneable
-Strukturen, die die for
-Schleife ab Zeile 28 abklappert und jeweils cloneOrUpdate()
aus Listing 8 aufruft.
01 package main 02 import ( 03 "fmt" 04 "os" 05 "os/user" 06 "path/filepath" 07 ) 08 func main() { 09 if len(os.Args) != 2 { 10 panic(fmt.Errorf("usage: %s repos.gmf", os.Args[0])) 11 } 12 cfg := os.Args[1] 13 gmf := NewGitmeta() 14 f, err := os.Open(cfg) 15 if err != nil { 16 panic(err) 17 } 18 gmf.AddGMF(f) 19 usr, err := user.Current() 20 if err != nil { 21 panic(err) 22 } 23 gitDir := filepath.Join(usr.HomeDir, "git") 24 err = os.Chdir(gitDir) 25 if err != nil { 26 panic(err) 27 } 28 for _, c := range gmf.AllCloneables() { 29 err := cloneOrUpdate(c, gitDir) 30 if err != nil { 31 panic(err) 32 } 33 } 34 }
Diese Funktion nimmt eine Cloneable
-Struktur und den Pfad entgegen, unter dem alle lokalen Git-Klone landen. Vorher hat das Hauptprogramm dazu gitPath
auf das Verzeichnis git
unter dem Home-Verzeichnis des aktuellen Users gesetzt, wer etwas anderes wünscht, kann dies entsprechend ändern.
01 package main 02 import ( 03 "os" 04 "os/exec" 05 "path" 06 ) 07 func cloneOrUpdate(c Cloneable, gitPath string) error { 08 fullPath := path.Join(gitPath, c.Dir) 09 _, err := os.Stat(fullPath) 10 if os.IsNotExist(err) { 11 cmd := exec.Command("git", "clone", c.URL, fullPath) 12 _, err := cmd.CombinedOutput() 13 if err != nil { 14 return err 15 } 16 } 17 err = os.Chdir(fullPath) 18 if err != nil { 19 return err 20 } 21 cmd := exec.Command("git", "pull") 22 _, err = cmd.CombinedOutput() 23 return err 24 }
Beim eigentlichen Klon-Vorgang gibt es nun zwei Möglichkeiten: Entweder existiert das Verzeichnis mit dem Klon schon und muss mit git pull
nur auf den neuesten Stand gebracht werden. Oder aber es existiert noch nicht und git clone
in Zeile 11 fertigt eine neuen lokale Kopie des Remote-Repos an.
Aus allen Listings das Binary gitmeta
zu erzeugen geht wie immer über den Dreisprung git mod init gitmeta; git mod tidy; go build
, der ein neues Go-Modul definiert, alle Abhängigkeiten von externen Libraries auflöst und alles zu einem Executable zusammenlinkt. Nun noch schnell eine .gmf
-Datei mit allen zu klonenden Repos erzeugt, und die Reise kann losgehen!
Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2024/10/snapshot/
Michael Schilli, "Überall Projekte": Linux-Magazin 08/2010, S.xxx, <U>https://www.linux-magazin.de/ausgaben/2010/08/ueberall-projekte/<U>
Hey! The above document had some coding errors, which are explained below:
Unknown directive: =desc