场景:您想为托管在 github 等公共 git 仓库服务上的开源项目贡献代码。很多人向我参与的项目发送拉取请求,很多时候它们的合并比应有的复杂,这会稍微减慢流程。基本工作流程概念上很简单:
- fork 一个公共开源项目
- 在本地对其进行一些更改并将其推送到您自己的远程 fork
- 请求项目负责人将您的更改与主代码库合并
有关此基本工作流程的精彩描述,请参阅 Keith Donald 的博客文章。
当您 fork 项目和发送拉取请求之间主代码库发生更改时,或者(更糟糕的是)您想为不同的功能或 bug 修复发送多个拉取请求,并且需要将它们分开以便项目所有者可以单独处理时,复杂性就出现了。本教程旨在帮助您使用 git 解决这些复杂问题。
这里的描述使用了 github 领域语言(“拉取请求”、“fork”、“合并”等),但同样的原则适用于其他公共 git 服务。在本教程中,我们假设公共项目在其 master 分支上接受拉取请求。大多数 Spring 项目都这样做,但其他一些公共项目则不然。您可以将下面的“master”替换为正确的分支名称,所有示例应该大致正确。
为了帮助您跟踪本地发生的情况,以下以“$”开头的 shell 命令可以提取到脚本中并按顺序运行。终点应该是一个本地仓库,位于名为“work”的目录中,其 origin 连接到其 master 分支(模拟远程公共项目),并在私有 fork 上有两个分支。这两个分支的 HEAD 内容相同,但提交历史不同(如下图 ASCII 图所示)。
两个远程仓库
如果您要发送拉取请求,涉及两个远程仓库:主公共项目和您推送更改的 fork。
在某种程度上这取决于个人偏好,但我喜欢做的是将主项目设为我的工作副本的远程“origin”,并将我的 fork 用作名为“fork”的第二个远程。这样可以轻松跟踪主项目中发生的情况,因为我只需
# git fetch origin
所有更改都可以在本地获得。这也意味着当我执行我的自然 git 工作流程时,我永远不会感到困惑
# git checkout master
# git pull --rebase
... build, test, install etc ...
这总是使我与主项目保持同步。通过在从 master pull 之后执行此操作,我可以简单地保持我的 fork 与主项目同步
# git push fork
初始设置
让我们在一个沙箱中创建一个简单的“远程”仓库来工作。我们将直接在您的文件系统中进行操作(使用 UN*X 命令作为示例),而不是使用 git 服务提供商。
$ 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 是将仓库留在分离头状态,以便稍后我们可以从克隆推送到它。)从现在开始,假装“repo”是一个公共 github 项目(例如 git://github.com/SpringSource/repo.git
)。
此克隆命令中的“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
如果您以这种方式操作,尽可能保持 master 在主仓库和您的 fork 之间同步,并且永远不对 master 分支进行任何本地更改,那么您将永远不会对世界其他地方的情况感到困惑。此外,如果您要向同一个公共项目发送多个拉取请求,如果您将它们分别放在各自的分支上(即不在 master 上),它们就不会相互重叠。
拉取请求
当您想开始处理拉取请求时,从完全最新的 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 没有变化,您可以从那里发送拉取请求。
如果 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
作为直接祖先(它位于另一个分支上)。这使得项目所有者合并您的更改变得很麻烦。您可以通过在本地自行完成一些工作,并在发送拉取请求之前将其推送到您的 fork 来使其更容易。
重写您分支上的历史记录
如果您没有与任何人协作在您的分支上,那么将您的分支 rebase 到远程仓库的最新更改上并强制推送应该是完全没问题的。
# git checkout mynewstuff
# git rebase master
如果您所做的更改与远程仓库中发生的事情不兼容,rebase 可能会失败。您需要解决冲突并提交,然后才能继续。这会给您带来麻烦,但对远程项目所有者来说很容易,因为拉取请求保证能成功合并。
在您重写历史记录时,也许您想将一些提交合并(squash)在一起,以便更容易阅读补丁,例如:
# git rebase -i HEAD~2
...
无论如何(即使 rebase 顺利进行),如果您已经推送到您的 fork,您需要强制进行下一次推送,因为它已经重写了历史记录(假设远程仓库已经更改)。
# git push --force fork mynewstuff
本地仓库现在看起来像这样(B 提交实际上与之前版本并不完全相同,但这里的差异并不重要)
A -- D (master, fork/master, origin/master) -- B (mynewstuff, fork/mynewstuff)
您的新分支有一个直接祖先是 origin/master
,所以大家都满意了。然后您就可以在 github UI 中针对 repo:master
发送您分支的拉取请求了。
如果我想保留我的本地提交怎么办?
如果您分步提交了您的更改,也许您想保留您所有细小的提交,但仍然将您的拉取请求作为单个提交呈现给远程仓库。没问题,您可以为此创建一个新分支并从那里发送拉取请求。如果您正在与某人协作在您的功能分支上,并且不想强制推送,这样做也是一个很好的方法。
首先我们将新内容推送到 fork 仓库,以便我们的合作者可以看到它(如果您想将更改保留在本地,这不是必需的)
$ git checkout mynewstuff
$ git push fork
然后我们将为合并(squashed)的拉取请求创建一个新分支
$ 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)
继续处理您的新内容
假设您的拉取请求被拒绝,项目所有者要求您进行一些更改,或者新内容变得更有趣,您需要对其做更多工作。
如果您没有在上面删除它,您可以继续在您的精细分支上工作...
$ 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)
或者我们可以对其进行 rebase 以合并提交并强制推送,示意图如下
# git rebase -i HEAD~2
...
# git push --force fork
因为 origin/master
是 fork/mypullrequest
的直接祖先,我知道我的拉取请求将非常容易合并。
总结
希望本教程为您提供了足够的 git 弹药,可以继续并对您喜欢的开源项目进行一些更改,并确信合并将非常容易。请记住,总是存在不止一种方法,git 是一个强大的底层工具,因此您的体验可能会有所不同,并且您可能会发现上述方法的变体更可取甚至必要,这取决于您的更改。