在本節(jié)中,你將應(yīng)用前面學(xué)到的知識(shí)建立這樣一個(gè) Git 工作流程:檢查提交信息的格式,并且指定只能由特定用戶修改項(xiàng)目中特定的子目錄。 你將編寫一個(gè)客戶端腳本來(lái)提示開發(fā)人員他們的推送是否會(huì)被拒絕,以及一個(gè)服務(wù)器端腳本來(lái)實(shí)際執(zhí)行這些策略。
我們待會(huì)展示的腳本是用 Ruby 寫的,部分是由于我習(xí)慣用它寫腳本,另外也因?yàn)?Ruby 簡(jiǎn)單易懂,即便你沒(méi)寫過(guò)它也能看明白。 不過(guò)任何其他語(yǔ)言也一樣適用。所有 Git 自帶的示例鉤子腳本都是用 Perl 或 Bash 寫的,所以你能從它們中找到相當(dāng)多的這兩種語(yǔ)言的鉤子示例。
所有服務(wù)器端的工作都將在你的?hooks
?目錄下的?update
?腳本中完成。?update
?腳本會(huì)為每一個(gè)提交的分支各運(yùn)行一次,它接受三個(gè)參數(shù):
被推送的引用的名字
推送前分支的修訂版本(revision)
如果推送是通過(guò) SSH 進(jìn)行的,還可以獲知進(jìn)行此次推送的用戶的信息。 如果你允許所有操作都通過(guò)公匙授權(quán)的單一帳號(hào)(比如“git”)進(jìn)行,就有必要通過(guò)一個(gè) shell 包裝腳本依據(jù)公匙來(lái)判斷用戶的身份,并且相應(yīng)地設(shè)定環(huán)境變量來(lái)表示該用戶的身份。 下面就假設(shè)?$USER
?環(huán)境變量里存儲(chǔ)了當(dāng)前連接的用戶的身份,你的 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]})"
是的,我們這里用的都是全局變量。 請(qǐng)勿在此吐槽——這樣做只是為了方便展示而已。
你的第一項(xiàng)任務(wù)是要求每一條提交信息都必須遵循某種特殊的格式。 作為目標(biāo),假定每一條信息必須包含一條形似“ref: 1234”的字符串,因?yàn)槟阆氚衙恳淮翁峤粚?duì)應(yīng)到問(wèn)題追蹤系統(tǒng)(ticketing system)中的某個(gè)事項(xiàng)。 你要逐一檢查每一條推送上來(lái)的提交內(nèi)容,看看提交信息是否包含這么一個(gè)字符串,然后,如果某個(gè)提交里不包含這個(gè)字符串,以非零返回值退出從而拒絕此次推送。
把?$newrev
?和?$oldrev
?變量的值傳給一個(gè)叫做?git rev-list
?的 Git 底層命令,你可以獲取所有提交的 SHA-1 值列表。?git rev-list
?基本類似?git log
?命令,但它默認(rèn)只輸出 SHA-1 值而已,沒(méi)有其他信息。 所以要獲取由一次提交到另一次提交之間的所有 SHA-1 值,可以像這樣運(yùn)行:
$ git rev-list 538c33..d14fc7
d14fc7c847ab946ec39590d87783c69b031bdfb7
9f585da4401b0a3999e84113824d15245c13f0be
234071a1be950e2a8d078e6141f5cd20c1e61ad3
dfa04c9ef3d5197182f13fb5b9b1fb7717d2222a
17716ec0f1ff5c77eff40b7fe912f9f6cfd0e475
你可以截取這些輸出內(nèi)容,循環(huán)遍歷其中每一個(gè) SHA-1 值,找出與之對(duì)應(yīng)的提交信息,然后用正則表達(dá)式來(lái)測(cè)試該信息包含的內(nèi)容。
下一步要實(shí)現(xiàn)從每個(gè)提交中提取出提交信息。 使用另一個(gè)叫做?git cat-file
?的底層命令來(lái)獲得原始的提交數(shù)據(jù)。 我們將在?Git 內(nèi)部原理?了解到這些底層命令的細(xì)節(jié);現(xiàn)在暫時(shí)先看一下這條命令的輸出:
$ 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
通過(guò) SHA-1 值獲得提交中的提交信息的一個(gè)簡(jiǎn)單辦法是找到提交的第一個(gè)空行,然后取從它往后的所有內(nèi)容。 可以使用 Unix 系統(tǒng)的?sed
?命令來(lái)實(shí)現(xiàn)該效果:
$ git cat-file commit ca82a6 | sed '1,/^$/d'
changed the version number
你可以用這條咒語(yǔ)從每一個(gè)待推送的提交里提取提交信息,然后在提取的內(nèi)容不符合要求時(shí)退出。 為了退出腳本和拒絕此次推送,返回非零值。 整個(gè)腳本大致如下:
$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
?腳本里,所有包含不符合指定規(guī)則的提交都會(huì)遭到拒絕。
假設(shè)你需要添加一個(gè)使用訪問(wèn)權(quán)限控制列表的機(jī)制,來(lái)指定哪些用戶對(duì)項(xiàng)目的哪些部分有推送權(quán)限。 某些用戶具有全部的訪問(wèn)權(quán),其他人只對(duì)某些子目錄或者特定的文件具有推送權(quán)限。 為了實(shí)現(xiàn)這一點(diǎn),你要把相關(guān)的規(guī)則寫入位于服務(wù)器原始 Git 倉(cāng)庫(kù)的 acl 文件中。 你還需要讓?update
?鉤子檢閱這些規(guī)則,審視推送的提交內(nèi)容中被修改的所有文件,然后決定執(zhí)行推送的用戶是否對(duì)所有這些文件都有權(quán)限。
先從寫一個(gè) ACL 文件開始吧。 這里使用的格式和 CVS 的 ACL 機(jī)制十分類似:它由若干行構(gòu)成,第一項(xiàng)內(nèi)容是?avail
?或者?unavail
,接著是逗號(hào)分隔的適用該規(guī)則的用戶列表,最后一項(xiàng)是適用該規(guī)則的路徑(該項(xiàng)空缺表示沒(méi)有路徑限制)。 各項(xiàng)由管道符?|
?隔開。
在本例中,你會(huì)有幾個(gè)管理員,一些對(duì)?doc
?目錄具有權(quán)限的文檔作者,以及一位僅對(duì)?lib
?和tests
?目錄具有權(quán)限的開發(fā)人員,相應(yīng)的 ACL 文件如下:
avail|nickh,pjhyett,defunkt,tpw
avail|usinclair,cdickens,ebronte|doc
avail|schacon|lib
avail|schacon|tests
首先把這些數(shù)據(jù)讀入你要用到的數(shù)據(jù)結(jié)構(gòu)里。 在本例中,為保持簡(jiǎn)潔,我們暫時(shí)只實(shí)現(xiàn)?avail
?的規(guī)則。 下面這個(gè)方法生成一個(gè)關(guān)聯(lián)數(shù)組,它的鍵是用戶名,值是一個(gè)由該用戶有寫權(quán)限的所有目錄組成的數(shù)組:
def get_acl_access_data(acl_file)
# 讀取ACL數(shù)據(jù)
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
對(duì)于之前給出的 ACL 規(guī)則文件,這個(gè)?get_acl_access_data
?方法返回的數(shù)據(jù)結(jié)構(gòu)如下:
{"defunkt"=>[nil],
"tpw"=>[nil],
"nickh"=>[nil],
"pjhyett"=>[nil],
"schacon"=>["lib", "tests"],
"cdickens"=>["doc"],
"usinclair"=>["doc"],
"ebronte"=>["doc"]}
既然拿到了用戶權(quán)限的數(shù)據(jù),接下來(lái)你需要找出提交都修改了哪些路徑,從而才能保證推送者對(duì)所有這些路徑都有權(quán)限。
使用?git log
?的?--name-only
?選項(xiàng)(在第二章里簡(jiǎn)單地提過(guò)),我們可以輕而易舉的找出一次提交里修改的文件:
$ git log -1 --name-only --pretty=format:'' 9f585d
README
lib/test.rb
使用?get_acl_access_data
?返回的 ACL 結(jié)構(gòu)來(lái)一一核對(duì)每次提交修改的文件列表,就能找出該用戶是否有權(quán)限推送所有的提交內(nèi)容:
# 僅允許特定用戶修改項(xiàng)目中的特定子目錄
def check_directory_perms
access = get_acl_access_data('acl')
# 檢查是否有人在向他沒(méi)有權(quán)限的地方推送內(nèi)容
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 # 用戶擁有完全訪問(wèn)權(quán)限
|| (path.start_with? access_path) # 或者對(duì)此路徑有訪問(wèn)權(quán)限
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
通過(guò)?git rev-list
?獲取推送到服務(wù)器的所有提交。 接著,對(duì)于每一個(gè)提交,找出它修改的文件,然后確保推送者具有這些文件的推送權(quán)限。
現(xiàn)在你的用戶沒(méi)法推送帶有不正確的提交信息的內(nèi)容,也不能在準(zhǔn)許他們?cè)L問(wèn)范圍之外的位置做出修改。
如果已經(jīng)把上面的代碼放到?.git/hooks/update
?文件里了,運(yùn)行?chmod u+x .git/hooks/update
,然后嘗試推送一個(gè)不符合格式的提交,你會(huì)得到以下的提示:
$ 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'
這里有幾個(gè)有趣的信息。 首先,我們可以看到鉤子運(yùn)行的起點(diǎn)。
Enforcing Policies...
(refs/heads/master) (fb8c72) (c56860)
注意這是從 update 腳本開頭輸出到標(biāo)準(zhǔn)輸出的。 所有從腳本輸出到標(biāo)準(zhǔn)輸出的內(nèi)容都會(huì)轉(zhuǎn)發(fā)給客戶端。
下一個(gè)值得注意的部分是錯(cuò)誤信息。
[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 腳本退出時(shí)返回了非零值因而推送遭到了拒絕。 最后一點(diǎn):
To git@gitserver:project.git
! [remote rejected] master -> master (hook declined)
error: failed to push some refs to 'git@gitserver:project.git'
你會(huì)看到每個(gè)被你的鉤子拒之門外的引用都收到了一個(gè) remote rejected 信息,它告訴你正是鉤子無(wú)法成功運(yùn)行導(dǎo)致了推送的拒絕。
又或者某人想修改一個(gè)自己不具備權(quán)限的文件然后推送了一個(gè)包含它的提交,他將看到類似的提示。 比如,一個(gè)文檔作者嘗試推送一個(gè)修改到?lib
?目錄的提交,他會(huì)看到
[POLICY] You do not have access to push to lib/test.rb
從今以后,只要?update
?腳本存在并且可執(zhí)行,我們的版本庫(kù)中永遠(yuǎn)都不會(huì)包含不符合格式的提交信息,并且用戶都會(huì)待在沙箱里面。
這種方法的缺點(diǎn)在于,用戶推送的提交遭到拒絕后無(wú)法避免的抱怨。 辛辛苦苦寫成的代碼在最后時(shí)刻慘遭拒絕是十分讓人沮喪且具有迷惑性的;更可憐的是他們不得不修改提交歷史來(lái)解決問(wèn)題,這個(gè)方法并不能讓每一個(gè)人滿意。
逃離這種兩難境地的法寶是給用戶一些客戶端的鉤子,在他們犯錯(cuò)的時(shí)候給以警告。 然后呢,用戶們就能趁問(wèn)題尚未變得更難修復(fù),在提交前消除這個(gè)隱患。 由于鉤子本身不跟隨克隆的項(xiàng)目副本分發(fā),所以你必須通過(guò)其他途徑把這些鉤子分發(fā)到用戶的?.git/hooks
?目錄并設(shè)為可執(zhí)行文件。 雖然你可以在相同或單獨(dú)的項(xiàng)目里加入并分發(fā)這些鉤子,但是 Git 不會(huì)自動(dòng)替你設(shè)置它。
首先,你應(yīng)該在每次提交前核查你的提交信息,這樣才能確保服務(wù)器不會(huì)因?yàn)椴缓蠗l件的提交信息而拒絕你的更改。 為了達(dá)到這個(gè)目的,你可以增加?commit-msg
?鉤子。 如果你使用該鉤子來(lái)讀取作為第一個(gè)參數(shù)傳遞的提交信息,然后與規(guī)定的格式作比較,你就可以使 Git 在提交信息格式不對(duì)的情況下拒絕提交。
#!/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
如果這個(gè)腳本位于正確的位置 (.git/hooks/commit-msg
) 并且是可執(zhí)行的,你提交信息的格式又是不正確的,你會(huì)看到:
$ git commit -am 'test'
[POLICY] Your message is not formatted correctly
在這個(gè)示例中,提交沒(méi)有成功。 然而如果你的提交注釋信息是符合要求的,Git 會(huì)允許你提交:
$ git commit -am 'test [ref: 132]'
[master e05c914] test [ref: 132]
1 file changed, 1 insertions(+), 0 deletions(-)
接下來(lái)我們要保證沒(méi)有修改到 ACL 允許范圍之外的文件。 假如你的?.git
?目錄下有前面使用過(guò)的那份 ACL 文件,那么以下的?pre-commit
?腳本將把里面的規(guī)定執(zhí)行起來(lái):
#!/usr/bin/env ruby
$user = ENV['USER']
# [ 插入上文中的 get_acl_access_data 方法 ]
# 僅允許特定用戶修改項(xiàng)目中的特定子目錄
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
這和服務(wù)器端的腳本幾乎一樣,除了兩個(gè)重要區(qū)別。 第一,ACL 文件的位置不同,因?yàn)檫@個(gè)腳本在當(dāng)前工作目錄運(yùn)行,而非?.git
?目錄。 ACL 文件的路徑必須從
access = get_acl_access_data('acl')
修改成:
access = get_acl_access_data('.git/acl')
另一個(gè)重要區(qū)別是獲取被修改文件列表的方式。 在服務(wù)器端的時(shí)候使用了查看提交紀(jì)錄的方式,可是目前的提交都還沒(méi)被記錄下來(lái)呢,所以這個(gè)列表只能從暫存區(qū)域獲取。 和原來(lái)的
files_modified = `git log -1 --name-only --pretty=format:'' #{ref}`
不同,現(xiàn)在要用
files_modified = `git diff-index --cached --name-only HEAD`
不同的就只有這兩個(gè)——除此之外,該腳本完全相同。 有一點(diǎn)要注意的是,它假定在本地運(yùn)行的用戶和推送到遠(yuǎn)程服務(wù)器端的相同。 如果這二者不一樣,則需要手動(dòng)設(shè)置一下?$user
?變量。
在這里,我們還可以確保推送內(nèi)容中不包含非快進(jìn)(non-fast-forward)的引用。 出現(xiàn)一個(gè)不是快進(jìn)(fast-forward)的引用有兩種情形,要么是在某個(gè)已經(jīng)推送過(guò)的提交上作變基,要么是從本地推送一個(gè)錯(cuò)誤的分支到遠(yuǎn)程分支上。
假定為了執(zhí)行這個(gè)策略,你已經(jīng)在服務(wù)器上配置好了?receive.denyDeletes
?和receive.denyNonFastForwards
,因而唯一還需要避免的是在某個(gè)已經(jīng)推送過(guò)的提交上作變基。
下面是一個(gè)檢查這個(gè)問(wèn)題的?pre-rebase
?腳本示例。 它獲取所有待重寫的提交的列表,然后檢查它們是否存在于遠(yuǎn)程引用中。 一旦發(fā)現(xiàn)其中一個(gè)提交是在某個(gè)遠(yuǎn)程引用中可達(dá)的(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
這個(gè)腳本利用了一個(gè)第六章“修訂版本選擇”一節(jié)中不曾提到的語(yǔ)法。通過(guò)運(yùn)行這個(gè)命令可以獲得一系列之前推送過(guò)的提交:
`git rev-list ^#{sha}^@ refs/remotes/#{remote_ref}`
SHA^@
?語(yǔ)法會(huì)被解析成該提交的所有父提交。 該命令會(huì)列出在遠(yuǎn)程分支最新的提交中可達(dá)的,卻在所有我們嘗試推送的提交的 SHA-1 值的所有父提交中不可達(dá)的提交——也就是快進(jìn)的提交。
這個(gè)解決方案主要的問(wèn)題在于它有可能很慢而且常常沒(méi)有必要——只要你不用?-f
?來(lái)強(qiáng)制推送,服務(wù)器就會(huì)自動(dòng)給出警告并且拒絕接受推送。 然而,這是個(gè)不錯(cuò)的練習(xí),而且理論上能幫助你避免一次以后可能不得不回頭修補(bǔ)的變基。
更多建議: