重学 Git 细节与本质
既然是重新学习,就不过多解释 Git 的基本用法,而是关注它的原理与进阶。
三大基本操作的细节
clone
大家都知道 git clone
命令是把一份远程的仓库拷贝到本地。具体来讲,clone 操作大致分为三个步骤。
假设当前远程仓库有三个提交,两个分支:
那么 clone 流程如下:
- 拷贝分支、分支在远程的镜像(即
origin/*
)。以及 HEAD 指针和它的远程镜像。 - 拷贝这些引用所指向的提交。
- 拷贝父提交。
push
和 clone 一样,push 也分为几个步骤执行。
假设本地状态如下:
那么 push 流程如下:
- 推送 HEAD 所指向的东西。当前 HEAD 指向 mater 分支,所以会推送 master 的位置。
- 推送上一步推送的那个引用所指向的 commit,以及中途所有 commit。
- 如果 HEAD 指向的是一个分支,并且这个分支是默认分支(远程 HEAD 所指向的分支),那么更新远程的 HEAD。
- 最后更新本地的远程镜像,确保它们与最新的远程仓库状态一致。
pull
git pull
命令其实是 git fetch
与 git merge origin/xxx
的合并。
为方便说明,先看一下远程与本地仓库的当前状态:
git fetch
操作是把远程镜像更新到本地,具体来说又分为两步:
- 把远程镜像更新到本地。注意,只更新镜像,不更新本地分支(
feature1
/master
的指向不变)。 - 远程镜像所指向的提交如果有缺失,就拉到本地。
到此为止 fetch 就完成了。
接着执行 git merge origin/xxx
,其中「xxx」指的是远程仓库中 HEAD 所指向的分支。在我们这个例子中就是 git merge origin/master
。注意,本地其他分支(比如 feature1
)是不会自动合并的。
其他命令
checkout
checkout 的中文翻译为「签出」,一个比较奇怪的词汇。首先我们要知道,一个 git 仓库,它的全部信息都储存在 .git
文件夹里,而项目文件夹里的其他文件只是 git 其中一个状态的反映。某种程度上可以认为这些文件是临时的,在已经提交的情况下即使被删掉,git 也有能力还原它们。
所谓 checkout,就是把仓库指定时刻的状态反应到工作目录中,通俗点讲,就是查看某个 commit 时的代码。我们又知道,git 中一个比较特殊的指针 HEAD
,它就代表着当前的状态。所以水到渠成地,checkout
命令会移动 HEAD 指针。当前 HEAD 的指向就存在 .git/HEAD
文件里。
HEAD 可以指向两类东西:
-
指向另一个指针(引用)。例如指向 master 分支,此时 HEAD 文件内容为
ref: refs/heads/master
-
直接指向某个提交。此时 HEAD 文件内容直接就是这个提交的 id。
fd975e8e876c3aa5a4d0fb9a26545eea446c8ed0
-
有一个特殊情况。远程的镜像分支 (
origin/*
) 是不允许被指向的,如果强制 checkout,HEAD 会滑落到实际的 commit。
当 HEAD 没有指向分支时就进入了分离状态。分离状态的 HEAD 不会随着新的提交而移动,而创建的新提交也会随着切换回分支而变得没有引用,从而隐藏起来。
如果希望强制指向到当前分支最新的那一个提交,与分支分离,除了查看 Commit Id 来 checkout 外,还有一个便捷命令:
# 签出当前分支最新的 commit,进入分离状态
git checkout --detach
rebase
rebase 命令相对比较形象,从用户角度看它用于移动当前分支,使之基于某一个特定的 commit 或分支。
实际上,git 的安全性很大程度上来源于 commit 不允许修改。显然,变更父 commit 也是一种修改。所以本质上 rebase 命令没有移动 commit,而是创建了一组新的 commit,最直观的证明就是 rebase 后看似相同的 commit id 变了。
例如下图是现在的状态:
在 feature1 分支执行 git rebase master
后不是想象中的直接移动:
真实情况是这样的:
原来的 commit 现在没有任何引用指向它们了,所以常规情况下无法访问到,也不会在 log 中显示。这些提交没有丢失,还是有办法找到的。 而且如果事先记录过它们的 id 的话,现在依然可以 checkout。
这也意味着如果有分支 feature2 也指向 commit5,那么 feature1 rebase 后不会影响 feature2 的指向,此时它们指向的是看似相同实则不同的两个 commit。如下图所示:
了解这一点,以后往 rebase 过的分支上提交代码时再也不会误以为两个分支依然是等价的了。
reset
reset
与 checkout
一样也会移动 HEAD(并同步更改工作目录里的文件),不一样的是 reset 除了会移动 HEAD 之外,也会把分支一并移动了。分支移动后,它之后的 commit 就被视为不存在(与 rebase 一样,其实还是存在的,并且有办法恢复)。正因为这个特性从而得名 reset
,也即把代码重置到之前的某个状态。
那我之后提交的那些代码去哪了? 这牵扯到 reset 的三个模式:
mixed
: 默认模式。工作目录不变(还是之后提交的代码)。hard
: 丢弃所有修改,工作目录只显示「那时候」的文件。soft
: 工作目录不变,并且把之后提交的代码加入暂存区。
和 checkout
类似,reset
到不能指向的引用时,会回落到具体的 commit。例如执行 git reset master
,实际会把当前分支移动到 master 分支的最新 commit(git 不允许分支嵌套,即一个分支指向另一个分支)。
默认情况下 reset 会带着 branch 一起移动,但如果当前处于分离情况,那就只移动 HEAD 了。 这种情况下 reset 到分支,HEAD 还是只能指向最新的 commit,还是处于分离状态,与 checkout 行为不一样。
tag
tag 和 branch 类似,也是一个引用。它的特点是
- 创建后不能修改(移动)
- 不能被 HEAD 指向。(手动指向会滑落到 commit)
因为 tag 不能移动,所以非常适合用来标记版本。后面无论如何修改,依然能够轻松定位某版本相应的代码。
cherry-pick
这是一个名字起得比较文艺的命令,其实际作用正如比喻的那样:从一个分支中取几个提交放到当前分支。
一个可能的使用场景如下:为某一新特性的开发做了个分支 feature1
,结果快做完了朝三暮四老板决定不做了。但其中某些代码其他功能也会用到,全扔了挺可惜的。此时就可以把那些 commit 挑选出来,拷贝一份加到 HEAD 处。
例如当前仓库状态如下:
现在要抛弃分支 feature1
,但想保留 commit 4/5。执行 git cherry-pick4 4 5
,结果如下:
修正提交
修正最新的提交
大概工程师们灵光都会在提交的一瞬间乍现,git 为修正最新提交提供了便捷命令 git commit --amend
。
和 rebase
原理一样,amend 不会真正修改提交,而是基于上一个 commit 创建一个新的 commit,如图:
这意味着假如有另一个分支 hotfix
指向旧的提交 5
,执行 amend 后 hotfix
不会自动切换到 5'
,如果继续提交的话很有可能和预期不符。
修改历史
要修改更早的 commit 需要用交互式 rebase 命令:
git rebase -i
此处应该 rebase 到要修改的 commit 的前一个 commit。
其背后的行为和常规 rebase 类似:生成新的 commit,其之后的所有 commit 也都会生成新的。这里就不再放图片了。
git 中每一个 commit 都依赖前一个 commit,因此使用 rebase -i
修改历史往往会导致一连串的冲突需要解决。例如下图显示了三个 commit 对应的文件状态,在删掉中间一个 commit 后,系统不会自动把其对应的文件修改撤回。于是在下一个 commit 中 git 不知道内容 "BBB" 来自哪,冲突就出现了。
修正已经 push 的操作
虽然从理论上远程仓库不是不能改,毕竟还有 git push --force
大杀器在呢。但这样就要求全团队成员全部重新从远端同步,并手动处理很多恶心的问题。所以实际开发中,一旦推送的远程,就不允许修改。
不过代码写错了总得撤销呀?小问题还好,手动删一删再 commit 一下。如果是大问题呢?比如要砍掉整个 feature?此时需要另一个命令:
git revert
revert 会自动创建一系列倒序的 commit,直到还原到之前某个 commit 的状态。它与 reset/rebase 的不同是没有修改历史,只是创建了新的 commit,只不过这些 commit 的内容是撤回代码而已。
revert 的参数就是要撤回的那个 commit,执行命令后代码的状态与撤回 commit 的前一个 commit 状态一致。
万能后悔药
现在我们已经明确知道,git 不可能搞丢 commit,因为 commit 禁止修改,也不能删除。但的确可能丢失它的引用,比如执行 git reset
后,如果没有其他引用指向之前的那个 commit,很可能就找不到了。此时怎么还原呢?
git 有一个堪称生死簿级别的记录:
git reflog
它会展示所有历史操作的记录,具体是保存在 .git/logs
下,这些记录按照引用分类。在命令行下也可以按引用过滤,比如:
# 只显示 master 引用变更记录
git reflog master
以设想的场景为例,下面是 hard reset 后的 ref 记录:
> git relog main
c141dfa (HEAD -> main) main@{0}: reset: moving to c141dfaef55839ad33502b65304c895ccf40b5b2
e17b664 main@{1}: merge feat1: Fast-forward
可以看到 reset 之前最后一个操作的 id 是 e17b664
,那么执行:
git reset --hard e17b664
就能撤回 reset 仿佛什么都没发生。
部分提交
有时候我们文思泉涌,叨叨叨叨叨写了一大堆,又或者是暂时对项目没有整体规划无法一次写完一个小模块,被迫写完整个需求。就这么一坨提交上去的话可能把同事看得云里雾里,也不利于后续的复盘或撤回,要是此时能按照更细分的代码块提交就好了。强大的 git 也满足这个需求,就是交互式添加:
> git add -i
*** Commands ***
1: status 2: update 3: revert 4: add untracked
5: patch 6: diff 7: quit 8: help
What now>
每个选项的具体作用可以输入 help 查看,目前需要的是 patch
。之后 git 会要求我们选择一个修改的文件确认后打开,询问下一步操作。默认情况下一次只显示这个文件连续修改的一块称为 hunk,一个文件文件可能被自动拆成多个 hunk 编辑,以方便操作。
@@ -1 +1,5 @@
-abcd
+ab
+
+line3
+
+line5
(1/1) Stage this hunk [y,n,q,a,d,e,?]?
此时输入 ?
查看帮助。输入 e
就是手动选择要添加暂存的行。打开编辑器后按照提示删除不想提交的行就行了。