## 使用強制策略的一個例子
在本節中,你將應用前面學到的知識建立這樣一個 Git 工作流程:檢查提交信息的格式,并且指定只能由特定用戶修改項目中特定的子目錄。 你將編寫一個客戶端腳本來提示開發人員他們的推送是否會被拒絕,以及一個服務器端腳本來實際執行這些策略。
我們待會展示的腳本是用 Ruby 寫的,部分是由于我習慣用它寫腳本,另外也因為 Ruby 簡單易懂,即便你沒寫過它也能看明白。 不過任何其他語言也一樣適用。所有 Git 自帶的示例鉤子腳本都是用 Perl 或 Bash 寫的,所以你能從它們中找到相當多的這兩種語言的鉤子示例。
## 服務器端鉤子
所有服務器端的工作都將在你的?`hooks`?目錄下的?`update`?腳本中完成。?`update`?腳本會為每一個提交的分支各運行一次,它接受三個參數:
* 被推送的引用的名字
* 推送前分支的修訂版本(revision)
* 用戶準備推送的修訂版本(revision)
如果推送是通過 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”的字符串,因為你想把每一次提交對應到問題追蹤系統(ticketing system)中的某個事項。 你要逐一檢查每一條推送上來的提交內容,看看提交信息是否包含這么一個字符串,然后,如果某個提交里不包含這個字符串,以非零返回值退出從而拒絕此次推送。
把?`$newrev`?和?`$oldrev`?變量的值傳給一個叫做?`git rev-list`?的 Git 底層命令,你可以獲取所有提交的 SHA-1 值列表。?`git rev-list`?基本類似?`git log`?命令,但它默認只輸出 SHA-1 值而已,沒有其他信息。 所以要獲取由一次提交到另一次提交之間的所有 SHA-1 值,可以像這樣運行:
~~~
$ git rev-list 538c33..d14fc7
d14fc7c847ab946ec39590d87783c69b031bdfb7
9f585da4401b0a3999e84113824d15245c13f0be
234071a1be950e2a8d078e6141f5cd20c1e61ad3
dfa04c9ef3d5197182f13fb5b9b1fb7717d2222a
17716ec0f1ff5c77eff40b7fe912f9f6cfd0e475
~~~
你可以截取這些輸出內容,循環遍歷其中每一個 SHA-1 值,找出與之對應的提交信息,然后用正則表達式來測試該信息包含的內容。
下一步要實現從每個提交中提取出提交信息。 使用另一個叫做?`git cat-file`?的底層命令來獲得原始的提交數據。 我們將在?[Git 內部原理](http://git-scm.com/book/zh/v2/1-git-internals/_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
changed the version number
~~~
通過 SHA-1 值獲得提交中的提交信息的一個簡單辦法是找到提交的第一個空行,然后取從它往后的所有內容。 可以使用 Unix 系統的?`sed`?命令來實現該效果:
~~~
$ git cat-file commit ca82a6 | sed '1,/^$/d'
changed the version number
~~~
你可以用這條咒語從每一個待推送的提交里提取提交信息,然后在提取的內容不符合要求時退出。 為了退出腳本和拒絕此次推送,返回非零值。 整個腳本大致如下:
~~~
$regex = /\[ref: (\d+)\]/
# 指定自定義的提交信息格式
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)系統
假設你需要添加一個使用訪問權限控制列表的機制,來指定哪些用戶對項目的哪些部分有推送權限。 某些用戶具有全部的訪問權,其他人只對某些子目錄或者特定的文件具有推送權限。 為了實現這一點,你要把相關的規則寫入位于服務器原始 Git 倉庫的 acl 文件中。 你還需要讓?`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)
# 讀取ACL數據
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 log -1 --name-only --pretty=format:'' 9f585d
README
lib/test.rb
~~~
使用?`get_acl_access_data`?返回的 ACL 結構來一一核對每次提交修改的文件列表,就能找出該用戶是否有權限推送所有的提交內容:
~~~
# 僅允許特定用戶修改項目中的特定子目錄
def check_directory_perms
access = get_acl_access_data('acl')
# 檢查是否有人在向他沒有權限的地方推送內容
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 # 用戶擁有完全訪問權限
|| (path.start_with? access_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`?獲取推送到服務器的所有提交。 接著,對于每一個提交,找出它修改的文件,然后確保推送者具有這些文件的推送權限。
現在你的用戶沒法推送帶有不正確的提交信息的內容,也不能在準許他們訪問范圍之外的位置做出修改。
#### 測試一下
如果已經把上面的代碼放到?`.git/hooks/update`?文件里了,運行?`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 腳本開頭輸出到標準輸出的。 所有從腳本輸出到標準輸出的內容都會轉發給客戶端。
下一個值得注意的部分是錯誤信息。
~~~
[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'
~~~
你會看到每個被你的鉤子拒之門外的引用都收到了一個 remote rejected 信息,它告訴你正是鉤子無法成功運行導致了推送的拒絕。
又或者某人想修改一個自己不具備權限的文件然后推送了一個包含它的提交,他將看到類似的提示。 比如,一個文檔作者嘗試推送一個修改到?`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']
# [ 插入上文中的 get_acl_access_data 方法 ]
# 僅允許特定用戶修改項目中的特定子目錄
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`?變量。
在這里,我們還可以確保推送內容中不包含非快進(non-fast-forward)的引用。 出現一個不是快進(fast-forward)的引用有兩種情形,要么是在某個已經推送過的提交上作變基,要么是從本地推送一個錯誤的分支到遠程分支上。
假定為了執行這個策略,你已經在服務器上配置好了?`receive.denyDeletes`?和`receive.denyNonFastForwards`,因而唯一還需要避免的是在某個已經推送過的提交上作變基。
下面是一個檢查這個問題的?`pre-rebase`?腳本示例。 它獲取所有待重寫的提交的列表,然后檢查它們是否存在于遠程引用中。 一旦發現其中一個提交是在某個遠程引用中可達的(reachable),它就終止此次變基:
~~~
#!/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
~~~
這個腳本利用了一個第六章“修訂版本選擇”一節中不曾提到的語法。通過運行這個命令可以獲得一系列之前推送過的提交:
~~~
`git rev-list ^#{sha}^@ refs/remotes/#{remote_ref}`
~~~
`SHA^@`?語法會被解析成該提交的所有父提交。 該命令會列出在遠程分支最新的提交中可達的,卻在所有我們嘗試推送的提交的 SHA-1 值的所有父提交中不可達的提交——也就是快進的提交。
這個解決方案主要的問題在于它有可能很慢而且常常沒有必要——只要你不用?`-f`?來強制推送,服務器就會自動給出警告并且拒絕接受推送。 然而,這是個不錯的練習,而且理論上能幫助你避免一次以后可能不得不回頭修補的變基。
- 前言
- Scott Chacon 序
- Ben Straub 序
- 獻辭
- 貢獻者
- 引言
- 1. 起步
- 1.1 關于版本控制
- 1.2 Git 簡史
- 1.3 Git 基礎
- 1.4 命令行
- 1.5 安裝 Git
- 1.6 初次運行 Git 前的配置
- 1.7 獲取幫助
- 1.8 總結
- 2. Git 基礎
- 2.1 獲取 Git 倉庫
- 2.2 記錄每次更新到倉庫
- 2.3 查看提交歷史
- 2.4 撤消操作
- 2.5 遠程倉庫的使用
- 2.6 打標簽
- 2.7 Git 別名
- 2.8 總結
- 3. Git 分支
- 3.1 分支簡介
- 3.2 分支的新建與合并
- 3.3 分支管理
- 3.4 分支開發工作流
- 3.5 遠程分支
- 3.6 變基
- 3.7 總結
- 4. 服務器上的 Git
- 4.1 協議
- 4.2 在服務器上搭建 Git
- 4.3 生成 SSH 公鑰
- 4.4 配置服務器
- 4.5 Git 守護進程
- 4.6 Smart HTTP
- 4.7 GitWeb
- 4.8 GitLab
- 4.9 第三方托管的選擇
- 4.10 總結
- 5. 分布式 Git
- 5.1 分布式工作流程
- 5.2 向一個項目貢獻
- 5.3 維護項目
- 5.4 總結
- 6. GitHub
- 6.1 賬戶的創建和配置
- 6.2 對項目做出貢獻
- 6.3 維護項目
- 6.4 管理組織
- 6.5 腳本 GitHub
- 6.6 總結
- 7. Git 工具
- 7.1 選擇修訂版本
- 7.2 交互式暫存
- 7.3 儲藏與清理
- 7.4 簽署工作
- 7.5 搜索
- 7.6 重寫歷史
- 7.7 重置揭密
- 7.8 高級合并
- 7.9 Rerere
- 7.10 使用 Git 調試
- 7.11 子模塊
- 7.12 打包
- 7.13 替換
- 7.14 憑證存儲
- 7.15 總結
- 8. 自定義 Git
- 8.1 配置 Git
- 8.2 Git 屬性
- 8.3 Git 鉤子
- 8.4 使用強制策略的一個例子
- 8.5 總結
- 9. Git 與其他系統
- 9.1 作為客戶端的 Git
- 9.2 遷移到 Git
- 9.3 總結
- 10. Git 內部原理
- 10.1 底層命令和高層命令
- 10.2 Git 對象
- 10.3 Git 引用
- 10.4 包文件
- 10.5 引用規格
- 10.6 傳輸協議
- 10.7 維護與數據恢復
- 10.8 環境變量
- 10.9 總結
- A. 其它環境中的 Git
- A1.1 圖形界面
- A1.2 Visual Studio 中的 Git
- A1.3 Eclipse 中的 Git
- A1.4 Bash 中的 Git
- A1.5 Zsh 中的 Git
- A1.6 Powershell 中的 Git
- A1.7 總結
- B. 將 Git 嵌入你的應用
- A2.1 命令行 Git 方式
- A2.2 Libgit2
- A2.3 JGit
- C. Git 命令
- A3.1 設置與配置
- A3.2 獲取與創建項目
- A3.3 快照基礎
- A3.4 分支與合并
- A3.5 項目分享與更新
- A3.6 檢查與比較
- A3.7 調試
- A3.8 補丁
- A3.9 郵件
- A3.10 外部系統
- A3.11 管理
- A3.12 底層命令