Git 高级教程:重点讲解
前言: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
分支。但如果在合并之前,我们先 pull
了 master
的最新版本,好巧不巧有个同事跟你修改了同一个文件的同一个行。这时你再合并你的 dev
分支就会出现冲突。此时会出现一个“灰色”的临时节点:Uncommitted Changes
. 这意味着你先要解决冲突。
冲突文件的冲突部分,会以如下格式显示出来:
1 | ... |
- 开头的
<<<<<<< HEAD
指的是当前分支的内容,左边指向过去 - 中间的
=======
七个等号是分割线 - 最后的
>>>>>>> dev
是目标分支的内容,代表传入的更改。右边指向未来。
Git 和 VSCode 都提供了自动处理冲突的功能,VSCode 更绝,直接点击鼠标进行选择即可。但是现实往往不那么理想,现在复杂一些的项目,同一个文件的上下文是关联的,因此不宜盲目点击接收传入或者接收合并,应该根据实际情况手动修改,随后进行代码格式化、二次测试后,再确认合并。
在这个过程中,出现冲突并不可怕。它只是让 staging
状态发生了修改,所以你完全可以基于这个冲突的状态做任何修改、任何测试。如果多个文件有冲突,也可以挨个手动合并、手动 add
.
2. 分支相关·
在开发过程中,最理想化的分支状态就是只有一个分支,即单链。但这显然是不可能的,多人共同开发也不能做到这种单链状态,除非互相等待。
2.1 fast-forward 策略·
git merge 的时候,默认使用 fast-forward 策略,也就是如果 dev 分支和 master 分支在同一个 commit 链时,落后的 master 合并 dev 时会采用直接移动的方式实现,也就是修改 master 对应的 SHA1 值。
- 优点:快速,不产生冗余的合并 commit 节点
- 缺点:
dev
和master
两个分支在一条 commit 链上,dev
超前master
,如果我们让master
合并dev
,那么就会面临一种情况:分支起始节点在历史记录里看不出来。如果合并后的版本在功能上出现了恶性 bug,我们想看一下是何人、何时开始开发的这个新特性以便找出背锅的人,然后我们就去搜git log
,这时发现根本看不出分支的起始点。
为此,笔者建议采用下述方案做分支开发与合并:
- 待合并的分支
dev
先rebase
到master
分支 master
merge
待合并的分支且禁用fast-forward
综合成以下命令:
1 | (main) git branch -b dev |
从上图可以看到,最近的 dev
更新,被作为一个额外的支线引入 master
,最终以生成了一个 merge
commit 作为终结。期间,dev
所做的操作(add hello2 func
)被良好地记录了进来。这就是先 rebase
再 merge --no-ff
的魅力。
最后,提一个有关的命令:git log --merges
. 该命令只输出合并的节点。也就是父节点数量为 2 的那种 commit 记录。该命令等价于 git log --min-parents=2
.
2.2 不要把 commit
当 Ctrl+S 用·
Git 的 commit message 应该是清晰干净的,对于笔者这样的有洁癖的人,每一次 commit 都要包含扎实的改进,如果这次改进不能独立成文则不应该被提交,必须有充足的工作量才可以。
2.3 正确对待提交历史·
提交历史对于回溯软件开发的生命周期至关重要,所以笔者一再强调要对每次提交负责,不要盲目提交。每次提交要保证内容的一致性,比如一次提交仅仅修改一个模块,或者对某项功能做了优化。如果你做了多个彼此正交的修改,则应该拆分成多次提交。
然而现实往往是残酷的,人的思路因为遗忘或者出错导致提交后悔是很正常的。那应该怎么处理比较好?
- 第一种做法是不对历史做任何篡改,毕竟要忠于历史记录,这样回溯的时候就不会产生任何遗漏,也不会对历史产生扭曲。然而这很容易导致提交记录很乱,特别是在多人合作的时候,对方
checkout
出来的代码不能保证干净、低耦合度。 - 第二种做法是尽情地修改历史,比如使用
amend
或者 Lazygit 的A
命令将 staging 的内容补充到前面的提交里。这样可以保证历史是干净整洁的。但是这无疑会扭曲历史。比如你在日记里写下我今天做了哪些事情。一个月以后,你发现之前的提交漏掉了一个文件,然后你在今天把文件重新补充到一个月前的提交里。这时候你的日记就和 git log 无法对应,你翻看之前的记录不仅会产生疑问,一个月之前我有对这个文件做过修改吗?
3. 平台配置相关·
这是一个很有历史的问题,导致现在出现很多困扰。不过理清历史有助于我们认知现实。
- LF: Line Feeding,指的是只进行换行,光标位置不变(参考电传打字机,为什么是 Feed(喂)这个词?因为输出这一行的样子,真的像给牛喂草)
- CR:指的是光标归位到首行(同样参考电传打字机)
根据不同操作系统,对换行的定义是不同的:
1 | CRLF -> Windows-style |
在 Git 中,通过设置 core.autocrlf
,可有效控制 line ending 的处理逻辑。该变量有三个取值:
1 | true: x -> LF -> CRLF |
下面用自然语言解释一下:
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 | $ git add -A |
意思是说,你虽然对换行符做了修改,而且也 git add
了,但是下次 git 签出时,你的这次修改将变得无效。比如,我们删除该文件,然后 git 恢复它,就发现 LF
换行符又出现了。