Squash Merge 的血缘陷阱:为什么删掉的代码又活了过来

你有没有遇到过这样的诡异场景:一段明显有问题的代码,你在功能分支上把它删掉了,合并请求(MR / PR)也合并了,可目标分支上它依然存在;你再合并一次,还在;翻 blame 想找责任人,它却指向一个几个月没碰过这块逻辑的同事。整个过程没有冲突、没有报错,就好像这行代码被"焊死"在了分支上。

这不是 Git 的 bug,而是 git merge --squash 一个非常隐蔽的副作用。它在一个特定条件下必然出现,而且静默发生、不抛任何冲突,属于那种"工作里会撞上、但很难自己想明白"的异常。本文用一个可复现的最小实验把它的来龙去脉讲透,并给出诊断手册和预防纪律。

先把结论放前面:

一旦你用 squash 方式把一个分支合入目标分支(切断了血缘),之后又继续用这个分支对同一目标做普通 merge,那么这个分支后续对"squash 已经带进去的内容"的删除或修改,会在合并时被静默丢弃 。根因是 squash 让 merge-base 停在了旧位置,导致三方合并的"基准版本"算错了。

一、现象:一个删不掉的改动

把场景抽象成最干净的形式。一个团队有长期存在的目标分支(就叫它 main,也可能是 test 这类环境分支)和短期的功能分支 feature。事情是这样发生的:

  1. 有人在 feature 上加了一行有问题的代码 X(比如一处错误的跨模块引用)。
  2. 这行代码进入了 main,线上/构建开始报错。
  3. 作者很快发现,在 feature 上把 X 删掉了。
  4. 然而 mainX 还在。又合并了一次 feature,X 仍然在。
  5. 用 blame 追这一行,指向的提交作者是另一个人------一个只是当初"建了这个文件"的同事。

四个反直觉点叠在一起,排查的人很容易陷入自我怀疑:删除生效了吗?分支合对了吗?是不是 Git 坏了?

都不是。问题出在第 2 步"这行代码是怎么进入 main 的"------它不是通过一次普通 merge,而是通过一次 squash 合并进去的。这个区别,是后面一切诡异的源头。

二、最小复现:同样的增删,只换合并方式

口说无凭,先上一个可以直接复制运行的实验。它在临时目录里建三个独立的小仓库,模拟"功能分支加了 X、又删了 X",唯一的变量是合入目标分支的方式 ,看 X 最终在不在目标分支。

bash 复制代码
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"

运行结果:

text 复制代码
组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:

text 复制代码
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:

text 复制代码
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:数父提交个数。

bash 复制代码
git show --no-patch --format='%h parents=[%P]' <commit>

普通 merge 有 2 个 父提交;普通提交和 squash 提交都只有 1 个父提交。如果某个本该是"合并 feature"的提交只有 1 个父,它很可能是 squash 出来的。

2. 判断血缘有没有断:被合并的源提交是不是它的祖先。

bash 复制代码
git merge-base --is-ancestor <feature 上加 X 的提交> <目标分支上那次合并> && echo 祖先 || echo 血缘断裂

正常 merge 应是"祖先";squash 会让它"血缘断裂"。

3. 追这一行的增删历史(pickaxe)。

bash 复制代码
git log --oneline -S '要追的代码片段' -- 路径/文件

-S 会列出所有改变该片段出现次数的提交。如果你在目标分支跑它,只看到"添加"而看不到本该有的"删除",说明删除从未在这条线上发生过。

4. 区分行级与文件级,看对作者。

bash 复制代码
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 追行"这几招诊断,这类"删了又活过来"的灵异现象就再也唬不住你了。

参考与数据源

相关推荐
kyriewen2 小时前
今天的科技圈,全在抢英伟达的饭碗
前端·面试·ai编程
SouthernWind2 小时前
RAGFlow——结合本地知识库检索开发实战指南(包含聊天、检索本地的知识库文档和Agent模式)
前端
三翼鸟数字化技术团队2 小时前
websocket及SSE原理解析
前端
程序员cxuan3 小时前
GPT-5.6 还不发布?不过大家可以先看看 Codex 的白皮书。
人工智能·后端·程序员
妙码生花3 小时前
从 PHP 到 AI + Golang,程序员自救转型手记(八):设计管理员模型、热重载配置
前端·后端·go
政采云技术3 小时前
Chrome 高阶调试技巧
前端
牧艺3 小时前
HTML-in-Canvas 深度解析:让 Canvas 真正「吃上」HTML 这碗饭
前端·html·canvas
秦瑜华3 小时前
前端页面添加AI自动翻译按钮
前端·openai·ai编程