Git
Chapters ▾ 2nd Edition

8.4 自定义 Git - 使用强制策略的一个例子

使用强制策略的一个例子

在本节中,你将应用前面学到的知识建立这样一个 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 内部原理 了解到这些底层命令的细节;现在暂时先看一下这条命令的输出:

$ 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 目录具有权限的文档作者,以及一位仅对 libtests 目录具有权限的开发人员,相应的 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.denyDeletesreceive.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 来强制推送,服务器就会自动给出警告并且拒绝接受推送。 然而,这是个不错的练习,而且理论上能帮助你避免一次以后可能不得不回头修补的变基。