
前面五篇把 Git 的基础命令、暂存区、提交、远程仓库、分支管理都盘了一遍分支怎么建、怎么切、怎么删大家应该都练过几轮了。但很多同学学到这一步心里其实一直悬着一个大问题分支是能随便开可最后怎么合回去合并的时候突然跳出一堆 conflict密密麻麻的红字该怎么处理今天这篇就专门把这个坎儿迈过去。我会从合并的底层逻辑讲起再走一遍完整的合并流程最后把常见的几类冲突逐一拆开给出一套能直接抄作业的解决思路。不管是个人项目开发还是团队里走 GitLab / GitHub 的 Merge Request 流程这篇文章都能用上。内容不绕弯子按顺序往下看就行。1. 合并之前先把三件事搞清楚1.1 合并的本质是什么在动手敲git merge之前建议你先把“合并”这两个字到底在做什么想明白。Git 里的分支本质上只是一个个提交对象组成的有向无环图上的游标。所谓合并就是把两个分支各自“走岔”的提交历史重新捏到一起让它们在逻辑上成为一个新的整体。举个例子你从master拉出feature-login分支做登录功能与此同时master上别人又提交了一个修复 bug 的改动。这时候两条分支的提交历史就分叉了。合并的动作就是要把feature-login上新增的提交和master上新增的提交都保留下来最终汇成一个包含双方改动的结果。理解了这一点你就能明白为什么合并有时候是件简单事有时候却变成一场灾难。简单的时候是因为双方改动的区域根本不重叠灾难的时候是因为大家都在同一块地盘上动土Git 没法替你拍板。1.2 值班两种人直接登陆的 Fast-forward 合并与三方合并很多教程上来就讲命令但我觉得先把两两种合并机制分清楚更重要否则即使执行git merge成功了你都不知道自己刚才经历了什么。第一种叫 fast-forward也就是快进合并。它的触发条件很苛刻当前分支从某个点切出去之后这个“根”分支自己一直没有产生新的提交。这时候 Git 不需要真正“合并”任何内容只需要把当前分支的指针直接挪到目标分支的最新位置就像顺着一条直路往前走。比如你从master拉出feature分支做了三个提交期间master纹丝不动那么把feature合回master时Git 就会执行一次 fast-forward。结果就是提交历史变成了一条直线看起来非常清爽。第二种叫三方合并three-way merge这才是合并真正复杂的地方。它的触发条件是两条分支自从分叉之后都有新的提交。此时 Git 会寻找三个对象共同祖先merge base、当前分支的 HEAD、以及你要合并进来的那个分支的最新提交。Git 分别拿着这三份内容做比较尽量把双方各自的新改动融合成一份结果。共同祖先base---- 当前分支改动HEAD \ / ---- 目标分支改动feature三方合并既是 Git 最强大的地方也是各种冲突的来源。理解它你才能在被冲突折磨的时候知道 Git 到底在纠结什么。1.3 合并前必须做的两个检查实操之前有两个习惯我强烈建议你先养成。第一确认你当前工作区是干净的。git status输出 “nothing to commit, working tree clean” 再合并否则会把未提交的改动和合并过程搅在一起一旦冲突出现现场会非常难看。第二确认你要合到哪个分支上。很多人习惯站在feature分支上执行git merge master结果把 master 的改动合到了 feature 分支里这本身没有错但如果本意是“发布 feature 到 master”方向就反了。我见过不少刚入门的朋友在这个地方翻车原因就是没有先切到目标分支。所以记住这个口诀先切到接收方再执行合并命令。提示执行git merge之前先git status看一眼工作区再git log --oneline --graph --all看一眼分支走向双重确认再动手。2. 合并实操从本地到远程的完整流程2.1 本地分支合并的标准三步走先给出一套最稳妥的本地合并流程适合大多数场景。第一步切到接收方分支。假设你要把feature-login合到master那就先执行git checkout master git pull origin master这里先pull是为了让本地master和远程保持同步避免拿一个过时的 master 去合并。很多冲突其实是“本地 master 已经落后了”造成的这一步能挡掉不少无谓的 pain。第二步执行合并git merge feature-login如果一切顺利你会看到一条消息提示这次 merge 是 fast-forward 还是 “Merge made by the recursive strategy”后面跟着被改动的文件列表。第三步查看合并结果并提交如果需要的话。Git 在执行git merge的时候会自动生成一个 merge commit前提是这次合并不是 fast-forward。也就是说正常情况下你不需要手动git commit除非你主动加了--no-commit参数。合并完用git log --oneline --graph看一下如果看到分叉的两条线汇聚到一个点那就说明一次标准的三方合并完成了。2.2 怎么判断合并用了哪种方式我见过不少人在合并之后一头雾水同样是git merge为什么有时候历史是直的有时候会多出一个“Merge branch”的提交判断方法很简单git log --oneline --graph展示出来如果提交历史是一条直线没有出现分叉交汇的节点那刚才就是 fast-forward。如果出现了一个带两个父提交的 merge commit在 graph 图上表现为两个分支线汇聚到一点那就是普通的三方合并。如果你希望强制走三方合并、哪怕实际上可以 fast-forward也就是为了在历史上保留一个“我合过一次”的提交节点可以在合并时加--no-ffgit merge --no-ff feature-login这个参数在团队合作里用得比较多。因为有些团队喜欢用 merge commit 作为“一次功能合并”的标记方便以后通过git log --graph快速回溯“这个功能是什么时候合进主干的”。与之相反如果你完全不希望保留 feature 分支的细碎提交历史只想把最终结果作为一个提交合进来那就用--squashgit merge --squash feature-login git commit -m feat: 登录功能使用--squash之后Git 会把 feature 分支上的所有改动压缩成一个“待提交状态”你需要手动执行一次 commit。这种方式的代价是会丢失原分支上每个提交的独立意义适合功能比较简单、一次提交就能说清楚的情况。2.3 远程分支与 GitLab / GitHub 的合并流程除了本地命令行现在很多团队用的是 GitLab 或 GitHub 上的 Merge RequestMR / Pull RequestPR 流程。这个流程的本质还是在远程仓库那边执行一次合并操作但多了一层“人工审核”的门槛。在 GitLab 上你通常是把feature分支推到远程然后发起一个 MR 请求把feature合并到master。如果代码没有冲突页面上会直接出现 “Merge” 按钮。如果出现冲突GitLab 会提示 “Cannot be merged”需要你手动处理。处理方式有两种一种是在页面上用 “Resolve conflicts” 在线解决简单冲突另一种更稳妥的做法是在本地把master拉回feature分支先解决冲突后再推上来更新 MR。具体到本地操作一般是git checkout feature-login git fetch origin git merge origin/master这一条命令是真正的“协同利器”。它的意思是把远程 master 的最新改动合并到当前功能分支里提前把冲突在功能分支上解决掉而不是等到 MR 合并的时候再抓瞎。功能分支自己更新得越勤快最后合并回 master 的时候就越顺畅。注意如果在本地直接合并 origin/master 后发现冲突很多不要急着强推。先用git status看清楚解决了再git push更新功能分支否则会把冲突标记原样推上去队友拉下来全是符号非常坑。2.4 合并到哪里最容易出问题目录结构变动很多人忽略了另一种“大合并”当功能分支改动了大量目录结构比如把某个模块整体移动位置而 master 分支上其他人又在这个模块里新增了文件合并时 Git 的 rename detection重命名检测不一定能百分之百识破你的意图。这时候常见的表现是新文件没有被识别为移动后的文件而是显示为删掉旧文件、新增一份一模一样的文件。遇到这种情况不要急着手动改文件。可以先强制让 Git 做一次 rename 检测git merge master -X rename-threshold30这个参数的意思是当两个文件相似度超过 30% 时就认为是“同一个文件被重命名了”。阈值设低一点Git 更容易识别出改名后的对应关系从而把改动更正确地合并过去。不过这个参数属于进阶玩法遇到目录大变动的时候再用平时保持默认即可。3. 合并冲突是怎么产生的3.1 冲突的本质Git 没有上帝视角说了这么多终于到了正题冲突。首先给个结论冲突不可怕它是三方合并机制正常工作的一种信号。Git 不是不让你合并而是告诉你“两边的改动我都看到了但它们放在同一个位置我决定不了以哪边为准你来看看。”冲突的前提是两条分支都修改了同一个文件的同一个区域。Git 拿共同祖先、当前分支和目标分支三份版本做 diff如果发现当前分支和目标分支对同一行做了不同的修改它就判断为冲突。这时候它会把自己能合的部分都合好只把拿不准的部分用冲突标记标出来等你人工裁决。打个比方你和同事同时在改一份合同。你想把第 3 条改成“付款期限 30 天”他想把同一家改成“付款期限 60 天”。两个人改的是同一句话系统不知道该听谁的只能把两种版本都摆出来让你俩商量。代码里的冲突就是这么回事。3.2 冲突标记逐行解读当你触发了冲突打开冲突文件会看到类似这样的内容 HEAD 当前分支的代码 function login() { return login from master; } 功能分支的代码 function login() { return login from feature; } feature-login这里每一段符号都有明确含义 HEAD冲突块开始下面到之前的内容是当前分支HEAD里的版本。分界线把它上下两部分隔开。 feature-login冲突块结束上面到之后的内容是你要合并进来的那个分支这里是 feature-login的版本。如果你用的是 VSCode 这类编辑器冲突区域会有明显的红绿色背景并且提供 “Accept Current Change”、“Accept Incoming Change”、“Accept Both Changes” 等按钮这就是把上面的标记转换成了可视化操作。但这里我要特别提醒一句讨论冲突时我们常把 HEAD 叫“当前版本”把feature-login叫“对方版本”这只是从“谁在合并谁”的角度说的。并不代表 HEAD 版本天生优先级更高。最终保留谁的代码完全取决于代码逻辑而不是谁站在哪一边。3.3 一个容易忽略的细节diff3 风格有时候默认的冲突标记让你非常困惑你既想知道两边各自改了什么更想知道改之前原来是长什么样的。默认merge.conflictStyle是merge只会显示两边版本。但如果把配置改成diff3git config --global merge.conflictstyle diff3冲突块会变成三段多了一个“合并前的基础版本” HEAD 当前分支的代码 ||||||| 1a2b3c4 基础版本共同祖先 功能分支的代码 feature-login这个|||||||段就是手工解决冲突时最重要的参考。因为它能帮你判断两边到底是谁改了谁没动比如基础版本是count 0;当前分支把它改成count 10;功能分支也把它改成count 15;那你就能很清楚地看到两边都有意修改剩下的问题就是选 10 还是 15或者结合上下文取一个新值。我自己的经验是遇到复杂冲突超过一个代码块那种先切到 diff3 风格再处理思路会清晰一大截。3.4 Git 不报冲突但你可能还是想干预的几种情况还有一种隐蔽情况Git 觉得能自动合并不报任何冲突但真正的“逻辑冲突”其实已经发生了。比如两个人同时改了一个上下文切换的变量名一个人把user改成了account另一个人在他没读到的地方也写了user。三方合并按行匹配可能把变量名合并出一种“一半新一半旧”的诡异状态代码跑起来直接报错。这种情况 Git 帮不了你只能靠测试和人工 review 去揪出来。所以合并完尤其是多人协作的合并跑一遍测试用例或者至少编译一下是必须的动作不要以为没有冲突就万事大吉了。4. 六类合并冲突的具体表现与解决思路下面我把日常开发里见到频率最高的冲突类型逐一拆开每类都给明确的操作思路。为了好记我用一个表格先把各类冲突的特征和解决要点列出来再逐个展开。冲突类型特征优先解决思路同一文件不同位置被修改本地混入双方改动后仍可合手动合并必要时保留两侧内容同一代码块被修改同一函数/语句两边逻辑不同对比共同祖先人工决定最终逻辑一边修改一边删除一方删了文件另一方改了内容确认该文件是否还需要存在再决定保留删除还是内容文件重命名一方移动文件另一方改了文件内容用 rename 检测降低阈值手动确认对应关系二进制文件图片、Excel 等无法按行合并只能二选一或用专门工具重新生成空白字符差异缩进、换行符不同导致大面积伪冲突开启忽略空白选项统一团队格式化规则4.1 同一文件不同位置修改最友好的冲突这类冲突的特征是两边都动了同一个文件但改的位置不重叠。多数情况下 Git 能自动合并只有在边界线上紧挨着的改动才会被标记为冲突。比如一个函数库里当前分支在上面加了一个函数功能分支在下面加了一个函数Git 通常自己就能合好。如果恰好一个把位置标在文件末尾另一个也在文件末尾才会出现冲突。解决思路很简单冲突标记里的两段内容很可能可以同时保留。你只需要确认上下文的顺序没有错乱把两个版本的代码都留下即可。编辑器里选择 “Accept Both Changes” 通常就是正确答案然后删除多余的冲突标记。4.2 同一代码块被修改最常见的硬仗这是最常遇到的场景。两个分支都对同一个函数、同一个表达式做了实质性修改。这时候别上来就想着“选一边”先停下来看共同祖先版本搞清楚各方改动的目的再决定是选一边、还是把两边的意图揉成一个新版本。假设基础版本是function getDiscount(userType) { if (userType vip) { return 0.8; } return 1; }当前分支把它改成了vip打 7 折功能分支加入了new_user打 9 折的逻辑。冲突标记里两段都是合理的改动正确解绝不是二选一而是把两段合并成function getDiscount(userType) { if (userType vip) { return 0.7; } if (userType new_user) { return 0.9; } return 1; }这种“第三方方案”只靠冲突标记本身无法自动完成需要你理解两边的业务逻辑。所以解决冲突时条件允许的话最好把改动的队友叫过来问一句比你自己猜要快得多。4.3 一边修改一边删除文件到底还要不要这种冲突有一个典型识别标志git status里显示的是deleted by them或deleted by us而不是both modified。场景重现你在功能分支上大改特改文件config.js另一个分支上同事觉得这个文件没用就直接删了。合并时 Git 骑虎难下按你的版本文件应该保留按对方的版本文件应该是消失的。解决前先问自己一个问题这个文件现在还需要吗如果确定要保留就用git add config.js把这个文件从冲突标记里“解救”回来或者执行git add config.js如果确定要删除执行git rm config.js然后在后续的git merge --continue中提交这个决定。这类冲突最忌讳的是犹豫不决删除保留二选一没有中间态赶紧拿主意就对了。4.4 文件重命名与合并的组合拳重名合并冲突比较隐蔽且容易被当成“新文件冲突”忽略掉。当功能分支把report.js重命名为annual-report.js而 master 分支上有人又改了几行report.js的内容Git 的 rename detection 可能成功识别“这是同一个文件”然后把改动合过去。但如果相似度不高Git 就不会把它当作重命名于是出现新文件annual-report.js和改动过的旧文件report.js同时存在于工作区的结果。处理方式git merge master -X rename-threshold30刚才提过这个参数这里是它最实用的场景。如果这样处理后Git 仍然没有正确配对那就只能手动把旧文件的内容合并到新文件里然后显式删除旧文件# 手动把旧文件内容合并到新文件后 git rm report.js git add annual-report.js这招别随便用只有在目录重构后出现大量错误配对时才值得手动干预。4.5 二进制文件冲突不靠编辑器靠人决策二进制文件比如图片、Excel、Word 文档、编译产物合并时的表现和源码完全不同。因为 Git 不能逐行比对它只会说“两边都动了这个文件我无从下手”。你打开文件看到的只会是二进制内容全是乱码没法像文本一样手动拉取两段。面对二进制冲突现实一点的方案有三种选其中一方放弃另一方git checkout --ours -- path/to/file或git checkout --theirs -- path/to/file然后git add标记解决。重新生成如果这是一个由代码生成的报告或图片最快的办法是修复生成源然后重新导出覆盖。用支持二进制合并的专业工具比如某些设计工具的 .psd 文件插件、Excel 的联机合并这类工具能做到“基于两个版本合并”而不是“二选一”。对于团队里频繁出现的二进制文件我的建议是图纸、设计稿最好别直接塞 Git而是用专门的文件管理平台代码库里只保留引用或说明。非要入库的话也要约定好一次只让一个人改改完立刻合并分发避免多人同时操作。4.6 空白字符引发的“伪冲突”这种冲突最气人因为它不是真正的逻辑冲突。A 同事用了 4 个空格缩进B 同事的编辑器配置了 Tab 缩进一合并整个文件到处飘出冲突标记代码逻辑其实一点都没冲突。对付这种情况Git 提供了空白处理参数git merge feature -Xignore-space-change-Xignore-space-change会忽略行末空白变化-Xignore-space-at-eol只忽略行尾空白。但要注意这些参数都是临时策略它们把“空白差异”这个信息扔掉了如果某次修改真的只改了空白你用了这个参数后合并结果里可能连这个修改都看不到。根治办法是团队内统一编辑器配置提交一份.editorconfig文件放到仓库根目录再配合项目里的代码格式化工具如 Prettier、ESLint 等让所有人的换行符、缩进风格一致。这个坑早统一早解脱。5. 冲突实战拆解从开始合并到提交完成5.1 模拟一次完整的冲突现场步骤有点多我从零开始模拟大家可以直接照抄着练一遍。假设当前在master分支上仓库结构如下# 创建一个演示文件 echo 第一版内容 demo.md git add demo.md git commit -m init demo # 创建功能分支 git checkout -b feature-modify # 在功能分支上修改文件 echo 功能分支修改 demo.md git commit -am feat: 功能分支的改动 # 切回 master也修改同一个位置 git checkout master echo 主分支修改 demo.md git commit -am master 的改动 # 执行合并 git merge feature-modify执行到git merge feature-modify大概率会出现Auto-merging demo.md CONFLICT (content): Merge conflict in demo.md Automatic merge failed; fix conflicts and then commit the result.看到CONFLICT (content)这一行就说明冲突了。此时git status会给出类似Unmerged paths: (use git add file... to mark resolution) both modified: demo.md5.2 手工解决冲突的完整操作打开demo.md你会看到类似这样的内容第一版内容 HEAD 主分支修改 功能分支修改 feature-modify这里我们假设两边的改动其实都有意义决定同时保留于是把文件手动改成第一版内容 主分支修改 功能分支修改然后把增量标记解决掉告诉 Git 这个冲突已经处理完毕git add demo.mdgit add在这里的作用就是标记“冲突已解决”所以可以不急着 commit。接着执行git merge --continue这时 Git 会打开编辑器让你写 merge commit 的提交信息默认已经写好了直接保存退出即可。至此一次从冲突出现到解决的过程就完成了。注意不要用git commit直接提交虽然效果上差不多但git merge --continue会按正规矩拼接 merge commit 的信息不会漏掉 MERGE 状态建议用它。5.3 使用图形化工具提高效率如果团队里冲突比较多纯靠手写冲突标记效率太低。可以考虑配置外部合并工具。命令行里最常用的是vimdiff不过对新手不友好。我推荐用 VSCode 或 Beyond Compare。VSCode 本身自带冲突可视化界面。开启红色高亮后可以直接点击按钮选取当前改动、引入改动或同时保留。修改完保存去终端执行git add就行。如果要配置 Beyond Compare 这类外部工具一条命令就能设置git config --global merge.tool bc3 git config --global mergetool.bc3.path /usr/bin/bcompare合并时遇到冲突直接执行git mergetoolGit 会逐个打开冲突文件让你在外部工具中完成三栏合并左边当前分支、右边目标分支、中间共同祖先。这个三栏视图对复杂冲突尤其友好可以直观看到两边各自改了什么、改之前什么样。6. 合并到一半想反悔的保命操作6.1 放弃合并冲突还没解决时合并过程中发现冲突太多或者发现自己合错分支了第一反应不要是手动删文件而是先撤git merge --abort这条命令会完全放弃本次合并把工作区恢复到执行git merge之前的状态所有合并产生的未提交改动都会被丢弃。前提是合并过程中你没有手动git add并git commit。如果你已经提交了 merge commit那就不是abort能解决的了得用下面的方式。6.2 回退误合并已经生成 merge commit 之后分两种情况。第一种情况merge commit 还只在本地没有被推送到远程。这时最简单粗暴的方式是git reset --hard ORIG_HEADORIG_HEAD是 Git 在执行合并等危险操作前自动记录的“原来位置”。执行这条命令当前分支会跳回到合并之前的位置合并产生的提交全部消失。注意--hard会把工作区的未提交修改也一并丢弃执行前确认没有要保留的东西。第二种情况merge commit 已经被推送到远程别人可能已经拉走了。千万别用reset强推覆盖因为这会改写公开历史队友下次 pull 会非常痛苦。正确做法是用 revert 生成一个反向提交git revert -m 1 merge-commit的哈希值-m 1表示保留 merge commit 的第一个父提交也就是合并前的原分支把第二个父提交被合并进来的那个分支的影响回退掉。这样本地会生成一个新的提交这个提交能把之前合并的内容全部撤销而且不破坏历史。6.3 用 reflog 找回丢失的提交如果你不小心执行了git reset --hard又发现合错了的其实是可以用的东西那就该git reflog出场了。它记录着所有分支指针移动的历史包括你刚才 reset 掉的那次合并。git reflog输出会列出当前的提交 SHA、执行过的操作及说明。找到合并产生的那条记录用git cherry-pick或git branch恢复即可。reflog 是 Git 最容易被忽视的保命技能建议每个人都练一遍。7. 团队协作中如何从源头上减少冲突7.1 小步提交高频同步冲突的产生有一个铁律两条分支分叉越久合并的代价越大。如果你拉了一条功能分支一埋头写了三个星期不更新等回头合并的时候基本就是一场灾难。正确做法是功能分支每次有阶段性的完成品就执行git fetch origin git merge origin/master高频同步的好处是你会发现大多数冲突都是小冲突因为两边的改动间隔很短共同祖先离得很近Git 自动合并的成功率非常高。7.2 功能分支保持“短命”理想的功能分支应该在一两天内合回主干。一个功能分支存活的时间越长和别人发生交集的可能性就越大。如果任务太大不要局限于一个分支可以拆成多个小步骤每个步骤都合回主干一次做到“小步快跑”。这不仅减少冲突review 起来也轻松。7.3 用 MR / PR 流程给自动合并加一道保险GitLab 和 GitHub 的 MR/PR 流程虽然有“人工审核”这一步但它的另一个好处是可以让你看到冲突是否出现。如果 MR 页面提示 “Cannot merge automatically”不要慌在本地先处理冲突再 push 更新MR 就能重新变绿。这一套流程本身就是倒逼大家保持高频同步的机制。另外很多团队会在 MR 里配置 CI代码合并前自动跑测试。这一步是压死“无声逻辑冲突”的最后一根稻草推荐有条件的一定要加上。7.4 统一格式化和编码规范在 4.6 里我提过.editorconfig和格式化工具这里再强调一遍团队仓库根目录放一份.editorconfig统一缩进、换行符和末尾换行规则。对于前端项目强制用 Prettier 之类的工具提交前执行格式化。只要大家习惯一致因为空白差异引发的伪冲突能减少八成以上。7.5 合理划分模块减少“共用文件”从软件工程的角度看冲突的根源是两个人在同一时间碰了同一个文件。如果模块划分得足够合理每个人负责的目录尽量不重叠冲突自然无从谈起。但这属于架构层面的事一时半会改不动也不用急先养成“改文件前看一眼谁最近也在动这个文件”的习惯及时沟通比事后解冲突省力得多。最后再分享一个我自己的习惯解决冲突时我永远不会先看冲突标记里的“当前版本”和“对方版本”谁对谁错而是先看共同祖先那一版。几乎所有复杂的冲突答案都在那一版里。想清楚改动意图之后再去决定是单边保留还是两边揉合基本就不会处理出逻辑上的问题。这个思路对新手尤其有效希望大家也能在自己的项目里试试看。