Skip to content

필사 모드: git 撤销 — 按情况挑选 restore、reset、revert、reflog

中文
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

引言 — 撤销之前该问自己的一个问题

把撤销命令背下来,结果每次还是要重新搜。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 switchgit 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 内部结构理解命令一篇讲了引用和索引的真实面貌。

현재 단락 (1/135)

把撤销命令背下来,结果每次还是要重新搜。reset 和 revert 和 restore 和 checkout 长得都差不多,搜索结果顶部又是一串不指明情况的命令罗列。从里面挑一条贴进去,然后飞掉一天...

작성 글자: 0원문 글자: 5,791작성 단락: 0/135