Git 分支模型中处理长期特性分支合并冲突的实用方法
先承认一个事实:长期分支就是会冲突
只要一个特性分支活过两三周,就很难指望最后合并时干干净净。尤其是多人同时在改同一个模块,main 上还在修 bug、升依赖、改配置,特性分支这边也在动同一批文件,冲突基本是早晚的事。
我以前也试过“最后一次性合,谁冲突谁解决”,结果就是合并当天啥也干不了,光看 diff 就看到眼花。后来我自己的做法变了:不再把冲突当成合并那一刻才处理的事,而是把它拆到日常里,让冲突小一点、早一点暴露。
把 main 合进特性分支,而不是反过来
长期特性分支最忌讳一直从 main 拉出去,然后几个月不回头。我的习惯是每周至少把 main 合进特性分支一次,忙的时候哪怕两周一次也行,但别拖到提 PR 前才做。
git switch feature/xxx
git fetch origin
git merge origin/main
这里用 merge 而不是直接 rebase,是因为长期分支通常已经推到远端,可能还有别人基于它开发。你私自 rebase 再 force push,别人拉代码时容易骂人。merge 会多几个提交,但历史清楚,冲突也能在特性分支里慢慢解决。
如果冲突很小,当场改掉,跑一下相关测试,继续干活。别攒着。攒冲突和攒 bug 差不多,越攒越不想碰。
用临时 worktree 做一次合并预演
有时候特性分支工作区里还有没提交的改动,直接 merge 不方便。我一般会开一个临时 worktree,专门用来试合并:
git fetch origin
git worktree add ../merge-preview feature/xxx
cd ../merge-preview
git merge origin/main
有冲突就看,能解决就顺手解决;只是想评估风险的话,看完直接:
git merge --abort
cd -
git worktree remove ../merge-preview
这样不会弄乱你原来的工作区。预演的时候重点看两类文件:一类是锁文件、生成文件,另一类是业务代码里双方都改过的函数。前者通常有固定处理方式,后者才需要认真读。
提前知道哪些文件会冲突:merge-tree 挺有用
Git 新版本可以用 merge-tree 在不真正合并的情况下看冲突:
git fetch origin
git merge-tree --write-tree --name-only origin/main feature/xxx
输出里会给出合并后的 tree 和冲突文件信息。我一般拿它做合并前的体检,尤其是准备提 PR 的时候,先看看到底是哪些文件在互相打架。Git 版本太老不支持这些参数的话,就用上面的 worktree 预演,效果也差不多。
冲突文件先分类,不要上来就手改
我现在看到冲突,第一反应不是打开编辑器,而是先分类:
git diff --name-only --diff-filter=U
第一类,锁文件,比如 package-lock.json、pnpm-lock.yaml、poetry.lock。这玩意儿手改就是给自己找事。一般先确定 package.json 或 pyproject.toml 已经合并好,然后取一边的锁文件重新生成:
git checkout --theirs package-lock.json
npm install
git add package-lock.json
注意,git merge origin/main 到特性分支时,ours 是特性分支,theirs 是 main。如果你在 rebase,含义会反过来,所以别死记命令,先看 git status 的提示。锁文件最终以重新生成的结果为准,不要相信手工合并出来的内容。
第二类,生成文件。比如前端构建产物、后端 protobuf 生成代码、数据库迁移的汇总文件。能不入库就不入库;必须入库的,就改生成脚本或源文件,然后重新生成,别在生成文件里解决冲突。
第三类,配置文件。比如路由表、菜单配置、环境变量示例。这种冲突经常是双方各加了一段,解决时不要只选一边,要把两边新增的内容都留下来,再按项目约定排序。我们项目里有个 routes.ts,每次冲突都是两边各加路由,后来干脆约定按模块名排序,冲突少了很多。
第四类才是业务代码。这类冲突不能只看哪边代码多,得看语义。
打开 diff3,先看共同祖先
我习惯把冲突样式设成 zdiff3,它会把共同祖先也显示出来,比默认的两边对比更容易判断:
git config --global merge.conflictStyle zdiff3
如果已经冲突了,可以重新检出冲突文件:
git checkout --conflict=zdiff3 path/to/file
老版本 Git 不支持 zdiff3 就用 diff3。看冲突的时候别只盯着 <<<<<<< 和 >>>>>>>,先看中间 base 那一版原来是什么。很多时候冲突是因为两边都基于旧代码改,理解了 base 才知道该保留谁。
再看一下这个文件最近的历史:
git log --merge -p -- path/to/file
--merge 会显示与当前合并相关的提交。看着看着就明白对方为什么改这一行了,比硬猜强。
重复冲突就用 rerere 记住解法
有些长期分支会反复解同一个冲突,比如某个配置文件每次合并都打架。可以打开 rerere:
git config --global rerere.enabled true
git config --global rerere.autoupdate true
它会把你的冲突解决方式记下来,下次遇到同样的冲突自动套用。不过我还是会看一眼,因为 main 上的上下文可能已经变了,自动解决不代表一定对。
长期分支最好有个 integration 分支
如果特性真的很大,几个月合不进 main,我会建议别让所有人直接在一个 feature/xxx 上开发,而是建一个 integration/xxx:
main继续保持可发布;integration/xxx作为长期集成分支,每天或隔天合一次main;- 子特性分支从
integration/xxx拉,完成后合回去; - 最后
integration/xxx整体合回main。
这样冲突主要发生在集成分支里,解决冲突的人也是熟悉这块的人,不会最后一股脑砸给主分支维护者。能配 feature flag 的话更好,把没完成的代码先合进 main,用开关挡住入口,长期分支自然就短了。
rebase 不是不能用,是别乱用
我个人对长期公共分支的态度很简单:别 rebase。自己的私有分支、还没人基于它开发的分支,rebase 没问题:
git fetch origin
git rebase origin/main
冲突了就用 git rebase --continue,不想干了就 git rebase --abort。rebase 后的分支要推远端,用:
git push --force-with-lease
别用裸的 --force。长期分支上如果有人协作,force push 之前先在群里说一声,不然别人刚拉的提交可能就对不上了。
最后别只看“冲突没了”
冲突标记解决完,git add 之前我一般会扫一眼还有没有残留:
git grep -n -E '^(<<<<<<<|=======|>>>>>>>)'
然后跑测试。说实话,Git 只能告诉你文本冲突,告诉不了你语义冲突。两个人都改了同一个函数,你合并完能编译,不代表逻辑是对的。预演没冲突,也不代表真没语义冲突;合并后测试跑一遍,不出问题的话才算没有问题。
长期特性分支的冲突没法彻底消灭,但可以把它从“合并当天的大爆炸”拆成“日常的小修补”。早点合 main,分类处理文件,重复冲突交给 rerere,大特性用 integration 分支兜着,基本上就能少掉很多无效加班。
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。