
1. 被拒的那条提示信息量其实很大git push 被拒绝几乎是每个开发者迟早要撞见的场面。那次我正在某个 feature 分支上收尾准备把两笔提交推上去结果终端直接甩了一行! [rejected] feature/xxx - feature/xxx (non-fast-forward) error: failed to push some refs to gitxxx:some/repo.git hint: Updates were rejected because the remote contains work that you do not have locally.当时第一反应是“我本地明明是最新的”但冷静下来才发现问题恰恰出在我对“最新”的理解太幼稚。1.1 什么叫 non-fast-forwardGit 的 push 默认是快进式的。所谓快进可以理解成一条直线远端分支当前指向 C 点本地分支也是从 C 点继续往前走只是在本地又走了几步而已。这时本地历史是远端的超集push 上去无非是把远端指针沿着同一方向往前挪不会有任何历史被重写。所谓 non-fast-forward表示本地分支和远端分支已经分道扬镳了。两边都从同一个祖先点往后走了一段远端到了 D我也到了 E虽然各自都有新东西但我这边的历史并不能把远端的历史直接盖掉。Git 拒绝这次 push本质上是在保护远端已经存在的提交防止有人拿一个不包含远端改动的新历史去覆盖远端记录。这个保护机制非常关键没有它两个人同时往一个分支推代码后推的人就会把先推的人的工作从历史里抹掉。1.2 远端到底保护了什么有人觉得 push 被拒很烦想跳过提示直接 force push 覆盖我只能说这是拿项目历史开玩笑。远端拒绝你不是它脾气差而是它知道有另一批提交存在于你的本地视野之外。这些提交可能是同事刚推上去的也可能是我自己另一台电脑推上去的但在当下这台机器里我的历史里不包含它们。Git 的纠错逻辑很朴素你推上去的新历史必须完整包含远端当前的历史否则就拒绝。这正是它和直接把文件拷到服务器完全不同的地方。文件系统上谁后保存谁覆盖而 Git 把每一次变更都记录在案它要保证任何人拉下来之后都能看到一条完整可追溯的演进过程而不是某一次的“覆盖后的最终结果”。2. 当时我的分支已经分叉两边都不知道对方做了什么被拒之后我没有瞎猜先把真实情况摸了一遍。执行git fetch origin git log --oneline --graph --all --decorate -10看到的结果大致是* 3f2a1c4 (origin/feature/xxx) 调整接口返回结构 * 8b7d2e3 补充超时重试逻辑 | * a94c31b (HEAD - feature/xxx) 修复空指针问题 | * 6d1e50f 优化查询语句 |/ * c4d91a2 合并订单详情页面基线也就是说从同一个基线 c4d91a2 出发远端往前走出了两笔提交我本地也往前走出了两笔提交。服务器上的 feature 分支和我机器上的 feature 分支已经分叉了。两边都不知道对方做了什么但都认为自己站在分支的最前面。2.1 我本地的工作内容我手上的两笔提交一笔是修复一个空指针异常另一笔是优化一条聚合查询语句。两笔都是本地写代码、跑测试、最后提交没有推到远端。因为当天大部分时间我都在自己的笔记本上开发想着“反正还没完成先不推上去”。这在单人开发时几乎没有成本但一旦你所在的分支不是独占的比如同事也在这个分支上做了集成或者你跨设备工作问题就会在你准备收尾的那一刻集中爆发。2.2 远端产生分叉的原因远端的提交是同事在我开始干活之后推上去的。他当时说自己改了一个接口返回结构的兼容问题顺带补了超时重试逻辑。我并不知情因为我干活前没有先 fetch也没在提交前看一眼远端的最新状态。这里要提一个我后来一直坚持的操作习惯动手之前先git fetch开始提交前再看一眼git status是不是落后远端几个提交。如果确认分支是共享的最稳妥的做法是确认远端有没有新东西再考虑自己是不是要在本地继续开新提交。很多 push 被拒根源不是操作问题而是时机问题。2.3 两个危险的做法被拒之后的直觉反应有好有坏。一种是图省事直接git push --force强行把本地历史覆盖上去。如果远端那两个提交真的不想要也没有其他人基于它们做后续开发这么干还能接受但只要是共享分支这种动作等于把同事提交的代码从历史里撕掉。同事下次 pull 的时候会非常痛苦对不上、找不着、重新改全线崩溃。另一种是一声不吭git pull --rebase但不看冲突、不理解 rebase 过程遇到冲突不知道怎么处理搞到一半想退出又不敢退最后把工作区搞得更乱。这些我都经历过所以下面先讲清楚该 merge 还是 rebase 的判断逻辑再讲当时我怎么走了两条路。3. merge 还是 rebase动手前先从三个维度算账我发现很多人纠结 merge 还是 rebase其实是把问题问窄了。正确的问法是这笔提交已经公开了吗这个分支的生命周期有多长这两拨改动冲突会让谁痛苦3.1 已经公开的提交禁止改写最硬的规则是凡是已经 push 到远端、且可能被别人拉取的提交一律不要 rebase 去改写。rebase 的本质是把提交摘下来、重新放到别的位置这会让提交的 hash 变掉。别人基于旧 hash 做的工作在你改写后全部失联。这种灾难在团队协作里代价极高。所以一个简单的判断标准分支还只有你自己在动随便 rebase分支已经被别人拉过、推过了merge 是唯一不会让所有人头疼的选择。我那次的情况是 feature 分支两个人都在推理论上更适合 merge。这也是我最初停下来想清楚这件事后决定第一轮走 merge 的原因虽然最后我没停在那个方案上。3.2 按分支生命周期判断长期存在的公共分支比如 main、main-dev、release 这类几乎永远不 rebase。这类分支的历史本身就是团队协作的记录频繁 rebase 会把记录弄得面目全非也无法追溯任何一次集成的真实时间点。短命的个人功能分支则恰恰相反反正没人看它的历史线性一点反而好读。我的个人习惯是功能分支如果只是给我自己用结尾前尽量 rebase 到最新的基线上因为这样后续再看 diff 范围非常干净也方便别人评审。但如果是多人共推的功能分支我第一选择是 merge遵循“别改共享历史”原则。3.3 按冲突规模和技术能力判断merge 和 rebase 面对冲突的形态也不一样。merge 只执行一次合并冲突通常一次性给你列完rebase 会把你的每笔提交依次重放到目标上每放一笔都可能遇到冲突同一片区域可能反复让你解决两遍、三遍。如果你对冲突解决还没有建立起完整的判断力第一次遇到大冲突就直接 rebase心态很容易崩。反过来如果你的改动非常独立比如只动了自己的工具函数和对应测试和别人改的文件完全不重叠rebase 冲突通常很少收益却很大。所以我的建议是先用下面这张表快速过一遍判断项偏向 merge偏向 rebase提交是否已被别人拉取是否分支是否长期公共是否是否想保留合并节点是否是否希望历史是一条直线否是需要重放的提交数量很多很少冲突风险高且一次合并高可能重复处理最后 push 方式普通 push需要 force-with-lease当时我对照这张表发现自己的 feature 分支是两个人共推的理论上 merge 更符合协作约定。可我又特别想让自己的提交看起来清爽于是先做了 merge又反悔改成 rebase。这段反复的过程其实很有代表性下面仔细讲讲。4. 我先选了 merge推送成功但心里很不舒服第一轮我选择 merge因为同事已经推了提交直接改写他的历史不合适。4.1 fetch merge 的实际操作我没有直接用git pull因为 pull 默认行为在不同版本和配置下可能是 merge 也可能是 rebase我不喜欢在关键时刻猜。所以我拆开执行git fetch origin git merge origin/feature/xxxmerge 的执行逻辑是把远端的最新历史并到我当前的本地历史上生成一个额外的 merge commit。这个合并提交的父节点有两个一个是我本地的 a94c31b一个是远端的 3f2a1c4。我的提交不会被改写同事的提交也原封不动双方历史都在。如果这里出现冲突Git 会停止合并标出冲突文件等你处理完再git add最后执行git commit生成合并提交。整个过程只处理一次压力不大。这就是 merge 对协作更友好的重要原因它不装作“你的提交本来就在远端改动之后”而是如实记录“两拨开发在这里汇合了”。4.2 巧的是这次没有冲突问题反而出在没冲突我改的空指针问题是页面渲染入口处的判空同事改的是接口返回结构和超时重试逻辑两拨改动没有碰到同一份文件。merge 很顺利没有冲突提示自动生成了一个合并提交我连提交信息都不用填。合完一看 log历史已经变成一个复杂的菱形结构* f7e21b3 Merge branch origin/feature/xxx into feature/xxx |\ | * 3f2a1c4 调整接口返回结构 | * 8b7d2e3 补充超时重试逻辑 * | a94c31b 修复空指针问题 * | 6d1e50f 优化查询语句 |/ * c4d91a2 合并订单详情页面基线从提交完整性上讲完全没有问题push 也顺利通过。但我盯着这个图看了好几秒心里很别扭。因为我当时的潜意识里希望 feature 分支的历史像一条笔直的线后面评审的人一眼能看到我这几笔提交的相对位置。merge 之后这个图形结构的确更“真实”但它显得绕明明四个人在一条短分支上合作历史却画出了分叉又汇合的复杂图案。4.3 看到 graph 那一刻我决定重来我犹豫了一会儿然后做了一个后来觉得值得商榷、但也能理解的决定把它改回一条直线。我当时的想法是这个分支还处于开发期还没有合并回主分支历史也不算正式对外公开我完全有空间把本地的两笔提交重放到同事提交之后让整个分支看起来像是一个线性演进。这个决定成立的前提是除了我自己没有其他人基于我这个 feature 分支再往外开分支。当时我确认过只有我和同事在这个分支上动而同事的提交我不会去动我只是移动我自己那两笔提交的位置。听起来安全但操作上需要格外小心。如果你也想这么做务必先确认没人拿当前分支当基线否则你改写的提交会从别人脚下抽空。5. 改用 rebase 重做一遍历史立刻清爽决定重来之后我先把临时产生的 merge 提交撤掉。因为那个 merge 提交还没 push 到远端只存在于本地所以可以安全调整。git reset --hard HEAD~1这里我明确知道自己只生成了一个 merge 提交本地没有其他重要改动所以可以放心。如果你有未提交的修改不要用这个命令。5.1 从头再来fetch 后的 rebasereset 之后我的本地分支回到了 a94c31b远端指针还停在 3f2a1c4。接下来执行git fetch origin git rebase origin/feature/xxxrebase 的含义是把当前分支上的若干本地提交“摘下来”以远端分支的最新提交为新的基点重新一个一个放上去。执行过程大致是记住当前分支从共同祖先之后的所有本地提交。把暂存区、工作区切到远端最新提交的位置。把第一步记下的提交按顺序依次重放到新基线上。按照这个逻辑我的两笔提交 6d1e50f 和 a94c31b 会被重新放在 3f2a1c4 之后。如果新基线里没有改到同样的文件rebase 会静默完成我的提交 hash 会被重新计算内容保持原样。5.2 冲突这次出现了逐条解决第一轮 merge 没冲突我总觉得 rebase 也不会冲突结果被自己打脸了。同事提交的 8b7d2e3 补了超时重试逻辑他顺便重构了查询入口的调用方式正好落在我的“优化查询语句”那笔提交涉及的文件上。Git 在重放我这笔提交时发现上下文对不上把 rebase 停了下来提示error: could not apply 6d1e50f... 优化查询语句 hint: Resolve all conflicts manually, fix them, commit them, hint: and then run git rebase --continue.此时工作区里出现冲突标记git status会列出 unmerged 的文件。我需要打开文件看冲突区块决定保留谁、修改谁。这个动作和 merge 冲突处理很像但心态上要知道我是在“把旧提交换一个新环境”而不是“把两条支流汇合在一起”。所以处理时更要以最终代码的正确性为准而不是机械地只保留某一侧。5.3 push 时别用裸 force用 force-with-leaserebase 完成之后本地的提交历史已经改写了。这些提交和远端当前记录的 hash 已经不是同一个链条直接 push 必然还会被拒需要强制更新。但强制更新有危险最安全的姿势是git push --force-with-lease--force-with-lease的意思是只有当远端分支的状态和你上次 fetch 到的一致时才允许覆盖。如果在 rebase 期间又有其他人往这个分支推了新提交这个命令会拒绝执行避免你把别人的新提交也“抹掉”。它比裸--force安全得多。我特别强调这一点是因为很多人知道要 force push却不知道 force 和 force-with-lease 的区别。裸 force 是“别管远程现在长什么样用我本地的覆盖上去”force-with-lease 是“我刚才看到远程是 A如果不是 A 我绝不上传”。在共享分支上后者几乎是唯一允许我改自己历史时还能维持基本尊重的方式。执行 force-with-lease 推送成功后再看 log* a94c31b2 修复空指针问题 * 6d1e50f3 优化查询语句 * 3f2a1c4 调整接口返回结构 * 8b7d2e3 补充超时重试逻辑 * c4d91a2 合并订单详情页面基线一条线直到底非常顺眼。我的功能分支在发给评审人之前已经变成本地提交坐在同事两笔提交之上的干净样子。这个结果正是很多人想用 rebase 的原因它让短分支的历史更像一个人连续开发的产物而不是一堆分叉和四合。6. rebase 冲突处理和 merge 冲突处理完全不是一回事前面我顺利解决了 rebase 中的一次冲突但 rebase 冲突处理里还有不少细节坑我在这节展开讲因为这些是大多数文档不会特意提醒的。6.1 一个提交一个提交地重放merge 冲突会在一个节点把所有不一致一次性指出就算你有 N 个文件冲突只要解决完、提交合并提交事情就结束了。rebase 不一样它是按提交逐个重放的假设计划要重放 3 笔本地提交第 1 笔就冲突了你解决完执行git add .然后git rebase --continue后可能第 2 笔又冲突了而且冲突的位置可能还是刚才那个文件的另外一部分。这是我的一个真实体会如果你在一个大功能上攒了十几笔提交rebase 前最好先评估一下重放次数否则你可能陷入一个漫长的“解决一下、continue 一下、又解决一下”的循环。这也是很多新人撞到 rebase 就喊“历史被搞乱了”的原因其实乱的不是历史是他的耐心。6.2 解决两次同样冲突的诡异局面更有意思的是重放过程中可能出现“同一个位置冲突第一次解决完第二次又冲突”。这种情况通常是因为你最早的提交和后续提交都对同一行做了改动。比如第一笔提交调整了函数名第二笔提交又调整了函数签名rebase 依次重放时第一笔把自己和远端基线合并第二笔又要在第一笔的结果上做新合并。解决这种局面唯一的办法是别贪快。你可以在冲突文件里看到类似 HEAD 远端/上一次重放后的内容 当前正在重放的提交内容 6d1e50f优化查询语句你要选的不一定是某一侧而是这个提交本身希望达成的最终结果。如果两个提交本来就是你前后脚做的你甚至可以回头调整计划用一次性改写来解决比如git reset --soft后重新组织提交但那就属于更高级的重写了不太建议当场尝试。6.3 中途想退出怎么办rebase 进行到一半发现局势比自己预期得复杂完全不需要硬抗。随时可以执行git rebase --abort这个命令会把分支、工作区、暂存区都恢复到 rebase 开始之前的状态你唯一丢掉的是自己在冲突处理期间做但还没提交的修改。所以一个实用建议是别在 rebase 过程中一路手动改完所有文件却迟迟不 add万一你想退出这些改动会消失。如果你已经处理完几笔提交、确认后续会顺利也可以继续到结束。但也有另一种退路git rebase --quit它会在保持当前已应用部分的情况下退出 rebase 状态不留 rebase 进度也保留已经生成的内容。这个命令很少被用到但知道存在比临时搜索强太多。7. 复盘什么场景下我坚决不 rebase那次经历之后我没有变成“什么都用 rebase”的人反而更敬畏公共历史了。一个好工具是看你能不能驾驭它的副作用。很多分支历史的混乱往往不是 rebase 本身造成的而是有人对“能不能改、改多少、改完怎么推”完全没概念。7.1 公共分支上的水不要去搅我坚决不 rebase 的场景有三类。第一类是已经明确是主干的分支比如 main、release、hotfix 这类所有人都从它上面拉分支的分支。在这类分支上做 rebase 式整理等于让整个团队的提交历史失去共同锚点其他人拉代码的时候可能整个工作区都歪掉。第二类是已经被多人基于它开发的分支。哪怕它只是一个 feature 分支只要我知道还有别人在这个分支上开了子分支或者已经把它 merge 到了别处我就绝不重新洗牌已有提交。我不能为了自己看得舒服让别人承担找不回提交的风险。第三类是时间跨度过长的大功能分支。这种分支上累计了几十笔提交和主干的差异已经很大一旦 rebase冲突会像连珠炮一样打过来与其反复重放每一笔不如直接 merge一次集成所有差异保留合并节点后续回溯还容易。7.2 团队约定和 pull.rebase 配置说实话单个人怎么选 merge 还是 rebase影响有限真正影响效率的是团队有没有统一约定。如果你团队的主分支走 merge 工作流那你最好不要在公共分支上用 rebase 重写别人提交如果你团队走的是“功能分支先 rebase 到主干再合并”的流程那每个人都有责任在往共享分支推代码前先看一眼对方是不是已经推了东西。我个人的配置是git config --global pull.rebase false这样执行git pull时默认走 merge不会被某些工具或环境暗中改成 rebase 模式行为可预期。而在自己的短期功能分支上我会手动fetch后rebase这样每次合并触发都受我控制不会被默认配置带着跑。7.3 我最后的个人习惯回到那次经历本身如果让我重新来一次我大概会在最初就判断清楚同事已经推了代码而我还没推正确顺序是先把远端拉下来看一遍再评估我的本地提交要不要重放。我最常用的流程是git fetch origin git log --oneline origin/main..HEAD # 看看自己本地领先多少 git rebase origin/main # 在个人功能分支上整理 git push --force-with-lease这套流程适合“功能分支只有自己在写、尚未对外合入”的场景。一旦分支开始有其他人参与我会切换成 fetch merge并接受历史上出现一个合并节点。那些节点不是丑化而是协作的证据。最后说一个很多人容易忽略的小细节rebase 之后你本地的文件内容和 merge 之后通常一致但提交 hash 变了。如果你已经开着某个工具的浏览器页面在看旧 hash或者某个测试平台记录着旧的提交 ID改动之后记得把那些关联信息一并更新。我在一次项目里就遇到过测试平台仍显示旧 hash、导致构建结果对不上代码版本的情况排查了半天才发现不是代码问题是 hash 没对上的乌龙。这种小地方恰恰是经验最值钱的所在。