
1. 这个报错到底在说什么从一次真实的推送翻车说起[rejected] master - master (fetch first)加上error: failed to push some refs这串红字几乎是每个用 Git 做协作开发的人都绕不过去的一道坎。我第一次见到它的时候脑子里全是问号代码明明在我本地跑得好好的git commit也成功了怎么一git push就被服务器拒了更让人抓狂的是后面还跟着一句(non-fast-forward)看起来像是某种高深的术语实际上它想表达的意思非常朴素——你手里的这条提交线和远端仓库里的那条提交线已经分叉了服务器不敢替你决定该听谁的。先把场景还原一下。假设你和同事都在同一个分支上干活。你早上从远端拉了一份代码然后埋头改了两小时本地提交了三个 commit。与此同时同事在他那边也提交了两个 commit 并且已经推到了远端。等你准备推送的时候你的本地历史里根本没有同事那两个 commit而远端历史里也没有你那三个 commit。两条线从某个共同的祖先节点开始各自往前延伸形成了两个不同的未来。这时候 Git 会拒绝你的推送因为它无法用简单的快进方式把你的提交叠加到远端历史之上。所谓fast-forward快进指的是远端分支的当前提交是你本地分支当前提交的祖先也就是说你只是在别人已有的工作之上追加了新内容没有产生分叉。这种情况下 Git 只需要把远端指针往前挪一挪就行干净利落。而non-fast-forward恰恰相反它意味着两条历史已经岔开了必须有人来做一次合并或者变基的动作把两条线重新拧成一股才能继续推送。理解了这个本质你就会明白这个报错不是 Git 在刁难你而是它在保护你防止你一不小心用本地的旧历史覆盖掉别人已经推上去的工作。想通这一点后面所有的处理手段就都有了方向——无非就是想办法把远端的新提交先拿到本地和你的提交整合到一起再推上去。这篇文章我会把这个问题从根因到解法完整拆一遍包括git pull和git pull --rebase的区别、什么时候该用哪个、冲突了怎么处理、以及几个我踩过的坑。不管你是刚学 Git 的新手还是用了几年但一直靠删库重拉蒙混过关的老手应该都能从里面找到点有用的东西。2. 分叉是怎么产生的把两条提交线画在纸上2.1 用生活化的方式理解提交历史Git 的提交历史本质上是一个有向无环图每个 commit 节点都指向它的父节点。你可以把它想象成一列火车每节车厢记录着一次改动车厢之间用挂钩连起来挂钩的方向是从新车厢指向旧车厢。分支名比如master只是一个贴在某一节车厢上的标签表示这条线目前走到这里了。当你git clone或者git pull的时候你拿到的是远端那一整列火车以及贴在最后一节车厢上的标签。你在本地提交新内容就是在自己这列火车的尾部接上新的车厢标签也跟着往后移。问题出在如果你接车厢的这段时间里别人也在远端那列火车尾部接了车厢那么当你想把自己的车厢接上去时两列火车的尾部就对不上了——它们从某一节共同的车厢开始各自往后接了不同的车厢。用文字画出来大概是这样共同祖先 A --- B --- C远端 master 现在到这里 \ D --- E你本地 master 现在到这里远端在 C你在 E而 C 并不是 E 的祖先E 也不是 C 的祖先。Git 一看这情况就知道没法快进了于是抛出non-fast-forward拒绝推送。2.2 为什么 Git 默认不允许非快进推送有人可能会想不就是分叉了嘛直接强行推上去覆盖掉远端不就行了技术上确实可以git push --force就是干这个的。但 Git 默认不让你这么干原因很实在——强推会永久性地丢掉远端那些你本地没有的提交。接着上面的图说如果你强推远端历史会变成 A-B-D-E那个 C 提交就从分支历史里消失了。如果 C 是同事辛苦写的功能这一下就全没了。虽然 Git 的 reflog 和悬空对象在一定时间内还能找回但对一个团队来说这种事故的代价太高。所以 Git 的设计哲学是默认保守把选择权交给人。它宁可让你多敲几条命令也不愿意替你做可能造成数据丢失的决定。理解了这层设计意图你就不会觉得这个报错烦人了。它其实是在说兄弟远端有你没有的东西你先看看再决定怎么办。2.3 哪些日常操作最容易触发这个报错根据我这些年的经验触发这个报错的高频场景无非这么几类多人协作同一分支最常见的情况。你和同事都在master或develop上直接提交谁先推谁赢后推的人必然撞上。在网页端改了东西很多代码托管平台支持在线编辑文件、在线解决冲突、在线合并 PR这些操作都会在远端产生新提交。你在本地完全不知情一推就炸。本地有未同步的旧提交比如你昨天提交了但忘了推今天直接接着改而远端昨天到今天已经前进了好几个提交。切换分支后没更新从别的分支切回来本地分支还停留在旧位置直接改直接推。CI/CD 或自动化工具往同一分支写东西比如自动格式化、自动生成文档的流水线它们也会产生提交。把这几种场景记在心里你就能在推送前有个预判只要我这次推送之前远端可能有人动过那大概率就要先拉一下。3. 标准解法拆解pull、rebase、merge 到底怎么选3.1 最省事的做法先 pull 再 push报错信息里其实已经给了提示——(fetch first)意思就是你先把远端的拿下来再说。最直接的操作就是git pull origin master git push origin mastergit pull实际上是两个动作的合体先git fetch把远端最新提交下载到本地的远程跟踪分支origin/master然后再把它合并到你当前所在的分支。默认情况下这个合并用的是merge方式也就是会产生一个额外的合并提交。合并之后你的历史会变成这样共同祖先 A --- B --- C --- M合并提交本地 master 现在到这里 \ / D --- EM 是一个新的合并提交它有两个父节点C 和 E。这样两条线就重新汇合了此时再 push 就是快进推送不会再被拒绝。这种做法的好处是简单、安全、不改变已有提交的哈希值。缺点是历史里会多出一堆合并提交如果团队里每个人都频繁地 pull-merge-push提交历史会变得像蜘蛛网一样乱。对于追求线性历史linear history的团队来说这不是个好选择。3.2 追求干净历史git pull --rebase如果你希望提交历史是一条直线那就用变基git pull --rebase origin master git push origin master--rebase的意思是先把远端的新提交 C 拿下来然后把你本地的 D、E 两个提交摘下来暂时放到一边把本地分支指针移到 C 上再把 D、E 依次重新应用到 C 之后。结果是共同祖先 A --- B --- C --- D --- E本地 master 现在到这里注意 D 和 E 变成了 D 和 E因为它们的父节点变了提交哈希值会改变。这就是变基的核心特征它重写了历史。变基的好处是历史干净、线性、易读git log看起来一目了然。代价是如果这些提交已经推送到远端并被别人拉取过变基会造成混乱。因为别人手里的 D、E 和你变基后的 D、E 是两套不同的提交虽然内容一样但 Git 认为它们是不同的东西。所以有一条铁律只对尚未推送的本地提交做变基。3.3 merge 和 rebase 的取舍对照我把两种方式的关键差异整理成一张表方便你按团队规范来选对比维度git pullmergegit pull --rebase历史形态有合并提交呈网状线性一条直线提交哈希不变本地提交哈希会改变冲突处理一次性解决所有冲突逐个提交解决冲突适用场景公共分支、已推送的提交个人分支、未推送的本地提交风险历史杂乱误用会重写公共历史回退难度较低冲突中途需 abort我个人的习惯是在个人功能分支上永远用 rebase在公共集成分支上永远用 merge。这样既保证了自己的提交历史干净又不会去动别人已经依赖的公共历史。3.4 配置默认行为少敲几个字如果你确定团队统一用变基可以把它设成默认行为省得每次都要加--rebasegit config --global pull.rebase true这样以后直接git pull就等价于git pull --rebase。反过来如果你希望 pull 时如果无法快进就自动创建合并提交避免 Git 在新版本里因为没配置而报错提示可以设git config --global pull.rebase false提示较新版本的 Git 在pull.rebase未配置时遇到需要整合的情况会直接报错并提示你配置而不是默默帮你 merge。所以显式配置一下是个好习惯能避免很多怎么突然 pull 不动了的困惑。4. 冲突来了怎么办一次完整的排查与修复链路4.1 冲突是怎么冒出来的不管是 merge 还是 rebase只要远端和本地改了同一个文件的同一片区域Git 就没法自动决定保留哪一份于是把决定权交给你这就是冲突。冲突发生时Git 会暂停当前操作把冲突文件标记出来等你去编辑。merge 冲突和 rebase 冲突的表现略有不同。merge 时所有冲突一次性呈现你解决完git add再git commit就完成了。rebase 时冲突是逐个提交出现的——因为它是一个提交一个提交地重新应用每应用一个就可能撞一次冲突。解决完当前这个git add之后要用git rebase --continue继续而不是git commit。4.2 冲突文件的标记长什么样打开冲突文件你会看到这样的标记 HEAD 你本地的内容 远端过来的内容 origin/master和之间是当前分支HEAD的内容和之间是即将合入的内容。你要做的就是手动编辑这一段保留你想要的结果然后把所有标记符号删干净。注意是删干净一个都不能留否则文件里会残留这些符号代码直接报语法错误。4.3 完整的冲突处理流程假设你执行git pull --rebase origin master之后撞了冲突标准处理链路是这样的先看状态git status会告诉你哪些文件冲突了both modified以及当前处于 rebase 的哪一步。逐个编辑冲突文件用编辑器打开找到冲突标记决定保留哪部分内容删掉标记。标记为已解决对每个解决完的文件执行git add 文件名。继续 rebasegit rebase --continue。如果还有下一个提交的冲突重复步骤 2-4。推送全部解决后git push origin master。如果中途发现搞砸了想放弃这次 rebase 回到操作前的状态执行git rebase --abort这个命令会把一切恢复到 rebase 开始之前非常有用。我强烈建议在不确定的时候先 abort理清思路再来而不是硬着头皮往下做。4.4 我踩过的几个坑坑一冲突解决完忘了 git add 就 continue。git rebase --continue会提示你还有未解决的冲突因为 Git 是靠暂存区来判断哪些文件已经处理好的。没add就等于没解决。坑二在 rebase 过程中手贱敲了 git commit。rebase 中途的提交是由 rebase 自己管理的你手动 commit 会打乱它的节奏可能产生奇怪的重复提交。记住 rebase 冲突解决后用--continue不要用commit。坑三把已经推送过的提交拿去 rebase。这是最危险的一种。如果你 rebase 了别人已经拉取的提交别人再 pull 的时候会看到两套历史冲突会非常难处理。判断标准很简单这个提交推送到远端了吗推了就别 rebase 它。坑四冲突文件里残留标记符号。尤其是大文件里冲突块很多的时候很容易漏删一两个。提交前用grep -rn .全局搜一遍能救命。5. 那些暴力解法到底能不能用5.1 git push --force 与 --force-with-lease前面说过强推能覆盖远端历史但会丢东西。如果你确实需要强推比如你刚 rebase 完自己的个人分支确定远端没有别人的新提交用--force要格外小心。更安全的替代品是git push --force-with-lease origin master--force-with-lease会在强推前检查远端的当前提交是不是你预期的那个。如果在你 fetch 之后有人又推了新东西这个命令会拒绝执行从而避免覆盖别人的工作。只要能用--force-with-lease就别用--force这是我这几年养成的肌肉记忆。5.2 删库重拉能解决问题但代价不小有些人的终极方案是把本地仓库删了重新 clone 一份再把改动复制过去。这招确实能绕过所有历史问题但代价是你本地所有未推送的提交、未提交的改动、stash 里的内容、本地分支、配置全都没了。除非你确定本地没有任何有价值的东西否则我不推荐。真要走这条路至少先把改动git diff backup.patch导出或者把整个目录复制一份备份。5.3 git reset 硬回退的适用边界还有一种情况你本地的提交其实不想要了就想让本地和远端保持一致。那可以git fetch origin git reset --hard origin/master这会把本地分支强制指向远端的最新提交丢弃所有本地未推送的提交和未提交的改动。这个操作不可逆除非靠 reflog 找回执行前一定要确认本地没有你想要的东西。我一般只在本地改乱了远端才是权威版本的时候才用它。5.4 各种解法的风险对照解法是否丢数据是否改历史适用场景git pullmerge否否公共分支保守优先git pull --rebase否改本地未推送部分个人分支追求线性git push --force-with-lease可能覆盖远端是确认远端无他人新提交git push --force可能覆盖远端是极少用风险最高git reset --hard丢本地改动是本地作废以远端为准删库重拉丢本地全部是万不得已先备份6. 把这类问题挡在门外日常习惯与团队规范6.1 推送前先拉是最便宜的习惯绝大多数 non-fast-forward 报错都能靠推送前先拉一下避免。我现在的工作流基本固定成改代码 →git add→git commit→git pull --rebase→ 解决可能的冲突 →git push。多花的那几秒钟比起事后处理分叉和冲突简直不值一提。如果你嫌手动敲麻烦可以配一个别名git config --global alias.sync !git pull --rebase git push以后git sync一条命令搞定拉取加推送。当然前提是你清楚它内部做了什么别把它当成黑盒乱用。6.2 小步提交、频繁推送提交粒度越小冲突的范围就越小解决起来越轻松。我见过有人攒了一周的改动一次性提交结果 pull 的时候几十个文件冲突那场面相当壮观。相反如果你每完成一个小功能就提交并推送即使撞上冲突也往往只是几行代码的事。6.3 团队层面的约定技术手段之外团队约定同样重要。几个我觉得值得推广的规矩公共分支保护给master、main、release这类分支开启保护禁止直接推送必须走合并请求。这样能从源头上减少直接推公共分支导致的冲突。统一 pull 策略团队要么统一用 merge要么统一用 rebase别一半人一半人否则历史会非常混乱。可以把这个配置写进仓库的文档或者用工具统一管理。功能分支隔离每个人在自己的功能分支上干活通过合并请求往主干合。这样主干上的分叉概率大大降低。推送前跑一遍测试避免把坏代码推上去也避免因为 CI 失败反复重推造成历史混乱。6.4 出问题时先看状态别急着敲命令最后分享一个我自己的习惯遇到任何 Git 报错第一反应是git status和git log --oneline --graph --all。前者告诉你当前处于什么状态有没有正在进行的 merge/rebase、哪些文件冲突后者用图形化方式展示所有分支的提交线一眼就能看出分叉在哪、谁领先谁落后。把这两条命令的输出看明白大部分问题你自己就能判断出该用哪种解法而不是盲目地复制粘贴网上的命令。Git 这东西命令可以背但真正让你少踩坑的是对它背后那套提交图模型的理解。一旦你脑子里能随时画出那两条分叉的线non-fast-forward就不再是拦路虎而只是一个提醒你该同步了的友好信号。