my-git

Git 心智模型 05:可达性、reflog 与恢复

English 交互演示 可运行实验

在执行 reset --hard、rebase、amend、删除 branch 或强制更新远端之前,先判断恢复线索是否存在。Git 的恢复能力来自对象、引用和本地 reflog,不来自“刚刚执行过命令”这一事实。

本章先建立恢复能力,再解释危险重写操作。这样可以把“分支名消失”和“对象已经不可恢复”分开判断。

先看结论

refs/heads/main -> C1
refs/heads/recovered -> C2

HEAD reflog earlier value -> C3

no ref, no reflog, object pruned -> cannot resolve C4

官方文档说明,reflog 是本地仓库中引用 tip 更新的记录,HEAD@{2} 等写法可以引用过去的 HEAD 值。git-reflog

从“有名字”到“无保护”的对象状态

实验使用以下对象关系:

main -> C1 baseline

C2 keep recoverable commit
  main reset away from C2
  no ref reaches it
  HEAD reflog still names C2

C3 discard after expiry
  temporary branch deleted
  no ref reaches it
  HEAD reflog still names C3

运行 git branch recovered C2 后:

refs/heads/recovered -> C2

C2 又有直接引用,GC 会把它视为受保护对象。对 C3 显式过期 reflog,再运行立即 prune 后:

no ref -> no reflog -> object pruned

此时对象 ID 可能已经无法通过 git cat-file 解析。这个过程说明恢复有时间和保留条件,不能当作永久备份策略。

可达性取决于使用的起点

git log main 从 main 出发遍历其祖先。若 main 已从 C2 reset 回 C1,C2 不再位于 main 的历史中。

但这不代表 C2 已从仓库磁盘消失。git fsck 默认把 index、refs 和 reflog 都作为可达性追踪的起点;加 --no-reflogs 可以检查一个只由 reflog 线索保留的旧对象是否会成为不可达对象。git-fsck

git fsck --no-reflogs --unreachable

预期会包含类似输出:

unreachable commit <old-commit-id>

这是一项检查结果,不是保证对象还能恢复多久。对象是否仍在对象库、reflog 是否尚未过期,以及 GC 是否已清理,都会改变结论。

reflog 是本地恢复线索,有期限

先查看本地 HEAD 移动记录:

git reflog show --oneline HEAD
git reflog show --oneline main

可以看到 commit、reset、checkout 等操作留下的记录。HEAD@{1} 表示当前 HEAD 的上一个 reflog 值;main@{1} 表示 main 的上一个值。gitrevisions 规定这些形式查询的是本地 ref 在过去某个时间或序号的位置。gitrevisions

默认保留时间来自配置项:

git config --get gc.reflogExpire
git config --get gc.reflogExpireUnreachable

没有本地覆盖时,Git 当前文档给出的默认值是 gc.reflogExpire=90 days,gc.reflogExpireUnreachable=30 days。前者用于一般条目,后者会更早清理从当前 branch tip 不可达的条目。仓库配置、组织模板、手动 expire 和维护任务都能改变实际保留结果。git-reflog 的 expire 选项

因此,reflog 适合作为短期事故恢复线索,不应替代远端仓库、bundle、备份或正式发布制品。不同 clone 各自拥有 reflog;在 A 仓库看到的 HEAD@{1} 不会自动出现在 B 仓库。

危险操作前先建立恢复点

在本地重写前执行以下检查:

git status --short
git branch safety/before-rewrite HEAD
git show --no-patch --oneline safety/before-rewrite
git reflog show -n 5 HEAD

这段命令有三个作用:

  1. git status --short 先暴露未提交状态。要保留的编辑需要先选择合适的保存方式。
  2. safety/before-rewrite 给当前 commit 一个显式引用,避免只依赖 reflog。
  3. git reflog 记录当前可用的本地恢复线索,便于操作后核对。

若历史已经推送,或其他人可能基于它开发,还需检查远端、协作者和发布影响。共享历史的撤销常常适合新增一个 git revert commit。Git 对数据库写入、部署、消息和其他外部副作用没有自动恢复能力。

git reset 的 commit 形式会改变 HEAD 所指向的 commit,并在操作前把当前 branch tip 放入 ORIG_HEAD;--hard 还会覆盖工作树和 Index。官方文档明确说明它可能覆盖未跟踪文件。git-reset

在临时仓库运行实验

实验脚本新建一个带标记的临时库,设置本地身份,关闭签名和 hooks。setup.sh 在该临时库中生成 C2,执行一次 reset --hard,移除 ORIG_HEAD,再生成并删除承载 C3 的临时 branch。宿主仓库不会被这些动作修改。

1. 建立临时库,先看仍受 reflog 保护的状态

从仓库根目录执行:

lab_path=$(bash labs/git-mental-model/05-recovery/setup.sh)
git -C "$lab_path" show-ref --heads
git -C "$lab_path" reflog show --oneline HEAD
git -C "$lab_path" fsck --no-reflogs --unreachable

预期语义:

main points to the baseline commit
HEAD reflog contains "keep recoverable commit"
HEAD reflog contains "discard after expiry"
fsck without reflogs reports both commits as unreachable

对象 ID 和 reflog 序号会变化。先把带有 keep recoverable commit 的对象 ID 记作 <recoverable-id>,不要凭格式猜测它。

2. 用 reflog 中的对象 ID 新建恢复 branch

git -C "$lab_path" branch recovered <recoverable-id>
git -C "$lab_path" show --no-patch --oneline recovered
git -C "$lab_path" rev-parse refs/heads/recovered

预期:

keep recoverable commit
<recoverable-id>

此时恢复动作只是创建一条 branch 引用,commit 内容没有被改写。接下来可以审查差异、选择 merge、rebase 或让该 branch 保持为恢复点。

3. 验证脚本执行两种结局

步骤 2 已向临时库写入 recovered。验证脚本需要从“没有 ref 能到达两个 commit”的初始状态开始,因此先删除这一个实验库,再创建新的实验库:

bash labs/git-mental-model/05-recovery/cleanup.sh "$lab_path"
lab_path=$(bash labs/git-mental-model/05-recovery/setup.sh)

然后运行:

bash labs/git-mental-model/05-recovery/verify.sh "$lab_path"

预期结构:

ok: reset moved main to <baseline-id> while reflog retained <recoverable-id>
ok: recovered branch now names <recoverable-id>
ok: after explicit reflog expiry and gc --prune=now, <expired-id> no longer resolves

脚本在第一项断言中确认没有 ref 能到达 C2 和 C3,它们仍存在于 HEAD reflog,且 git fsck --no-reflogs --unreachable 报告它们。第二项创建 recovered。第三项故意让 C3 失去 reflog 保护并立即清理。

4. 在实验库中观察过期与清理的风险

验证脚本的最后一个断言使用下面两条命令删除恢复线索并立即清理无保护对象。只可用于由本章 setup.sh 刚创建的 $lab_path,不要复制到活跃仓库、共享仓库或另有 Git 进程写入的仓库。若要手动重现整个过程,请重新运行 setup.sh,先检查 reflog,再运行这些命令:

git -C "$lab_path" reflog expire --expire=now --expire-unreachable=now --all
git -C "$lab_path" gc --prune=now

Git 文档说明 gc --prune=now 会清理所有年龄的无用 loose object,且与并发写入同一仓库会增加损坏风险。普通 git gc 的 prune 默认阈值是两周前,实际值可由 gc.pruneExpire 覆盖。git-gc

5. 清理临时库

bash labs/git-mental-model/05-recovery/cleanup.sh "$lab_path"

清理脚本会检查本实验标记,并拒绝删除仓库根目录、用户主目录和根目录。

安全边界

“没有分支名”需要继续取证

先依次检查:

git show-ref --heads --tags
git reflog show --oneline HEAD
git fsck --no-reflogs --unreachable
git cat-file -t <candidate-object-id>

这些命令分别回答:有没有显式引用、HEAD 是否记录过该对象、去掉 reflog 作为起点后它是否不可达、对象是否仍可读取。只有这些证据都不存在或对象已被清理时,才能说 Git 对象库中没有该恢复线索。

“我没有改文件”不能证明工作树安全

reset --hard 可能覆盖工作树、Index 和未跟踪文件。执行前先检查 git status --short 和实际文件内容。若工作中含有从未经过 git add 或 git commit 记录的编辑,Git 对象库没有相应恢复依据;应暂停危险操作并转向编辑器历史、文件系统快照或其他备份。

reflog 不承担跨机器和长期保存

reflog 属于本地仓库,条目也会过期。重要恢复点应保留明确的 branch 或 tag,并按照团队策略推送、备份或制作可验证的发布制品。

常见误解

“删除 branch 会立刻删除 commit”

删除 branch 先移除一个引用。只要对象仍被其他 refs、reflog 或其他保留机制引用,并且尚未被清理,仍可能找到它。

“reflog 是永久历史”

它是本地、可过期的引用更新记录。默认期限也可以被配置和命令改变。

“只要知道对象 ID,就一定能恢复”

对象 ID 只能在对象仍存在时解析。没有 refs、reflog 或其他保留,GC 可能已经清理对象。

“Git 能恢复所有未提交编辑”

Git 只能基于它实际记录过的对象和引用工作。从未经过 git add 或 git commit 记录的编辑,需要从 Git 之外寻找恢复来源。

“git fsck 输出 unreachable 就等于对象损坏”

unreachable 说明相对于本次检查使用的起点找不到该对象。对象仍可能完整存在于对象库,并能通过新的 branch 恢复。

Agent 场景迁移题

Agent 在本地生成了一个可审查 commit,随后执行 git reset --hard HEAD~1。它说“提交丢了”,没有推送,也没有说明工作树里是否有其他编辑。

先完成以下判断,再决定恢复方式:

  1. 运行 git status --short,确认是否还有未提交编辑需要保护。
  2. 运行 git reflog show --oneline HEAD,找到 reset 前的对象 ID。
  3. 运行 git cat-file -t <id> 和 git show --stat <id>,确认对象类型和内容。
  4. 创建 git branch recovery/agent-reset <id>,让恢复对象重新获得显式引用。
  5. 核对该 branch 是否需要推送、合并、保留为审查点,或改用共享历史适用的撤销策略。

如果 Agent 的关键编辑从未经过 git add 或 git commit 记录,reflog 和对象图不能恢复它。此时应明确缺少 Git 证据,并检查授权范围内的编辑器历史或备份。

延伸阅读