Chapters ▾ 2nd Edition

8.4 سفارشی‌سازی Git (Customizing Git) - یک نمونه سیاست اعمال شده توسط گیت (An Example Git-Enforced Policy)

یک نمونه سیاست اعمال شده توسط گیت (An Example Git-Enforced Policy)

در این بخش، از آنچه آموخته‌اید برای ایجاد یک گردش کار گیت استفاده خواهید کرد که قالب پیام‌های سفارشی را بررسی می‌کند و فقط به کاربران خاص اجازه می‌دهد زیرشاخه‌های خاصی را در یک پروژه تغییر دهند. شما اسکریپت‌های کلاینتی می‌سازید که به توسعه‌دهنده کمک می‌کند بفهمد آیا فشار او رد خواهد شد یا خیر، و اسکریپت‌های سروری که در واقع سیاست‌ها را اجرا می‌کنند.

اسکریپت‌هایی که نشان خواهیم داد به زبان روبی نوشته شده‌اند؛ تا حدی به دلیل اینرسی فکری ما، اما همچنین به این دلیل که روبی خواندنش آسان است، حتی اگر لزوماً نتوانید آن را بنویسید. با این حال، هر زبانی کار می‌کند – تمام اسکریپت‌های قلاب نمونه که با گیت توزیع می‌شوند یا در پرل یا در باش هستند، بنابراین با نگاهی به نمونه‌ها می‌توانید مثال‌های زیادی از قلاب‌ها را در این زبان‌ها نیز ببینید.

قلاب سمت سرور (Server-Side Hook)

تمام کارهای سمت سرور در فایل update در دایرکتوری hooks شما قرار خواهد گرفت. قلاب update یک بار برای هر شاخه‌ای که push می‌شود اجرا می‌شود و سه آرگومان می‌گیرد:

  • نام مرجعی که به آن فشار داده می‌شود

  • نسخه قدیمی که آن شاخه در آن قرار داشت

  • نسخه جدیدی که فشار داده می‌شود

اگر push از طریق SSH اجرا می‌شود، شما همچنین به کاربر انجام‌دهنده push دسترسی دارید. اگر به همه اجازه داده‌اید که از طریق احراز هویت با کلید عمومی به یک کاربر واحد (مانند git) متصل شوند، ممکن است مجبور شوید یک پوسته پوششی به آن کاربر بدهید که بر اساس کلید عمومی تعیین کند کدام کاربر در حال اتصال است و متغیر محیطی را بر این اساس تنظیم کند. در اینجا فرض می‌کنیم کاربر متصل در متغیر محیطی $USER قرار دارد، بنابراین اسکریپت به‌روزرسانی شما با جمع‌آوری تمام اطلاعات مورد نیازتان شروع می‌شود:

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

بله، آن‌ها متغیرهای سراسری هستند. قضاوت نکن – این‌طوری نشان دادنش راحت‌تر است.

اجبار به قالب خاص پیام کامیت (Enforcing a Specific Commit-Message Format)

اولین چالش شما این است که اطمینان حاصل کنید هر پیام کامیت از قالب خاصی پیروی می‌کند. فقط برای داشتن یک هدف، فرض کنید هر پیام باید شامل رشته‌ای باشد که شبیه “ref: 1234” است، زیرا می‌خواهید هر کامیت به یک مورد کاری در سیستم تیکتینگ شما پیوند داشته باشد. شما باید هر کامیت که آپلود می‌شود را بررسی کنید، ببینید آیا آن رشته در پیام کامیت وجود دارد یا خیر، و اگر رشته در هیچ یک از کامیت‌ها وجود نداشت، با کد غیرصفر خارج شوید تا آپلود رد شود.

شما می‌توانید با گرفتن مقادیر $newrev و $oldrev و پاس دادن آن‌ها به یک دستور لوله‌کشی گیت به نام git rev-list، فهرستی از مقادیر SHA-1 تمام کامیت‌هایی که در حال push شدن هستند را دریافت کنید. این اساساً دستور git log است، اما به طور پیش‌فرض فقط مقادیر SHA-1 را چاپ می‌کند و هیچ اطلاعات دیگری را نمایش نمی‌دهد. بنابراین، برای دریافت لیستی از تمام شناسه SHA-1 کامیت‌های معرفی شده بین یک شناسه SHA-1 کامیت و دیگری، می‌توانید چیزی شبیه این را اجرا کنید:

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

شما می‌توانید آن خروجی را بگیرید، روی هر یک از آن SHA-1های کامیت حلقه بزنید، پیام آن را بگیرید و آن پیام را در برابر یک عبارت منظم که به دنبال یک الگو می‌گردد، آزمایش کنید.

تو باید یاد بگیری که چطور پیام هر کدام از این کامیت‌ها را بگیری تا تست کنی. برای گرفتن داده‌ی خام کامیت، می‌توانی از یک دستور plumbing دیگر به نام git cat-file استفاده کنی. ما تمام این دستورهای plumbing را به‌طور کامل در (Git Internals) بررسی خواهیم کرد؛ اما فعلاً، این چیزی است که آن دستور به تو می‌دهد:

$ 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

یک راه ساده برای دریافت پیام commit از یک commit زمانی که مقدار SHA-1 را دارید، این است که به اولین خط خالی بروید و هر چیزی را که بعد از آن می‌آید بردارید. می‌توانید این کار را با دستور sed در سیستم‌های یونیکس انجام دهید:

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

می‌توانید از آن ورد برای گرفتن پیام commit از هر commit که در حال تلاش برای push شدن است استفاده کنید و اگر چیزی را مشاهده کردید که مطابقت نداشت، خارج شوید. برای خروج از اسکریپت و رد کردن پوش، با خروج غیرصفر خارج شوید. کل روش به این صورت است:

$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

قرار دادن این در اسکریپت update شما، به‌روزرسانی‌هایی را که شامل کامیت‌هایی با پیام‌هایی هستند که از قانون شما پیروی نمی‌کنند، رد خواهد کرد.

اجبار به سیستم کنترل دسترسی مبتنی بر کاربر (Enforcing a User-Based ACL System)

فرض کنید می‌خواهید مکانیزمی اضافه کنید که از فهرست کنترل دسترسی (ACL) استفاده می‌کند و مشخص می‌کند که کدام کاربران مجاز به اعمال تغییرات در کدام بخش‌های پروژه‌های شما هستند. برخی افراد دسترسی کامل دارند و برخی دیگر فقط می‌توانند تغییرات را به زیرشاخه‌های خاص یا فایل‌های مشخص فشار دهند. برای اعمال این، این قوانین را در فایلی به نام acl که در مخزن گیت برهنه شما در سرور قرار دارد، خواهید نوشت. شما قلاب update را طوری تنظیم می‌کنید که به این قوانین نگاه کند، ببیند چه فایل‌هایی برای تمام کامیت‌هایی که در حال push شدن هستند معرفی می‌شوند و تعیین کند که آیا کاربری که push را انجام می‌دهد، دسترسی به به‌روزرسانی تمام آن فایل‌ها را دارد یا خیر.

اولین کاری که انجام می‌دهید نوشتن ACL خود است. در اینجا از فرمتی بسیار شبیه به مکانیسم ACL CVS استفاده خواهید کرد: این فرمت از یک سری خطوط استفاده می‌کند که در آن فیلد اول avail یا unavail است، فیلد بعدی لیستی از کاربران است که با کاما از هم جدا شده‌اند و قانون برای آنها اعمال می‌شود، و فیلد آخر مسیر اعمال قانون است (به معنی دسترسی آزاد). تمام این فیلدها با کاراکتر لوله (|) از هم جدا شده‌اند.

در این مورد، شما چند مدیر، تعدادی نویسنده مستندات با دسترسی به دایرکتوری doc و یک توسعه‌دهنده دارید که فقط به دایرکتوری‌های lib و tests دسترسی دارد و فایل ACL شما به این شکل است:

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

شما با خواندن این داده‌ها در ساختاری که می‌توانید از آن استفاده کنید، شروع می‌کنید. در این مورد، برای ساده نگه داشتن مثال، فقط دستورالعمل‌های avail را اعمال خواهید کرد. این روشی است که به شما یک آرایه انجمنی می‌دهد که کلید آن نام کاربری و مقدار آن آرایه‌ای از مسیرهایی است که کاربر به آنها دسترسی نوشتن دارد:

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

در فایل ACL که قبلاً بررسی کردید، این متد get_acl_access_data ساختار داده‌ای شبیه به این را برمی‌گرداند:

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

حالا که مجوزها را مرتب کرده‌اید، باید مشخص کنید که کامیت‌های در حال push چه مسیرهایی را تغییر داده‌اند تا مطمئن شوید کاربری که در حال push کردن است به همه آن‌ها دسترسی دارد.

تو می‌توانی خیلی راحت ببینی که در یک کامیت چه فایل‌هایی تغییر کرده‌اند؛ با استفاده از گزینه‌ی --name-only در دستور git log (که به‌طور خلاصه در مقدمات گیت (git basics chapter) اشاره شد):

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

README
lib/test.rb

اگر از ساختار ACL که از متد get_acl_access_data برگردانده شده استفاده کنید و آن را با فایل‌های فهرست شده در هر یک از کامیت‌ها مقایسه کنید، می‌توانید تعیین کنید که آیا کاربر به تمام کامیت‌های خود دسترسی دارد یا خیر:

# 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

با استفاده از دستور git rev-list لیستی از کامیت‌های جدیدی که به سرور شما push می‌شوند، دریافت می‌کنید. سپس، برای هر یک از آن کامیت‌ها، فایل‌های تغییر یافته را پیدا می‌کنید و مطمئن می‌شوید که کاربری که در حال پوش کردن است، به تمام مسیرهای در حال تغییر دسترسی دارد.

اکنون کاربران شما نمی‌توانند هیچ کامیتی را با پیام‌های بدشکل یا با فایل‌های اصلاح‌شده خارج از مسیرهای تعیین‌شده خود پوش کنند.

آزمایش کردن آن (Testing It Out)

اگر دستور chmod u+x .git/hooks/update را اجرا کنید، که فایل حاوی تمام این کد است، و سپس سعی کنید یک کامیت با پیام غیرمطابق را پوش کنید، چیزی شبیه این دریافت می‌کنید:

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

اینجا چند نکته‌ی جالب وجود دارد. اول، این بخش را می‌بینی که جایی است که هوک شروع به اجرا می‌کند.

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

یادت هست که این را در همان ابتدای اسکریپت به‌روزرسانی‌ات چاپ کردی؟ هر چیزی که اسکریپت شما به stdout منعکس کند، به مشتری منتقل خواهد شد.

چیز دیگری که متوجه آن خواهید شد، ارور است.

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

خط اول توسط شما چاپ شد، دو خط دیگر Git به شما می‌گوید که اسکریپت به‌روزرسانی با کد غیرصفر خارج شده است و این همان چیزی است که باعث رد شدن push شما می‌شود. در نهایت، این را دارید:

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

برای هر مرجعی که قلاب شما رد کرده است، یک پیام رد شده از راه دور خواهید دید که به شما می‌گوید به طور خاص به دلیل شکست قلاب رد شده است.

علاوه بر این، اگر کسی سعی کند فایلی را که به آن دسترسی ندارد ویرایش کند و کامیتی حاوی آن را push کند، چیزی مشابه را خواهد دید. به عنوان مثال، اگر نویسنده مستندات سعی کند کامیتی را که چیزی را در دایرکتوری lib تغییر می‌دهد، فشار دهد، با این پیام مواجه می‌شود:

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

از این به بعد، تا زمانی که اسکریپت update وجود داشته باشد و قابل اجرا باشد، مخزن شما هرگز پیام کامیتی بدون الگوی شما نخواهد داشت و کاربران شما در یک محیط ایزوله قرار خواهند گرفت.

قلاب‌های سمت کلاینت (Client-Side Hooks)

نقطه ضعف این رویکرد غرغره‌هایی است که به ناچار در صورت رد pushهای commit کاربران شما ایجاد می‌شود. رد شدن کار با دقت ساخته شده آنها در آخرین لحظه می‌تواند بسیار خسته‌کننده و گیج‌کننده باشد؛ و علاوه بر این، آنها باید تاریخچه خود را برای اصلاح آن ویرایش کنند، که همیشه برای افراد ضعیف‌القلب آسان نیست.

پاسخ به این معضل ارائه برخی قلاب‌های سمت مشتری است که کاربران می‌توانند آن‌ها را اجرا کنند تا در صورت انجام کاری که احتمالاً سرور آن را رد می‌کند، به آن‌ها اطلاع داده شود. به این ترتیب، می‌توانند هر مشکلی را قبل از تعهد و قبل از اینکه این مسائل سخت‌تر شوند، اصلاح کنند. چون قلاب‌ها با کپی یک پروژه منتقل نمی‌شوند، باید این اسکریپت‌ها را به روش دیگری توزیع کنید و سپس از کاربران خود بخواهید که آن‌ها را در دایرکتوری .git/hooks خود کپی کرده و قابل اجرا کنند. شما می‌توانید این قلاب‌ها را در داخل پروژه یا در یک پروژه جداگانه توزیع کنید، اما گیت آن‌ها را به طور خودکار راه‌اندازی نخواهد کرد.

برای شروع، باید پیام‌های کامیت خود را درست قبل از ثبت هر کامیت بررسی کنید تا بدانید سرور تغییرات شما را به دلیل پیام‌های کامیت با فرمت بد رد نخواهد کرد. برای این کار می‌توانید قلاب commit-msg را اضافه کنید. اگر آن را دارید، پیام فایل را که به عنوان آرگومان اول پاس داده شده است، بخوانید و آن را با الگو مقایسه کنید، می‌توانید گیت را مجبور کنید که اگر مطابقت وجود نداشت، از commit کردن منصرف شود:

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

اگر آن اسکریپت در جای خود (در .git/hooks/commit-msg) قرار داشته باشد و قابل اجرا باشد، و شما با پیامی که به درستی قالب‌بندی نشده است commit کنید، این را می‌بینید:

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

در آن نمونه هیچ کامیتی تکمیل نشد. با این حال، اگر پیام شما الگوی صحیح را داشته باشد، گیت به شما اجازه می‌دهد که commit کنید:

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

بعد، می‌خواهید مطمئن شوید که فایل‌هایی را که خارج از محدوده ACL شما هستند، تغییر نمی‌دهید. اگر دایرکتوری .git پروژه شما شامل کپی از فایل ACL است که قبلاً استفاده کرده‌اید، اسکریپت pre-commit زیر این محدودیت‌ها را برای شما اعمال خواهد کرد:

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

این تقریباً همان اسکریپت قسمت سمت سرور است، اما با دو تفاوت مهم. اولاً، فایل ACL در مکان دیگری قرار دارد، زیرا این اسکریپت از دایرکتوری کاری شما اجرا می‌شود، نه از دایرکتوری .git شما. شما باید مسیر فایل ACL را از این به این تغییر دهید:

access = get_acl_access_data('acl')

to this:

access = get_acl_access_data('.git/acl')

تفاوت مهم دیگر نحوه دریافت فهرست فایل‌هایی است که تغییر کرده‌اند. چون متد سمت سرور به لاگ کامیت‌ها نگاه می‌کند و در این مرحله کامیت هنوز ثبت نشده است، باید لیست فایل‌های خود را از ناحیه آماده‌سازی دریافت کنید. به جای:

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

شما باید از این استفاده کنید:

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

اما این تنها دو تفاوت است - در غیر این صورت، اسکریپت به همان روش کار می‌کند. یک نکته این است که انتظار دارد شما را به صورت محلی با همان کاربری که به ماشین راه دور پوش می‌کنید، اجرا کنید. اگر این متفاوت است، باید متغیر $user را به صورت دستی تنظیم کنید.

یک کار دیگر که می‌توانیم اینجا انجام دهیم این است که مطمئن شویم کاربر مراجع غیر fast-forward را push نمی‌کند. برای اینکه یک مرجع غیر از جلو بردن سریع داشته باشید، یا باید مبنای یک کامیت که قبلاً آن را پوش کرده‌اید را تغییر دهید یا سعی کنید یک شاخه محلی متفاوت را به همان شاخه ریموت پوش کنید.

احتمالاً سرور از قبل با receive.denyDeletes و receive.denyNonFastForwards پیکربندی شده است تا این سیاست را اجرا کند، بنابراین تنها چیزی که می‌توانید به طور تصادفی سعی کنید بگیرید، بازسازی کامیت‌هایی است که قبلاً پوش شده‌اند.

در اینجا یک مثال از اسکریپت قبل از ریبیس وجود دارد که این موضوع را بررسی می‌کند. این لیست تمام کامیت‌هایی را که قرار است بازنویسی کنید، دریافت می‌کند و بررسی می‌کند که آیا در هیچ یک از مراجع دوردست شما وجود دارند یا خیر. اگر یکی از مراجع دوردست شما را ببیند که قابل دسترسی است، عملیات rebase را متوقف می‌کند.

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

این اسکریپت از یک سینتکس استفاده می‌کند که در انتخاب بازبینی (Revision Selection) پوشش داده نشده بود. با اجرای این دستور، فهرستی از کامیت‌هایی که قبلاً پوش شده‌اند را به دست می‌آوری:

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

سینتکس SHA^@ به همه‌ی والدهای آن کامیت اشاره می‌کند. تو دنبال هر کامیتی هستی که از آخرین کامیت روی ریموت قابل دسترسی باشد و از هیچ‌یک از والدهای هرکدام از SHA-1هایی که می‌خواهی پوش کنی قابل دسترسی نباشد — یعنی یک fast-forward است.

نقطه ضعف اصلی این رویکرد این است که می‌تواند بسیار کند باشد و اغلب غیرضروری است – اگر با استفاده از -f سعی نکنید فشار را مجبور کنید، سرور به شما هشدار می‌دهد و فشار را نمی‌پذیرد. با این حال، این یک تمرین جالب است و در تئوری می‌تواند به شما کمک کند از یک بازنویسی که ممکن است بعداً مجبور شوید به آن برگردید و آن را اصلاح کنید، اجتناب کنید.

scroll-to-top