你有没有遇到过这样的诡异场景:一段明显有问题的代码,你在功能分支上把它删掉了,合并请求(MR / PR)也合并了,可目标分支上它依然存在;你再合并一次,还在;翻 blame 想找责任人,它却指向一个几个月没碰过这块逻辑的同事。整个过程没有冲突、没有报错,就好像这行代码被"焊死"在了分支上。
这不是 Git 的 bug,而是 git merge --squash 一个非常隐蔽的副作用。它在一个特定条件下必然出现,而且静默发生、不抛任何冲突,属于那种"工作里会撞上、但很难自己想明白"的异常。本文用一个可复现的最小实验把它的来龙去脉讲透,并给出诊断手册和预防纪律。
先把结论放前面:
一旦你用 squash 方式把一个分支合入目标分支(切断了血缘),之后又继续用这个分支对同一目标做普通 merge,那么这个分支后续对"squash 已经带进去的内容"的删除或修改,会在合并时被静默丢弃。根因是 squash 让
merge-base停在了旧位置,导致三方合并的"基准版本"算错了。
一、现象:一个删不掉的改动
把场景抽象成最干净的形式。一个团队有长期存在的目标分支(就叫它 main,也可能是 test 这类环境分支)和短期的功能分支 feature。事情是这样发生的:
- 有人在
feature上加了一行有问题的代码X(比如一处错误的跨模块引用)。 - 这行代码进入了
main,线上/构建开始报错。 - 作者很快发现,在
feature上把X删掉了。 - 然而
main上X还在。又合并了一次feature,X仍然在。 - 用 blame 追这一行,指向的提交作者是另一个人——一个只是当初"建了这个文件"的同事。
四个反直觉点叠在一起,排查的人很容易陷入自我怀疑:删除生效了吗?分支合对了吗?是不是 Git 坏了?
都不是。问题出在第 2 步"这行代码是怎么进入 main 的"——它不是通过一次普通 merge,而是通过一次 squash 合并进去的。这个区别,是后面一切诡异的源头。
二、最小复现:同样的增删,只换合并方式
口说无凭,先上一个可以直接复制运行的实验。它在临时目录里建三个独立的小仓库,模拟"功能分支加了 X、又删了 X",唯一的变量是合入目标分支的方式,看 X 最终在不在目标分支。
set -e
# ===== 组 1:全程普通 merge =====
D1=$(mktemp -d); cd "$D1"
git init -q && git config user.email t@t.com && git config user.name t
printf 'keep\n' > f.txt && git add f.txt && git commit -qm base
MB=$(git rev-parse --abbrev-ref HEAD) # 默认分支名(main 或 master)
git branch feature && git checkout -q feature
printf 'keep\nX\n' > f.txt && git commit -qam 'feat: add X'
git checkout -q "$MB" && git merge feature -q -m 'merge add' # 普通 merge 把 X 合进来
git checkout -q feature && printf 'keep\n' > f.txt && git commit -qam 'fix: remove X'
git checkout -q "$MB" && git merge feature -q -m 'merge remove'
grep -q X f.txt && echo '组1 [普通 merge]:X 仍在 -> 删除丢失' || echo '组1 [普通 merge]:X 已删 -> 删除生效'
# ===== 组 2:squash 合入,再普通 merge 删(复现本案) =====
D2=$(mktemp -d); cd "$D2"
git init -q && git config user.email t@t.com && git config user.name t
printf 'keep\n' > f.txt && git add f.txt && git commit -qm base
MB=$(git rev-parse --abbrev-ref HEAD)
git branch feature && git checkout -q feature
printf 'keep\nX\n' > f.txt && git commit -qam 'feat: add X'
git checkout -q "$MB" && git merge --squash feature -q && git commit -qm 'squash add X' # squash 合入
git checkout -q feature && printf 'keep\n' > f.txt && git commit -qam 'fix: remove X'
git checkout -q "$MB" && git merge feature -q -m 'merge remove'
grep -q X f.txt && echo '组2 [squash 后 merge]:X 仍在 -> 删除丢失(复现)' || echo '组2:X 已删'
# ===== 组 3:同样 squash 后,直接在目标分支上删 =====
cd "$D2" && printf 'keep\n' > f.txt && git commit -qam 'target 直接删 X'
grep -q X f.txt && echo '组3 [直接在目标删]:X 仍在' || echo '组3 [直接在目标删]:X 已删 -> 直接删永远有效'
rm -rf "$D1" "$D2"
运行结果:
组1 [普通 merge]:X 已删 -> 删除生效
组2 [squash 后 merge]:X 仍在 -> 删除丢失(复现)
组3 [直接在目标删]:X 已删 -> 直接删永远有效
三组对比一目了然:
| 组 | 合入方式 | 之后删除的途径 | X 最终在目标分支? |
|---|---|---|---|
| 1 | 普通 merge | 在 feature 删,再普通 merge | 已删,生效 |
| 2 | squash 合入 | 在 feature 删,再普通 merge | 仍在,删除丢失 |
| 3 | squash 合入 | 直接在目标分支删 | 已删,生效 |
这组实验直接否定了两个常见误解:
- "squash 进来的东西不可逆"——错。组 3 证明,在目标分支上直接删它永远有效,squash commit 不过是个普通提交。
- "是改动被重复叠加才出问题"——错。组 1 全程普通 merge,哪怕反复加删,删除照样同步。
唯一的变量,就是组 2 用了 squash。问题的全部秘密,都在 squash 改变了什么。
三、原理:merge 是三方合并,squash 动了 merge-base
要讲清楚,得先认识 Git 合并的工作方式。
3.1 普通 merge 是"三方合并"
git merge 不是简单地把两边的改动叠加,而是一次三方合并(three-way merge),它要看三个版本:
- base:两个分支的最近共同祖先(由
git merge-base计算)。 - ours:当前分支(目标分支)的版本。
- theirs:被合并进来的分支版本。
对每一行,Git 比较"base 到 ours 改了什么"和"base 到 theirs 改了什么":只有一边改了就采用那边;两边都改且不一致才算冲突。这里的关键是:判断"某一边有没有删除某行",是相对 base 来看的。 base 选错,删除的判定就会错。
3.2 squash 不是 merge,它抹掉了"来源"
再看 squash。Git 官方文档对 --squash 的描述是:
Produce the working tree and index state as if a real merge happened (except for the merge information), but do not actually make a commit, move the HEAD, or record
$GIT_DIR/MERGE_HEAD... This allows you to create a single commit on top of the current branch whose effect is the same as merging another branch.
翻成人话:squash 把"合并的效果"应用到工作区,但故意不记录合并信息(即不记录被合并分支这个父提交),最后你提交出来的是一个普通的单父提交。社区有个精辟的说法:git merge --squash 是动词(执行了合并这个动作),而不是名词(并没有产生一个 merge commit)。它带走了所有改动,却没记下这些改动从哪来。
后果是决定性的:因为 squash 提交不把源分支当作父提交,源分支和目标分支的 merge-base 不会因为这次 squash 而前移,它还停在 squash 之前的那个旧共同祖先上。
3.3 把两组的 base 摆在一起看
用 DAG 把组 1 和组 2 画出来(A 是加 X 的提交,D 是删 X 的提交)。
组 1,全程普通 merge:
feature: B0 ── A(+X) ─────────────── D(-X)
\ \
main: B0 ───── M1(merge,X 进来) ───── M2(merge)
↑
第二次 merge 时 merge-base = A(A 已是 main 的祖先)
base = A 里【有 X】
组 2,squash 合入后再普通 merge:
feature: B0 ── A(+X) ─────────────── D(-X)
\ \
main: B0 ──── S(squash 抄入 X) ───── M(merge)
↑
S 不记录 A 为父,故 merge-base 仍 = B0
base = B0 里【没有 X】
差别就这一处:merge-base 是 A(有 X)还是 B0(没有 X)。 接下来三方合并的结论,完全被它决定。
第二次合并 feature(此时 feature 已删掉 X)进 main,逐行判定如下。
组 1(base = A,有 X):
| 版本 | X 这一行 |
|---|---|
| base = A | 有 X |
| ours = main | 有 X(没动) |
| theirs = feature | 无 X(删了) |
| 判定 | 只有 theirs 相对 base 删除了 X -> 采用删除 -> X 没了 |
组 2(base = B0,没有 X):
| 版本 | X 这一行 |
|---|---|
| base = B0 | 无 X |
| ours = main | 有 X(squash 抄进来的) |
| theirs = feature | 无 X(加了又删,净效果没有) |
| 判定 | 相对 base:theirs"没动"(本来就没有),ours"新增了 X" -> 采用 ours 的新增 -> X 保留 |
看懂这两张表,整件事就通透了:组 2 里 feature 删除 X 这个动作,从 B0 这个错误基准看过去,等于"feature 从头到尾都没有 X",于是 Git 认为 feature 根本没碰这行,只有 main 单方面加了 X,自然保留。你的删除不是被否决,而是压根没被 Git 看见。
四、为什么它特别难发现
这个陷阱之所以"难理解",是因为它同时踩中三个反直觉点。
第一,它是静默的,不报冲突。 上面组 2 的判定里,Git 认为只有一边动了这行,这是一个可以自动合并的情形,不会产生冲突标记。没有 <<<<<<< 提醒你,合并显示成功,你以为一切正常,直到很久以后才发现删除没生效。
第二,blame 会"甩锅"给无辜的人。 很多人遇到问题第一反应是看 blame 找作者,但要分清两个层面:
- 逐行追溯(blame):
git blame告诉你"某一行最后是哪个提交改的"。 - 文件历史(log):
git log -- 文件列出"所有改过这个文件的提交"。
如果你打开图形工具看的是文件历史,排在最显眼位置的往往是"当初创建这个文件、写了大部分行"的那个提交——哪怕那个人根本没碰过出问题的那一行。真正要找"这一行是谁写的",得用逐行 blame,并且把光标精确落在那一行。这两个视图混淆,是"指向无辜同事"的根源。
第三,它常常伴随另一个反模式:直接往长期分支提交。 squash 合并本身,加上"在目标分支上手动提交业务代码、绕过正常流程",会让这一行彻底脱离源分支的版本管控。两者叠加,删除就更不可能从源分支同步过去了。
五、诊断手册:几条命令快速定位
遇到"删了又在"的诡异现象,按下面几步基本能锁定它是不是 squash 血缘断裂。
1. 看那个提交是不是 merge:数父提交个数。
git show --no-patch --format='%h parents=[%P]' <commit>
普通 merge 有 2 个父提交;普通提交和 squash 提交都只有 1 个父提交。如果某个本该是"合并 feature"的提交只有 1 个父,它很可能是 squash 出来的。
2. 判断血缘有没有断:被合并的源提交是不是它的祖先。
git merge-base --is-ancestor <feature 上加 X 的提交> <目标分支上那次合并> && echo 祖先 || echo 血缘断裂
正常 merge 应是"祖先";squash 会让它"血缘断裂"。
3. 追这一行的增删历史(pickaxe)。
git log --oneline -S '要追的代码片段' -- 路径/文件
-S 会列出所有改变该片段出现次数的提交。如果你在目标分支跑它,只看到"添加"而看不到本该有的"删除",说明删除从未在这条线上发生过。
4. 区分行级与文件级,看对作者。
git blame -L 31,31 <分支> -- 文件 # 第 31 行到底是谁写的(行级)
git log --oneline <分支> -- 文件 # 谁改过这个文件(文件级,别拿它当行作者)
5. 顺带一招:用时间戳识别 cherry-pick。 普通本地提交的 author date 与 committer date 相同;cherry-pick 会保留原 author date、刷新 committer date,两者不一致。git show --format='%ai | %ci' <commit> 一眼就能看出来。
六、怎么修
明确一点:既然血缘已经断了,指望"在源分支删除 + 再 merge"来同步,是无效的(这正是组 2 反复失败的原因)。可行的有两条路:
- 直接在目标分支上删除——也就是组 3 的做法。它脱离了源分支血缘,只能就地清除,而且永远有效。
git revert那个 squash 提交(若想整体回退它带进来的内容)。注意 revert 单父提交是直接的;若它本身是 merge 提交则需-m指定主线。
如果这个源分支后面还要继续用,别让它带着断裂的血缘继续合。更干净的做法是让它从目标分支的最新位置重新分叉(例如 git rebase 到最新 main,或基于最新 main 重建分支),把血缘接回来,后续的 merge 才会正常。
七、怎么预防
这个坑的根子不在 squash 本身,而在"squash 之后又拿同一分支做普通 merge"这个组合。围绕它立几条纪律即可基本免疫:
- squash 合入后,废弃该源分支。 这是社区通行的做法:用 squash 合过的分支不要再拿去对同一目标做后续合并。要继续开发,就从目标最新处重新拉一条新分支。
- 一个目标分支,统一一种合并策略。 要么全程普通 merge(保留血缘),要么团队约定 squash + 合并后即弃。最忌讳的是同一分支一会儿 squash、一会儿普通 merge 地混用。
- 长期/环境分支只接收正规合并,不直接往上提交业务代码。 直接提交会让改动脱离源分支管控,叠加 squash 后几乎不可逆。
- 能用自动化兜住的就别靠记忆。 例如在 CI 或钩子里检查"是否存在对受保护分支的直接业务提交""合并方式是否符合约定",把规范变成机器可执行的拦截。
八、小结
把这件事压缩成一句话:squash 是"动作"不是"对象"——它合并了改动,却抹掉了改动的来源。一旦你 squash 合入之后又拿同一分支做普通 merge,merge-base 会停在旧位置,三方合并的基准算错,于是源分支后续对"squash 已带入内容"的删除会被静默吞掉。
它不报错、不冲突、blame 还会甩锅,所以格外难懂。但只要记住"squash 切断血缘、合完即弃该分支",并掌握"数父提交、查 merge-base、pickaxe 追行"这几招诊断,这类"删了又活过来"的灵异现象就再也唬不住你了。
参考与数据源
- Git 官方文档
git merge(--squash语义:产生单父提交、不记录合并来源):git-scm.com/docs/git-me… - Pro Git 第三章 Basic Branching and Merging(三方合并与 merge-base 基础):git-scm.com/book/en/v2/…
- Git 官方文档
git merge-base(共同祖先的计算):git-scm.com/docs/git-me…