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

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

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

Grails 一直在 Github 上,并且在社区贡献方面拥有良好的经验,因此 SpringSource 的一些其他项目也开始迁移到那里。一些迁移的项目是新的(例如 Spring AMQP),而一些项目已经建立,并已从 SVN 迁移(例如 Spring Batch)。还有一些 Spring 项目位于 SpringSource 托管的 Gitorious 实例上,例如 Spring Integration。Github 和 Gitorious 上的社交编码过程略有不同,但底层的 Git 操作是相同的,这就是我们在此处介绍的内容。希望阅读本文并可能完成示例后,您会受到启发尝试新的模型并为 Spring 项目做出贡献。Git 很有趣,并且具有一些非常适合此类开发的功能。

如果您从未使用过 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而不是分支名称,以便原始版本保留分离的 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)

从那里,他可以坐下来等待其他人解决这个问题。简出现了……

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

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

Jane 也是拥有测试存储库写入权限的开发人员。她比 Bob 更大胆,并决定需要变基以保持历史记录的线性。

$ 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,然后变基具有相同的端点,因为变基足够聪明,可以意识到它可以节省一些重复,并且不会显示不包含任何新更改的中间状态。)

现在一切看起来都还可以,但是 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 从这里接受提示并合并,她真的会后悔的。变基的结果实际上只是还可以——它有重复的提交(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 的 origin),以及一个与main存储库相同的bob-main存储库。让我们使其从只有一个提交开始以保持简单(因此,从第一个示例中获取 origin 创建方法,并在第一次提交后停止)。

A (master)

Mary 的本地副本与 Bob 的初始副本相同,两者看起来像这样

A (master,origin/master)

但它们的源代码引用不同。对于 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 对于主项目有了一个很棒的想法,所以他创建了他的功能分支并开始编码,最终得到了这个结果

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

他对这个结果很满意,所以他把它推回到他自己的源代码仓库

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

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

Mary 进行了一些更改

Mary 是项目所有者,她可以随时向她的主分支推送代码。所以她做了这件事

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

Bob 发送了一个拉取请求

现在 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 的代码与她的主分支有所不同。她应该怎么做?

方案一:不强制推送

如果没有任何冲突,这种情况可能非常简单。她决定花一些时间清理历史记录,以防万一很容易。这与 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,因为它不再标记任何重要内容,并且它没有跟踪远程分支,因此她也不必处理该引用。

方案二:在 Fork 中强制推送

如果上面的变基失败了,或者 Mary 认为如果 Bob 想要合并他的更改,那么让他使历史记录线性化是他的责任,她可以让他基于她的主分支进行变基。她通过精巧的社交编码网站给他发送一条消息,然后重置她的本地副本

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

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

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

所以现在他拥有对主仓库的只读引用以及它的别名,以便他可以快速地与 Mary 的工作同步。(别名是可选的,但它将帮助他保持最新状态,并一目了然地看到他的主分支相对于 Mary 的主分支的位置。)首先,他将他的主分支与主仓库同步

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

在这里,我们可以看到在功能分支上工作的优势:如果保持本地更改为空(主分支从不领先于主分支/主分支),则始终可以轻松地将主分支与主仓库合并。现在他尝试 Mary 要求的变基

$ 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 是有自主能力的成年人,Bob 的仓库中功能分支存在的唯一原因是固定拉取请求,因此其他人不太可能跟踪该分支。如果有人正在跟踪该分支,他们可能会感到不便,如果他们在该分支上标记了公共版本,则可能会严重不便。这是 Bob 决定承担的风险——事实上,在这个例子中,这根本没有风险,因为 Bob 是唯一拥有他仓库写权限的人,而且他非常确信没有人使用他的分支来发布版本。

结论

除非更改微不足道,否则合并贡献的过程并不简单,但 Git 确实减轻了很多痛苦,而且一旦你掌握了窍门就足够容易了。本例中的关键点是 Git 正在使用特定的风格,并且有一些约束和约定使其更容易:Bob 和 Mary 的仓库彼此之间是只读的,而 Bob 实际上是唯一拥有他 fork 写权限的人,所以他根本不介意 Mary 想要他强制推送。这远不是 Git 使其对开源开发者更有趣的唯一功能,但它在很大程度上解释了为什么我们中的一些人正在转向像Github这样的网站。

获取 Spring 新闻通讯

通过 Spring 新闻通讯保持联系

订阅

领先一步

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

了解更多

获取支持

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

了解更多

即将举行的活动

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

查看全部