我们需要什么样的 git 多人协作方式?

背景

当我们查看开源项目的 git 历史记录时,会发现非常干净整洁,记录完全成一条直线:

v2X4tP.png

但看我们项目的记录时,会发现几个问题:

  • 掺杂太多「merge branch ……」之类的无用信息。
  • commit 太杂、太乱,例如一个需求为了解决测试的 bug,会有很多「fix」相关的提交,但其他人并不关心这个需求上线之前解决了几个 bug。
  • 线很乱。

例如(隐藏了一些不方便公开的关键信息):

vRAp3d.png

为了使项目提交记录也能和上面一样,需要有一些提交的策略。

接下来从原理开始,一点一点尝试把这个事情讲清楚。

提交的本质

在 git 内部,每一个提交都会保存这些信息:

  • 当前时间的整个目录(被称为 tree 对象)。
  • 上一个提交的 sha-id。
    • 这样可以形成类似链表的数据结构,但与链表的区别在于:
      • 可以为零个(初始提交)、一个(正常提交)或多个(合并时)。
      • id 可以重复,此时相当于提交分叉了。
    • 但我们一般把箭头反过来指,这样可以表示提交的顺序。
  • 作者,一般包含姓名和邮箱。
  • 提交 message。

由于每个提交都有上一个提交的指向,因此 git 可以把这些提交组合成一个完整的提交记录:

1
2
3
4
5
6
7
8
9
A -> B -> C -> D
\ \
\ \
\ \
E -> F H
\
\
\
G

分支的本质

分支实际上是指向某一个提交的指针。

例如,如果只有一个分支(一般为 master 分支),有了三个提交:

1
A -> B -> C

此时 master 会指向 C 提交:

1
2
3
A -> B -> C

master

此时,从 master 新建一个分支为 dev,此时它与 master 指向同一个提交:

1
2
3
4
5
          dev

A -> B -> C

master

然后,在 dev 分支上继续提交:

1
2
3
4
5
               dev

A -> B -> C -> D

master

此时 master 上有了另两个提交:

1
2
3
4
5
6
7
8
9
               dev

A -> B -> C -> D
\
\
\
E -> F

master

但约定俗成地,分支也代指从与其他分支公共祖先到当前最新提交的提交记录,如上面的 E -> F

理解了分支,接下来来看如何把两个分支的内容进行合并。

关于 rebase 和 merge

rebase 和 merge 都是用来把一个分支的提交同步到另一个分支上,一般会有几种情况:

  • 提测 / 上线 / master 的分支有更新,需要把这些更新同步到我开发的分支上。
  • 在自己的分支上开发完毕,需要集成:
    • 如果用单独的测试分支测试,需要同步到测试分支上。
    • 如果用 master 分支上线,需要同步到 master 分支;如果用单独的上线分支上线,需要同步到上线分支上,上线完毕后还要再同步到 master 上。

先贴一个图描述一下这两者的区别:

vR9wuT.jpg

观察这个图,具体来说:

1
2
3
4
5
  commit B    commit C      commit D
A -------> AB --------> ABC -------> ABCD master
\
\ commit E commit F
--------> ABE -------> ABEF tabby

tabby 是自己的开发分支,现在需要把 master 上的修改同步过来。

我们先考虑 merge 的场景,即在 tabby 上使用 git merge master(注意,这里没有加参数,应用的是 merge 的默认行为)。

此时,git 首先会检查 tabby 是否可以直接指向 master(换句话说,tabby 上是否有 master 没有的提交,再换句话说,从 master 切出 tabby 之后,tabby 是否有更新)

如果可以,会直接把 tabby 指向 master

否则,就会在 tabby 上创建一个新的 commit,名叫 Merge branch 'master' into 'tabby',内容为 master 上有差异的内容(如图里的 CD)。

因此按照上面的例子来说,由于 master 上有 tabby 没有的提交,就会变成这样:

1
2
3
4
5
  commit B      commit C     commit D
A -------> AB ---------> ABC -------> ABCD master
\ \
\ commit E commit F Merge branch 'master' into 'tabby'
--------> ABE -------> ABEF ---------------------------------> ABCDEF tabby

这就会带来前面的两个问题:

  • 当我们查看 tabby 分支的提交记录时,由于 CD 是从 master 上过来的,提交记录就会有一个分叉(可以想象一下把这个图竖过来看)
  • 多了一个无意义的 Merge branch 'master' into 'tabby' 提交。

rebase 时,git 会把 tabby 分支上的提交挪动位置,具体来说,是放到 master 的后面,相当于在 master 分支的后面把 tabby 的提交逐个重放:

1
2
3
4
5
  commit B    commit C     commit D
A -------> AB -------> ABC -------> ABCD master
\
\ commit E commit F
-------> ABCDE -------> ABCDEF tabby

此时,上面两个问题就得到了解决:

  • tabby 分支和 master 分支位于同一条线了,所以提交记录看不到分叉。
  • 没有额外的提交。

merge 的一些参数

上一节中只是简单地介绍了 merge 的行为,实际上还有一些点需要补充。

首先,上文提到:

此时,git 首先会检查 tabby 是否可以直接指向 master(换句话说,tabby 上是否有 master 没有的提交,再换句话说,从 master 切出 tabby 之后,tabby 是否有更新)

如果可以,会直接把 tabby 指向 master

否则,就会在 tabby 上创建一个新的 commit,名叫 Merge branch 'master' into 'tabby',内容为 master 上有差异的内容(如图里的 CD)。

但例子只讲到了「否则」的场景,把上一个场景也讲一下,此时相当于从 master 切出 tabby 之后还没有更新(或者 rebase 之后 master 又有新的提交):

1
2
3
4
5
  commit B      commit C     commit D
A -------> AB ---------> ABC -------> ABCD master
\
\
tabby

此时 master 指向 ABCDtabby 指向 AB,此时在 tabby 上使用 git merge master 时,git 发现 tabbymaster 在同一条线上,因此只需要改变 tabby 的指向到 master 就好了:

1
2
  commit B    commit C      commit D
A -------> AB --------> ABC -------> ABCD tabby & master

我们把这种提交叫 Fast Forward。

上面就是 git merge 的默认行为,我们也可以通过参数来调整它的行为:

--no-ff

在满足 Fast Forward 条件时也会创建一个合并提交,也就是说,每次合并都会创建合并提交。

例如:

1
2
3
4
5
  commit B      commit C     commit D
A -------> AB ---------> ABC -------> ABCD master
\
\
tabby

会变为:

1
2
3
4
5
  commit B      commit C     commit D
A -------> AB ---------> ABC -------> ABCD master
\ \
\ Merge branch 'master' into 'tabby'
---------------------------------------------------------------> ABCD tabby

这样做的好处是:让每一个 merge 行为都可以被记录下来。

--ff-only

如名称所示,只当满足 Fast Forward 条件时才可以 merge,否则 git 会给出错误提示。

在强制使用 Fast Forward 的场景中,该选项可以作为一个流程上的卡控。

--squash

这个参数的含义很简单,就是在 merge 分支的时候把分支上的所有 commit 合并为一个 commit 后再 merge 到目标分支。

因为在工作中会有这样的场景:

  • 开发时在自己的分支上提交了很多 commit,联调、测试的时候为了修复 bug 又提交了很多 fix 类型的 commit。
  • 由于 git 按时间顺序处理 commit,不同人提交 commit 的时间很难控制,这导致不同分支的 commit 在 merge 到同一个分支之后会交错在一起,不好找。
  • 在需要回滚的场合,由于过于耦合,很难准确地进行代码回滚。
  • 尤其是有些功能急于发布,但由于混入了有 bug 的代码又回退不了,这是非常折磨人的。

具体说来,就是这个场景:

1
2
3
4
5
6
7
8
9
------------------------------------------------------------------------ master
\ \ \
\ \ -----> A1 ------------------------------> A2 ---> A3 ---------- branch-a
\ \
\ ---------------------- B1 ---------> B2 -----------------> B3 --- branch-b
\
--> C1 ---------> C2 ---------> C3 -------------------------------- branch-c


branch-abranch-bbranch-c 分支都 merge 到 master 上之后,master 上的提交记录会变成:

1
-------> C1 --> A1 --> C2 --> B1 --> C3 --> B2 --> A2 --> A3 --> B3 --- master

此时,如果要回退所有跟 A 相关的代码,需要仔细地找出 A1A2A3,这是很麻烦的事情。

此时我们用了 squash,并把 A1A2A3 合并成 AB1B2B3 合并成 BC1C2C3 合并成 C(假设按 BCA 的提交顺序)

1
2
3
4
5
6
7
------------------------------------------------------------------------ master
\ \ \
\ \ -------------------------------------------> A ---------------- branch-a
\ \
\ --------------> B ----------------------------------------------- branch-b
\
--------------------------------> C ------------------------------- branch-c

此时,将 branch-abranch-bbranch-c 分支都 merge 到 master 上之后,master 上的提交记录会变成:

1
--------------------> B -------------> C -----------> A ---------------- master

由于一个需求往往对应一个分支,此时也对应一个 commit,既好找,也方便回滚。

这样做虽然会丢失一些提交信息,但站在整个项目的维度,我们很可能关心的只是「这个需求的代码什么时候上线的(或者说合并进主分支的)」,而不是「这些代码都是什么时候,分几次提交的」,更具体的一点说,版本历史记录的应该是代码的发展,而不是开发者在编码时的活动。

一般来说,merge 到基本分支的操作都是发生在远端(也就是在 Web 端操作),而 squash merge 会生成一个新的提交,这就需要对提交信息进行控制:

  • GitHub 会生成一个默认的提交信息(默认的提交信息也可以配置),可以修改:

  • GitLab 可以在 squash 时指定提交信息:

  • 而有些内部实现的 Git 工具没有这一功能(提交信息被写死成「squash merge branch … to …」),此时有两种方案:

    • merge 之后,手动 pull,git commit --amend(修改最近一次提交信息),force push。
    • force push 有一定风险,可以用用下面的方法,在 merge 之前就合并成一个提交。

-no-commit

当 merge 有冲突时,我们可以在 git 的暂存区看到一大堆的文件修改。这是因为 merge 默认会直接提交,而有冲突时需要手动解决冲突,无法自动提交。

这个参数可以不让 git 自动提交(无论有冲突与否),方便我们进一步调整 merge 之后的代码。

-m

在非 Fast Forward 场景时,可以编辑提交信息(即可以替换掉默认的 Merge branch 'xxx' into 'xxx')。

--no-verify

可以跳过 pre-commitcommit-msg 这两个 Git Hook。