导读
在上一篇文章[前端漫谈]一巴掌拍平Git中的各种概念中,描述了 Git 的一些概念,但是太过虚化,描述的都是一些概念和命令。这篇文章结合实际场景,主要描述我在项目实践中使用 Git 管理项目、团队协作的一些经验。包括 1)merge 和 rebase 使用的区别和选择;2)多人团队合作开发流程;3)标准化 commit message;4)commit 精细化管理等。这些都是为项目的健壮发展和代码的精细管理所流的泪累积出来的。
0x000 前言
由上一片文章[前端漫谈]一巴掌拍平Git中的各种概念中,可以知道,Git 世界就像一个 宇宙,每一个 commit 都是一颗星球,而 commitId 就是星球的坐标,branch 是一条条的航线,穿过无数的 星球,tag 是航线上重要的星球,可能是供给站,可能是商业中心,而 HEAD 则是探索号飞船,不断向前探索。中间可能会有岔道,但是永远有一个真正的方向等待勇敢的船长。
0x001 merge 还是 rebase
merge 还是 rebase,这是经久不衰的讨论点。但是这里我不去争论孰优孰略,我只说我在不同场景的实践。
1. merge
我通常使用 merge 来将多个分支合并到当前分支,比如要发布的时候,将多个功能分支合并到带发布分支:
已知:feat/A、feat/B、feat/C,是从主分支新建的功能分支,feat/B和feat/C都修改了文件1。
- 新建待发布分支:
# 从主分支新建分支 pub/191205 $ git checkout -b pub/191205 Switched to a new branch 'pub/191205' - 合并
feat/A到pub/191205:$ git merge feat/A Updating 53ab8fd..e443dd4 Fast-forward featA | 1 + 1 file changed, 1 insertion(+) create mode 100644 featA
pub/191205 和 feat/A 都是从主分支新建,所以 pub/191205 指向的 commit 是 feat/A 的祖先,当把 feat/A 合并到pub/191205的时候,会发生快速合并(Fast-forward)。不会新建一个合并节点(当然也可以通过--no-ff(no-fast-forward)来强制生成一个节点):
# 查看 log
$ git log --oneline
e443dd4 (HEAD -> pub/191205, feat/A) feat: a
53ab8fd (master) chore: first commit
- 合并
feat/B到pub/191205$ git merge feat/B # 进入 vim 填写合并信息 Merge made by the 'recursive' strategy. 1 | 2 +- featB | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 featB
feat/B是从主分支新建的分支,pub/191205原本指向的也是feat/B的祖先,但是因为已经和feat/A合并了,所以pub/191205不再是feat/B的祖先。因此,pub/191205和feat/B的合并不再是快速合并(Fast-forward),而是Merge made by the 'recursive' strategy.。会产生一个新的节点:
$ git log --oneline
5d0ee9b (HEAD -> pub/191205) Merge branch 'feat/B' into pub/191205
d7773d6 (feat/B) feat: b
e443dd4 (feat/A) feat: a
53ab8fd (master) chore: first commit
- 合并
feat/C到pub/191205$ git merge feat/C Auto-merging 1 CONFLICT (content): Merge conflict in 1 Automatic merge failed; fix conflicts and then commit the result.
feat/C和fix/B修改了相同文件,所以产生冲突,因此,会提示解决冲突。这时候查看状态,可以发现,处于you have unmerged paths状态:
```
$ git status
On branch pub/191205
You have unmerged paths.
(fix conflicts and run "git commit")
(use "git merge --abort" to abort the merge)
Changes to be committed:
new file: featC
Unmerged paths:
(use "git add <file>..." to mark resolution)
both modified: 1
```
这时候可以执行git merge --abort放弃继续合并,恢复合并之前的状态。也可以解决冲突之后,执行git merge。这里选择解决冲突:
# 解决冲突 $ git commit # 进入 vim 编写 message [pub/191205 98d63aa] Merge branch 'feat/C' into pub/191205
feat/C是从主分支新建的分支,pub/191205原本指向的也是feat/C的祖先,但是因为已经和feat/A、feat/B合并了,所以pub/191205不再是feat/C的祖先。因此,pub/191205和feat/C的合并不再是快速合并(Fast-forward),会产生一个新的节点:
$ git log --oneline 98d63aa (HEAD -> pub/191205) Merge branch 'feat/C' into pub/191205 5d0ee9b Merge branch 'feat/B' into pub/191205 d7773d6 (feat/B) feat: b 52dd922 (feat/C) feat: c e443dd4 (feat/A) feat: a 53ab8fd (master) chore: first commit
历史如下:
2. rebase
注:rebase 的功能很强大,这里先介绍和 merge 相对应的功能。
我通常用它来和主分支同步,比如一个新版本发布,主分支比我当前的功能分支超前,我使用rebase将当前分支和主分支“合并(变基)”。
已知:feat/A、feat/B 是从主分支新建,feat/A开发完成之后合并到主分支。feat/B继续开发,需要将master的功能合并到当前分支上,使用merge可以这么做:
- 切换到 feat/B
$ git switch feat/B Switched to branch 'feat/B' - 将 master 合并到 feat/B
$ git merge master # 进入 vim 编写 message Merge made by the 'recursive' strategy. featA | 1 + 1 file changed, 1 insertion(+) create mode 100644 featA - 查看状态
$ git log b4f178e (HEAD -> feat/B) Merge branch 'master' into feat/B d7773d6 feat: b e443dd4 (pub/191205, master, feat/A) feat: a 53ab8fd chore: first commit
因为master合并了feat/A,因此不再是feat/B的祖先节点,不会进行快速合并(Fast-forward),会产生一个新的节点。历史如下
这么做是可以,但是我不喜欢这个合并产生的节点,所以我选择使用rebase:
- 恢复到合并
feat/B之前$ git reset e443dd4 --hard HEAD is now at e443dd4 feat: a - 使用
rebase“合并(变基)”master$ git rebase master git rebase master First, rewinding head to replay your work on top of it... Applying: feat: b - 查看历史:
$ git log --oneline ef3450c (HEAD -> feat/B) feat: b e443dd4 (pub/191205, master, feat/A) feat: a 53ab8fd chore: first commit
可以发现没有新的节点产生,但是rebase的操作过程并不只是不产生一个合并节点而已,它的中文翻译是变基,听起来很 Gay 的样子。但它的意思是“改变基础”。那改变的是什么基础呢?就是这个分支checkout出来的commit,原本feat/B是从master中checkout出来的,但是使用git rebase master之后,就会以master最新的节点作为feat/B分支的基础。就像feat/B上所有的commit都是基于最新的master提交的。
历史如下:
由于rebase之后,master始终是feat/B的祖先节点,因此,之后将feat/B合并到master将执行Fast-Farword,不会产生冲突(如果有冲突,rebase的时候就需要解决了),也不会产生新节点。
3. merge 还是 rebase
merge还是rebase,有人提倡不要使用rebase,应该rebase改变了历史(在上一小节中一直在改变分支的启始节点),有人提倡使用merge,保留完整的历史。
我是这么做的,在私有的分支上,我始终使用rebase将主分支的更新合并到私有的分支上(后面还有很多使用rebase的操作,都是在私有的分支,这里的私有的分支,指的是只有自己使用的分支,一旦分享出去,或者有人基于你的分支开发,那就不再是私有),而在将自己的分支合并到其他分支(主分支或者待发布分支),则使用merge。
- 切换到主分支:
$ git switch mater Switched to branch 'master' - 将
feat/B合并到主分支$ git merge feat/B Updating e443dd4..ef3450c Fast-forward 1 | 2 +- featB | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 featB
这样在长时间开发(master中间发布过n多版本)的feat/B就不会有无数乱七八糟的分支合并。而在master也不会存在rebase导致的历史变更后果。
历史如下:
准则:不要对在你的仓库外有副本的分支执行变基。
如果你遵循这条金科玉律,就不会出差错。 否则,人民群众会仇恨你,你的朋友和家人也会嘲笑你,唾弃你。-- 3.6 Git 分支 - 变基 - 变基的风险
0x002 多人合作开发
1. 新功能开发
开发方式
新功能开发的时候从主分支新建新分支,所有该功能的开发工作都在这个分支上完成。如果主分支有新的发布,使用rebase同步主分支功能:
名称规范
功能分支的命名方式是feat/${name}_${featName},它的构成如下:
- 常量
feat:表示这是一个功能分支 - 变量
name:你的名字 - 变量
featName:功能名字 好处是见名知意,一看就知道是功能分支,是谁负责,是什么功能
2. bug 修复及其发布
开发方式
bug修复大体上和新功能的开发类似,但是bug修复一般时间短,立马上线。
bug修复从主分支新建新分支,所有的bug修复工作都在这个分支上完成。如果主分支有新的发布,使用rebase同步主分支功能(这个步骤其实和新功能开发一样):
名称规范
bug修复分支的命名方式是hotfix/${name_${bugName}},它的构成如下:
- 常量
hotfix:表示这是一个功能分支 - 变量
name:你的名字 - 变量
bugName:bug名字 好处是见名知意,一看就知道是bug修复分支,是谁负责,是什么bug
bug 发布
bug发布可以直接推送到待发布版本分支,比如1.1.1,然后CodeReview(如果有),然后合并主分支部署上线。
完整过程如下:
2.5 stash
一般我们修复bug的时候都在开发新功能,也就是在feat/*上,这时候如何快速进入bug修复状态呢?可以保存当前代码,提交commit,但是这时候会有一些问题,比如,1)当前的代码并未完成,并不想提交;2)commit有钩子,比如ESLint,必须修复语法问题才能提交。
这时候就是使用stash了。stash可以将当前工作区和暂存区的内容暂时保存起来,之后再使用。
如下:
- 开发功能中
$ echo "this is a feat" >> feat.txt - 收到
bug通知$ git stash Saved working directory and index state WIP on master: ef3450c feat: b $ git switch master $ git checkout -b hotfix/bugA Switched to a new branch 'hotfix/bugA' - 修复
bug之后$ git switch feat/A $ git stash pop On branch hotfix/bugA Changes to be committed: (use "git restore --staged <file>..." to unstage) new file: feat.txt Dropped refs/stash@{0} (32cf119fc1dcbe7088d1a12e290b868d6707526d)
stash命令有一整套完整的增删改查指令,可以查看git-pro 7.3 Git 工具 - 储藏与清理了解更多。
3. 新功能发布
新功能发布和bug发布有些不同,1)可能会有多个功能共同发布,需要提前合并,避免大冲突;2)可能有bug修复需要插队。3)可能需要等待后端发布,时间长。
因为(2),所有无法像bug发布那样直接推送到版本分支,等待发布。因为在真正发布之前,是无法知道准确版本的。
因为(1)、(3),所以需要提前合并,所以引入一个“日期分支”的概念,即以日期为分支名,比如pub/191205。
所以发布过程如下:
(其实我还画了一张贼复杂的图,把自己都恶心了,有空还是画个动态图吧(没空))
0x003 标准化 commit message
标准化 commit message 可以参考阮一峰 - Commit message 和 Change log 编写指南。(阮一峰真的是写博客跨不过去的坎😢,写啥都可以引用他)
- 安装
commitizen
$ npm install -g commitizen
- 在项目目录中初始化
commitizen
$ commitizen init cz-conventional-changelog --save --save-exact
- 使用
git cz代替git commit,以下是我常用的类型:
$ git cz
feat: 一个新功能
fix: 一个 bug 修复
docs: 只改变文档
refactor: 改变代码但是不添加或者修复功能,我一般用于优化或者重构
test: 添加测试代码
chore: 其他改变
style: 样式更新
0x004 commit 精细化管理
首先是为什么?为什么要管理commit,commit有啥好管理的?
在以前,我觉得git是用来记录代码操作的,我对代码的任何操作都应该被记录下来,而且就像历史一样,是神圣不可侵犯的。通过git历史,我必须要可以知道我在某一刻做了什么,就算我在一个commit添加了一行代码,然后在后一个commit删除了它,我也必须可以从log中看出来。
所以我的git历史中充满了各种无效的commit,因为有时候真的不知道如何为命名。
但是后来,我就想通了,我使用git的目标是不是为了记录,而是为了项目的稳定发展。只要实现了这个目的,手段不是问题,更何况git只是一个工具,工具是用来用的,不是用来供奉的。让自己快乐快乐才叫做意义。
所谓的管理commit,就是对commit执行增、删、改、拆操作。会在后面的章节一一列出。而管理的目的,是为了让每一个commit都有存在的意义,让Git成为项目管理真正的后盾。
后面的例子将同时提供SourceTree的操作,命令式可以看上一篇文章[前端漫谈]一巴掌拍平Git中的各种概念。
1. 排序 commit
场景:完成登陆页面之后,提交一个commit,message是feat(登陆): 完成登陆页面。然后进入其他功能的开发,之后又回到登陆页面的开发。提交记录如下:
我们有两个feat(登陆)或者多个相关的的commit,但是却分布于不同的地方,假设每一个feat(登陆)只会与前一个feat(登陆)有文件修改的交集,那么我们希望feat(登陆)相关的功能可以放在一起。如下:
如何实现:
2. 合并 commit
场景:完成登陆页面之后,提交一个commit,message是feat(登陆): 完成登陆页面。然后进入其他功能的开发,后来发现登陆有一个文案错误,继续修改,完成之后又提交一个commit,message为feat(登陆): 修改文案。提交记录如下:
在我看来,feat(登陆): 修改文案这个commit的存在是不应该的,比如,1)如果有一天我们需要单独上“登陆”功能,还有可能被遗漏;2)单独占据一个commit可能只是为了修复一个符号问题,在回溯历史的时候有不必要的浪费。也就是我希望一个commit它是独立的,不依赖后续commit的存在。
所以我希望将这两个commit合并:
操作过程:
3. 更新 commit
更新commit的场景有两个:
- 更新
message- 正好上面有一个不符合标准的
message: - 我希望改为:
- 操作说明
- 正好上面有一个不符合标准的
- 更新、添加、删除
- 1)有时候我们会通过修改某个变量来做一些测试,然后提交的时候突然发现忘记改回来;2)忘记或者误添加文件;3)忘记删除文件。这时候可以通过再创建一个
commit再改回来,但是误添加的文件依旧会在历史中存在,占据一定的空间。我们可以根据上面的“合并”方式合并commit消除影响,也可以一步到位: feat(mine): 个人中心提交中有一个mime.html文件,我希望删掉bad line;还有一个mineBad.bad这么一个看起来坏坏的文件,我希望删除它。- 操作过程(略复杂):
- 1)有时候我们会通过修改某个变量来做一些测试,然后提交的时候突然发现忘记改回来;2)忘记或者误添加文件;3)忘记删除文件。这时候可以通过再创建一个
4. 增加/分离 commit
-
增加一个
commit的意义其实不大,在更新commit的过程中我们选择的是更正上一次提交,也就是git commit --amend,但是如果我们不选择,而是创建一个提交,其实就是增加一个commit了。- 我希望在
feat(mine): 完成个人中心和feat(main): 完成主页中间添加一个commit,可以通过新建一个commit然后之后通过前面的排序手段来做到,也可以一步到位:
- 操作过程(和前面差不多,只是不选择更正上一次提交):
- 我希望在
-
分离
commit的意义重大,有时候我们希望只发布一个功能,却发现这个功能的commit中包含我们不希望发布的另一个功能,可能是因为本来要放到两个commit的功能误添加到一个commit,- 我们有一个
feat(detail): 完成详情页的commit,却不小心把other的功能给包含进去了,这时候我希望只发布detail页面,因此,对于commit的分离是必须的:
- 操作过程
- 我们有一个
5. 删除 commit
当我们做了一次修改后来发现这个修改没有必要,就可以删除这个commit,但是不推荐,除非真的确认。
-
在
feat(detail): 完成详情页面后面做了一个不需要的提交: -
删除步骤
7. cherry-pick
有时候,我们需要发布一个分支中的几个功能,比如我们在一次统一优化中修复了 5 个 bug,做了 5 个优化,但是其中几个并没有通过验证:
-
refactor/A分支中有 3 个commit,通过了 2(用 ok 标记) 个 -
fix/A分支中有 3 个 commit,通过了 2(用 ok 标记) 个
我们只能发布通过的 bug 修复和优化(标注了 ok 的),而这些修复和优化并不一定在哪个分支,是随机分布的:
在这种场景中,虽然可以用分支去处理,但是有点麻烦,这个时候 cherry-pick 是最好的工具。
-
操作过程
0x005 reflog
上面的很多操作都涉及到历史的操作,用普通的 revert 或者 reset 是无法消除影响的,只有在清楚这些命令的原理和本质的情况下才应该使用这些命令。但是对于这些操作也是有办法处理的,那就是 reflog:
在git中,所有的操作都会被记录下来,比如切换分支、合并分支等,可以使用 reflog查看这个记录,下面是cherry-pick例子产生的记录:
$ git reflog
# 执行 cherry-pick,一共 4 个 commit
b185e09 (HEAD -> pub/191206) HEAD@{0}: cherry-pick: feat(A): ok
dd67bf5 HEAD@{1}: cherry-pick: fix(A): ok
1d0237e HEAD@{2}: cherry-pick: feat(A): ok
51f808e HEAD@{3}: cherry-pick: refactor(A): ok
### 从 master 新建分支 pub/191206
a48cdd2 (master) HEAD@{4}: checkout: moving from master to pub/191206
如果我们撤销cherry-pick,可以执行以下命令:
$ git reset --hard HEAD@{4}
HEAD is now at a48cdd2 chore: 项目初始化
-
就没啦
-
再次查看
reflog,多了一条记录$ git reflog a48cdd2 (HEAD -> pub/191206, master) HEAD@{0}: reset: moving to HEAD@{4} b185e09 HEAD@{1}: cherry-pick: feat(A): ok dd67bf5 HEAD@{2}: cherry-pick: fix(A): ok 1d0237e HEAD@{3}: cherry-pick: feat(A): ok 51f808e HEAD@{4}: cherry-pick: refactor(A): ok a48cdd2 (HEAD -> pub/191206, master) HEAD@{5}: checkout: moving from master to pub/191206 -
撤销
cherry-pick又后悔啦$ git reset --hard HEAD@{1} HEAD is now at b185e09 feat(A): ok -
效果
-
又又后悔啦!!!滚
0x006 总结
-
勿忘初心,砥砺前行。我们一开始使用
git是为了更好的辅助项目,而不是让项目更加复杂,如果不使用这些方式可以让你的项目更加简单,那就不要用,为了使用git而使用git,不如不使用。 -
要理解工具的原理,再去使用,不要盲目。使用上面的命令之前,务必了解这些命令或者操作背后发生了什么。
0x007 后记
-
我一直在寻找一种好的表达方式,从截图标注、绘图,到
gif等,希望可以将文章讲的更加透彻。现在看来,可能还是gif比较好。 -
写一篇文章真的有点难啊,构思、布局、实验、总结,每一步都需要花很大的功夫,但是一篇精心总结的文章,对自己的帮助还是很大的,希望对各位也有帮助把。
0x008 资源
- git pro
- 阮一峰 - Commit message 和 Change log 编写指南
- [前端漫谈]一巴掌拍平Git中的各种概念
- SourceTree:Git 可视化工具
- ProcessOn:绘图工具
- 截图:截图和标注
- GIF Brewery 3 by Gfycat:GIF 录制和标注工具
- IDEA:IDE
0x009 带货
最近发现一个好玩的库,作者是个大佬啊--基于 React 的现象级微场景编辑器。