Git:三棵树、引用系统与本地时间机器
git restore、git reset、git revert——三个命令都和”撤销”有关,但场景不同、效果不同,初学者靠搜索引擎应付,每次遇到都重新查。这三个命令不是功能重复,是针对三种不同状态的操作。它们为什么存在,要从 Git 内部同时维护的三份”状态”说起。
三棵树模型
Git 在任何时刻都同时维护三份数据,称为”三棵树”:
HEAD 是上一次 commit 的快照,也是下一次 commit 的参照点。它不是一个文件,是一个指针——指向当前分支,当前分支再指向某个 commit,那个 commit 对应的 tree 就是 HEAD 的内容。
Index(暂存区) 是下一次 commit 的草稿。运行 git add 时,修改从工作区写入 Index;运行 git commit 时,Git 把 Index 的内容打包成新的 tree 和 commit 对象。Index 不是文件列表,是一份完整的项目快照——只是还没有提交。
Working Directory 是磁盘上的实际文件,是你直接编辑的东西。它是三棵树里唯一肉眼可见的那棵。
git status 做的事情,就是比较这三棵树之间的差异:
Working Dir vs Index → "Changes not staged for commit"
Index vs HEAD → "Changes to be committed"git add 把 Working Dir 的改动同步到 Index;git commit 把 Index 打包成新的 commit,HEAD 随之前进。这两步操作,本质上就是在三棵树之间搬数据。
reset:移动指针,选择性同步
git reset 是三棵树模型里最能说明问题的命令。理解它之前,需要先明白 reset 做的第一件事是什么:移动 HEAD 指向的 commit。不是撤销文件,不是清空暂存区——是把当前分支的指针挪到另一个 commit 上。
在这个基础上,三个选项的区别只是”移动指针之后,还要不要把 Index 和 Working Dir 也同步过去”:
--soft:只移动指针。Index 和 Working Dir 都不动,原来暂存的内容还在,原来的文件改动也还在。效果是:commit 消失了,但改动全部回到了”已暂存”的状态。
--mixed(不加选项时的默认值):移动指针,同时把 Index 重置为目标 commit 的状态。Working Dir 不动。原来的文件改动还在磁盘上,但不再是暂存状态,需要重新 git add。
--hard:三棵树全部同步到目标 commit。Index 重置,Working Dir 也重置——磁盘上未提交的改动会丢失。这是 reset 三种模式里唯一真正”危险”的一种,因为工作区的改动不在 objects/ 里,没有哈希,丢了就是丢了。
git restore 可以看作 reset 的局部版本:它只针对某个文件操作,不移动分支指针。git restore <file> 用 Index 的版本覆盖 Working Dir;git restore --staged <file> 用 HEAD 的版本覆盖 Index。两个操作都是在三棵树之间同步数据,只是粒度是单个文件,而不是整个仓库状态。
git revert 和 reset 完全不同:它不移动指针,不改写历史,而是创建一个新的 commit,内容是把目标 commit 的改动反过来应用。历史不变,只是多了一条记录。这是在共享分支上”撤销”的正确方式——因为 reset 改写历史,会导致其他人的本地仓库与远程产生分歧。
引用系统:一切皆指针
从 02-内容寻址数据库与四种对象 里知道,commit 对象一旦写入就不可变。Git 怎么表示”当前在哪里”、“main 分支是哪个 commit”?答案是用引用——存在 .git/refs/ 下的一批文本文件,每个文件的内容就是一个 40 位 SHA 哈希。
HEAD 是一个特殊引用,存在 .git/HEAD。它通常不直接存 commit SHA,而是存一个分支名:
ref: refs/heads/main这叫 symbolic ref。Git 读 HEAD 时,先读到这行,再去 .git/refs/heads/main 里取那个分支当前指向的 commit SHA。两层间接引用,目的是让 HEAD 跟着当前分支走。
分支 就是 refs/heads/ 下的文件。git branch feature 做的事情只是创建一个新文件 .git/refs/heads/feature,写入当前 commit 的 SHA。提交新 commit 时,当前分支文件的内容被更新为新 commit 的 SHA。分支没有历史,没有结构,就是一个会随着提交自动前进的指针。
Detached HEAD 是 HEAD 直接存了一个 commit SHA 而不是分支名的状态:
d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3检出一个历史 commit、检出一个 tag,都会进入这个状态。在 Detached HEAD 下做的提交不会被任何分支追踪——HEAD 确实前进了,但没有分支文件在记录新的 commit SHA。一旦切回其他分支,这些 commit 就变成”无引用对象”,最终被 git gc 清理。如果想保留,在离开之前创建一个分支:git switch -c new-branch。
远程追踪引用 存在 refs/remotes/ 下,例如 refs/remotes/origin/main。它是本地对远程分支状态的一份镜像,git fetch 时更新,但不会随本地提交自动前进。git push 和 git pull 都在操作本地分支与这份镜像之间的关系。
标签 有两种。轻量标签(git tag v1.0)和分支一样,只是 refs/tags/ 下的一个文件,指向某个 commit,没有额外元数据。附注标签(git tag -a v1.0 -m "...")会创建一个真正的 tag 对象,存入 objects/,记录打标签的人、时间、消息,以及可选的 GPG 签名,再由 refs/tags/ 里的文件指向这个 tag 对象。发布版本应该用附注标签,因为它有独立的身份和可验证的签名。
Reflog:本地时间机器
git reset --hard 执行完,commit 消失了——但它真的消失了吗?
从对象模型来看,reset --hard 只是移动了分支指针。旧的 commit 对象还在 .git/objects/ 里,只是没有引用指向它了。对象不会立刻被删除,git gc 在清理”无引用对象”时有一个默认的宽限期(约两周)。
找回它的方法是 reflog(reference log)。Git 把每一次 HEAD 移动都追加记录到 .git/logs/HEAD,格式是”旧 SHA → 新 SHA + 操作描述”。git reflog 命令把这个日志以可读的方式展示出来:
d4e5f6a HEAD@{0}: reset: moving to HEAD~2
9c8b7a6 HEAD@{1}: commit: feat: add user auth
3f2e1d0 HEAD@{2}: commit: fix: typo in READMEHEAD@{1} 是 reset 之前 HEAD 所在的 commit。它的 SHA 还在,对象还在 objects/ 里,只需要:
git reset --hard HEAD@{1}
# 或者
git switch -c recovered 9c8b7a6分支指针重新指向那个 commit,它就”回来了”。
Reflog 是纯本地的。它记录的是这台机器上 HEAD 的移动历史,不随 git push 同步到远程,git clone 时也不包含远程仓库的 reflog。这意味着两件事:一,别人不能通过你的 reflog 看到你的操作历史;二,reflog 不是备份——远程仓库不保留它,机器丢了 reflog 也丢了。
Reflog 条目的默认过期时间是 90 天(可达状态的条目)和 30 天(不可达对象的条目)。超过期限后 git gc 会清理。在过期之前,reset --hard、误删分支、rebase 中断——所有”丢失”的 commit,都能从 reflog 里找回来。
走进引用系统
引用系统的实体可以直接读取:
cat .git/HEAD # 看 HEAD 现在指向什么
cat .git/refs/heads/main # 看 main 分支当前的 commit SHA
ls .git/refs/heads/ # 列出所有本地分支
ls .git/refs/remotes/origin/ # 列出所有远程追踪引用当分支数量多了,Git 会把引用从独立文件合并进 .git/packed-refs,一行一个,格式是 <sha> <refname>。引用的逻辑不变,只是物理存储从目录树变成了单个文件。
reflog 的常用操作:
git reflog # 查看 HEAD 的移动历史
git reflog show main # 查看某个分支的移动历史
git reflog --date=iso # 带时间戳的格式,方便定位
git fsck --lost-found # 找出所有无引用对象(dangling commits)git fsck --lost-found 是 reflog 之外的另一种恢复手段:它扫描整个 objects/ 目录,把所有没有被任何引用覆盖的对象列出来,写入 .git/lost-found/。即使 reflog 已经过期,这个命令也可能找回对象——只要 git gc 还没有清理它们。
关联
- 02-内容寻址数据库与四种对象 — 三棵树模型建立在对象不可变的基础上;引用系统是对象图之上的指针层
- 05-Merge、Rebase 与分支策略 — merge 和 rebase 都是对引用和对象图的操作,三棵树模型解释了 rebase 冲突时的状态
- 01-从零上手,先用起来 — restore / reset 的用法在工具层已覆盖;这篇解释了它们为什么是这样工作的