📚 Git 深入课(以 sourcepack 仓库为标本)· 第 9/12 章
全部示例均来自真机实测;实验请在隔离克隆里做。
本章目标:把"改写历史"从玄学变成可推理的操作。
核心只有一句话:对象不可变(第 2 章),所以改写 = 造一批新对象 + 把引用指过去。
学完你能预测每个命令会动三棵树里的哪几棵、哪些哈希会变、
以及事故之后按什么顺序救。
9.1 改写的本质
| 你以为的 | 实际发生的 |
|---|---|
"amend 修改了那个提交" |
造了一个新提交对象,把分支引用挪过去;旧的仍在对象库里 |
"rebase 移动了提交" |
把每个提交重新应用到新基底,产生一串全新提交 |
"reset 删掉了提交" |
只把引用往回挪;对象仍在(reflog 还引用着它) |
这就是"几乎所有误操作都能恢复"的根基:Git 从不修改对象,只移动引用(第 3 章)。
9.2 reset 的三种模式:动三棵树里的哪几棵
用第 4 章的三棵树(HEAD / 索引 / 工作区)来理解,一次讲清:
| 模式 | HEAD(当前分支) | 索引 | 工作区 | 典型用途 |
|---|---|---|---|---|
--soft |
✅ 移动 | ❌ | ❌ | 撤销提交、保留改动在暂存区(重新组织提交) |
--mixed(默认) |
✅ | ✅ 重置 | ❌ | 撤销提交与暂存,改动留在工作区 |
--hard |
✅ | ✅ | ✅ 覆盖 | 彻底丢弃(⚠️ 工作区改动会消失) |
实测(固定夹具:c1=99592e1、c2=54fb056,每次都从 c2 干净状态出发):
| 探测 | 起点 | --soft c1 |
--mixed c1 |
--hard c1 |
|---|---|---|---|---|
HEAD |
54fb056 | 99592e1 | 99592e1 | 99592e1 |
索引里有 g.txt |
1 | 1 | 0 | 0 |
工作区有 g.txt |
1 | 1 | 1 | 0 |
git status 残留 |
干净 | M f.txt A g.txt(已暂存) |
M f.txt ?? g.txt(未暂存/未跟踪) |
干净(改动消失) |
读法:--soft 之后那些改动全在暂存区(A 表示已 add),
--mixed 之后掉回工作区(?? 未跟踪),--hard 之后连文件都不在了。
# 被 --hard 丢掉的提交还在吗?
$ git log --oneline -1 54fb056
54fb056 c2 # ← 按哈希仍可读
$ git fsck --unreachable | grep -c 54fb056
0 # ← 甚至不算"吊对象":reflog 还引用着它
实用模式:
git reset --soft HEAD~1是"把上一个提交拆开重做"的起手式;
--hard是唯一会丢工作区改动的模式(且未提交的改动真的很多都救不回来,
它们从没进过对象库)。
9.3 revert:只增不改的反向提交
实测(三个线性提交 c-a/c-b/c-c):
$ git revert --no-edit 889875e # 撤销中间的 c-b
$ git log --oneline
d2caee3 Revert "c-b: 加 b.txt" # ← 新提交
af88922 c-c: 加 c.txt
889875e c-b: 加 b.txt # ← 原提交仍在历史里
18755e3 c-a: 加 a.txt
$ test -f b.txt || echo "b.txt 被 revert 删掉了"
b.txt 被 revert 删掉了
$ git log --oneline | grep -c 'c-b: 加 b.txt'
2 # 一条是原提交,一条是它的 Revert
关键性质:
- 历史只增长:不重写任何既有提交 → 已经推送的历史可以安全 revert;
- 适合公共分支(main / release):撤销某次改动而不影响别人的 clone;
- 代价:历史里会留下"加了又撤"的痕迹(对审计反而是好事)。
9.4 cherry-pick:复制"改动",不是复制提交
$ git checkout -B side <root>
$ git cherry-pick 889875e # 把 c-b 的改动打到 side 上
$ git log --oneline -2
d9f7adc c-b: 加 b.txt # ← 新哈希:d9f7adc ≠ 889875e
e1ad902 side: 独立提交
$ test -f b.txt && echo "b.txt 回来了"
b.txt 回来了
机制:把该提交的 diff(第 6 章)重新应用一次,生成新提交。
因为父提交不同,哈希必然不同。
用途:把修复从 main 挑到 release 分支(backport)、把某个提交搬到另一条分支。
和
rebase的关系:rebase本质就是"对一串提交自动做 cherry-pick"(见 9.6)。
9.5 amend:最常用的"改写"
$ git rev-parse --short HEAD # 前
54fb056
$ git add h.txt && git commit --amend --no-edit
$ git rev-parse --short HEAD # 后
7b194b5 # ← 新对象
$ git log --oneline -1 54fb056 # 旧对象仍可读
54fb056 c2
但要注意:--amend 之后,如果该提交已经推送过,你需要 force push,
并且其他人的历史会与你不一致(第 8 章的 --force-with-lease 出场)。
9.6 rebase:把提交"重放"到新基底
实测:feat 分支有两个提交,基线前进后执行 git rebase mainline:
# 重放前
$ git log --oneline feat -2
b5e1aee feat: 2
0fcdaaf feat: 1
$ git checkout feat && git rebase mainline
# 重放后
$ git log --oneline feat -2
8e6fb08 feat: 2 # ← 提交信息一样
ba860b5 feat: 1 # ← 哈希全变了
为什么哈希全变:feat: 1 的父提交从 c1 变成了 mainline 的尖端,
而 commit 的哈希包含 parent 字段(第 2 章)→ 它变了,feat: 2 的父提交跟着变 → 连锁反应。
交互式 rebase -i:squash / reword / drop
squash(合并提交)实测:
$ git log --oneline # 4 个提交(base + wip 1/2/3)
71d6ba4 wip 3
620c53d wip 2
9a61c5c wip 1
5eba07e base
$ GIT_SEQUENCE_EDITOR="sed -i '' '3,4s/^pick/squash/'" GIT_EDITOR=true git rebase -i -q HEAD~3
$ git log --oneline # 变成 3 个
9d06044 wip 2
9a61c5c wip 1
5eba07e base
$ cat w.txt
1 2 3 # ← 内容一字未变,只是提交被压扁了
GIT_SEQUENCE_EDITOR改"要做哪些操作"(pick/squash/reword/drop),
GIT_EDITOR改"提交信息"。两者都设才能完全非交互,适合写脚本与教学演示。
reword(改提交信息)实测——注意下游哈希全部变化:
$ git log --oneline -3
02fe3e4 wip 3 49cfc8f wip 2 3b80547 wip 1
$ GIT_SEQUENCE_EDITOR="sed -i '' '2s/^pick/reword/'" \
GIT_EDITOR="sed -i '' 's/^wip 1/feat: 初始化 w.txt/'" git rebase -i -q --root
$ git log --oneline -3
5e74cb3 wip 3 2f606d0 wip 2 b41185a feat: 初始化 w.txt
只改了一条信息,三个哈希全变——这是"改写历史影响面"的最直观演示。
drop(丢弃提交)实测:我试着 drop 掉 wip 2,结果撞上冲突:
Could not apply 5e74cb3... wip 3
原因:wip 3 是在 wip 2 的基础上继续写的,drop 掉中间那个提交后,
wip 3 的改动不再能干净地应用(工作区出现冲突标记)。
结论:drop 不是"删除文件",而是"撤掉这次改动";后续提交若依赖它,必然冲突。
9.7 改写之后,什么会"跟不上"
| 东西 | 会怎样 | 应对 |
|---|---|---|
| tag | 不跟随(实测:tag 仍指向旧哈希,而分支已前移) |
发布锚点用 tag 正是这个原因;要移动就 git tag -f |
| 别人 clone 的历史 | 与你的不一致,推送需要 force | --force-with-lease + 提前通知(第 8 章) |
| CI 里缓存的哈希 | 失效 | 用分支名/tag,别缓存提交哈希 |
| sourcepack 生成的产物 | 里面记录的短哈希对不上了 | 见 9.10 |
git blame 的作者归属 |
改写会把 author 保留、committer 变成你(第 2 章的字段差异) | 正常现象 |
tag 实测:
$ git tag -a my-release -m "发布" HEAD
$ git rev-parse --short my-release^{commit}
d349aec
$ git reset --hard HEAD~1 # 历史被回退(模拟改写)
$ git rev-parse --short my-release^{commit}
d349aec # ← tag 一动不动
$ git rev-parse --short HEAD
b0add57 # ← 分支走了
9.8 恢复的三层:ORIG_HEAD → reflog → fsck
第一层:ORIG_HEAD(一枪救回)——reset/merge/rebase 之前的位置会被记下来:
$ git log --oneline -1
d2caee3 Revert "c-b: 加 b.txt"
$ printf 'oops\n' > oops.txt && git add oops.txt && git commit -m "重要提交(会被搞丢)"
$ git log --oneline -1
b0add57 重要提交(会被搞丢)
$ git reset --hard HEAD~2 # 事故:回退过头
$ git log --oneline -1
af88922 c-c: 加 c.txt # 重要提交不见了,oops.txt 也没了
$ git rev-parse --short ORIG_HEAD
b0add57 # ← 正是那个提交
$ git reset --hard ORIG_HEAD
$ git log --oneline -1 ; test -f oops.txt && echo "oops.txt 回来了"
b0add57 重要提交(会被搞丢)
oops.txt 回来了
第二层:reflog(完整的引用移动史)——ORIG_HEAD 会被下一次操作覆盖,
但 reflog 记录了每一次移动:
$ git reflog -2
af88922 HEAD@{0}: reset: moving to HEAD~2
b0add57 HEAD@{1}: commit: 重要提交(会被搞丢)
于是 git reset --hard HEAD@{1} 等价于上面的救援。
第三层:fsck(最后手段)——reflog 过期、引用全无时,
仍可用 git fsck --lost-found 找 dangling 对象(第 3 章实测过 6 个 dangling commit),
再 git branch rescue <sha> 捞回。
预防胜过抢救:动手改写前先 git branch backup 或 git tag save-point
(成本 41 字节,第 3 章)。
9.9 清理历史的两把刀
| 工具 | 说明 |
|---|---|
git filter-branch |
老工具(重写全部历史,很慢)。Git 已不推荐,会留下 refs/original/* 备份引用 |
git filter-repo |
官方推荐的替代品(需单独安装),快且安全默认更多 |
铁律(改写历史的社会性约束):
- 只在自己独占的分支/仓库上做;
- 共享分支改写前必须通知所有人——他们必须重新 clone(或至少
fetch+reset --hard),
因为哈希全变,旧 clone 会与你的历史"分叉"; - 改写前先做备份 tag(9.8 第二层)。
9.10 回到 sourcepack:我们不改写历史,但会被它影响
9.10.1 只读工具:从不参与改写
sourcepack 生产代码里发出的 git 子命令只有五种,全部只读:
# changeset.go 里实际用到的子命令
diff # 拿清单与 patch
log # -G 的逐提交遍历
ls-files # 未跟踪文件清单
rev-parse # 解析 revspec / HEAD / --show-toplevel / --show-prefix
symbolic-ref # 取当前分支名(detached 时会失败,见第 3 章)
$ grep -oE '"(diff|log|ls-files|rev-parse|symbolic-ref)"' changeset.go | sort -u | tr '\n' ' '
"diff" "log" "ls-files" "rev-parse" "symbolic-ref"
$ grep -c '"commit"\|"push"\|"reset"\|"rebase"' changeset.go
0
没有任何 commit/reset/rebase/checkout/add 这类会改动仓库状态的调用。
(main.go 里有 "push" 字样,但那是我们自己的 -p 推送与 HTTP 请求,
不是 git push;changeset_test.go 等测试文件里确实有 add/commit/init/merge,
那是测试夹具在搭建场景。)
工程含义:这个工具可以在任何仓库里安全运行——没有副作用、不需要备份、
不需要权限(第 8 章),甚至可以在 CI 的只读 checkout 里跑。
9.10.2 但产物里记着哈希——改写会让它"对不上"
-g/-G 的产物会写上 HEAD 的短哈希(shortSHA,取前 7 位):
$ gx -G 1 --dry-run -d . | grep HEAD
HEAD: 7ae297f docs(git-course): 第 8 章——远端与传输(含 force-with-lease 对照实验与增量传输量化)
真实场景:你把这份产物发给了同事(或喂给了模型),
然后你 rebase/amend 了那个提交——产物里的 7ae297f 在新历史里找不到了。
这不是 bug,而是"哈希是内容的函数"(第 2 章)的直接后果。
实用推论:
- 需要长期引用某次审查 → 打 tag(tag 不跟随改写,9.7 已实测);
- 想让产物里的哈希稳定 → 在已冻结的提交上生成(例如合并后的 main、或 tag 指向的提交);
- 这正是我们 release 流程用 tag 触发(
on: push: tags: ['v*'])的原因:
分支会动,tag 是锚点。
9.10.3 --rev 支持 reflog 语法,改写后特别好用
HEAD@{1} 是合法 revspec(第 3 章实测过),于是"刚 rebase 完,看看改了什么"变成:
$ gx -g --rev 'HEAD@{1}' --dry-run -d .
📦 Git changes: git diff HEAD@{1} (working tree + index vs HEAD@{1})
这比记住旧哈希方便得多——因为 reflog 里就写着"上一步在哪"。
9.11 动手实验 [lab]
# A. reset 三模式 × 三棵树
rm -rf /tmp/l9 && mkdir /tmp/l9 && cd /tmp/l9 && git init -q -b main . \
&& git config user.email t@t.c && git config user.name T
printf 'v1\n' > f.txt && git add -A && git commit -qm c1 && C1=$(git rev-parse HEAD)
printf 'v2\n' >> f.txt && printf 'new\n' > g.txt && git add -A && git commit -qm c2 && C2=$(git rev-parse HEAD)
probe() { printf "%-9s HEAD=%s 索引g=%s 工作区g=%s status=[%s]\n" "$1" \
"$(git rev-parse --short HEAD)" "$(git ls-files|grep -c '^g.txt$')" \
"$(test -f g.txt && echo 1 || echo 0)" "$(git status --porcelain|tr '\n' ' ')"; }
base() { git reset -q --hard $C2; git clean -qfd; }
base; probe 起点 ; base; git reset -q --soft $C1 ; probe --soft
base; git reset -q --mixed $C1 ; probe --mixed
base; git reset -q --hard $C1 ; probe --hard
# B. revert vs cherry-pick
git reset -q --hard $C2; git clean -qfd
git revert --no-edit $C1 && git log --oneline -2
git reset -q --hard $C2; git clean -qfd
git cherry-pick $C1 ; git log --oneline -1 # 新哈希
# C. rebase 换哈希
git branch feat $C1 && git checkout -q feat && printf 'x\n' > x.txt && git add x.txt && git commit -qm "feat 1"
git checkout -q main && printf 'y\n' > y.txt && git add y.txt && git commit -qm "main 前进"
git checkout -q feat && git log --oneline -1 && git rebase main && git log --oneline -1
# D. squash(非交互)
git checkout -q -B sq main
for i in 1 2 3; do printf "$i\n" >> q.txt; git add q.txt; git commit -qm "wip $i"; done
GIT_SEQUENCE_EDITOR="sed -i '' '3,4s/^pick/squash/'" GIT_EDITOR=true git rebase -i -q HEAD~3
git log --oneline
# E. 一枪救回:ORIG_HEAD
printf 'oops\n' > o.txt && git add o.txt && git commit -qm "重要提交"
git reset --hard HEAD~2
git rev-parse --short ORIG_HEAD
git reset --hard ORIG_HEAD && test -f o.txt && echo "救回来了"
自测题:
reset --soft/--mixed/--hard分别动三棵树里的哪几棵?各自"改动去哪了"?revert与cherry-pick都会产生新提交,它们的新哈希分别为什么与原来不同?rebase之后为什么所有被重放提交的哈希都变了?--amend之后 tag 会跟着动吗?为什么?(用 9.7 的实测回答)- 事故后第一、第二、第三层恢复手段分别是什么?哪一层会被下一次操作覆盖?
- sourcepack 为什么不参与任何改写?它记录的哈希在改写后会怎样?
9.12 小结
- 改写历史的本质:造新对象 + 移动引用(对象库只追加,从不修改);
reset三模式用三棵树解释:--soft只动 HEAD、--mixed动 HEAD+索引、
--hard三棵全动(唯一会丢工作区改动的);revert只增不改(适合公共分支)、cherry-pick复制改动产生新哈希、
amend/rebase造新提交——reword 一个提交会让下游哈希全变(实测);- 交互式 rebase 的三种操作都有实测:squash(4→3 提交,内容不变)、reword(哈希全变)、
drop(与后续提交冲突——drop 撤的是改动,不是文件); - 改写后 tag 不跟随、别人必须重新 clone、推送需要
--force-with-lease; - 恢复三层:
ORIG_HEAD(一枪救回,实测)→ reflog(完整移动史)→
fsck(最后的 dangling 对象);最好的办法是动手前先git branch backup; - 清理历史(filter-branch/filter-repo)有社会性铁律:只在自己独占的仓库上做,
共享仓库必须先通知全员重新 clone; - sourcepack 只读、不改写,但产物里的短哈希会因改写而失效——
发布锚点因此必须用 tag(tag 不跟随改写,已实测)。
下一章:性能与底层——delta 链为什么让快照模型省空间、commit-graph 与 bitmap 如何加速
log/fetch、core.fsmonitor 与索引的 stat 缓存(第 4 章那条线)、
partial clone 与 sparse checkout,以及"大仓库为什么变慢"的诊断方法。