Die vielfältigen Fähigkeiten von Git, Teil 2
Der zweite Teil der Artikelserie widmet sich den vielfältigen Git-Optionen für DevOps und Automatisierer.
![Die vielfältigen Funktionen von Git, Teil 2](https://heise.cloudimg.io/width/610/q85.png-lossy-85.webp-lossy-85.foil1/_www-heise-de_/imgs/18/2/7/1/7/6/9/5/shutterstock_482861365-642790a9a1af50a4.jpeg)
(Bild:
- Andreas Krüger
In dieser Artikelserie geht es um "vielfältige" Fähigkeiten von Git. Gemeint sind damit Möglichkeiten, in Git "mehrere" verwalten zu können, wo im normalen Alltag "eins" genügt. Der erste Teil führte verschiedene Beispiele für eine solche Multiplizität auf, die zu kennen für die allgemeine Entwicklungspraxis nützlich ist. Der zweite Teil bietet weitere Beispiele. Einige sind speziell für Mitwirkende an Open-Source-Projekten interessant (und alle, die es werden wollen), andere für DevOps und andere Automatisierende.
Mehrere Identitäten
Im Zusammenhang mit Open Source tritt manchmal ein Problem auf. Und zwar betrifft es Aktive, die für Lohn und Brot in kommerziellen Projekten arbeiten und sich zusätzlich in ihrer Freizeit für Open-Source-Projekte engagieren. Auf den ersten Augenschein hin sieht es so aus, als müssten sie sich entscheiden: Welche E-Mail-Anschrift sollen sie für die eigenen Git-Aktivitäten benutzen: die berufliche oder die private?
Viele entscheiden sich für die private. Es kann ganz amüsant sein, sich für ein internes kommerzielles Repository von example.com eine Gesamtliste der im Projekt benutzten E-Mail-Anschriften zu besorgen, die nicht zu example.com gehören:
git log --pretty='%ae' | sort -u | grep -v example.com
Obwohl solche Kommandozeilen oft viel Output erzeugen, besteht das Problem eigentlich nur scheinbar. Denn Git erlaubt durchaus mehrere Identitäten derselben Person: Man kann die eigene Identität für jedes Repository einzeln konfigurieren.
Konkret wechselt man in ein Repository und konfiguriert dort spezifisch die in die Commits einzutragende eigene E-Mail-Anschrift, sowie, wenn man für den Namen eine andere Variante benutzen möchte als sonst, auch diesen. Dafür genügt es, git config wie bei der Ersteinrichtung von git aufzurufen, aber jetzt die Option --global wegzulassen – dadurch wird die Konfiguration Repository-spezifisch:
git config user.email 'a.u.thor@example.org'
git config user.name 'A. U. Thor'
Mehrere Bäume
Die folgende Mehrfachmöglichkeit von Git wird nicht so oft gebraucht, aber wenn, ist sie da: Und zwar kann Git im selben Repository komplett getrennte Branch-Bäume verwalten.
Normalerweise gibt es in einem Repository nur einen Commit ohne Vorgänger: den ersten. Aber es kann durchaus auch mehrere solcher Commits geben. Graphentheoretisch ausgedrückt: Der Commit-Graph kann unzusammenhängend sein.
Man kann in einem existierenden Repository einen neuen leeren Branch ohne jede Commits und Vorfahren erzeugen, zum Beispiel mit:
git checkout --orphan isolated-branch
Die Situation ist ähnlich der nach git init: Ein Commit, den man nun erzeugt, ist ein "erster" Commit, also ein Commit ohne Vorgänger. Man fängt sozusagen ganz von vorne an.
Dies kann nützlich sein unmittelbar vor der Veröffentlichung bisher interner Arbeit. Etwas hat klein angefangen, als privates Feierabendprojekt einer einzelnen Person, hat einige Irrungen und Wirrungen durchlebt, aber sich ganz manierlich entwickelt und soll nun als Open-Source-Repository debütieren.
In der Situation ist es eine offene Frage, ob man die Irrungen und Wirrungen mit veröffentlichen will. Die genauen Zeitstempel, wann man seinerzeit aktiv war, sind verhältnismäßig intime personenbezogene Daten, die man der Welt vielleicht nicht zur Verfügung stellen will. An die Stelle der Gesamthistorie kann man eine Art "Paukenschlag-Commit" treten lassen, der das gesamte bisher Erreichte sozusagen "aus dem Nichts" entstehen lässt.
Ein beispielhaftes Vorgehen ist:
git branch -m master old-master
git checkout --orphan master
git reset old-master -- .
git commit -m 'Results thus far, without convoluted history.'
Der Code benennt zunächst den bisherigen master in old-master um. Es gibt also kurzfristig keinen Branch master mehr im Repository. In der nächsten Zeile wird master neu hergestellt, als leerer Branch, ohne Commit, Vorgänger oder Historie.
Anschließend kommt eine Variante von git reset zum Einsatz. Die Variante kopiert den gesamten Datenbestand des letzten Commits von old-master direkt in den Index. (Man beachte den die Kommandozeile abschließenden Punkt.)
Es ist etwas ungewohnt, dass die Daten direkt im Index landen und nicht im Arbeitsverzeichnis. Wer das nicht weiß, den kann der Output von git status verwirren.
Mit dem abschließenden git commit wird ein neuer Commit ohne Vorgänger angelegt (und nebenbei wird das Arbeitsverzeichnis gefüllt). Man kann sich anschließend mit gitk oder git log --pretty=raw davon überzeugen, dass der neue Commit tatsächlich keine Vorgänger hat. Der Commit ist der gewünschte "Paukenschlag-Commit", der veröffentlicht werden kann.
Copy and Paste mit Git
Eine Situation mit mehreren getrennten Zusammenhangskomponenten des Commit-Graphen lässt sich auch anders herstellen: Man konfiguriert dazu für das lokale Repository zwei verschiedene Remotes, zwei beliebige Repositorys, die inhaltlich nichts miteinander zu tun haben.
Das kann gelegentlich nützlich sein, um einzelne Dateien oder Verzeichnisbäume von einem anderen Repository zu übernehmen, ohne dass Commits des anderen Repositorys deshalb in die eigene Versionsgeschichte integriert werden.
Die oben schon benutzte Variante von git reset erlaubt es ganz allgemein, bequem Material (Dateien und Verzeichnisse) von einem beliebigen Commit direkt in den Index zu kopieren. Es ist dabei nicht nötig, einen Umweg über Kopien im Arbeitsverzeichnis zu gehen.
Der Ursprungs-Commit des Materials erscheint dadurch nicht als Vorgänger. Man kann also kurzfristig ein zweites Repository als ein neues Remote lokal einbinden, Material mit git reset kopieren und das Remote wieder entfernen. Dadurch entsteht keine dauerhafte Beziehung zum zweiten Repository.
Mehrere Repositorys synchronisieren: "Die Pumpe"
Eine kurzfristige Kopie eines Repositorys auf einem zweiten Server kann wie dargelegt nützlich sein. Aber auch für einen langfristigen Zweitwohnsitz eines Repositorys gibt es oft gute Gründe.
Im einfachsten Fall ist dabei die Version auf einem der Server die führende Instanz. In solchen Fällen nutzt man gerne eine "Pumpe". Damit ist eine Automatik gemeint, die regelmäßig alle Commits vom führenden Repository in das andere kopiert.
Wann kann man so etwas gebrauchen? Zum Beispiel wenn man ein intern benutztes Repository veröffentlicht hat, aber nicht alle Teammitglieder und CI/CD/Build-Pipelines zwingen will, sich Credentials für das öffentliche Angebot zu besorgen. Stattdessen bleibt das alte, intern benutzte Repository wie bisher in Gebrauch. Eine Pumpe hält die öffentlich sichtbare Instanz automatisch aktuell.
Ein anderer Fall aus der Praxis: Das Entwicklungsteam ist gewohnt, auf den Server eines Git-as-a-Service-Anbieters zuzugreifen. Aber ein alternatives Angebot bietet eine angenehme CI-Lösung (für dort gehostete Repositorys), die das Team nutzen möchte. Hier hilft ebenfalls eine Pumpe.
Eine Pumpe soll wartungsfrei laufen, gehört also automatisiert und in einem CI/CD-Job untergebracht. Die nötigen Funktionen sind in jeder passablen Skriptsprache schnell geschrieben. Es folgt eine kurze Übersicht, was dafür zu tun ist. Die hier beschriebene Beispielpumpe nutzt ein lokales Repository, sie soll von remote_from nach remote_to kopieren.
Den folgenden Befehl ruft die Pumpe zunächst auf:
git fetch -p remote_from
git fetch -p remote_to
um sich beidseitig auf den neuesten Stand zu bringen. Dann wird der Stand extrahiert:
git for-each-ref --format '%(refname) %(objectname)' refs/remotes/remote_from
git for-each-ref --format '%(refname) %(objectname)' refs/remotes/remote_to
und der jeweilige Output (einzeln) aufgefangen. Das ergibt je eine Liste von Branch-Namen (HEAD ignoriert man) mit SHAs. Bei aktuellen Versionen von git kann man sich das Leben übrigens noch einfacher machen, indem man %(refname:lstrip=3) benutzt.
Beide Listen kann man nun mit ein paar Zeilen Code der genutzten Skriptsprache vergleichen. Auf Unterschiede reagiert die Pumpe wie folgt:
Einen Branch branch_n von remote_from, den es auf remote_to noch nicht gibt oder der dort auf einen anderen SHA hinausläuft als auf remote_from, kopiert man mit
git push -q --force remote_to refs/remote/remote_from/branch_n:refs/heads/branch_n
Einen old_branch, den es auf remote_from nicht mehr gibt, aber noch auf remote_to, löscht man mit
git push -q remote_to :old_branch
Regelmäßig aufgerufen, zwingt die programmierte Pumpe remote_to stets auf denselben Stand wie remote_from.
Mehrere Credentials im Zugriff
Innerhalb der CI/CD-Umgebung braucht die Pumpe zwei Credentials, um auf beide Repositorys zuzugreifen. Ein gängiger Weg ist, Credentials in CI/CD-Umgebungen mit Umgebungsvariablen zur Verfügung zu stellen. Wie kann man das praktisch organisieren? Das kommt ganz auf den Zugriff an und es gibt verschiedene Möglichkeiten. Die üblichen Kandidaten sind SSH und HTTPS.
SSH Credentials
Auf ein entferntes (remote) Repository user@host.example.org (oft git@host.example.org) wird mit SSH zugegriffen. Damit der Zugriff funktioniert, sind normalerweise zwei Bedingungen zu erfüllen:
- Der SSH-Hostkey des Servers ist lokal bekannt.
- Der SSH-Key des lokalen Users ist auf dem Server eingetragen und mit den jeweiligen Berechtigungen versehen.
Um die erste Bedingung zu erfüllen, fängt man den Hostkey einmalig manuell mit folgender Zeile auf:
ssh -o UserKnownHostsFile=known_hosts user@host.example.org
Hat der eigene Rechner mit diesem Host schon kommuniziert, überprüft die Zeile, ob der aufgefangene Host-Key in der neuen Datei known_hosts im derzeitigen Verzeichnis mit dem in der zentralen $HOME/.ssh/known_hosts übereinstimmt. Dazu vergleicht man den Output:
ssh-keygen -F host.example.org -l -f $HOME/.ssh/known_hosts
ssh-keygen -F host.example.org -l -f known_hosts
Damit erhält man den öffentlichen Host-Key in der Datei known_hosts im derzeitigen Verzeichnis. An der Datei ist nichts Geheimes. Sie kann beispielsweise in ein Git-Repository eingecheckt oder in ein Docker-Image kopiert werden. Um sie zu nutzen, kopiert man sie ins Verzeichnis $HOME/.ssh des betreffenden Benutzers, der in der CI/CD-Umgebung die Pumpe ausführt.
Will man auf mehrere Server via SSH zugreifen, ist ein Kopieren der Angaben hintereinander in eine gemeinsame known_host notwendig.
Das eigentliche Credential für die Pumpe ist der private SSH-Schlüssel. Den erzeugt man zum Beispiel mit
ssh-keygen -N '' -f id_rsa
Der Befehl erzeugt zwei Dateien, id_rsa.pub und id_rsa. Die erste ist der öffentliche Schlüssel, den kopiert man einmalig in die UI des Git-Servers. Die Datei braucht weder Schutz noch Geheimhaltung. Die Pumpe selbst benötigt diese Datei übrigens nicht.
Der Inhalt von id_rsa ist dagegen zu schützen. Wer den Inhalt kennt, der kann auf die entsprechenden Repos auf dem Git-Server zugreifen (also genau das, was die Pumpe tun soll). Den Inhalt konfiguriert man in die UI des CI/CD-Umgebung als "Geheimnis", das während eines Laufes der Pumpe als Umgebungsvariable zur Verfügung steht, zum Beispiel GIT_PRIVATE_KEY.
Damit die Pumpe den Key nutzen kann, läuft am Anfang des Jobs:
mkdir -p "$HOME/.ssh"
touch "$HOME/.ssh/id_rsa"
chmod go-rwx "$HOME/.ssh" "$HOME/.ssh/id_rsa"
echo "$GIT_PRIVATE_KEY" > "$HOME/.ssh/id_rsa"
Die Datei known_hosts gehört ins selbe Verzeichnis $HOME/.ssh.
HTTPS Credentials
Beim Zugriff auf ein Git-Repository via HTTPS kommt man mit klassischen User- und Passwort-Angaben weiter. Die stellt ein Skript zur Verfügung, das auf den Aufruf mit dem Parameter get wartet und dann zwei Zeilen Output erzeugt: eine mit der User-Angabe, eine für das Passwort. Das Skript kann so aussehen:
#!/bin/bash
if test "$1" = get
then
echo username="${GIT_USER}"
echo password="${GIT_PASSWD}"
fi
Die nötigen Angaben konfiguriert man in der jeweiligen CI/CD-Umgebung als Geheimnisse, die in den Umgebungsvariablen GIT_USER und GIT_PASSWD zur Verfügung stehen.
Das Skript stellt man für die Pumpe zur Verfügung, im Beispiel eines Docker-Container möglicherweise als /usr/local/bin/gitcreds. Es wird für Zugriffe auf einen Git-Server https://git.example.org durch folgenden Befehl aktiviert:
git config --global credential.https://git.example.org.helper /usr/local/bin/gitcreds
Wenn man Credentials für mehrere Server braucht, arbeitet man möglicherweise mit mehreren Instanzen wie gitcreds1 und gitcreds2. Alternativ genügt ein einzelnes Skript. Indem es STDIN auswertet, kann es erfahren, welche Credentials gerade notwendig sind. Einzelheiten hierzu finden sich in der Git-Dokumentation. Der passende Einstieg ist die Beschreibung von "Credential Helpers" in API-Credentials.
Fazit
Git bietet vielfache Möglichkeiten abseits des allgemein Üblichen. Wenn man sie kennt, kann man sich immer mal wieder das Leben erleichtern. Obendrein hilft die Kenntnis der Möglichkeiten, besser zu durchschauen, was man beim Üblichen eigentlich tut. Das verschafft das angenehme Gefühl, bei der Nutzung von Git mehr "Wasser unter dem Kiel" zu haben.
Dr. Andreas Krüger
arbeitet als Senior Consultant bei INNOQ. Er übernimmt Verantwortung für Infrastruktur wie Cloud-Umgebungen oder Build- und Deployment Pipelines, plant, verhandelt, dokumentiert und implementiert Schnittstellen, Algorithmen und Anwendungen sowie entwirft tragfähige Software-Architektur.
(bbo)