- Published on
git 撤销 — 按情况挑选 restore、reset、revert、reflog
- Authors

- Name
- Youngju Kim
- @fjvbn20031
引言 — 撤销之前该问自己的一个问题
把撤销命令背下来,结果每次还是要重新搜。reset 和 revert 和 restore 和 checkout 长得都差不多,搜索结果顶部又是一串不指明情况的命令罗列。从里面挑一条贴进去,然后飞掉一天的工作,这是最常见的事故路径。
在挑命令之前只要确定两件事,选项就会收敛成一个。第一,想撤销的东西在哪里。是工作树,是暂存区,还是提交。第二,这个提交是不是已经到了别人手里。第一个问题决定命令的种类,第二个问题决定能不能改历史。
三棵树 — 什么能撤销,什么不能
Git 把文件的状态放在三个地方:上一个提交指向的快照,装着下一个提交内容的索引,以及此刻躺在磁盘上的工作树。所有撤销命令的区别,都在于它碰的是这三者中的哪一个。
$ git status --short
M pricing.ts # 索引和工作树都和提交不同
M coupon.ts # 只有工作树和提交不同
?? refund.ts # 哪里都没有
由此引出贯穿全文的一条规则。曾经被提交过的东西几乎总能恢复,从未被提交过的东西几乎恢复不了。后面要看的引用日志记录的是"谁曾经指向过哪个提交",它不是工作树的备份。所以风险不该按命令的名字来评,而该按"这条命令会不会覆盖未提交的改动"来评。
还没提交的时候 — restore 和 stash 的坑
被搜得最多的命令就在这里,最陈旧的建议残留的地方也在这里。
# 只解除暂存(文件内容原样保留)
$ git restore --staged pricing.ts
# 丢弃工作树里的修改(无法撤销)
$ git restore pricing.ts
# 只把某个提交时点的文件取出来
$ git restore --source=HEAD~2 --staged --worktree pricing.ts
搜索结果顶部至今仍能看到大量下面这种写法。
$ git checkout -- pricing.ts
$ git reset HEAD pricing.ts
行为是一样的,但不推荐。git checkout 用一个名字同时干"切换分支"和"恢复文件"这两件完全不同的事,而且当存在与分支同名的文件时,它的行为会背离你的意图。Git 2.23 把这两者拆成 git switch 和 git restore,理由正是如此。没有理由再遵循只有一个命令那个年代的建议。
未被跟踪的文件不是 restore 的对象。要删它得用另一条命令,而且这一边引用日志和 fsck 都帮不上忙。请务必先确认。
$ git clean -nd
Would remove refund.ts
Would remove tmp/
$ git clean -fd
Removing refund.ts
Removing tmp/
想把手头改动先挪开时用的 stash,有三个坑。
$ git status --short
M pricing.ts
?? coupon.ts
$ git stash push -m "쿠폰 작업 중"
Saved working directory and index state On main: 쿠폰 작업 중
$ git status --short
?? coupon.ts
第一,未被跟踪的文件会原样留下。想把新建的文件也挪走需要另加选项,想把被忽略列表命中的文件也包含进来还需要再加一个选项。
$ git stash push -u -m "쿠폰 작업 중" # 包含未被跟踪的文件
$ git stash push -a -m "빌드 산출물까지" # 连被忽略的文件也包含
第二,取出来时如果冲突,stash 不会消失,会继续留在列表里。因为它只在成功时才删除,所以解决冲突之后不删掉,同一份改动就会堆两遍。
$ git stash list
stash@{0}: On main: 쿠폰 작업 중
$ git stash drop
第三,这是实务中最常踩的坑,stash 不绑定在分支上。在另一个分支上取出来,它就原样应用上去。功能很方便,但取错地方,别人的改动就叠到了不相干的分支上。
已经提交但还没推送 — amend 和 reset
这里按"只改信息"还是"把提交本身干掉"分岔。
# 只修改提交信息
$ git commit --amend -m "쿠폰 정책 반영 — 만료 검사 추가"
# 把刚才漏掉的文件补进上一个提交(信息不变)
$ git add src/order/coupon.ts
$ git commit --amend --no-edit
--amend 同样会创建新的提交对象。哈希会变,所以对已经推送出去的提交,适用的规则和变基完全一样。
要把提交本身收回时,就轮到 reset 了。三个选项的区别在于把三棵树中的哪些一起挪。
$ git log --oneline
08daaa8 쿠폰 정책 반영
dd596de 할인율 적용
fc945f2 가격 계산기 초안
$ git reset --soft HEAD~2
$ git status --short
M pricing.ts
$ git reset --mixed
Unstaged changes after reset:
M pricing.ts
$ git status --short
M pricing.ts
--soft 抹掉了两个提交,同时把它们的内容以暂存状态留了下来;--mixed 连暂存也一起解除了。文件内容在哪一边都没有消失。
| 命令 | 分支引用 | 索引 | 工作树 | 可能丢失的东西 | 使用场景 |
|---|---|---|---|---|---|
git reset --soft | 移动 | 保持不变 | 保持不变 | 没有 | 把多个提交重新捆成一个时 |
git reset (默认的 mixed) | 移动 | 对齐到目标提交 | 保持不变 | 只有暂存状态 | 解开提交和暂存但保留文件时 |
git reset --hard | 移动 | 对齐到目标提交 | 对齐到目标提交 | 所有未提交的改动 | 只在确定要丢弃时 |
git reset --keep | 移动 | 对齐到目标提交 | 有重叠就中止 | 没有 | 想保住本地改动同时回退时 |
git reset --merge | 移动 | 对齐到目标提交 | 清理合并状态 | 已暂存的改动 | 收回一次失败的合并时 |
表里实务上最重要的是最后两行。想回退又不想丢掉手头的修改时,人们习惯性地敲 --hard,可正好贴合这种情况的选项另有其人。--keep 在回退范围和本地修改重叠时,会什么都不做直接停住。在覆盖之前被拒绝,好过覆盖之后后悔。
已经推送出去了 — 为什么 revert 是唯一安全的答案
从这里开始规则变了。如果提交已经在别人的仓库里,任何抹掉它的做法都会破坏别人的历史。答案不是改历史,而是往历史上追加。
$ git revert 08daaa8
[main 5b21c4e] Revert "쿠폰 정책 반영"
1 file changed, 1 deletion(-)
revert 会把改动反向应用,再叠一个新提交。没有任何提交消失,别人照常拉取就行。如果想一次撤销好几个、但只生成一个提交,可以这么做。
$ git revert --no-commit dd596de^..08daaa8
$ git commit -m "쿠폰 정책 롤백"
合并提交有两个父提交,所以要告诉它以哪一边为基准来撤销。通常是目标分支,也就是第一个父提交。
$ git revert -m 1 40600e3
[main 3c06ce3] Revert "Merge branch 'feature/checkout'"
1 file changed, 2 deletions(-)
delete mode 100644 retry.txt
这里有个不太为人所知的坑。把合并 revert 掉之后,再把那个分支改好重新合并一次,被撤销的改动不会回来。因为在 Git 看来那个分支已经是合并过的状态,而合并只按历史上的可达性来判断。实际文件仍然停在 revert 提交把它删掉之后的样子。正确做法是在重新合并之前,先把那个 revert 提交再 revert 一次。
$ git revert 3c06ce3 # 把 revert 再 revert 一次
$ git merge feature/checkout
这类事故常见到 Git 官方文档专门有一节讲它。撤销合并时,先判断"以后还要不要把这个分支再放进来",如果还要放进来,那么与其撤销合并,不如只 revert 分支上出问题的那个提交更干净。
反过来,绝对不该听的建议也很明确:为了从共享分支上抹掉提交而先回退再强制推上去。
# 在共享分支上不要这么做
$ git reset --hard HEAD~1
$ git push --force origin main
已经拉走的人本地原封不动,他们下次一推,被删掉的提交就复活了。如果你想删的是密钥,那就更严重。强制推送并不会让对象立刻从服务器上消失,而在这期间 CI 日志和 fork 里早就复制了一份。那种情况下的正确顺序,另外整理在.gitignore 不生效的时候一篇的最后一节。
reflog — 删掉的东西大多还在那里
删了分支、或者用 --hard 把提交飞掉时,这是最先该看的地方。Git 会在本地持续记录 HEAD 和每个分支曾经指向过哪个提交。
$ git reflog
fc945f2 HEAD@{0}: reset: moving to HEAD~2
08daaa8 HEAD@{1}: commit: 쿠폰 정책 반영
dd596de HEAD@{2}: commit: 할인율 적용
fc945f2 HEAD@{3}: commit (initial): 가격 계산기 초안
$ git reset --hard HEAD@{1}
HEAD is now at 08daaa8 쿠폰 정책 반영
删了分支的情况下,有一点必须知道。删除分支会连同这个分支的引用日志一起删除。所以按分支名去查什么都查不到。但 HEAD 的引用日志里,那个分支上做的提交仍然原样留着。
$ git branch -D tmp/lost
Deleted branch tmp/lost (was 990a949).
$ git reflog
08daaa8 HEAD@{0}: checkout: moving from tmp/lost to main
990a949 HEAD@{1}: commit: 환불 규칙 추가
08daaa8 HEAD@{2}: checkout: moving from main to tmp/lost
$ git branch tmp/lost 990a949
删除时打印的那句话本身就带着提交哈希,这一点也值得记住。往上滚一点终端,恢复往往就结束了。
引用日志的边界也要清楚。它是本地文件,克隆时不会跟过来,也不是永久的。默认值是可达条目 90 天,不可达条目 30 天。
$ git config gc.reflogExpire
90 days
$ git config gc.reflogExpireUnreachable
30 days
而下面这两条命令会立刻拆掉这张安全网。它们常以"能减小仓库体积"的名义出现在搜索结果里,但用之前要知道:执行的那一刻,可恢复性就没了。
$ git reflog expire --expire=now --all
$ git gc --prune=now
连引用日志里也没有的时候 — 用 fsck 打捞孤儿对象
有时变基搞乱了,有时 stash 被误删了,有时提交是以不会留下引用日志记录的方式掉出去的。这时就直接扫对象数据库。
$ git fsck --full --unreachable --no-reflogs
Checking object directories: 100% (256/256), done.
unreachable blob ed7ce12274bc71c66cb596feaaf9f02ce91b820e
unreachable tree 970df65883de43d243ce919be12841c5b9f01857
unreachable commit 990a9490f7069940e9d425bde9d1f5bc44048b1d
$ git show --stat 990a949
commit 990a9490f7069940e9d425bde9d1f5bc44048b1d
Author: Demo <d@e.com>
환불 규칙 추가
pricing.ts | 1 +
找到之后给它起个名字救回来。
$ git branch recovered/refund 990a949
这里还有一个常被忽略的事实。输出里之所以连 blob 一起出现,是因为只做了暂存、还没提交的内容也已经被存成了对象。只要执行过一次 git add,那一刻的文件内容就已经进到仓库里了。哪怕已经被 --hard 飞掉,也能像下面这样取出来。
$ git cat-file -p ed7ce12 > pricing.recovered.ts
如果想以文件的形式一次性拿到,有专门的选项。它会把提交和其他对象分别展开到各自的目录里。
$ git fsck --lost-found
$ ls .git/lost-found/commit .git/lost-found/other
这个办法同样有时限。整理作业默认会删除超过两周的不可达对象,所以太久以前的事故在这里也可能捞不回来。
$ git config gc.pruneExpire
2.weeks.ago
结语 — 要记的不是命令,是边界线
撤销命令不必全部背下来。出事的时候要问自己的只有两件事:这个改动曾经被提交过吗,以及这个提交到别人手里了吗。
如果被提交过,引用日志和 fsck 几乎总能把它捞回来。如果从未被提交过,任何命令都帮不了你。而如果已经到了别人手里,与其改历史,不如用 revert 往上追加,这是唯一安全的路。撤销的功力不在于知道命令,而在于打危险命令之前先建一个提交的习惯。
如果好奇这些命令为什么这样划分,用 Git 内部结构理解命令一篇讲了引用和索引的真实面貌。