章節 ▾ 第二版

8.4 Git 自定義 - 一個示例 Git 強制策略

一個示例 Git 強制策略

在本節中,您將運用所學知識來建立一個 Git 工作流程,該工作流程會檢查自定義的提交訊息格式,並只允許某些使用者修改專案中的特定子目錄。您將構建客戶端指令碼來幫助開發人員瞭解他們的推送是否會被拒絕,以及伺服器端指令碼來實際執行這些策略。

我們將展示的指令碼是用 Ruby 編寫的;部分原因是我們的思維慣性,但也是因為 Ruby 易於閱讀,即使您不一定能寫出來。然而,任何語言都可以。Git 自帶的所有示例鉤子指令碼都是用 Perl 或 Bash 編寫的,因此您也可以透過檢視示例來找到這些語言中大量的鉤子示例。

伺服器端鉤子

所有伺服器端工作都將放在 `hooks` 目錄中的 `update` 檔案中。`update` 鉤子在每次推送的分支上執行一次,並接受三個引數:

  • 要推送的引用的名稱

  • 該分支之前的修訂版本

  • 要推送的新修訂版本

如果推送是透過 SSH 執行的,您還可以訪問執行推送的使用者。如果您允許所有人透過公鑰認證以單一使用者(如“git”)連線,您可能需要給該使用者一個 shell 包裝器,該包裝器根據公鑰確定哪個使用者正在連線,並相應地設定一個環境變數。在這裡,我們將假設連線使用者在 `$USER` 環境變數中,因此您的 update 指令碼首先收集所有需要的資訊:

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

是的,這些是全域性變數。不要評判——這樣演示更簡單。

強制執行特定的提交訊息格式

您的第一個挑戰是強制每個提交訊息都遵循特定的格式。為了有一個目標,假設每條訊息都必須包含一個看起來像“ref: 1234”的字串,因為您希望每次提交都連結到您的票證系統中的一個工作項。您必須檢視正在推送的每個提交,檢視該字串是否在提交訊息中,如果該字串在任何提交中都不存在,則退出非零,以拒絕推送。

您可以透過獲取 `$newrev` 和 `$oldrev` 值,並將它們傳遞給一個名為 `git rev-list` 的 Git 管道命令來獲取正在推送的所有提交的 SHA-1 值列表。這基本上就是 `git log` 命令,但預設情況下它只打印 SHA-1 值而不列印其他資訊。因此,要獲取一個提交 SHA-1 和另一個提交 SHA-1 之間引入的所有提交 SHA-1 的列表,您可以執行類似這樣的命令:

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

您可以獲取該輸出,迴圈遍歷每個提交 SHA-1,獲取其訊息,並使用一個查詢模式的正則表示式來測試該訊息。

您需要找出如何從每個提交中獲取提交訊息進行測試。要獲取原始提交資料,您可以使用另一個名為 `git cat-file` 的管道命令。我們將在 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

當您擁有 SHA-1 值時,從提交中獲取提交訊息的一種簡單方法是找到第一個空行,並獲取該行之後的所有內容。在 Unix 系統上,您可以使用 `sed` 命令來完成此操作:

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

您可以使用這個命令來獲取正在推送的每個提交的訊息,並在看到任何不匹配的內容時退出。要退出指令碼並拒絕推送,請退出非零。整個方法如下:

$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` 指令碼中,將拒絕包含不符合您規則的訊息的提交的更新。

強制執行基於使用者的 ACL 系統

假設您想新增一個機制,該機制使用一個訪問控制列表 (ACL),指定哪些使用者可以推送更改到您專案的哪些部分。有些人擁有完全訪問許可權,而其他人只能將更改推送到特定的子目錄或特定檔案。為了強制執行此操作,您將這些規則寫入一個名為 `acl` 的檔案,該檔案位於伺服器上的裸 Git 倉庫中。您將讓 `update` 鉤子檢視這些規則,檢視正在推送的所有提交引入了哪些檔案,並確定執行推送的使用者是否擁有更新所有這些檔案的訪問許可權。

您首先要做的是編寫您的 ACL。在這裡,您將使用一種非常類似於 CVS ACL 機制的格式:它使用一系列行,其中第一個欄位是 `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"]}

既然您已經整理好了許可權,您就需要確定被推送的提交修改了哪些路徑,以便確保執行推送的使用者能夠訪問所有這些路徑。

使用 `git log` 命令的 `--name-only` 選項(在 Git Basics 中簡要提及)可以很容易地看到單個提交中修改了哪些檔案。

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

README
lib/test.rb

如果您使用 `get_acl_access_data` 方法返回的 ACL 結構,並將其與每個提交中列出的檔案進行檢查,您就可以確定使用者是否有許可權推送他們所有的提交。

# 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` 獲取推送到您伺服器的新提交列表。然後,對於每個提交,找出修改了哪些檔案,並確保執行推送的使用者能夠訪問所有被修改的路徑。

現在,您的使用者無法推送任何格式錯誤的提交訊息或修改了其指定路徑之外檔案的提交。

測試

如果您執行 `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)

請記住,您在 update 指令碼的開頭就列印了這一點。您的腳本回顯到 `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 告訴您 update 指令碼退出了非零,這正是拒絕您的推送的原因。最後,您有這個:

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

您將看到每個被您的鉤子拒絕的引用的遠端拒絕訊息,它告訴您拒絕的原因是鉤子失敗。

此外,如果有人嘗試修改他們無權訪問的檔案並推送包含該檔案的提交,他們會看到類似的內容。例如,如果一位文件作者嘗試推送修改 `lib` 目錄中內容的提交,他們會看到:

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

從現在開始,只要 `update` 指令碼存在且可執行,您的儲存庫將永遠不會有不包含您的模式的提交訊息,您的使用者也將被沙盒化。

客戶端鉤子

這種方法的缺點是,當用戶的提交被拒絕時,不可避免地會產生抱怨。在最後關頭拒絕他們精心製作的工作可能會非常令人沮喪和困惑;此外,他們將不得不編輯他們的歷史來糾正它,這對於膽小的人來說並不總是容易的。

解決這個困境的方法是提供一些客戶端鉤子,使用者可以執行這些鉤子來通知他們何時在做伺服器可能會拒絕的事情。這樣,他們可以在提交之前、在這些問題變得更難修復之前糾正任何問題。由於鉤子不會隨專案的克隆一起傳輸,您必須以其他方式分發這些指令碼,然後讓您的使用者將它們複製到他們的 `.git/hooks` 目錄並使其可執行。您可以將這些鉤子分發在專案內或單獨的專案中,但 Git 不會自動設定它們。

首先,您應該在每次提交被記錄之前檢查您的提交訊息,這樣您就知道伺服器不會因為格式錯誤的提交訊息而拒絕您的更改。要做到這一點,您可以新增 `commit-msg` 鉤子。如果您讓它讀取傳遞為第一個引數的檔案中的訊息,並將其與模式進行比較,您就可以強制 Git 在沒有匹配項時中止提交:

#!/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` 中)並且可執行,並且您使用格式不正確的訊息進行提交,您會看到:

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

在這種情況下,沒有完成提交。但是,如果您的訊息包含正確的模式,Git 會允許您提交:

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

更改為這個

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` 變數。

我們還可以做的另一件事是確保使用者不推送非快進引用。要獲取一個非快進的引用,您要麼需要 rebase 已經推送過的提交,要麼嘗試將不同的本地分支推送到同一個遠端分支。

可能,伺服器已經配置了 `receive.denyDeletes` 和 `receive.denyNonFastForwards` 來強制執行此策略,因此您唯一可以嘗試捕獲的意外情況是 rebase 已經推送過的提交。

這是一個示例 pre-rebase 指令碼,用於檢查這一點。它獲取您即將重寫的提交列表,並檢查它們是否存在於您的任何遠端引用中。如果它看到一個可以從您的遠端引用之一到達的提交,它會中止 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 的父項到達的提交——這意味著它是快進的。

這種方法的主要缺點是它可能非常慢而且通常不必要——如果您不使用 `-f` 強制推送,伺服器會警告您並且不會接受推送。然而,這是一個有趣的練習,理論上可以幫助您避免以後可能需要修復的 rebase。