Kapitel ▾ 2. Auflage

8.4 Git anpassen - Ein Beispiel für eine Git-erzwungene Richtlinie

Ein Beispiel für eine Git-erzwungene Richtlinie

In diesem Abschnitt verwenden Sie Ihr Wissen, um einen Git-Workflow einzurichten, der ein benutzerdefiniertes Commit-Nachrichtenformat prüft und nur bestimmten Benutzern erlaubt, bestimmte Unterverzeichnisse in einem Projekt zu ändern. Sie erstellen Client-Skripte, die dem Entwickler helfen zu wissen, ob sein Push abgelehnt wird, und Server-Skripte, die die Richtlinien tatsächlich durchsetzen.

Die Skripte, die wir zeigen, sind in Ruby geschrieben; teilweise aufgrund unserer intellektuellen Trägheit, aber auch, weil Ruby leicht zu lesen ist, auch wenn man es nicht unbedingt schreiben kann. Jede Sprache funktioniert jedoch – alle Beispiel-Hook-Skripte, die mit Git verteilt werden, sind entweder in Perl oder Bash geschrieben, sodass Sie auch viele Beispiele für Hooks in diesen Sprachen finden können, wenn Sie sich die Beispiele ansehen.

Server-seitiger Hook

Die gesamte serverseitige Arbeit wird in die Datei update im Verzeichnis hooks eingefügt. Der update-Hook wird einmal pro gepushtem Branch ausgeführt und nimmt drei Argumente entgegen

  • Der Name der Referenz, zu der gepusht wird

  • Die alte Revision, an der dieser Branch war

  • Die neue Revision, die gepusht wird

Sie haben auch Zugriff auf den Benutzer, der den Push durchführt, wenn der Push über SSH läuft. Wenn Sie allen erlaubt haben, sich mit einem einzigen Benutzer (wie "git") über Public-Key-Authentifizierung zu verbinden, müssen Sie diesem Benutzer möglicherweise eine Shell-Wrapper geben, die bestimmt, welcher Benutzer sich basierend auf dem öffentlichen Schlüssel verbindet, und eine Umgebungsvariable entsprechend setzt. Hier gehen wir davon aus, dass der verbindende Benutzer in der Umgebungsvariable $USER ist, sodass Ihr Update-Skript damit beginnt, alle benötigten Informationen zu sammeln

#!/usr/bin/env ruby

$refname = ARGV[0]
$oldrev  = ARGV[1]
$newrev  = ARGV[2]
$user    = ENV['USER']

puts "Enforcing Policies..."
puts "(#{$refname}) (#{$oldrev[0,6]}) (#{$newrev[0,6]})"

Ja, das sind globale Variablen. Urteilen Sie nicht – so ist es einfacher zu demonstrieren.

Erzwingen eines bestimmten Commit-Nachrichtenformats

Ihre erste Herausforderung besteht darin, sicherzustellen, dass jede Commit-Nachricht einem bestimmten Format entspricht. Nehmen wir einfach als Ziel an, dass jede Nachricht die Zeichenfolge "ref: 1234" enthalten muss, da Sie möchten, dass jeder Commit mit einem Arbeitselement in Ihrem Ticketsystem verknüpft ist. Sie müssen jeden Commit untersuchen, der hochgeladen wird, prüfen, ob diese Zeichenfolge in der Commit-Nachricht vorhanden ist, und, wenn die Zeichenfolge bei einem der Commits fehlt, mit einem Wert ungleich Null beenden, damit der Push abgelehnt wird.

Sie können eine Liste der SHA-1-Werte aller Commits, die gepusht werden, abrufen, indem Sie die Werte $newrev und $oldrev nehmen und sie an ein Git-Kommando namens git rev-list übergeben. Dies ist im Grunde das git log-Kommando, aber standardmäßig gibt es nur die SHA-1-Werte und keine weiteren Informationen aus. Um eine Liste aller Commit-SHA-1s zu erhalten, die zwischen einem Commit-SHA-1 und einem anderen eingeführt wurden, können Sie etwas wie folgt ausführen

$ git rev-list 538c33..d14fc7
d14fc7c847ab946ec39590d87783c69b031bdfb7
9f585da4401b0a3999e84113824d15245c13f0be
234071a1be950e2a8d078e6141f5cd20c1e61ad3
dfa04c9ef3d5197182f13fb5b9b1fb7717d2222a
17716ec0f1ff5c77eff40b7fe912f9f6cfd0e475

Sie können diese Ausgabe nehmen, jeden dieser Commit-SHA-1s durchlaufen, die Nachricht dafür abrufen und diese Nachricht gegen einen regulären Ausdruck testen, der nach einem Muster sucht.

Sie müssen herausfinden, wie Sie die Commit-Nachricht von jedem dieser Commits erhalten, um sie zu testen. Um die Rohdaten des Commits zu erhalten, können Sie ein weiteres Kommando namens git cat-file verwenden. Wir werden all diese Kommandozeilenbefehle im Detail in Git Internals besprechen; aber vorerst hier ist, was dieser Befehl Ihnen gibt

$ git cat-file commit ca82a6
tree cfda3bf379e4f8dba8717dee55aab78aef7f4daf
parent 085bb3bcb608e1e8451d4b2432f8ecbe6306e7e7
author Scott Chacon <schacon@gmail.com> 1205815931 -0700
committer Scott Chacon <schacon@gmail.com> 1240030591 -0700

Change the version number

Eine einfache Möglichkeit, die Commit-Nachricht eines Commits zu erhalten, wenn Sie den SHA-1-Wert haben, besteht darin, zur ersten Leerzeile zu gehen und alles danach zu nehmen. Sie können dies auf Unix-Systemen mit dem Befehl sed tun

$ git cat-file commit ca82a6 | sed '1,/^$/d'
Change the version number

Sie können diese Anrufung verwenden, um die Commit-Nachricht von jedem Commit abzurufen, der versucht wird zu pushen, und beenden, wenn Sie etwas sehen, das nicht übereinstimmt. Um das Skript zu beenden und den Push abzulehnen, beenden Sie mit einem Wert ungleich Null. Die gesamte Methode sieht so aus

$regex = /\[ref: (\d+)\]/

# enforced custom commit message format
def check_message_format
  missed_revs = `git rev-list #{$oldrev}..#{$newrev}`.split("\n")
  missed_revs.each do |rev|
    message = `git cat-file commit #{rev} | sed '1,/^$/d'`
    if !$regex.match(message)
      puts "[POLICY] Your message is not formatted correctly"
      exit 1
    end
  end
end
check_message_format

Wenn Sie dies in Ihr update-Skript einfügen, werden Updates abgelehnt, die Commits enthalten, deren Nachrichten nicht Ihrer Regel entsprechen.

Erzwingen eines benutzerbasierten ACL-Systems

Angenommen, Sie möchten einen Mechanismus hinzufügen, der eine Zugriffskontrollliste (ACL) verwendet, die festlegt, welche Benutzer Änderungen an welchen Teilen Ihrer Projekte pushen dürfen. Einige Personen haben vollen Zugriff, andere können nur Änderungen an bestimmten Unterverzeichnissen oder spezifischen Dateien pushen. Um dies zu erzwingen, schreiben Sie diese Regeln in eine Datei namens acl, die sich in Ihrem Bare-Git-Repository auf dem Server befindet. Sie lassen den update-Hook diese Regeln überprüfen, sehen, welche Dateien für alle gepushten Commits eingeführt werden, und ermitteln, ob der Benutzer, der den Push durchführt, Zugriff hat, um all diese Dateien zu aktualisieren.

Das erste, was Sie tun werden, ist, Ihre ACL zu schreiben. Hier verwenden Sie ein Format, das dem CVS-ACL-Mechanismus sehr ähnlich ist: Es verwendet eine Reihe von Zeilen, wobei das erste Feld avail oder unavail ist, das nächste Feld eine durch Kommas getrennte Liste von Benutzern ist, auf die sich die Regel bezieht, und das letzte Feld der Pfad ist, auf den sich die Regel bezieht (leer bedeutet offener Zugriff). All diese Felder sind durch ein Pipe-Zeichen (|) getrennt.

In diesem Fall haben Sie ein paar Administratoren, einige Dokumentationsautoren mit Zugriff auf das Verzeichnis doc und einen Entwickler, der nur Zugriff auf die Verzeichnisse lib und tests hat, und Ihre ACL-Datei sieht so aus

avail|nickh,pjhyett,defunkt,tpw
avail|usinclair,cdickens,ebronte|doc
avail|schacon|lib
avail|schacon|tests

Sie beginnen damit, diese Daten in eine Struktur zu lesen, die Sie verwenden können. In diesem Fall, um das Beispiel einfach zu halten, erzwingen Sie nur die avail-Direktiven. Hier ist eine Methode, die Ihnen ein assoziatives Array gibt, bei dem der Schlüssel der Benutzername und der Wert ein Array von Pfaden ist, zu denen der Benutzer Schreibzugriff hat

def get_acl_access_data(acl_file)
  # read in ACL data
  acl_file = File.read(acl_file).split("\n").reject { |line| line == '' }
  access = {}
  acl_file.each do |line|
    avail, users, path = line.split('|')
    next unless avail == 'avail'
    users.split(',').each do |user|
      access[user] ||= []
      access[user] << path
    end
  end
  access
end

Auf die zuvor betrachtete ACL-Datei gibt diese Methode get_acl_access_data eine Datenstruktur zurück, die so aussieht

{"defunkt"=>[nil],
 "tpw"=>[nil],
 "nickh"=>[nil],
 "pjhyett"=>[nil],
 "schacon"=>["lib", "tests"],
 "cdickens"=>["doc"],
 "usinclair"=>["doc"],
 "ebronte"=>["doc"]}

Nachdem Sie die Berechtigungen sortiert haben, müssen Sie ermitteln, welche Pfade die gepushten Commits geändert haben, damit Sie sicherstellen können, dass der Benutzer, der pusht, Zugriff auf alle davon hat.

Sie können ziemlich einfach sehen, welche Dateien in einem einzelnen Commit mit der Option --name-only für den Befehl git log (kurz erwähnt in Git Basics) geändert wurden.

$ git log -1 --name-only --pretty=format:'' 9f585d

README
lib/test.rb

Wenn Sie die ACL-Struktur verwenden, die von der Methode get_acl_access_data zurückgegeben wird, und diese gegen die aufgeführten Dateien in jedem der Commits prüfen, können Sie feststellen, ob der Benutzer Zugriff hat, um all seine Commits zu pushen

# only allows certain users to modify certain subdirectories in a project
def check_directory_perms
  access = get_acl_access_data('acl')

  # see if anyone is trying to push something they can't
  new_commits = `git rev-list #{$oldrev}..#{$newrev}`.split("\n")
  new_commits.each do |rev|
    files_modified = `git log -1 --name-only --pretty=format:'' #{rev}`.split("\n")
    files_modified.each do |path|
      next if path.size == 0
      has_file_access = false
      access[$user].each do |access_path|
        if !access_path  # user has access to everything
           || (path.start_with? access_path) # access to this path
          has_file_access = true
        end
      end
      if !has_file_access
        puts "[POLICY] You do not have access to push to #{path}"
        exit 1
      end
    end
  end
end

check_directory_perms

Sie erhalten eine Liste neuer Commits, die auf Ihren Server gepusht werden, mit git rev-list. Dann finden Sie für jeden dieser Commits, welche Dateien geändert wurden, und stellen sicher, dass der Benutzer, der pusht, Zugriff auf alle geänderten Pfade hat.

Jetzt können Ihre Benutzer keine Commits mit falsch formatierten Nachrichten oder mit geänderten Dateien außerhalb ihrer zugewiesenen Pfade pushen.

Testen

Wenn Sie chmod u+x .git/hooks/update ausführen, was die Datei ist, in die Sie all diesen Code eingefügt haben sollten, und dann versuchen, einen Commit mit einer nicht konformen Nachricht zu pushen, erhalten Sie etwas wie das hier

$ git push -f origin master
Counting objects: 5, done.
Compressing objects: 100% (3/3), done.
Writing objects: 100% (3/3), 323 bytes, done.
Total 3 (delta 1), reused 0 (delta 0)
Unpacking objects: 100% (3/3), done.
Enforcing Policies...
(refs/heads/master) (8338c5) (c5b616)
[POLICY] Your message is not formatted correctly
error: hooks/update exited with error code 1
error: hook declined to update refs/heads/master
To git@gitserver:project.git
 ! [remote rejected] master -> master (hook declined)
error: failed to push some refs to 'git@gitserver:project.git'

Hier gibt es ein paar interessante Dinge. Erstens sehen Sie hier, wo der Hook zu laufen beginnt.

Enforcing Policies...
(refs/heads/master) (fb8c72) (c56860)

Denken Sie daran, dass Sie das ganz am Anfang Ihres Update-Skripts ausgedruckt haben. Alles, was Ihr Skript nach stdout ausgibt, wird an den Client übertragen.

Das Nächste, was Sie bemerken werden, ist die Fehlermeldung.

[POLICY] Your message is not formatted correctly
error: hooks/update exited with error code 1
error: hook declined to update refs/heads/master

Die erste Zeile wurde von Ihnen ausgegeben, die beiden anderen sind Git-Meldungen, die Ihnen mitteilen, dass das Update-Skript mit einem Wert ungleich Null beendet wurde und dies Ihren Push ablehnt. Zuletzt haben Sie hier

To git@gitserver:project.git
 ! [remote rejected] master -> master (hook declined)
error: failed to push some refs to 'git@gitserver:project.git'

Sie sehen eine "remote rejected"-Meldung für jede Referenz, die Ihr Hook abgelehnt hat, und es wird Ihnen mitgeteilt, dass sie speziell aufgrund eines Hook-Fehlers abgelehnt wurde.

Darüber hinaus wird jemand, der versucht, eine Datei zu bearbeiten, auf die er keinen Zugriff hat, und einen Commit enthält, der sie enthält, etwas Ähnliches sehen. Wenn beispielsweise ein Dokumentationsautor versucht, einen Commit zu pushen, der etwas im Verzeichnis lib ändert, sieht er

[POLICY] You do not have access to push to lib/test.rb

Von nun an, solange das update-Skript vorhanden und ausführbar ist, wird Ihr Repository niemals einen Commit ohne Ihr Muster darin haben, und Ihre Benutzer werden isoliert sein.

Client-seitige Hooks

Der Nachteil dieses Ansatzes ist das Jammern, das unweigerlich entstehen wird, wenn die Commits Ihrer Benutzer abgelehnt werden. Ihre sorgfältig ausgearbeitete Arbeit wird im letzten Moment abgelehnt, was extrem frustrierend und verwirrend sein kann; außerdem müssen sie ihre Historie bearbeiten, um dies zu korrigieren, was nicht immer für schwache Nerven ist.

Die Antwort auf dieses Dilemma ist, einige clientseitige Hooks bereitzustellen, die Benutzer ausführen können, um sie zu benachrichtigen, wenn sie etwas tun, das der Server wahrscheinlich ablehnen wird. Auf diese Weise können sie Probleme korrigieren, bevor sie committen und bevor diese Probleme schwieriger zu beheben sind. Da Hooks nicht mit einem Klon eines Projekts übertragen werden, müssen Sie diese Skripte auf andere Weise verteilen und Ihre Benutzer sie dann in ihr .git/hooks-Verzeichnis kopieren und ausführbar machen lassen. Sie können diese Hooks innerhalb des Projekts oder in einem separaten Projekt verteilen, aber Git richtet sie nicht automatisch ein.

Zunächst sollten Sie Ihre Commit-Nachricht unmittelbar vor jeder Aufnahme eines Commits überprüfen, damit Sie wissen, dass der Server Ihre Änderungen aufgrund falsch formatierter Commit-Nachrichten nicht ablehnen wird. Dazu können Sie den commit-msg-Hook hinzufügen. Wenn Sie die Nachricht aus der Datei lesen lassen, die als erstes Argument übergeben wird, und diese mit dem Muster vergleichen, können Sie Git zwingen, den Commit abzubrechen, wenn keine Übereinstimmung vorhanden ist.

#!/usr/bin/env ruby
message_file = ARGV[0]
message = File.read(message_file)

$regex = /\[ref: (\d+)\]/

if !$regex.match(message)
  puts "[POLICY] Your message is not formatted correctly"
  exit 1
end

Wenn dieses Skript vorhanden ist (in .git/hooks/commit-msg) und ausführbar ist und Sie mit einer nicht ordnungsgemäß formatierten Nachricht committen, sehen Sie Folgendes

$ git commit -am 'Test'
[POLICY] Your message is not formatted correctly

In diesem Fall wurde kein Commit abgeschlossen. Wenn Ihre Nachricht jedoch das richtige Muster enthält, erlaubt Git Ihnen, zu committen

$ git commit -am 'Test [ref: 132]'
[master e05c914] Test [ref: 132]
 1 file changed, 1 insertions(+), 0 deletions(-)

Als Nächstes möchten Sie sicherstellen, dass Sie keine Dateien ändern, die außerhalb Ihres ACL-Geltungsbereichs liegen. Wenn das .git-Verzeichnis Ihres Projekts eine Kopie der von Ihnen zuvor verwendeten ACL-Datei enthält, erzwingt das folgende pre-commit-Skript diese Einschränkungen für Sie

#!/usr/bin/env ruby

$user    = ENV['USER']

# [ insert acl_access_data method from above ]

# only allows certain users to modify certain subdirectories in a project
def check_directory_perms
  access = get_acl_access_data('.git/acl')

  files_modified = `git diff-index --cached --name-only HEAD`.split("\n")
  files_modified.each do |path|
    next if path.size == 0
    has_file_access = false
    access[$user].each do |access_path|
    if !access_path || (path.index(access_path) == 0)
      has_file_access = true
    end
    if !has_file_access
      puts "[POLICY] You do not have access to push to #{path}"
      exit 1
    end
  end
end

check_directory_perms

Dies ist ungefähr dasselbe Skript wie der serverseitige Teil, jedoch mit zwei wichtigen Unterschieden. Erstens befindet sich die ACL-Datei an einem anderen Ort, da dieses Skript aus Ihrem Arbeitsverzeichnis und nicht aus Ihrem .git-Verzeichnis ausgeführt wird. Sie müssen den Pfad zur ACL-Datei von diesem ändern

access = get_acl_access_data('acl')

zu diesem

access = get_acl_access_data('.git/acl')

Der andere wichtige Unterschied ist die Art und Weise, wie Sie eine Liste der geänderten Dateien erhalten. Da die serverseitige Methode die Protokolle der Commits betrachtet und zum jetzigen Zeitpunkt der Commit noch nicht aufgezeichnet wurde, müssen Sie Ihre Dateiliste stattdessen aus dem Staging-Bereich abrufen. Anstatt

files_modified = `git log -1 --name-only --pretty=format:'' #{ref}`

müssen Sie verwenden

files_modified = `git diff-index --cached --name-only HEAD`

Aber das sind die einzigen beiden Unterschiede – ansonsten funktioniert das Skript genauso. Eine Einschränkung ist, dass es erwartet, dass Sie lokal als derselbe Benutzer ausgeführt werden, als den Sie auf die Remote-Maschine pushen. Wenn dies anders ist, müssen Sie die Variable $user manuell festlegen.

Eine weitere Sache, die wir hier tun können, ist sicherzustellen, dass der Benutzer keine nicht-fast-forwarded Referenzen pusht. Um eine Referenz zu erhalten, die kein Fast-Forward ist, müssen Sie entweder durch einen Commit rebasen, den Sie bereits hochgeladen haben, oder versuchen, einen anderen lokalen Branch auf denselben Remote-Branch hochzuladen.

Vermutlich ist der Server bereits mit receive.denyDeletes und receive.denyNonFastForwards konfiguriert, um diese Richtlinie zu erzwingen. Das einzige versehentliche, das Sie versuchen können, abzufangen, ist das Rebasen von Commits, die bereits hochgeladen wurden.

Hier ist ein Beispiel für ein pre-rebase-Skript, das dies prüft. Es ruft eine Liste aller Commits ab, die Sie gerade umschreiben werden, und prüft, ob diese in einer Ihrer Remote-Referenzen vorhanden sind. Wenn es einen sieht, der von einer Ihrer Remote-Referenzen erreichbar ist, bricht es das Rebase ab.

#!/usr/bin/env ruby

base_branch = ARGV[0]
if ARGV[1]
  topic_branch = ARGV[1]
else
  topic_branch = "HEAD"
end

target_shas = `git rev-list #{base_branch}..#{topic_branch}`.split("\n")
remote_refs = `git branch -r`.split("\n").map { |r| r.strip }

target_shas.each do |sha|
  remote_refs.each do |remote_ref|
    shas_pushed = `git rev-list ^#{sha}^@ refs/remotes/#{remote_ref}`
    if shas_pushed.split("\n").include?(sha)
      puts "[POLICY] Commit #{sha} has already been pushed to #{remote_ref}"
      exit 1
    end
  end
end

Dieses Skript verwendet eine Syntax, die in Revision Selection nicht behandelt wurde. Sie erhalten eine Liste der Commits, die bereits hochgeladen wurden, indem Sie dies ausführen

`git rev-list ^#{sha}^@ refs/remotes/#{remote_ref}`

Die Syntax SHA^@ wird zu allen Eltern des Commits aufgelöst. Sie suchen nach jedem Commit, der vom letzten Commit auf dem Remote erreichbar ist und der nicht von einem Elternteil eines der SHA-1s erreichbar ist, die Sie hochladen möchten – das bedeutet, es ist ein Fast-Forward.

Der Hauptnachteil dieses Ansatzes ist, dass er sehr langsam sein kann und oft unnötig ist – wenn Sie nicht versuchen, den Push mit -f zu erzwingen, wird der Server Sie warnen und den Push nicht akzeptieren. Es ist jedoch eine interessante Übung und kann theoretisch dazu beitragen, ein Rebase zu vermeiden, das Sie später möglicherweise rückgängig machen müssen.