说起Git合并这件事我见过太多人把Merge和Rebase当成两个可选按钮哪个顺手就点哪个。直到去年有次同事为了清理feature分支的历史直接git rebase origin/master然后git push --force半小时内三个人的工作区全乱了——那条分支早就被当成公共开发分支在用了被重写的历史让所有人的本地提交都变成了孤儿最后靠git reflog一个个捞回来项目进度白白耽误了一整天。从那次之后我才真正意识到Git的Merge和Rebase不是两套平行的咒语而是一套针对提交历史的编辑策略。理解不了这一层你就永远不知道什么时候该用哪个更不知道什么操作会连累别人。下面我把底层逻辑、适用场景和踩坑后的急救手段完整盘一遍。1. 底层差异Merge和Rebase到底动了提交历史的哪一块1.1 一次真实事故给我的教训先把那次事故说细一点。当时我们的工作流是所有人从master拉出feature分支开发完提PR再由负责人合回master。那位同事觉得自己的分支上有十几条琐碎的提交想整理得干净点于是执行了rebase。问题在于这条分支在他整理之前已经被另外两个同事基于它继续开发并推送过了也就是说它已经从私有分支变成了事实上共享的分支。rebase重放了提交改了哈希又用--force强行覆盖了远端。另外两位同事一pull发现本地提交和远端历史完全对不上git提示他们必须合并或者reset有个人还因为直接git pull把一堆已经存在的旧改动又合了回去产生了一堆重复提交。这件事让我总结出一条最值钱的教训在你执行rebase之前先问自己一个问题——这个分支除了我之外还有没有别人正在用只要答案是有你就没有资格重写它的历史。这个问题后面我会反复提到因为它是整个Merge/Rebase争议的核心。1.2 提交历史的两种形态分叉汇聚与线性重放从数据结构上看Git的提交历史是一个有向无环图。每个提交都记录着它的父提交普通提交有一个父而Merge产生的合并提交有两个父。Merge做的事情是在两个分支的分叉点之后把两边的改动合并到一起然后生成一个新的合并提交。这个合并提交会把两条开发线重新链接起来历史图因此呈现为先分叉、后汇聚的形状。它不会改动已经存在的任何提交只是在一个新节点上把两边补全。Rebase做的事情恰恰相反。它把当前分支上从共同祖先之后的所有提交一个个摘下来然后在目标分支的最新尖端上重新排一遍。每摘一个提交就等于重新生成一个提交这些提交会排列成一条直线。最终的结果是没有分叉历史看起来像是一条从起点一路向前延伸的线。这也意味着原来的那些提交对象虽然还留在对象库里但已经没有任何分支引用它们从普通视角看就像被重写了。我用一个生活化的类比方便理解Merge像两本流水账最后被人用订书机钉在一起原来怎么写的还怎么写的Rebase像把旧账本的内容按新账本的格式重新抄了一遍写错的顺手改掉但抄完以后原来那本账就没人承认了。1.3 merge-base和提交哈希理解原理的两个关键概念要把两个命令的差异讲透必须先认识两个概念merge-base和提交哈希。merge-base是两个分支的共同祖先提交。比如你在master上提交了A又从中拉出feature分支之后master推进到B、Cfeature推进到D、E那么A就是master和feature的merge-base。Merge的三方比较就是以这个共同祖先为基准拿A作为原始版本分别对比B、C的变化和D、E的变化然后把两边都认可的变化合到一起。如果你对merge-base没有概念遇到冲突时就会很懵因为Git报冲突依据的不是两个文件谁新谁旧而是两边都基于哪个共同祖先各自改了什么。提交哈希则是Git给每个提交算出的一个40位SHA-1值它的输入包括父提交哈希、提交内容快照、作者信息、提交时间、提交信息。也就是说任何一个输入变了最终哈希都会变。Merge因为不改动原有提交所以所有旧提交的哈希保持不变Rebase重放每个提交时它们的父提交变成了新位置父哈希一变后续所有提交的哈希全部跟着变。这就是为什么rebase会重写历史也是它危险性的来源——一旦远程分支上的历史哈希变化所有基于旧哈希的本地副本都会失去同步基础。2. 两种机制的内部流程以及它们各自擅长解决什么问题2.1 Merge的三方合并机制到底在算什么Merge在执行时Git会同时读取三个快照merge-base共同祖先、当前分支、目标分支。它会比较当前分支相对merge-base的差异和目标分支相对merge-base的差异然后把两边差异叠加到一个合并后的工作树里。如果两边改的是完全不同的文件或者改的是同一文件的不同区域Git能自动完成合并只有两边同时改动了同一块内容且结果不一致时才会产生冲突。举一个非常直观的例子。master从共同祖先开始改了README第1行feature分支改了README第10行Git会直接把两处改动都保留生成一个合并提交。如果feature分支和master都改了README第5行一边写苹果一边写香蕉Git就会停下来让你决定保留哪个。这里有个容易被忽略的点Merge产生的冲突只需要解决一次。因为你最终只会生成一个合并提交这个提交把两个分支的当前状态完整接起来解决完冲突之后整个合并过程就结束了。从策略上看Git目前默认使用的合并策略是ort它取代了老版本的recursive。底层会先计算改动路径再尽量通过重命名检测、模式匹配等手段自动整合。作为使用者我们不需要关心它内部细节太多只要明白一点Merge的本质是基于三方内容做一次完整整合它不关心你有多少次提交只看最终内容差异。2.2 Rebase的逐个重放机制以及它为什么不会产生合并提交Rebase的底层实现用一句话说就是批量cherry-pick。Git会先找出从merge-base到当前分支之间的所有提交然后从最老到最新逐个以目标分支的最新提交为基底重新应用。每个提交在应用时本质上是把该提交引入的补丁打到目标文件上打成功了就生成一个新提交打不成功就停下来让你解决冲突。这里就带来Merge和Rebase最关键的一个体验差异Rebase冲突可能会遇到很多次。假设你的feature分支上有10个提交每个提交都改过同一个文件而目标分支也改了这个文件那么Rebase把第1个提交重放时就可能冲突解决完后继续第2个又可能冲突。同一个冲突点你可能会反反复复解决好几遍因为每个提交相对于新的基底都产生了一次新的补丁应用。这也是为什么很多人在大分支上rebase时心态直接崩掉的原因。Rebase全程不生成合并提交它只是把旧提交在另一个位置上重新落一遍最终你的分支指针直接指向重放后的最后一个提交历史就是一条干净直线。这种方式最直观的好处是当你开PR合并到主干时Git只会看到你的分支一次性落后于主干的一份线性增量没有中间分叉代码审查的人看历史会非常轻松。2.3 哈希重算带来的连锁反应很多人不理解为什么rebase之后git push会被拒绝。原因就在哈希。你本地rebase后的提交和远端上的提交内容上可能完全一样但哈希却不同Git认为这是两个互不相关的提交链。当你尝试推送时远端发现你要写入的新历史不是基于远端当前HEAD的线性快进而是另一套历史于是直接拒绝并提示你使用git pull先把远端历史合进来或者使用--force覆盖。在本地私有分支上这种哈希变化是无所谓的甚至正是我们想要的整理出一套干净、线性、好读的历史。但一旦这个分支被推送过并且别人已经基于它继续开发你的rebase就等于把大家脚下的地基打碎。别人的提交还挂在老哈希上你的分支已经指向新哈希两边历史从此分叉任何一方合并都会把另一方已经解决的改动再次引回来。所以能不能rebase从来不是技术上的能不能而是这个历史是不是只有我在用。3. 场景选择什么情况下用Rebase什么情况下必须用Merge3.1 适合Rebase的场景本地、未推送、个人整理我把Rebase真正适合的场景分成三类。第一类是本地branch还没推送出去感觉提交历史太乱想整理一下。这时执行git rebase -i可以做交互式变基把多次提交合成一次、改提交信息、调整提交顺序。因为这条分支远端完全没有记录你怎么改都不会影响别人是绝对安全的。第二类是写代码过程中远端主干已经往前走了你想让自己本地分支的基底往前走。比如你在feature分支上工作发现master被别的同事合入了新功能这时候执行git fetch origin再git rebase origin/master可以让自己的分支站在master最新提交之上后面合回master时就是干净快进不会出现一堆Merge remote-tracking branch这种噪音提交。第三类是配合git pull --rebase用在保持一致性的场景。我自己有一个习惯当我要把一个已存在的本地分支同步到远端最新状态时如果该分支只有一个用户在维护我会用git pull --rebase而不是git pull因为后者默认会执行merge每当远端有更新就会产生一个多余的merge提交时间久了分支历史会像毛线球一样。3.2 必须用Merge的场景共享分支、主干分支、合入功能分支反过来Merge是我在团队协作时的默认选择原因只有一句话它不会改写任何人已有的历史。所有真正的共享分支——主干分支、release分支、参加CI发布的分支——都应该用Merge来接收改动。任何人在这些分支上执行rebase并强制推送都是在给团队制造事故。就算你只是rebase了自己那条feature分支它在被合并进主干之前如果已经推送过并被同事自取过同样也会连累同事。Merge还有一个不可替代的作用保留这个功能是在哪个时间点、以什么形态合入的上下文。git merge --no-ff feature/xxx产生一个合并提交即使feature分支已经删除之后你依然能在历史里看到一条清晰的分支引入线。对比一下如果你用了rebase再合并时就是快进合并看不出当时有一个独立分支的存在也不能在历史里轻易定位这个功能从哪次合并引入。团队里做发布追溯、线上问题定位时这种历史可读性差异会直接影响排查效率。3.3 黄金法则的实际判断一个分支到底算不算共享业界有一条著名的黄金法则不要rebase一个已经在公共仓库里的分支。但实际工作中公共两个字很容易被各人理解出各自的意思。我总结出三个实际判断标准只要命中任意一条这个分支就是要当共享分支对待这个分支是否已经推送到远端是否有其他人基于这个分支创建了新分支或直接推送过提交这个分支是否被CI/CD流水线、自动化部署或任何其他系统引用有人会说我只是整理一下自己的feature分支没影响别人啊。但如果这条feature分支已经推送过而其他同事基于它又拉了自己的子分支那么你rebase后force push同事的子分支历史就全断了。所以判断的标准不是我觉得它是私有的而是有没有任何人在可能依赖它的地方留下过引用。如果不确定提前在群里喊一声永远比事后救火便宜。3.4 顺带把Squash和Cherry-Pick也理清讲Merge和Rebase的时候总有人把git merge --squash和git cherry-pick也拿来掺和。它们其实属于另外两个维度我把它们放在一起对比一下操作是否产生合并提交是否保留原提交哈希典型场景git merge是保留合入功能分支进入主干、多人协作合并git merge --squash否不保留合成一个新提交想把feature分支十几条提交压成一个提交进入主干git rebase否不保留重放所有提交本地整理、线性化历史、同步最新基底git cherry-pick否不保留复制单个提交从某个分支挑选单个修复应用到另一个分支git merge --squash是一种折中方案它把feature分支的全部改动合并进当前分支但只生成一个新提交不保留feature分支的原始提交。用它的好处是主干历史极其简洁缺点是你失去了完整的功能开发过程记录。git cherry-pick则是把某个特定提交的补丁复制到当前分支上常用于hotfix跨分支传递它和rebase其实是同一类底层机制只是rebase一次性处理一串提交cherry-pick只处理你指定的那一个。4. 零坑操作从命令到工作流的完整落地4.1 本地开发时的标准操作流我个人的标准工作流是这样的# 从最新主干创建分支 git switch master git pull --rebase git switch -c feature/xx-story # 写代码分几次提交 git add . git commit -m feat: 实现xxx # 干了几天后发现主干有更新 git fetch origin git rebase origin/master # 如果冲突逐个解决并继续 git add 冲突文件 git rebase --continue # 推送自己的分支 git push -u origin feature/xx-story这里有一个细节值得单独说我建议把git pull默认习惯改成git pull --rebase。普通git pull是fetch加merge只要远端有更新就会在本地产生一个merge提交git pull --rebase则是把本地未推送的提交临时放到一边先拉到远端最新提交再把本地提交依次重放结果就是本地分支始终在远端最新提交之上没有任何多余的分叉。这个习惯只适合你自己独占的分支如果要在team分支上拉取代码请老老实实使用不带--rebase的pull避免重写共享历史。4.2 合入主干时的标准操作流当feature开发完成需要合入主干时我会先做一次收尾检查确认分支上的提交都是自己想要的且没有临时代码。执行git fetch origin确认主干有没有新提交。如果有先git rebase origin/master把基底同步再重新跑一遍测试。切回主干执行git pull --rebase更新本地主干。执行git merge --no-ff feature/xx-story合入。--no-ff的意思是即使feature分支相对于主干可以快进合并也强制生成一个合并提交。这个选项是我强烈建议团队使用的因为它能在历史里保留这是一个功能分支的合并这件事。反之如果执行普通mergeGit在feature分支没有分叉时会直接快进合并后的历史看起来就只是主干上多了几个提交分支信息完全丢失。对于只看主干历史的同事来说这种丢失会让他们分不清哪些提交属于同一个功能。如果团队追求主干历史极简也可以改用git merge --squash feature/xx-story把整条feature压缩成一个提交。到底用哪种取决于你们复盘功能时更看重清楚还是粒度。我个人的取舍是提交信息写得好、功能划分清晰的团队用--no-ff提交信息混乱、功能迭代非常碎片化的团队前期先用squash过渡。4.3 冲突处理Rebase冲突和Merge冲突各自怎么解很多人在rebase遇到冲突时手忙脚乱是因为照搬了merge的冲突处理习惯。两种冲突的处理姿势是有区别的。Merge冲突Merge冲突时工作区里会出现冲突标记你只需要把文件打开手动处理成理想状态然后执行git add 冲突文件 git commitGit会把这次合并生成合并提交一切到此结束。不需要--continue因为你本来就在merge的收尾阶段。Rebase冲突Rebase冲突时Git会告诉你当前正在重放哪个提交并且HEAD处于一个临时状态分支还没有移动。你需要# 处理冲突改成期望结果 git add 冲突文件 git rebase --continue注意千万不要在rebase中途直接git commit否则会弄乱rebase内部状态。--continue会让Git继续处理下一个提交直到全部重放完。如果你想放弃整个rebase回到rebase之前的状态执行git rebase --abort这个操作是非常安全的。还有一个git rebase --skip会跳过当前正在重放的这个提交一般在确定该提交已经不需要时才用。冲突处理的时间点差异是最容易让人崩溃的同一个冲突在merge时只需解决一次在rebase时可能每个提交都要再解一遍。如果你发现某个文件在rebase过程中反复冲突通常意味着这个文件被两边改得太碎这时候可以考虑放弃rebase改用merge——虽然历史会脏一点但至少你的头不会秃。4.4 防止翻车的安全约定不管团队用什么样的工作流我都建议你们把下面几条约定写进Wiki或者README禁止对已推送的分支执行rebase除非经过整个团队确认并且准备好应对一切后果。永远不要用裸的--force推送。如果确实需要强制推送用--force-with-lease它会在推送前检查远端是否还是你上次fetch的状态能避免覆盖别人刚推的新提交。git commit --amend同样只适用于未推送的提交。它本质上是把上一个提交替换成新提交修改了哈希对已经推送过的分支做amend会带来和rebase一样的连锁问题。共享分支上的任何重写操作先在工作群里喊一声。不要试图安静地搞定一个已经公开的分支你眼中的整理在别人那里可能是灾难。5. 翻车急救当历史已经被搞乱之后5.1 reflog找回丢失提交的唯一入口首先要明确一个底层事实Git里的提交对象不会因为rebase、reset或分支删除就立刻消失它们只会在没有任何引用指向后成为悬空对象最终在垃圾回收时被清理。所以在误操作之后你有充足时间把它们找回来而找回的入口就是git reflog。git reflog记录的是所有引用尤其是HEAD每次移动的历史。哪怕你用git reset --hard回退了一万步reflog里也会记录你曾经的每一个位置。git reflog -20你会看到类似这样的输出a1b2c3d HEAD{0}: reset: moving to a1b2c3d f4e5d6c HEAD{1}: rebase (finish): returning to refs/heads/feature/xxx e7f8a9b HEAD{2}: rebase (abort): returning to refs/heads/feature/xxx只要找到你误操作之前的那个HEAD哈希就能把分支恢复到那个状态git switch -c rescue-branch a1b2c3d git cherry-pick e7f8a9b这个方法对rebase中断、reset误用、merge失败后想反悔都有效。我第一次帮同事救人时就是靠reflog把他以为已经丢掉的15个提交全部捞了回来。所以遇到commit丢了先别慌Git比你以为的保守得多。5.2 共享分支已经被force push重写之后假设你已经踩了雷同事对一个已经推送的feature分支执行了rebase并force push其他人的本地分支还停在旧哈希上。这时候你直接pullGit会把新旧两段历史都拉下来然后尝试merge结果往往是产生一大堆重复提交。正确做法是这样先不要做任何写操作打开git reflog看看自己当前分支的HEAD位置记录下还没推送过的本地提交哈希。执行git fetch origin让本地拿到远端重写后的新历史。找出自己本地独有的提交。如果这些提交确实还需要保留先在当前分支上给它们做一个临时的备份分支比如git branch backup-my-commits 旧哈希。把当前分支重置到远端最新状态git reset --hard origin/feature/xxx。再通过git cherry-pick把备份分支上的提交依次补回来。这套流程能保证你在不引入旧历史噪音的前提下保住自己的未推送改动。我特意强调先记录reflog再reset是因为很多人在慌张中先执行了reset结果连找旧提交的入口都丢了。任何时候先备份再重置。5.3 revert、reset、cherry-pick的取舍急救时到底该用哪个命令取决于你动的是已推送还是未推送的历史。git revert适合已经推送到远端且大家都在用的分支。它不会删除历史里的坏提交而是新增一个反向提交把改动抵消掉。这样历史保持完整不会影响协作。git reset适合本地未推送的分支可以任意回退到之前某个提交包括--hard连工作区一起动、--soft只动HEAD、--mixed动HEAD和暂存区。git cherry-pick在紧急修复时最好用。比如某个commit只在release分支上提交了master也需要这个修复不能rebase或merge整个分支时用git cherry-pick commit精准复制。我给团队培训时总说一句话已推送的用revert未推送的用reset单一提交要转移用cherry-pick。至于rebase它和这三者都不冲突但永远把它限制在只有你能掌控的分支这个范围内。我自己现在的工作习惯其实很简单个人开发阶段的feature分支常用git rebase -i整理提交、用git pull --rebase同步主干一旦分支推送到远端并开始在群里被讨论就切换到merge模式合入主干时统一用git merge --no-ff绝不碰force push。这个习惯坚持下来以后团队里的历史错误明显少了。别人再问我Git合并怎么选我也总是给同一句回答先问自己这段历史除了你之外还有谁在看。如果答案是别人——请选Merge别让一条git rebase毁掉一整天的协作。