Git特种作战:stash、cherry-pick、bisect三件套
# 第5章 · 特种作战
# 一、中断开发
下午 4 点 20 分,小李正在实现一个复杂的「数据导出功能」。他改了 5 个文件、新增了一个 export.js、重构了 data.js 里一半的函数。代码现在完全跑不起来——因为导出功能还差最后两步。
突然 Leader 的消息弹出来:
"线上支付回调接口挂了,所有订单都无法支付。紧急!马上修!"
小李看着终端里的 git status:
Changes not staged for commit:
modified: data.js
modified: user.js
modified: report.js
modified: config.js
modified: utils.js
Untracked files:
export.js
怎么办?
- ❌ 直接 commit?不行,代码根本跑不起来,commit 一个 broken commit 是团队耻辱。
- ❌ 直接切分支?不行,Git 会阻止——工作区有未保存的改动,切分支会覆盖它们。
- ❌ 全部丢掉?不行,写了两个小时的心血。
- ❌ 把整个目录复制一份?第 3 章已经证明这是低效灾难。
这就是本章要解决的问题:当你的工作区处于「不能 commit 又不能丢」的尴尬状态时,Git 给了你三件特种作战武器。
# 二、暂停手艺
# 2.1 stash 做了什么?
你的工作区(杂乱) stash 栈
┌────────────────────┐ ┌─────────────┐
│ data.js (已修改) │ stash │ 存起来 ✅ │
│ export.js (新文件) │ ───────→ │ │
│ config.js (已修改) │ ├─────────────┤
└────────────────────┘ │ │
git status: clean 🎉 └─────────────┘
git stash 的底层逻辑:把你工作区和暂存区的所有改动,打包压缩成一个临时 commit,存到 stash 栈里。然后工作区恢复到上一次 commit 的干净状态。
# 2.2 实战:中断开发 → 修紧急 bug → 恢复现场
mkdir stash-lab && cd stash-lab && git init
# 模拟已有的项目基础
echo "function pay() { /* 支付逻辑 */ }" > payment.js
echo "function login() { /* 登录逻辑 */ }" > user.js
git add . && git commit -m "init: 基础功能"
# ==================== 小李正在开发导出功能 ====================
# 改了一大堆文件
echo "function exportData() { /* 导出数据 - 未完成 */ }" > export.js
echo "// 重构数据模块" >> payment.js
echo "function exportUser() {}" >> user.js
echo "const EXPORT_CONFIG = { debug: true };" > config.js
git add config.js # config.js 已经 add 了
# export.js 是不跟踪的新文件
git status
#输出:
# Changes to be committed:
# new file: config.js
# Changes not staged for commit:
# modified: payment.js
# modified: user.js
# Untracked files:
# export.js
# ==================== 🚨 紧急 bug 来了! ====================
# Step 1:连不跟踪的文件一起保存
git stash --include-untracked # -u 缩写,保存新文件
# 或:git stash -u -m "导出功能开发到一半"
git status
# 输出:nothing to commit, working tree clean
# 🎉 工作区干净了!导出的代码全部安全保存
# Step 2:查看 stash 列表
git stash list
# 输出:stash@{0}: On master: 导出功能开发到一半
# Step 3:切分支,修 bug
git switch -c hotfix/支付回调修复
# 修复线上支付回调
cat > payment.js << 'EOF'
function pay(orderId) {
// 修复:回调地址编码问题
const callback = encodeURIComponent('/api/callback');
console.log('支付成功,回调地址:', callback);
return { success: true };
}
EOF
git add . && git commit -m "hotfix: 修复支付回调地址编码问题"
# Step 4:合并回 master
git switch master
git merge --no-ff hotfix/支付回调修复 -m "merge: 合并支付回调 hotfix"
# Step 5:回到 feature 分支,恢复现场!
git stash pop
# 输出类似:
# On branch master
# Changes not staged for commit:
# ...
# Dropped refs/stash@{0} (abc123...) ← stash 被清掉了
git status
# 🎉 导出功能的代码全回来了!和 stash 前一模一样
# 2.3 stash 的高频操作
# 查看 stash 列表
git stash list
# 只保存已跟踪的文件(不保存新文件)
git stash # 快捷但不安全,新文件会丢
# 保存所有(含新文件和忽略文件)
git stash --include-untracked # 推荐!-u 缩写
git stash --all # 连 .gitignore 里的也存
# 带备注
git stash push -m "导出功能开发到一半,先修支付回调"
# 恢复最新的 stash 并删除
git stash pop
# 恢复最新的 stash 但不删除
git stash apply
# 恢复指定的 stash
git stash apply stash@{2}
# 删除某个 stash
git stash drop stash@{1}
# 清空所有 stash
git stash clear
# 查看 stash 里存了什么
git stash show -p stash@{0}
# -p 显示详细 diff
# 2.4 stash 的进阶用法:部分保存
# 只 stash 某个文件
git stash push -m "只存 config.js" -- config.js
# 只 stash 已暂存的文件(保留工作区未暂存的)
git stash --keep-index
💡 场景映射:
stash不仅用于「中断开发去修 bug」,还适用于:
- 你改着改着发现方向错了,想先把当前改动存起来,试试别的方案
- 你 pull 之前本地有改动,不想 commit,先 stash → pull → pop
- 你改出了 bug 不确定是哪次改动引起的,stash 一部分 → 测试 → 二分定位
# 三、精准移植
# 3.1 cherry-pick 解决什么问题?
在第 3 章,我们学了 merge——把一个分支的所有改动合过来。在第 4 章,我们学了 rebase——把你分支的所有 commit 搬到别人后面。
但如果我只想要一个 commit,不要其他的呢?
- 你在 hotfix 分支上修了一个 bug,这个修复需要同步到 main 和 develop 两个分支。但 hotfix 上有 3 个 commit,只有一个跟这个 bug 相关。
- 同事在他的实验分支上写了一个非常棒的工具函数,你想拿到你的 feature 分支用。但那个实验分支上还有一堆你不需要的东西。
这就是 cherry-pick 的场景:从一堆樱桃里,只摘你想要的这一颗。
# 3.2 动手:把 hotfix 的修复移植到 develop
mkdir cherry-lab && cd cherry-lab && git init
# 模拟主线开发
echo "const app = { version: '1.0' };" > app.js
git add . && git commit -m "init: v1.0"
# 线上报了个 bug,hotfix 分支修复
git switch -c hotfix/紧急修复
echo "function safeJSONParse(str) { try { return JSON.parse(str); } catch { return null; } }" >> app.js
git add . && git commit -m "fix: 添加安全 JSON 解析,防止崩溃"
# hotfix 上还做了一些其他改动(跟 bug 无关)
echo "// 日志临时调试" >> app.js
git add . && git commit -m "tmp: 加一些调试日志后面删" # 这个不要移植
echo "const DEBUG = process.env.NODE_ENV !== 'production';" >> app.js
git add . && git commit -m "chore: 配置环境变量" # 这个也不要
# 现在 hotfix 上有 3 个 commit
git log --oneline
# 输出:
# c3c3c3c chore: 配置环境变量
# b2b2b2b tmp: 加一些调试日志后面删
# a1a1a1a fix: 添加安全 JSON 解析,防止崩溃
# ==================== 回到 develop 分支 ====================
git switch -c develop
# develop 上也有一些自己的改动
echo "function fetchData() { /* 获取数据 */ }" >> app.js
git add . && git commit -m "feat: 添加数据获取函数"
git log --oneline --graph --all
# 输出:
# * d4d4d4d (develop) feat: 添加数据获取函数
# | * c3c3c3c (hotfix/紧急修复) chore: 配置环境变量
# | * b2b2b2b tmp: 加一些调试日志后面删
# | * a1a1a1a fix: 添加安全 JSON 解析,防止崩溃
# |/
# * 0000000 (master) init: v1.0
# ==================== 🎯 cherry-pick! ====================
# 我只要 a1a1a1a 那个「安全 JSON 解析」的修复
git cherry-pick a1a1a1a
# 输出:
# [develop e5e5e5e] fix: 添加安全 JSON 解析,防止崩溃
# Date: ...
# 1 file changed, 1 insertion(+)
git log --oneline --graph --all
# 输出:
# * e5e5e5e (develop) fix: 添加安全 JSON 解析,防止崩溃 ← 移植过来了!
# * d4d4d4d feat: 添加数据获取函数
# | * c3c3c3c (hotfix/紧急修复) chore: 配置环境变量
# | * b2b2b2b tmp: 加一些调试日志后面删
# | * a1a1a1a fix: 添加安全 JSON 解析,防止崩溃 ← 原始 commit
# |/
# * 0000000 (master) init: v1.0
注意:cherry-pick 过来的 commit 有新的 hash(e5e5e5e ≠ a1a1a1a),但改动内容一样。
# 3.3 cherry-pick 的进阶操作
# 移植多个不连续的 commit
git cherry-pick aabbcc ddeeff
# 移植一个连续范围的 commit(不含起点,含终点)
git cherry-pick a1a1a1a..c3c3c3c
# 移植 b2b2b2b 和 c3c3c3c,不含 a1a1a1a
# 移植一个连续范围(含起点)
git cherry-pick a1a1a1a^..c3c3c3c
# 只取改动不自动提交(取过来先放着,改完再 commit)
git cherry-pick -n a1a1a1a
# -n = --no-commit
# 移植时顺便修改 commit message
git cherry-pick -e a1a1a1a
# -e = --edit
# 如果冲突了
# → 手动解决冲突文件
# → git add <冲突文件>
# → git cherry-pick --continue
# 如果不想继续了
git cherry-pick --abort
# 跳过当前冲突的 commit,继续下一个
git cherry-pick --skip
# 3.4 cherry-pick vs merge:什么时候用谁?
| 场景 | 用什么 | 原因 |
|---|---|---|
| 整个功能分支合入主分支 | merge | 保留完整上下文 |
| 单个 bug 修复需要同步到多个分支 | cherry-pick | 只要这一个修复,不要别的 |
| 把同事某个实验分支的工具函数拿过来 | cherry-pick | 只要那个函数,不要他实验的垃圾 |
| 发布分支上打补丁 | cherry-pick | main 上修了,需要同步到 release 分支 |
# 四、二分定位
# 4.1 bisect 解决什么问题?
你的项目有 200 个 commit。v2.0 跑得好好的,v2.1 出了个 bug——某个接口偶尔返回 500。你看了半天代码,200 个 commit 每个都可能有问题。
逐个人工检查 200 个 commit? 今晚不用回家了。
二分查找来自动定位! 你告诉 Git「commit #50 是好的」「commit #200 是坏的」,Git 自动帮你跳到第 125 个——测试一下,好的?那 bug 在 125 后面。坏的?那 bug 在 125 前面。最多 log₂(150) ≈ 8 次测试就能定位。
# 4.2 实战:二分法找出谁搞坏了 getTotalPrice
mkdir bisect-lab && cd bisect-lab && git init
# 构建 8 个 commit 的模拟项目
echo 'const app = "Shop";' > app.js
git add . && git commit -m "v1: 项目初始化" # commit 1
echo 'function login() { return true; }' >> app.js
git add . && git commit -m "v2: 登录" # commit 2
echo 'function logout() {}' >> app.js
git add . && git commit -m "v3: 登出" # commit 3
echo 'let total = 0;' >> app.js
echo 'function addToTotal(n) { total += n; }' >> app.js
git add . && git commit -m "v4: 购物车总计" # commit 4 ✅
echo 'function discount(rate) { total *= rate; }' >> app.js
git add . && git commit -m "v5: 折扣" # commit 5 ✅
echo '// 重构总计逻辑' >> app.js
# 🔴 这里引入了一只隐晦 bug:把 total 的计算方式改了
sed -i '' 's/total += n/total = n/' app.js # 把 += 改成 =,导致 total 变成最后一个值
git add . && git commit -m "v6: 重构计数逻辑" # commit 6 💀 bug 在这!
echo 'function clearTotal() { total = 0; }' >> app.js
git add . && git commit -m "v7: 清空购物车" # commit 7 💀
echo 'function getTotal() { return total; }' >> app.js
git add . && git commit -m "v8: 获取总计" # commit 8 💀
# 现在 v4、v5 是好的,v6、v7、v8 是坏的
# 但你不知道 bug 在哪次引入的!
开始二分定位:
# Step 1:开始二分查找
git bisect start
# Step 2:标记当前版本是坏的(v8)
git bisect bad HEAD
# 或:git bisect bad v8
# Step 3:标记 v5 是好的(你确认过 v5 没问题)
git log --oneline
# 假设 v5 的 hash 是 5555555
git bisect good 5555555
# Step 4:Git 自动跳到中间位置(v6 和 v7 之间)
# Git 输出:
# Bisecting: 1 revision left to test after this (roughly 1 step)
# [6666666] v6: 重构计数逻辑
# 现在你在 commit 6(v6: 重构计数逻辑)
# 测试一下:v6 有没有 bug?
# 你可以手动测试:
node -e "
let total = 0;
function addToTotal(n) { total = n; } // 从 app.js 拷贝
addToTotal(100);
addToTotal(200);
console.log(total); // 输出 200 ← 应该是 300!bug 在!
"
# 确认 v6 是坏的
git bisect bad
# Step 5:Git 继续缩小范围
# 只剩下 v5(好)和 v6(坏)之间的一个版本了
# Bisecting: 0 revisions left to test after this
# [5555555] v6: 重构计数逻辑
# 🎯 定位完成!
# Git 输出:
# 6666666 is the first bad commit
# commit 6666666
# Author: 杨充 <yangchong@example.com>
# Date: ...
#
# v6: 重构计数逻辑
#
# app.js | 2 +-
# 1 file changed, 1 insertion(+), 1 deletion(-)
Git 精准找到了那个把 += 改成 = 的 commit!
# 4.3 退出 bisect 模式
git bisect reset
# 回到 bisect 之前的状态
# 4.4 bisect 的自动化(有测试用例时更强大)
# 如果你有单元测试,一条命令自动跑完所有二分步骤:
git bisect start HEAD v5
git bisect run npm test
# Git 会自动在每个 commit 上执行 npm test
# test 通过 → 标记 good
# test 失败 → 标记 bad
# 最终输出第一个坏的 commit
# 完成!
git bisect reset
💡 bisect 的强大之处:不管项目有多少个 commit,定位时间都是 O(log N)。1000 个 commit 只需要约 10 次测试。这个经典算法思想(二分查找)被用在几乎所有调试场景中。
# 五、追溯责任
# 5.1 追责不是目的,定位上下文才是
git blame app.js
# 输出:
# 0000000 (杨充 2025-06-06 10:00:00 +0800 1) const app = "Shop";
# 2222222 (杨充 2025-06-06 10:01:00 +0800 2) function login() { return true; }
# 3333333 (杨充 2025-06-06 10:02:00 +0800 3) function logout() {}
# ...
# 6666666 (杨充 2025-06-06 10:05:00 +0800 6) function addToTotal(n) { total = n; }
输出解读:每一行前面都标注了「谁 + 什么时候 + 哪个 commit」最后改了它。
# 5.2 常用选项
# 只看某个范围的行
git blame -L 5,10 app.js
# 看特定 commit 版本的 blame
git blame <commit-hash> app.js
# 忽略空白改动(只追踪实质修改)
git blame -w app.js
# 追踪代码移动(某行是从哪个文件拷过来的)
git blame -M app.js
# 更激进的移动追踪
git blame -C app.js
# 5.3 真实应用场景
# 场景1:代码里有个奇怪的写法,你想知道当时为什么这么写
git blame app.js -L 6,6
# 找到 commit hash → 看 commit message → 理解历史背景
# 场景2:线上 bug 排查——这行报错代码是谁改的
git blame -L 15,15 src/api/user.js
# 找到最近一次修改 → git show 那个 commit → 确认是功能改动还是错误引入
💡 blame 的正确心态:不是用来「追责批斗」,而是用来「理解上下文」。看到一行代码看不懂为什么这么写,blame 找到 commit → 看 commit message → 可能还关联一个 PR → 里面有人讨论过为什么选这个方案。这比删了重写安全得多。
# 六、终极救生艇
第 2 章我们体验了 reflog 的最基本用法(reset --hard 后找回)。这里补充两个高阶场景。
# 6.1 找回误删的分支
# 模拟:手滑删了一个还在开发中的分支
git branch
# develop
# * feature/新支付
git switch develop
git branch -D feature/新支付 # 💀 没合并就删了!
# 找回!
git reflog --date=iso
# 找到 feature/新支付 被删之前指向的 commit
# 输出类似:
# abcdef1 HEAD@{3}: checkout: moving from feature/新支付 to develop
# ↑ 看到它了!
git switch -c feature/新支付 abcdef1
# 🎉 分支回来了,代码全在!
# 6.2 找回 rebase 之前的原始 commit
# 你做了 rebase,觉得不满意,想回到 rebase 之前
git reflog
# 找到 rebase 之前的 ORIG_HEAD 或 HEAD@{n}
git reset --hard HEAD@{5}
# 🎉 回到 rebase 之前,一切照旧
# 七、综合实战
# 场景设定
你是小李,上午有三件事:
- 正在开发「导出报告」功能(还没写完)
- 线上报了个 bug——用户搜索时输入特殊字符会崩溃(需要 cherry-pick 到 develop 和 release 两个分支)
- 一个历史遗留 bug:某个接口偶尔超时,不知道是哪次 commit 引入的
用今天学的三件套,逐个击破。
# 实战开始
# ==================== Phase 1:环境准备 ====================
mkdir spec-ops && cd spec-ops && git init
echo 'function search(keyword) {
return db.query("SELECT * FROM products WHERE name LIKE '%" + keyword + "%'");
}' > search.js
echo '// 购物车模块
const cart = [];' > cart.js
echo 'module.exports = { search, cart };' > index.js
git add . && git commit -m "init: 项目初始化"
# 创建 develop 分支和 release 分支
git branch develop
git branch release
# develop 上开发了一些新功能
git switch develop
echo 'function exportReport() { /* TODO */ }' > report.js
git add . && git commit -m "feat: 导出报告占位"
# 模拟线上分支的演进
git switch master
echo '// 修复上一版 typo' >> search.js
git add . && git commit -m "fix: typo"
git switch release
echo '// release 分支补丁' >> search.js
git add . && git commit -m "chore: release 配置"
# ==================== Phase 2:事件一——开发中被叫去修 bug ====================
git switch develop
# 小李开始写导出报告的真正逻辑
cat > report.js << 'EOF'
function exportReport(format) {
// 支持导出为 CSV 和 PDF
if (format === 'csv') {
// 转换数据为 CSV 格式
let csv = '';
// 未完成:还需要把 JSON 转成 CSV 行
}
// TODO: PDF 格式
}
EOF
echo 'const REPORT_CONFIG = { maxRows: 10000 };' > report-config.js
git add report-config.js # 这个已经 add 了
git status
# 输出:
# Changes to be committed:
# new file: report-config.js
# Changes not staged for commit:
# modified: report.js
# 🚨 线上 bug:搜索特殊字符导致 SQL 注入!
# Step 1:保存现场
git stash --include-untracked -m "导出报告开发到一半,先去修搜索 SQL 注入"
# Step 2:创建 hotfix 分支
git switch -c hotfix/sql注入 master
# 注意:hotfix 应该从 master(线上分支)开出
# Step 3:修复
cat > search.js << 'EOF'
function search(keyword) {
// 修复:使用参数化查询,防止 SQL 注入
const sanitized = keyword.replace(/['";\\]/g, '');
return db.query("SELECT * FROM products WHERE name LIKE ?", [`%${sanitized}%`]);
}
EOF
git add . && git commit -m "hotfix: 修复搜索 SQL 注入漏洞"
# ==================== Phase 3:事件二——cherry-pick 到多个分支 ====================
# 这个修复需要同步到 develop 和 release
# 切到 develop,cherry-pick 过来
git switch develop
git cherry-pick $(git log hotfix/sql注入 --oneline | head -1 | awk '{print $1}')
# 如果冲突了(develop 的 search.js 可能跟 master 不同),手动解决
# 假设没冲突,继续
# 切到 release,同样 cherry-pick
git switch release
git cherry-pick $(git log hotfix/sql注入 --oneline | head -1 | awk '{print $1}')
# 现在三个分支都有了修复
# 合并 hotfix 到 master
git switch master
git merge --no-ff hotfix/sql注入 -m "merge: 合并搜索 SQL 注入修复"
# ==================== Phase 4:恢复导出报告开发 ====================
git switch develop
git stash pop
# 🎉 导出报告的代码全回来了!
git status
# 确认:report.js 已修改,report-config.js 在暂存区
# ==================== Phase 5:事件三——用 bisect 定位历史 bug ====================
# 产品经理反馈:某个 API 偶尔超时,大概是最近两周引入的
# 已知两周前(约 10 个 commit 之前)没问题
# 模拟:在历史里插入一个「超时 bug」...
# 先看看最近的提交
git log --oneline
# 假设输出:
# xxxxxxx hotfix 合并
# yyyyyyy feat: 导出报告占位
# zzzzzzz init: 项目初始化
# (这里用 bisect 的模拟场景已经足够说明用法,不再重复构建历史)
echo '
# 手动二分查找示范(概念):
#
# git bisect start
# git bisect bad HEAD
# git bisect good <两周前的commit>
#
# Git 自动跳到中间,你测试 → good/bad → Git 再跳到中间...
# 最多 log₂(提交数) 次测试就能定位
#
# git bisect reset # 退出二分模式
'
# 🎯 时间线回顾
9:00 AM 10:00 AM 10:15 AM 10:30 AM 11:00 AM
──────┼────────────────┼───────────────┼─────────────────┼────────────────┼──→
│ │ │ │ │
开始写导出报告 🚨 线上报 bug stash 保存现场 cherry-pick 到 stash pop
写到一半 搜索 SQL 注入 创建 hotfix 修复 develop+release 继续开发
三件套的协作模式:
stash让你随时中断、随时恢复;cherry-pick让你精准同步单个修复到多个分支;bisect让你在几百个 commit 里快速找到元凶。一个上午三个危机,全靠这三件套从容化解。
# 八、本章回顾
| 我学会了什么 | 一句话总结 |
|---|---|
stash | 把你的半成品打包暂存,还你一个干净的工作区 |
stash pop vs apply | pop 用完即删;apply 保留以便复用 |
stash -u | 务必加 -u,否则新创建的文件会被忽略 |
cherry-pick | 精准摘取一个 commit 到另一个分支 |
| cherry-pick 的冲突 | 同 merge:手动解决 → add → --continue |
| cherry-pick vs merge | 单 commit 用 cherry-pick;完整功能用 merge |
bisect | 二分法自动定位第一个引入 bug 的 commit |
bisect run | 有测试用例时,全自动二分定位 |
blame | 看每一行代码的最后修改人和时间 |
reflog | 30 天内的任何操作都有记录——删分支、rebase 都能救 |
🎯 核心习惯:
- 工作区不干净又不想 commit → 立刻
git stash -u -m "备注",不要带着半成品切分支。- 只同步一个修复到另一个分支 →
cherry-pick,不要盲目 merge 一堆东西。- 遇到不知道哪次提交引入的 bug →
git bisect,不要肉眼逐个人工排查。
🏃 下一章预告:个人技巧学完了。接下来是团队的战场——Git Flow 工作流。你会学到:
main / develop / feature / release / hotfix五条分支各自的分工、一次完整的发布流程、保护分支的配置、以及 Conventional Commits 规范。从「一个人能打」到「团队能战」。
# 📎 本章涉及的命令速查
# === stash(保存现场) ===
git stash # 只保存已跟踪文件(不推荐)
git stash -u # 推荐:含未跟踪文件
git stash -u -m "备注" # 带备注
git stash push -m "备注" -- <file> # 只 stash 指定文件
git stash list # 查看 stash 列表
git stash show -p stash@{0} # 查看 stash 里存了什么
git stash pop # 恢复 + 删除
git stash apply # 恢复 + 保留
git stash apply stash@{2} # 恢复指定的
git stash drop stash@{1} # 删除指定的
git stash clear # 清空所有
# === cherry-pick(精准移植) ===
git cherry-pick <hash> # 移植单个 commit
git cherry-pick <hash1> <hash2> # 移植多个不连续
git cherry-pick <hashA>..<hashB> # 移植连续范围(不含 A)
git cherry-pick <hashA>^..<hashB> # 移植连续范围(含 A)
git cherry-pick -n <hash> # 只取改动不 commit
git cherry-pick -e <hash> # 移植时修改 message
git cherry-pick --continue # 解决冲突后继续
git cherry-pick --abort # 放弃
git cherry-pick --skip # 跳过冲突的
# === bisect(二分定位) ===
git bisect start # 开始二分模式
git bisect bad HEAD # 标记当前是坏的
git bisect good <hash> # 标记已知好的版本
git bisect reset # 退出二分模式
git bisect run <test-command> # 自动化二分
# === blame(追溯每行代码) ===
git blame <file> # 逐行显示作者+时间
git blame -L 5,10 <file> # 只看指定行
git blame -w <file> # 忽略空白改动
git blame -M <file> # 追踪代码移动
# === reflog(终极后悔药) ===
git reflog # 查看 HEAD 移动记录
git reflog --date=iso # 带时间戳
git reflog show <branch> # 查看特定分支的 reflog