-
1. 起步
-
2. Git 基础
-
3. Git 分支
-
4. 服务器上的 Git
- 4.1 协议
- 4.2 在服务器上部署 Git
- 4.3 生成 SSH 公钥
- 4.4 架设服务器
- 4.5 Git Daemon
- 4.6 Smart HTTP
- 4.7 GitWeb
- 4.8 GitLab
- 4.9 第三方托管服务
- 4.10 小结
-
5. 分布式 Git
-
A1. 附录 A: Git 在其他环境
- A1.1 图形界面
- A1.2 Visual Studio 中的 Git
- A1.3 Visual Studio Code 中的 Git
- A1.4 IntelliJ / PyCharm / WebStorm / PhpStorm / RubyMine 中的 Git
- A1.5 Sublime Text 中的 Git
- A1.6 Bash 中的 Git
- A1.7 Zsh 中的 Git
- A1.8 PowerShell 中的 Git
- A1.9 小结
-
A2. 附录 B: 在应用程序中嵌入 Git
-
A3. 附录 C: Git 命令
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)来指定哪些用户可以向你的项目的哪些部分推送更改。有些人拥有完全访问权限,而另一些人只能将更改推送到某些子目录或特定文件。为了强制执行此操作,你将这些规则写入一个名为 acl 的文件,该文件位于服务器上的裸 Git 仓库中。你将让 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)
# 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 变量。
我们在这里可以做的另一件事是确保用户不会推送非快进引用。要获取一个非快进引用,你要么必须在一个你已经推送的提交之后进行 rebase,要么尝试将不同的本地分支推送到同一个远程分支。
大概,服务器已经配置了 receive.denyDeletes 和 receive.denyNonFastForwards 来强制执行此策略,所以你唯一可以尝试捕获的意外事情是 rebase 已经推送的提交。
这是一个检查此情况的 pre-rebase 脚本示例。它获取你将要重写的所有提交的列表,并检查它们是否存在于你的任何远程引用中。如果它发现其中一个可从你的一个远程引用中到达,它会中止 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 强制推送,服务器会警告你并且不接受推送。但是,这是一个有趣的练习,理论上可以帮助你避免以后可能需要返回并修复的 rebase。