社交编程:拉取请求 - 当事情变得复杂时怎么办

工程 | Dave Syer | 2011 年 7 月 18 日 | ...

场景:你想为托管在公共 Git 仓库服务(如 GitHub)上的开源项目贡献代码。许多人向我参与的项目提交拉取请求,而很多时候它们合并起来比实际需要更复杂,这会稍微减慢整个过程。基本工作流程概念上很简单

  1. fork(分叉)一个公共开源项目
  2. 在本地对其进行一些修改,并将它们推送到你自己的远程分叉仓库
  3. 请求项目负责人将你的修改合并到主代码库

关于这个基本工作流程,在 Keith Donald 的一篇博客 中有很好的介绍。

复杂情况出现在你 fork 项目之后到发送拉取请求这段时间里,主代码库发生了变化,或者(更糟的是)你想针对不同的特性或错误修复发送多个拉取请求,并且需要将它们分开,以便项目所有者可以单独处理它们。本教程旨在帮助你使用 Git 处理这些复杂情况。

此处的描述使用 GitHub 领域语言(如 "pull request"、"fork"、"merge" 等),但相同的原则适用于其他公共 Git 服务。为本教程的目的,我们假设公共项目接受对 master 分支的拉取请求。大多数 Spring 项目都是这样工作的,但其他一些公共项目则不然。你可以将下面出现的词 "master" 替换为正确的分支名称,并且示例应该大致正确。

为了帮助你理解本地发生了什么,下面以 "$" 开头的 shell 命令可以提取到一个脚本中并按顺序运行。最终状态应该是名为 "work" 目录下的一个本地仓库,它有一个 origin 连接到其 master 分支(模拟远程公共项目),并且在私有 fork 上有两个分支。这两个分支的头部内容相同,但提交历史不同(如底部的 ASCII 图所示)。

两个远程仓库

如果你要发送拉取请求,会涉及两个远程仓库:主公共项目,以及你推送修改的 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 是为了让仓库处于分离头指针状态,这样我们稍后可以从克隆的仓库推送到它。)从现在开始,假设 "repo" 是一个公共 GitHub 项目(例如 git://github.com/SpringSource/repo.git)。

在这个克隆命令中,“fork” 的 URL 会像这样:[email protected]/myuserid/repo.git。现在我们创建 fork。这相当于你在 GitHub 上请求 fork 一个仓库时 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 分支进行任何本地修改,你就不会对外部世界的状态感到困惑。此外,如果你要向同一个公共项目发送多个拉取请求,如果你将它们分别放在各自的分支上(即不在 master 分支上),它们就不会相互重叠。

拉取请求

当你想开始进行拉取请求的工作时,从上面提到的完全同步的 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 没有发生变化,你就可以从那里发送拉取请求了。

如果 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

然后我们将为合并后的拉取请求创建一个新分支

$ 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

现在所有我们想要的修改都已到位,但分支指向了错误的提交。如下所示,我希望 mypullrequest 指向 mynewstuff 所在的提交,并且远程 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)

如果我们接受拉取请求包含 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 它来合并(squash)提交,并强制推送,示意图如下

# git rebase -i HEAD~2
...
# git push --force fork

因为 origin/masterfork/mypullrequest 的直接祖先,我知道我的拉取请求将很容易合并。

总结

希望本教程为你提供了足够的 Git 武器库,可以放心地为你喜欢的开源项目做出一些修改并相信合并将很容易。记住做事总有不止一种方法,Git 是一个强大且低级的工具,因此你的经验可能会有所不同,并且你可能会发现上述方法的变体更可取甚至必需,具体取决于你的修改。

获取 Spring 新闻通讯

通过 Spring 新闻通讯保持联系

订阅

领先一步

VMware 提供培训和认证,助力你的进步。

了解更多

获取支持

Tanzu Spring 在一个简单的订阅中提供对 OpenJDK™、Spring 和 Apache Tomcat® 的支持和二进制文件。

了解更多

即将举办的活动

查看 Spring 社区的所有即将举办的活动。

查看全部