三大基本操作的细节

clone

大家都知道 git clone 命令是把一份远程的仓库拷贝到本地。具体来讲,clone 操作大致分为三个步骤。

假设当前远程仓库有三个提交,两个分支:

那么 clone 流程如下:

  1. 拷贝分支、分支在远程的镜像(即 origin/*)。以及 HEAD 指针和它的远程镜像。
  2. 拷贝这些引用所指向的提交。
  3. 拷贝父提交。

push

和 clone 一样,push 也分为几个步骤执行。

假设本地状态如下:

那么 push 流程如下:

  1. 推送 HEAD 所指向的东西。当前 HEAD 指向 mater 分支,所以会推送 master 的位置。
  2. 推送上一步推送的那个引用所指向的 commit,以及中途所有 commit。
  3. 如果 HEAD 指向的是一个分支,并且这个分支是默认分支(远程 HEAD 所指向的分支),那么更新远程的 HEAD。
  4. 最后更新本地的远程镜像,确保它们与最新的远程仓库状态一致。

pull

git pull 命令其实是 git fetchgit merge origin/xxx 的合并。

为方便说明,先看一下远程与本地仓库的当前状态:

git fetch 操作是把远程镜像更新到本地,具体来说又分为两步:

  1. 远程镜像更新到本地。注意,只更新镜像,不更新本地分支(feature1 / master 的指向不变)。
    image-20221102213619611
  2. 远程镜像所指向的提交如果有缺失,就拉到本地。

到此为止 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

resetcheckout 一样也会移动 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 就是手动选择要添加暂存的行。打开编辑器后按照提示删除不想提交的行就行了。

Last modification:November 3, 2022