场景:您想为托管在 GitHub 等公共 Git 存储库服务上的开源项目贡献一些代码。很多人向我参与的项目发送拉取请求,而且很多时候,它们的合并比需要的时间更复杂,这在一定程度上减缓了流程。基本工作流程在概念上很简单
- 分叉一个公共开源项目
- 在本地进行一些更改并将它们推送到您自己的远程分叉
- 请求项目负责人将您的更改与主代码库合并
并且 Keith Donald 的一篇博客 中对这个基本工作流程进行了很好的描述。
当您分叉它与您发送拉取请求之间的时间间隔内主代码库发生更改时,或者(更糟糕的是)您想要为不同的功能或错误修复发送多个拉取请求,并且需要将它们分开以便项目所有者可以单独处理它们时,就会出现复杂情况。本教程旨在帮助您使用 Git 应对这些复杂情况。
此处的描述使用 GitHub 领域语言(“拉取请求”、“分叉”、“合并”等),但相同的原则也适用于其他公共 Git 服务。出于本教程的目的,我们假设公共项目在其 master 分支上接受拉取请求。大多数 Spring 项目都是这样工作的,但其他一些公共项目并非如此。您可以用正确的分支名称替换下面的“master”一词,所有相同的示例都应该大致正确。
为了帮助您了解本地发生了什么,以下以“$”开头的 shell 命令可以提取到脚本中并按出现的顺序运行。端点应该是名为“work”的目录中的本地存储库,该存储库具有链接到其 master 分支的 origin(模拟远程公共项目)以及私有分叉上的两个分支。这两个分支在头部具有相同的内容,但提交历史记录不同(根据底部的 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`)
(那里的最后一个检出是为了使存储库处于分离头状态,以便我们稍后可以从克隆中推送到它。)从现在起,假设“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
并将其与你的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"
并将它推送到你的fork仓库,使用新的分支名称(而不是master)
$ 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来简化操作。
在你的分支上重写历史
如果你没有与任何人合作开发你的分支,那么应该绝对可以将它重新定位到远程仓库的最新更改,并强制推送
# 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包含两个提交,我们可以按原样推送它
$ 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
的直接祖先,所以我知道我的pull request将很容易合并。
总结
希望本教程为你提供了足够的Git知识,让你可以继续对喜爱的开源项目进行一些更改,并确信合并将很容易。请记住,总有多种方法可以做到这一点,Git是一个强大的低级工具,因此你的体验可能会有所不同,并且你可能会发现上面方法的变体更可取,甚至必要,具体取决于你的更改。