ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Git误操作急救指南:用reflog找回reset/rebase丢失的代码

Git误操作急救指南:用reflog找回reset/rebase丢失的代码

Git 误操作这事,几乎每个人都经历过:手一抖reset了分支,rebase到一半发现冲突一大堆,或者刚commit完就发现漏了个文件。最要命的是那一瞬间的心慌——代码好像没了,连怎么恢复都不知道。其实 Git 的底层对象模型给了我们极强的保险机制:大多数误操作并不是真正删除数据,只是把引用指针移开了,数据仍然躺在对象库里。也就是说,“被删掉”的提交、分支、甚至 stash,大概率能在很短的时间内救回来。这篇内容就把我这些年实际用到的急救命令和思路完整整理出来,适合刚接触 Git 的初学者,也适合那些对reflog还不太熟的团队骨干。从最常见的提交改错,到分支被删、rebase 翻车、stash 丢失,我把每个场景的完整抢救流程都写清楚,你可以直接照做。

1. 急救前必须建立的三个保命认知

1.1 理解 reflog:Git 的自救日记

很多人不知道git reflog到底有什么用,它可以说是 Git 误操作救援里最重要的工具,没有之一。简单来说,reflog记录的是 HEAD 指针和各个分支引用在过去一段时间内的每一次移动历史。你在本地执行过的commit、reset、checkout、merge、rebase、cherry-pick等操作,只要让分支引用或 HEAD 发生了变化,都会被记录在案。

这个设计其实很像一个“操作日志”或者“后悔药清单”。默认情况下,reflog 记录会保留 90 天左右,也就是说,只要你误操作发生在 90 天内,基本都能通过 reflog 找到操作之前的那个 commit 哈希值,然后用reset或checkout把分支指回去。我抢救过不少同事的代码,几乎无一例外都是先用git reflog定位“出事前的位置”,再恢复。

核心原理在于:Git 的提交对象一经创建就不可变,分支指针和 HEAD 指针只是“贴在提交对象上的标签”。你执行git reset --hard把分支退到旧提交,旧提交并不会从对象库消失,只是“没有任何引用指向它”而已。而 reflog 恰好记录了“它曾经被引用过”这个事实,所以就能顺藤摸瓜找到它。

$ git reflog a1b2c3d HEAD@{0}: reset: moving to a1b2c3d e4f5g6h HEAD@{1}: commit: 修复登录逻辑 i6j7k8l HEAD@{2}: checkout: moving from feature/login to master

HEAD@{1}这种写法就是“当前 HEAD 之前第 1 次移动时的位置”。如果误操作后你发现自己把分支退回了旧版本,第一步永远是git reflog,而不是慌着重新写代码。

$ git reflog | head -20

如果平时你连 reflog 都很少看,那我强烈建议从现在开始,每次误操作后的第一反应就是打开它。它是你能在 30 秒内恢复代码的最大底气。

1.2 三个区块的底层状态:工作区、暂存区、提交历史

想理解各种急救命令为什么要这么用,必须先搞清楚 Git 的三个区域:工作区、暂存区(index)和提交历史(HEAD)。工作区就是你本地能看到的文件内容;暂存区是git add之后、git commit之前的中间缓冲区;提交历史则是已经用commit固化下来的版本节点。

常见的三类重置命令git reset有不同的模式,对应影响不同区域:

命令影响工作区影响暂存区提交历史典型用途
git reset --soft否否是(仅移动指针)撤销 commit 但保留所有改动
git reset --mixed(默认)否是是撤销 commit 和暂存状态,但保留工作区改动
git reset --hard是是是完全回到目标提交的状态,工作区也会被覆盖

这个表格非常关键。很多人误以为reset --hard会把代码“删光”,其实它只是把工作区、暂存区和分支指针全部强制移动到某个历史提交。如果那个历史提交里没有包含后来新写的代码,看起来就像“代码蒸发了”。但实际上,那个“蒸发”的提交还存在于对象库里,reflog 里有它的记录。所以只要 not panic,第一步永远是查看 reflog,而不是急着git pull或者重新创建文件。

理解了这三个区域,很多命令就不需要死记。比如git restore --staged是把暂存区恢复到与 HEAD 一致,但不动工作区;git checkout -- file是把工作区文件恢复到暂存区或 HEAD 的版本;git reset --soft HEAD~1只是撤销最后一次 commit,改动全部留在暂存区里。每一个命令的差异,本质都是“你希望让哪个区域回到哪个状态”。

1.3 急救第一步永远是备份现场,而不是赶着执行命令

我见过很多人在误操作后情绪激动,直接敲一个reset --hard试图“滚回去”,结果反而把原本还有可能恢复的东西彻底弄丢。这里我有一个铁律:任何急救操作之前,先花 10 秒备份现场。

备份现场的具体做法很简单:

  • 先不要关闭当前终端,把自己的当前分支名、当前 reflog 的最后几条记录复制到一个临时文件里。
  • 如果是编辑过的文件,立刻cp一份到/tmp或者项目外目录。
  • 如果正在 rebase 或 merge 途中,先git status看处于什么状态,别急着abort。

例如,你刚执行了git reset --hard想撤销,又意识到这不是自己想要的提交。这时如果马上再执行一次reset --hard 别的提交,虽然大概率也能找回,但中间那几步的 reflog 链条会显得更乱。如果你在误操作后第一时间记录下 reflog 的最初位置,后面恢复就只是“把指针指回去”的事了。

备份不是浪费时间,而是为了降低“二次误操作”带来的风险。执行急救命令前,先问自己一句:我现在知道我要回到哪个提交吗?如果答不上来,就先去查 reflog。

2. 高频场景三连:提交写错、文件没了、分支被 reset

2.1 提交后想改:用 amend 把上一次提交“重做”

场景描述:你刚执行git commit -m "fix: 修正登录逻辑",结果发现漏了一个文件,或者提交信息打错了一个字。这种误操作非常常见,而且完全不必慌张。

如果这个提交还没有推送到远程,也没被别人拉取过,最优雅的处理方式是git commit --amend。它的作用是用一个新的提交来替换当前分支的 HEAD 提交,新提交会继承旧提交的所有改动,同时可以补充新的暂存内容或修改提交信息。

具体操作分两种:

# 场景一:只修改提交信息 $ git commit --amend -m "fix: 修正登录逻辑与验证码校验" # 场景二:漏了文件,先补充到暂存区再合并进上次提交 $ git add forgot-file.js $ git commit --amend --no-edit

--no-edit表示保留原有提交信息,不额外打开编辑器。--amend的本质并不是“修改”旧提交,而是“创建一个新提交替换旧提交”,reflog 里还是会保留旧提交的记录。因此即使你对 amend 后的结果不满意,依然能从 reflog 找回 amend 之前的那个提交。

有一点必须强调:amend只适合“尚未推送到远程”的提交。如果这个提交已经push到了团队共享分支,再用 amend 就会造成本地历史与远程历史分叉,其他人 pull 时会遇到麻烦。团队协作中的处理原则很简单——已推送的提交请走revert(见后面章节),未推送的提交才能用 amend 或 reset。

2.2 文件被 rm 或改乱了:用 restore 和 checkout 快速还原

误删文件或者把文件改得一团糟,也是高频事故。如果你还没有执行git commit或者git add,那么工作区里的文件变动是可以直接丢弃,并恢复到 HEAD 版本的。

最直接的命令是:

# 用 HEAD 版本覆盖工作区文件 $ git restore --source=HEAD --worktree src/App.js # 等价的老命令 $ git checkout -- src/App.js

如果你只想撤销暂存区里的“已 add 状态”,但不想动工作区的内容,可以用:

$ git restore --staged src/App.js

这个命令相当于把git add撤销,文件会重新变成“未暂存”状态,但本地内容不会变。老版本 Git 里对应的是git reset HEAD src/App.js。

这里需要特别注意的是:一旦你对文件执行过git add,然后又继续编辑并意外保存,git restore --source=HEAD --worktree会把 HEAD 版本覆盖到工作区,这意味着你最后的未暂存编辑会被覆盖。遇到这种情况,如果那部分编辑很重要,应该先cp备份文件,再执行还原操作,或考虑从编辑器/IDE 的本地历史里找回。

为什么不建议轻易用checkout .或reset --hard直接覆盖全部文件?因为一旦项目里同时有多个文件变更,你可能只想恢复其中一个。恢复动作越精确越好,把影响范围控制到单文件级别,可以避免误伤其他正常改动。

2.3 分支被 reset 退了十几个提交:用 reflog 一步找回来

这是事故率最高的场景之一。你本来在feature/login分支上做了 8 次提交,结果手滑执行了git reset --hard HEAD~8,或者误 reset 到了origin/develop,发现自己这 8 个提交的代码全部“不见了”。

遇到这种情况,千万别急着重新写代码。先执行:

$ git reflog

你会看到类似这样的输出:

9f8e7d6 HEAD@{0}: reset: moving to 3c4d5e6 a1b2c3d HEAD@{1}: commit: 重构登录模块 e4f5g6h HEAD@{2}: commit: 增加记住我功能 i6j7k8l HEAD@{3}: commit: 修复 token 刷新逻辑 ...

HEAD@{1}或更早的位置,就是reset之前分支指向的提交。这个提交就是那 8 次提交里最新的一个,也就是你想要恢复的目标。接下来只要把分支指针切回它:

$ git reset --hard a1b2c3d

这样你的feature/login分支就重新指向了那个包含 8 次提交的节点,全部代码恢复,reflog 里会新增一条reset: moving to a1b2c3d的记录。

有个细节要注意:如果你在误操作后又执行了很多其他操作,reflog 里的HEAD@{1}可能不再是你想要的位置。这时候不要只看相对位置,要直接认哈希值。看到2e4f6a8这种 commit id,在那个提交是你需要的节点上,直接reset过去就行。

$ git reset --hard 2e4f6a8

这个过程基本可以在 30 秒内完成。关键在于:reset只是移动了引用,对象库里的提交对象一直还在,不存在“真正删除”这件事。除非你手动执行了git gc --prune=now或者 reflog 记录已被清除,否则绝大多数情况都能救回。

2.4 只想撤销 commit 但保留改动:优先使用 --soft

有些时候你并不是想彻底丢掉一次提交,而是想把这次提交“拆开重新整理”。比如提交信息写错了,或者提交内容里混入了不该提交的调试代码。这种场景不要用--hard,用--soft或--mixed更安全。

git reset --soft HEAD~1的含义是“把分支指针回退到上一次提交,但工作区和暂存区都保持不变”。执行之后,原本这次提交包含的所有改动依然在暂存区内,你可以重新git add或重新git commit。

$ git reset --soft HEAD~1 $ git status

这时你会看到所有改动都处于“Changes to be committed”状态。如果你希望把其中一部分文件移出暂存区,再执行git restore --staged即可。

--soft比--hard安全得多,因为它不会触碰工作区文件内容,不存在真正覆盖数据的风险。我在实战中总是建议:如果你不确定后果,先尝试--soft,再根据git status的结果决定下一步。--mixed(默认模式)会额外清除暂存状态,但同样不动工作区文件,也比较安全。只有在确实需要“让整个分支回到某个过去的状态,彻底丢弃后续改动”时,才考虑--hard。

3. 中等灾难:分支被删、rebase 翻车、远程推错

3.1 分支被删了怎么找回来

分支被git branch -D删掉的时候,很多人以为整个分支所有提交都没了。其实和 reset 一样,分支删除只是删除了一个指向提交对象的“引用”,所有提交对象本身仍然保留在对象库里。所以你只要找到该分支最后一次指向的 commit,就能重建分支。

# 先确认要恢复分支最后一个 commit $ git reflog # 找到类似这样的记录:deleted branch feature/payment (was 8a7b6c5) # 或者通过 reflog 里最近的 checkout 记录

如果git log里没看到这个分支的提交,你还可以用git fsck找到悬空提交:

$ git fsck --unreachable --no-reflogs | grep commit

输出中每一行都是一个还没有被任何分支引用的提交对象。你可以逐个用git show 哈希查看提交信息,找到属于被删分支的那个,然后:

$ git checkout -b feature/payment 8a7b6c5

这样分支就回来了,所有提交也都完整保留。如果你记得分支被删前最后提交的大致时间和消息,git reflog会更快;如果你连 reflog 都看不到那条记录,再用git fsck兜底。

这里要注意:git fsck找到的悬空提交可能很多,尤其是项目历史较长、操作频繁时,避免在输出中大海捞针。可以用git show --stat 哈希快速判断这个提交里改了哪些文件,和你要找的分支内容是否一致。

3.2 rebase 翻车:中止、跳过、完成后的后悔药

git rebase的翻车概率非常高。常见情况有:rebase 执行到一半发现冲突太复杂,不想玩了;或者 rebase 完成了,但发现整个提交顺序被搞乱,气得想拍桌子。

先说“进行到一半想反悔”的情况。Git 提供了一个很舒服的命令:

$ git rebase --abort

这个命令会放弃整个 rebase 过程,把当前分支恢复到 rebase 开始之前的状态。它比手动撤销冲突文件要可靠得多,因为 rebase 过程中你处于一个临时 detached HEAD 状态,手动 reset 容易搞错方向。

如果 rebase 中出现了冲突,但你想保留已处理的部分,继续往下走,就用:

# 解决冲突后 $ git add 已解决的冲突文件 $ git rebase --continue # 或者跳过当前提交 $ git rebase --skip

--skip的作用是放弃当前正在处理的这个提交,通常只在确定这个提交不再需要时才使用,谨慎一点为妙。

最麻烦的是 rebase 已经完成,并且你发现 rebase 后的结果完全不对。这时第一反应还是 reflog。rebase 的本质是把一系列提交在另一个基点上一一重放,每次重放都会生成新的提交对象,所以原来的提交对象仍然存在。你可以在 reflog 里找到 rebase 开始前的那个分支指针位置,直接git reset --hard回去。

$ git reflog # 找到类似:feature/x HEAD@{2}: rebase finished: refs/heads/feature/x onto 8d2f3a1 $ git reset --hard HEAD@{2}

HEAD@{2}的位置要根据你的实际 reflog 来判断,不一定就是 2。建议先git reflog后看哪一条是 rebase 开始前的记录,认准哈希值再 reset,不要凭感觉数数。

3.3 远程 push 错了:revert 比 reset 稳得多

本地误操作的后悔药很足,但远程分支是另一个世界。如果错误提交已经git push到了团队的共享分支,这时候不建议用 reset 再强推,因为会影响所有协作者。更稳妥的做法是用git revert。

git revert会生成一个新的提交,这个提交的内容是“把某个提交的改动反向撤销”,但历史记录里保留了这个被撤销提交的存在。这样做的好处是不会重写公共历史,其他人pull时不会遇到历史分叉问题。

# 撤销最近一次已推送的提交 $ git revert HEAD # 或撤销指定的某个提交 $ git revert 4f6a8c1

如果你需要撤销的是一个合并提交(merge commit),普通 revert 可能不够,通常得加-m参数指定保留哪一侧主线:

$ git revert -m 1 合并提交的哈希

-m 1表示保留当前分支侧的历史,撤销另一侧分支的改动。这个命令的效果是生成一个新提交,把合并引入的改动全部撤销,同时保留两条分支的合并关系。

那如果已经用了git reset --hard回退了本地分支,怎么办?如果这个错误提交还没有 push,或者只在你的本地出现,reset 后正常 force push 即可。如果已经 push 了,强烈建议放弃 reset 远程的念头,改用 revert。只有当错误提交存在于一个刚建的临时分支、或所有协作者都明确同意强推时,才考虑 reset + 强推。

3.4 force push 必须用 --force-with-lease

有时候你确实需要强推,比如本地 reset 了未推送的提交,或者覆盖了一个临时分支。这时不要裸用git push --force,因为它会无脑覆盖远程引用,可能把同事刚推上去的新提交直接“冲掉”。正确的姿势是:

$ git push --force-with-lease

--force-with-lease的含义是:只有当远程分支的当前状态与你上一次获取(fetch)到的状态一致时,才允许强推。如果远程已经有别人推送了新提交,它会拒绝强推并提示你重新 fetch。这个机制相当于给强推加了一道安全带,避免你用本地陈旧状态覆盖远程的新内容。

我强烈建议在团队协作里把这条命令设为默认肌肉记忆,只有在极端情况下才用全裸的--force,而且用之前一定要在群里或者当面确认所有人的状态。就算你确定自己的操作没毛病,也要用--force-with-lease,因为远程引用变了这件事本身就是一个强信号,说明有人在你不知情的情况下推送过代码。

3.5 误合并之后怎么办

git merge到一半发现问题,可以执行git merge --abort直接取消合并,回到 merge 开始前的状态。但是如果“merge 已经提交完成”,再想撤销就得区分情况了。

如果合并提交还没有 push,最舒服的方式是 reset 回合并之前:

$ git reset --hard HEAD~1

如果合并提交已经 push,并且这个合并本身就是错误操作,建议用git revert -m 1 合并提交哈希。注意-m 1和-m 2的区别:合并提交有两个父提交,-m 1表示保留合并发起分支的变更主线,-m 2表示保留被合并分支的主线。一般撤销整个 merge 用-m 1,就是回到发起合并前的状态,但这条命令和普通 revert 不同,它会产生一次“反向合并”,后续如果再次合并这条分支,Git 可能会因为“已经 revert 过”而误判,需要额外处理。这个坑比较深,我的经验是:如果不太确定,先把合并提交的哈希、两个父提交的哈希都记下来,再用 revert。

4. 隐藏刺客:stash丢失、错误分支提交、悬空对象

4.1 stash list 空了,但改动还在:用 fsck 找悬空对象

git stash也是误操作重灾区。常见情况是:你git stash保存了一堆临时改动,然后过几天一看git stash list居然是空的。可能的原因是你执行了git stash clear或者git stash drop。

这种情况下不要慌。git stash的实现本质是创建两个(或多个)提交对象,一个保存暂存区状态,另一个保存工作区状态,并且会生成一个 stash 引用指向这些提交。当你执行stash drop或stash clear时,只是删除了 stash 引用,提交对象仍然残留在对象库中,成为悬空提交。

使用git fsck找回来:

$ git fsck --unreachable --no-reflogs | grep commit

输出类似:

unreachable commit 3d2f0a1e5f6a... unreachable commit 9a8b7c6d5e4f...

逐个git show查看提交内容,看看哪个包含你 stash 过的文件。找到后,你可以直接用那个提交创建一个新分支,或者恢复成一个新的 stash:

# 将找到的悬空提交恢复为 stash $ git stash apply 3d2f0a1e5f6a # 或者基于它创建分支 $ git checkout -b recover-stash 3d2f0a1e5f6a

git stash apply 哈希可以直接把那个提交里的改动应用到当前工作区,效果和恢复一个普通 stash 一样。搜改动用git show --stat 哈希判断它包含哪些文件,这样能快速定位。

如果git fsck输出很多,你可以结合时间判断,也可以先把输出存到文件里慢慢筛。总之 stash 的丢失大多数都能救回来,但要趁早,别等久了对象被git gc清理。

4.2 切换分支后改动“消失”了:先别重建文件

有一种很常见的“误以为丢失”的情况:你在分支 A 上改了一堆代码,没提交,然后直接git checkout到分支 B。这时 Git 要么拒绝切换(提示覆盖冲突),要么带着未提交改动一起到了分支 B。如果你在分支 B 上执行了提交,那些改动就进入了分支 B 的提交里,而分支 A 反而没有。

遇到“改动怎么不见了”的情况,先不要重新写代码。第一步是git status,看工作区里文件是否处于已修改状态;第二步是git reflog,看你刚才执行过哪些切换操作;第三步是排查git stash list,看有没有不小心把改动塞进 stash 里。

如果你的改动只是被另一个分支的提交“吞并”了,可以用git log -p在另一个分支上搜索特定代码片段或文件名。也可以使用git log --all --oneline -- 某个文件名来全局搜索哪个提交涉及该文件,然后把它切回去。这个操作在大型项目里特别实用,因为改动往往不是真的没了,而是跑到了错误的分支或提交里。

4.3 提交到了错误分支:cherry-pick 迁移

如果改动已经 commit 了,但提交到了错误的分支上,不要急着撤销。正确姿势是用git cherry-pick把这一个提交迁移到目标分支。

假设你在feature/ticket-101分支上,误把属于feature/ticket-102的改动提交了,哈希是4f6a8c1。现在你需要:

# 在错误分支上记录当前状态,确认是当前分支多了一个提交 # 切到目标分支 $ git checkout feature/ticket-102 # 把目标提交摘过来 $ git cherry-pick 4f6a8c1

cherry-pick会把那个提交的改动在当前分支上重放一遍,生成一个全新的提交。完成之后,回到错误分支,把误提交的改动移出:

$ git checkout feature/ticket-101 # 如果这个分支刚才的提交还没 push $ git reset --hard HEAD~1

如果误提交的那次 commit 之后你在错误分支上又提交了新内容,就不能简单地reset HEAD~1,而是要精确定位到误提交的哈希,用git revert 4f6a8c1撤销它,或者用git rebase -i在交互式 rebase 里把那个提交删除。交互式 rebase 的操作要点是:进入编辑器后,把误提交所在行从pick改为drop,然后保存退出,Git 就会在重放其他提交时跳过这个提交。

4.4 悬空对象兜底:git fsck 与 git show 组合拳

前面几个场景都提到了git fsck,这里集中讲一下它的定位和用法。git fsck是一个用于检查对象库完整性的底层工具,它会列出所有不可达对象,这些对象大部分是因为各种操作遗留下来的“孤儿”。

$ git fsck --lost-found

带上--lost-found会把悬空对象按类型导出到.git/lost-found/目录里,通常主要是 commit 对象。你可以直接查看这些 commit 的内容,或者用git log对比。组合拳的完整思路是:

  1. git reflog优先找最近几十次操作记录;
  2. 如果 reflog 里没有线索,执行git fsck --unreachable --no-reflogs | grep commit;
  3. 用git show 哈希逐个查看悬空提交的改动内容;
  4. 找到目标后,用git branch 恢复名 哈希或git cherry-pick 哈希恢复。

这套组合拳能解决九成以上的“代码凭空消失”类问题。核心心法就是:Git 的对象库是非常保守的存储系统,只要不是手动清理或 gc 过期,几乎不会真正删除提交对象。

4.5 急救加速器:常用 alias 和配置建议

急救命令虽然不难,但真的慌起来,手速慢会加剧焦虑。我建议给自己的 Git 配几个顺手别名,把常用操作缩到最短。下面是我用了多年的配置:

$ git config --global alias.lg "log --oneline --graph --all --decorate" $ git config --global alias.unstage "restore --staged" $ git config --global alias.last "log -1 HEAD --stat" $ git config --global alias.undo "reset --soft HEAD~1" $ git config --global alias.co "checkout"

配置之后,git lg可以清晰查看所有分支的图形化提交历史;git undo可以快速撤销上一次提交但保留改动;git unstage可以快速取消暂存。还有一个我特别推荐的配置是开启 reflog 的可视化对照:

$ git config --global alias.myreflog "reflog --date=relative --format='%h %gd %gs'"

这样在查看 reflog 时,每条记录会显示相对时间和完整操作说明,定位速度明显提升。

5. 长期避坑习惯:把后悔药前置到工作流里

5.1 提交粒度越小,急救越容易

很多“灾难”其实是可以从源头上减少的。提交粒度越细,每次提交只包含一个逻辑变更,那么需要撤销或恢复的范围就越小。比如“修复登录 bug”“更新接口文档”“重构用户模块”分开提交,比塞进一个“乱七八糟”的大提交好处理得多。如果一次大提交里混了多个改动,你想撤销其中一部分,就得用到git revert+ 手动修改,或者git rebase -i拆分,复杂度成倍增加。

我建议养成“写完一个功能点就 commit”的习惯。哪怕同一个上午提交三次,也比晚上一次性提交一个巨型 commit 好。误操作后悔药的前提是你有足够细的 checkpoint,而提交就是 Git 世界里的 checkpoint。

5.2 大型重构前先打 tag 或建备份分支

涉及到大规模重命名、移动目录、修改数据表结构之类的高风险操作时,不要只依赖 reflog。reflog 虽好用,但如果你一个月后又来翻,记录可能已经因为频繁操作被冲掉。更可靠的方式是在动手前打个 tag,或者建一个备份分支。

# 操作前 $ git tag backup/feature-login-20240601 # 或者 $ git branch backup/feature-login-20240601

这两行命令的成本几乎为零,但价值极高。tag 和普通分支一样,是一个指向提交对象的固定引用,除非你手动删除,否则它不会消失。有了这样一个标记,无论后续发生什么操作,你都有一个“绝对安全点”可以随时回退。这个习惯特别适合那种需要改写大量文件的操作,比如代码格式化工具批量处理、目录结构调整等。

5.3 强推之前先确认三件事

团队协作中,最严重的 Git 事故大都来自强推。在按下 enter 之前,我要求自己必须确认三件事:

  1. 这条分支是否只属于我个人?如果是共享分支,强推会直接冲击其他人的本地状态;
  2. 我是否用了--force-with-lease?这个参数能否兜住“远程比我新”的情况;
  3. 如果强推出错,我是否有本地备份分支或完整记录?能否在 5 分钟内恢复到强推前?

只要任何一项答案是否定的,就不要强推。宁可多花几分钟沟通,也不要让团队所有人陷入“本地历史被改写”的泥潭。远程代码一旦被覆盖,虽然远端也有 reflog,但普通开发者通常没有权限直接操作远程仓库的引用,恢复难度会大很多。

5.4 每日结束前看一眼 reflog 和 status

这个习惯听起来很土,但非常有效。每天下班前,花 30 秒看一眼git status和git reflog | head -5,能让你清楚地知道今天做了哪些操作、当前处于什么状态、哪些东西还没提交。真发现哪个提交位置不对劲,趁记忆新鲜赶紧处理,成本最低。如果你是在团队里带新人,也可以把这个建议分享出去,减少很多不必要的救火时间。

5.5 一条实用的本地备份脚本思路

如果你经常处理重要分支,可以考虑写一个简单的 shell 脚本,自动给所有分支打备份 tag。这里提供一个我实践过的基础版本:

#!/bin/bash # 备份全部本地分支的当前快照 branchs=$(git branch --format='%(refname:short)') for b in $branchs; do tag="backup/$b-$(date +%Y%m%d-%H%M%S)" git tag "$tag" "$b" echo "已备份 $b -> $tag" done

脚本会为每个本地分支创建一个带日期的备份 tag。当然,生产环境用可能还需要考虑 tag 太多的问题,建议定期清理一个月前的旧备份。这个方案的优点是:不改变任何现有分支状态,不干扰工作流程,只额外增加一些安全的“存档点”。

6. 一些我踩过坑后总结出的实战心得

说了这么多命令,最后补充几个不太写进文档里的经验吧。第一,reflog是本地命令,只在你的本地仓库里有效。如果你换了电脑、重装了系统,或者 clone 了一份新仓库,reflog 是空的。所以不要把 reflog 当成唯一保险,重要分支定期 push 或打 tag 才是硬道理。第二,当你用git fsck找到一个悬空提交后,想恢复的提交如果是一个合并提交,直接用git branch重建分支通常没问题;但如果你想把它cherry-pick到别的分支,要小心只捡了第一父提交的改动。第三,如果你手头同时打开了多个终端窗口,误操作概率会大幅上升。我在一个终端里reset,另一个终端里直接继续写代码,然后发现工作区被覆盖的情况发生过不止一次。所以开始任何 git 操作前,先看一眼当前分支名,最好让git status里的分支信息保持在可见范围内。

还有一个小技巧:如果你做了大量不熟悉的高级操作,比如filter-branch、subtree、replace这类会改写历史的命令,强烈建议先在一个 clone 出来的临时仓库里演练一遍,确认无误后再对正式仓库动手。Git 的急救能力再强,也最好别天天用。把后路前置到工作流里,比每次事故后“拆弹”更让人安心。整套思路总结下来就一句话:任何时候别慌,先备份现场,再查 reflog,最后再做决定。希望这篇内容能帮你省下一次通宵抢救代码的经历。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进