
开发群里最怕听到一句话冲突了谁来处理一下。每当这时候总有人盯着满屏的和发愣也有人翻着聊天记录想找出是谁动了那一行。坦白讲我刚开始用 Git 的那一两年也对冲突发怵甚至觉得是不是我合并的方式不对。后来把合并机制吃透又处理过几百次冲突之后才明白git代码冲突不是代码坏了也不是 Git 能力不行它更像是 Git 在分支合并时发现两边做了互不相让的改动停下来问你一句到底听谁的。这篇文章就把冲突这件事讲透先解释冲突是怎么产生的、冲突标记代表什么再带你把一次冲突从出现到解决完整跑一遍然后拆解真实开发里最常见的几类冲突形态最后聊聊怎么靠工程习惯让冲突少到几乎不用遇见。适合刚接触分支开发的新手也适合天天帮同事收拾冲突的老开发。1. 冲突是 Git 在向你求助合并机制与冲突标记的底层逻辑1.1 Git 到底怎么判断冲突了很多人以为 Git 是把两个分支的代码直接叠在一起比较这个理解有点偏差。真实过程中Git 执行合并时拿到的是三个版本的对比当前分支的最新版本、被合并分支的最新版本以及两个分支分叉之前的那个公共提交版本专业叫法叫merge base合并基。三方对比的规则并不神秘只要当前分支改了某一行另一个分支没动这一行Git 就能自动采用你改后的版本。如果两边改的是完全不同的文件甚至同一个文件的不同区域Git 也能自动合并。只有当双方都改了同一文件的同一位置、且改法不一致时Git 没有标准答案才会停下来等你拍板。这个机制想明白后那些怎么合并了两个分支我的代码不在了的困惑就能解释清楚了。比如你这边删了一行对面那行也改了Git 不知道该按谁的来只能把两段都摆出来给你看。这也是为什么一次合并可能改了二十个文件冲突却只有一个文件——因为绝大多数改动其实 Git 都能自动处理它只把你真正需要做决策的地方留了出来。每次遇到有人跟我说Git 太蠢了合并都做不好我都会纠正他Git 不蠢它反而很诚实。代码合并本质上是想办法把两个人对同一版本的修改捏合成一个最终成果这个动作在理论上就不存在完美的自动方案。Git 能在大多数情况下自动合并已经是它聪明的体现了。真正到了冲突那一刻是它把本该由人来承担的判断权交还给你。1.2 冲突标记Git 留给你的便签纸冲突发生之后你打开冲突文件会看到类似这样的内容 HEAD const RETRY_TIMES 3; const RETRY_TIMES 5; feature/retry这四行标记的含义很好记 HEAD到之间是当前分支你执行 merge 时所在分支的内容到 feature/retry之间是被合并分支feature/retry的内容。Git 把双方存在分歧的区块原原本本摆在一起你的任务就是从两段里挑一个或者重新写一段然后把那些标记行删掉。注意这个标记是 Git 原样写进文件里的不是注释也不是临时文本。如果谁稀里糊涂没清理就直接提交轻则编译不过重则把 HEAD这种字符串写进业务代码。我见过有人把冲突标记提交到正式分支上的排查起来非常狼狈。所以解决冲突的第一原则是改完文件后扫描全文确认没有任何冲突标记残留。检查命令很简单grep -rn -E ^({7}|{7}|{7}) . --include*.py我每次处理完冲突都会跑这一条确认输出是空的再提交。你也可以把这个查文件的习惯固化下来尤其是冲突涉及十几个文件的时候只靠肉眼扫很容易漏。1.3 冲突状态是现在时别用旧眼光看文件当冲突发生时除了文件里的标记Git 还在仓库的.git目录里记录了这次合并的状态比如.git/MERGE_HEAD。这意味着你随时可以用git status、git diff去查看当前卡在哪。碰到我明明改了但不知道改到哪了的新手我一般直接让他先跑git status看哪些文件标着both modified那就是冲突面。先把冲突面找齐再动手改文件比打开几个文件乱翻效率高得多。2. 亲手造一个冲突再解决它全流程实操演示2.1 一分钟复现冲突现场很多人看教程最怕纸上谈兵所以我们先花一分钟在自己的电脑上造一个真实的冲突现场。打开终端按下面的命令走一遍mkdir -p ~/git-learning cd ~/git-learning git init conflict-demo cd conflict-demo git config user.name You git config user.email youexample.com echo def hello(): app.py echo print(hello) app.py git add app.py git commit -m feat: init app git checkout -b feature/refactor-hello # 用编辑器把 app.py 里的 hello 改成 hi git add app.py git commit -m feat: change hello to hi git checkout main # 用编辑器把 app.py 里的 hello 改成 bonjour git add app.py git commit -m feat: change hello to bonjour git merge feature/refactor-hello执行到git merge feature/refactor-hello时你会看到类似这样的输出Auto-merging app.py CONFLICT (content): Merge conflict in app.py Automatic merge failed; fix conflicts and then commit the result.恭喜你的第一个冲突诞生了。这里我不建议用sed -i去改内容因为 macOS 和 Linux 的sed参数不兼容新手很容易在这一步翻车。老老实实用编辑器打开文件改文字最稳。2.2 诊断冲突范围先看清战场再动手冲突发生后第一步永远不是急着改文件而是先搞清楚战场有多大。执行git status你会看到类似这样的结果On branch main You have unmerged paths. (fix conflicts and run git commit) (use git merge --abort to abort the merge) Unmerged paths: (use git add file... to mark resolution) both modified: app.pyboth modified就是冲突的标准信号。如果项目文件多你还可以用一条命令把所有冲突文件一次性列出来git diff --name-only --diff-filterU这条命令只输出未合并Unmerged的文件路径比在git status里一行行找清爽多了。如果你想看冲突的详细内容用git diffGit 会同时显示两侧冲突区块的具体差异。看完之后再决定怎么处理。很多人跳过这一眼直接就去冲突文件里改结果改到一半发现根本不是自己想的那样浪费时间。2.3 冲突解决的三种姿势手动编辑、取我方、取对方解决冲突本质上就是在三个选项里做选择保留当前分支ours、保留被合并分支theirs、或者两者都要再自己改一版。对应到实际操作姿势一手动编辑。打开app.py会看到冲突标记。把 HEAD、、 feature/refactor-hello这些标记行删掉留下你想要的内容。比如决定用 bonjour就保留print(bonjour)然后把文件保存。姿势二明确只需要一边的版本。比如调研后发现 feature 分支的写法已经完全不需要可以直接用命令让 Git 帮你选git checkout --ours app.py # 保留当前分支版本 git checkout --theirs app.py # 保留被合并分支版本这里必须提醒一句新手特别容易踩的坑在 merge 场景下--ours指当前分支你执行 merge 时所在的分支--theirs指被合并进来的分支。但你如果是在做 rebase这俩角色会反过来。我在第 5 章会专门聊这个反转。姿势三两边都要重新写一版。很多冲突不是二选一能解决的。比如 main 分支加了一个参数校验feature 分支重构了同一个函数的内部逻辑正确结果往往是保留 feature 的重构骨架补上 main 的参数校验。下表是我日常处理冲突时放桌面的速查场景命令说明查看所有冲突文件git diff --name-only --diff-filterU快速列出 Unmerged 文件查看冲突差异git diff看双方具体改动保留当前分支版本git checkout --ours file快速采用 HEAD 侧内容保留被合并分支版本git checkout --theirs file快速采用对侧内容标记冲突已解决git add file告诉 Git 这个文件处理完了放弃本次合并git merge --abort回到合并前的状态2.4 收尾动作add、commit、验证一个都不能少冲突文件改完后执行git add app.py git status注意git add的作用不只是把文件加入暂存区更重要的是告诉 Git这个文件的冲突我已经处理完了。执行完git status后app.py 会从both modified变成正常的modified然后你就可以正常提交git commit -m merge: 合并feature/refactor-hello采用bonjour文案这里的提交会生成一个 merge commit它跟普通提交不一样有两个父提交。你可以用下面的命令看合并结果git log --graph --oneline你会看到* 2f3a4b5 merge: 合并feature/refactor-hello采用bonjour文案 |\ | * c1d2e3f feat: change hello to hi * | a1b2c3d feat: change hello to bonjour |/ * 9e8f7a6 feat: init app看到这个分叉结构就说明合并真正完成了。3. 真实开发中最常见的冲突形态与对应破解思路3.1 同一逻辑被改成了两套实现冲突解决是业务决策不是代码编辑工作中最常见的冲突是两个人对同一个需求各自写了一套实现。比如缓存时间main 分支把 token 有效期从 60 分钟改成了 30 分钟feature 分支却改成了 45 分钟两边还都加了注释说明这是产品确认过的。这种冲突看着是代码问题实际是业务问题。如果你直接选一边很可能把另一方辛苦对齐的需求丢掉。我处理这类问题时一定会做两件事先看一眼两个分支最近的提交信息搞清楚他们各自改动的出发点然后找相关同事口头确认一遍。很多冲突根本不是谁写错了而是需求理解产生了分叉这时候解决的不只是 Git 冲突还是团队沟通问题。至于技术操作反而不复杂——把双方代码都保留下来重新组织逻辑删掉冲突标记提交。重点是别把选边当成默认动作。3.2 一边删文件一边改文件deleted by us / deleted by them 的处理二进制冲突之外还有一种让新手很懵的情况一侧删除了某个文件另一侧还在修改它。合并时你会看到类似这样的状态Unmerged paths: deleted by them: legacy.py意思是feature 分支把legacy.py删了而 main 分支还在里面修 bug。Git 不知道你最终想不想要这个文件于是把它列为冲突。这时候的判断依据很简单这个文件现在的业务价值还在不在如果确认不需要了直接git rm legacy.py告诉 Git删掉是对的。如果还需要保留用git checkout --theirs legacy.py把 feature 侧删除前的版本恢复回来再手动合入 main 分支的 bug 修复。文件重命名的冲突也属于这一类。Git 会把重命名识别成删除旧文件 新增新文件如果另一个分支恰好也在操作旧文件就会产生冲突。处理逻辑一模一样搞清楚业务到底是要旧文件还是新文件再做删除或保留。3.3 大规模重构分支合入时的连片冲突最让人头大的冲突是结构性改动撞上增量改动。举个例子A 同学把utils.py里的工具函数整体迁移到了lib/helpers.py并把调用方全部改掉B 同学这段时间一直在往utils.py里加新函数。等两个分支合并时你会看到十几二十个文件都在冲突而且不是简单的选一边能解决的。遇到这种连片冲突我的建议是不要试图在冲突文件里手工来回改。先把结构性改动完整保留下来通常重构分支的版本更接近最终目标然后逐个把增量改动 apply 回去。实际操作上可以先把冲突文件全部采用重构分支版本git checkout --theirs .然后重新梳理那些增量功能利用git cherry-pick把 B 同学的提交一个个摘过来摘到某个文件冲突时再单独处理。这样比在二十个文件里同时解冲突要可控得多。更重要的是这种冲突一定要拉上两个分支的负责人一起对齐。闷头自己解完很容易导致重构的成果被抹掉或者新功能被弄丢。冲突解决完后让涉及同事过一眼最终代码能在上线前省掉很多事故。3.4 二进制文件与锁文件不要试图手动合并图片、jar 包、exe 这类二进制文件Git 没法做逐行合并。还有一类容易被忽略的是锁文件比如package-lock.json两个分支只要各自安装过依赖它就大概率会冲突。有人会打开package-lock.json试图手动把两边的 JSON 拼起来这是标准的作死行为。锁文件必须由包管理工具自己重新计算才有效。正确做法是保留某一方的版本然后重新执行安装命令让工具把锁文件重新生成git checkout --theirs package-lock.json npm install道理很简单package-lock.json记录的是依赖树的完整快照手工拼接几乎不可能拼出一个自洽的版本最后你还是要靠npm install去重算。与其纠结冲突标记不如直接让工具接管。其他二进制文件也是同一个思路保留一方之后重新生成。比如设计稿图片就按最新确定的设计版本覆盖编译产物就重新构建。3.5 可视化工具能帮大忙mergetool 配置如果你觉得在命令行里解冲突不够直观Git 其实自带了一个俗称mergetool的机制可以把冲突文件交给可视化编辑器处理。我推荐两种配置用 VS Code 做 mergetoolgit config --global merge.tool vscode git config --global mergetool.vscode.cmd code --wait $MERGED用 KDiff3老牌三方对比工具git config --global merge.tool kdiff3配置好后遇到冲突只需运行git mergetool它会弹出一个三栏编辑器左边 ours、中间 base公共祖先版本、右边 theirs下方是合并结果区。你可以在可视化界面里直接选择、编辑保存后工具会自动帮你清理冲突标记。不过我得说实话mergetool 只是把信息呈现得更直观最终决策还是要你自己做。它解决的是看清场上局势的问题不负责判定谁对谁错。4. 解决错了怎么办验证、回滚与兜底手段4.1 冲突解决后的验证顺序优先级排第一冲突解决本质上是一次人工合并代码正确性完全由你承担。Git 能帮你标记冲突但没法保证你选的组合在业务上是对的。所以我每次解决完冲突都会按固定顺序做一遍验证看最终 diffgit diff HEAD^ --stat查看这次合并引入了哪些文件变化和两边分支的实际内容对一下。查残留标记grep -rn -E ^({7}|{7}|{7}) . --include*.py确保没有漏网的冲突标记。编译 跑相关测试只要工程支持先编译再跑跟冲突文件相关的单测。拉个同事过一眼冲突文件越多越需要第二双眼睛。我自己的经历里就出现过一次自认为合并得很完美结果上线后才发现把其中一方的配置项给丢了。从那次之后找同事 review 冲突文件就成了我解决冲突后的固定动作。4.2 合并过程中反悔merge --abort 与 --quit如果冲突越解越乱或者你发现根本不应该合这个分支Git 给了你后悔药git merge --abort执行后仓库会干净地回到合并之前的状态所有冲突解决工作全部丢弃。注意是全部丢弃所以如果你已经手工解决了几个文件、里面还有不想丢的内容先把它们备份到仓库外面再 abort。还有个冷门命令git merge --quit它的作用是放弃本次合并状态但保留你工作区里改到一半的文件。这个一般不推荐因为它会把仓库置于一个既不是合并中、又不完全干净的状态后续容易搞混。还有一个常见疑问合并已经提交了还能不要吗能但要看是否已经推送到远端见下一节。4.3 合并提交生成后反悔revert 与 reflog 兜底如果你已经提交了 merge commit然后发现合并结果有问题有两种处理方式已经推送到远端别用 reset用git revert生成一个反向提交安全且不影响协作。只在本地且没推送可以git reset --hard HEAD^直接把分支指回合并前。merge commit 的 revert 比普通提交多一个参数git revert -m 1 merge-commit-hash-m 1表示保留第一个父提交也就是合并前 main 这边的历史撤销第二个父提交被合并分支带来的改动。另外推荐你养成了一个习惯遇到任何删错了、改坏了、回不去了的恐慌先执行git reflog。它记录的是本地仓库每一次 HEAD 变动的历史基本等于后悔药的药房git reflog比如你用git reset --hard HEAD^之后反悔了reflog 里能找到之前 merge commit 的哈希然后用git reset --hard hash直接跳回去。这个命令几乎能救回任何丢失的提交前提是你没清理过.git/logs目录。4.4 在 commit message 里记录冲突处理决策很多人提交 merge commit 时就用默认信息sMerge remote-tracking branch feature/xxx into main之类。我建议在 merge commit 的 message 里简单写清楚冲突处理了哪些文件、各自保留了什么。举个例子merge: 合并feature/xxx到main 冲突处理 - app.py 定时任务间隔采用feature侧的30s因为运维需求由对方对接 - README.md 保留main侧文档结构并入了feature新增的章节 - package-lock.json 重新install生成这个习惯作用在哪儿半年后出线上问题回溯时你翻到这条 merge commit就能立刻知道当时谁做了决定、依据是什么。信息透明对团队协作的长期价值远比你省下的那几秒钟大。5. 让冲突少发生的工程习惯与 git 环境坑位排查5.1 小步提交、高频合入别攒大分支经验上冲突概率跟分支存续时间是强相关的。一个 feature 分支活了两周才合回主干几乎必然会撞上一堆冲突如果每天或者每半天就把小改动同步到主干别人动过的地方有限冲突自然少。我自己现在的习惯是任何改动尽量拆成不超过半天工作量的小提交分支生命周期控制在两三天内。PR 越小冲突概率越低code review 质量也越高。这可能是所有减少冲突的方法里投入产出比最高的一项。5.2 模块边界清晰代码归属明确很多冲突的根源是大家都在改同一个文件。如果团队的模块边界划分清楚比如接口层、数据层、工具层各有归属那同文件并发修改的概率就大大降低。这听上去像架构课上的废话但在 Git 协作场景里它是最实在的冲突预防手段。比如我参与过的项目里工具类文件一直是冲突重灾区。后来把工具库单独拆成一个内部包由一个人专门负责合并提案其他人只做依赖引用那一类冲突几乎绝迹。5.3 团队统一 pull --rebase 或 merge并小心 rebase 的翻转坑除了合并分支日常开发中大家还会频繁把主干的最新代码拉到自己分支这就涉及git pull的两种模式git pull默认 merge本地分支和远端主干产生分叉历史每次同步都可能生成一次合并提交。git pull --rebase把本地未推送的提交重新放到远端主干的最新提交之上历史是线性的冲突处理集中在自己的提交上。我更推荐团队统一用 rebase 模式可以这样设置git config --global pull.rebase true但 rebase 有一个必须反复提醒的坑冲突时 ours 和 theirs 的含义反过来了。在 merge 中ours 是当前分支、theirs 是被合并分支在 rebase 中ours 变成了 rebase 的目标分支也就是远端主干theirs 才是你正在重放的本地提交。举个例子你在自己的 feat 分支上执行git rebase main出现冲突时git checkout --ours app.py拿到的是 main 最新版的 app.pygit checkout --theirs app.py拿到的才是你自己的改动。很多人按 merge 的直觉来在 rebase 时反而把自己辛苦写的代码弄丢了。rebase 冲突的处理流程git rebase main # 出现冲突后编辑文件解决 git add file git rebase --continue # 如果中途想放弃回到rebase之前 git rebase --abort另外记住铁律不要 rebase 公共分支。rebase 会重写提交历史一旦你 rebase 了一个别人也在用的分支并强推整个团队都会陷入混乱。rebase 只适合处理你自己还没推送过的本地提交。5.4 环境排查git 安装、基础配置与 ssh 认证失败说完冲突本身顺手回应一下经常被热搜刷到的几个 git 环境问题。很多冲突没解决好的混乱底层其实是环境基础没打牢。首先是 git 安装。装完第一件事不是急着 clone而是确认版本并配置身份信息git --version git config --global user.name 你的名字 git config --global user.email youexample.com如果不配置 user.name 和 user.email你的提交会被贴上错误作者甚至部分操作会直接报错。git config --list可以随时查看当前配置。其次是 ssh 认证失败。这个问题的排查链路基本都是围绕密钥展开的# 1. 生成密钥用了 ed25519 算法比老的 rsa 更推荐 ssh-keygen -t ed25519 -C youexample.com # 2. 启动 ssh-agent 并把私钥添加进来 eval $(ssh-agent -s) ssh-add ~/.ssh/id_ed25519 # 3. 把公钥内容复制到 git 托管平台的 SSH Keys 配置里 cat ~/.ssh/id_ed25519.pub # 4. 测试认证连通性不同平台返回的提示不同但看到你的用户名就说明通了 ssh -T gitgithub.com常见坑包括复制公钥时带了多余字符、平台没保存成功、或者 clone 地址用了 ssh 协议但本机根本没生成密钥。排查顺序建议是先确认 clone 地址是不是git开头再确认公钥已添加最后再测ssh -T。环境问题解决后再回到分支合并和冲突解决整个流程会顺畅很多。最后再分享一个让我冲突处理体验提升明显的小配置。Git 2.35 以上版本支持冲突标记风格 zdiff3git config --global merge.conflictstyle zdiff3开启后冲突区域里会额外显示 merge base 那份原始内容。也就是说以前你只能看到两边改成了什么样很容易忘了原来长什么样现在原版直接摆在中间很多时候不用翻提交历史就能猜出两边各自想干什么。这个配置对我的帮助比任何可视化工具都大。我的日常习惯是每天上班第一件事git pull --rebase保持本地同步小改小合冲突数量真的从每周都要救火降到了一个月碰不了几次。如果哪天真遇到了处理完也别急着合把冲突文件甩给相关同事过一眼让决策留下记录这比在群里发一句已解决靠谱得多。