Git 和社交编码:如何无畏地合并

工程 | Dave Syer | 2010年12月21日 | ...

Git 非常适合社交编码和开源项目的社区贡献:贡献者可以轻松地试用代码,并且可以有大量的人进行分叉和实验,而不会危及现有用户。本文提供了一些 Git 命令行示例,可能有助于您对这个过程建立信心:如何获取、拉取和合并,以及如何撤销错误。如果您对社交编码过程本身以及如何为 Spring 项目做出贡献感兴趣,请查看此网站上 Keith Donald 的另一篇博客

Grails 在 Github 上已经存在一段时间了,并且社区贡献的体验非常棒,因此 SpringSource 的其他一些项目也开始迁移到那里。一些迁移的项目是新的(例如 Spring AMQP),而一些已经成熟并从 SVN 迁移过来(例如 Spring Batch)。在 SpringSource 托管的 Gitorious 实例上也有一些 Spring 项目,例如 Spring Integration。Github 和 Gitorious 的社交编码流程略有不同,但底层的 Git 操作是相同的,而这正是我们在这里要介绍的。希望在阅读完本文并可能通过示例进行实践后,您会受到启发,尝试新的模型并为 Spring 项目做出贡献。Git 很有趣,并且具有一些很棒的功能,非常适合此类开发。

如果您从未用过 Git,那么这里可能不是开始学习的地方。如果您正从 SVN 迁移到 Git,并且在出现问题时不够自信,或者您想摆脱那些烦人的“Merged branch 'master'...”日志消息,并保持历史记录的简洁线性,那么这里就是为您准备的。如果您已在社交编码网站注册,并希望将您的更改合并到您最喜欢的开源项目中,本文将帮助您对此更有信心,但您仍应阅读您托管网站关于 fork 和 merge 的文档。希望届时一切都会变得清晰明了。

本文将引导您了解 Git 和多个用户的一些简单但常见的场景。我们首先介绍两个用户共享一个存储库,并展示他们可能遇到的某些陷阱以及一些自我挽救的技巧。然后,我们将进入一个社交编码示例,其中仍有两个用户,但现在有两个远程存储库。这在开源项目中很常见,并且在变更管理方面具有一些优势,我们将看到这一点。

起源

我们将从设置一个简单的存储库开始,用于进行一些示例。以下是一些您可以在任何 UN*X shell 中自行执行的 Git 命令行操作,然后是一个 Git 索引的草图,展示提交和分支的布局。

$ mkdir test; cd test; touch foo.txt bar.txt
$ git init .
$ git add .
$ git status
To be added
$ git commit -m "Initial"
[master (root-commit) 5f1191e] initial
 2 files changed, 2 insertions(+), 0 deletions(-)
 create mode 100644 bar.txt
 create mode 100644 foo.txt
$ git checkout -b feature
$ echo bar > bar.txt
$ git commit -am "change bar"
$ git checkout master
$ echo foo > foo.txt
$ git commit -am "change foo
A - B (master)
  \
    C (feature)

一个简单的布局,但足够复杂以至于很有趣。有 3 个提交(我们在图中省略了提交消息),以及两个独立的分支。这两个分支是故意设计成没有冲突的——它们包含不同文件的更改。如果您正在使用命令行示例,并且也想查看索引树,请使用 Git UI 工具(我使用的是 gitk --all,我认为它在所有平台上都可用)。

最后一件事是准备好此存储库以供克隆。

$ git checkout HEAD~1 

我们故意使用了一个引用 HEAD1 而不是分支名称,以便 origin 留下一个分离的 HEAD。如果您习惯了远程存储库工作流,这就会有意义,因为我们正在本地模拟一个远程存储库,而远程存储库通常是“裸”的(没有签出的分支)。HEAD1 引用表示“回退一步,但不要将新的 HEAD 分配给任何分支”,这使得以后可以从克隆中推送更改到存储库。

Bob 克隆并跟踪分支

Bob 是我们存储库的第一个用户。这是他的终端和本地存储库中的索引布局。

$ git clone test bob
$ cd bob
$ git checkout --track origin/feature
A - B (master,origin/master)
  \
    C (feature,origin/feature)

Bob 知道 feature 分支是实验性的,但现在已经过测试,所以他想将其合并到 master 中以包含在下一个版本中。但是,如果他从这里合并,他会得到一个非线性的混乱(尽管实际上没有冲突)。

$ git merge master
Merge made by recursive.
 foo.txt |    2 +-
 1 files changed, 1 insertions(+), 1 deletions(-)
A - B (master,origin/master) - D (feature) "Merge branch 'master' into feature"
  \                           /
    C (origin/feature) ------

Bob 讨厌这样。历史是非线性的,因此很难看出所有更改的来源,而且还留下了可怕的自动生成的提交消息“Merge branch 'master'...”。(无论是将 feature 合并到 master,还是将 master 合并到 feature,结果都是相同的结构,具有相同的祖先和相同的子提交,只是自动生成的提交消息略有不同。)从这里进行推送是合法的,但他最终会向所有人展示丑陋的历史以及不太有用的自动生成的注释。

Bob 不要惊慌!由于他还没有推送任何内容,他仍然可以恢复到原始索引。

$ git reset --hard origin/feature
A - B (master,origin/master)
  \
    C (feature,origin/feature)

从那里,他可以坐下来,等待别人来解决问题。然后 Jane 出现了……

(请注意,并非每个人都同意 Bob 的观点,即不必要的非线性历史和没有新更改的自动生成的提交日志是不好的。有些人实际上认为有并行开发迹象是“令人欣慰”的。他们通常不使用 rebase,而更喜欢简单的 pull 和 merge 方法与 Git 进行协作。)

Jane 克隆另一个副本并执行本地 Rebase

Jane 也是一个有权访问测试存储库的开发人员。她比 Bob 更大胆,并决定需要进行 rebase 以保持历史的线性。

$ git clone test jane
$ cd jane
$ git checkout --track origin/feature
$ git rebase master
A - B (master,origin/master) - D (feature)
  \
    C (origin/feature)

(请注意,Jane 可以通过与 Bob 相同的路线获得相同的结果——合并 master,然后进行 rebase,因为 rebase 足够智能,可以意识到它可以节省一些重复,并且不显示不包含任何新更改的中间状态。)

现在一切看起来都还可以(ish),但 git 不允许推回到 origin,因为 feature 已经分叉。

$ git push
To file:///path/to/test
 ! [rejected]        feature -> feature (non-fast-forward)
error: failed to push some refs to 'file:///path/to/test'
To prevent you from losing history, non-fast-forward updates were rejected
Merge the remote changes before pushing again.  See the 'Note about
fast-forwards' section of 'git push --help' for details.

如果 Jane 听从提示并在此处合并,她将真的后悔。rebase 的结果实际上只是一般般——它有重复的提交(CD,在进行 quint 操作时具有相同的日志消息和相同的更改),因此合并将不会好看。Git 只会按照她告诉它的那样做,并且合并是合法的,但效果将是:

  • 非线性的历史
  • 自动生成的提交消息
  • 重复的提交消息(每个祖先分支上一个)

这是结果。

$ git merge origin/feature 
Merge made by recursive.
A - B (master,origin/master) - D "change bar" - E (feature) "Merge branch 'master' into feature"
  \                                            /
    C (origin/feature) "change bar" ----------

她只对源代码进行了两次更改,但结果是索引中有 5 个提交。这太糟糕了。要恢复,她可以使用与之前相同的技巧,只是现在在 D 提交处没有命名的分支。她可以添加一个,或者使用 UI 工具(gitk 在这方面做得很好),或者使用相对引用。

$ git reset --hard HEAD~1
A - B (master,origin/master) - D (feature)
  \
    C (origin/feature)

不友好的做法,也是所有 Git 手册警告你的做法,就是强制推送。Jane 尝试了一下。

$ git push --force
A - B (master,origin/master) - D (feature,origin/feature)

这样就好多了!两次更改和三个提交(变化的两侧各一个),以及一个漂亮的线性历史,没有令人不悦的提交消息。那么,为什么这是一个糟糕的做法呢?让我们再次看看我们不幸的朋友 Bob。

Bob 现在可能处于混乱之中

如果他没有修改“feature”分支,他会没事的。

$ git checkout master
$ git pull
remote: Counting objects: 5, done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 3 (delta 0), reused 0 (delta 0)
Unpacking objects: 100% (3/3), done.
From file:///path/to/test
 + 4b223e2...4db65c2 feature       -> origin/feature  (forced update)
Already up-to-date.
A - B (master,origin/master) - D (origin/feature)
  \
    C (feature)

这里看起来有点丑陋,但 Git 已经将一切都整合在一起了。Bob 可以看到 Jane(或某人)强制更新了他正在跟踪的远程分支,所以他的本地分支是由于他的过错而分叉的。他可能对此有点恼火,但在这种情况下,这是无害的,因为他没有对他的本地分支进行任何更改,所以他可以简单地重置他的分支。

$ git checkout feature
Switched to branch 'feature'
Your branch and 'origin/feature' have diverged,
and have 1 and 2 different commit(s) each, respectively.
$ git reset --hard origin/feature
A - B (master,origin/master) - D (feature,origin/feature)

皆大欢喜!因此,在某些情况下,强制推送是可以接受的。特别是,对于在“主”项目的 fork 上工作的人来说,这可能是可以接受的,就像在 Github 这样的社交编码网站上经常出现的那样。让我们更详细地研究一下这个用例。

Fork 和社交编码

Git 的预期有用功能之一是它可以作为分布式存储库使用——您不必采用 SVN 和旧系统常见的单一起源方法。当您从公共开源项目 fork 并要求项目所有者将您的某些更改合并到主存储库时,分布式功能会被大量使用,但并非广泛使用。

所以,假设有一个名为 main 的很棒的开源项目,由 Mary 拥有,Bob 从项目主页 fork 了它。他得到了一个具有 main 存储库 Git 索引精确副本的新存储库,并且可以随意命名(他选择 bob-main 以便我们区分)。这部分的 Git 操作非常简单——他实际上只是克隆了 main,将 origin 引用移动到他在服务器上自己的空间的新位置,然后将更改推回。社交编码应用程序会在后台处理所有这些,并乐于建议 Bob 克隆他的新远程 fork。

现在我们有一个 main 存储库(对 Mary 来说是 origin,但对 Bob 不是),以及一个 bob-main 存储库,它们是相同的。为了简单起见,让我们让它从一个提交开始(因此,采用第一个示例中的 origin 创建配方,在第一个提交后停止)。

A (master)

Mary 的本地副本与 Bob 的副本最初是相同的,它们看起来都像这样。

A (master,origin/master)

但它们的 origin 引用是不同的。对于 Mary 来说。

$ git remote -v
origin	git@host:/mary/main (fetch)
origin	git@host:/mary/main (push)

对于 Bob 来说。

$ git remote -v
origin	git@host:/bob/bob-main (fetch)
origin	git@host:/bob/bob-main (push)

通常 Mary 无权推送 Bob 的存储库,反之亦然。

Bob 添加了一个功能

Bob 对主项目有一个很棒的想法,所以他创建了他的 feature 分支并开始编码,最终变成了这样。

$ git checkout -b feature
$ echo foo >> foo.txt
$ git commit -am "change foo"
A(master,origin/master) - C (feature)

他对此很满意,所以他将它推回他自己的 origin。

$ git push origin feature
A(master,origin/master) - C (feature,origin/feature)

注意 Bob 如何将他所有的更改都保留在一个分支上。这并非强制性,但正如我们稍后将看到的,这使得跟踪与 main 存储库的差异变得非常容易(尽管到目前为止 Bob 没有与那里建立明确的连接)。Github 上的用户文档实际上并不推荐这种方法,但您可能会发现它很有用。

Mary 做出了一些更改

Mary 是项目所有者,她可以在任何时候将更改推送到她的 master 分支。所以她这样做了。

$ echo bar >> bar.txt
$ git commit -am "change bar"
$ git push
A - B (master,origin/master)

Bob 发送了一个 Pull Request

现在 Bob 请求 Mary 合并他的更改。Mary 遵循社交编码网站上的友好说明,并拉取 Bob 的更改进行查看。

$ git checkout -b bob master
$ git pull https://host/bob/bob-main feature
A - B (master,origin/master) - D (bob) "Merge branch 'feature' of '...bob-main' into bob"
  \                           /
    C  ----------------------

Mary 立即看到 Bob 的分支与她的 master 分叉了。她该怎么办?

选项 1:不强制推送

在这种情况下,如果没有任何冲突,这可能非常直接。她决定花一些时间清理历史记录,以防万一很容易。这与 Bob 在之前的单一起源示例中使用的过程相同。

$ git reset --hard HEAD~1
$ git rebase master
A - B (master,origin/master) - C (bob)

没有问题,历史记录再次线性化。Mary 只需将更改合并到她的主项目中。

$ git checkout master
$ git merge bob
$ git push
$ git branch -D bob
A - B - C (master,origin/master)

她在最后删除了本地分支 bob,因为它不再标记任何重要的内容,而且它也没有跟踪远程分支,所以她不必处理该引用。

选项 2:在 Fork 中强制推送

如果上面的 rebase 失败,或者 Mary 简单地认为,如果 Bob 希望合并他的更改,那么让他使历史记录线性化的责任就在于他,她可以让他基于她的 master 进行 rebase。她通过那个精巧的社交编码网站给他发了一条消息,然后重置她的本地副本。

$ git checkout master
$ git branch -D bob
$ git prune
A - B (master,origin/master)

现在 Bob 开始工作了。他仍然在他的 feature 分支上,所以。

$ git remote add main https://host/mary/main
$ git fetch main
A (master,origin/master) - B (main/master)
 \
  C (feature,origin/feature)

所以现在他有一个主存储库的只读引用和一个指向它的别名,这样他就可以快速跟上 Mary 的工作。(别名是可选的,但它将帮助他保持最新,并一目了然地看到他的 master 相对于 Mary 的 master 的位置。)首先,他将他的 master 与主存储库保持一致。

$ git checkout master
$ git merge main/master
$ git push
A - B (master,origin/master,main/master)
 \
  C (feature,origin/feature)

在这里,我们看到了在 feature 分支上工作的优势:如果 master 分支没有本地更改(master 永远不会超前于 main/master),那么将 master 与主存储库合并总是很容易的。现在他尝试了 Mary 所要求的 rebase。

$ git checkout feature
$ git rebase master
A - B (master,origin/master,main/master) - D (feature)
 \
  C (origin/feature)

Bob 看到历史记录是他想要的,所以他把它推送到他的远程存储库。

$ git push --force
A - B (master,origin/master,main/master) - D (feature,origin/feature)

Bob 在这里使用了与 Jane 在上一个示例中相同的技巧——他强制推送了一个本地分支以保持线性历史。

Bob 和 Mary 是自愿的成年人,feature 分支存在于 Bob 的存储库中的唯一原因是为了锚定 pull request,因此其他人不太可能跟踪该分支。如果有人跟踪该分支,他们可能会感到不便,甚至可能非常不便,如果他们在该分支上标记了公共发布。这是 Bob 决定承担的风险——实际上在这个例子中没有任何风险,因为 Bob 是唯一有权访问他存储库的人,而且他很有信心没有人使用他的分支进行发布。

结论

除非更改很琐碎,否则合并贡献的过程并不简单,但 Git 确实消除了其中的很多痛苦,一旦您掌握了它,它就足够容易了。示例中的关键点是 Git 正在以一种特定的风格使用,并且存在一些限制和约定使其更容易:Bob 和 Mary 的存储库彼此之间是只读的,而且 Bob 实际上是唯一有权访问他的 fork 的人,所以 Mary 想让他强制推送他一点也不介意。这远非 Git 吸引开源开发者的唯一功能,但它在很大程度上解释了为什么我们中的一些人正在迁移到 Github 这样的网站。

获取 Spring 新闻通讯

通过 Spring 新闻通讯保持联系

订阅

领先一步

VMware 提供培训和认证,助您加速进步。

了解更多

获得支持

Tanzu Spring 提供 OpenJDK™、Spring 和 Apache Tomcat® 的支持和二进制文件,只需一份简单的订阅。

了解更多

即将举行的活动

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

查看所有