写在前面:这一章要解决什么
摘要: 本文是一篇 Git 高级操作动手实验教程,围绕
reflog、rebase、cherry-pick、revert、stash、bisect与冲突解决七组实验展开。读者在可丢弃目录中,按「场景 → 步骤 → 预期结果 → 通过标准 → 容易卡的地方」完整走一遍,把高级操作练成肌肉记忆。同时强调安全边界:已推送用revert、未推送用reset、共享分支不改历史。 高级操作看书看懂了,上手还是懵。这一章把第 15–23 章的知识点 拧成七组动手实验,在 可丢弃目录 里走完整一遍。做完你才敢说「高级操作我用过了」。
学完后,你应该能 不看资料:
- 用
reflog找回被reset --hard吃掉的提交 - 用交互式
rebase合并、重排提交 - 用
cherry-pick从别的分支挑选提交 - 用
revert安全地撤回已推送的提交 - 用
stash救急切分支修 bug 再切回来 - 用
bisect二分查找第一个坏提交 - 亲手解决一次冲突并理解标记含义
读者设定: 大一同学,读完 15–23 章,知道这些命令是什么,但还没真练过手。
1. 定位:为什么要单开一章高级实验
1.1 一句话先记住
高级操作没练过手,等于没学会。
这一章不强加新知识,只把分散的高级操作 串成可复现的练习,逼你把动作练成肌肉记忆。
1.2 只读不练会怎样
| 只读不练 | 真做了实验 |
|---|---|
| 以为懂 rebase,一用就冲突 | rebase 冲突解决过一次就不怕 |
| 知道 reflog 能救命,但没真救过 | 找回过一次丢掉的提交,心里有底 |
| cherry-pick 听过没用过 | 挑过一次提交,手法清楚了 |
| revert 和 reset 分不清 | 实际用过,知道一个加提交一个改指针 |
1.3 实验规则
| 规矩 | 原因 |
|---|---|
| 用可丢弃目录 | 不怕点错 |
| 虚构身份练手 | 不泄露隐私 |
每步后 git status 或 git log | 知道自己在哪 |
| 卡住时回查对应章节 | 别瞎试 |
| 不在生产仓库练 | 这些命令威力大,别碰真作业 |
| 每个实验做完自检通过标准 | 不是敲完就行,要确认结果对 |
2. 实验前准备
找一个可以随便删的目录,比如 ~/lab-advanced/:
mkdir -p ~/lab-advanced
cd ~/lab-advanced
git --version
git version 2.43.0
本系列样例验证环境。你的版本接近即可。
设个虚构身份,省得真信息混进练习历史:
git config --global user.name "Ada Example"
git config --global user.email "ada@example.com"
你也可以只在每个实验仓库里设 local 配置,避免改全局。
准备就绪,下面开始七组实验。
3. 实验组 A:reflog 恢复实验
目标: 故意用 reset --hard 丢掉提交,再用 reflog 找回来。
场景
你手贱敲了 git reset --hard HEAD~2,两个提交「消失」了。别慌,reflog 记着呢。
步骤
- 建仓并做三次提交:
cd ~/lab-advanced
mkdir lab-a && cd lab-a
git init -b main
printf '第一行\n' > file.txt
git add file.txt
git commit -m "feat: 第一版"
printf '第二行\n' >> file.txt
git add file.txt
git commit -m "feat: 加第二行"
printf '第三行\n' >> file.txt
git add file.txt
git commit -m "feat: 加第三行"
- 看一下现在的历史,记住最新的那个哈希:
git log --oneline
- 模拟手贱,硬重置回两个提交前:
git reset --hard HEAD~2
git log --oneline
只剩一条了。file.txt 也只剩第一行。
- 用 reflog 看你走过的路:
git reflog
你会看到每一项都有哈希和操作说明。找到刚才「加第三行」那条记录,记下它的哈希(比如 a1b2c3d)。
- 恢复到那个提交:
git reset --hard a1b2c3d
git log --oneline
cat file.txt
三个提交全都回来了,file.txt 也恢复了三行。
预期结果
reflog里能看到reset: moving to HEAD~2这条记录- 恢复后
git log --oneline是三条 file.txt内容是三行
通过标准
- 能指出
reflog里哪一行是reset前的提交 - 不看资料能独立用
reflog找回丢失的提交 - 理解
reflog是「操作日志」,不是提交历史
容易卡的地方
| 卡点 | 回查 |
|---|---|
| 不知道找哪个哈希 | reflog 输出里找 feat: 加第三行 那行 |
| 恢复后还是旧的 | 检查是不是用了 --hard,不是 --soft |
| reflog 里没有记录 | 确认你确实做过 reset 操作 |
4. 实验组 B:rebase 整理实验
目标: 用交互式 rebase 把多个碎提交合并成一个干净的提交。
场景
你在功能分支上做了五个碎提交:「加文件」「改错字」「又改」「再改」「终于对了」。合并前想把它们捏成一个体面的提交。
步骤
- 建仓并做五个碎提交:
cd ~/lab-advanced
mkdir lab-b && cd lab-b
git init -b main
printf 'base\n' > app.py
git add app.py
git commit -m "init: 初始化"
git switch -c feature/nice
- 连续五个碎提交:
printf 'def hello():\n pass\n' > app.py
git add app.py
git commit -m "wip: 加函数框架"
printf 'def hello():\n print("hi")\n' > app.py
git add app.py
git commit -m "wip: 加了打印"
printf 'def hello():\n print("hello")\n' > app.py
git add app.py
git commit -m "wip: 改成 hello"
printf 'def hello():\n print("hello")\n\nhello()\n' > app.py
git add app.py
git commit -m "wip: 加调用"
printf 'def hello():\n print("hello world")\n\nhello()\n' > app.py
git add app.py
git commit -m "wip: 最终版"
- 看一下现在的历史:
git log --oneline
五个 wip 开头的碎提交。
- 开始交互式 rebase,把最近五个提交合并:
git rebase -i HEAD~5
编辑器会弹出来,长这样:
pick abc1234 wip: 加函数框架
pick def5678 wip: 加了打印
pick ghi9012 wip: 改成 hello
pick jkl3456 wip: 加调用
pick mno7890 wip: 最终版
- 把后面四个
pick改成squash(或简写s),意思是压进前一个提交:
pick abc1234 wip: 加函数框架
squash def5678 wip: 加了打印
squash ghi9012 wip: 改成 hello
squash jkl3456 wip: 加调用
squash mno7890 wip: 最终版
保存关闭编辑器。
- 接着弹出第二个编辑器让你写提交说明,改成:
feat: 加 hello 函数并调用
保存关闭。
- 看结果:
git log --oneline
五个碎提交变成了一个干净的提交。
预期结果
git log --oneline只有一条feat: 加 hello 函数并调用- 代码内容跟
rebase前一模一样 - 历史变干净了
通过标准
- 独立完成交互式
rebase合并提交 - 理解
pick和squash的区别 - 知道
rebase会改写历史(哈希会变)
容易卡的地方
| 卡点 | 回查 |
|---|---|
| 编辑器不会操作 | vi 里按 i 进入编辑,改完按 Esc,输入 :wq 保存退出 |
| rebase 中途改错了 | git rebase --abort 放弃重来 |
| 不知道选几个 | 从第一个 pick 往后数,都要合进去就都改成 squash |
| 冲突了怎么办 | 先解决冲突,再 git add,再 git rebase --continue |
5. 实验组 C:cherry-pick 选菜实验
目标: 从另一个分支挑选一个特定的提交,移植到当前分支。
场景
队友在 feature/ui 分支上修了一个 bug,你也需要这个修复合到你的 main 上,但不需要他分支上的其他东西。
步骤
- 建仓,主线上做两步:
cd ~/lab-advanced
mkdir lab-c && cd lab-c
git init -b main
printf 'v1\n' > core.py
git add core.py
git commit -m "feat: 核心功能 v1"
printf 'v2\n' > core.py
git add core.py
git commit -m "feat: 核心功能 v2"
- 开一个功能分支,做三个提交(其中一个是我们想要的):
git switch -c feature/ui
printf '样式1\n' > style.css
git add style.css
git commit -m "feat: 加样式文件"
printf '紧急修复\n' > fix.txt
git add fix.txt
git commit -m "fix: 关键补丁"
printf '样式2\n' >> style.css
git add style.css
git commit -m "feat: 更多样式"
- 找到「关键补丁」的哈希:
git log --oneline
记下 fix: 关键补丁 那行的哈希(比如 x1y2z3a)。
- 切回主线,只挑这个提交:
git switch main
git cherry-pick x1y2z3a
- 看结果:
git log --oneline
cat fix.txt
主线历史里出现了 fix: 关键补丁,但 style.css 不在。你只挑了你想要的。
预期结果
- 主线历史里多了一条
fix: 关键补丁 fix.txt出现了,但style.css没有- cherry-pick 的提交哈希跟原始的不同(因为父提交变了)
通过标准
- 独立用
cherry-pick挑选指定提交 - 理解 cherry-pick 是「复制内容,生成新提交」而不是「移动提交」
- 知道挑过来的提交哈希会变
容易卡的地方
| 卡点 | 回查 |
|---|---|
| 挑过来有冲突 | 跟合并冲突一样解决:改文件、add、继续 |
| 找不到哈希 | 回原分支 git log --oneline 查 |
| 挑错了一个 | git cherry-pick --abort 放弃,或 git reset --hard HEAD~1 退回 |
6. 实验组 D:revert 公开撤回实验
目标: 用 revert 安全地撤销一个已经推送的提交,不加掩饰地留痕。
场景
你昨天推了一个有问题的提交到 main,同事已经拉了。你不能改历史,只能加一个「反提交」来撤回。
步骤
- 建仓并做三次提交:
cd ~/lab-advanced
mkdir lab-d && cd lab-d
git init -b main
printf '功能A\n' > a.txt
git add a.txt
git commit -m "feat: 功能A"
printf '功能B(有bug)\n' > b.txt
git add b.txt
git commit -m "feat: 功能B(有bug)"
printf '功能C\n' > c.txt
git add c.txt
git commit -m "feat: 功能C"
- 看一下当前历史:
git log --oneline
- 发现「功能B」有 bug,用 revert 撤回:
git revert HEAD~1
编辑器弹出提交说明,默认是 Revert "feat: 功能B(有bug)",直接保存退出即可。
- 看结果:
git log --oneline
ls
cat b.txt 2>/dev/null || echo "b.txt 内容被撤回了"
历史里多了一条 Revert 提交,b.txt 的内容被还原了。原来的 feat: 功能B 提交还在历史里,没被删掉。
- 对比
reset和revert的区别:
| 操作 | 改不改历史 | 适合场景 |
|---|---|---|
reset | 改历史,提交没了 | 只在本地、没推送过 |
revert | 不改历史,加反提交 | 已推送、别人拉过了 |
预期结果
git log --oneline里能看到原始提交和 revert 提交- 被撤回的提交内容被还原
b.txt不再包含原来的内容
通过标准
- 独立用
revert撤回一个提交 - 能说清
revert和reset --hard的区别 - 理解为什么已推送的提交要用
revert而不是reset
容易卡的地方
| 卡点 | 回查 |
|---|---|
| revert 有冲突 | 说明要撤回的提交跟后面的提交改了同一块,先解决冲突再 add + git revert --continue |
| 不知道该用 revert 还是 reset | 已推送的用 revert,还没推的可以用 reset |
| 撤回的是哪个提交 | HEAD~1 表示当前提交的上一条,也可以用哈希 |
7. 实验组 E:stash 救急实验
目标: 代码写到一半,突然要切分支修 bug。先把改动藏起来,修完再取回来。
场景
你在 feature/new-ui 分支写功能,改到一半产品经理喊你修线上 bug。代码还不能提交,但切分支又被挡住。
步骤
- 建仓,在功能分支上写一半:
cd ~/lab-advanced
mkdir lab-e && cd lab-e
git init -b main
printf '主程序\n' > main.py
git add main.py
git commit -m "feat: 主程序"
git switch -c feature/new-ui
printf '新界面(还没写完)\n' > ui.py
git add ui.py
printf '更多内容\n' >> main.py
- 此时你既没提交,又有未暂存的改动:
git status
- 来活了,需要切到
main修 bug。先把改动藏起来:
git stash push -m "新界面写了一半"
git status
工作区干干净净。
- 切到
main,修 bug:
git switch main
printf '主程序(修了bug)\n' > main.py
git add main.py
git commit -m "fix: 紧急修bug"
- 修完了,切回功能分支,把改动取回来:
git switch feature/new-ui
git stash list
git stash pop
git status
刚才写到一半的改动全回来了。
- 继续写完并提交:
git add ui.py main.py
git commit -m "feat: 完成新界面"
预期结果
stash后工作区干净stash pop后改动回来了- 修 bug 的提交在
main上,功能开发的提交在分支上
通过标准
- 独立用
stash保存和恢复半成品 - 理解
stash push和stash pop是一对 - 知道
stash list能看多个贮藏
容易卡的地方
| 卡点 | 回查 |
|---|---|
| pop 有冲突 | 跟之前一样解决冲突 |
| stash 里好多条 | git stash list 看编号,git stash pop stash@{2} 取指定条 |
| 忘了 pop 就继续写 | 先 pop 再继续,别在 stash 还没取时又攒新改动 |
8. 实验组 F:bisect 查案实验
目标: 用 git bisect 二分查找,定位第一个引入 bug 的提交。
场景
你知道项目上周还没 bug,今天有了。中间有十几个提交,不知道是哪个搞坏的。一个个试太慢,二分查找最快。
步骤
- 建仓,做一系列提交(其中一个是「坏提交」):
cd ~/lab-advanced
mkdir lab-f && cd lab-f
git init -b main
# 提交 1:好的
printf 'ok\n' > check.py
git add check.py
git commit -m "第1次提交:正常"
# 提交 2:好的
printf 'ok\nok\n' >> check.py
git add check.py
git commit -m "第2次提交:正常"
# 提交 3:坏的(偷偷改了状态)
printf 'ok\nok\nbad\n' > check.py
git add check.py
git commit -m "第3次提交:引入bug"
# 提交 4:坏的状态延续
printf 'ok\nok\nbad\nmore\n' > check.py
git add check.py
git commit -m "第4次提交:还在坏"
# 提交 5:坏的状态延续
printf 'ok\nok\nbad\nmore\nstuff\n' > check.py
git add check.py
git commit -m "第5次提交:还是坏"
- 写一个判断脚本:如果
check.py里有bad就是坏提交,否则是好提交:
printf '#!/bin/bash\nif grep -q bad check.py; then\n exit 1\nelse\n exit 0\nfi\n' > test.sh
chmod +x test.sh
- 开始二分查找:
git bisect start
git bisect bad HEAD
git bisect good HEAD~4
- 让 bisect 自动跑:
git bisect run ./test.sh
- 它会自动切提交、跑脚本、二分缩小范围,直到找到:
第3次提交:引入bug is the first bad commit
- 结束 bisect,回到原来的状态:
git bisect reset
预期结果
- bisect 自动定位到
第3次提交:引入bug - 不用逐个手动测试
- 结束后回到正常工作状态
通过标准
- 独立用
bisect找到第一个坏提交 - 理解二分查找的思路:好/坏标记让范围减半
- 知道用完要
git bisect reset
容易卡的地方
| 卡点 | 回查 |
|---|---|
| 不知道哪个是好哪个是坏 | 你确定的好提交用 git bisect good,坏的用 git bisect bad |
| 脚本报错 | 确认脚本有执行权限,且在仓库根目录运行 |
| 中途想放弃 | git bisect reset 回到开始前的状态 |
| 手动标记太慢 | 用 git bisect run 自动化 |
9. 实验组 G:冲突解决实验
目标: 亲手制造一次冲突,读明白冲突标记,手动解决它。
场景
两个人同一段代码改了不同的东西,合并时必冲突。这不是灾难,这是 Git 在保护你。
步骤
- 建仓,做一个基础版本:
cd ~/lab-advanced
mkdir lab-g && cd lab-g
git init -b main
printf '第一行\n第二行\n第三行\n' > data.txt
git add data.txt
git commit -m "init: 基础版本"
- 开分支 A,改第二行:
git switch -c branch-a
printf '第一行\nA改的行\n第三行\n' > data.txt
git add data.txt
git commit -m "feat: 分支A改第二行"
- 切回主线,也改第二行(不同内容):
git switch main
printf '第一行\nB改的行\n第三行\n' > data.txt
git add data.txt
git commit -m "feat: 主线也改第二行"
- 合并,必然冲突:
git merge branch-a -m "合并 branch-a"
- 看 Git 标记的冲突:
cat data.txt
你会看到:
第一行
<<<<<<< HEAD
B改的行
=======
A改的行
>>>>>>> branch-a
第三行
三段标记的含义:
| 标记 | 内容 |
|---|---|
<<<<<<< HEAD 到 ======= | 你当前分支的版本 |
======= 到 >>>>>>> branch-a | 要合并进来的版本 |
| 其他行 | 双方没分歧的行 |
- 手动解决:两边都留着:
printf '第一行\nB改的行\nA改的行\n第三行\n' > data.txt
- 确认没有残留标记:
grep '<<<<<<' data.txt || echo "没有残留标记"
grep '>>>>>>' data.txt || echo "没有残留标记"
- 提交解决结果:
git add data.txt
git commit -m "合并 branch-a(已解决冲突)"
- 看最终历史:
git log --oneline --graph --all
预期结果
- 合并时报冲突
- 打开文件能看到
<<<<<<<、=======、>>>>>>>三段标记 - 手动改完后提交成功
- 图上有
|\合并形状
通过标准
- 能解释三段标记各是谁的版本
- 提交前搜过
<<<<<<<确保没残留 - 独立解决冲突并提交
- 不害怕冲突提示
容易卡的地方
| 卡点 | 回查 |
|---|---|
| 改完忘了 add | 解决冲突后必须 git add 再 commit |
| 标记没删干净 | 提交前务必 grep 检查 |
| 想放弃合并 | git merge --abort 回到合并前 |
| 不知道该留哪边 | 看场景决定,也可以两边都留 |
10. 自评表
做完七组实验后,对照自评:
| 能力 | 通过标准 |
|---|---|
| reflog | 不看资料能找回 reset --hard 丢掉的提交 |
| rebase | 用交互式 rebase 合并过碎提交 |
| cherry-pick | 从别的分支挑选过指定提交 |
| revert | 安全撤回过已推送的提交,说清与 reset 区别 |
| stash | 写到一半切分支修过 bug,改完取回来 |
| bisect | 用二分法定位过第一个坏提交 |
| 冲突解决 | 独立解决过一次冲突,没残留标记 |
| 安全意识 | 没在真仓库练破坏性命令 |
11. 卡住时怎么办
| 症状 | 去哪查 |
|---|---|
| 提交丢了 | 第 16 章 reflog |
| rebase 中途想放弃 | git rebase --abort |
| cherry-pick 有冲突 | 跟合并冲突一样解决 |
| revert 有冲突 | 同上 |
| stash pop 冲突 | 同上 |
| bisect 找不准 | 确认好/坏标记对了没 |
| 冲突标记没删 | grep '<<<<<<' . -r 全局搜 |
12. 学完之后
做完这七组实验,你已经把高级操作 练过手了。接下来:
- 回到你的真项目,遇到问题不慌了
- 需要深挖原理,回顾 09–14 章
- 遇到没见过的场景,先
git status看清楚状态
记住:可丢弃目录是你最好的老师。 任何操作不确定,先在玩具仓库试一遍。
13. 常见问题
问 1:实验能跳过吗? 能跳,但高级操作不练手永远停留在「知道但不会」的阶段。建议至少做 A、B、G 三组。
问 2:实验做坏了怎么办? 删掉重来。本来就是可丢弃目录。
问 3:rebase 和 merge 到底用哪个? 个人分支整理用 rebase,多人协作的公共分支用 merge。原则:别改别人已经拉走的历史。
问 4:revert 以后还能再 revert 回来吗? 能。revert 那个 revert 提交就行,或者手动把代码改回去再提交。
问 5:bisect 没有测试脚本怎么办?
手动标记也行:git bisect start,然后每到一步你手动看代码,好就 git bisect good,坏就 git bisect bad。
问 6:stash 里存了多个,怎么取指定的?
git stash list 看编号,git stash apply stash@{1} 取指定的但不删,git stash pop stash@{1} 取并删。
问 7:cherry-pick 跟 merge 有什么区别? cherry-pick 只拿一个提交,merge 把整个分支合进来。一个是点菜,一个是自助餐。
问 8:实验里哈希和书里不一样正常吗? 正常。哈希每次都不同,看结构和说明。
14. 总结、学习路线与思维升华
14.1 这一章请记住的
| 点 | 记住什么 |
|---|---|
| 实验 | 高级操作不动手等于没学 |
| 七组实验 | reflog / rebase / cherry-pick / revert / stash / bisect / 冲突解决 |
| 自评 | 对照表打勾 |
| 心态 | 不怕改坏,可丢弃目录随便折腾 |
| 安全 | 已推送用 revert,未推的用 reset,共享分支不改历史 |
14.2 在整个系列中的位置
01–07 基础
08 新手实验 ← 第一轮动手
09–14 原理
15–23 进阶操作
24 高级实验 ← 当前(进阶动手)
14.3 思维升华
把高级操作练成肌肉记忆,遇到真问题才不慌。 丢提交想 reflog,整理历史想 rebase,挑提交想 cherry-pick,公开撤回想 revert,写一半想 stash,找 bug 想 bisect,冲突不慌手动删标记。 每个操作你都做过一次,第二次就不会手生。
14.4 参考资料
以 Git 2.43.0 验证。演示身份:Ada Example <ada@example.com>。
- Pro Git 中文版 — 重写历史
- Pro Git 中文版 — 使用 Git 调试
- Pro Git 中文版 — 贮藏与清理
- Git 文档 — git-rebase
- Git 文档 — git-cherry-pick
- Git 文档 — git-bisect
- 图示署名:
assets/diagrams/ATTRIBUTION.md
14.5 本章检查清单
- 实验组 A:用 reflog 找回过丢掉的提交
- 实验组 B:交互式 rebase 合并过碎提交
- 实验组 C:cherry-pick 挑过指定提交
- 实验组 D:revert 撤回过提交,说清与 reset 区别
- 实验组 E:stash 保存并取回过半成品
- 实验组 F:bisect 定位过第一个坏提交
- 实验组 G:独立解决过一次冲突,无残留标记
- 自评表至少六项打勾