Git 与社交编码:如何无惧合并

工程 | Dave Syer | December 21, 2010 | ...

Git 非常适合社交编码和社区对开源项目的贡献:贡献者可以轻松尝试代码,并且有大量人员可以分叉和试验代码,而不会危及现有用户。本文介绍了一些 Git 命令行示例,这些示例可能有助于您对这一过程建立信心:如何 fetch、pull 和 merge,以及如何回滚错误。如果您对社交编码过程本身以及如何为 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'..." 日志消息并保持历史记录简洁线性,那么这里就是您应该来的地方。如果您已在社交编码网站上注册并想将您的更改合并到您最喜欢的开源项目中,本文将帮助您对此更有信心,但您仍应阅读您的代码托管提供商关于分叉和合并的文档。希望那时一切都会水到渠成。

本文将引导您了解 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 处于 detached 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 相同的路线达到同样的结果——先 merge master,然后 rebase 会达到相同的终点,因为 rebase 足够聪明,能够意识到它可以节省一些重复并不会显示没有新更改的中间状态。)

现在一切看起来都 OK(差不多),但 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 的结果只是 OK差不多——它有重复的提交(CD,当你仔细看时,它们有相同的日志消息和相同的更改),因此合并不会很漂亮。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)

大家都开心了!所以强制推送在某些情况下是可以接受的。特别是,对于在“主”项目分叉上工作的人来说,这是可以接受的,这在社交编码(如在 Github 上)中经常出现。让我们更详细地看看那个用例。

分叉和社交编码

Git 的一个预期的有用特性是它可以被用作分布式仓库——您不必采用 SVN 和旧系统中常见的单源方法。当您从公共开源项目分叉然后请求项目所有者将您的部分更改合并到主仓库时,分布式特性被大量但非广泛地使用。

那么让我们假设有一个很酷的开源项目叫 main,由 Mary 所有,Bob 去项目主页将其分叉。他得到一个新仓库,其中包含 main 仓库 Git 索引的精确副本,并且他可以随心所欲地命名(他选择 bob-main 以帮助我们区分)。这部分的 Git 操作很简单——实际上他只是克隆 main,将 origin 引用移到他在服务器上自己空间的新位置,然后将更改推送上去。社交编码应用会幕后处理所有这些,并友好地建议 Bob 克隆他的新远程分叉。

所以现在我们有一个 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 对 main 项目有一个很棒的想法,所以他创建了他的 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 只需将更改与她的 main 项目整合起来

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

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

方案 2:在分叉中强制推送

如果上面的 rebase 失败了,或者 Mary 简单地认为如果 Bob 希望他的更改被合并,那么使历史记录线性是他的责任,她可以要求他 rebase 到她的 master 分支。她通过巧妙的社交编码网站给他发送一条消息,然后重置她的本地副本

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

所以现在他有了一个指向 main 仓库的只读引用以及它的别名,这样他就可以快速地与 Mary 的工作同步。(别名是可选的,但这将帮助他保持最新状态并一眼看出他的 master 相对于 Mary 的位置。)首先,他使他的 master 与 main 同步

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

正是在这里,我们看到了在 feature 分支上工作的优势:如果 master 分支没有本地更改,将其与 main 仓库合并总是微不足道的(master 永远不会超前于 main/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 实际上是他分叉仓库中唯一拥有写入权限的人,所以 Mary 要求他在那里强制推送,他一点也不介意。这绝不是 Git 吸引开源开发者的唯一特性,但这在很大程度上解释了为什么我们中的一些人正在转向像 Github 这样的网站。

获取 Spring 新闻通讯

订阅 Spring 新闻通讯,保持联系

订阅

领先一步

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

了解更多

获取支持

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

了解更多

即将举办的活动

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

查看全部