Git 強(qiáng)制策略實(shí)例

2018-09-27 15:51 更新

在本節(jié)中,我們應(yīng)用前面學(xué)到的知識(shí)建立這樣一個(gè)Git 工作流程:檢查提交信息的格式,只接受純fast-forward內(nèi)容的推送,并且指定用戶只能修改項(xiàng)目中的特定子目錄。我們將寫一個(gè)客戶端腳本來提示開發(fā)人員他們推送的內(nèi)容是否會(huì)被拒絕,以及一個(gè)服務(wù)端腳本來實(shí)際執(zhí)行這些策略。

這些腳本使用 Ruby 寫成,一半由于它是作者傾向的腳本語言,另外作者覺得它是最接近偽代碼的腳本語言;因而即便你不使用 Ruby 也能大致看懂。不過任何其他語言也一樣適用。所有 Git 自帶的樣例腳本都是用 Perl 或 Bash 寫的。所以從這些腳本中能找到相當(dāng)多的這兩種語言的掛鉤樣例。

服務(wù)端掛鉤

所有服務(wù)端的工作都在hooks(掛鉤)目錄的 update(更新)腳本中制定。update 腳本為每一個(gè)得到推送的分支運(yùn)行一次;它接受推送目標(biāo)的索引,該分支原來指向的位置,以及被推送的新內(nèi)容。如果推送是通過 SSH 進(jìn)行的,還可以獲取發(fā)出此次操作的用戶。如果設(shè)定所有操作都通過公匙授權(quán)的單一帳號(hào)(比如"git")進(jìn)行,就有必要通過一個(gè) shell 包裝依據(jù)公匙來判斷用戶的身份,并且設(shè)定環(huán)境變量來表示該用戶的身份。下面假設(shè)嘗試連接的用戶儲(chǔ)存在 $USER 環(huán)境變量里,我們的 update 腳本首先搜集一切需要的信息:

!/usr/bin/env ruby

refname = ARGV[0]
oldrev  = ARGV[1]
newrev  = ARGV[2]
user    = ENV['USER']

puts "Enforcing Policies... \n(#{refname}) (#{oldrev[0,6]}) (#{newrev[0,6]})"

指定特殊的提交信息格式

我們的第一項(xiàng)任務(wù)是指定每一條提交信息都必須遵循某種特殊的格式。作為演示,假定每一條信息必須包含一條形似 "ref: 1234" 這樣的字符串,因?yàn)槲覀冃枰衙恳淮翁峤缓晚?xiàng)目的問題追蹤系統(tǒng)。我們要逐一檢查每一條推送上來的提交內(nèi)容,看看提交信息是否包含這么一個(gè)字符串,然后,如果該提交里不包含這個(gè)字符串,以非零返回值退出從而拒絕此次推送。

把 $newrev 和 $oldrev 變量的值傳給一個(gè)叫做 git rev-listGit plumbing命令可以獲取所有提交內(nèi)容的 SHA-1 值列表。git rev-list基本類似 git log命令,但它默認(rèn)只輸出 SHA-1 值而已,沒有其他信息。所以要獲取由 SHA 值表示的從一次提交到另一次提交之間的所有 SHA 值,可以運(yùn)行:

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

截取這些輸出內(nèi)容,循環(huán)遍歷其中每一個(gè) SHA 值,找出與之對(duì)應(yīng)的提交信息,然后用正則表達(dá)式來測(cè)試該信息包含的格式話的內(nèi)容。

下面要搞定如何從所有的提交內(nèi)容中提取出提交信息。使用另一個(gè)叫做 git cat-file 的 Git plumbing 工具可以獲得原始的提交數(shù)據(jù)。我們將在第九章了解到這些 plumbing 工具的細(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

通過 SHA-1 值獲得提交內(nèi)容中的提交信息的一個(gè)簡(jiǎn)單辦法是找到提交的第一行,然后取從它往后的所有內(nèi)容??梢允褂?Unix 系統(tǒng)的 sed 命令來實(shí)現(xiàn)該效果:

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

這條咒語從每一個(gè)待提交內(nèi)容里提取提交信息,并且會(huì)在提取信息不符合要求的情況下退出。為了退出腳本和拒絕此次推送,返回一個(gè)非零值。整個(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í)現(xiàn)基于用戶的訪問權(quán)限控制列表(ACL)系統(tǒng)

假設(shè)你需要添加一個(gè)使用訪問權(quán)限控制列表的機(jī)制來指定哪些用戶對(duì)項(xiàng)目的哪些部分有推送權(quán)限。某些用戶具有全部的訪問權(quán),其他人只對(duì)某些子目錄或者特定的文件具有推送權(quán)限。要搞定這一點(diǎn),所有的規(guī)則將被寫入一個(gè)位于服務(wù)器的原始 Git 倉庫的 acl 文件。我們讓 update 掛鉤檢閱這些規(guī)則,審視推送的提交內(nèi)容中需要修改的所有文件,然后決定執(zhí)行推送的用戶是否對(duì)所有這些文件都有權(quán)限。

我們首先要?jiǎng)?chuàng)建這個(gè)列表。這里使用的格式和 CVS 的 ACL 機(jī)制十分類似:它由若干行構(gòu)成,第一項(xiàng)內(nèi)容是 avail 或者 unavail,接著是逗號(hào)分隔的規(guī)則生效用戶列表,最后一項(xiàng)是規(guī)則生效的目錄(空白表示開放訪問)。這些項(xiàng)目由 | 字符隔開。

下例中,我們指定幾個(gè)管理員,幾個(gè)對(duì) doc 目錄具有權(quán)限的文檔作者,以及一個(gè)對(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ī)則(譯注:也就是省略了 unavail 部分)。下面這個(gè)方法生成一個(gè)關(guān)聯(lián)數(shù)組,它的主鍵是用戶名,值是一個(gè)該用戶有寫權(quán)限的所有目錄組成的數(shù)組:

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

針對(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ù),下面需要找出哪些位置將要被提交的內(nèi)容修改,從而確保試圖推送的用戶對(duì)這些位置有全部的權(quán)限。

使用 git log 的 --name-only 選項(xiàng)(在第二章里簡(jiǎn)單的提過)我們可以輕而易舉的找出一次提交里修改的文件:

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

README
lib/test.rb

使用 get_acl_access_data 返回的 ACL 結(jié)構(gòu)來一一核對(duì)每一次提交修改的文件列表,就能找出該用戶是否有權(quán)限推送所有的提交內(nèi)容:

# 僅允許特定用戶修改項(xiàng)目中的特定子目錄
def check_directory_perms
  access = get_acl_access_data('acl')
  # 檢查是否有人在向他沒有權(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 || # 用戶擁有完全訪問權(quán)限
          (path.index(access_path) == 0) # 或者對(duì)此位置有訪問權(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

以上的大部分內(nèi)容應(yīng)該都比較容易理解。通過 git rev-list 獲取推送到服務(wù)器內(nèi)容的提交列表。然后,針對(duì)其中每一項(xiàng),找出它試圖修改的文件然后確保執(zhí)行推送的用戶對(duì)這些文件具有權(quán)限。一個(gè)不太容易理解的 Ruby 技巧是path.index(access_path) ==0 這句,它的返回真值如果路徑以 access_path開頭——這是為了確保 access_path并不是只在允許的路徑之一,而是所有準(zhǔn)許全選的目錄都在該目錄之下。

現(xiàn)在你的用戶沒法推送帶有不正確的提交信息的內(nèi)容,也不能在準(zhǔn)許他們?cè)L問范圍之外的位置做出修改。

只允許 Fast-Forward 類型的推送

剩下的最后一項(xiàng)任務(wù)是指定只接受 fast-forward 的推送。在 Git 1.6 或者更新版本里,只需要設(shè)定 receive.denyDeletesreceive.denyNonFastForwards選項(xiàng)就可以了。但是通過掛鉤的實(shí)現(xiàn)可以在舊版本的 Git 上工作,并且通過一定的修改它它可以做到只針對(duì)某些用戶執(zhí)行,或者更多以后可能用的到的規(guī)則。

檢查這一項(xiàng)的邏輯是看看提交里是否包含從舊版本里能找到但在新版本里卻找不到的內(nèi)容。如果沒有,那這是一次純 fast-forward 的推送;如果有,那我們拒絕此次推送:

# 只允許純 fast-forward 推送
def check_fast_forward
  missed_refs = `git rev-list #{$newrev}..#{$oldrev}`
  missed_ref_count = missed_refs.split("\n").size
  if missed_ref_count > 0
    puts "[POLICY] Cannot push a non fast-forward reference"
    exit 1
  end
end

check_fast_forward

一切都設(shè)定好了。如果現(xiàn)在運(yùn)行chmod u+x .git/hooks/update—— 修改包含以上內(nèi)容文件的權(quán)限,然后嘗試推送一個(gè)包含非 fast-forward 類型的索引,會(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] Cannot push a non fast-forward reference
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) (8338c5) (c5b616)

注意這是從 update 腳本開頭輸出到標(biāo)準(zhǔn)你輸出的。所有從腳本輸出的提示都會(huì)發(fā)送到客戶端,這點(diǎn)很重要。

下一個(gè)值得注意的部分是錯(cuò)誤信息。

[POLICY] Cannot push a non fast-forward reference
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'

我們將為每一個(gè)被掛鉤拒之門外的索引受到一條遠(yuǎn)程信息,解釋它被拒絕是因?yàn)橐粋€(gè)掛鉤的原因。

而且,如果那個(gè) ref 字符串沒有包含在任何的提交里,我們將看到前面腳本里輸出的錯(cuò)誤信息:

[POLICY] Your message is not formatted correctly
又或者某人想修改一個(gè)自己不具備權(quán)限的文件然后推送了一個(gè)包含它的提交,他將看到類似的提示。比如,一個(gè)文檔作者嘗試推送一個(gè)修改到 lib 目錄的提交,他會(huì)看到

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

全在這了。從這里開始,只要 update 腳本存在并且可執(zhí)行,我們的倉庫永遠(yuǎn)都不會(huì)遭到回轉(zhuǎn)或者包含不符合要求信息的提交內(nèi)容,并且用戶都被鎖在了沙箱里面。

客戶端掛鉤

這種手段的缺點(diǎn)在于用戶推送內(nèi)容遭到拒絕后幾乎無法避免的抱怨。辛辛苦苦寫成的代碼在最后時(shí)刻慘遭拒絕是十分悲劇且具有迷惑性的;更可憐的是他們不得不修改提交歷史來解決問題,這怎么也算不上王道。

逃離這種兩難境地的法寶是給用戶一些客戶端的掛鉤,在他們作出可能悲劇的事情的時(shí)候給以警告。然后呢,用戶們就能在提交--問題變得更難修正之前解除隱患。由于掛鉤本身不跟隨克隆的項(xiàng)目副本分發(fā),所以必須通過其他途徑把這些掛鉤分發(fā)到用戶的 .git/hooks 目錄并設(shè)為可執(zhí)行文件。雖然可以在相同或單獨(dú)的項(xiàng)目?jī)?nèi) 容里加入并分發(fā)它們,全自動(dòng)的解決方案是不存在的。

首先,你應(yīng)該在每次提交前核查你的提交注釋信息,這樣你才能確保服務(wù)器不會(huì)因?yàn)椴缓蠗l件的提交注釋信息而拒絕你的更改。為了達(dá)到這個(gè)目的,你可以增加'commit-msg'掛鉤。如果你使用該掛鉤來閱讀作為第一個(gè)參數(shù)傳遞給git的提交注釋信息,并且與規(guī)定的模式作對(duì)比,你就可以使git在提交注釋信息不符合條件的情況下,拒絕執(zhí)行提交。

#!/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è)腳本放在這個(gè)位置 (.git/hooks/commit-msg) 并且是可執(zhí)行的, 并且你的提交注釋信息不是符合要求的,你會(huì)看到:

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

在這個(gè)實(shí)例中,提交沒有成功。然而如果你的提交注釋信息是符合要求的,git會(huì)允許你提交:

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

接下來我們要保證沒有修改到 ACL 允許范圍之外的文件。加入你的 .git 目錄里有前面使用過的 ACL 文件,那么以下的 pre-commit 腳本將把里面的規(guī)定執(zhí)行起來:

#!/usr/bin/env ruby

$user    = ENV['USER']

# [ insert acl_access_data method from above ]

# 只允許特定用戶修改項(xiàng)目重特定子目錄的內(nèi)容
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ì)錄的方式,可是目前的提交都還沒被記錄下來呢,所以這個(gè)列表只能從暫存區(qū)域獲取。和原來的

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

不同,現(xiàn)在要用

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

不同的就只有這兩點(diǎn)——除此之外,該腳本完全相同。一個(gè)小陷阱在于它假設(shè)在本地運(yùn)行的賬戶和推送到遠(yuǎn)程服務(wù)端的相同。如果這二者不一樣,則需要手動(dòng)設(shè)置一下 $user 變量。

最后一項(xiàng)任務(wù)是檢查確認(rèn)推送內(nèi)容中不包含非fast-forward 類型的索引,不過這個(gè)需求比較少見。要找出一個(gè)非 fast-forward 類型的索引,要么衍合超過某個(gè)已經(jīng)推送過的提交,要么從本地不同分支推送到遠(yuǎn)程相同的分支上。

既然服務(wù)器將給出無法推送非 fast-forward 內(nèi)容的提示,而且上面的掛鉤也能阻止強(qiáng)制的推送,唯一剩下的潛在問題就是衍合一次已經(jīng)推送過的提交內(nèi)容。

下面是一個(gè)檢查這個(gè)問題的 pre-rabase 腳本的例子。它獲取一個(gè)所有即將重寫的提交內(nèi)容的列表,然后檢查它們是否在遠(yuǎn)程的索引里已經(jīng)存在。一旦發(fā)現(xiàn)某個(gè)提交可以從遠(yuǎn)程索引里衍變過來,它就放棄衍合操作:

!/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é)中不曾提到的語法。通過這一句可以獲得一個(gè)所有已經(jīng)完成推送的提交的列表:

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

SHA^@語法解析該次提交的所有祖先。這里我們從檢查遠(yuǎn)程最后一次提交能夠衍變獲得但從所有我們嘗試推送的提交的 SHA 值祖先無法衍變獲得的提交內(nèi)容——也就是 fast-forward 的內(nèi)容。

這個(gè)解決方案的硬傷在于它有可能很慢而且常常沒有必要——只要不用 -f來強(qiáng)制推送,服務(wù)器會(huì)自動(dòng)給出警告并且拒絕推送內(nèi)容。然而,這是個(gè)不錯(cuò)的練習(xí)而且理論上能幫助用戶避免一次將來不得不折回來修改的衍合操作。

以上內(nèi)容是否對(duì)您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)