「提交信息打错了」「刚才那个提交不该提」「本地改的东西想全部丢掉」「reset 完发现删多了」——Git 的撤销需求每一个都很具体,而命令的选择取决于三个问题:改动在哪个层(工作区/暂存/提交)、提交是否已推送、有没有协作者。
这篇把撤销操作按场景矩阵整理,全部命令在临时仓库里真实跑过(输出见「可复现的实测结果」),并覆盖一个常被忽略的边界:untracked 文件与 reset --hard 的关系。
场景矩阵:先对号入座
| 场景 | 命令 | 风险 |
|---|---|---|
| 刚提交,信息有 typo | git commit --amend |
未推送 = 零风险 |
| 撤销未推送的提交,保留改动 | git reset --soft/--mixed HEAD~1 |
零风险(可用 reflog 找回) |
| 撤销未推送的提交,彻底丢弃 | git reset --hard HEAD~1 |
已跟踪文件的未提交改动一并丢失 |
| 撤销已推送的提交 | git revert <hash> |
零风险(新增反向提交) |
| 找回 reset 掉的提交 | git reflog + git branch rescue <hash> |
reflog 过期(默认 90 天)后失效 |
| 未跟踪的新文件误删 | Git 无法恢复 | 文件从未进入 Git 对象库 |
最后一条先划重点:Git 只能恢复进入过对象库的内容。untracked 文件(新建还没 git add 过)被删除时,Git 爱莫能助——本文实测也验证了这一点。
amend:改最近一次提交(含信息与内容)
git commit --amend 把暂存区当前内容与新的提交信息合成一个新提交,替换掉最近一次提交:
echo v1 > a.txt && git add . && git commit -qm "initail commmit" # typo
git commit --amend -qm "initial commit"
git log --oneline # → ad04563 initial commit(哈希已变)
实测确认两点:typo 修正生效;哈希完全改变(ad04563 → 新哈希)——amend 本质是「新提交替换旧提交」,不是「修改」。因此它只适用于未推送的提交;已推送的 amend 等同于改写历史。
另外 amend 会把当前暂存区一并并入:如果暂存区有不想提交的改动,先 git reset 取出,再 amend。
reset 三档:改动到底去了哪
git reset 把分支指针移回目标提交,三种模式的区别是工作区与暂存区的改动各自去哪。实测(先提交 a.txt,再依次提交 b.txt、c.txt,然后从 add-c 开始逐档回退):
| 模式 | HEAD | 暂存区 | 工作区 | 实测 git status |
|---|---|---|---|---|
--soft HEAD~1 |
回退 1 | 保留(c.txt 在暂存区) | 保留 | A c.txt |
--mixed HEAD~1(默认) |
回退 1 | 清空 | 保留(取消暂存) | ?? b.txt ?? c.txt |
--hard HEAD~1 |
回退 1 | 清空 | 丢弃(已跟踪文件) | 干净 |
选法一句话:soft 重新组织提交,mixed 重新编辑,hard 彻底放弃。
一个实测出的真实边界:reset --hard 不会删除 untracked 文件。上面实测中 b.txt 在 mixed 阶段变成未跟踪后,hard 重置并未把它删掉——未跟踪文件不在 Git 对象库与索引的管理范围内。想连它们一起清,用 git clean -fd(先 git clean -nd 预览)。
这也带来一个反直觉的安全提示:hard reset 并不保证「工作区变干净」,untracked 与 ignored 文件会留下来;反过来,它们也因此躲过了 hard 的误删。
revert:唯一安全的「撤销已推送」
git revert <hash> 不移动任何指针,而是计算目标提交的反向补丁并生成一个新提交:
git revert --no-edit HEAD
# → [main 47d7e82] Revert "add-b"
git log --oneline | head -2
# 47d7e82 Revert "add-b"
# 6c3764a add-b
实测确认:add-b 引入的 b.txt 在 revert 后被移除,且历史里两个提交都在——没有改写任何东西,任何人 pull 之后状态一致。代价是历史里会多一条「撤销记录」,但这正是可审计性的来源。
选择铁律由此而来:提交已推送(尤其是共享分支)→ revert;仅存在于本地 → reset。对共享分支 reset 后强推,是把「我的历史」强加给所有协作者,属于最常见的团队事故之一(详见 Git Rebase vs Merge 的黄金法则一节)。
reflog:找回你以为丢掉的提交
git reset --hard 删掉的提交并没有立刻消失——git reflog 记录了 HEAD 的每一次移动(默认保留 90 天)。实测找回流程:
git reset --hard HEAD~1 # 误操作:回退掉 add-b
git log --oneline | head -1 # → 6c3764a add-b(当前 HEAD)
git reflog | head -4
# 6c3764a HEAD@{0}: reset: moving to HEAD~1
# 47d7e82 HEAD@{1}: revert: Revert "add-b" ← 要找回的提交还在
git branch rescue 47d7e82 # 一条命令找回
git log rescue --oneline | head -1
# → 47d7e82 Revert "add-b" ✓
实测确认被 reset 掉的提交仍可通过 reflog 定位并建成分支。三个注意点:reflog 是本地的(不随 push 传输,别人的仓库没有你的记录);过期条目会被 gc 回收;未提交的改动不在其中——它们从未进入对象库,这也是「先提交再折腾」的真正含义。
可复现的实测结果
以下全部输出来自一次性创建的临时仓库(命令可直接照跑):
① amend 修正提交信息:
echo v1 > a.txt && git add . && git commit -qm "initail commmit"
git commit --amend -qm "initial commit"
git log --oneline
# → ad04563 initial commit (typo 修正,哈希已变)
② reset 三档实测:
# 从 add-c 开始逐档回退
git reset --soft HEAD~1
# log: 6c3764a add-b status: A c.txt ← 改动在暂存区
git reset --mixed HEAD~1
# log: ad04563 init commit status: ?? b.txt ?? c.txt ← 改动回工作区
git reset --hard HEAD~1
# log: ad04563 init commit status: 干净(已跟踪文件)
# ⚠️ b.txt(untracked)仍然存在 —— hard 不清未跟踪文件
③ revert 生成反向提交:
git revert --no-edit HEAD
# → [main 47d7e82] Revert "add-b"
git log --oneline | head -2
# 47d7e82 Revert "add-b" ← 反向提交在历史顶部
# 6c3764a add-b ← 原提交仍完整保留
④ reflog 找回:
git reset --hard HEAD~1 # 误删 revert 提交
git reflog | head -2
# 6c3764a HEAD@{0}: reset: moving to HEAD~1
# 47d7e82 HEAD@{1}: revert: Revert "add-b"
git branch rescue 47d7e82 # 找回 ✓
小结
| 原则 | 一句话 |
|---|---|
| 先提交再折腾 | 未提交的改动没有任何恢复手段;提交进对象库后,reflog 是安全网 |
| 私有 reset,共享 revert | 以「是否已推送/是否有协作者」划线,不看命令顺不顺手 |
| amend 只给未推送 | 哈希会变,push 过的提交用 revert |
| untracked 是盲区 | reset --hard 不清也不救;新建文件先 git add 进索引 |
| force push 用 lease | --force-with-lease 在远端有新提交时拒绝推送 |
配套命令速查见 Git 命令参考与 git-memo 工具;合并策略的完整对比见 Git Rebase vs Merge。