阅读原文
Linux & Git 被称为 Linus Travis 的两大神作, 实至名归!
在谈 Git 之前, 先谈一下 Linux。
Linux 和 Windows 作为两个广泛使用的操作系统, 有着极大的差异, 在各种广泛的评价和争执中, 我对下面的评价十分赞同 :
Linux 与 Windows 最本质的区别在哪里。有人会说前者免费,后者需要买 (或偷)。这只是对 “free software” 的曲解。在我看来,二者最重要的区别乃是它们对自己的用户所做的假设。
对于 Linux,这个假设是:用户知道自己想要什么,也明白自己在做什么,并且会为自己的行为负责。
而 Windows 则恰好相反:用户不知道自己想要什么,也不明白自己在做什么,更不打算为自己的行为负责。
我不晓得上述观点最初源自哪里, 或许是这里: Zaikun’s Blog。
我不是一个极端主义者, 这两种理念没有谁是谁非, 孰优孰劣. 海纳百川, 有容乃大, 我会尝试发现每一种理念的优势和适用场景, 而不是一味地去否定什么。
- 在工作场景上, 我更喜欢 Linux 的理念 :
我曾rm -rf误删过/etc目录, 导致系统陷入瘫痪; 也曾因包管理依赖问题而导致软件损坏 …
在我看来, 这些都不可怕, Linux 会准确的向我展示故障原因, 而不是「请稍后…」, 「我们正在做一些准备工作」
这里故障原因是指一个基于广泛认知基础上的解释. 比如: 如果因为代码编写的失误, 导致一段程序没有按照设计意图执行, 我只需了解代码层面的逻辑错误即可, 而不是深究错误的代码在电路层面导致了什么样的问题发生
日复一日的使用, 我犯错误的概率越来越低, 对 Linux 本身的理解越来越深入, 对 Linux 越来越信任, 并且逐渐有了一种对 Linux 的掌控感。
- 然而在游戏娱乐场景上, 我更欣赏 Windows 的理念:
当我想玩游戏放松一下时, 我希望 Windows 为我包办一切, 我要做的就是双击运行, 然后开始游戏。
Git 与 Linux 同源同宗, 亦是有着相似的理念, 其本身有着极为灵活的设计, Git 认为 :
用户知道自己在做什么, 并且会为自己的行为负责。
在开源领域的广泛使用中形成了三种被广泛接受的最佳实践 : Git flow, Github flow, Gitlab flow, 可以参考 Git 工作流程 - 阮一峰 一文。
当我初学 Git 时, 我关注 Git 的工程实践胜过其内在的设计理念, 以至于迫切的去寻找一些所谓的最佳实践, 然后僵硬地模仿甚至生搬硬套, 结果显而易见, 我始终无法做到
flow,原意是水流,比喻项目像水流那样,顺畅、自然地向前流动,不会发生冲击、对撞、甚至漩涡.
收起急功近利的心态, 我开始思考, Git 的设计理念到底是什么。
Git 是一种版本控制系统, 先不谈 Git 是如何设计的, 如果让我来设计一个版本管理系统, 该如何下手?
设计一个最简单的版本控制系统
上面的每一个版本都是基于上一个版本修改而来的, 并且当新的版本出来之后, 老旧版本的价值就几乎不存在了, 在使用 SVN 或者 Git 一个人开发小项目或记笔记的时候, 场景与此类似。
如果场景复杂一点儿呢?
如果导师帮我一块改, 都基于「毕业论文最终版1.doc」修改, 导师改出了「C.doc」, 我改出了「D.doc」, 这时若想保留两人所有的修改, 并合并出一个新的版本「E.doc」, 似乎就要花些功夫了。
-
首先要找出来导师改了哪些, 我改了哪些;
-
然后基于「毕业论文最终版1.doc」, 把导师的修改和我的修改应用过来;
-
如果导师的修改和我的修改是在不同地方修改的, 那么互不影响, 分别应用;
-
如果导师的修改和我的修改在同一处, 要选择以导师的为准, 还是以我的为准;
-
即使导师的修改和我的修改不在同一处, 但是否会造成整体逻辑的矛盾, 要从整体上修正逻辑.
导师的加入使得我们简易的毕业论文版本控制系统变得有点儿力不从心, 我们必须小心处理导师的修改与我的修改对论文本身造成的影响, 如果又来一个热心学长同时对我的论文加以指导(修改), 问题似乎变得更加复杂了。
不知不觉中, 我们的关注点已经从论文本身转向了修改, 多人同时进行修改使得我必须小心处理每个人的修改, 不能遗漏, 不能冲突, 也不能逻辑矛盾, 这简直太混乱了。
理解 Commit
通过对上面例子的分析, 相信你已经体会, 论文版本控制系统的核心关注点应该是修改, 而不是论文本身。
让思路回到 Git 上来, Git 分支图中的每个点由git commit命令产生, 并且会产生一个唯一的sha1值, 因此可以通过sha1值来唯一确定一个提交点。
在上图中, B点应有两种含义 :
- 表示一个快照, 即项目工程所有文件在这一刻的状态;
- 表示一个差异, 即B状态与A状态文件的差异, 亦称作补丁(patch)。
类比于毕业论文, 快照也就是毕业论文最终版1.doc论文本身, 差异也就是修改.
如何体现B点的这两个属性? (我们用[B]来表示B点的sha1值)
- 回到B点的快照: git checkout [B]
- 查看B点与上一个提交点的差异: git show [B]
对一个提交点含义的双重解释看上去很不错, 不过, 在这个分支图上, E点有点儿特殊, 只有E点有两个上游节点C和D, 尝试执行git show [E], 发现并没有像其他节点一样, 显示出diff信息, 这说得过去, 不然到底该显示E和C的差异, 还是E和D的差异呢?
这时就只能借助 Git 的另一个命令git diff [X] [Y]来显式声明要比较任意两个节点X和Y的差异。
日常一天
在一些项目组里, 你可能会被告诫道: “记得每天下班前提交下你的代码.” 也许他们已经发现: “怎么代码又冲突了”, “我写的代码怎么被覆盖了”, 会对你多提一句告诫: “记得提交前先拉一下代码, 别把同事写的覆盖了”. 于是, Git 就仅仅成为了一个远程代码仓库。
这是日常的一天, 也是糟糕的一天, 大把的时间浪费在代码的删除和拷贝上, 而不是在创造上。
问题出在哪了?
上节我们提到, Git 每个 Commit 都有两种属性, 快照和补丁. 在上面的使用场景中, Git 只发挥出了不到一层功力, 大家关注的仅仅是最新提交点的快照, 当然, 这个快照是极为重要的, 重要到我们的HEAD指针几乎总是在指向他, 重要到我们会把他称为最新的master分支。
我们把关注点转移到 Git 的补丁属性上来, 你每天提交的 commit 代表着你这一天的工作成果, 那么描述怎么写?
“张三20190622工作”? 还是 “增加了A功能, B功能, C功能写了一半”
或许后者稍微好一点儿, 至少在几天时候查看 Git 提交记录时能一目了然的知道这次提交包含什么修改。
还记得git diff的输出吗? 是行级的差异。 为什么不是文件级别, 或者字符级别? 每次代码提交以天为单位真的合适吗? 当然不合适, 每个 commit 的最佳粒度应该是相对独立的特性(feature), 比如上文提到的A, B, C三个功能。
- 一般来说, 大家同时使用的分支只前进, 不后退, 即不能篡改历史;
- 若真的篡改了历史, 那么 B 功能的代码就从提交记录上消失了, 万一需要再次添加 B 功能, 这将是悲剧。
既然这三种状态是等价的, 那么作为倾向于完美主义的我们, 更希望在 Git 提交历史上留下的是最后一种干净的状态.。但我已经在B2状态了, 怎么才能实现C3? 相信你已经想到了办法, 回到最初的检出点, 通过cherry-pick拾取A, B, C3个补丁, 即可创建一个干净的提交历史。或许你还听说过git rebase, 这是一个非常强大的命令, 我们会在后文讨论。
重新认识分支
我们终于讨论到分支了, 或许你已经发现, 大家在谈论 Git 的时候, 分支似乎是最重要的事情, 几乎三句不离分支; 而我们说了这么多, 还没有提及分支这件事; 上面所有的插图中, 尽管我把他称作分支图, 却没有分支标记, 这并不影响我们对 Git 的理解。
再次重申一下, 我们的关注点是commit, 用唯一的sha1标识, 他有两种含义快照和补丁。
但是, sha1不是一个好记的标识, 我们需要给一些重要的commit别名。 前面我们已经提到了HEAD指针, 他指向当前的commit, 这就是一个标识. 除此之外, Git 还有两种重要的标识, 分支(branch)和标签(tag)。
分支和标签是某个commit的别名, 因此, 在 Git 命令中可以使用分支和标签来代替commit的sha1值。比如切换到某个分支, git checkout [branch-name], 其实就是切换到了这个commit点的快照。
使用分支切换和使用sha1切换会有一些差异, Git 会维持一个 Context, 记录了当前激活的分支, 如果你的命令提示符上有 Git 分支的标识(macOS终端默认有该标识), 将会看到这种差异。
分支和标签都可以作为任何一个commit的标识, 他们区别在于:
- 分支(branch)具有前进功能, 可以前进到下游commit节点上;
- 标签(tag)仅仅绑定在一个commit, 主要应用场景是作为版本发布的标识。
git pull命令其实是个git fetch和git merge的组合命令, git fetch是仅仅拉取远程分支的进度, 上图这种状态, 远程origin/master超前了本地master, 必然是执行了git fetch后才能看到, 一般支持 Git 的图形工具或者 IDE 会在后台定期做这项工作, 在远程分支更新后及时通知。
当HEAD指针在master时, 执行git merge origin/master, master即会前进到origin/master。
Merge 不是合并分支吗? 怎么变成了分支前进?
危险的 Merge
我把git merge定义为高危操作! 一般开发人员(非项目leader)应尽可能避免使用直接或间接使用该命令。
有句话怎么说来着? 权威就是用来质疑的! 不过质疑之前, 我们先尝试理解。
- 当箭头由上游节点指向下游节点, 就像我最初的插图那样. 从整个分支图上, 我们能看到因为团队的努力, 分支正在前进, 项目正在进展. 也就是说, 更符合宏观上的趋势;
- 当箭头由下游节点指向上游节点, 就像官方文档的插图那样. 还记得每个commit的含义吗? 快照和差异, 是该节点与其上游节点的差异, 所以在 Git 内部存储时, 每个commit一定会保留一个指针, 指向其上游节点. 也就是说, 这样的设计更能体现 Git 的内部设计.
好了, 我们该关注 Merge 到底做了什么:
- 构造一个节点C6, 这个节点将会有两个上游节点: C4, C5;
- 将分支master由C4移动到C6。
这看起来没有什么难的, Git 的diff功能会自动帮我们计算差异, 剩下的工作也是 Git 默默帮我们完成的。但是, 你还记得我们的论文版本控制系统吗?
- 如果C4和C5对同一个行做了修改, 该取哪个呢? 取了C4的, 那么C5其他代码还能工作吗? 或者反之. 又或者两者都不能取, 而应该重写这行代码, 以兼容两者的修改。
- 即便他们修改的地方互不交叉, 那么会不会照成整体上的逻辑错误呢? 比如C4修正了一个成员变量的拼写错误, C5在增的代码中还在引用原有的变量名, 这时构造C6时并不会有任何冲突提醒, 但构造出的代码却是无法通过编译的。
或许你已经习惯, 每当我们遇到问题时, Git 几乎都能给我们提供自动化的解决方案。 比如 : 当需要对比差异时, 可以使用git diff; 当需要制作反向补丁时, 可以使用git revert; 当需要复制补丁时, 可以使用git cherry-pick。那么, 现在这种场景, Git 有什么命令能帮助我们呢? 很遗憾, 没有, Git 能给我们的仅仅是当出现行级冲突时, 给我们一个 conflict 提示, 除此之外, 只能靠我们来发现和解决了。
我们相信, 在你或你的同事提交C4, C5时, 他们都是一个可以工作的版本, 至少应该能够正常编译和通过测试用例。但是如果存在我们描述的第二种场景, 合并C6时没有冲突, 但却无法通过编译。
以上正是我把 merge 操作定义为高危操作的原因。
既然 Git 不能给予我们帮助, 那必须要寻找缓解 merge 带来的潜在危险的措施了。
一个方法是把危险抛给更有经验的人的。 就像本节开始提到的那样, 一般开发人员(非项目leader)应尽可能避免使用直接或间接使用该命令。他们踩过更多的坑, 在合并分支时会考虑的更多更全面, 并且他们将对本次合并的成果(即新的 commit, 就像上图中的C6)负责。
计算机工程中最不可靠的部分是人件。 再细致的人也有犯错的时候, 并且相比于计算机来说, 这个概率要远远高的多, 因此还应该引入自动化测试机制。比如持续集成(CI), 每当一次合并结束后, 自动触发编译和测试, 并发送测试报告。
总是把这些风险推给有经验的人, 这是不公平的。 况且, 作为经验欠缺的我们, 没有机会处理风险, 我们怎么积累经验呢? 最重要的是, 我们能做的仅仅是事后补救吗? 能不能从根源上避免这种风险?
bugfix分支从master检出, 很幸运, master分支还没有更新. 这时, 将bugfix合入master。
我们从 commit 的补丁属性入手, 把B->C看成一个补丁, 那么我们对 merge 动作的期望结果应该是B->X和X->Y两个补丁累计作用。也就是说:
- B->C = B->X + X->Y (1)
但是从图中, 从B到C有两条路径, 一条是直达, 另一条是分步:
- B->C = B->X + X->Y + Y->C (2)
那么Y->C呢? 若想让我们的期望(1)和事实(2)都成立, Y->C必须是是一个空补丁, 也就是说, C和Y的快照状态是完全一致的, 可以用git diff [C] [Y]验证一下我们的推论。
为什么要有这个空补丁, 直接将使用Y节点不行吗? 当然可以!
执行git merge [X]动作时, 若无需构造新的 commit 节点, 直接将当前分支标签前进到要X节点, 这就是所谓的 fast-forward 特性。
这种情况下的 merge 动作让风险大大降低。 首先 commit X, Y的提交者要对两次修改负责, 他们有责任保证每次提交后的代码是可以通过编译和测试的; 其次, 项目负责人在将bugfix分支合入master之前, 只需确保Y的快照版本是正确的, 因为 merge 动作将不会带来任何再次的变更, 只是将分支前进到Y的快照, 这大大降低了 merge 的风险。
再次提醒, 慎用git pull, 这条命令隐含了git fetch, git merge两条命令。一个更好的做法是先git fetch获取远程分支状态, 当你确认本地关联的分支能与远程分支以fast-forward合并的时候, 再执行git merge或者git pull。
理想的分支图
我们已经找到了一种来尽量避免 merge 风险的场景, 在这种场景下, 我们会构造出怎样的分支图?
看到区别了吗? fast-forward 结果将会是一条一线, 这是最干净整洁的分支图, 但是相应的, 我们已经无法一目了然的区分出哪几个 commit 构成一个功能, 必须通过规范的注释(比如上图中全部以 JIRA 编号开头)来做分区; 而–no-ff参数虽然让分支图变得看上去复杂了一点儿, 但却非常直观地保留了 commit 集合和功能的对应关系。
两种方式哪个更好? 像文章最初说的那样, 我不是一个极端主义者, 两种各有优劣, 要分场景对待。
对于超大规模的开源项目来讲, 每一个 commit 都不是随意的, 必须要有 JIRA, 邮件列表, Github Issue 列表等诸如此类的讨论, 明确 commit 的功能和影响, 确保每个 Commit 只做一件事, 变动最小化, 然后通过 Pull Request 方式请求合并至主仓库的主线分支。在这种情况下, 使用–no-ff的话, 几乎每个 commit 都会产生一个空的 merge 节点, 分支图就变成了锯齿状, 带来的收益微乎其微; 而规范 commit 注释, 并且使用 fast-forward 或许是一个更好的选择。
对于需要快速响应变化的互联网公司来说, 每一次改动之前都先建立 JIRA 或者 Issue, 这几乎不太现实, 通过–no-ff的节点加上相对简洁明了的注释可能是一个更明智的选择。
现实与理想的差距
一个粗暴的方法是, 我们可以先删掉bugfix分支, 然后从Y’创建它。不过, Git 也提供了将分支标签指向任意commit节点的命令, 即git reset。
当HEAD指针指向bugfix(Y)分支时, 执行git reset --hard [Y’], 会将HEAD指针指向bugfix分支同时指向Y’。(参数–hard会清空工作区和暂存区, 此外还有–mixed, --soft选项, 会对工作区和暂存区有不同的影响, 如果你不了解, 也许你需要寻找其他的教程, 本文不讨论这些)
为了达到这种理想的分支状态, 我们要经常这么干, 这一切工作似乎变得有点儿繁琐, 要执行这么多步骤才能达到分支嫁接的目的。 对的, Git 为我们提供了自动化方案, 那就是强大的 rebase。
Rebase 译作变基, 从字面上理解, rebase 命令可以改变当前分支的基点, 我们现在仅关注 rebase 功能其中的一个特性, 来达到我们分支嫁接的目的就足够了。 回到最初的场景, bugfix 分支还指向Y, 这是我们只要执行git rebase master, 即可达到目的。
当真正执行了git pull命令后, 这才是糟糕的场面!
好了, 假装刚才什么都没发生, 我们仔细看看服务器返回的错误, 并且思考一下问题到底出在哪里?
首先, git push到底在做什么? pull 和 push 是一对反义词, git pull是把远程分支进度同步到本地, 然后尝试将远程分支合并到关联的本地分支; git push在做类似的事情, 不过是相反的, 他会先把本地分支同步到远程, 然后尝试将本地分支合并到关联的远程分支。但是, 当无法满足 fast-forward 条件时, git push会直接报错, 而不是尝试构造一个新的commit, 这就是我们刚刚遇到的错误场景。
上面的git rebase, git push --force看起来很有效果。但是, 这在协作中似乎会照成一个问题 : 如果大家都在 force push, 那岂不就乱套了?
所以, 应该制定一个约定 : 公共分支不允许 force push。也就是说, 公共分支只能前进。
在常用的 Git 服务器上, 比如码云, GitLab, Github都支持分支保护功能, 我们至少要设定一个保护分支(以 master 为例), 作为功能分支。 该分支应该有以下特性:
- 只能前进, 也就是不允许 force push;
- 不允许直接 commit, 只能通过 merge 动作使分支前进;
- 收紧 merge 权限, 只允许部分高级工程师执行 merge;
- 只允许 merge 满足 fast-forward 条件的 commit;
- 每次 merge 前, 必须进行 code review 和持续集成(CI);
- Commit 提交者, code review 者, merge 者都要对代码变更负责.
在这种模式下, 所有团队成员以 master 分支为核心进行开发。 每个人接到开发需求后:
- 从最新的远程 master 分支检出自己的开发分支;
- 开发;
- 开发结束后, 以最新的远程 master 为基点, 执行 rebase 操作, 解决掉冲突;
- 向有 merge 权限的人提交合并请求(码云和 Github 称作 Pull Request, Gitlab 称作 Merge Request);
- Code review 和 CI;
- 若第5步通过, 提交被合并, master 前进; 否则回到第2步;
- 已被合入的开发分支生命周期结束, 被删除。
关于第7步, 你没看错, 一个分支的生命周期就是这么短暂! 这取决于一个特性的大小, 可能只有几分钟, 或许有几天, 而不是像 master 分支一样永远存在。
每个人在开发过程中都应该有自己的分支, (我推荐以你的名字结尾, 这样便于标识), 这条分支是你的私有分支. 你应该对 master 分支保持敬畏, 但对于你的私有分支, 你可以任意的 force push, rebase, 甚至你不把他放到项目的公有仓库, 放到自己 fork 的私有仓库里, 这就是一张草稿纸!
让我们篡改历史吧!
看, 我在开发一个订单功能, 当我开始开发的时候, master 在c80dc1e这个提交点, 我通过git checkout -b feature-order-pancheng检出一个自己的开发分支。
我在开发过程中, 做了7次 commit, 但事实上只有4个是有意义的, 其他的几个仅仅是我在提交后立刻就发现了很明显的错误, 然后修正过来了, 这看起来就是个草稿, 如果同事 review 我的代码, 看到如此低级的错误, 似乎不太好。 这里最好的做法就是篡改 Git 提交历史, 把 fix 类型的 commit 与上一个 commit 合并。
每个 commit 最前面都是 pick 命令, 这就与我们前面使用的 cherry-pick 命令作用相似, 下面有对所有命令的解释, 你可以自行尝试。
那么发版呢?
相比于往 master 上 merge 提交, 项目发版是一个更谨慎的话题。
我们上面已经提到持续集成(CI), 这是一种自动化的打包和测试机制, 往往会与持续交付(CD)一起协作。我们可以将 Git 的某些行为作为 CI/CD 的触发条件, 来达到自动化打包, 测试, 部署的能力。
我们对分支做以下规范:
- master 主功能分支;
- feature-xxx-[developer name] 特性开发分支;
- fix-xxx-[developer name] 非紧急bug修复分支;
- hotfix-xxx-[developer name] 线上紧急bug修复分支;
- dev-[date] 开发环境发布分支(或tag);
- test-[date] 测试环境发布分支(或tag);
- uat-[date] 准生产环境发布分支(或tag);
- release-[date] 线上发布分支(或tag)。
在 Git 服务器中, 几乎都会提供 CI/CD 功能, CI/CD 触发条件根据正则表达式匹配branch或tag, 自动触发项目的编译, 打包, 测试, 部署等行为。
看上去这次发版成功了, 那么这两个 bugfix commit 怎么合入到 master 呢?
还记得我们说 master 分支的 merge 原则吗? 只允许 merge 满足 fast-forward 条件的 commit. 在我们开始测试后, master 已经前进, bugfix commit(即在test-[date], uat-[date], release-[date]上的 hotfix) 就不能直接合并到 master, 并且发布点 rebase 是有风险的, 这时就只能通过 cherry-pick 来把补丁手动打回到 master 分支上了!