如果你无法理解 Git 的设计背后的动机, 你就惨了.
Git 提供了丰富的选项, 你可以通过它们来让 Git 按照你的想法进行操作, 而不是它自己的默认行为, 但这就好像用改锥当锤子使, 虽说也能工作, 但即费事费力又损伤工具.
试想如何分解一个通用的 Git 工作流:
- 从 Master 分支上创建一个分支
- 编辑该分支
- 完成编辑后将该分支 Merge 回 Master 分支.
大多数时候, 这样的操作其结果都符合你的预期, 因为 Master 上的内容从你创建分支后也在变化着. 然而, 当你将一个功能分支 Merge 回 Master 的时候, Master 在这段时间中并没有任何变化. 此时, Git 将直接将 Master 指向最后一次功能分支的 Commit 上, 而不是在 Master 上创建一个新的 Merge Commit. 这就是 fast forwards.
不幸的是, 功能分支包含了一些常规(checkpoint) commits. 频繁的 commit 可以随时备份你的工作, 但却产生了一些代码处于不稳定状态下的 commit, 这些提交在 merge 后会和 master 分支上的稳定提交混在一起无法区分, 你有可能会回滚到一个危险的 commit 上.
所以, 你给自己加了一个规矩: 当 merge 一个功能分支的时候, 使用 -no-ff
来强制创建一个新的 commit. 这样就解决问题了, 你可以继续前进.
然而一天你在线上产品中发现一个严重 Bug, 因此需要查出它是什么时候被引入进来的, 你使用 bisect
来用二分法定位问题, 但最终却定位在了一个常规 commit 上, 因此不得不放弃并开始手动查找.
后来你将 Bug 定位到了一个特定文件上, 使用 blame
来看它在 commit 前的最后 48 小时是如何被修改的. 结果 blame
报告说该文件近一周都没被碰过, 你知道这不可能. 事实上 blame
是检查从首次提交以来所发生的改变, 而不是从它被 merge 以来. 你的首次常规 commit 在一周前修改过这个文件, 但是这个修改是今天才 merge 进来的.
-no-ff
这个"创可贴"弄坏了 bisect
, 让 blame
行为诡异. 所有这些症状都是因为你把改锥当作锤子使用.
版本控制存在的原因只有两个.
其一, 帮助代码编写工作. 你需要与团队成员同步各自的修改, 并周期性的备份你们的工作成果. 用 Email 一次次发送 zip 文件? 开玩笑吧!
其二, 配置管理. 这包括管理多分支的开发, 比如在开发下一主版本的同时, 需要偶尔修复目前在用版本的 bug. 配置管理同时也被用于精确的显示每次 commit 的确切时间, 以及非常有用的 bug 诊断工具.
但一般来说, 这两个原因存在冲突.
当设计功能的原型时, 你应该经常做常规 commit. 但这些 commit 会将你的 build 给拆碎.
理想情况下, 你的每一次 commit 都应该是简洁而可稳定运行的, 不应该在分支上出现大量由常规 commit 造成的无用干扰信息, 不应该有巨大, 甚至一万行的 commit. 一个清晰整洁的 Git 历史可以让回滚撤销, 以及在不同分支上进行 cherry-pick
变得简单易行, 也可以方便将来对代码提交历史进行查阅(inspect)和分析. 然而, 维护这样的 Git 历史意味着必须等修改的部分达到完美才能合并进来.
那么, 你采用哪种方式呢? 经常 commit, 还是去维护一个整洁的 Git 历史?
如果你在一个两人的创业公司开发一个新产品的时候, 维护简洁 Git 历史非常容易. 你只需要随心所欲的提交代码到 master, 随心所欲的部署.
接下来修改不断增加, 开发团队越来越大, 用户基数也不断增长, 此时你需要一些工具和技术.来保证一切都在掌控之中, 包括自动化测试, code review, 以及一个真正整洁的 Git 历史.
功能分支就好象一个轻松快乐的中间地带. 他们解决了基本的多分支开发问题. 开发过程中你几乎不用过多关心代码整合的问题, 但无论如何你还是得花费些精力.
当你的项目扩张到足够大时, 简单的 branch / commit / merge 工作流将会完蛋. "强力胶带"的时代结束了, 你确实需要一个真正干净整洁的版本历史.
Git 是革命性的, 因为它同时给了你上述两种方式的优点. 你可以在设计功能原型的时候频繁的将修改 check in, 但最终的交付却有一个干净的版本历史. 如果这是你所期望的, 那 Git 的默认行为就在适合不过了.
请思考两种不同的分支种类: 公开(public)和私有(private).
公开的分支是该项目的权威历史记录. 在公开的分支中, 每一个 commit 都应该具有简洁性和原子性, 并且应该具有良好的文档和 commit 信息. 这个分支应该尽量保证线性, 并且它也是不可改变的. 公开分支指的就是 Master 分支和 release 分支.
私有分支是只给你自己使用的. 它是你在解决问题时的草稿纸.
最安全的方法应该是在本地维护私有分支. 如果你需要将一个分支 push 上去, 比如为了让公司和家里的电脑之间保持同步, 你应该告诉队友哪个是你的私人分支, 让他们避免使用.
你不应该直接使用原生的 merge 一个私有的分支到公开的分支. 而是应该在那之前先使用 reset
, rebase
, squash
和 amending
等工具清理你的分支.
把你自己当作一个作家, 把你的每次 commit 当作是发布一个章节. 作家是不能发布自己的草稿的. Michael Crichton 说过: 伟大的作品不是创作出来的, 而是修改出来的.
如果你来自其他系统, 修改历史是一个禁忌. 所有的修改都像是被刻在了石头上. 按照这个逻辑, 我们的文本编辑器是不应该有撤销操作的.
实用主义者在意修改, 直到修改变成了噪音. 在配置管理中, 我们只关心大片的修改. 那些常规性的 commit 其本质不过是云端的用于撤销操作的 buffer.
如果你希望公开分支的历史保持原始信息, fast-forward
merge 是更好更安全的方式. 它能保证其版本历史维持线性, 并且易于跟进.
剩下的唯一争论在于 -no-ff
是一种"文档". 人们可能会使用 merge commit 来表示最近一次线上部署的版本号码. 这其实是一种反模式, 直接使用 tag 就好了.
我使用三种基本的操作方法, 用法的不同基于以下三种因素: 修改规模, 将会花在这次修改上的时间, 以及该分支离开分支点的距离.
绝大多数时候, 我的清理方式就是使用 squash merge
.
设想我创建了一个功能分支, 并在接下来的一个小时中, 在其上进行了一系列常规性 commit:
git checkout -b private_feature_branch
touch file1.txt
git add file1.txt
git commit -am "WIP"
当我完成修改后, 我将使用如下命令, 而不是通常的 git merge
:
git checkout master
git merge --squash private_feature_branch
git commit -v
接下来我将花点时间来写下详细的 commit 信息.
有时, 功能分支会变成一个持续几天的项目, 其中又有着几十个小的提交.
我决定将这个修改拆分成若干个小的修改, 因此 squash
显得有些不太合适. (按照经验法则我会质疑: 这样做有利于 code review 么?)
如果我的常规性 commit 遵循一定的推进规律, 我将可以使用交互模式的 rebase
命令.
交互模式非常有用, 你可以用它来对之前的 commit 做编辑, 拆分, 记录, 以及 squash 操作, 比如以下这个例子.
在我的功能分支上:
git rebase --interactive master
它将打开一个编辑器, 其中包含多个 commit 的列表. 每行都包含: 将要执行的操作, commit 的 SHA1 码, 以及当前的 commit 信息. 其中会提供一些信息, 用来说明所有可用的命令.
默认情况下, 每个 commit 都使用 "pick", 这不会对该 commit 造成任何修改.
pick ccd6e62 Work on back button
pick 1c83feb Bug fixes
pick f9d0c33 Start work on toolbar
我将第二个 commit 对应的操作修改为 "squash", 这将使第二个 commit 被塞入第一个 commit 中.
pick ccd6e62 Work on back button
squash 1c83feb Bug fixes
pick f9d0c33 Start work on toolbar
当我保存并关闭时, 会出现一个新的编辑器并提示让我针对这次合并后的 commit 输入对应的 commit 信息. 输入后提交, 这就完成了.
也许我的功能分支已经存在了非常长的时间, 因此不得不将其他几个分支 merge 到我的分支上来以保证它在我工作过程中一直保持最新状态. 版本历史变得错综复杂. 此时最简单的方式就是带着所有的修改去创建一个新的干净的分支.
git checkout master
git checkout -b cleaned_up_branch
git merge --squash private_feature_branch
git reset
现在我的工作目录中全部都是我自己的修改, 完全没有之前分支的包袱. 我现在可以手动添加并提交我的修改了.
如果你正在和 Git 的各种默认值较劲, 不妨问问自己到底是为了什么.
让公开的版本分支历史不可修改, 原子化, 易于追踪检查. 而让私有版本分支历史用完就扔想改就改.
真正预期的工作流是:
- 从公开分支上创建一个私有分支.
- 在私有分支上做常规性 commit.
- 一旦你的代码完全搞定, 开始整理私有分支的版本历史.
- 将整理后的私有分支 merge 回公开分支上.