章节 ▾ 第二版

5.3 分布式 Git - 维护项目

维护项目

除了了解如何有效地为一个项目做贡献外,你很可能还需要知道如何维护一个项目。这包括接受和应用通过 format-patch 生成并以电子邮件发送给你的补丁,或者为你项目中添加的远程仓库集成远程分支上的更改。无论你是维护一个权威仓库,还是想通过验证或批准补丁来提供帮助,你都需要知道如何以对其他贡献者最清晰、对自己长期维护最可持续的方式来接受工作成果。

使用主题分支

当你考虑集成新的工作时,通常最好在主题分支(topic branch)中尝试——这是一个专门为尝试新工作而创建的临时分支。这样,你可以轻松地单独调整补丁,如果它不起作用,可以将其搁置,直到你有时间再回来处理它。如果你根据要尝试的工作主题创建一个简单的分支名称(例如 ruby_client 或其他描述性名称),如果需要暂时放弃它并在稍后回来处理,你就可以很容易地记住它。Git 项目的维护者也倾向于为这些分支使用命名空间,例如 sc/ruby_client,其中 sc 是贡献该工作的人名的缩写。记得吗,你可以像这样基于 master 分支创建分支

$ git branch sc/ruby_client master

或者,如果你想立即切换到它,可以使用 checkout -b 选项

$ git checkout -b sc/ruby_client master

现在你已经准备好将收到的贡献代码添加到这个主题分支中,并确定是否要将其合并到你的长期分支中。

应用电子邮件中的补丁

如果你通过电子邮件收到一个需要集成到项目中的补丁,你需要将其应用到你的主题分支中进行评估。有两种应用电子邮件补丁的方法:使用 git applygit am

使用 apply 应用补丁

如果你收到的补丁是对方用 git diff 或 Unix diff 命令的某种变体生成的(不推荐这样做;请参阅下一节),你可以用 git apply 命令应用它。假设你将补丁保存在 /tmp/patch-ruby-client.patch,你可以这样应用补丁

$ git apply /tmp/patch-ruby-client.patch

这会修改你的工作目录中的文件。它几乎等同于运行 patch -p1 命令来应用补丁,尽管它更谨慎,且比 patch 接受更少的模糊匹配。如果文件添加、删除和重命名是在 git diff 格式中描述的,它也能处理,而 patch 则无法做到。最后,git apply 采用“全部应用或全部中止”模式,即要么全部应用,要么什么都不做;而 patch 可以部分应用补丁文件,这可能会使你的工作目录处于混乱状态。总的来说,git applypatch 保守得多。它不会为你创建提交——运行它之后,你必须手动暂存并提交所引入的更改。

你也可以在真正应用补丁之前,先用 git apply 检查补丁是否能干净利落地应用——你可以用补丁运行 git apply --check

$ git apply --check 0001-see-if-this-helps-the-gem.patch
error: patch failed: ticgit.gemspec:1
error: ticgit.gemspec: patch does not apply

如果没有输出,说明补丁可以干净地应用。如果检查失败,该命令也会以非零状态退出,因此如果你想在脚本中使用它,是可以做到的。

使用 am 应用补丁

如果贡献者是 Git 用户,并且能够使用 format-patch 命令生成补丁,那么你的工作就容易多了,因为补丁中包含了作者信息和提交信息。如果可以的话,鼓励你的贡献者使用 format-patch 而不是 diff 来生成补丁。你只有在处理旧式补丁或类似情况时才应该使用 git apply

要应用由 format-patch 生成的补丁,请使用 git am(该命令名为 am,因为它用于“从邮箱中应用一系列补丁”)。从技术上讲,git am 被设计用来读取 mbox 文件,这是一种简单的纯文本格式,用于在一个文本文件中存储一个或多个电子邮件消息。它看起来像这样

From 330090432754092d704da8e76ca5c05c198e71a8 Mon Sep 17 00:00:00 2001
From: Jessica Smith <jessica@example.com>
Date: Sun, 6 Apr 2008 10:17:23 -0700
Subject: [PATCH 1/2] Add limit to log function

Limit log functionality to the first 20

这是你在上一节中看到的 git format-patch 命令输出的开头;它也代表了一种有效的 mbox 电子邮件格式。如果有人使用 git send-email 正确地将补丁通过电子邮件发送给你,并且你将其下载为 mbox 格式,那么你可以让 git am 指向该 mbox 文件,它就会开始应用其中看到的所有补丁。如果你运行的邮件客户端可以将多封电子邮件保存为 mbox 格式,你可以将整个补丁系列保存到一个文件中,然后使用 git am 一次一个地应用它们。

但是,如果有人将通过 git format-patch 生成的补丁文件上传到了票务系统或类似的地方,你可以将该文件保存在本地,然后将该文件传给 git am 进行应用

$ git am 0001-limit-log-function.patch
Applying: Add limit to log function

你可以看到它应用得很干净,并自动为你创建了新的提交。作者信息取自电子邮件的 FromDate 头信息,提交信息取自电子邮件的 Subject 和正文(补丁之前的内容)。例如,如果这个补丁是从上面的 mbox 示例中应用的,生成的提交看起来会像这样

$ git log --pretty=fuller -1
commit 6c5e70b984a60b3cecd395edd5b48a7575bf58e0
Author:     Jessica Smith <jessica@example.com>
AuthorDate: Sun Apr 6 10:17:23 2008 -0700
Commit:     Scott Chacon <schacon@gmail.com>
CommitDate: Thu Apr 9 09:19:06 2009 -0700

   Add limit to log function

   Limit log functionality to the first 20

Commit 信息指明了应用该补丁的人员及应用时间。Author 信息是最初创建该补丁的人员及最初创建的时间。

但补丁有可能无法干净地应用。也许你的主分支与补丁构建时的分支已经偏移太远,或者该补丁依赖于你尚未应用的其他补丁。在这种情况下,git am 进程将失败并询问你想要做什么

$ git am 0001-see-if-this-helps-the-gem.patch
Applying: See if this helps the gem
error: patch failed: ticgit.gemspec:1
error: ticgit.gemspec: patch does not apply
Patch failed at 0001.
When you have resolved this problem run "git am --resolved".
If you would prefer to skip this patch, instead run "git am --skip".
To restore the original branch and stop patching run "git am --abort".

该命令会在出现问题的文件中加入冲突标记,这与冲突的合并或变基操作非常相似。解决此问题的方法是一样的——编辑文件以解决冲突,暂存新文件,然后运行 git am --resolved 以继续处理下一个补丁

$ (fix the file)
$ git add ticgit.gemspec
$ git am --resolved
Applying: See if this helps the gem

如果你想让 Git 更智能地尝试解决冲突,可以给它传递一个 -3 选项,这会让 Git 尝试进行三路合并。默认情况下不开启此选项,因为如果补丁所基于的提交不在你的仓库中,它就无法工作。如果你确实拥有那个提交(如果补丁是基于一个公共提交生成的),那么使用 -3 选项通常在应用冲突补丁时会聪明得多

$ git am -3 0001-see-if-this-helps-the-gem.patch
Applying: See if this helps the gem
error: patch failed: ticgit.gemspec:1
error: ticgit.gemspec: patch does not apply
Using index info to reconstruct a base tree...
Falling back to patching base and 3-way merge...
No changes -- Patch already applied.

在这种情况下,如果不使用 -3 选项,该补丁会被视为冲突。由于使用了 -3 选项,该补丁得以干净地应用。

如果你要应用 mbox 中的多个补丁,也可以在交互模式下运行 am 命令,它会在找到每个补丁时停下来,询问你是否要应用它

$ git am -3 -i mbox
Commit Body is:
--------------------------
See if this helps the gem
--------------------------
Apply? [y]es/[n]o/[e]dit/[v]iew patch/[a]ccept all

如果你保存了多个补丁,这会很有用,因为如果你不记得补丁的内容,可以先查看它,或者如果你已经应用过该补丁,可以选择跳过。

当你的主题分支中的所有补丁都已应用并提交后,你可以决定是否以及如何将它们集成到长期运行的分支中。

检出远程分支

如果你的贡献来自一个 Git 用户,他设置了自己的仓库,并向其中推送了一些更改,然后发给你仓库的 URL 以及更改所在的远程分支名称,你可以将他们添加为远程仓库并在本地进行合并。

例如,如果 Jessica 发邮件告诉你她在她仓库的 ruby-client 分支中有一个很棒的新功能,你可以通过添加远程仓库并在本地检出该分支来测试它

$ git remote add jessica https://github.com/jessica/myproject.git
$ git fetch jessica
$ git checkout -b rubyclient jessica/ruby-client

如果她后来又发邮件给你另一个包含其他很棒功能的分支,因为你已经设置了远程仓库,你可以直接执行 fetchcheckout

如果你与某人持续合作,这种方式最有用。如果有人偶尔只贡献一个补丁,那么通过电子邮件接受它可能比要求每个人运行自己的服务器,并为了几个补丁而不断地添加和删除远程仓库要省时得多。你也不太可能想要拥有数百个远程仓库,每个远程仓库只对应一个偶尔贡献一两个补丁的人。然而,脚本和托管服务可能会让这变得更容易——这很大程度上取决于你如何开发,以及你的贡献者如何开发。

这种方法的另一个优点是,你还可以获得提交的历史记录。虽然你可能确实会遇到合并问题,但你知道他们的工作是基于你历史记录中的哪个位置;默认使用标准的三路合并,而不必手动提供 -3 选项,也不必寄希望于补丁是基于你能够访问的公共提交生成的。

如果你不是与某人持续合作,但仍然想以这种方式从他们那里获取代码,你可以将远程仓库的 URL 提供给 git pull 命令。这会进行一次性的拉取,并且不会将该 URL 保存为远程引用

$ git pull https://github.com/onetimeguy/project
From https://github.com/onetimeguy/project
 * branch            HEAD       -> FETCH_HEAD
Merge made by the 'recursive' strategy.

确定引入了什么

现在你有一个包含贡献代码的主题分支。此时,你可以决定对它做什么。本节回顾了几个命令,以便你能看到如何使用它们来精确审查合并到主分支后将引入的内容。

查看该分支中存在但 master 分支中不存在的所有提交记录通常很有帮助。通过在分支名称前添加 --not 选项,可以排除 master 分支中的提交。这与我们之前使用的 master..contrib 格式效果相同。例如,如果你的贡献者发给你两个补丁,你创建了一个名为 contrib 的分支并将这些补丁应用在其中,你可以运行此命令

$ git log contrib --not master
commit 5b6235bd297351589efc4d73316f0a68d484f118
Author: Scott Chacon <schacon@gmail.com>
Date:   Fri Oct 24 09:53:59 2008 -0700

    See if this helps the gem

commit 7482e0d16d04bea79d0dba8988cc78df655f16a0
Author: Scott Chacon <schacon@gmail.com>
Date:   Mon Oct 22 19:38:36 2008 -0700

    Update gemspec to hopefully work better

要查看每个提交引入了哪些更改,请记住你可以将 -p 选项传递给 git log,它会附加每个提交所引入的差异(diff)。

要查看如果你将此主题分支与其他分支合并后会发生什么的完整差异,你可能需要使用一个巧妙的技巧来获得正确的结果。你可能想运行这个

$ git diff master

该命令会给你一个差异对比,但它可能会产生误导。如果你的 master 分支在你从它创建主题分支之后又向前推进了,那么你会得到看似奇怪的结果。发生这种情况的原因是,Git 直接比较了你当前所在的主题分支的最后一次提交快照,与 master 分支的最后一次提交快照。例如,如果你在 master 分支的文件中添加了一行代码,直接对比快照看起来就像主题分支要删除那一行一样。

如果 master 是你主题分支的直接祖先,这就不是问题;但如果两条历史路径已经分叉,差异对比看起来就像是你在主题分支中添加了所有新内容,并且删除了 master 分支中特有的所有内容。

你真正想要看的是添加到主题分支中的更改——即如果你将此分支与 master 合并后将引入的工作成果。你可以通过让 Git 比较主题分支的最后一次提交与它和 master 分支的第一个共同祖先来实现。

从技术上讲,你可以通过明确找出共同祖先,然后对它运行差异对比来实现

$ git merge-base contrib master
36c7dba2c95e6bbb78dfa822519ecfec6e1ca649
$ git diff 36c7db

或者,更简洁地

$ git diff $(git merge-base contrib master)

然而,这两种方式都不是特别方便,因此 Git 提供了另一种简写方式来完成同样的事情:三点语法。在 git diff 命令的上下文中,你可以通过在另一个分支后面加上三个点,来对你当前所在分支的最后一次提交与它和另一个分支的共同祖先进行 diff 操作

$ git diff master...contrib

该命令只显示你的当前主题分支自与 master 的共同祖先以来所引入的工作成果。这是一个非常值得记住的实用语法。

集成贡献的工作

当主题分支中的所有工作都准备好集成到主线分支时,问题就是如何去做。此外,你想要使用什么样的整体工作流来维护你的项目?你有多种选择,我们将介绍其中几种。

合并工作流

一种基本的工作流是简单地将所有工作直接合并到你的 master 分支中。在这种情况下,你有一个基本上是稳定代码的 master 分支。当你认为主题分支中的工作已经完成,或者有人贡献并经你验证后的工作准备好时,你将其合并到你的 master 分支,删除刚合并的主题分支,然后重复此过程。

例如,如果我们有一个仓库,其中 ruby_clientphp_client 两个分支中有工作,看起来像 带有几个主题分支的历史记录,我们先合并 ruby_client,然后合并 php_client,你的历史记录最终看起来会像 主题分支合并后

History with several topic branches
图 72. 带有几个主题分支的历史记录
After a topic branch merge
图 73. 主题分支合并后

这可能是最简单的工作流,但如果你处理的是大型或更稳定的项目,并且非常小心所引入的内容,它可能会有问题。

如果你的项目比较重要,你可能需要使用两阶段合并周期。在这种情况下,你有两个长期运行的分支:masterdevelop。你决定仅在发布非常稳定的版本时才更新 master 分支,而所有新代码都集成到 develop 分支中。你会定期将这两个分支推送到公共仓库。每次有新的主题分支要合并时(主题分支合并前),你将其合并到 develop 中(主题分支合并后);然后,当你标记一个发布版本时,你将 master 快进(fast-forward)到目前稳定的 develop 分支所在的位置(项目发布后)。

Before a topic branch merge
图 74. 主题分支合并前
After a topic branch merge
图 75. 主题分支合并后
After a project release
图 76. 项目发布后

这样,当人们克隆你的项目仓库时,他们既可以检出 master 来构建最新的稳定版本并轻松跟上进度,也可以检出 develop,这是包含最新前沿内容的分支。你还可以通过拥有一个 integrate 分支来扩展这个概念,所有工作都在其中合并。然后,当该分支上的代码库稳定并经过测试后,你将其合并到 develop 分支;当它在一段时间内被证明稳定后,你再快进你的 master 分支。

大型合并工作流

Git 项目有四个长期分支:masternextseen(以前称为 'pu',即 proposed updates,用于新工作),以及用于维护向后移植(backports)的 maint。当贡献者引入新工作时,它们会以类似于我们所描述的方式收集在维护者的仓库主题分支中(见 管理一系列复杂的并行贡献主题分支)。此时,会对这些主题进行评估,以确定它们是否安全且准备好被采用,或者是否需要更多工作。如果它们是安全的,它们会被合并到 next 中,该分支会被推送上去,以便每个人都能尝试集成的这些主题。

Managing a complex series of parallel contributed topic branches
图 77. 管理一系列复杂的并行贡献主题分支

如果这些主题仍需要工作,它们会被合并到 seen 中。当确定它们完全稳定后,这些主题会被重新合并到 master 中。然后,nextseen 分支会从 master 重建。这意味着 master 几乎总是向前推进,next 会偶尔重构(rebased),而 seen 的重构频率甚至更高

Merging contributed topic branches into long-term integration branches
图 78. 将贡献的主题分支合并到长期集成分支

当一个主题分支最终被合并到 master 时,它就会从仓库中移除。Git 项目还有一个从上一个版本中分支出来的 maint 分支,用于在需要维护发布时提供向后移植的补丁。因此,当你克隆 Git 仓库时,你可以检出四个分支,根据你想要使用多么前沿的版本或者想要如何贡献,来评估项目的不同阶段;而维护者则拥有一套结构化的工作流来帮助审查新的贡献。Git 项目的工作流是专门化的。为了清楚地理解这一点,你可以查阅 Git 维护者指南

变基与拣选工作流

其他维护者更喜欢在他们的 master 分支之上变基(rebase)或拣选(cherry-pick)贡献的代码,而不是将其合并,以保持基本线性的历史。当你确定想要集成某个主题分支中的工作时,你会切换到该分支并运行变基命令,在当前 master(或 develop 等)分支之上重建这些更改。如果效果良好,你可以快进你的 master 分支,最终你将得到一个线性的项目历史。

将引入的工作从一个分支移动到另一个分支的另一种方法是拣选(cherry-pick)。Git 中的拣选就像是针对单个提交的变基。它获取提交中引入的补丁,并尝试在你当前所在的分支上重新应用它。如果你在主题分支上有多个提交,并且只想集成其中一个,或者如果你在主题分支上只有一个提交,并且你更愿意拣选它而不是运行变基,那么这非常有用。例如,假设你的项目历史看起来像这样

Example history before a cherry-pick
图 79. 拣选前的历史示例

如果你想把提交 e43a6 拉取到你的 master 分支中,你可以运行

$ git cherry-pick e43a6
Finished one cherry-pick.
[master]: created a0a41a9: "More friendly message when locking the index fails."
 3 files changed, 17 insertions(+), 3 deletions(-)

这会拉取 e43a6 中引入的相同更改,但你会得到一个新的提交 SHA-1 值,因为应用日期不同。现在你的历史记录看起来像这样

History after cherry-picking a commit on a topic branch
图 80. 在主题分支上拣选提交后的历史

现在你可以删除你的主题分支,并丢弃那些你不想拉取的提交。

Rerere

如果你在进行大量的合并和变基,或者在维护一个长期存在的主题分支,Git 有一个名为“rerere”的功能可以提供帮助。

Rerere 代表“reuse recorded resolution”(重用记录的解决方案)——这是一种简化手动冲突解决的方法。当开启 rerere 时,Git 会保留成功合并时的预状态和后状态。如果它注意到有一个冲突看起来与你之前修复过的冲突完全一样,它就会直接使用上次的修复方案,而无需再麻烦你。

此功能包含两部分:一个配置设置和一个命令。配置设置是 rerere.enabled,将其放在全局配置中非常方便

$ git config --global rerere.enabled true

现在,无论何时你执行合并并解决了冲突,该解决方案都会被记录在缓存中,以防将来需要使用。

如果需要,你可以使用 git rerere 命令与 rerere 缓存进行交互。当单独调用它时,Git 会检查其解决方案数据库,并尝试寻找与当前任何合并冲突的匹配项并解决它们(尽管如果 rerere.enabled 设置为 true,此过程会自动完成)。还有一些子命令可以查看将记录什么内容、从缓存中删除特定的解决方案以及清除整个缓存。我们将在 Rerere 中更详细地介绍它。

标记你的发布

当你决定发布一个版本时,你可能需要分配一个标签,以便在以后的任何时间都可以重新创建该版本。你可以按照 Git 基础 中讨论的方法创建新标签。如果你决定以维护者的身份对标签进行签名,标记过程可能看起来像这样

$ git tag -s v1.5 -m 'my signed 1.5 tag'
You need a passphrase to unlock the secret key for
user: "Scott Chacon <schacon@gmail.com>"
1024-bit DSA key, ID F721C45A, created 2009-02-09

如果你对标签进行了签名,可能会遇到分发用于签名标签的公共 PGP 密钥的问题。Git 项目的维护者通过将他们的公钥作为一个 blob 包含在仓库中,然后添加一个直接指向该内容的标签来解决这个问题。为此,你可以通过运行 gpg --list-keys 来确定你想要的密钥

$ gpg --list-keys
/Users/schacon/.gnupg/pubring.gpg
---------------------------------
pub   1024D/F721C45A 2009-02-09 [expires: 2010-02-09]
uid                  Scott Chacon <schacon@gmail.com>
sub   2048g/45D02282 2009-02-09 [expires: 2010-02-09]

然后,你可以通过导出密钥并将其通过管道传给 git hash-object,直接将密钥导入 Git 数据库。这会将包含这些内容的新 blob 写入 Git,并返回该 blob 的 SHA-1 值

$ gpg -a --export F721C45A | git hash-object -w --stdin
659ef797d181633c87ec71ac3f9ba29fe5775b92

现在你的密钥内容已在 Git 中,你可以通过指定 hash-object 命令给出的新 SHA-1 值,创建一个直接指向它的标签

$ git tag -a maintainer-pgp-pub 659ef797d181633c87ec71ac3f9ba29fe5775b92

如果你运行 git push --tagsmaintainer-pgp-pub 标签将与所有人共享。如果有谁想验证标签,他们可以直接通过从数据库中拉取 blob 并将其导入 GPG 来导入你的 PGP 密钥

$ git show maintainer-pgp-pub | gpg --import

他们可以使用该密钥来验证你所有的签名标签。此外,如果你在标签信息中包含了说明,运行 git show <tag> 将允许你为最终用户提供关于标签验证的更具体说明。

生成构建版本号

因为 Git 没有类似 'v123' 这样单调递增的数字来随每个提交一起对应,如果你想让提交有一个人类可读的名字,可以在该提交上运行 git describe。作为响应,Git 会生成一个字符串,由该提交之前最近的标签名、自该标签以来的提交次数以及该提交的部分 SHA-1 值(前缀为表示 Git 的字母 "g")组成

$ git describe master
v1.6.2-rc1-20-g8c5b85c

通过这种方式,你可以导出快照或构建版本,并将其命名为人们可以理解的名字。事实上,如果你从克隆的 Git 仓库源代码构建 Git,git --version 会给你类似于这样的输出。如果你正在描述一个你直接打过标签的提交,它只会给你标签名。

默认情况下,git describe 命令需要注解标签(使用 -a-s 标志创建的标签);如果你也想利用轻量级(非注解)标签,请在命令中添加 --tags 选项。你也可以将此字符串作为 git checkoutgit show 命令的目标,尽管它依赖于末尾缩写的 SHA-1 值,所以它可能不会永久有效。例如,Linux 内核最近从 8 位字符增加到 10 位,以确保 SHA-1 对象的唯一性,因此旧的 git describe 输出名称就作废了。

准备发布

现在你想发布一个构建版本。你想要做的事情之一是为那些不使用 Git 的可怜人创建一个代码的最新快照归档。执行此操作的命令是 git archive

$ git archive master --prefix='project/' | gzip > `git describe master`.tar.gz
$ ls *.tar.gz
v1.6.2-rc1-20-g8c5b85c.tar.gz

如果有人解压该压缩包,他们会得到一个放在 project 目录下的项目最新快照。你也可以通过向 git archive 传递 --format=zip 选项,以同样的方式创建 zip 压缩包

$ git archive master --prefix='project/' --format=zip > `git describe master`.zip

现在你已经有了项目发布版本的 tar 包和 zip 压缩包,可以上传到你的网站或通过电子邮件发送给人们了。

Shortlog(简要日志)

是时候给那些想要了解项目动态的邮件列表发送电子邮件了。一个快速获取自上次发布或上次发邮件以来添加到项目中的变更日志的简单方法是使用 git shortlog 命令。它会汇总你指定范围内的所有提交;例如,如果你的上次发布版本名为 v1.0.1,以下命令会给你汇总自上次发布以来的所有提交

$ git shortlog --no-merges master --not v1.0.1
Chris Wanstrath (6):
      Add support for annotated tags to Grit::Tag
      Add packed-refs annotated tag support.
      Add Grit::Commit#to_patch
      Update version and History.txt
      Remove stray `puts`
      Make ls_tree ignore nils

Tom Preston-Werner (4):
      fix dates in history
      dynamic version method
      Version bump to 1.0.2
      Regenerated gemspec for version 1.0.2

你会得到一个按作者分组、自 v1.0.1 以来所有提交的清晰摘要,你可以将其通过电子邮件发送给你的列表。