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