从手忙脚乱开始
在开源领域的广泛使用中形成了三种被广泛接受的最佳实践: 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
, 把导师的修改
和我的修改
应用过来;
- 如果
导师的修改
和我的修改
是在不同地方修改的, 那么互不影响, 分别应用; - 如果
导师的修改
和我的修改
在同一处, 要选择以导师的为准, 还是以我的为准; - 即使
导师的修改
和我的修改
不在同一处, 但是否会造成整体逻辑的矛盾, 要从整体上修正逻辑.
哈! 这不就是 git merge
嘛!
不好意思, 图放错了
关注点到底在哪里?
导师的加入使得我们简易的毕业论文版本控制系统变得有点儿力不从心, 我们必须小心处理导师的修改
与我的修改
对论文本身造成的影响, 如果又来一个热心学长同时对我的论文加以指导(修改
), 问题似乎变得更加复杂了
不知不觉中, 我们的关注点已经从论文本身转向了修改
, 多人同时进行修改
使得我必须小心处理每个人的修改
, 不能遗漏, 不能冲突, 也不能逻辑矛盾, 这简直太混乱了:astonished:
Git 的设计理念
理解 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]
使用git checkout
命令我们可以在整个 Git 提交历史上的所有快照版本穿梭, 你可能听过说HEAD
指针, git checkout
正是通过挪动HEAD
指针来达到快照切换的目的, 如果多次穿梭后, 你迷失了自己, 找不到当前在哪一个快照, 请查看 Git 分支图, 找到HEAD
指针, 这就是你所处的快照版本.
可以看到, git show
命令完整的展示了B
点与其上有节点A
点的差异, Git 作为一个面向源码的版本控制工具, 将差异以行为基本单位表示是比较合理的一个选择. 这也意味着将 Git 用于非文本资源的版本控制工具或许不是最佳选择.
对一个提交点含义的双重解释看上去很不错, 不过, 在这个分支图上, E
点有点儿特殊, 只有E
点有两个上游节点C
和D
, 尝试执行git show [E]
, 发现并没有像其他节点一样, 显示出diff信息, 这说得过去, 不然到底该显示E
和C
的差异, 还是E
和D
的差异呢?
这时就只能借助 Git 的另一个命令git diff [X] [Y]
来显式声明要比较任意两个节点X
和Y
的差异.
砖头有了, 城堡在哪呢?
日常一天
在一些项目组里, 你可能会被告诫道: "记得每天下班前提交下你的代码." 也许他们已经发现: "怎么代码又冲突了", "我写的代码怎么被覆盖了", 会对你多提一句告诫: "记得提交前先拉一下代码, 别把同事写的覆盖了". 于是, Git 就仅仅成为了一个远程代码仓库
.
产品: "上次提的3个需求, 今天就上1个, 另外2个不用了"
开发: "我代码昨天都写完提交了, 那只能把2个需求代码删掉了. 我可是有代码洁癖的, 不能让我的项目里这么多无用代码留着"[两小时后]
产品: "我想了一下, B功能还是要上的, 一共上2个功能"
开发: "行吧, 我再把代码拷贝回来"[临上线]
产品: "不行, 下掉B功能, 上C功能! 快!!!"
开发: "W-- 我佛慈悲!"[上线后]
老大: "C功能有bug, 立刻回滚"
开发: "好, 我退回到上次发版的快照"
这是日常的一天, 也是糟糕的一天, 大把的时间浪费在代码的删除和拷贝上, 而不是在创造上.
问题出在哪了?
上节我们提到, Git 每个 Commit 都有两种属性, 快照和补丁. 在上面的使用场景中, Git 只发挥出了不到一层功力, 大家关注的仅仅是最新提交点的快照, 当然, 这个快照是极为重要的, 重要到我们的HEAD指针几乎总是在指向他, 重要到我们会把他称为最新的master分支.
我们把关注点转移到 Git 的补丁属性上来, 你每天提交的 commit 代表着你这一天的工作成果, 那么描述怎么写?
"张三20190622工作"? 还是 "增加了A功能, B功能, C功能写了一半"
或许后者稍微好一点儿, 至少在几天时候查看 Git 提交记录时能一目了然的知道这次提交包含什么修改.
还记得git diff
的输出吗? 是行级的差异. 为什么不是文件级别, 或者字符级别? 每次代码提交以天为单位真的合适吗? 当然不合适, 每个 commit 的最佳粒度应该是相对独立的特性(feature), 比如上文提到的A, B, C三个功能.
理想情况下, A, B, C是三个独立的功能, 分别作为三次 commit.
更好的做法是什么?
还记得吗? A, B, C都是独立的补丁(patch), 那么A, B, C的次序是没有关系的, 也就是说C1
, B2
, A3
的代码快照应该是一样的. 不信试一下, 可以用git diff
验证结果.
当要求撤掉 B 功能时, 如果可以直接删掉 B 这次提交, 那么瞬间就达到目的了. 但是, 有两点是需要考虑的:
- 一般来说, 大家同时使用的分支只前进, 不后退, 即不能篡改历史;
- 若真的篡改了历史, 那么 B 功能的代码就从提交记录上消失了, 万一需要再次添加 B 功能, 这将是悲剧.
我们可以换一种思路来达到相同的目的: 构造一个补丁, 该补丁B
完全相反, 即把B
增加的行删除, 新增B
删除的行. 当然, 这一切都是自动的, 只要使用git revert [B]
命令, 即可创建一个B
的反向提交. 显然, -B1
和C2
的快照状态是一致的, 可以用git diff
命令验证.
当要求把 B 功能加回来时, 是该祭出神器了吗?
当然不是, 我们可以再制作一个-B
的反向补丁--B
, 负负得正嘛:laughing:. 不过这看起来怪怪的, 如果能复制一份B
补丁重新打上就好了, git cherry-pick [B]
正是我们要找的答案. 显然, --B1
, B2
, C3
的快照状态必然是一致的.
注意: 通过git cherry-pick
复制的B
和原有的B
有不一样的sha1
, 即便这两个 commit 的内容相同.
既然这三种状态是等价的, 那么作为倾向于完美主义的我们, 更希望在 Git 提交历史上留下的是最后一种干净的状态. 但我已经在B2
状态了, 怎么才能实现C3
? 相信你已经想到了办法, 回到最初的检出点, 通过cherry-pick
拾取A
, B
, C
3个补丁, 即可创建一个干净的提交历史. 或许你还听说过git rebase
, 这是一个非常强大的命令, 我们会在后文讨论.
重新认识分支
当提出A, B, C三个需求的时候, 如果分派给三个人, 每个人负责一个功能, 同时基于最新的代码开发, 那么将会进入这种状态
但是, 如果我们遵循master
, develop
分支模型开发, 那么永远不会在 Git 分支图上看到这种状态.
我们终于讨论到分支了, 或许你已经发现, 大家在谈论 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
, 主要应用场景是作为版本发布的标识.
我们主要讨论分支(branch)
. 分支怎样前进呢?
- 当执行
git commit
后, 分支就前进了;
- 执行
git merge
后, 分支会前进.
当 Git 关联到远程仓库时, 每个分支可以设置一个远程追踪分支git branch --set-upstream-to=[origin]/[branch]
, 当执行git fetch
, git pull
, git push
时, 默认都是在操作关联的远程分支. 一个本地 Git 仓库可以关联多个远程仓库, 习惯上默认仓库或者主仓库叫做origin
.
当本地master
分支落后远程origin/master
分支时, 一般会执行git pull
命令跟进, 但这后面到底发生了什么?
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
定义为高危操作! 一般开发人员应尽可能避免使用直接或间接使用该命令.
提到 Merge, 或许下面的这种场景是我们第一时间想到的:
当我处在master
时, 也就是HEAD指针
指向master
, 执行git merge iss53
: 若无冲突, 即会得到下图结果; 若有冲突, 则会提示手动解决, 然后作为一次新的 commit, 同样也会得到下图结果.
也许你发现了, 这里分支图风格变化了, 不仅仅是画风的转变, 最重要的是箭头方向. 这两张图是我从 Git 官方文档复制过来的, 所以请不要质疑他的权威. 那么是我之前的箭头方向画错了吗?
有句话怎么说来着? 权威就是用来质疑的! 不过质疑之前, 我们先尝试理解.
当箭头由上游节点指向下游节点, 就像我最初的插图那样. 从整个分支图上, 我们能看到因为团队的努力, 分支正在前进, 项目正在进展. 也就是说, 更符合宏观上的趋势;
当箭头由下游节点指向上游节点, 就像官方文档的插图那样. 还记得每个
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 带来的潜在危险的措施了.
一个方法是把危险抛给更有经验的人的. 就像本节开始提到的那样, 一般开发人员应尽可能避免使用直接或间接使用该命令. 他们踩过更多的坑, 在合并分支时会考虑的更多更全面, 并且他们将对本次合并的成果(即新的 commit, 就像上图中的C6
)负责.
计算机工程中最不可靠的部分是人件. 再细致的人也有犯错的时候, 并且相比于计算机来说, 这个概率要远远高的多, 因此还应该引入自动化测试机制. 比如持续集成(CI), 每当一次合并结束后, 自动触发编译和测试, 并发送测试报告.
总是把这些风险推给有经验的人, 这是不公平的. 况且, 作为经验欠缺的我们, 没有机会处理风险, 我们怎么积累经验呢? 最重要的是, 我们能做的仅仅是事后补救吗? 能不能从根源上避免这种风险?
我们来看一下另一种 merge 场景:
插图风格又换了, 这次的插图来自 猴子都能懂的 Git 入门
bugfix
分支从master
检出, 很幸运, master
分支还没有更新. 这时, 将bugfix
合入master
.
我们首先让HEAD指针
指向master
, 然后执行git merge bugfix --no-ff
, 分支图将会变成这个样子.
没有意外, 这根我们上面对 merge 行为的描述是一样的: 构造一个新的 commit 节点C
, 其上游节点分别为B
和Y
, 然后将master
分支标签指向C
. 相较于上面的场景, 这种情况下构造C
是一定不会产生冲突的. 为什么?
我们从 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
节点不行吗? 当然可以!
观察一下我们的 merge 命令, 有一个附加参数--no-ff
, 这个参数强制关掉了 fast-forward 特性. 如果我们不添加这个参数, 直接只用git merge bugfix
, 那么得到的结果将是这样的:
master
直接被指向了Y
节点. 还记得吗, 让分支前进的第二种方法是什么来着? git merge
, 这不是就例子嘛!
执行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 特性, 结果将是这样:
(该图是 RedHat 旗下 debezium 项目的分支图, Github 传送门
如果我们使用git merge --no-ff
参数, 结果将是这样的:
(该图来自掘金文章: 如何优雅地使用 Git)
看到区别了吗? fast-forward 结果将会是一条一线, 这是最干净整洁的分支图, 但是相应的, 我们已经无法一目了然的区分出哪几个 commit 构成一个功能, 必须通过规范的注释(比如上图中全部以 JIRA 编号开头)来做分区; 而--no-ff
参数虽然让分支图变得看上去复杂了一点儿, 但却非常直观地保留了 commit 集合和功能的对应关系.
两种方式哪个更好? 像文章最初说的那样, 我不是一个极端主义者, 两种各有优劣, 要分场景对待.
对于超大规模的开源项目来讲, 每一个 commit 都不是随意的, 必须要有 JIRA, 邮件列表, Github Issue 列表等诸如此类的讨论, 明确 commit 的功能和影响, 确保每个 Commit 只做一件事, 变动最小化, 然后通过 Pull Request 方式请求合并至主仓库的主线分支. 在这种情况下, 使用--no-ff
的话, 几乎每个 commit 都会产生一个空的 merge 节点, 分支图就变成了锯齿状, 带来的收益微乎其微; 而规范 commit 注释, 并且使用 fast-forward 或许是一个更好的选择:smile:.
对于需要快速响应变化的互联网公司来说, 每一次改动之前都先建立 JIRA 或者 Issue, 这几乎不太现实, 通过--no-ff
的节点加上相对简洁明了的注释可能是一个更明智的选择.
现实与理想的差距
但多数情况下, 现实场景并不满足这样的状态, 因为项目不是一个人在开发, 在我们提交的同时, 别人也在提交, 当我们的分支准备合入master
时, master
已经前进了, 又回到了最初那种糟糕的状态. 是去面对糟糕的状态, 还是避免糟糕的状态, 想办法修正它?
向理想靠拢
如果我们在向主分支合入之前, 把这两个commit
通过git cherry-pick
命令嫁接到最新的 master 分支上, 看起来一切都变好了:laughing:. 当然, X'
和Y'
会被视作全新的commit
, 他们都会有新的sha1
.
不过这里有个问题, 前文提过, 分支(branch)
是一个可以向前滑动的标签, 从Y
到Y'
似乎不能直接前进, 我们的分支标记怎么才能转移到Y'
上呢?
一个粗暴的方法是, 我们可以先删掉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
, 即可达到目的.
我们本地的bugfix
已经变基完成, 若它已经关联过远程分支, 那么origin/bugfix
还处在Y
, 我们要把本地的状态变更推送到远程, 如果接着执行git push
, 将会报错:
可以看到, Git 服务器拒绝了我们的推送请求, 并返回了一些提示信息, 或许看到这场面, 你一下就慌了, 我辛苦写的的代码不会丢掉吧! 提示里面有git pull
命令, 我是不是应该执行, 挽救一下!
当真正执行了git pull
命令后, 这才是糟糕的场面!
别忘了, git pull
暗含git merge
语义, 这会导致一次合并, 构造的一个新的 commit Z
, 上游分别是 bugfix Y'
和 origin/bugfix Y
, bugfix 指向了Z
. 如果这时再执行了git push
命令, 那么这糟糕的分支图就推到了服务器上, 整个团队将会看到你把分支图搞乱了, 这画面简直不可描述! (如果你脑补不出来这时分支图的样子, 下个实操案例中会演示)
记住, 不要慌, 你已经了解了 Git 的原理, 你有能力掌控 Git, 而不是被一两个莫名的错误吓退了. 还记得刚刚使用的git reset
命令吗? 他可以把分支强制指向任一commit
, 我们使用git reset --hard [Y']
不就回到刚才的状态了吗?
好了, 假装刚才什么都没发生, 我们仔细看看服务器返回的错误, 并且思考一下问题到底出在哪里?
首先, git push
到底在做什么? pull 和 push 是一对反义词, git pull
是把远程分支进度同步到本地, 然后尝试将远程分支合并到关联的本地分支; git push
在做类似的事情, 不过是相反的, 他会先把本地分支同步到远程, 然后尝试将本地分支合并到关联的远程分支. 但是, 当无法满足 fast-forward 条件时, git push
会直接报错, 而不是尝试构造一个新的commit
. 这就是我们刚刚遇到的错误场景.
但很显然, 我们在本地调整了分支, 并且期望把调整后的状态推送到远程, 覆盖远程分支原有的状态. 这时需要添加一个参数git push --force
, 强制覆盖远程关联分支. 现在远程的 bugfix 分支和本地 bugfix 保持同步了, 都指向了Y'
, team leader 可以 review 代码, 然后合入 master 了.
对主分支保持敬畏
上面的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 的私有仓库里, 这就是一张草稿纸!
让我们篡改历史吧!
在我们自己的分支(草稿纸)上, 我们可以相对随意地修改, 但是当提交 PR 时, 必须整理出一份干净整洁的提交记录, 这必然涉及到 commit 历史的修改. 还记得上文提到的一个强大命令吗? 对的, 就是git rebase
!
在macOS终端上通过git log --oneline --graph --all
可以打印出上面的分支图, 这是我最常用的一个命令, 在linux上的表现行为可能会有点儿区别, 或许你可以尝试git log --oneline --graph --all --decorate=short | less -r
, 或者参考git log --help
进行调整, 来达到你想要的打印效果. 当然, 使用图形软件查看分支图也是一个很好的选择.
看, 我在开发一个订单功能, 当我开始开发的时候, master 在c80dc1e
这个提交点, 我通过git checkout -b feature-order-pancheng
检出一个自己的开发分支.
我在开发过程中, 做了7次 commit, 但事实上只有4个是有意义的, 其他的几个仅仅是我在提交后立刻就发现了很明显的错误, 然后修正过来了, 这看起来就是个草稿, 如果同事 review 我的代码, 看到如此低级的错误, 似乎不太好:fearful:. 这里最好的做法就是篡改 Git 提交历史, 把 fix 类型的 commit 与上一个 commit 合并.
我们现在执行git rebase -i c80dc1e
, -i
代表交互模式:
进入了一个 vim 界面(也可能是 nano, 取决于你配置的默认编辑器), 上面列出了我们的每次提交. 注意, 这里是从上往下排列的, 上一个分支图中时从下往上排列的, 在不同的命令或软件中, 方向可能不一样.
每个 commit 最前面都是 pick 命令, 这就与我们前面使用的 cherry-pick 命令作用相似, 下面有对所有命令的解释, 你可以自行尝试.
我们看到, 有一个 fixup 命令似乎正是我们想要找的:
保存退出, 再次查看分支图:
哈! 我们的黑历史在本地的 feature-order-pancheng 分支被抹掉了:laughing:! 然后把它推送到远程.
不出意外, Git 服务器拒绝了我们的推送请求, 因为不满足 fast-forward 条件. 现在你应该不会慌了吧! 我们假装慌一把, "根据提示"执行git pull
:
哈! 双份提交! 被老大看见说不定要挨批的! 还记得这时候应该做什么吗? 先回到 merge 前的状态, 执行git reset --hard 395ef39
:
然后执行git push -f
:
之前的 origin/feature-order-pancheng 分支所处的点从图上消失了, 我们还有可能找回他吗? 哦对了, 分支名只是个标签而已, 我还记得那个点之前的sha1
是ccce49d
, 执行git checkout ccce49d
, 分支又回来了, 原来只是隐藏了! 我们把这种没有任何标签的分支称谓游离分支, 他默认不会在分支图中显示, 并且会在一段时间后由 Git 进行垃圾回收, 才会真正的消失, 在此之前, 我们可以通过git reglog
找到他们的sha1
, 回到那个快照.
订单功能开发好了, 可以向主分支提合并请求了, 哦, 对了, master 已经前进了, 我们提 PR 之前必须先跟进. 执行git rebase master
, git push -f
, 然后再查看分支图:
这时就可以去提交 Pull Request 了.
当 PR 通过后, 你的分支将被合入 master 分支, 执行git fetch
拉取远程分支信息, 然后查看分支图:
嗯, 一次愉快的开发结束了.
如果大家都遵守这个约定, 那么我们的分支图将会是这样:
虽然我们在 master 分支合并上使用了--no-ff
方式, 但是它等价于是一条直线, 这对 code review 和协作开发将十分友好.
那么发版呢?
相比于往 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
, 自动触发项目的编译, 打包, 测试, 部署等行为.
在分支管理中, dev-[date]
分支可以由任意开发人员随时检出发布到开发环境联调; test-[date]
, uat-[date]
, release-[date]
原则上必须从master
上逐级检出, 分别测试, 若发现问题, 进行 bugfix.
看, 我们从75a8e22
检出test-20190623
分支, 当推送到服务器上时, CI/CD 会自动触发, 最终项目被部署到测试服务器上. 我们在测试上发现一个 bug, 在真正上线前发现的 bug 总比上线后好. bugfix 后, 我们认为没有问题了, 检出release-20190623
分支, 触发 CI/CD, 部署到生产环境. 半天后, 我们发现一个紧急的线上 bug, 我们紧急创建了 hotfix 分支, 在 CI 通过后, 将其合入到release-20190623
分支, 然后删除 hotfix 分支.
看上去这次发版成功了, 那么这两个 bugfix commit 怎么合入到 master 呢?
还记得我们说 master 分支的 merge 原则吗? 只允许 merge 满足 fast-forward 条件的 commit. 在我们开始测试后, master 已经前进, bugfix commit(即在test-[date]
, uat-[date]
, release-[date]
上的 hotfix) 就不能直接合并到 master, 并且发布点 rebase 是有风险的, 这时就只能通过 cherry-pick 来把补丁手动打回到 master 分支上了!
我们从最新 master 切出一个 fix 分支, 并把两个补丁通过 cherry-pick 移植过来:
接下来就是 PR 流程, 当合入 master 后, 删除该 fix 分支:
嗯, 这篇文章前后大约写了一个礼拜, 是时候提 PR 了, 我要去 rebase 了