理解 Git 工作流
英文原文:Understanding the Git Workflow
如果你没有理解 Git 背后的设计初衷的话,那么你可能处处感受到满满的恶意。因为实在有太多的可能你把 Git 给用歪了,你不受伤谁受伤呢?就好比你拿着一把螺丝刀当锤子使,你确实也能使,但是你不痛苦谁痛苦,螺丝刀还受伤呢。
我们来看看一个普通的 Git 工作流主要分成以下几个部分。
- 基于 Master 分支创建一个工作的分支
- 在工作分支下工作
- 工作完成后将工作分支合并回 Master 分支
大部分时间里,这个工作流程总能如你所愿,因为在你创建分支后 Master 已经发生了改变。然后有一天你将某个功能的分支合并到了 Master 分支,但是 Master 分支却并没有分叉。跟往常每次 Merge 都会创建一个 Merge Commit 不一样的是,Git 直接将 Master 分支的 HEAD 指向了这个功能分支的最后一次提交,也可以称之为 “fast forwards”。如下图解:

很不幸的是,你在该功能分支上开发的时候为了及时备份,你做了多次断点式的提交,而这些断点提交的代码又不太稳定。现在尴尬的是这些功能不稳定的提交没法与 Master 分支上哪些稳定的提交区分开来了。这下如果要将这个功能进行回滚简直就是个噩梦。
好吧,现在你给自己加了一条新的规则:“在每次合并分支之前,一定带上 no-ff 选项来强制 Git 生成一个新的 Commit”。这样确实可以解决上一个问题,然后咱们继续吧。
然后某一天你发现了线上的版本有个很严重的 Bug,你得好好查查究竟是在哪个提交中引入了这个 Bug。你通过 git bisect
命令来定位,最终发现问题就出在某个功能分支的某次断点提交里头。好吧,最终你放弃了 git bisect
,开始手动的查找问题所在了。
最终你已经将问题缩小到了某个文件里头了。然后你执行了 git blame
命令来显示最近的 48 小时内这个文件都做过哪些修改。你知道这根本就不可能,但是 git blame
命令确实告诉你这个文件已经有好几周就没有做过任何修改了。好吧,原来 git blame
命令只会显示从这个文件首次提交之后的修改,而并不会显示它被合并时的修改。实际上这个修改是你在几周之前在这个功能分支的某次断点提交中做的,但是你今天才将这个修改合并到 Master 分支中去的。
好吧,这真是按下葫芦起了瓢啊,no-ff
选项开启后,又把 git bisect
整得不好使了,还有这个 git blame
出现的状况,这些都是因为你非得拿着螺丝刀当锤子使给弄的。
重新认识版本控制
版本控制因两个目的而存在。
第一个是它能帮助我们写好代码。你需要和团队中其他的伙伴们同步你的代码,同时也需要时不时地备份一下你的代码。而这些事情没法通过邮件发送文件压缩包来实现。
第二个是它能帮助我们做好配置管理。其中包括管理并行的开发,例如我们经常需要在开发下一个大版本的时候,时不时地对线上出现的 Bug 进行修复。配置管理还能用于搞清楚到底做了哪些修改,这是一个用来定位 Bug 的好工具。
一般来说,这两个目的之间是存在矛盾的。
当我们正在快速地实现某个功能的原型时,我们会很频繁地进行断点式的提交。不过这些提交通常都是没法编译通过的。
在理想的情况下,在你的修改版本历史中的任一修改都应该是简洁明了并且稳定的。不应该出现那种断点式的提交,也不应该有那种包含了上万行代码修改的提交。一个清晰明了的提交历史,将会使得我们想回滚某些改动或者通过 cherry-pick
命令在不同的分支中应用提交变得十分简单和轻松。另外一个清晰明了的提交历史,也非常便于后续的查看和分析。然而维护一个清晰明了的提交历史就意味着你需要在确认某个修改已经彻底 OK 了之后再确认合并。
那么你究竟应该选择哪种方式呢?是继续保持有规律地进行断点式提交呢?还是保持一个清晰明了的提交历史?
如果你在一个只有两个人的初创团队中,清晰明了的提交历史对你来说不会有太大的帮助。你完全可以在 Master 分支上随意进行提交,也可以随时进行部署。
但是随着你的开发团队和用户基数的增长,问题就变得越来越不一样了,你需要一些工具和技术手段来确保事情不会出错。包括自动化测试,代码审查和清晰明了的提交历史。
功能分支乍看上去还蛮不错的。因为它们可以用来解决基本的并行开发的问题。你只需要在进行合并的时候去考虑这些事情,在你进行功能开发的时候,你可以完全不用去考虑这些。
当你的项目大到一定程度的时候,这个简单的 branch/commit/merge 的工作流就没法胜任了。是时候鸟枪换炮了。你需要一个清晰明了的提交历史。
Git 最牛逼的革新之处就是它能同时满足你的两种诉求。在你快速实现原型的时候,你可以经常提交你的修改,但是在你最终完成的时候,又能以一个非常清晰明了的历史记录进行最终的交付。一旦你设定了这样的目标,你就会发现 Git 默认的各种设定简直就是天造地设。
工作流
假设现在有两种分支:公开的和私密的。
公开的分支是整个项目中最权威的,那么这个公开的分支上的所有提交就必须保持简洁和原子化,并且需要确保每个提交都有良好的提交记录,同时尽可能保持该分支的线性,不要打破。公开的分支包含 Master 和 Release 分支。
私有的分支就完全由你自己支配了。你想在里头怎么折腾就怎么折腾吧。
最安全的做法是私有的分支只在自己工作的本地上创建和使用。如果你确实有需要在办公室和家里进行同步的话,事先告知你的伙伴们你推送上去的这个分支是你自己私有的分支,让他们别也在这个上分支上做事情。
你永远都不能使用普通的 merge
命令将一个私有的分支直接合并到公开的分支上去。在进行合并之前,一定要使用类似于 reset
,rebase
,squash merges
或者 commit amending
这样的工具清理私有的分支的提交历史。
把你自己当作一个作家,把你的每次提交当作一本书中的一个章节。作家从来不会直接发布他们的第一版手稿的。Michael Crichton 说过:“好书不是写出来的——而是改出来的”。
如果你之前有用过其他的版本管理系统,你觉得每次修改的历史记录都应该是铁板钉钉,不能轻易修改提交历史记录的话。那么按照你这个逻辑,我们的文本编辑器就不应该有“撤销”这个功能。
实用主义者只管着不断地改改改,直到改得姥姥都不认识了。而配置管理又只在乎大版本的改动。这样一来,断点式的提交就成为了一个缓冲区了。
如果你想让公开的分支上的提交历史干净又漂亮的话,fast-forward 式的合并就不仔只是安全的了,更应该是首选的合并方式了。因为这样会让整个分支的历史保持线性的演进,并且很容易就能看明白。
还有争论说 -no-ff
合并不会有任何提交记录。有些人会使用合并的提交来作为产品环境最终部署的版本。好吧,这是一个反模式。你用 Tag 啊。
指南和示例
针对当前修改的大小,在这个分支上工作的时间,以及这个分支分叉了多远,我有 3 种不同的处理方法来应对。
短期的改动
大部分时间里,我只需要通过 squash merge
清理一下我的提交历史即可。
假设我创建了一个功能分支,然后在一个小时之内做了多次提交:
1 2 3 4 5 |
git checkout -b private_feature_branch touch file1.txt git add file1.txt git commit -am "WIP" |
当我完成了这个功能的开发之后,我不会简单地使用原生的 git merge
进行合并,我会这么做:
1 2 3 4 |
git checkout master git merge --squash private_feature_branch git commit -v |
然后我会花一分钟时间来好好写一个详细一些的提交记录。
更大的改动
有的时候一个功能可能会连续开发上好几天,功能分支里头也会有很多小的提交。
我认为我做的这些修改就应该分成多个小的修改,这个时候 squash 就有点太过于暴力了。(如我之前所说的一个经验法则,我们可以先问问自己:“这样是不是更便于代码审查?”)
如果我做的这些断点式的提交之间有逻辑顺序的话,我会使用 rebase
的交互模式来进行合并。
rebase
的交互模式很强大。你可以在这个模式下编辑老的提交,将提交拆开,重新排序和压缩提交。
例如在我的功能分支上,执行:
1 2 |
git rebase --interactive master |
这个时候会打开一个编辑器,然后会显示一个 Commmit 的列表。每一行中都由一个操作指令、Commit 的 SHA1 值和提交记录构成。还有一个图例列出了所有可以执行的指令。
默认情况下,每一个 Commit 的操作指令都是 “pick”,这个并不会对 Commit 做任何修改。
1 2 3 4 |
pick ccd6e62 Work on back button pick 1c83feb Bug fixes pick f9d0c33 Start work on toolbar |
我把第二条 Commit 的指令修改为 “squash” 了,这会将第二条 Commit 给压缩到第一条 Commit 中去。
1 2 3 4 |
pick ccd6e62 Work on back button squash 1c83feb Bug fixes pick f9d0c33 Start work on toolbar |
当我保存并且关闭编辑器后,会再打开一个新的编辑器,提示我为这个组合后的 Commit 写提交记录,写完就好了。
功能分支废了
可能是我的功能分支已经开发了太长时间,这个时候我需要将好几个分支合并到我的功能分支上来,才能让我的功能分支能跟得上最新的进展。这么一合并,提交历史就纠缠在一起了。这个时候可以理解为当前工作的分支已经废了,但是已经做了的工作不能直接丢弃啊,这个时候为了避免出现这种情况最简单的办法就是直接创建一个全新的分支,然后再将功能分支的修改应用到这个全新的分支上去:
1 2 3 4 5 |
git checkout master git checkout -b cleaned_up_branch git merge --squash private_feature_branch git reset |
这下我的工作空间里头就有了我之前做的所有的修改,同时也不会有在刚才那个功能分支上合并造成提交历史纠缠不清的负担了。接下来我就可以手动的添加和提交我的改动了。
总结
如果你发现你在纠结 Git 的默认设置,先问问为什么。
将公共分支的提交历史视为不可变的,原子的,易于理解的,将私有分支的提交历史当作一次性的可塑的就好了。
理想中的工作流是这样的:
- 基于公共的分支创建一个私有的分支
- 经常性地将你做好的改动提交到私有分支
- 一旦功能开发完毕,清理好私有分支的提交历史
- 将清理好的私有分支合并到公共分支中去
译后碎碎念
这篇文章中的核心思想很好,读原文也很容易读懂,很容易就能 Get 到原文作者要表达的意图。但是在翻译的过程中发现,这个哥们的行文风格简直就跟咱们的文言文一般,相当的言简意赅,语法感觉非常的俚语化,对于我这种英文半吊子都不够的人来说,翻译起来确实困难重重。
真的翻译完了之后都担心自己是不是尼玛把意思给表达错了。所以这篇文章可能真的有不少错误之处,大家海涵吧,还望各位看官不吝赐教。