社交编码:拉取请求 - 复杂情况下的处理方法

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

场景:你想为托管在像 GitHub 这样的公共 Git 代码仓库服务上的开源项目贡献一些代码。很多人向我参与的项目提交拉取请求,很多时候它们的合并比必要的情况更复杂,这会稍微减慢流程。基本的工作流程在概念上很简单:

  1. fork 一个公共开源项目
  2. 在本地进行一些更改并将它们推送到你自己的远程 fork
  3. 请求项目负责人将你的更改与主代码库合并

Keith Donald 的一篇博客 对这个基本工作流程做了很好的说明。

当你 fork 项目和发送拉取请求之间主代码库发生更改时,就会出现复杂情况;或者(更糟糕的是)你想为不同的特性或错误修复发送多个拉取请求,并且需要将它们分开,以便项目所有者可以分别处理它们。本教程旨在帮助你使用 Git 应对这些复杂情况。

这里的描述使用了 GitHub 的术语(“拉取请求”、“fork”、“合并”等),但相同的原则也适用于其他公共 Git 服务。在本教程中,我们假设公共项目在其 master 分支上接受拉取请求。大多数 Spring 项目都是这样工作的,但其他一些公共项目则不是。你可以将下面的“master”替换为正确的分支名称,这些示例应该大致正确。

为了帮助你了解本地发生了什么,下面以“$”开头的 shell 命令可以提取到一个脚本中,并按照它们出现的顺序运行。最终结果应该是一个名为“work”的目录中的本地仓库,该仓库有一个链接到其 master 分支的 origin(模拟远程公共项目)和 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 仓库时所做的操作:

$ 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"

并将其使用新的分支名称(不是 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 来简化这个过程。

重写你的分支上的历史记录

如果你没有与任何人合作处理你的分支,那么将它重新设置到远程仓库的最新更改并强制推送应该是完全没问题的:

# git checkout mynewstuff
# git rebase master

如果你进行了与远程仓库中发生的某些更改不兼容的更改,则重新设置可能会失败。你需要修复冲突并在继续之前提交它们。这会让你很难受,但会让远程项目所有者轻松,因为拉取请求保证能够成功合并。

在重写历史记录时,你可能希望将一些提交压缩在一起以使补丁更容易阅读,例如:

# 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发送你的分支的拉取请求。

如果我想保留我的本地提交怎么办?

如果你在本地分多个步骤提交了更改,你可能希望保留所有的小提交,并且仍然将你的拉取请求作为单个提交呈现给远程仓库。没问题,你可以为此创建一个新分支并从那里发送拉取请求。如果你正在与其他人合作处理你的功能分支并且不想强制推送,这也是一个好主意。

首先,我们将新内容推送到 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

现在我们想要的所有更改都已到位,但分支位于错误的提交上。如下所示,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)

如果我们对拉取请求包含 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/masterfork/mypullrequest的直接祖先,所以我知道我的拉取请求将很容易合并。

总结

希望本教程能为您提供足够的 Git 知识,以便您可以放心地对您最喜欢的开源项目进行一些更改,并确保合并过程顺利进行。请记住,方法不止一种,Git 是一款功能强大的底层工具,因此您的实际效果可能会有所不同,您可能会发现上述方法的变体更可取,甚至必要,具体取决于您的更改。

获取 Spring 电子简讯

通过 Spring 电子简讯保持联系

订阅

抢先一步

VMware 提供培训和认证,助您快速提升技能。

了解更多

获取支持

Tanzu Spring 通过单一订阅提供 OpenJDK™、Spring 和 Apache Tomcat® 的支持和二进制文件。

了解更多

即将举行的活动

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

查看全部