Git
Chapters ▾ 2nd Edition

8.4 Git’i Özelleştirmek - Bir Örnek: Mecburi Git Politikası

Bir Örnek: Mecburi Git Politikası

Bu bölümde, öğrendiklerinizi kullanarak, özel bir katkı mesaj biçimini kontrol eden ve belirli kullanıcıların bir projedeki belirli alt dizinleri değiştirmesine izin veren bir Git iş akışı oluşturacaksınız. Geliştiricinin itmesinin reddedilip reddedilmeyeceğini bilmesine yardımcı olan istemci betikleri oluşturacak ve politikaları uygulayan sunucu betikleri oluşturacaksınız.

Göstereceğimiz betikler (kısmen zihinsel ataletimizden dolayı) Ruby dilinde yazılmıştır, ancak bir diğer sebebi de yazamıyor olsanız bile, Ruby’nin okunması kolay bir dil olmasıdır. Ancak herhangi bir dil işe yarayacaktır (Git ile birlikte dağıtılan tüm örnek kanca betikleri Perl veya Bash’te yazılmıştır ve örnekleri inceleyerek, bu dillerdeki kancaları görebilirsiniz).

Sunucu Tarafı Kancası

Tüm sunucu tarafı işlemleriniz, hooks dizininizdeki update dosyasına gidecektir. update kancası, itilen her dal için bir kez çalışır ve üç argüman alır:

  • İtinilen referansın adı

  • O dalın eski sürümü

  • İtilen yeni sürüm

Ayrıca, itmenin SSH üzerinden gerçekleştirilip gerçekleştirilmediğine bağlı olarak iten kullanıcıya da erişiminiz vardır. Herkesin tek bir kullanıcıyla (örneğin, git) genel anahtar kimlik doğrulaması yoluyla bağlanmasına izin verdiyseniz, bu kullanıcıya, hangi kullanıcının genel anahtara dayanarak bağlandığını belirleyen ve buna göre bir ortam değişkeni ayarlayan bir kabuk sarmalayıcı (shell wrapper) vermeniz gerekebilir. Burada, bağlantı kuran kullanıcının $USER ortam değişkeninde olduğunu varsayacağız, bu nedenle güncelleme betiğiniz ihtiyacınız olan tüm bilgileri toplayarak işe başlar:

#!/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]})"

Evet bunlar global değişkenler. Bizi yargılamayın; bu şekilde göstermek daha kolay.

Belirli Bir Katkı Mesajı Formatını Zorunlu Hale Getirme

Zorlanacağınız ilk konu, her katkı mesajının belirli bir formata uygun olmasını sağlamaktır. Sırf bir hedefiniz olsun diye, diyelim ki her katkının iş takip sisteminizde bir iş ögesine bağlanmasını istediğiniz için, her katkı mesajının ref: 1234 gibi görünen bir dize içermesi şartını koydunuz. Her itilen katkı mesajında bu dizenin olup olmadığını kontrol etmeli ve eğer dize herhangi bir katkıda yoksa, itmenin reddedilmesi için çıkış yapmalısınız.

$newrev ve $oldrev değerlerini alıp bunları git rev-list adlı bir Git tesisat komutuna ileterek itilen tüm katkıların SHA-1 değerlerinin bir listesini alabilirsiniz. Bu, temelde git log komutudur; ancak varsayılan olarak SHA-1 değerleri dışında hiçbir bilgi yazdırmaz. Bu nedenle, bir katkı SHA-1 değeri ile diğer bir katkı SHA-1 değeri arasında tanıtılan tüm katkı SHA-1’lerinin bir listesini almak için şöyle bir şey çalıştırabilirsiniz:

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

Bu çıktıyı alıp, her bir katkı SHA-1’i üzerinden dönebilir, onun için mesajı alabilir ve bir model arayan bir düzenli ifadeye karşı test edebilirsiniz.

Her bir katkı mesajını test etmek için bu katkılardan nasıl katkı mesajını alacağınızı bulmanız gerekmektedir. Ham katkı verilerini almak için, git cat-file adlı başka bir tesisat komutunu kullanabilirsiniz. Bu tesisat komutlarını detaylı olarak Dahili Git Ögeleri bölümünde ele alacağız; ancak şu anda, bu komutun size ne verdiğini aşağıda gösteriyoruz:

$ 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

changed the version number

Bir katkının SHA-1 değerine sahipken katkı mesajını almanın basit bir yolu, ilk boş satıra gitmek ve o satırdan sonrasını almaktır. Bunu Unix sistemlerinde sed komutuyla yapabilirsiniz:

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

incantation’ı kullanarak itilmeye çalışılan her katkının mesajını alabilir ve eşleşmeyen herhangi bir şey gördüğünüzde çıkabilirsiniz. İtmeyi reddederek betiği sonlandırmak için sıfırsız (non-zero) çıkış yapın. Yöntemin tamamı şöyle görünüyor:

$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

Bunu update betiğinize koyarak, kuralınıza uymayan mesajları içeren katkıların reddedilmesini sağlayabilirsiniz.

Kullanıcı Tabanlı bir ACL Sisteminin Uygulanması

Kullanıcıların, hangi projelerin, hangi bölümlerine değişiklik yapmalarına izin verildiğini belirten bir erişim kontrol listesi (ACL) mekanizması eklemek istediğinizi varsayalım. Bazı kullanıcılar tam erişime sahipken, diğerleri yalnızca belirli alt dizin veya dosyalarad değişiklik yapabilirler. Bu kısıtlamaları uygulamak için, bu kuralları sunucudaki çıplak Git reponuzda bulunan bir acl dosyasına yazacaksınız. update kancası bu kurallara bakacaktır: itilen tüm katkılar için tanıtılan dosyaları görecek ve itme işlemini gerçekleştiren kullanıcının tüm bu dosyalara erişiminin olup olmadığını belirleyecektir.

İlk yapmanız gereken şey ACL’nizi yazmaktır. Burada, CVS ACL mekanizmasıyla oldukça benzer bir biçim kullanacaksınız: İlk alan avail veya unavail; bir sonraki alan, kuralın uygulandığı kullanıcıların virgülle ayrılmış bir listesi, ve son alan ise kuralın uygulandığı dizin (boşluk karakteri açık erişim anlamına gelir) şeklinde bir dizi satır kullanılır. Tüm bu alanlar bir boru (|) karakteri ile ayrılmıştır.

Senaryomuzda, birkaç yönetici, doc dizinine erişimi olan bazı belge yazarları ve yalnızca lib ve tests dizinlerine erişimi olan bir geliştiriciniz var ve ACL dosyanız şöyle görünüyor:

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

Bu veriyi, onu kullanabileceğiniz bir yapıya okuyarak başlıyorsunuz. Bu örneği basit tutmak için sadece avail direktiflerini uygulayacaksınız. İşte, kullanıcı adının anahtar olduğu ve kullanıcının yazma erişimine sahip olduğu yol dizinini içeren ilişkisel bir dizi (array) veren bir yöntem:

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

Önceki incelediğiniz ACL dosyasına göre, bu get_acl_access_data yöntemi aşağıdaki gibi bir veri yapısı döndürür:

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

İzinleri düzenledikten sonra, itilen katkıların değiştirdiği yolları belirlemeniz gerekiyor. Böylece iten kullanıcının hepsine erişimi olduğundan emin olabilirsiniz.

git log komutuna --name-only seçeneğini ekleyerek, tek bir katkıda hangi dosyaların değiştirildiğini oldukça kolayca görebilirsiniz (Git Temelleri bölümünde bundan kısaca bahsedilmektedir):

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

README
lib/test.rb

get_acl_access_data yönteminden dönen ACL yapısını kullanarak ve her bir katkıda listelenen dosyaları bu yapıyla karşılaştırarak, kullanıcının tüm katkılarını itmek için erişime sahip olup olmadığını belirleyebilirsiniz:

# 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

Sunucunuza itilen yeni katkıların bir listesini git rev-list kullanarak alırsınız. Ardından, bu katkıların her biri için değiştirilen dosyaları bulur ve iten kullanıcının değiştirilen tüm yollara erişimi olduğundan emin olursunuz.

Artık kullanıcılarınız, kötü biçimlendirilmiş mesajlara veya belirlenmiş yolların dışında değiştirilmiş dosyalara sahip katkıları itemezler.

Deneme

chmod u+x .git/hooks/update komutunu çalıştırırsanız, bu kodları yerleştirmeniz gereken dosya olan .git/hooks/update dosyasına erişim izni verirsiniz. Ardından, uyumsuz bir mesajla bir katkı itmeye çalıştığınızda, aşağıdakine benzer bir çıktı alırsınız:

$ 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'

Burada birkaç ilginç şey var. Öncelikle kancanın çalışmaya başladığı yeri görüyorsunuz.

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

Bunu güncelleme komut dosyanızın en başında yazdırdığınızı unutmayın. Komut dosyanızın stdout 'a yansıttığı her şey istemciye aktarılacaktır.

Bir sonraki fark edeceğiniz şey hata mesajıdır.

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

İlk satır sizin tarafınızdan yazdırıldı, diğer ikisi Git’in size güncelleme komut dosyasının sıfırsız (non-zero) bir durumdan çıktığını ve gönderiminizi reddeden şeyin bu olduğunu söylemesiydi. Son olarak şunu görürsünüz:

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

Her reddedilen referans için bir "uzak repo reddedildi" mesajı göreceksiniz ve bu mesaj kancanın başarısız olması nedeniyle özellikle reddedildiğini size bildirir.

Dahası, birinin erişim izni olmayan bir dosyayı düzenlemeye ve içeren bir katkıyı itmeye çalıştığında, benzer bir şey görürler. Örneğin, bir belge yazarı, lib dizininde bir şeyi değiştiren bir katkıyı itmeye çalışırsa, aşağıdakine benzer bir çıktı alır:

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

Artık o update betiği orada ve çalıştırılabilir olduğu sürece, repo hiçbir zaman istediğiniz biçimde olmayan bir katkı mesajı içermeyecek ve kullanıcılarınız izole edilecekler.

İstemci Tarafı Kancaları

Bu yaklaşımın dezavantajı, kullanıcıların katkı itmelerinin reddedilmesiyle kaçınılmaz olarak ortaya çıkacak olan şikayetlerdir. Dikkatle hazırladıkları çalışmalarının son anda reddedilmesi, son derece sinir bozucu ve kafa karıştırıcı olabilir. Ayrıca, bunu düzeltmek için geçmişlerini düzenlemeleri gerekeceğinden, uygulaması her zaman kolay değildir.

Bu ikileme çözüm, kullanıcıların sunucunun muhtemelen reddedeceği bir şey yaptıklarında onları bilgilendirmek için çalıştırabilecekleri bazı istemci tarafı kancaları sağlamaktır. Böylece, katkı işlemeden önce ve bu sorunlar daha zor çözülebilir hale gelmeden önce o sorunu düzeltebilirler. Kancalar bir projenin kopyasıyla aktarılmadığından, bu betikleri başka bir şekilde dağıtmanız ve ardından kullanıcılarınızın bunları .git/hooks dizinlerine kopyalamalarını ve çalıştırılabilir hale getirmelerini sağlamanız gerekir. Bu kancaları projenin içinde veya ayrı bir projede dağıtabilirsiniz, ancak Git bunları otomatik olarak kurmaz.

Öncelikle, her katkı kaydedilmeden hemen önce katkı mesajınızı kontrol etmelisiniz, böylece sunucunun kötü biçimlendirilmiş katkı mesajları nedeniyle değişikliklerinizi reddetmeyeceğini bilirsiniz. Bunu yapmak için, commit-msg kancasını ekleyebilirsiniz. İlk argüman olarak geçirilen dosyadan mesajı okuyup, bunu modele karşılaştırırsanız; eşleşme yoksa Git’i katkıyı iptal etmeye zorlayabilirsiniz:

#!/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

Eğer o betik (.git/hooks/commit-msg içinde) yerinde ve çalıştırılabilir durumdaysa ve düzgün biçimlendirilmemiş mesajlı bir katkı işlerseniz, şunu görürsünüz:

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

Bu durumda hiçbir katkı tamamlanmaz. Ancak, mesajınız uygun modeli içeriyorsa, Git katkıyı işlemenize izin verir:

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

Sonraki adım olarak, ACL kapsamınız dışındaki dosyaları değiştirmediğinizden emin olmak isterseniz. Projenizin .git dizini, önceki kullandığınız ACL dosyasının bir kopyasını içeriyorsa, aşağıdaki pre-commit betiği bu kısıtlamaları sizin için uygulayacaktır:

#!/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

Bu betik, sunucu tarafı kısmıyla hemen hemen aynıdır, ancak iki önemli fark vardır: İlk olarak, ACL dosyası farklı bir yerde bulunur, çünkü bu betik .git dizininizden değil, çalışma dizininizden çalıştırılır. Bu nedenle ACL dosyasının yolunu değiştirmeniz gerekmektedir.

Bundan:

access = get_acl_access_data('acl')

şuna:

access = get_acl_access_data('.git/acl')

Diğer önemli fark, değiştirilen dosyaların bir listesini nasıl elde ettiğinizdir. Sunucu tarafı yöntemi, katkı günlüğüne bakar ama bu noktada katkı henüz kaydedilmemiş olduğundan, dosya listenizi izlem alanından almanız gerekir.

şunun yerine:

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

bunu kullanmalısınız:

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

Bu ikisi aradaki yegane farklılıklardır, bunlar dışında, betik aynı şekilde çalışır. Bir diğer önemli not: sizden yerel makinenizde çalışırken veya uzak makineye iterken aynı kullanıcı adıyla çalışmanız beklenir. Bu ikisi farklıysa, $user değişkenini manuel olarak ayarlamanız gerekir.

Burada yapabileceğimiz başka bir şey, kullanıcının ileri-sarmasız (non-fast-forwarded) referanslar itmemesini sağlamaktır. ileri-sarmasız bir referans almak için, zaten ittiğiniz bir katkıyı tekrar düzenlemek veya aynı uzak dal üzerine farklı bir yerel dalı itmek zorundasınız.

Otomatik olarak, sunucunun zaten bu politikayı uygulamak için receive.denyDeletes ve receive.denyNonFastForwards ile yapılandırıldığını varsayıyoruz, bu yüzden yakalamaya çalışabileceğiniz tek kaza, zaten itilmiş katkıları yeniden temellendirilmesidir.

İşte bu durumu kontrol eden bir yeniden temellendirme öncesi betiği örneği: Yeniden yazacağınız tüm katkıların bir listesini alır ve bunların herhangi birinin, herhangi bir uzak referansta var olup olmadığını kontrol eder. Bir uzak referanstan ulaşılabilir birini görürse, yeniden temellendirme işlemini iptal eder.

#!/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

Bu betik, Düzeltme Seçimi bölümünde ele alınmayan bir sözdizimi kullanır. Zaten yukarı itilmiş katkıları almak için şunu çalıştırarak bir liste alabilirsiniz:

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

SHA^@ sözdizimi, bu katkının tüm öncellerinde çözülür. Ulaşılabilir herhangi bir katkı arıyorsunuz ve bu katkı, itmeye çalıştığınız SHA-1’lerin herhangi birinin herhangi bir öncelinden ulaşılamadığı anlamına gelir. Yani ileri-sarma (fast-forward)'dır.

Bu yaklaşımın başlıca dezavantajı çok yavaş olabilmesi ve genellikle gereksiz olmasıdır. Zorla itme (forced push) işlemi yapmazsanız, sunucu sizi uyaracak ve itmeyi kabul etmeyecektir. Ancak, bu ilginç bir egzersizdir ve teorik olarak daha sonra geri dönüp düzeltmeniz gerekebilecek bir yeniden temellendirme işleminden kaçınmanıza yardımcı olabilir.

scroll-to-top