Über einen Kamm geschert (Linux-Magazin, Oktober 2024)

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.

Meta-Format

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.

Listing 1: repos.gmf

    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.

Ohne Spaghetti-Code

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.

Vielgestaltig in Go

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.

Listing 2: gmf.go

    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.

Plugin je nach Aufgabe

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.

Plugin gesucht

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.

Sachbearbeiter gefunden

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.

Listing 3: parser.go

    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.

Einfacher Plugin

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.

Listing 4: gmf-entry.go

    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!

Plugin für Github

Wie sieht's mit einer Github-Referenz auf die Repos eines Users aus? Listing 5 zeigt den nach dem selben Schema konstruierten Plugin.

Listing 5: gmf-ghuser.go

    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.

Plugin für SSH

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.

Listing 6: gmf-ssh.go

    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.

Und ... Action!

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.

Listing 7: gitmeta.go

    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.

Listing 8: clone.go

    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.

Zusammenzimmern

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!

Infos

[1]

Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2024/10/snapshot/

[2]

Michael Schilli, "Überall Projekte": Linux-Magazin 08/2010, S.xxx, <U>https://www.linux-magazin.de/ausgaben/2010/08/ueberall-projekte/<U>

Michael Schilli

arbeitet als Software-Engineer in der San Francisco Bay Area in Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen verschiedener Programmiersprachen. Unter mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.

POD ERRORS

Hey! The above document had some coding errors, which are explained below:

Around line 5:

Unknown directive: =desc