← 返回画廊

Git 是怎么工作的:从 blob 到 rebase

读者画像:天天用 git add / commit / push,但一遇到 rebase 冲突、detached HEAD、"提交丢了"就开始慌的工程师。<br><br>读完这篇文章,你会建立一个心智模型:git 的本质是一个内容寻址的对象数据库(content-addressable object database),外面套了一层「移动指针」的工作流。一旦这个模型立住了,几乎所有 git 命令都会变成"显然如此",而不是"背下来的咒语"。

全文所有命令输出都来自真实执行的 demo 仓库(git 2.51.2),不是编造的示例。你可以照着任何一段命令自己敲一遍复现。


目录

  1. 四种对象:blob / tree / commit / tag
  2. 一次 commit 到底存了什么;HEAD、分支、标签是什么
  3. 三个区域:工作区 / 暂存区 / 版本库
  4. 分支与合并:fast-forward、三方合并、merge vs rebase
  5. reset / revert / cherry-pick / reflog
  6. 常见困惑澄清
  7. 速查表:我想做 X,用什么命令

1. 四种对象:blob / tree / commit / tag

1.1 git 仓库的本质:.git/objects 目录

初始化一个仓库、提交一个文件之后,.git 目录里真正存数据的地方只有一个:.git/objects

$ git init
$ echo "hello world" > hello.txt
$ git add hello.txt
$ git commit -m "add hello.txt"

$ find .git/objects -type f | sort
.git/objects/60/339342fff8a403550baaf9aca38dc0425e844d
.git/objects/68/aba62e560c0ebc3396e8ae9335232cd93a3f60
.git/objects/3b/18e512dba79e4c8300dd08aeb37f8e728b8dad

三个文件,对应这次 commit 产生的三个对象:一个 blob(文件内容)、一个 tree(目录快照)、一个 commit(元数据 + 指向 tree 的指针)。目录名是哈希的前 2 位,文件名是剩下的 38 位——纯粹是为了避免一个目录下塞几十万个文件拖垮文件系统,语义上它们仍然是同一个 40 位(SHA-1)哈希。

git 里只有这四种对象类型,没有第五种:

对象类型存的是什么类比
blob一个文件的内容(不含文件名、权限)文件的字节流
tree一层目录结构:文件名 + 权限 + 指向 blob/tree 的哈希目录清单
commit一个 tree 的哈希 + 父 commit 哈希 + 作者/时间/信息某一时刻的快照 + 元数据
tag(附注标签)指向某个 commit(一般)的哈希 + 标签信息给某个 commit 贴的标签

1.2 内容寻址:哈希就是地址,不是校验和

git hash-object 可以让你直接算出某个文件内容对应的 blob 哈希,不需要真的提交:

$ git hash-object hello.txt
3b18e512dba79e4c8300dd08aeb37f8e728b8dad

这个哈希不是随便算的,它是这样来的(关键点:哈希对象包含一个类型+长度的 header,不是对文件内容裸算 SHA-1):

store = "blob " + <content 字节数> + "\0" + <文件原始内容>
hash  = SHA1(store)

验证一下(Python 手算,不调用 git):

import hashlib
content = b'hello world\n'
header = f'blob {len(content)}\x00'.encode()
store = header + content
print(hashlib.sha1(store).hexdigest())
# 3b18e512dba79e4c8300dd08aeb37f8e728b8dad

git hash-object 的结果完全一致。这就是"内容寻址"(content-addressable storage)的含义:对象的地址(文件名)不是你起的,是内容本身算出来的。同样的内容,无论提交多少次、在多少个文件里出现,哈希永远一样,对应同一个 blob 对象——这也是为什么"相同内容只存一份"。

实测一下:在同一个仓库里再加一个内容完全相同的文件:

$ echo "hello world" > sub/hello_copy.txt
$ git hash-object hello.txt
3b18e512dba79e4c8300dd08aeb37f8e728b8dad
$ git hash-object sub/hello_copy.txt
3b18e512dba79e4c8300dd08aeb37f8e728b8dad     # 完全一样

$ git add sub/hello_copy.txt
$ git commit -m "add duplicate content file"

$ find .git/objects -type f | sort
.git/objects/18/79b6118a6fcb894ba4d253c76ecc5a7ce8fc91   # 新 tree(子目录 sub/)
.git/objects/3b/18e512dba79e4c8300dd08aeb37f8e728b8dad   # 还是同一个 blob,没有变多
.git/objects/60/339342fff8a403550baaf9aca38dc0425e844d
.git/objects/65/44704f738c5939372e374d60e8a54d5bc0d17b   # 新 tree(根目录)
.git/objects/68/aba62e560c0ebc3396e8ae9335232cd93a3f60
.git/objects/f4/34ae77e7a10b3cd7ce8108c1b56389a7473cf6   # 新 commit

$ git ls-tree -r HEAD
100644 blob 3b18e512dba79e4c8300dd08aeb37f8e728b8dad	hello.txt
100644 blob 3b18e512dba79e4c8300dd08aeb37f8e728b8dad	sub/hello_copy.txt

两个不同路径的文件,ls-tree 里指向同一个 blob 哈希.git/objects 里只新增了两个 tree 对象和一个 commit 对象,blob 一个没多。这就是"相同内容只存一份"的字面意思——文件名和目录结构信息全部由 tree 对象承担,blob 只关心内容。项目里如果你 cp 了一个大文件、或者两个分支上有大段相同代码,git 存储层面完全不会因此翻倍。

顺带一提:对象在磁盘上是经过 zlib 压缩的,cat .git/objects/3b/18... 看到的是乱码,要用 git cat-file 或手动 zlib.decompress 才能还原:

import zlib
raw = open('.git/objects/3b/18e512dba79e4c8300dd08aeb37f8e728b8dad', 'rb').read()
print(zlib.decompress(raw))
# b'blob 12\x00hello world\n'

git cat-file -t <hash> 查类型,-p 查内容(pretty-print,帮你把 header 和压缩去掉):

$ git cat-file -t 3b18e512dba79e4c8300dd08aeb37f8e728b8dad
blob
$ git cat-file -p 3b18e512dba79e4c8300dd08aeb37f8e728b8dad
hello world

1.3 SHA-1 到 SHA-256:这是"内容寻址"而不是"加密"

git 传统上用 SHA-1(40 个十六进制字符)。SHA-1 早已不算密码学安全(2017 年 Google 就公开演示过 SHA-1 碰撞攻击 SHAttered),但对 git 来说风险模型不同:git 用哈希做的是内容寻址和完整性校验,不是加密,攻击者需要在你已经信任的仓库里植入一个哈希碰撞的恶意对象,门槛和收益都很有限。

不过 git 官方从 2020 年(2.29 版本)就已经支持 SHA-256 作为仓库的对象格式(git init --object-format=sha256),这是一个仓库级别的选择,一旦确定无法混用。截至目前,SHA-256 已经在 Git 2.51 中被定为 Git 3.0 的默认哈希算法方向,但完整迁移还卡在生态上——GitHub 至今不支持 SHA-256 仓库,GitLab 从 2023 年开始支持但仍是实验特性。也就是说,你现在克隆的绝大多数仓库,包括 GitHub/GitLab 上的项目,哈希仍然是 SHA-1,这篇文章里看到的 40 位十六进制哈希就是 SHA-1 的产物。等生态完全切过去,本文讲的所有原理不变,只是哈希从 40 位变成 64 位(SHA-256 是 32 字节 = 64 个十六进制字符)。

参考:Git hash-function-transition 官方文档Help Net Security:Git 2.51 为 SHA-256 铺路

1.4 tree 对象长什么样

一个 tree 就是一层目录的清单,每一行是"权限 类型 哈希 文件名":

$ git cat-file -p HEAD^{tree}
100644 blob 3b18e512dba79e4c8300dd08aeb37f8e728b8dad	hello.txt
040000 tree 1879b6118a6fcb894ba4d253c76ecc5a7ce8fc91	sub

100644 是文件权限(普通文件),040000 是目录(子 tree)。git 不直接存"目录"这个概念,目录只是"一个指向子 tree 的条目",本质上 tree 对象自己会递归指向别的 tree 对象,构成一棵树——这也是为什么它叫 tree。

1.5 tag 对象:附注标签才是"对象"

git tag v1.0 这种轻量标签(lightweight tag)其实只是一个 ref,不产生新对象,等价于给某个 commit 哈希起了个别名。而 git tag -a v1.0 -m "release" 这种附注标签会真的创建一个 tag 对象,里面记录了指向的 commit 哈希、打标签的人、时间、说明文字——这也是为什么正式发布建议用附注标签,它自带审计信息。


2. 一次 commit 到底存了什么;HEAD、分支、标签是什么

2.1 commit 对象的原始内容

cat-file -p 直接看某次 commit 对象的内容(不是 git show 那种格式化输出,是最原始的对象内容):

$ git cat-file -p HEAD
tree 68aba62e560c0ebc3396e8ae9335232cd93a3f60
author Demo <demo@example.com> 1783803035 +0800
committer Demo <demo@example.com> 1783803035 +0800

add hello.txt

一个 commit 对象只有这几行:

这里最容易被误解的一点:git 不是存 diff 的系统,它存的是每次提交的完整快照。 你在网上搜到的"git 是快照流,不是差异流"说的就是这件事——tree 字段指向的是这一刻整个项目的完整目录树,不是相对上一次的改动。git 能省空间,靠的是 1.2 节讲的内容寻址去重(没改的文件,tree 里的条目还是指向老 blob,不会产生新对象),以及打包(pack)时的 delta 压缩,而不是提交时就按 diff 存储。

2.2 parent 链就是提交历史

git log 展示的历史,本质上就是从某个 commit 开始,顺着 parent 字段往回走:

$ git log --oneline
f434ae7 add duplicate content file
6033934 add hello.txt

$ git cat-file -p f434ae7
tree 6544704f738c5939372e374d60e8a54d5bc0d17b
parent 6033934...
author ...

第二次提交的对象里出现了 parent 6033934...,指向第一次提交。一条提交历史,本质上是一条通过 parent 指针串起来的单向链表;有多个 parent 的 commit(合并提交)会让它变成一张有向无环图(DAG),这一点在第 4 节会很关键。

2.3 HEAD、分支、标签:全都是"指针文件"

现在关键问题来了:git log 怎么知道从哪个 commit 开始往回找?答案在 .git/refs.git/HEAD 里,这两个东西极其简单,简单到有点反直觉。

$ cat .git/HEAD
ref: refs/heads/main

$ cat .git/refs/heads/main
f434ae77e7a10b3cd7ce8108c1b56389a7473cf6

就这样。分支不是一个"容器",也不装着任何提交,它就是一个只有 40 个字符的文本文件,内容是某个 commit 的哈希git commit 做的事情,说到底就是:

  1. 创建一个新的 commit 对象,parent 指向当前 HEAD 指向的 commit;
  2. 把当前分支这个文件(如 refs/heads/main)的内容改写成新 commit 的哈希。

HEAD 则是"指针的指针":正常情况下它不直接存哈希,而是存一行 ref: refs/heads/<分支名>,告诉 git"我现在在哪个分支上"。git checkout <分支> / git switch <分支> 做的事就是把 .git/HEAD 这一行文本改成指向另一个分支文件。

标签同理,只是放在 refs/tags/ 下,且默认不随提交移动(打上就不再变,这是它和分支的本质区别:分支是会自动跟着新提交移动的指针,标签是钉死不动的指针)。仓库标签、分支多了以后,git 会把这些 refs 打包进 .git/packed-refs 文件做优化,但语义完全一样,只是省了每个 ref 一个独立文件的开销。

理解了这一点,你会发现:"切分支" (git switch) 从物理层面看,只是改了一个文本文件里的一行内容 + 把工作区文件换成对应 tree 快照的内容,成本极低——这也是为什么 git 分支被称为"轻量级"分支,创建一个分支就是 echo <hash> > .git/refs/heads/<name> 这么便宜的操作,不涉及复制任何代码。


3. 三个区域:工作区 / 暂存区 / 版本库

git 管理你代码时始终有三个区域,几乎所有命令的行为都可以归结为"在这三者之间挪动数据":

区域英文存在哪是什么
工作区Working Directory磁盘上你能直接编辑的文件当前你看到、改动的实际文件
暂存区 / 索引Staging Area / Index.git/index(二进制文件)"下一次 commit 打算提交什么"的草稿
版本库Repository (History).git/objects + refs已经封存成 commit 对象的历史

.git/index 本身就是一个记录了"路径 → blob 哈希 + 权限 + 文件状态"的二进制文件,可以用 git ls-files --stage 直接看到它现在指向哪些 blob:

$ git ls-files --stage
100644 3b18e512dba79e4c8300dd08aeb37f8e728b8dad 0	hello.txt
100644 3b18e512dba79e4c8300dd08aeb37f8e728b8dad 0	sub/hello_copy.txt

注意这一行的格式和 tree 对象几乎一模一样(权限 + 哈希 + 路径)——这不是巧合。index 本质上就是"下一次 commit 要生成的那个 tree 对象"的一份可编辑草稿git commit 做的事情,简化说就是把 index 内容原样封成一个 tree 对象,再包一层 commit 对象。

3.1 命令对照表

这是本节最重要的表:常用命令到底在哪两个区域之间搬东西。

命令工作区 → 暂存区暂存区 → 版本库版本库/暂存区 → 工作区说明
git add <file>把工作区当前内容写入 index
git commit把 index 封装成新的 commit 对象,移动分支指针
git commit -a✅(仅已跟踪文件)add + commit 的合并,不包括新文件(untracked)
git restore <file>✅(从 index 恢复到工作区)丢弃工作区改动,找 index 里的版本
git restore --staged <file>↩️(反向:撤销 add)把 index 里的改动退回工作区,不动版本库
git checkout -- <file>(旧写法)等价于 restore,历史遗留命令,语义重载严重
git reset --soft <commit>只移动分支指针,index 和工作区都不动
git reset --mixed <commit>(默认)↩️(反向:index 回退,工作区不变)移动分支指针 + 重置 index,工作区保留改动
git reset --hard <commit>↩️↩️移动分支指针 + 重置 index + 覆盖工作区,改动彻底丢失
git checkout <commit/branch>把 HEAD、index、工作区都切到目标 commit 的快照
git stash↩️↩️把工作区 + 暂存区的改动打包存起来,恢复到干净状态

用一句话记忆三个 reset 的区别(这是全文最容易混的地方,第 5 节会用真实输出验证):

soft 只挪指针(回到"刚 commit 之前,改动都还暂存着"的状态);<br>mixed(默认)挪指针 + 清空暂存区(回到"改了但没 add"的状态);<br>hard 挪指针 + 清空暂存区 + 覆盖工作区(改动物理消失,除非能用 reflog 找回)。


4. 分支与合并:fast-forward、三方合并、merge vs rebase

4.1 fast-forward:没有分叉时,合并就是挪指针

如果 feature 分支就是在 main 的基础上一路往前走、main 期间没有任何新提交,那么"合并"根本不需要产生新的 commit——只要把 main 指针直接挪到 feature 指向的地方就行,这叫 fast-forward(快进)

$ git log --oneline --all --graph
* 90b40e2 (feature) feature: edit hello.txt
* f434ae7 add duplicate content file
* 6033934 add hello.txt

$ git switch main
$ git merge feature
更新 f434ae7..90b40e2
Fast-forward
 hello.txt | 1 +
 1 file changed, 1 insertion(+)

$ git log --oneline --graph
* 90b40e2 (HEAD -> main, feature) feature: edit hello.txt
* f434ae7 add duplicate content file
* 6033934 add hello.txt

合并前后一条直线,没有任何"合并提交"产生——git 只是把 refs/heads/main 这个文件的内容,从 f434ae7 改成了 90b40e2。这也解释了为什么有些团队要求 git merge --no-ff:强制哪怕能快进也生成一个合并提交,为的是在历史上留下"这里确实做过一次功能分支合并"的记录,方便以后回溯和整体 revert。

4.2 三方合并:分叉之后,需要一个新的 commit

如果 maintopic 各自都有新提交(历史分叉了),合并就没法只挪指针了,必须造一个新的 merge commit,它比普通 commit 多一个特征:有两个(或更多)parent

$ git log --oneline --all --graph
* 95b30dc (main) main: edit copy file
| * ee1565e (topic) topic: another edit
|/
* 90b40e2 feature: edit hello.txt
...

$ git merge topic -m "merge topic into main"
Merge made by the 'ort' strategy.

$ git log --oneline --graph --all
*   f07f24e (HEAD -> main) merge topic into main
|\
| * ee1565e (topic) topic: another edit
* | 95b30dc main: edit copy file
|/
* 90b40e2 feature: edit hello.txt
...

$ git cat-file -p HEAD
tree fbc5d4b871f53f5edf203caa8a7830174a656a44
parent 95b30dccf3a6f24be7167af678823eade767aa50
parent ee1565eb054defcf34b51d488501350606750cfc
author Demo <demo@example.com> ...
committer Demo <demo@example.com> ...

merge topic into main

注意 cat-file -p 里出现了两行 parent。这就是三方合并(three-way merge)名字的由来:git 找到两个分支的共同祖先(这里是 90b40e2,术语叫 merge base),拿"共同祖先 vs main"和"共同祖先 vs topic"两份差异分别对比,自动合并不冲突的部分,冲突的部分交给你手动解决,最后生成一个双 parent 的新 commit。

4.3 merge vs rebase:改写的是什么,什么时候用哪个

这是最容易讲混的部分,先给结论,再用真实命令验证:

用真实命令验证"哈希真的变了":

$ git switch -c topic4 90b40e2
$ echo "brand new independent file" > new_file.txt
$ git add new_file.txt && git commit -m "topic4: add new independent file"
$ git rev-parse HEAD
f6e38f27a921916897662da05a222d6279b36e6d

$ git rebase main
正在变基(1/1)成功变基并更新 refs/heads/topic4。

$ git rev-parse HEAD
6a439dde26d9121a466a5f4dcdcda400a324d4e8

同一次改动("add new_file.txt",内容完全没变),rebase 前是 f6e38f2...,rebase 后变成了 6a439dd...——这不是同一个 commit 挪了个位置,是 git 拿着同样的 diff,在新的 parent 上重新生成了一个全新对象。这一点怎么强调都不过分:rebase 不是"移动"提交,是"删旧建新"。

黄金法则:不要 rebase 已经推送的公共历史

原因直接从上面的实验就能推出来:既然 rebase 会产生全新的哈希,那么如果你已经把 topic4 推到远程、同事已经在这个基础上拉取并开发,你一 rebase,你本地和远程/同事那边的提交历史就在同一段时间线上产生了两条哈希完全不同但内容相似的提交序列。同事下次 pull,git 认不出这些"内容相似但哈希不同"的提交是同一批东西,会造成大量重复提交或者奇怪的合并冲突,需要人工介入才能理清。

这就是社区反复强调的 "Golden Rule of Rebasing":只 rebase 你自己独占、还没有被别人拉取过的本地分支/提交;一旦推送到共享分支(多人协作的 main/develop 之类),此后只用 merge,不要再 rebase 或者 push --force 改写它。个人的 feature 分支、还没开 PR 之前,想怎么 rebase -i 整理提交都可以,这不违反黄金法则,因为没有别人依赖这段历史。

什么时候用哪个:一张对比

场景推荐理由
个人 feature 分支,PR 前想让历史干净好看rebase -i(交互式变基,整理/压缩提交)还没人依赖这段历史,改写没有代价
把 feature 分支同步最新 main 的改动(分支还没推送/没人协作)rebase main保持线性历史,避免产生一堆无意义的合并提交
合并已经开放讨论的 PR、多人协作分支merge不改写哈希,不会让其他协作者的本地历史失效
需要保留"这确实是一次功能合并"的历史痕迹merge --no-ff便于以后整体 revert 这个功能
已经 push 到远程、别人可能已经拉取的分支绝对不用 rebase违反黄金法则,会给协作者带来历史冲突

4.4 为什么会冲突:本质是三方合并的自动化在某处失败了

无论 merge 还是 rebase,底层做的都是三方比较(共同祖先 / 你的改动 / 对方的改动)。冲突发生的唯一原因是:同一个文件的同一处内容区域,双方都相对共同祖先做了不同的改动,git 的自动合并算法无法判断该保留哪个版本,于是把两边的内容都写进文件、留下标记,交给人来判断。

真实的冲突标记长这样(topictopic2 两个分支都改了 hello.txt 的最后一行):

$ git rebase main
正在变基(1/1)自动合并 hello.txt
冲突(内容):合并冲突于 hello.txt

$ cat hello.txt
hello world
feature line
<<<<<<< HEAD
topic change
=======
topic2 change
>>>>>>> f1c9066 (topic2: change for rebase demo)

<<<<<<< HEAD======= 之间是当前分支(rebase 时这里是"目标分支")的版本,=======>>>>>>> <commit> 之间是被合并进来那一方的版本。解决冲突就是手动编辑成你想要的最终内容、删掉这三行标记,然后:

如果发现搞砸了想回到冲突开始之前的状态:git merge --abortgit rebase --abort,两者都会干净地把工作区、index、HEAD 恢复到操作前的样子(这一步安全,随时可以用)。


5. reset / revert / cherry-pick / reflog

5.1 reset --soft / --mixed / --hard 的真实差异

沿用 3.1 节的表,用真实命令验证三种模式到底动了什么:

$ git log --oneline -1
f07f24e merge topic into main

# --soft:只挪 HEAD/分支指针,index 和工作区不变
$ git reset --soft HEAD~1
$ git status --short
M  hello.txt          # 注意:M 在第一列,说明改动还在暂存区(已 add 状态)
$ git diff --cached --stat
 hello.txt | 1 +

# 恢复现场,测试 --mixed(默认,不加参数就是这个)
$ git reset HEAD~1
重置后取消暂存的变更:
M	hello.txt
$ git status --short
 M hello.txt           # 注意:M 在第二列,说明改动退回了工作区,没有暂存

# 恢复现场,测试 --hard
$ git reset --hard f07f24e37a7d0acbfb388227f94be838c7cb98df
HEAD 现在位于 f07f24e merge topic into main

git status --shortM 出现在第一列还是第二列,正是"暂存区状态"和"工作区状态"的直接体现——这两列本身就是 index 和工作区两个独立区域的写照。--hard 之后工作区文件被直接覆盖成目标 commit 的快照内容,如果你有没提交的改动,这一步会让它们从磁盘上消失,是三种模式里唯一有数据丢失风险的。

5.2 revert:不改写历史,新增一个"反向提交"

reset --hard 是删除历史(移动指针,让某些 commit 不再被任何分支引用),revert 是保留历史、新增一个内容相反的提交去抵消它——这是公共分支上撤销改动的唯一安全方式,因为它不违反"不要改写已推送历史"的原则。

$ git log --oneline -1
ebe8495 feature-x: add cherry_target.txt

$ git revert --no-edit ebe8495
[main a6af969] Revert "feature-x: add cherry_target.txt"
 1 file changed, 1 deletion(-)
 delete mode 100644 cherry_target.txt

$ git log --oneline -3
a6af969 Revert "feature-x: add cherry_target.txt"   # 新提交,撤销了改动
ebe8495 feature-x: add cherry_target.txt             # 原提交依然在历史里
f07f24e merge topic into main

被撤销的原提交 ebe8495 并没有消失,历史上仍然能看到"曾经加过这个文件,后来又撤销了"的完整记录——这正是团队协作分支上应该用 revert 而不是 reset --hard 去撤销一个已经推送的错误提交的原因:reset --hard 需要强推(push --force)才能同步给别人,而强推公共分支正是黄金法则明确禁止的操作。

小提醒:revert 一个合并提交(有两个 parent)时必须用 -m 指定"以哪个 parent 为主线"(git revert -m 1 <merge-commit>),因为 git 不知道该拿哪一侧的历史做三方对比的基准,直接 revert 会报错拒绝执行。

5.3 cherry-pick:把某个提交的改动"复制"到别处

cherry-pick 做的事情是:拿某个 commit 相对它自己 parent 的 diff,应用到你当前所在的分支上,生成一个新的 commit(新哈希,parent 是你当前分支的 HEAD,不是原提交的 parent)。

$ git log --oneline --all
76ae969 (feature-x) feature-x: add cherry_target.txt
f07f24e (HEAD -> main) merge topic into main
...

$ git cherry-pick 76ae969
[main ebe8495] feature-x: add cherry_target.txt
 1 file changed, 1 insertion(+)
 create mode 100644 cherry_target.txt

$ git log --oneline -2
ebe8495 feature-x: add cherry_target.txt    # 新哈希,跟 76ae969 内容一样但不是同一个对象
f07f24e merge topic into main

典型场景:某个 hotfix 提交在 develop 分支上,需要单独补到已发布的 release/v1.2 分支,但不想把 develop 上其他未完成的改动一起带过去——cherry-pick 精确挑出这一个提交即可,不需要合并整条分支。

5.4 reflog:git 的"后悔药",救回看似丢失的提交

这是本节最重要的实操:只要一个 commit 对象还没被 git 垃圾回收(默认至少保留 90 天,gc.reflogExpire 控制),哪怕当前没有任何分支/标签指向它,也能通过 reflog 把它找回来

先制造一次"误操作丢失提交"的真实场景:

$ echo "important work" > important.txt
$ git add important.txt && git commit -m "important: work that will be lost"
$ git rev-parse HEAD
9c3c906727e43828bd8a60aee57f44b2b2ca96e5

# 手滑,用 reset --hard 把这次提交"丢"了
$ git reset --hard HEAD~1
HEAD 现在位于 f07f24e merge topic into main

$ git log --oneline -3
f07f24e merge topic into main
95b30dc main: edit copy file
ee1565e topic: another edit
# important.txt 那次提交,在 log 里已经彻底看不到了

这时候大部分人会慌,但其实数据完全没丢——reset 只是移动了分支指针,commit 对象本身还老老实实躺在 .git/objects,只是没有任何 ref 指向它,git log 顺着 parent 链找不到它而已。reflog 记录的是"HEAD 曾经指向过哪些地方"的操作日志,跟 commit 历史是两套完全独立的账本:

$ git reflog
f07f24e HEAD@{0}: reset: moving to HEAD~1
9c3c906 HEAD@{1}: commit: important: work that will be lost   # 找到了
f07f24e HEAD@{2}: reset: moving to f07f24e...
...

$ git reset --hard 9c3c906
HEAD 现在位于 9c3c906 important: work that will be lost

$ cat important.txt
important work

一行 git reset --hard <reflog 里那个哈希>,文件内容完整找回。这也是为什么理解了"git 是内容寻址数据库"这个心智模型之后,你会对误操作没那么恐慌:只要没有主动 git gc --prune=now 或者等到默认的过期时间,被"丢弃"的提交只是暂时没人指向它,对象本身还在,reflog 就是那份"曾经指向过谁"的备忘录。

reflog本地的,存在 .git/logs/HEAD.git/logs/refs/... 里,不会随 push 同步给别人,也不会被 clone 带走——这意味着它只能救你本地仓库里因为 resetrebasecheckout 等操作导致"暂时找不到"的提交,救不回你从没在本地存在过的东西。


6. 常见困惑澄清

6.1 Detached HEAD 到底是什么状态

正常情况下 .git/HEAD 内容是 ref: refs/heads/main 这种"指向一个分支"的间接引用。但如果你直接 checkout 一个具体的 commit 哈希(而不是分支名),.git/HEAD 会变成直接存那个 commit 的哈希:

$ git switch --detach 90b40e2
头指针分离于 90b40e2

$ cat .git/HEAD
90b40e2aceb359ac25684cd882bcf207c8ab21ba

对比一下:正常状态下 HEAD 文件内容是一行文本 ref: refs/heads/main;detached 状态下变成了赤裸裸的 40 位哈希,没有 ref: 前缀。这就是 detached HEAD(分离头指针) 名字的由来——HEAD 不再"依附"于任何分支,它自己直接指向一个 commit。

危险之处在于:这个状态下如果你新建了 commit,这些新 commit 没有任何分支指向它们。一旦你切换到别的分支,刚才那些 commit 就变成了"游离对象",如果不记住哈希或者不马上 git switch -c <新分支名> 把它们接上一个分支,等 reflog 过期后就真的找不回来了(跟 5.4 节的区别是:5.4 节里至少还有 reflog 记录,这里如果你压根没意识到自己在 detached HEAD 就更危险)。

git log --allgit checkout 某个 tag、git bisect 过程中,都会让你处于 detached HEAD——这不是 bug,是正常状态,git 甚至会主动提示你"如果想保留这里的改动,请新建分支"。看到这个提示不用慌,处理方式很简单:git switch -c rescue-branch 把当前位置接上一个新分支名即可。

6.2 为什么会冲突(再强调一次触发条件)

已经在 4.4 节展示过真实的冲突标记,这里补充触发条件的精确定义:冲突只在"同一个文件的同一处代码区域,两边相对共同祖先都发生了改动,且改动内容不同"时才会发生。如果两个分支改的是同一个文件的不同区域(比如一个改第 10 行,一个改第 50 行),git 的三方合并算法能自动拼接,根本不会冲突,你可能都感觉不到。真正冲突的场景通常是:同一行/相邻行被两边用不同方式改动,或者一边删除了文件、另一边修改了同一个文件。

6.3 fetch vs pull:pull 就是 fetch + merge(或 rebase)

理解了这一点,遇到"不确定要不要 pull,怕搞乱本地改动"的场景,标准做法是:先 git fetch 看看远程有什么新东西(git log main..origin/main 预览差异),确认没问题再 git merge origin/mainpull,而不是盲目直接 pull

6.4 .gitignore 对已经被跟踪(tracked)的文件不生效

这是新手最常踩的坑之一,原理其实很直接:.gitignore 的作用范围是"未跟踪(untracked)文件要不要被 git add . / git status 提示",它完全不检查已经在 index 里的文件。一旦一个文件被 git add 过一次,git 就已经在 index 里记下了它的路径,之后这个路径的改动都会被正常跟踪,.gitignore 里写不写这条规则,git 根本不会去查。

真实验证:

$ echo "tracked_secret.txt" > tracked_secret.txt
$ git add tracked_secret.txt && git commit -m "oops: committed a file that should be ignored"

$ echo "tracked_secret.txt" >> .gitignore
$ git add .gitignore && git commit -m "add gitignore rule after the fact"

$ echo "modified content" >> tracked_secret.txt
$ git status --short
 M tracked_secret.txt          # 依然被检测为改动,.gitignore 规则完全没生效

正确的补救方式是先把它从 index 里移除(但保留工作区文件),再让 .gitignore 规则接管:

$ git rm --cached tracked_secret.txt
$ git status --short
D  tracked_secret.txt          # 从版本库移除的改动,下次 commit 后它就变回 untracked

git rm --cached 只动 index(对照 3.1 节的表:这是"暂存区 → 版本库"方向的一次特殊操作,把文件标记为"下次提交时删除",但 --cached 参数保证工作区的文件本体不删)。提交这次 rm --cached 之后,.gitignore 里的规则才会真正对这个路径生效。


7. 速查表:我想做 X,用什么命令

我想做的事命令背后原理(一句话)
看某个文件内容对应的 blob 哈希git hash-object <file>"blob <len>\0<content>" 算 SHA-1
看某个哈希对应的对象类型/内容git cat-file -t/-p <hash>.git/objects/<前2位>/<后38位> 解压读取
看某个 commit 完整目录快照git ls-tree -r <commit>递归展开 commit 指向的 tree
撤销 git add(不删改动)git restore --staged <file>index → 工作区反向操作,不动版本库
丢弃工作区未提交的改动git restore <file>用 index 里的版本覆盖工作区
回到上一次提交前,但保留改动在暂存区git reset --soft HEAD~1只挪分支指针
回到上一次提交前,改动退回工作区(未暂存)git reset HEAD~1(mixed,默认)挪指针 + 清空 index
彻底丢弃改动,回到某个历史状态git reset --hard <commit>挪指针 + 清空 index + 覆盖工作区,有数据丢失风险
撤销一个已经推送、别人可能已拉取的提交git revert <commit>新增反向提交,不改写历史,安全用于公共分支
把某个提交的改动搬到别的分支git cherry-pick <commit>取 diff 在新 parent 上重新生成一个 commit
让 feature 分支追上最新 main(个人分支、未推送)git rebase main逐个提交在新 base 上重放,改写哈希
合并两条已分叉、且有人依赖历史的分支git merge <branch>生成双 parent 合并提交,不改写已有哈希
整理本地还没推送的提交(改 message、合并几个 commit)git rebase -i <base>交互式重放 + 编辑,只能用于没被别人拉取的提交
找回被 reset --hard "丢掉"的提交git refloggit reset --hard <哈希>commit 对象未被 gc 前依然在 .git/objects
只看远程有什么新提交,不改动本地分支git fetch只更新 refs/remotes/origin/*,不碰当前分支和工作区
同步远程并合并到当前分支git pullfetch + merge(或配置了 --rebase 则是 fetch + rebase
.gitignore 规则对已跟踪文件生效git rm --cached <file>先把文件移出 index,规则才会重新检查这个路径
临时切到某个具体 commit 看代码,不新建分支git switch --detach <commit>.git/HEADref: refs/heads/x 变成直接存哈希
中止一次搞砸的合并/变基git merge --abort / git rebase --abort恢复到操作开始前的 HEAD、index、工作区状态
给某个 commit 打正式发布标签,带说明和签名信息git tag -a v1.0 -m "release"真正创建一个 tag 对象,区别于只是引用的轻量标签

一句话收尾

git 命令行表面上有几十个子命令、上百个参数,但只要记住这一句话,绝大多数行为都能推导出来:

一切内容(文件、目录快照、提交记录)都是按内容哈希存起来的不可变对象;分支、标签、HEAD 只是指向某个对象的、可以随意改写的"指针文件";几乎每个 git 命令做的事,无非是"造一个新对象"和/或"挪动某个指针"。

下次遇到不确定的命令,不妨先问自己:它是在动对象(.git/objects 里多了什么),还是在动指针(.git/refs.git/HEAD.git/index 变了什么)?想清楚这一点,git 就不再是背下来的咒语了。