ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Git冲突完全指南:从原理到标准处理流程

Git冲突完全指南:从原理到标准处理流程 用过 Git 的人早晚都会在某次分支合并时撞见那几个刺眼的单词CONFLICT (content)。我第一次看到它时第一反应是完了代码是不是被我改坏了。后来多踩了几次坑才明白Git 冲突根本不是代码事故它只是 Git 在两段都合理又互相矛盾的修改之间不敢替你拍板于是把选择权交还给人类。用更准确的话说解决冲突就是在做一次三方对比的人工裁决而 Git 会把所有冲突证据、双方原始内容、共同祖先版本都摆在桌面上你要做的其实就三件事看懂冲突标记、判断保留哪一方或合并双方、在验证之后提交。这篇文章不打算给你背命令字典而是把我实际处理分支冲突时默认走的完整思考链路拆开讲冲突为什么产生、常见于哪些命令、从一个CONFLICT提示到最终提交中间每一步应该做什么以及怎么用工具和工程习惯把冲突频率降下来。无论你是刚装好 Git 的新手还是已经合并过无数次分支但偶尔还是心里没底的开发者这套流程应该都能直接用。1. 冲突的本质三方对比下的安全提醒不是代码事故很多人把冲突理解为两个人的代码打架Git 拉偏架失败。其实冲突的出现恰好说明 Git 在严格按规则办事——它只在确确实实无法自动合并时才停下来而不是像某些人想象的那样动不动就报错。1.1 从 merge-base 说起快进合并为什么不会冲突要理解冲突先得知道 Git 合并的基础模型。假设你当前在main分支执行git merge feature/foo。Git 会先找两个分支的共同祖先提交这个提交在文档里叫 merge-base。然后 Git 拿三个版本做对比base两个分支分叉之前的原始内容ours当前分支main的版本theirs待合并分支feature/foo的版本如果feature/foo的提交历史刚好是从main的某个提交一路线性长出来的也就是说main自己没有任何新提交Git 直接把这个分支指针往前挪就行这叫fast-forward 快进合并根本不会产生冲突。很多新手看到git merge偶尔特别顺利就是这个原因。真正会冲突的是两条分支已经分叉的情况。比如你和同事各自基于同一个版本改代码他改了 A 文件第 10 行你也改了 A 文件第 10 行而且改得不一样Git 就没办法判断哪个才是你们想要的。注意快进合并虽然省事但有些团队会故意禁用快进--no-ff保留一个显眼的合并提交这样历史更清晰。跟冲突本身关系不大但理解这个机制对后面看合并历史很有帮助。1.2 冲突生成的三个条件与 Git 的不敢自作主张我总结过冲突出现的条件其实非常苛刻需要同时满足两个分支对同一个文件的同一区域都做修改修改后的内容不一致这个文件不是二进制文件二进制文件只要两边版本不同Git 基本都会报冲突只有同时满足这三条Git 才会放下自动合并的念头。反过来说只要有一方没动那个区域不管另一方改得多夸张Git 都能自动合并成功。这也是为什么实际工作中很多合并很顺利因为大家的改动天然分散在不同代码区域。Git 为什么宁可停下来也不自己猜想想就明白了——它没有业务语义不知道你要的是新价格还是旧版本、保留 if 还是 switch。与其生成一份错误代码让你在运行时排查半天不如直接打断你把所有候选版本整整齐齐摆出来让你这个真正有上下文的人来做决定。所以说冲突是 Git 最保守也最负责的表现。用生活类比两个同事同时改了一份会议纪要都改了第三段的预算数字一个写 100 万一个写 80 万。你有最终解释权但你不能怪他们为什么不自动合并。Git 就是那个提醒你这里到底听谁的的助理。2. 冲突的真实战场merge、pull、rebase、cherry-pick、stash pop 各有脾气很多人以为冲突只会出现在git merge里。实际上你日常用的好几个命令底层都会走合并逻辑只是表现形式不同处理方式也有一点点差别。我按遇到频率排个序。2.1 merge 与 pull被当作普通合并的隐性冲突最常见的当然是git merge。执行到一半如果看到类似输出Auto-merging src/App.js CONFLICT (content): Merge conflict in src/App.js Automatic merge failed; fix conflicts and then commit the result.这已经是很明确的提示了。这时候仓库处于一种特殊的合并中状态你可以选择解决冲突后提交也可以执行git merge --abort撤回到合并前的状态。值得留意的是git pull。它的本质是git fetch git merge所以 pull 拉取远程分支时同样可能触发冲突。很多人以为 pull 只是下载代码看到冲突标记会懵很久。如果你不希望 pull 产生合并提交还有一种选择是git pull --rebase让本地提交叠到远程分支之上但这样带来的就是另一类冲突下面说。2.2 rebase 的连环冲突为什么改同一处 Bug 的提交要打持久战git rebase的冲突从根上就和 merge 不同。merge 只对比两个最终状态rebase 则是把你分支上的每一个提交逐个取出来重新应用的目标分支之上。这意味着如果你分支上有 5 个提交且每个提交都恰好和新的基线在同一个位置有交集你要解决5 次冲突每次都可能要重新思考一次。rebase 冲突时仓库状态更像是正在进行某一步重放而不是两个分支终点之间的叠加。具体操作中每解决完一个冲突需要git add文件后继续执行git rebase --continueGit 会接着处理下一个提交。如果中途实在不想弄了git rebase --abort可以回到 rebase 之前的状态。这里最大的坑在于冲突期间你不能直接按提交了事git commit不符合 rebase 的上下文必须使用--continue。2.3 cherry-pick 与 stash pop小众场景同样可能翻车git cherry-pick commit是把某个分支的单个提交应用到当前分支像是精准搬运。如果当前分支同样位置也有改动同样会冲突。处理完冲突后执行git cherry-pick --continue完成这次搬运或者git cherry-pick --abort放弃。git stash pop的冲突则更隐蔽。Stash 保存的是一份工作区快照当你恢复到另一个已经推进过的分支上时如果两边的修改有重叠就会冲突。而且 stash pop 冲突时原本的 stash 并不会自动删除——这是 Git 的保守策略——需要你解决完、确认无误后手动git stash drop。如果你误以为pop 完了 stash 就没了而重新操作很容易把事情搞混。我把这几类命令的冲突后续处理方式整理成了一个速查表触发命令冲突后仓库状态继续操作放弃操作特别注意点git merge合并进行中解决后git commitgit merge --abort可直接提交但提交信息可修改git pull同上同上同上pull 本质是 merge策略一致git rebaserebase 进行中解决后git add再git rebase --continuegit rebase --abort提交会被重放多次冲突可能多次出现git cherry-pickcherry-pick 进行中解决后git add再git cherry-pick --continuegit cherry-pick --abort只影响单个提交git stash pop工作区部分合并解决后git add正常提交无专门 abort手动还原恢复的 stash 不会自动清理这张表建议存下来。很多人遇到冲突手忙脚乱不是不会改文件而是不知道当前处于什么状态、下一步该敲哪个命令。3. 从告警到提交一次完整的分支冲突解决过程理论讲完直接跑一个实际例子。假设我在feature/order分支上工作现在需要把main的更新合并进来git merge main输出Auto-merging payment/order.go CONFLICT (content): Merge conflict in payment/order.go Automatic merge failed; fix conflicts and then commit the result.注意这里只有payment/order.go一个文件冲突其他文件 Git 都已经自动合并好了。这是常见情况不要一看到冲突就把整个合并撤销先冷静确认范围。3.1 冲突前的三个信号merge 输出、git status、文件标记第一个信号是上面的 merge 输出它明确告诉你哪个文件冲突了。第二个信号来自git status会列出所有处于 Unmerged paths 状态的文件Unmerged paths: (use git add file... to mark resolution) both modified: payment/order.go第三个信号就是文件内容本身。打开payment/order.go会看到冲突标记 HEAD total : order.Total * 0.95 total : order.Total * 0.9 shippingFee main这个三段结构是所有内容冲突的统一格式 HEAD到当前分支HEAD也就是你正在合入的一方的内容到 main被合并进来的一方main的内容如果你是merge main那HEAD是feature/ordermain是外部引入的。搞清楚谁是 ours谁是 theirs后逻辑就清晰了。3.2 读懂冲突标记与 diff3 显示的公说公有理大部分 Git 默认的冲突风格只显示两份候选版本。但如果你像我一样配置过merge.conflictStyle为diff3或zdiff3你还会看到额外一段以|||||||开头的内容那是共同祖先版本。 HEAD total : order.Total * 0.95 ||||||| parent of main total : order.Total total : order.Total * 0.9 shippingFee main有了 base 版本很多冲突一眼就能看清哦原来最初是order.Total两边各自加了不同的折扣策略。 这让决策容易得多。3.3 编辑、验证、收尾三种解决策略与提交时机面对冲突通常有三种解决策略全取当前分支删除和 main之间的内容只保留上面部分。全取对方分支删除 HEAD和之间的内容只保留下方。手动合并双方逻辑两段代码都保留一部分甚至重写整个区域。只要最后的代码是你想要的删干净所有标记即可。无论用哪种关键一步是删除全部冲突标记包括三个标记行不能有任何存留否则代码编译不过。改完后执行git add payment/order.go git status这时 Unmerged paths 会消失Git 会提示你合并仍在进行、可以提交了。提交建议直接用默认的 Merge branch 提交信息也可以补充说明。一个容易被忽略的细节Merge 冲突解决后的提交不要用git commit --amend去改上一个提交它只会把你自己搞晕把这次合并提交当作普通提交处理就好。提交前一定先做一次验证。有测试就跑测试没有就至少编译一遍或者git diff --cached看一遍暂存区内容。我个人一定会做因为这是冲突中人工取舍的区域最容易混入看似对、实际错的逻辑。4. rebase 冲突中身份互换ours/theirs 的反常识与应对如果你认为 HEAD永远是自己的代码那 rebase 冲突一定会坑你一次。4.1 为什么 rebase 里 ours 不是你自己在git merge中HEAD 表示当前分支这是直觉上的自己人。但在git rebase main时Git 的工作方式完全不同它先把你的分支临时放到一边把仓库 HEAD 切换到main的最新提交上然后逐个把你的提交重放上去。所以冲突发生时 HEAD显示的实际上是main 分支目标基线的内容而后面跟着的是你正在被重放的那个提交也就是你原本的改动。换句话说ours 你 rebase 的目标分支比如 main不是你自己theirs 你原本要带过来的提交这听起来很反直觉我第一次遇到时差点把自己分支的改动全清掉。举个例子我在feature/login上执行git rebase main冲突标记长这样 HEAD // main 上已经重构过的登录校验逻辑 // feature/login 提交里写的旧版校验逻辑 4c2f0a1 (实现登录校验)你需要保留的是哪边不一定。如果 main 已经重构过这块逻辑你想要的可能是它们的版本也可能是把你的新校验逻辑合进去。重点是你不能因为theirs 是我自己就盲目保留下方内容而忽略上方基线上已经存在的演进。正确做法是同时读两边搞清楚两者差异再决定最终形态。4.2 具体操作时的判断策略不要盲信标记身份我现在的操作习惯是遇到任何 rebase 冲突先把git log --oneline HEAD..HEAD{1}这类命令用起来看看我原本分支上都提交了什么再打开冲突文件基于内容判断而不是基于哪个名字像自己人判断。如果文件不大直接手动重写这一段逻辑忽略标记身份只保留正确结果反而是最省心的。另外如果你的某次 rebase 需要解决很多次冲突每解决完一个提交的冲突并--continue后都会生成一个新提交。这些新提交的哈希和原来不同这是正常的rebase 本来就是在重写历史。需要推送到远程时就要加上--force-with-lease但这是另一个话题这里不展开。5. 把冲突解决从人工找茬变成标准化流程工具与配置手动改冲突标记当然可行但遇到大文件、多区域冲突时非常费眼。好消息是Git 提供了完整的可视化合并工具接入能力而且不少 IDE 直接内置了合并编辑器。5.1 先改冲突标记风格zdiff3 带来的上下文是最大的元信息工具栏配置之前我建议先做一件零成本但收益极大的事把冲突展示风格改成 diff3 或 zdiff3。git config --global merge.conflictstyle zdiff3zdiff3是 Git 2.35 起提供的改进版本。它和 diff3 一样会在冲突标记里附带|||||||共同祖先块但只在真正有信息量的时候才显示普通冲突不会增加过多噪音。有了 base 版本你就能看到两边各自基于什么改的很多时候直接决定保留哪边不用再瞎猜。我见过很多开发者不知道这个配置每次冲突都只有一个分隔的两段内容条件少的冲突还好条件多的根本看不明白。提示如果 Git 版本较老2.35 以下用diff3也能达到类似效果只是显示稍啰嗦一些。5.2 配置可视化合并工具meld、beyond compare、vimdiff如果你的冲突经常比较复杂命令行编辑效率太低。Git 支持git mergetool调起外部工具常用这几款工具平台特点配置MeldWindows / Linux免费开源三栏对比清晰git config --global merge.tool meldBeyond CompareWindows / macOS商业功能最强需额外设置工具路径KDiff3跨平台免费专门的合并工具git config --global merge.tool kdiff3vimdiff自带无需安装适合 vim 用户git config --global merge.tool vimdiffVS Code / IDEA 内置跨平台图形化合并界面最直观一般无需额外配置配置完成后遇到冲突只需git mergetoolGit 会逐个打开冲突文件。以 Meld 为例左边是 ours中间是合并结果区域右边是 theirs你在中间区域编辑出最终内容并保存即可。全部文件处理完后Git 会问你是否标记为已解决。用工具时注意一点很多工具会自动生成.orig备份文件配置git config --global mergetool.keepBackup false可以避免满仓库残留垃圾文件。5.3 用 IDE 内置合并编辑器收尾对于主要工作在 VS Code 或 JetBrains 系 IDE 里的开发者内置合并编辑器通常比外部工具更顺手。VS Code 里打开冲突文件会看到高亮分栏可以直接点 Accept Current Change / Accept Incoming Change或进入合并编辑器精细调整左右两部分。IDEA 则会弹出 Diff 窗口左右两侧各显示 ours/theirs底部是合并结果还可以逐冲突块选择左右策略使用体验非常流畅。不过 IDE 内置编辑器有一个常见坑很多人点完了对话框里的完成就以为自己已经解决了冲突。实际上完成后仍需要回到终端执行git add甚至如果 IDE 没有自动触发 addGit 状态不会改变。所以我的习惯是图形界面里处理完回到终端git status确认再手动git add和后续命令。6. 让冲突少发生的工程化约定前置预防比事后处理省心工具和方法都讲了但一个资深的做法是尽量让冲突少出现。冲突频率高不是 Git 的错往往是协作习惯出了问题。6.1 行尾符与 .gitattributes——最沉默的全文件冲突制造者有一种特别离谱的冲突明明两个分支只改了同一文件的两个不同方法但 Git 却判定整个文件冲突。查半天发现原因跟业务代码无关而是行尾符问题。Windows 上默认core.autocrlftrue提交时会把 CRLF 转成 LF而 Linux/macOS 开发者通常不转换。两边一合并Git 认为整个文件的每一行都变了冲突区域直接扩展到全文件。解决方案是让仓库统一声明行尾策略。在仓库根目录放一份.gitattributes* textauto *.js text eollf *.ts text eollf *.py text eollf *.bat text eolcrlf这样无论你在哪个平台提交Git 都按仓库约定存储行尾符。这个文件最好项目刚建时就纳入否则历史上有大量已用错误行尾提交的文件第一次生效时会显示一堆改动需要专门做一次行尾归一化提交。6.2 小步提交、频繁同步与任务拆分冲突的根源是两条分支在同一个位置各自产生了修改。你对同一文件的这段区域持续操作越久别人碰它的概率就越高。所以我在实践里坚持三条提交粒度要小最好一个提交对应一个可独立描述的逻辑修改而不是攒一个月的大杂烩。小提交天然让冲突区域变小也方便cherry-pick精准搬运。分支生命周期要短。一个 feature 分支活一两个月几乎必然和 main 产生大量代码漂移。我会尽量让分支在一两周内合回主分支中途至少一两天git fetch一次及时把 main 的更新合并进来。任务边界要清晰。两个人同时改同一个服务里同一个核心模块用什么 Git 技巧都难救。团队做需求排期时就把文件归属说清楚比任何工具配置都有效。6.3 分支策略与模块边界有些团队适合 Git Flow有些适合 trunk-based没有绝对正确。但从减少冲突这个角度看模式越简单越好。trunk-based 强调所有人频繁小步合并到主干分支存活时间以天计冲突概率天然很低。如果你需要并行维护多个版本可以考虑git worktree它让同一个仓库的不同分支在物理目录上分开省去来回 stash 的麻烦也减少切换分支时本地修改交叉引发的冲突。至于git submodule我的经验是能不用就不用。子模块在父仓库中只是一个 commit 指针多人更新子模块后父仓库非常容易因为两边各自指向不同子模块提交而出现无法自动合并的情况。处理方式也不复杂冲突时进入子模块目录看到正确的 commit 后git add 子模块目录即可。真正需要多仓库复用的场景可以评估 monorepo 或更成熟的包管理方案但这是另一个话题了。最后说说我个人的习惯。我做完一次带冲突的 merge 或 rebase 之后不会只验证编译通过就收工而是专门用git diff把人工取舍过的区域重新过一遍确认没有把自己的逻辑混丢。如果哪次 rebase 让我连续解决了四五个冲突我会停下来反思是不是分支活得太久、欠的代码账太多了该早点同步主分支了。冲突解决能力当然要练但通过规范协作让冲突尽量少出现才是更省心的路线。这个道理不是从文档里看来的是我踩过不少坑之后才真正认同的。
返回列表