git 学习笔记 7
start at 2023/01/15.

让我们开始 merge 吧!

一提到 merge 就想到并查集,好像 merge 是什么关键词,我都会用 merg 来进行合并操作

合并分支

我把之前的仓库都删了,所以这次我们再来新建一个仓库吧

$ git reflog
2b93f32 (HEAD -> cat) HEAD@{2}: commit: modify a file
4e36abe HEAD@{3}: commit: add b file
e19273b (master) HEAD@{4}: checkout: moving from master to cat
e19273b (master) HEAD@{5}: commit (initial): first commit

第一次提交我新建了一个 a 文件,里面的内容是 1,然后我在这个 commit 上贴了一张新的 cat 分支并切换过去,之后的一次提交是创建了一个 b 文件,最后的提交是我在 a 文件里加了行东西

最后的逻辑图是这样

2023115before_merge

现在我觉得任务做的差不多了,差不多要准备合并回来了,如果想要用 master 分支来合并 cat 分支,就要先切换回 master 分支,然后用 git merge 命令合并

$ git checkout master
Switched to branch 'master'
$ git merge cat
Updating e19273b..2b93f32
Fast-forward
 a | 2 ++
 b | 1 +
 2 files changed, 3 insertions(+)
 create mode 100644 b

我们来查看一下文件

$ ls
a  b
$ cat a
1
2
2

我们发现目录中增加了 b 文件,并且 a 也更新了,这样就算 master 合并了 cat

谁合并谁

master 合并 cat 和 cat 合并 master 有什么区别?

因为 cat 是从 master 上分支出去的,这时候git 就会直接使用快转模式(Fast Forward)进行合并,从结果上来看是一样的:master 会直接收割 cat 的劳动成果

但是如果两个没那么直属关系的 commit 合并就没那么简单了,我们来看下下面这种情况

$ git reflog
4f0e8dd (HEAD -> dog) HEAD@{0}: commit: dog.1
47817eb (master) HEAD@{1}: checkout: moving from master to dog
47817eb (master) HEAD@{2}: checkout: moving from cat to master
c72bc15 (cat) HEAD@{3}: commit: cat.1
47817eb (master) HEAD@{4}: checkout: moving from master to cat
47817eb (master) HEAD@{5}: commit: commit 1
331e83c HEAD@{6}: commit (initial): first commit

branch2023123

如果这时候我们让 dog 和 cat 合并,情况就没有之前那么简单了,由于我预判失误,以为作者要演示合并冲突就写了个冲突的样例,所以我们被报错了

$ git merge cat
Auto-merging tmp
CONFLICT (content): Merge conflict in tmp
Automatic merge failed; fix conflicts and then commit the result.

我们来看一下 tmp 文件

$ cat tmp
i am first commit
<<<<<<< HEAD
commit dog 1
=======
commit cat.1
>>>>>>> cat
commit 1

那个 <<<<<=====>>>>> 的出现就意味着出现合并冲突,这三个符号我在之前给 suckless 软件打补丁的时候经常看到,那时候以为只是老软件不经用出现的小问题,就直接把三行符号全删了编译,有时候运气好能过编译,但有时候没过就能让我直接放弃这个补丁,但事实上编辑合并冲突的部分是需要仔细斟酌的,需要合并者自己做出抉择后再 commit,但是我们这就懒得抉择了,直接 commit !

$ git reflog
fdb53c0 (HEAD -> dog) HEAD@{0}: commit (merge): Merge branch 'cat' into dog
4f0e8dd HEAD@{1}: commit: dog.1
47817eb (master) HEAD@{2}: checkout: moving from master to dog
47817eb (master) HEAD@{3}: checkout: moving from cat to master
c72bc15 (cat) HEAD@{4}: commit: cat.1
47817eb (master) HEAD@{5}: checkout: moving from master to cat
47817eb (master) HEAD@{6}: commit: commit 1
331e83c HEAD@{7}: commit (initial): first commit

看上去平平无奇吗,那我们来看一下 commit 对象的内容吧

$ git cat-file -p fdb53c0
tree 25dabe5774036adefc460810ebf8aa136f5de2c1
parent 4f0e8dd636b5a066ad9b47d7a3f0fabbd46b9a95
parent c72bc1585262661e441e14cfd479b17326d6f703
author lol <233@qq.com> 1674435231 +0800
committer lol <233@qq.com> 1674435231 +0800

发现了吗,不同于一般的 commit 对象,不止只有一个 parent ,他同时指向了 4f038dc72bc1,正如下图

branch20231232

你可能觉得这和之前应该也没什么区别,但是我要告诉你的是有区别,之前的快转模式合并的东西,只有一个指针指向之前的 commit,如果你有兴趣可以戏子看下面这个例子,我就不画图了,事实上,git 只是试图把 commit 这条链拉直

$ git reflog
4c4f0ac (HEAD -> master, cat) HEAD@{0}: merge cat: Fast-forward
3933769 HEAD@{1}: checkout: moving from cat to master
4c4f0ac (HEAD -> master, cat) HEAD@{2}: commit: cat
3933769 HEAD@{3}: checkout: moving from master to cat
3933769 HEAD@{4}: commit (initial): first commit
$ git cat-file -p 4c4f
tree 56b90eda9c7c3a6469586c711f381864fbb42569
parent 393376917f02a226a54eac7857c7fee49f1bc9f7
author lol <233@qq.com> 1674436181 +0800
committer lol <233@qq.com> 1674436181 +0800

cat

这里对 a 合并 b 和 b 合并 a 总结一下,从最后的结果来看,是有差别的,因为上面新建的 commit 贴上的是 dog 而不是 cat

但是事实上并没有区别,我们之前说过了,分支只是一张贴纸,把所有的分支都撕掉,本质上就是把两个 commit 合到一个 commit 对象上,合并前的两个 commit 都是平行的,并没有谁先谁后的关系(决定了 parent 谁先谁后的顺序,但是这个影响还是当作没有好了)

上面快转模式的拉直就属于特殊情况了,但是如果你想要不拉直也是可以的 **git merge cat --no-ff**,貌似对公共分支的合并都是用 --no-ff 来合并,这样对公共的分支就能减少

合并后的分支

一般一个分支在和主分支合并之后就没什么用了,这时候可以删除它了,但由于在 git 中分支的成本实在是太小了(仅需 40 字节),所以你也可以选择保留下来当一扇传送门用,但是如果是还没有被合并的分支就另当别论了。从本质上来讲,分支就是一张贴纸而已,删除分支只是把贴纸撕下来,删不删除就要看使用者自己的决定了

删除未合并分支

如果你不小心删除了一个没有合并的分支,别怕,是可以挽救回来的,再强调一遍分支的概念,分支只是一张贴纸,删除一个分支并不会让已经提交的 commit 消失,所以想挽救回来我们只需要知道一件事情,那就是,你想在哪个 commit 上贴上那张分支的贴纸,在删除未合并分支的时候 git 会告诉你那条 commit 的 SHA-1 是多少,如果你没记住的话还是可以用 git reflog 来找找是哪一条 commit,贴上贴纸的方法很简单,git branch new_cat theSHA1code,但还是要小心,书上说 git reflog 默认只有三十天的保质期,但是根据 .git 文件夹里的 git 对象,我们其实可以自己画出一个 commit 的 DAG,实现起来应该不是特别难(有空写个脚本?

PS:合并分支的本质其实是合并两个 commit,只要理解了分支的概念(只是贴在 commit 上的指针),合并分支这一个概念就不会太难理解

另一种合并

git 还提供另一种合并方式,叫做 git rebase,现在给出一个例子用于学习 rebase

$ git reflog
b1ba426 (HEAD -> dog) HEAD@{0}: commit: dog 1
3d1a12e (master) HEAD@{1}: checkout: moving from master to dog
3d1a12e (master) HEAD@{2}: checkout: moving from cat to master
ba47345 (cat) HEAD@{3}: commit: cat 1
3d1a12e (master) HEAD@{4}: checkout: moving from master to cat
3d1a12e (master) HEAD@{5}: commit (initial): first commit

rebase2023123

现在我们使用 git merge cat 就会得到一个 commit 指向 ba4734b1ba42,而使用 git rebase 的结果不同于 git merge,字面意思,rebase 就是重新定义了那条分支的 base,在没有合并前,cat 分支的 base 是 master,想象一下,rebase 到 dog 后会发生什么

$ git rebase cat
Successfully rebased and updated refs/heads/dog.
$ git log --oneline
1b21a4a (HEAD -> dog) dog 1
ba47345 (cat) cat 1
3d1a12e (master) first commit
$ git reflog
1b21a4a (HEAD -> dog) HEAD@{0}: rebase (finish): returning to refs/heads/dog
1b21a4a (HEAD -> dog) HEAD@{1}: rebase (pick): dog 1
ba47345 (cat) HEAD@{2}: rebase (start): checkout cat
b1ba426 HEAD@{3}: commit: dog 1
3d1a12e (master) HEAD@{4}: checkout: moving from master to dog
3d1a12e (master) HEAD@{5}: checkout: moving from cat to master
ba47345 (cat) HEAD@{6}: commit: cat 1
3d1a12e (master) HEAD@{7}: checkout: moving from master to cat
3d1a12e (master) HEAD@{8}: commit (initial): first commit

观察 reflog 的最上面三行,git 先 checkout 到了 cat,然后讲 dog 中的修改应用到 cat 中,然后修改了 refs/heads/dog,最后的效果会是这样

rebase2023123then

看上去像是 git 废弃了之前 dog 分支的 commit,把那些 commit 从 cat 分支上重新提交一遍,这样看区别还是很明显的,另一个区别就是 git 不会为合并再专门做出一个用于合并的 commit

和 merge 的交叉合并比起来,rebase 更像是嫁接,这个特点在原本 commit 链很长的分支中更为明显,那之后所有嫁接过去的 commit 的 SHA-1 也会理所当然地被重新计算

我们再把目光移到之前的 commit,那条 commit 现在并没有消失,只是没有分支贴在上面,但会在 git 的资源回收时被清理掉,所以如果你后悔了 rebase 操作,是有一段反悔的时间的,这时候你可以使用 git reflog 查找之前 commit 的 SHA-1,然后 git reset SHA-1 --hard 就行了,另一种方法是用 git 的特别记录点 ORIG_HEAD,它会记录合并分支这类危险操作之前的 HEAD 位置,直接 git reset ORIG_HEAD --hard 就能移动回去了

什么时候用rebase

我们有两种合并的方法了,但我们要如何抉择什么时候用什么合并方式呢

我们总结一下 rebase 的好处,它不会产生额外的 commit 来记录合并操作,但缺点是它会改变历史,而且不是非常直观,对于新手很可能会 rebase 出事故,遇到冲突不知道要如何解决的情况

一般情况下,rebase 会用在还没有 push 出去但是 commit 很琐碎想整理一下的情况下,因为改动历史对于公共空间来说算是一种污染,是要避免发生的,对于已经推出去的内容,如果没有必要,不要使用 rebase

感觉 rebase 这个合并方式和分支是张贴纸这个概念有点冲突,因为如果分支只是一张贴纸,那么它只会记录当前贴着的 commit 的信息,但是从 rebase 的过程来看分支还拥有一个 base 的属性,这样分支很可能不是一张贴纸,而是更有可能是一条 commit 链,不过应该只有 rebase 的时候需要这样考虑,不过在我未来的开发方式应该会更加倾向于 git merge

经过我的本地试验和网上查阅,发现分支就是张简单的贴纸没有 base 属性,而 rebase 操作中的 base 是指两个要合并分支所在 commit 的 LCA!!!

DAG 是不是能求 LCA 来着(忘光了?

合并冲突

之前说过合并会发生冲突,这里来具体讲一下吧

git 能检测出简单的冲突,所以并不是改到同一个文件就一定会发生冲突,但是改到同一行一定会发生冲突!

下面是 cat 分支的文件内容

$ git checkout cat
Switched to branch 'cat'
$ cat tmp
1
2
345
4
5

下面是 dog 分支的文件内容

$ git checkout dog
Switched to branch 'dog'
$ cat tmp
1
2
3333
4
5

这时候如果使用合并就会发生冲突

$ git merge cat
Auto-merging tmp
CONFLICT (content): Merge conflict in tmp
Automatic merge failed; fix conflicts and then commit the result.
$ git status
On branch dog
You have unmerged paths.
  (fix conflicts and run "git commit")
  (use "git merge --abort" to abort the merge)

Unmerged paths:
  (use "git add <file>..." to mark resolution)
        both modified:   tmp

no changes added to commit (use "git add" and/or "git commit -a")

现在来查看一下 tmp 就会发现之前类似的符号

$ cat tmp
1
2
<<<<<<< HEAD
3333
=======
345
>>>>>>> cat
4
5

符号的意思很直观明了,<<<<<===== 之间的是 HEAD 也就是 dog 分支的内容,另外一个则是 cat 分支的内容,现在这种情况就需要思考要怎么处理了,是要留一个还是两个都保留,如果没法做决定就把两边的人都叫来商量一下,改完之后记得 git addgit commit

值得一提的是,如果你使用 git rebase 合并过程中发生了冲突可能会有点不同,因为 git rebase 需要多次应用修改,冲突有可能是在 rebase 了一半的时候出现的,这时候处理完当前的冲突之后还需要继续刚刚中断的 rebase,git rebase --continue

分支的存储

为什么都说 git 中开分支很便宜呢?

答案是分支其实就是一个只有 40 字节的文件而已,你可以在你的 .git/refs/heads/ 里找到你的分支们,而那些文件里存储的就是他们指向的 commit,如果你把这些文件删了,就相当于删了这些分支,除此之外还有一个重要的文件,那就是 .git/HEAD,它存储的是当前处于哪个分支上,当切换分支时,它会改变,.git/ORIG_HEAD 则是和分支文件差不多,功能之前提过就不重复了,还有一个冷知识就是你可以用 @ 来替代 HEADgit reset @^ <=> git reset HEAD^

分支这一章就到这里了,学了分支之后感觉已经有点理解 git 的设计思路了

下一章讲的是修改历史记录,期待能学到新东西

2023/01/23
> CLICK TO back <