前言:Git 作为版本控制工具在全球的开发者(游戏除外)中得到了广泛的使用。加深对 Git 的理解,有助于规范化开发流程,降低合作开发的成本,减少合作时误操作的产生。

1. 编辑相关·

1.1 git diff 相关的算法·

在 Git 中,我们会利用内置的算法对文件进行操作,普通 add、commit 涉及的数学算法并不多。但是如果产生了冲突,或更普遍的文件修改,Git 就需要知道文件发生了哪些修改。

文件更新和冲突标记其实是一个原理,区别在于前者是在一个 commit 链上进行,有着严格的前后时序关系,因此 Git 会很自信地将老版本的内容更新为新版。但是如果基于同一个 commit 产生了两个有冲突的分叉,当我们合并这两个分叉的时候就产生了冲突。这时 Git 不再自信,它需要我们人工介入,按照程序员的意愿修改完冲突,随后再合并。因此,文件更新前后的版本对比和冲突时的 diff 标记,使用的算法是一致的。

在此,我们隆重介绍 Meyers 算法。该算法来自于 Eugene W. Myers 的一篇十五页的论文:An O(ND) Difference Algorithm and Its Variations. 详细描述该算法以及进行证明超越了本文的范畴。

1.2 merge 冲突产生的中间文件·

现在我们要把刚开发、测试好的 dev 分支合并到 master 里。第一步我们自然是要切换到 master 分支,现在 git 的 HEAD 指针就指向 master 分支。第二步自然是执行 git merge dev 命令合并 dev 分支。但如果在合并之前,我们先 pullmaster 的最新版本,好巧不巧有个同事跟你修改了同一个文件的同一个行。这时你再合并你的 dev 分支就会出现冲突。此时会出现一个“灰色”的临时节点:Uncommitted Changes. 这意味着你先要解决冲突。

图 1. git 块冲突。

冲突文件的冲突部分,会以如下格式显示出来:

1
2
3
4
5
6
7
...
<<<<<<< HEAD
hello master
=======
hello dev
>>>>>>> dev
...
  • 开头的 <<<<<<< HEAD 指的是当前分支的内容,左边指向过去
  • 中间的 ======= 七个等号是分割线
  • 最后的 >>>>>>> dev 是目标分支的内容,代表传入的更改。右边指向未来。

Git 和 VSCode 都提供了自动处理冲突的功能,VSCode 更绝,直接点击鼠标进行选择即可。但是现实往往不那么理想,现在复杂一些的项目,同一个文件的上下文是关联的,因此不宜盲目点击接收传入或者接收合并,应该根据实际情况手动修改,随后进行代码格式化、二次测试后,再确认合并。

在这个过程中,出现冲突并不可怕。它只是让 staging 状态发生了修改,所以你完全可以基于这个冲突的状态做任何修改、任何测试。如果多个文件有冲突,也可以挨个手动合并、手动 add.

2. 分支相关·

在开发过程中,最理想化的分支状态就是只有一个分支,即单链。但这显然是不可能的,多人共同开发也不能做到这种单链状态,除非互相等待。

2.1 fast-forward 策略·

git merge 的时候,默认使用 fast-forward 策略,也就是如果 dev 分支和 master 分支在同一个 commit 链时,落后的 master 合并 dev 时会采用直接移动的方式实现,也就是修改 master 对应的 SHA1 值。

  • 优点:快速,不产生冗余的合并 commit 节点
  • 缺点:devmaster 两个分支在一条 commit 链上,dev 超前 master,如果我们让 master 合并 dev,那么就会面临一种情况:分支起始节点在历史记录里看不出来。如果合并后的版本在功能上出现了恶性 bug,我们想看一下是何人、何时开始开发的这个新特性以便找出背锅的人,然后我们就去搜 git log,这时发现根本看不出分支的起始点。

为此,笔者建议采用下述方案做分支开发与合并:

  • 待合并的分支 devrebasemaster 分支
  • master merge 待合并的分支且禁用 fast-forward

综合成以下命令:

1
2
3
4
5
6
7
8
(main) git branch -b dev
(dev) # Edit and coding
(dev) git add ...
(dev) git commit -m 'Add new feature or fix bugs'
(dev) git rebase master
(dev) # 解决冲突
(dev) git checkout main
(main) git merge dev

图 2. 在不使用 fast forward 选项的情况下进行 git merge.

从上图可以看到,最近的 dev 更新,被作为一个额外的支线引入 master,最终以生成了一个 merge commit 作为终结。期间,dev 所做的操作(add hello2 func)被良好地记录了进来。这就是先 rebasemerge --no-ff 的魅力。

最后,提一个有关的命令:git log --merges. 该命令只输出合并的节点。也就是父节点数量为 2 的那种 commit 记录。该命令等价于 git log --min-parents=2.

2.2 不要把 commit 当 Ctrl+S 用·

Git 的 commit message 应该是清晰干净的,对于笔者这样的有洁癖的人,每一次 commit 都要包含扎实的改进,如果这次改进不能独立成文则不应该被提交,必须有充足的工作量才可以。

最佳实践

这里提到的充分的工作量并非指要有很多行的提交,而是指对于这次 commit 的意图,所有的细节改进必须是绝对相关的。比如你在 commit message 里面提到更新了函数 A,则你的改进就只能包含函数 A 的行,其余函数不应该有任何改变,也不应该对代码的风格做美化。

如果你同时修改了多个函数:

  • 在保证两者没有互相依赖的情况下,可以分成两次提交。Git 支持选中某些行并添加到 staging Tree,并非全部 git add. 可以搜索 git add -pgit add --interactive
  • 如果两个函数彼此依赖,比如 Func_B 会调用 Func_A,则需要将两个函数的修改全部 git add,不可分开提交。当然,我们认为这样的编码习惯是不好的,函数作为一种功能封装,对外保持接口即可,如果 Func_A 的改变导致 Func_B 也要修改,就说明设计不够科学。但是谁又能保证不出现这种情况呢?所以这个 Git 思想还是要掌握。

2.3 正确对待提交历史·

提交历史对于回溯软件开发的生命周期至关重要,所以笔者一再强调要对每次提交负责,不要盲目提交。每次提交要保证内容的一致性,比如一次提交仅仅修改一个模块,或者对某项功能做了优化。如果你做了多个彼此正交的修改,则应该拆分成多次提交。

然而现实往往是残酷的,人的思路因为遗忘或者出错导致提交后悔是很正常的。那应该怎么处理比较好?

  • 第一种做法是不对历史做任何篡改,毕竟要忠于历史记录,这样回溯的时候就不会产生任何遗漏,也不会对历史产生扭曲。然而这很容易导致提交记录很乱,特别是在多人合作的时候,对方 checkout 出来的代码不能保证干净、低耦合度。
  • 第二种做法是尽情地修改历史,比如使用 amend 或者 Lazygit 的 A 命令将 staging 的内容补充到前面的提交里。这样可以保证历史是干净整洁的。但是这无疑会扭曲历史。比如你在日记里写下我今天做了哪些事情。一个月以后,你发现之前的提交漏掉了一个文件,然后你在今天把文件重新补充到一个月前的提交里。这时候你的日记就和 git log 无法对应,你翻看之前的记录不仅会产生疑问,一个月之前我有对这个文件做过修改吗?

最佳实践

抱持极端是不理智的,特别是很多情况下你都是有选择权的。我建议,如果只是单人工作目录,大可对提交历史宽容一些,毕竟自己一个人看,本质上干净的提交历史最为重要。如果是多人合作,或者面临修改很久之前的提交,则尽量保守一些,因为这很容易导致历史扭曲和合作冲突。

总结一下,下面的情况是可以接受的:

  • 提交发生在最近,而且这些提交还没有进入主线代码;
  • 提交只对个人项目有效,不与其他人发生关系;
  • 提交记录没有溢出到别的系统里。

反之则否。

3. 平台配置相关·

这是一个很有历史的问题,导致现在出现很多困扰。不过理清历史有助于我们认知现实。

  • LF: Line Feeding,指的是只进行换行,光标位置不变(参考电传打字机,为什么是 Feed(喂)这个词?因为输出这一行的样子,真的像给牛喂草)
  • CR:指的是光标归位到首行(同样参考电传打字机)

根据不同操作系统,对换行的定义是不同的:

1
2
3
CRLF -> Windows-style
LF -> Unix Style
CR -> Mac Style

在 Git 中,通过设置 core.autocrlf,可有效控制 line ending 的处理逻辑。该变量有三个取值:

1
2
3
true:     x -> LF -> CRLF
input: x -> LF -> LF
false: x -> x -> x

下面用自然语言解释一下:

  • true
    • add/commit时:CRLF -> LF,但是 staging 目录依旧保持 CRLF
    • 签出时:LF -> CRLF
  • false
    • line endings 将不做转换操作,文本文件保持原来的样子
  • input
    • add/commit时:CRLF -> LF
    • 签出时:和数据库源文件保持一致

3.1 Windows 平台开发 Windows 项目·

可设置为 false. 此时保证一致性即可。

3.2 Windows 平台查看 Linux / Mac 项目·

如果设置为 true,签出展开时 LF -> CRLF,因此 Git 会提示所有文件都发生了修改!

但是,略作修改再提交时,CRLF -> LF,因此不影响 Linux 库原本的面貌。只是在浏览时,Git 会提示你所有代码都改变了,这个很烦人,也影响对代码的把控。

建议此时设置为 input,签出保持原样,只是在 add/commit 时做 CRLF -> LF.

硬核观察:在 Windows 上 (autocrlf=input) 编辑一个新拉下来的 Linux 代码库,换行全都是 LF. 此时我们故意把所有 LF 替换为 CRLF,那么 git 立即提示代码发生修改,但是当你 git add -A 之后,所有的更改都消失了,换言之,因为 git add 触发了 CRLF -> LF 的自动转换,所以此时临时储存站和 git 数据库保持一致了,因此更改消失。但是,此时 Windows 上却是 CRLF,与 git 数据库并不一致,而且 git 也不会报文件修改提示。此时再偷偷换回 LF,也不会有任何提示。总之,这个还是很别扭的。

1
2
$ git add -A
warning: in the working copy of 'Zsh_Configs/zshrc', CRLF will be replaced by LF the next time Git touches it

意思是说,你虽然对换行符做了修改,而且也 git add 了,但是下次 git 签出时,你的这次修改将变得无效。比如,我们删除该文件,然后 git 恢复它,就发现 LF 换行符又出现了。