你让 Claude Code 重构一个 helper,它顺手把整个文件重写、你 commit 了,然后才发现旧版本里那段处理特殊情况、线上还在用的 5 行代码被一并删了。或者你对错的 commit 跑了 git reset --hard。又或者远程被 rebase,本地 git pull 之后旧 commit 看起来”不见了”。几乎所有情况下代码都没真丢:只是 ref 指针不再指向它们,但 git object 还留在 .git/objects 里。真正的问题是时间。只要某个 object 还能从 reflog 追到,它大约能安全保留 90 天;但一个谁都没引用的彻底悬挂(dangling)object,最快约 2 周 就会被 git gc 清理(gc.pruneExpire 默认值)。所以先抢救,再慢慢理解。
最快修复(你能说出是哪个文件):
git log -p --follow -- path/to/file.ts # 找到好版本的 SHA
git restore --source=<sha> -- path/to/file.ts
最快修复(整段 commit / HEAD 不见了):
git reflog # 找到坏的 reset/rebase 之前的 hash
git reset --hard <hash> # 或:git branch recovered <hash>(不破坏现状)
两条都找不到,直接跳到 路径 C:git fsck。下文逐条讲每个路径,并给出确认找回成功的方法。
我属于哪条路径?
| 症状 | 最可能的原因 | 去 |
|---|---|---|
| 单个文件丢了几行,仓库其余正常 | 某次 commit(常是 AI 重写)覆盖了文件局部 | 路径 A |
git log 里最近的 commit 直接没了 | git reset --hard / git rebase 移走了 HEAD | 路径 B |
git pull 之后 git log 里那个 commit 已不是你的 | 分支被别人 rebase 并 force-push | 路径 B |
| 删掉的分支上的工作没了 | git branch -D 删了未合并的分支 | 路径 C |
| stash 被 drop 或 clear 了 | git stash drop / git stash clear | 路径 C |
| ”两周前还能跑,现在不行”,但不知道是哪个 commit | 某处混进了回归 | 路径 D:bisect |
常见原因
按实际发生频率从高到低排序。
1. AI 或手动 commit 覆盖了文件局部
你 git add . && git commit -am "..." 时,AI agent 的重写盖掉了你要留的几行。这是和 AI 编程时最常见的丢失,尤其是 prompt 没写”只改某个函数”的时候。
如何判断:git log --oneline -5 -- path/to/file.ts 看该文件最近的 commit;git diff HEAD~1 -- path/to/file.ts 看上一个 commit 到底改了什么。
2. git reset --hard 把 HEAD 移过头了
本想撤销最后一个 commit,结果输成了 git reset --hard HEAD~3,三个 commit 一起从 git log 里消失。
如何判断:git reflog 第一行是 HEAD@{0}: reset: moving to ...。
3. 分支被 rebase 或 force-push
队友(或你自己)git rebase main 然后 git push --force,旧 commit 的 hash 全变了,你本地 git pull 之后老 commit 就成了孤儿。
如何判断:git fetch 之后,git log origin/<branch> --oneline 里的 hash 和你 git reflog 里的对不上。
4. 误删分支
git branch -D feature-x 删掉了还没合并的分支,分支 tip 的 commit 立刻悬挂。
如何判断:git reflog show feature-x 已经报错,但 git reflog | grep -i feature-x 还能找到最后一次 checkout 的 hash。
5. stash 被 drop 或 clear
git stash pop 遇到冲突,没解决就慌着 git stash drop;或者堆了好几个 stash 后 git stash clear。
如何判断:git fsck --unreachable | grep commit 列出悬挂 commit;stash 通常是 merge commit,时间戳接近你 stash 的那一刻就是它。
找回路径
Step 0:先把当前状态备份
无论接下来走哪条路,第一步都是先让”现在”也能找回:
git stash push -u -m "before-recovery-$(date +%s)"
# 或更稳妥,因为它什么都不动:
git branch backup/before-recovery-$(date +%s)
万一找回操作翻车,你能 git stash pop 或 git checkout backup/... 回到此刻。
Step 1:路径 A — 知道是哪个文件丢了内容
最常见的情况。用 log -p 通读文件历史,找到好版本:
git log -p --follow -- path/to/file.ts
--follow 让 git 跨改名继续追踪。翻到你要的版本,记下它的 SHA,然后还原:
# 现代写法,Git 2.23+ 推荐(2026 年的环境都已远超此版本):
git restore --source=<sha> -- path/to/file.ts
# 等效的旧写法(同时也会 stage 这个文件):
git checkout <sha> -- path/to/file.ts
# 或者只看不动工作区:
git show <sha>:path/to/file.ts > /tmp/oldversion.ts
diff /tmp/oldversion.ts path/to/file.ts
从 commit 里取文件内容,Git 现在推荐的就是 git restore;git checkout <sha> -- file 仍然可用,并且会额外把结果 stage。如果只需要旧版本里的某几行,从 git show 的输出里复制粘贴,比整文件还原更干净。
Step 2:路径 B — 整段 HEAD 被 reset 或 rebase 移走
git reflog 是 git 在本地记录的 HEAD 每一次移动的日志:
git reflog
# 输出例:
# a3f7c1d HEAD@{0}: reset: moving to HEAD~3
# 9b2e8f4 HEAD@{1}: commit: fix auth bug
# 7c4a1d2 HEAD@{2}: commit: add login form
# ...
找到坏操作之前的 hash(这里是 9b2e8f4),先看一眼,再找回:
git show 9b2e8f4 # 确认就是那个状态
git reset --hard 9b2e8f4 # 把 HEAD 整个移回去
# 更稳的做法:基于它建一个分支,不动当前 HEAD
git branch recovered-state 9b2e8f4
不确定时优先用 git branch recovered-state <hash>:它不破坏现状,你可以先 git diff HEAD recovered-state 对比,再决定要不要 reset --hard。
Step 3:路径 C — reflog 也找不到(删分支、丢 stash)
如果连 reflog 都没记录(在另一个 clone 里删的分支,或条目已超期),就用 fsck 扫悬挂对象:
git fsck --lost-found
# 输出例:
# dangling commit a1b2c3d...
# dangling commit e4f5a6b...
git show a1b2c3d # 逐个查看候选
git show e4f5a6b
git branch recovered a1b2c3d # 找到后立刻给它一个 ref
按时间排序能一眼挑出对的那个:
git fsck --unreachable --no-reflogs | grep commit | \
awk '{print $3}' | \
xargs -I{} git log -1 --format='%ci %H %s' {} | sort -r
找回被 drop 的 stash 是同一个思路,只是 stash 是 merge commit,所以专门过滤 merge:
git fsck --unreachable | grep commit | cut -d ' ' -f3 | \
xargs git log --merges --no-walk --oneline
# 然后重新应用你要的那个:
git stash apply <commit-hash>
如果你还在同一个终端里,git stash drop 当时打印过被丢掉的 hash,比如 Dropped refs/stash@{0} (2ca03e2...),直接把那个 hash 喂给 git stash apply 即可。
Step 4:路径 D — 用 git bisect 定位最后能跑的版本
当你只知道”两周前能跑、现在不行”,但不知道是哪个 commit 弄坏的:
git bisect start
git bisect bad HEAD # 当前是坏的
git bisect good v1.4.0 # 这个 tag(或 hash)已知是好的
# git 自动 checkout 中间的 commit,你测试后回答:
git bisect good # 或:git bisect bad
# 重复直到 git 报出第一个坏 commit,然后:
git bisect reset
bisect 定位到坏 commit 后,用路径 A 把文件回退到出问题之前的版本。想自动化,就给 bisect 一个测试脚本:git bisect run npm test(或任何失败时返回非零的命令),然后你就能走开了。
Step 5:在 gc 清掉之前,给找回的内容一个永久 ref
找回的悬挂 commit 仍然没有任何引用。git gc 会清理超过 gc.pruneExpire 窗口(默认约 2 周)的 loose object,而且日常命令期间可能触发自动 gc。一旦找回,立刻给它一个 ref 并 push:
git branch recovery/found-it <sha>
git push origin recovery/found-it
一个分支(或 tag)就让这个 object 变成可达,从此彻底脱离 gc 的清理范围。
怎么确认已经修好
- 文件还原(路径 A):
git diff(或git diff --staged)现在应能看到丢失的那几行重新出现在工作区。跑一下相关测试或打开文件,确认那一段确实回来了。 - HEAD 还原(路径 B):
git log --oneline -5应能列出原本消失的 commit,git status干净(或符合你的预期)。 - 悬挂找回(路径 C/D):
git branch --contains <sha>现在应打印出你新建的分支,说明这个 commit 已被引用、不会被 gc 清掉。 - 已 push 的找回:
git ls-remote origin recovery/found-it应返回一个 hash,证明远程已经有它。
常见问题
丢失的 commit 还有多久会真的没掉?
只要还在 reflog 里,约 90 天(gc.reflogExpire)。如果是完全悬挂、没有任何 reflog 条目,最快约 2 周(gc.pruneExpire),而且日常命令期间可能跑自动 gc。把任何丢失都当成紧急情况,第一时间给它建分支。
git reflog 里没有有用的东西,怎么办?
reflog 是每个 clone 各自的、纯本地。如果那段工作发生在另一个 clone 或 CI 的 checkout 里,你本地的 reflog 从来没记过它。直接去那段工作存在过的那个 clone,走路径 C(git fsck --lost-found)。
force-push 之后 git pull 会毁掉我本地的 commit 吗?
不会立刻毁。force-push 的 rebase 改的是 origin/<branch>,你本地的旧 commit 仍留在 reflog 里。先 git reflog 找到 pull 之前的 hash,给它建分支,再做别的。
没 stage 过、也没 commit 的改动能找回吗?
只有当它被 stash 过、或短暂 commit 过才行。纯工作区的修改,如果被 git checkout/git restore 或 git reset --hard 覆盖、又从没进过 git object,就无法从 git 里找回。这种情况下编辑器的 Local History(见下文)是唯一的机会。
取旧文件用 git restore 还是 git checkout?
两者都能从某个 commit 里取出文件内容。git restore --source=<sha> -- file 是现在推荐的写法(Git 2.23+);git checkout <sha> -- file 效果相同,并且会顺便 stage。哪个顺手用哪个。
预防建议
- AI 重构前先
git commit -am "checkpoint before AI refactor",让 agent 始终有一个”已知好”的回退点。 - 启用编辑器的 auto-save 和 Local History(VS Code 与 Cursor 都内置 Local Timeline / Local History)。它独立于 git,能兜住那些从没变成 git object 的改动。
- 每天结束前
git push到远程或个人备份分支。reflog 只在本地,清盘就没。 - 关键改动改完先 push 到
wip/分支再继续,这样后面的 squash 永远不会抹掉你可能要找回的中间 commit。 - 团队规范里禁止向共享分支(
main/develop)force push,改用--force-with-lease——它会拒绝覆盖自己没见过的工作。 - 想把保留期拉得比默认更长,设
git config gc.reflogExpire 180.days(或never)和git config gc.pruneExpire 30.days。默认是 reflog 90 天、prune 约 2 周,调大它们就能换来更大的找回窗口。