场景:您想向托管在公共 Git 仓库服务(如 GitHub)上的开源项目贡献代码。我参与的许多项目都有很多人提交 pull request,但很多时候合并起来比预期的要复杂,这会稍微减慢过程。基本工作流程在概念上很简单:
- Fork 一个公共开源项目
- 在本地对其进行一些更改,并将它们推送到您自己的远程 Fork
- 请项目负责人将您的更改合并到主代码库
并且 Keith Donald 的博客 对这个基本工作流程有精彩的介绍。
当您 Fork 主代码库之后到发送 pull request 之间,主代码库发生了变化,或者(更糟糕的是)您想为不同的功能或 bug 修复发送多个 pull request,并且需要将它们分开,以便项目所有者能够单独处理,这时就会出现复杂情况。本教程旨在帮助您使用 Git 应对这些复杂情况。
这里的描述使用 GitHub 的领域语言(“pull request”、“fork”、“merge” 等),但相同的原则适用于其他公共 Git 服务。在本教程中,我们假设公共项目接受对其 master 分支的 pull request。大多数 Spring 项目都是这样工作的,但一些其他公共项目不是。您可以在下面的内容中用正确的 branch 名称替换“master”一词,相同的示例应该大致正确。
为了帮助您跟踪本地发生的情况,下面以“$”开头的 shell 命令可以提取到一个脚本中并按出现的顺序运行。终端应该是一个名为“work”的目录中的本地仓库,该仓库有一个指向其 master 分支的 origin(模拟远程公共项目)以及私有 Fork 上的两个分支。这两个分支的 HEAD 内容相同,但提交历史不同(如底部的 ASCII 图所示)。
两个远程仓库
如果您要发送 pull request,则涉及两个远程仓库:主公共项目和您推送更改的 Fork。
在某种程度上,这取决于个人喜好,但我喜欢做的是将主项目设置为我工作副本的远程“origin”,并将我的 Fork 用作第二个名为“fork”的远程。这使得跟踪主项目中的情况变得容易,因为我只需要:
# git fetch origin
这样就可以在本地获得所有更改。这也意味着,在执行我的常规 Git 工作流程时,我永远不会感到困惑:
# git checkout master
# git pull --rebase
... build, test, install etc ...
这总能让我与主项目保持同步。我可以通过在从 master 拉取后执行此操作来使我的 Fork 与主项目保持同步:
# git push fork
初始设置
让我们在一个沙盒中创建一个简单的“远程”仓库供您使用。我们不使用 Git 服务提供商,而是在文件系统本地进行(以 UN*X 命令为例)。
$ rm -rf repo fork work
$ git init repo
$ (cd repo; echo foo > foo; git add .; git commit -m "initial"; git checkout `git rev-parse HEAD`)
(上一次 checkout 的目的是将仓库置于分离 HEAD 状态,以便我们稍后可以从中进行推送。)从现在开始,请假装“repo”是公共 GitHub 项目(例如 git://github.com/SpringSource/repo.git)。
这个 clone 命令中的“fork”URL 类似于 [email protected]/myuserid/repo.git。现在我们来创建 Fork。这等同于 GitHub 在您请求 Fork 仓库时所做的操作。
$ git clone repo fork
$ (cd fork; git checkout `git rev-parse HEAD`)
最后,我们需要设置一个工作目录,在那里进行更改(请记住“repo”= git://github.com/SpringSource/repo.git)。
$ git clone repo work
$ cd work
$ git checkout origin/master
因为我们克隆了主公共仓库,所以它默认是远程“origin”。我们将添加一个新的远程,以便我们可以推送我们的更改。
$ git remote add fork ../fork
$ git fetch fork
$ git push fork
现在,本地仓库中有一个提交,在 gitk(或您喜欢的 Git 可视化工具)中看起来如下:
A (origin/master, fork/master, master)
在此图中,“A”是提交标签,括号中列出了与该提交关联的分支。
获取最新内容
您始终可以使用以下命令从主仓库获取最新内容:
# git checkout master
# git pull --rebase
并将其与 Fork 同步:
# git push fork
如果您以这种方式操作,尽可能使主仓库和您的 Fork 之间的 master 保持同步,并且从不在 master 分支上进行任何本地更改,您将永远不会对世界的现状感到困惑。此外,如果您要向同一个公共项目发送多个 pull request,如果将它们保留在自己的分支上(即不在 master 上),它们就不会相互重叠。
Pull Request
当您想开始处理 pull request 时,从完全更新的 master 分支开始(如上所示),然后创建一个新的本地分支:
$ git checkout -b mynewstuff
进行更改、测试等。
$ echo bar > bar
$ echo myfoo > foo
$ git add .
$ git commit -m "Added bar, edited foo"
并使用新的分支名称(而不是 master)将其推送到您的 Fork 仓库:
$ git push fork mynewstuff
如果没有对 origin 做出任何更改,您可以直接发送 pull request。
如果 Origin 发生变化怎么办?
在本教程中,我们通过以下方式模拟 origin 的更改:
$ cd ../repo
$ git checkout master
$ echo spam > spam; git add .; git commit -m "add spam"
$ git checkout `git rev-parse HEAD`
$ cd ../work
现在我们准备好应对这种变化了。首先,我们将本地 master 更新到最新:
$ git checkout master
$ git pull
$ git push fork
本地仓库现在看起来像这样:
A -- B (mynewstuff, fork/mynewstuff)
\
-- D (master, fork/master, origin/master)
请注意,您的新内容没有 origin/master 作为直接祖先(它在另一个分支上)。这使得项目所有者很难合并您的更改。您可以通过在本地完成部分工作,并在发送 pull request 之前将其推送到您的 Fork 来简化此过程。
重写您分支上的历史
如果您没有在您的分支上与任何人协作,那么变基(rebase)到远程仓库的最新更改并强制推送应该完全没问题:
# git checkout mynewstuff
# git rebase master
如果您的更改与远程仓库中发生的事情不兼容,变基可能会失败。您需要修复冲突并提交它们,然后再继续。这会增加您的工作难度,但会使远程项目所有者的工作变得轻松,因为 pull request 保证能够成功合并。
在重写历史时,也许您想将一些提交合并在一起,使补丁更易于阅读,例如:
# git rebase -i HEAD~2
...
无论如何(即使变基顺利进行),如果您已经推送到您的 Fork,您将需要强制推送下一次推送,因为它已经重写了历史(假设远程仓库已更改)。
# git push --force fork mynewstuff
本地仓库现在看起来像这样(B 提交实际上与上一个版本不完全相同,但差异在这里并不重要):
A -- D (master, fork/master, origin/master) -- B (mynewstuff, fork/mynewstuff)
您的新分支有一个直接祖先 origin/master,所以每个人都很高兴。然后,您就可以进入 GitHub UI,从您的分支对 repo:master 发送 pull request 了。
如果我想保留我的本地提交怎么办?
如果您以多个步骤提交了本地更改,也许您想保留所有细小的提交,同时仍将您的 pull request 作为单个提交呈现给远程仓库。这没关系,您可以为此创建一个新分支,然后从该分支发送 pull request。如果您确实与某人协作开发了您的功能分支,并且不想强制推送,这也是一个不错的选择。
首先,我们将新内容推送到 Fork 仓库,以便我们的协作者可以看到它(如果您想将更改保留在本地,则不需要此步骤):
$ git checkout mynewstuff
$ git push fork
然后,我们将创建一个新分支用于合并的 pull request:
$ git checkout master
$ git checkout -b mypullrequest
$ git merge --squash mynewstuff
$ git commit -m "comment for pull request"
$ git push fork mypullrequest
这是本地仓库:
A -- B (mynewstuff, fork/mynewstuff)
\
-- D (master, fork/master, origin/master) -- E (mypullrequest, fork/mypullrequest)
您可以使用这个分支,并且您的新分支有一个直接祖先 origin/master,因此合并将非常简单。
如果您没有在 mynewstuff 分支上协作,您甚至可以在此时将其删除。我经常这样做以保持我的 Fork 清洁:
# git branch -D mynewstuff
# git push fork :mynewstuff
这是本地仓库,与两个远程仓库完全同步:
A -- D (master, fork/master, origin/master) -- E (mypullrequest, fork/mypullrequest)
继续处理您的新内容
假设您的 pull request 被拒绝,并且项目所有者希望您进行一些更改,或者新的内容变得更有趣,您需要对其进行更多工作。
如果您上面没有删除它,您可以继续在您的精细分支上工作……
$ git checkout mynewstuff
$ echo yetmore > foo; git commit -am "yet more"
$ git push fork
然后,当您准备好时,将更改转移到 pull request 分支:
$ git rebase --onto mypullrequest master mynewstuff
我们想要的所有更改都已到位,但是分支指向了错误的提交。如下所示,mynewstuff 处于我希望 mypullrequest 所在的位置,并且远程 fork/mynewstuff 没有对应的本地分支。
A -- B -- C (fork/mynewstuff)
\
-- D (master, fork/master, origin/master) -- E (mypullrequest, fork/mypullrequest) -- F (mynewstuff)
我们可以使用 git reset 来将两个分支切换到我们想要的位置(如果您愿意,您也可以在图形界面中执行此操作)。
$ git checkout mypullrequest
$ git reset --hard mynewstuff
$ git checkout mynewstuff
$ git reset --hard fork/mynewstuff
新的仓库看起来像这样:
A -- B -- C (mynewstuff, fork/mynewstuff)
\
-- D (master, fork/master, origin/master) -- E (fork/mypullrequest) -- F (mypullrequest)
如果我们对 pull request 包含 2 个提交感到满意,我们可以直接推送它:
$ git checkout mypullrequest
$ git push fork
终端看起来像这样:
A -- B -- C(mynewstuff, fork/mynewstuff)
\
-- D (master, fork/master, origin/master) -- E -- F (mypullrequest, fork/mypullrequest)
或者,我们可以变基(rebase)它以合并提交,并强制推送,示意图如下:
# git rebase -i HEAD~2
...
# git push --force fork
因为 origin/master 是 fork/mypullrequest 的直接祖先,所以我知道我的 pull request 将会非常容易合并。
总结
希望本教程能为您提供足够的 Git 工具,以便您可以对您最喜欢的开源项目进行一些更改,并确信合并将很简单。请记住,总有不止一种方法可以做到这一点,而 Git 是一个强大、底层的工具,所以您的体验可能会有所不同,您可能会发现上述方法的一些变体更可取,甚至在某些情况下是必需的,这取决于您的更改。