Git 撤销、恢复与回滚
一次“撤销”如何扩大成事故
开发者在 main 上合并了一次错误配置,线上已经开始报错;另一名同事同时说“我刚才 reset 之后两个提交不见了”。两个人都在问怎样撤销,处理对象却完全不同:前者面对的是已经共享、可能已经触发部署副作用的公共历史,后者面对的是本地分支引用移动。若不先分层,同一句 git reset --hard 既可能只是清理实验分支,也可能覆盖唯一一份未提交工作,并把多人已经拉取的历史改成彼此不兼容的形状。
Git 的恢复决策建立在四个对象边界上:工作区保存磁盘上的编辑,index 保存下一次提交的候选快照,分支名保存一个可移动的提交引用,commit 则把 tree、父提交和元数据连成不可变对象。restore 主要在文件与 index 之间复制内容,reset 可以移动本地引用,revert 在公共历史末端新增反向提交,reflog 记录本地引用曾经指向哪里。命令相似,改变的对象、共享影响和恢复窗口却完全不同。
代码树恢复也不等于业务回滚。一次 revert 不会自动撤销已经发布的制品、数据库写入、消息投递、云资源或运行中配置;这些副作用必须交给部署与数据 runbook。凭证进入 Git 历史时更不能把重写历史当作唯一处置:先撤销和轮换凭证,再清理仓库历史、托管平台缓存与下游副本。
先冻结现场,再选择状态层
开始恢复时,先停止会继续改写仓库的自动化、IDE 后台 Git 操作和清理任务,再记录现场:
git --version
git status --short --branch
git rev-parse --show-toplevel
git rev-parse HEAD
git log --graph --oneline --decorate -12
git reflog -12把问题归入一个明确层次:
| 现场 | 默认优先工具 | 核心问题 |
|---|---|---|
| 未暂存编辑错误 | git restore 或 git restore -p | 是否真要覆盖唯一工作副本 |
| 暂存范围错误 | git restore --staged | 只退 index,还是连工作区也恢复 |
| 本地未共享提交错误 | git reset --soft/--mixed | 是否保留 staged 或未暂存改动 |
| 已 push / 已合并提交错误 | git revert | 用新提交反向,保持公共历史可追踪 |
| reset/rebase 后提交“消失” | git reflog + 救援分支 | 对象是否仍在、何时会过期 |
| merge commit 引入问题 | 先判断单提交 revert 还是 revert -m | mainline 父提交是谁、未来是否还要重合 |
恢复操作先在专用实验仓库复现。真实仓库若有未提交文件,先复制到受控临时位置或创建救援提交与分支;不要为了“让命令能跑”先 hard reset。涉及已共享分支时,还要确认保护规则、push 权限、CI、签名和审批要求。
四类命令改变的是不同对象
Git 总手册在 Reset, restore and revert 中把三个相似命令按对象分开:restore 恢复工作区或 index 中的路径而不移动分支,reset 可移动当前分支 tip,revert 则创建新提交反向已有提交。git-restore 进一步规定,只恢复工作区时默认来源是 index,带 --staged 时默认来源是 HEAD;--source 可显式指定 tree,--patch 可逐块选择。
git-reset 定义了 --soft、默认的 --mixed、--hard、--merge 与 --keep 对 HEAD、index 和工作区的不同影响,并在移动前把旧 tip 写入 ORIG_HEAD。这也是为什么误 reset 后应先保全引用,而不是继续试命令。
共享历史使用 git-revert 新增反向提交,能保留原提交和审查链。对 merge commit 使用 -m <parent> 时,选中的父提交是 mainline;Git 会把该 merge 相对 mainline 带来的树变化反向掉,并影响未来合并如何识别已经引入过的祖先。复杂合并要结合 Git 官方的 faulty merge 恢复说明 判断后续是继续修补原分支,还是重建后再合并。
git-reflog 记录的是本地引用 tip 的移动,不是远端备份,也不保存从未进入对象库的文件编辑。默认配置通常让可达记录保留 90 天、从当前 tip 不可达的记录保留 30 天,但 gc.reflogExpire*、手工 expire、gc 和 prune 都能缩短窗口。gitrevisions 定义了 HEAD@{n}、ORIG_HEAD、MERGE_HEAD 等引用写法,git-fsck 则可在引用已经丢失时检查仍存在的 dangling 或 unreachable 对象。
安装与首次启用
这些命令随 Git 提供,不需要插件。启用的第一件事不是配置别名,而是建立“先预览、再保全引用、最后修改”的操作习惯:
git status --short --branch
git diff
git diff --staged
git show --stat --oneline <target-commit>
git branch rescue/before-recovery-<date> HEAD救援分支为当前提交创建稳定引用,不会保存未提交工作。若工作区有重要改动,使用普通提交放到专用救援分支通常比依赖 stash 更容易审查;团队不允许 WIP 提交进入远端时,保留在本地并清楚标记即可。
不建议建立把 git reset --hard 缩成短别名的团队配置,也不要用脚本根据报错自动 hard reset、clean、expire reflog 或 prune。破坏性动作应该保留完整命令、目标 revision 和人工确认。
用三棵树理解 restore 与 reset
可以把常用状态简化成三层:
HEAD / 当前分支引用 -> 上一次提交快照
index -> 下一次提交准备写入的快照
working tree -> 磁盘上的当前文件git restore path 默认把 index 内容写回工作区;git restore --staged path 默认把 HEAD 内容写回 index;同时写 --staged --worktree 才会同时改变两层。显式 --source=<commit> 时,先用 git show <commit>:<path> 或 diff 验证来源。
git reset <mode> <commit> 则先移动当前分支到目标提交,再按 mode 处理 index 和工作区:
| 模式 | 分支 / HEAD | index | 工作区 | 典型用途 |
|---|---|---|---|---|
--soft | 移动 | 保留 | 保留 | 拆改本地提交,所有变化仍 staged |
--mixed | 移动 | 对齐目标 | 保留 | 默认模式,把提交退回未暂存修改 |
--hard | 移动 | 对齐目标 | 对齐目标 | 仅在确认无须保留本地改动时清理 |
--merge | 移动 | 有条件更新 | 尽量保留未暂存修改 | 退出合并类现场,冲突时会拒绝危险覆盖 |
--keep | 移动 | 对齐目标 | 保留不冲突本地修改 | 去掉本地提交但保护工作区,冲突则拒绝 |
reset <path> 是另一种模式:它只更新 index,不移动 HEAD,等价于相应的 restore --staged 用途。写脚本时优先用语义更清楚的 restore --staged,减少“reset 到底有没有移动分支”的误读。
公共历史默认用 revert
一旦提交已被他人拉取、进入受保护分支或成为审计基线,就把它视为共享历史。git revert <commit> 通过新增提交反向原补丁,保留原提交、故障证据、评审和时间线。它不会让世界回到过去,只会改变当前 Git 树;数据库写入、外部 API 调用和已经发布的二进制仍然存在。
正反实验:分别恢复四种状态
在专用实验目录创建仓库,依次验证 unstaged、staged、本地提交和 reflog 恢复:
git init -b main undo-lab
cd undo-lab
git config user.name "Example Developer"
git config user.email "developer@example.com"
printf 'value=base\n' > app.conf
git add app.conf
git commit -m "建立恢复实验基线"
printf 'value=wrong-worktree\n' > app.conf
git diff
git restore -- app.conf
git diff第一次预期 git diff 有变化,restore 后工作区恢复为 index 中的 value=base,diff 为空。继续验证暂存区:
printf 'value=staged\n' > app.conf
git add app.conf
git diff --staged
git restore --staged -- app.conf
git diff --staged
git diff预期 staged diff 消失,但工作区仍保留 value=staged。再把它提交并制造一次本地误 reset:
git add app.conf
git commit -m "添加待恢复提交"
lost_commit=$(git rev-parse HEAD)
git reset --hard HEAD^
git log --oneline -3
git reflog -5
git branch rescue/recovered "$lost_commit"
git show --stat rescue/recovered预期提交从当前分支 log 中消失,但 reflog 仍显示 reset 前的位置;创建救援分支后,该提交重新变为可达。最后验证公共历史式回滚,不重写 main:
git cherry-pick rescue/recovered
git revert --no-edit HEAD
git log --oneline -4
git diff HEAD~2 HEAD预期历史中同时保留原提交和 revert 提交,最终树相对二者之前没有该变化。实验结束前确认 git fsck --connectivity-only 没有对象连通性错误;退出目录并确认它确为实验仓库后,再安全删除 undo-lab。
项目应把恢复决策写入 CONTRIBUTING.md 或仓库治理手册,至少回答:哪些分支允许 reset/rebase,哪些分支只允许 revert;谁能批准 merge revert;revert 后需要重跑哪些检查;外部副作用由哪个 runbook 接管;恢复证据保留多久。
处理事故时采用两阶段动作:
保全阶段:记录 HEAD、status、log、reflog、目标提交和远端状态;为关键 commit 创建 rescue/ 引用;停止 gc/prune 和自动清理。修复阶段:在隔离分支预演 restore/reset/revert,比较树和提交图,跑测试,经 review 后再作用于共享分支。
对于已经 push 的功能分支,如果只有作者使用且平台允许,可以在沟通后重写;对于主干和多人共享分支,默认新增 revert。即使必须重写共享分支,也应先保存旧 tip,使用 --force-with-lease 而非裸 --force,并重新触发平台检查和审批。
代码 rollback 与发布 rollback 必须建立关联但不混为一谈。PR/MR 或事故记录应写明:Git revert commit、受影响制品版本、数据库、配置和消息副作用、部署回退入口与责任 owner;只有这些入口一起闭环,才算完成业务层恢复。
只撤销工作区或暂存区
git restore -p -- path/to/file
git restore -- path/to/file
git restore --staged -- path/to/file
git restore --source=<commit> -- path/to/file-p 逐块选择,适合一个文件里好坏修改混在一起。restore --source 会让目标路径匹配指定 tree;若源中没有该 tracked 路径,文件可能被删除以匹配来源,因此执行前必须预览。
修正尚未共享的最近提交
git reset --soft HEAD^
# 修改 index 或工作区后重新 commit
git reset --mixed HEAD^
# 原提交变化保留为未暂存修改只改最后一次提交消息或补入遗漏文件时,git commit --amend 通常更直接,但它同样会产生新 commit ID。是否可用仍取决于历史是否共享。
回滚已共享提交
git show --stat <bad-commit>
git revert <bad-commit>
git show --stat HEAD连续 revert 多个提交时,顺序会影响冲突和最终树。可以逐个 revert 保留清晰证据,或经审查后用 --no-commit 组合为一次提交;后一种会降低单提交可追踪性,必须在 message 中列清目标提交和原因。
从 reflog 恢复
git reflog --date=iso
git show HEAD@{2}
git branch rescue/recovered HEAD@{2}先创建分支,再做 reset 或 cherry-pick。HEAD@{2} 是当前本地 reflog 的相对位置,后续 Git 操作会让编号变化;事故记录应保存解析后的完整对象 ID,而不是长期引用相对编号。
若 reflog 已无目标,可只读检查 dangling 对象:
git fsck --full --no-reflogs --unreachable找到候选 commit 后用 git show <oid> 验证,再创建救援分支。git fsck --lost-found 会写入 .git/lost-found,不是第一步;对象已被 prune 后,Git 本地命令无法凭空重建内容,只能转向其他克隆、备份、补丁、CI 工作区或托管平台引用。
回滚 merge commit
先确认父提交顺序:
git show --no-patch --pretty=raw <merge-commit>
git diff <merge-commit>^1 <merge-commit>
git diff <merge-commit>^2 <merge-commit>若主干是第一父提交,常见命令是:
git revert -m 1 <merge-commit>-m 1 不是固定口诀,而是“以第一个父提交为 mainline,反向 merge 相对它带来的变化”。八爪鱼 merge、多次回合合并或非标准父顺序必须单独分析。优先考虑只 revert 真正有问题的普通 commit;整 merge revert 会形成大范围反向改动,降低 bisect 和后续重合并的可理解性。
restore 后未提交代码彻底消失
执行 git restore -- <path> 后,编辑内容不在 diff、log 或 reflog 中。确认这部分是否曾被 git add、commit、stash 或由 IDE local history 保存。普通工作区内容不是 Git 对象;restore 用 index 覆盖它,reflog只记录引用移动,不记录每次文件编辑。
立即停止写盘,检查编辑器本地历史、文件系统快照或备份。Git 自身通常无法恢复从未进入对象库的内容。恢复候选内容后另存并 diff,确认完整,再提交到救援分支。
reset 后提交不见了
git log 看不到刚才的提交,以为数据已删除。运行 git reflog --date=iso,用 git show <candidate> 验证对象内容。reset 移动了分支 tip,提交变成不可达,但通常尚未从对象库剪枝。
立即 git branch rescue/<name> <oid> 建立稳定引用,不要先运行 gc、prune 或 reflog expire。git log rescue/<name> 能看到提交,git fsck --connectivity-only 无错误,再决定 cherry-pick、merge 或 reset。
revert 发生冲突或结果不等于旧版本
revert 停在冲突,或成功后文件并未完全回到原提交之前。比较目标 commit 的补丁与之后提交对同一区域的修改;查看当前 sequencer 状态。revert 是把反向补丁应用到当前树,后续演化可能与原补丁重叠;它不是 checkout 历史快照。
按冲突处理流程合并当前意图,git add 后 git revert --continue;不应继续时用 git revert --abort。检查 staged/final diff、目标测试和提交 message,确认反向的是预期行为而非整个文件的后续修复。
merge revert 后再次合并,旧功能没有回来
修复分支追加新提交后再次 merge,Git 只带入新提交,第一次 merge 中的旧变化仍缺失。查看提交图,确认原 merge 仍是双方共同祖先,且主干上存在其 revert。revert 只反向树变化,不抹掉 merge 的历史连接;未来 merge 仍认为旧提交已经合入。
若原分支是在旧提交上继续修补,通常先评审“revert the revert”再重合;若分支已完整重建为新提交序列,则直接合并新序列可能更合适。必须按官方 faulty merge 两种场景推演。在隔离分支画出父子关系,比较预演 merge 的树和测试,再提交正式 PR/MR。
reflog 里也找不到目标
git reflog 无候选,或候选对象 git show 报不存在。检查是否在正确克隆和 worktree、是否查询了目标分支 reflog,随后只读运行 git fsck --full --no-reflogs --unreachable。操作发生在另一克隆/另一 worktree;reflog 已过期;对象被 gc/prune;提交从未创建成功。
从其他开发者克隆、CI checkout、平台未关闭的 PR/MR 引用、备份或补丁恢复。不要反复运行清理命令。恢复后立即建立分支和远端受控备份,核对 commit tree、作者时间与测试结果。
restore、reset、本地 revert 和 reflog 基本不需要网络。若本地恢复完成但 push 被拒绝,应检查保护分支、远端已推进、签名和审批,而不是改用更破坏性的 reset。需要更新重写后的个人分支时,先 git fetch,确认远端 tip,再按政策使用 git push --force-with-lease;共享主干默认禁止重写。
恢复过程中输出的 remote URL、reflog、commit message 和 patch 可能包含真实邮箱、内部路径、工单号或敏感配置。共享证据前应脱敏;不要把带 PAT 的 URL 写入命令历史。凭证泄漏事件的首要动作是撤销、轮换和阻断使用,revert 或删文件不能使已经暴露的 secret 失效。
执行公共 revert 通常需要正常写权限与 review,不应临时授予管理员绕过来“抢时间”。紧急流程也要保留操作者、目标 commit、mainline 选择、验证结果和外部副作用 owner。恢复权限应最小化,且事故后回收临时授权。
团队需要一张可执行的撤销策略,而不是一句“出事就回滚”:
个人未共享分支允许 restore、reset、rebase,但作者负责保存救援引用和重新验证。受保护主干及已共享历史默认只允许 PR/MR 形式的 revert,保留检查和审批。merge revert 需要至少一名理解提交图的 reviewer 明确确认 parent/mainline 与未来重合策略。
仓库 owner 定义 reflog 和 gc 配置的责任边界;默认保留期不是恢复承诺,重要历史依赖远端分支、tag、备份和平台保留策略。事故模板同时记录代码 revert、制品、数据库、配置、消息和权限凭证的状态,避免“Git 已回滚”被误报成系统已恢复。季度演练覆盖误 restore、mixed/hard reset、rebase 丢提交、reflog 恢复、merge revert 和其他克隆恢复。
恢复脚本应以只读诊断为默认:展示 status、目标 revision、将变化的 refs 和 diff。真正修改前要求显式确认,并优先创建 rescue ref。不要让机器人自动判断 -m 1,也不要让 CI 在失败后执行 hard reset 之外还删除开发者持久工作区。
reflog 是机会窗口,不是备份
reflog 属于本地仓库,记录会过期,不同 worktree 与不同克隆也有各自状态。官方默认的 90/30 天可被配置和清理改变。需要长期可恢复的提交必须有 branch、tag、远端引用或备份;不能用“反正 reflog 能找”替代保存策略。
hard reset 的风险不止 tracked 文件
官方文档说明 --hard 会让 index 和工作区匹配目标提交,并可能覆盖妨碍写入的 untracked 路径。它不等同于“只删最近提交”。子模块还涉及是否递归 reset 和 detached HEAD。执行前必须列出 untracked、ignored、submodule 和 nested repository 状态,不能在未知目录运行。
merge revert 改的是树,不是拓扑
原 merge commit 仍留在图中,merge base 计算不会忘记它。选择 revert the revert、重建分支或逐提交修复,取决于分支是在原历史上继续补丁,还是已经完整重写。这个决定需要提交图和预演,不是命令语法问题。
代码历史与外部状态会分叉
一个提交可能同时包含 schema migration、配置开关、事件格式和代码。Git revert 只生成反向代码补丁,不会撤销已经执行的数据变化。团队设计变更时应让数据库变更前向兼容、配置具备独立回退、事件 schema 可兼容,并在 PR 中关联各自 owner。
“抹掉证据”与“恢复服务”目标冲突
事故中立即重写主干、expire reflog 或 prune 对象,会损失诊断和恢复证据。优先阻断影响、撤销凭证、创建 rescue refs、导出必要审计信息,再评估历史清理。敏感信息历史净化需要平台缓存、fork、镜像和其他克隆协同,不能靠单次 reset 或 revert 完成。
执行前
已区分工作区、index、本地引用和公共历史,明确只改变哪一层。已记录 status、HEAD、log、reflog、远端状态和目标 commit。重要 commit 已有 rescue 分支;未提交工作另有可验证副本。
已确认代码之外的制品、数据库、配置和凭证副作用 owner。
选择命令时
只退暂存使用 restore --staged,没有误带 --worktree。本地 reset 已明确 soft/mixed/hard 对三层状态的影响。已共享历史默认使用 revert,不以裸 force push 改写。
merge revert 已检查父提交顺序,-m 不是照抄固定数字。
执行后
已比较最终树、staged diff 和提交图,未只看命令退出码。项目测试、构建、签名和平台检查按风险重新执行。reflog 恢复出的对象已建立稳定引用,不再依赖相对编号。
revert message、PR/MR 和事故记录说明目标、原因、验证和后续动作。
团队推广前
共享分支重写、force-with-lease、merge revert 和紧急权限政策明确。reflog/gc 保留边界与真正备份策略明确。恢复脚本默认只读并先创建 rescue ref,不自动 hard reset/prune。
团队已演练 Git 恢复与外部系统回滚分工,退出和交接责任清楚。
