导读
在上一篇文章[前端漫谈]一巴掌拍平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 的现象级微场景编辑器。
