章节 ▾ 第二版

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

一个 Git 强制策略的例子

在本节中,你将使用所学知识来建立一个 Git 工作流程,该流程检查自定义提交消息格式,并且只允许特定用户修改项目中特定的子目录。你将构建客户端脚本,以帮助开发者了解他们的推送是否会被拒绝,以及服务器脚本,以实际执行这些策略。

我们展示的脚本是用 Ruby 编写的;部分原因是我们的智力惰性,但也因为 Ruby 易于阅读,即使你不能编写它。然而,任何语言都可以使用——与 Git 一起分发的所有示例钩子脚本都是用 Perl 或 Bash 编写的,因此你也可以通过查看示例来了解这两种语言中的大量钩子示例。

服务端钩子

所有服务器端工作都将进入你 hooks 目录中的 update 文件。 update 钩子每次推送分支时运行一次,并接受三个参数:

  • 要推送到的引用的名称

  • 该分支所在的旧版本

  • 正在推送的新版本

如果推送是通过 SSH 运行的,你还可以访问执行推送的用户。 如果你允许每个人通过公钥认证以单个用户(例如“git”)连接,你可能需要为该用户提供一个 shell 包装器,该包装器根据公钥确定哪个用户正在连接,并相应地设置一个环境变量。 在这里,我们假设连接的用户位于 $USER 环境变量中,因此你的更新脚本首先收集你需要的所有信息

#!/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 内部原理 中详细介绍所有这些底层命令;但现在,这就是该命令给你的

$ 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),该列表指定哪些用户可以推送对项目的哪些部分的更改。 有些人拥有完全访问权限,而另一些人只能推送对某些子目录或特定文件的更改。 为了强制执行此操作,你需要将这些规则写入到服务器上的裸 Git 存储库中的名为 acl 的文件中。 你将让 update 钩子查看这些规则,查看为所有正在推送的提交引入了哪些文件,并确定执行推送的用户是否有权更新所有这些文件。

你首先要做的是编写你的 ACL。 在这里,你将使用与 CVS ACL 机制非常相似的格式:它使用一系列行,其中第一个字段是 availunavail,下一个字段是以逗号分隔的应用于该规则的用户的列表,最后一个字段是该规则应用到的路径(空白表示开放访问)。 所有这些字段都由管道 (|) 字符分隔。

在这种情况下,你有一些管理员,一些有权访问 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)
  # 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 基础 中简要提及)来非常轻松地查看单个提交中修改了哪些文件

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

请记住,你在更新脚本的开头就打印了它。 你的脚本回显到 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 告诉你更新脚本以非零状态退出,这就是拒绝你的推送的原因。 最后,你有这个

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 变量。

我们在这里可以做的另一件事是确保用户不推送非快进引用。 要获取不是快进的引用,你要么必须在已经推送的提交之后进行变基,要么尝试将不同的本地分支推送到相同的远程分支。

据推测,服务器已经配置了 receive.denyDeletesreceive.denyNonFastForwards 来强制执行此策略,因此你可以尝试捕获的唯一意外事情是已经推送的提交的变基。

这是一个检查此问题的 pre-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

此脚本使用了 修订选择 中未涵盖的语法。 你可以通过运行以下命令获取已推送的提交的列表

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

SHA^@ 语法解析为该提交的所有父提交。 你正在寻找可以从远程上的最后一个提交访问的任何提交,并且无法从你要推送的任何 SHA-1 的任何父提交访问 - 这意味着它是快进。

这种方法的主要缺点是它可能非常慢并且通常是不必要的 - 如果你不尝试使用 -f 强制推送,服务器会警告你并且不接受推送。 然而,这是一个有趣的练习,并且理论上可以帮助你避免你以后可能需要回去修复的变基。

scroll-to-top