适用读者:提交错了、推错了、合错了、找不到是哪次改动引入 bug 的人。
前置:S01-J01(对象与引用模型)、S01-J02(分支与 CR 流程)。
实测环境:Ubuntu 24.04.1 / Git 2.43.0;bisect、revert、cherry-pick、worktree、reflog 输出均为 2026-11-05 实测。
覆盖旧文:A-346(Git Alias 详解,48 点)。
新增扩展:E-S01-015(worktree / partial clone / sparse checkout,本篇讲 worktree)。
上一篇:S01-J02 · 分支策略与 Code Review / CI 流程
下一篇:S01-J04 · IDEA 调试与源码阅读。
1. 一句话结论
判断标准只有一条:这段历史有没有别人在用?
| 情况 | 用什么 | 为什么 |
|---|---|---|
| 只有我本地用(未推送) | reset 改写 |
改写不影响任何人 |
| 已经推送、别人可能基于它工作 | revert 追加反向提交 |
不改变历史,别人不用重新同步 |
| 想把某个提交搬到别的分支 | cherry-pick |
复制提交,不动原分支 |
| 想找出哪次改动引入 bug | bisect |
二分,O(log n) |
| 想同时开两个工作目录 | worktree |
同一个仓库、两个目录、互不干扰 |
| 已经闯祸了 | reflog + fsck |
对象还在,只是没引用 |
reset 与 revert 的选择题,本质是"历史是私有的还是共享的"。
2. reset 三档:改的是哪个位置
git reset [--soft|--mixed|--hard] <commit>
│ │ │
只动 HEAD 动 HEAD+暂存区 动 HEAD+暂存区+工作区
| 模式 | HEAD | 暂存区 | 工作区 | 典型场景 |
|---|---|---|---|---|
--soft |
✅ 移 | ❌ | ❌ | 撤提交但保留改动在暂存区(想重新组织提交) |
--mixed(默认) |
✅ | ✅ 清 | ❌ | 撤提交与暂存,改动留在工作区(重新 add) |
--hard |
✅ | ✅ | ✅ 覆盖 | 彻底回退(未提交的改动会丢) |
git reset --soft HEAD~1 # 把上一个提交退回暂存区
git reset HEAD~1 # 退回工作区(默认 mixed)
git reset --hard HEAD~1 # 连工作区一起回退
--hard不是"删除历史":它只是把引用挪走,对象还在(S01-J01 §5)。所以下一条命令往往能救命。
3. revert:共享分支上唯一正确的"撤销"
$ git log --oneline | head -3
18c61e8 Revert "c10" ← revert 产生的反向提交
8f8c49f c10
ca561d3 c9
git revert <commit> # 生成一个"反向"提交
git revert --no-edit <commit> # 不打开编辑器
git revert -m 1 <merge-commit> # 撤销合并(1 = 保留第一父,即主干)
为什么共享分支必须用 revert:它追加新提交,历史不变。别人 git pull 就能拿到;如果用 reset + push -f,所有人的本地历史会与远端分叉,每个人都要手动修——这是团队级事故。
| revert 场景 | 注意 |
|---|---|
| 撤销普通提交 | 直接 revert |
| 撤销 merge 提交 | 必须 -m 1;之后想"再合一次"要 revert 那个 revert,否则 Git 认为变更已存在 |
| 连续撤销多个 | 从新到旧依次 revert(或 git revert A..B) |
4. reflog:本地"时光机"(实测)
$ git reset --hard HEAD~1
$ git reflog --oneline
a547b41 HEAD@{0}: reset: moving to HEAD~1
2f56673 HEAD@{1}: commit: c2 ← 丢掉的提交
a547b41 HEAD@{2}: commit (initial): c1
$ git reset --hard HEAD@{1} # 原地复活
$ cat f.txt
v2
三条边界:
| 情况 | 能否找回 |
|---|---|
| 刚 reset / 刚删分支 / 刚 rebase 出错 | ✅ reflog 里有 |
| reflog 过期(默认 90 天) | ⚠️ reflog 没了,git fsck --lost-found 可能还有悬空对象 |
已 git gc --prune=now |
❌ 对象物理删除 |
从未 git add 的新文件被覆盖 |
❌ Git 没存过(靠 IDE Local History,S01-B09) |
5. bisect:把"哪次引入的 bug"变成二分查找(实测)
5.1 场景
10 个提交,第 5 个引入了 BUG,现在 HEAD 是坏的。
5.2 手工流程
git bisect start
git bisect bad HEAD
git bisect good $(git rev-list --max-parents=0 HEAD) # 第一个提交
# 每次 Git 检出中间提交,你验证后告诉它 good/bad
git bisect good # 或 git bisect bad
# …
git bisect reset # 结束,回到原分支
5.3 自动化:git bisect run(实测)
cat > /tmp/check.sh <<'EOF'
#!/bin/sh
if grep -q BUG f.txt; then exit 1; else exit 0; fi
EOF
chmod +x /tmp/check.sh
git bisect start
git bisect bad HEAD
git bisect good $(git rev-list --max-parents=0 HEAD)
git bisect run /tmp/check.sh
实测输出:
Bisecting: 2 revisions left to test after this (roughly 2 steps)
...
c5
f.txt | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
bisect found first bad commit
它准确指出了 c5。 10 个提交手工排查要试 5 次以上,二分只要 ⌈log₂10⌉ = 4 步;100 个提交也只要 7 步。
5.4 前提:你需要一个"能自动判断好坏"的命令
bisect run 的价值全在这个脚本上。它可以是:
#!/bin/sh
mvn -q -pl order-service test -Dtest=OrderConcurrencyTest # 测试通过=good
这也是"每个修复都要有回归测试"的真正理由:有测试,bisect 才能自动化(S16 会讲测试体系)。
6. cherry-pick:把某个提交搬到另一个分支(实测)
$ git cherry-pick --no-commit 8f8c49f
$ git status --short
M f.txt
| 用法 | 说明 |
|---|---|
git cherry-pick <sha> |
复制该提交到当前分支 |
git cherry-pick A..B |
复制一段(不含 A) |
git cherry-pick -x <sha> |
在消息里记录来源 sha(跨分支追踪时强烈建议) |
--no-commit |
只应用改动,不自动提交(想再改一改时用) |
风险:cherry-pick 会生成新的 hash(父提交不同,见 S01-J01 §2)。同一个改动在两处存在,后续合并容易冲突——能用 merge 就别用 cherry-pick。
7. worktree:一个仓库,两个工作目录(实测)
$ git worktree add /tmp/bd-wt HEAD~1
$ git worktree list
/tmp/bd 18c61e8 [main]
/tmp/bd-wt 8f8c49f (detached HEAD)
用途(这是旧文完全没提的高频场景):
| 场景 | 好处 |
|---|---|
| 正在改功能,线上突然要 hotfix | 不用 stash 当前工作,直接开一个 worktree 修 |
| 要对比两个版本的行为 | 两个目录并行运行,互不干扰 |
| 跑长时间构建/测试 | 一个目录跑测试,另一个继续写代码 |
清理:git worktree remove <path>;git worktree prune 清失效记录。
8. alias:把高频命令变短(A-346)
git config --global alias.st status
git config --global alias.co checkout
git config --global alias.br branch
git config --global alias.lg "log --oneline --graph --decorate -15"
git config --global alias.last "log -1 --stat"
git config --global alias.unstage "reset HEAD --"
shell 型别名(以 ! 开头,能跑任意命令):
git config --global alias.undo '!git reset --soft HEAD~1'
git config --global alias.cleanbr '!git branch --merged | grep -v "\*" | xargs -r git branch -d'
| 风险 | 说明 |
|---|---|
! 别名会执行 shell,可被用于注入 |
别从不可信来源复制 alias,尤其 ! 开头的 |
| 覆盖内建命令 | alias.co=checkout 这类覆盖是安全的;但不要覆盖 git status 这类带 --porcelain 语义的命令(脚本会坏) |
| 别名不进仓库 | 只在本机 ~/.gitconfig;团队共享要走 dotfiles(S01-B09 §5.4) |
A-346 的补充:旧文讲了"什么是 alias、为什么需要、如何配置",但没提
!型别名的执行风险,也没提"别覆盖带脚本语义的内建命令"。这两条是实践里真正会踩的。
9. 五种事故处置手册
| 事故 | 立刻做 | 不要做 |
|---|---|---|
误提交到 main(未推) |
git reset --soft HEAD~1 或 git branch tmp && git reset --hard HEAD~1 |
别 push -f 到 main |
| 误提交并已推送 | git revert <sha>;若在个人分支可 reset + push -f |
别在共享分支 push -f |
| 误合并且已推送 | git revert -m 1 <merge> |
别 reset 共享分支 |
| 误删分支 | git reflog 找到 sha → git branch <name> <sha> |
别先 gc |
| 找不到引入 bug 的提交 | git bisect run + 可自动化判定的脚本 |
别手工逐个 checkout 试 |
10. 自测
reset --soft、--mixed、--hard分别动了哪几个位置?- 已推送的提交要撤销,为什么必须 revert 而不是 reset?
revert -m 1 <merge>里的1是什么?撤销 merge 后再想合并回去要做什么?bisect run的脚本要满足什么条件?为什么说这解释了"回归测试为什么必须写"?worktree与git stash解决的场景有何不同?
11. 下一篇
S01-J04 · IDEA 调试与源码阅读:断点、求值、远程调试
Git 层面能定位"哪次改动",代码层面还要定位"哪一行、哪个线程、哪个变量"。下一篇从旧文的 4 个调试技巧出发,补上条件断点、日志断点、Stream Debugger 与容器内远程调试(JDWP),并给出"不改代码就能观察变量"的三条路径。
本篇属于系列《S01 · 开发环境与工具链》。旧文来源:A-346;扩展点 E-S01-015。所有输出为 2026-11-05 实测。