
1. 多分支并行时历史为什么会长杂草手上同时跑三条线一条主干、一条修 bug 的紧急分支、一条做了两周还没合的功能分支中间还夹着一条给某次演示临时拉出来的试验分支。这个状态下你只要敲一次git log --graph --oneline --all屏幕上就是一锅乱麻——横七竖八的合并线、来回穿插的提交、十几个fix typo和再改一下。这不是谁的错多分支协作本来就长这样。但问题是这种历史过了两周连自己都看不懂更别说让同事 review 或者回头定位是哪次提交引入的问题。git rebase就是在这个场景里被反复拿出来用的工具它干的事情说白了只有一件把你某一串提交挪个位置重新落一遍让历史保持一条连贯的线而不是到处打结。这篇文章不打算讲 rebase 最基础的那点东西——git rebase master这种操作随便搜都有——而是聚焦在多分支并行管理这个真实场景里rebase 怎么当整理工具用特性分支怎么跟进主干、一串依赖分支怎么整体搬家、交互式整理怎么做得干净、冲突怎么处理、出了问题怎么全身而退。适合已经能正常用 commit / branch / merge但一遇到 rebase 冲突就想--abort收工的朋友也适合团队里负责定流程、想搞清楚到底哪些分支允许 rebase的人。下面所有操作我都按实际跑过的顺序写命令能直接抄。2. 先把 rebase 的底层动作想明白2.1 它不是在合并而是在重新落一遍很多人对 rebase 的误解来自把它和 merge 放在同一维度比较。严格说merge 是两个历史线的汇合会生成一个双亲提交rebase 是一串提交的重新落地它根本不产生合并提交。理解这一点后面所有行为都能推导出来。具体动作是这样的假设你有main和featurefeature从某个旧点分出来之后main往前走了三个提交。执行git rebase main时Git 先找到feature和main的共同祖先merge base把共同祖先之后feature上的提交按顺序取出来暂存成一组补丁或者更准确地说记录下每个提交的变更内容与元信息然后把feature指针拨到main的最新提交上最后把这组提交一个接一个地重新应用到新基底之上。每一个重新应用的提交都是新的对象哈希值全部变了作者信息保留、提交时间保留但提交者时间和父提交变了。这个机制直接决定了三件事第一rebase 之后提交 ID 必然全变任何基于旧 ID 的引用别人本地基于它拉的分支、CI 上的记录、issue 里贴的链接都会对不上第二每次重新应用都可能产生冲突冲突次数等于改动区域重叠的提交数可能一个提交冲突三次第三rebase 是可中断、可续做的因为它本质是一个循环过程每一步的状态都存在.git/rebase-merge/目录里。我习惯把 rebase 想成把一摞卡片换个桌面重新摆一遍——卡片内容不变但摆放位置和顺序可能被你调整而且中途随时可以停下来说这张我要改一下。2.2 three 指针HEAD、分支引用、ORIG_HEAD 与 reflogrebase 过程里最容易被忽略的是我原来在哪。这里有两层保险理解了就不会慌。第一层是ORIG_HEAD。执行 rebase 的瞬间Git 会把当前 HEAD 的位置写进ORIG_HEAD所以哪怕你 rebase 到一半彻底蒙了git reset --hard ORIG_HEAD就能回到动手之前。第二层是 refloggit reflog会记录每一次 HEAD 的移动包括 rebase 过程中的每一步。git reflog输出里那行rebase (start): checkout main和rebase (finish): returning to refs/heads/feature就是分界线你可以精确地跳回任何一个中间状态。还有一个很多人不知道的细节rebase 进行中.git/下会有一个特殊状态此时HEAD是游离的detached指向那个正在被应用的新提交。这就是为什么 rebase 冲突时git status会提示 You are currently rebasing branch feature on xxxx。这个状态下你做的任何 commit 都会被算作 rebase 流程的一部分所以千万别在冲突时手贱git commit正确动作是git add之后git rebase --continue。2.3 冲突标记里谁是我们的这个坑必须提前说这是我在带人时讲过最多遍的一件事也是新手在 rebase 里最容易懵的地方。merge 冲突时 HEAD那段是你当前所在分支的内容后面是要合进来的分支。rebase 冲突时语义反过来了HEAD那一侧是你正在 rebase 到的新基底也就是 upstream / main 的内容另一侧才是你自己的提交。因为 rebase 的机制是先站到新基底上再把你的改动叠上去所以起点变成了别人的代码。举个具体场景你在feature上把某个函数签名改了main上同事也改了这个函数。rebase 到 main 时冲突 HEAD里显示的是同事的新签名里是你的旧签名加你的改动。如果你习惯性认为HEAD 是我的就会把同事的改动删掉反过来保留自己基于旧代码写的版本然后悲剧在几天后爆发。解决办法不是背口诀而是每次冲突都先看一眼git status里那句both modified: src/xxx.js上面跟着的rebase in progress; onto abc1234确认当前处于 rebase 状态再做判断。3. 特性分支跟进主干的标准动作3.1 动手之前的三件准备rebase 出事故九成不是因为命令敲错而是因为动手前的状态不干净。我固定会做这三件事做了两年多基本没再翻过车。第一件确认工作区干净。git status必须显示nothing to commit, working tree clean。有未提交的改动时要么 commit要么git stash。关于 stash有个配置很值得开git config --global rebase.autoStash true它会在 rebase 前自动 stash、结束后自动 pop省掉手工步骤。但我建议新手先别开这个开关因为它会掩盖你带着脏工作区在跑 rebase这个事实等哪天 pop 冲突了你都不知道发生了什么。先练熟手动流程再上自动化。第二件给自己留后路。git branch feature-backup建一条备份分支成本你算一下就是一个 41 字节的文件但它是所有后悔药里最快的那一颗。虽然ORIG_HEAD和 reflog 也能救但在紧张状态下敲git reset --hard feature-backup比回忆 reflog 里那一长串哈希要可靠得多。第三件先看清要搬哪些提交。git log --oneline main..feature列出本次会被重放的提交清单git log --oneline feature..main列出主干上有哪些新东西。这两条命令一敲你对这次 rebase 会动到什么就有谱了。尤其是提交数量超过 10 个的时候先看一眼往往能发现里面混着几个不该提交的文件或者没意义的 WIP 提交那正好顺手用交互式整理掉。3.2 完整命令序列与逐步解读标准流程我固定成这五步直接抄# 1. 切到主干拿最新代码 git switch main git pull --ff-only # 2. 回到特性分支建备份 git switch feature git branch feature-backup-$(date %m%d) # 3. 看一眼将被重放的提交 git log --oneline main..feature # 4. 执行 rebase git rebase main # 5. 结果验证 git log --graph --oneline --all -20 git range-diff main...feature-backup-0725 main...feature第 1 步里我用--ff-only而不是普通pull原因是如果本地 main 有本地提交导致无法快进说明流程上出问题了应该是没人直接在 main 上开发的这时候报错比自动 merge 出一个合并提交更好能及时暴露问题。第 4 步如果一路无冲突几秒钟就结束输出类似Successfully rebased and updated refs/heads/feature。如果有冲突会停在冲突处git status告诉你哪些文件冲突。第 5 步里的git range-diff是我强烈推荐的一个命令Git 2.19 之后就有。它专门用来对比两个提交区间的差异rebase 前后各给一个区间它会告诉你哪些提交是等价的内容一致但哈希变了、哪些被改动了。输出里用表示等价、!表示有差异。这个命令的价值在于rebase 之后你没法靠哈希确认我的改动没丢但 range-diff 能明确告诉你12 个提交里 11 个完全等价第 7 个因为解决冲突有细微差异。我现在的习惯是每次 rebase 完跑一遍比肉眼看 diff 靠谱得多。如果 rebase 完成后要推到远端因为哈希变了必须强推但绝对不要用--force用git push --force-with-lease origin feature区别在哪--force是无条件覆盖远端如果这期间有同事往这个分支推了东西你会直接抹掉他的提交而且毫无提示。--force-with-lease会先检查远端分支是否还是你本地记录的那个位置如果不是就拒绝推送。这是团队协作里的基本礼貌也是给自己留的保险。3.3 交互式整理把提交打磨成能看的样子rebase 真正让我离不开的地方在这里。合并之前把一堆零碎提交整理成几个逻辑清晰的提交review 效率能差出好几倍。启动方式git rebase -i main或者整理最近三个提交git rebase -i HEAD~3。打开的是一个文本编辑器列出从旧到新的提交每行前面有个指令词。常用的就这几个指令缩写作用典型用途pickp保留该提交默认rewordr保留内容改提交信息修错别字、补上下文edite停下来让你改内容拆提交、补漏文件squashs合并到上一个提交合并信息几个提交合并成一个fixupf合并到上一个提交丢弃本条信息清理fix typodropd删掉该提交移除调试代码execx执行一条 shell 命令每个提交后跑一次测试一个我天天用的组合本地开发时随手写git commit -m fixup! 实现用户登录校验fixup!前缀配合git config --global rebase.autosquash true之后git rebase -i --autosquash main会自动把这条提交标成fixup并排到目标提交后面。这套组合的价值在于开发时完全不用纠结提交粒度想到哪写到哪最后统一整理。整理成本从每写一行就要想清楚放哪个提交降到了最后一次批量处理。exec的用法值得单独说一下。在交互列表里加一行exec npm testGit 会在应用完它上面的所有提交之后执行这条命令失败就停下来。用它做二分定位特别有效一堆提交里不知道哪个引入了测试失败就在中间位置插exec逐步缩小范围。比git bisect轻量适合提交数不多的情况。4. 多分支场景下的进阶玩法4.1--onto把一段提交精确搬到另一个地方这是 rebase 里最有威力也最少人用的参数。基础形式是git rebase --onto 新基底 起始点 要移动的分支意思很直白把起始点之后、分支之前的那些提交搬到新基底上。为什么需要它考虑一个常见结构从main拉出feature-afeature-a上又拉出feature-bB 依赖 A 的代码。现在发现feature-a的实现方向有问题要重写但feature-b的代码是好的。如果直接在feature-b上git rebase mainB 上的提交会被重放到 main但 A 那些提交就丢了B 会因为找不到依赖而编译失败。正确做法是# 把 feature-b 上仅属于 B 的提交搬到新的 feature-a 上 git rebase --onto feature-a-new feature-a-old feature-b三个参数的关系用一句话记--onto后面是新家第一个参数是旧家的起始坐标第二个参数是要搬的区间终点。这里有个特别容易踩的坑起始点填的是旧分支的顶端而不是共同祖先。填错会导致多搬或少搬提交。判断方法很直接git log --oneline feature-a-old..feature-b列出来的必须恰好是你要搬的那些提交对不上就说明参数不对。还有一种用法是摘掉中间几个提交git rebase --onto HEAD~5 HEAD~3这种写法可以把最近第 5 到第 3 之间的提交摘出去把更近的两个提交接到 HEAD~5 上。用得少但需要的时候很救命。4.2 保留合并结构--rebase-merges默认的 rebase 会把所有提交拍平成一条线包括你之前在特性分支上做的合并。如果你的分支里有意保留了合并结构比如多个子功能分别合并进来合并提交本身就是一种文档拍平之后就丢失了这个信息。git rebase --rebase-merges main能保留合并结构。它的原理是在 todo 列表里插入label、reset、merge指令通过这些指令重建原来的分叉和汇合点。和它配套的交互式编辑会复杂一些你会看到类似这样的内容label onto reset onto pick abc1234 子功能A的第一个提交 pick def5678 子功能A的第二个提交 label feature-a reset onto pick 9012abc 子功能B的提交 label feature-b reset feature-a merge -C 3456def feature-b # 合并子功能B第一次看到会懵但读一遍就懂了label是给当前位置打标记reset是跳到某个标记merge -C 原哈希是重放一次合并。你要做的是保持这个结构别动只调整pick行的顺序或者合并策略就行。注意--preserve-merges这个老参数已经废弃了在较新版本里被标记为 deprecated因为它有已知的正确性问题涉及交互式 rebase 时会丢提交。看到旧文章里推荐它直接跳过用--rebase-merges。4.3 依赖分支栈式管理--update-refs这是近几年最让我惊喜的一个改进直接解决了多分支管理里最烦的一类操作。场景是这样feature-a→feature-b→feature-c三条分支依次依赖。现在feature-arebase 到 main 了feature-a上的提交哈希全变了feature-b和feature-c的基底瞬间指向了不存在的历史。以前只能手动一条条 rebase先--onto搬 B再--onto搬 C搬错一次就要从头来。现在只要在第一次 rebase 时加参数git rebase --update-refs mainGit 会识别出当前分支下游还有哪些分支的提交被包含在内rebase 完成后自动把这些分支引用一起更新到新位置。整个过程一次搞定三条分支的依赖关系保持完好。想默认开启的话git config --global rebase.updateRefs true这个配置我建议直接开。它只在检测到下游分支时才生效不会影响普通的 rebase属于纯收益。开了之后git log --graph --all会看到三条分支还是整整齐齐地串着只是基底换到了最新的 main 上。配合一个小技巧给分支起名时用前缀区分层级比如feat/login-base、feat/login-ui、feat/login-test一眼能看出依赖顺序。人类可读的命名在栈式管理里比哈希有用得多——毕竟--update-refs帮你自动更新了引用但你得知道该从哪条分支开始动手。5. 冲突处理与全身而退5.1 冲突解决的实操节奏冲突出现了别慌节奏是这样的git status # 看哪些文件冲突、当前在 rebase 第几步 git diff --name-only --diff-filterU # 只列冲突文件 # 编辑文件处理标记 git add file # 标记为已解决 git rebase --continue # 继续下一个提交关键点git add之后不要 commit。很多人条件反射地敲git commit结果会在 rebase 流程里多插入一个提交历史反而更乱。--continue会自己完成提交动作。如果冲突内容你完全看不懂两个命令能救命git diff看当前冲突的具体差异git show 冲突提交的哈希看那个提交原本想干什么。知道意图之后再决定怎么合并比对着标记猜要快得多。还有一种情况某个提交的改动已经完全被 upstream 吸收了比如你改的 bug 别人也单独修了解决冲突时发现该文件应该保持 upstream 版本。这时候不要硬凑直接git checkout --ours file # rebase 中 --ours 是 upstream 那一侧 git add file git rebase --continue甚至更干脆用git rebase --skip直接把当前这个提交整个跳过。但跳过前务必确认这个提交里没有其他你想保留的改动git show一下再决定。跳过之后这个提交就真的没了虽然能从 reflog 找回来但麻烦。5.2 回滚三板斧和它们的语义差别rebase 出问题时的三个退出选项语义完全不同别混用命令效果适用场景git rebase --abort完全放弃回到 rebase 开始前状态发现方向错了、参数填错了git rebase --skip跳过当前提交继续下一个当前提交的改动已被 upstream 包含git rebase --quit退出 rebase 流程但保留当前已应用的部分想保住已完成的部分剩下的手动处理--quit是最容易被忽略的。它和--abort的区别是--abort是撤销一切--quit是我不 continue 了但已经搬好的提交我认了。20 个提交搬到第 18 个时遇到难以解决的冲突前面 17 个都好好的这时候--abort会全部作废重来--quit能保住成果之后再单独处理剩下两个。这个命令我用过三四次每次都能省下十几分钟。--abort之后如果发现备份分支和当前状态不一致比如 rebase 中途你自己手改了文件最稳的是git reset --hard 备份分支。但注意--hard会丢弃工作区改动执行前git status确认一遍。5.3rerere让 Git 记住你解决过的冲突这个功能名字来自 reuse recorded resolution默认关闭但我觉得每个长期用 rebase 的人都该开。git config --global rerere.enabled true开启后每次你手动解决一个冲突Git 会把冲突前的样子和解决后的结果成对记下来。下次遇到完全相同的冲突它会自动套用上次的解法并在文件里留下标记让你确认。它的杀手场景是反复 rebase 同一个分支。比如你 rebase 到 main 解决了 8 个冲突review 之后要求你调整某个提交的内容你就得重新 rebase 一次同样的 8 个冲突又要解一遍。开了 rerere第二次几乎是瞬间完成。另一种场景是长期分支定期跟进主干每周 rebase 一次冲突模式高度重复rerere 的命中率会很高。需要留意的两点一是自动解决的结果仍然要检查git diff看一眼再git add它只是省了手工编辑不是替你做决策二是如果发现某个记录是错的git rerere forget file可以清掉该文件的历史记录避免一直被错误方案自动套用。6. 排查速查表与踩过的坑6.1 高频问题速查现象大概率原因处理方式error: cannot rebase: You have unstaged changes工作区脏git stash或先 commitrebase 卡住无输出很久提交数量多逐个应用慢耐心等或分批次-i处理完成后发现少了提交--skip用过头 或--onto参数错git reflog找回git cherry-pick补回提交内容莫名其妙变了冲突时误删了对方改动git range-diff定位git show比对推送被拒哈希变了远端不认git push --force-with-lease分支在上游显示重复提交之前用 merge 同步过主干见下方 6.2 说明fatal: invalid upstream分支名拼错或远端未拉取git branch -a确认名称6.2 三个我自己踩进去过的坑第一个坑和 merge 混用导致重复提交。早期我做过这样的操作先把 main merge 进 feature过两天又对 feature 做 rebase 到 main。结果git log里出现了一堆看起来重复的提交——因为 merge 那个动作已经把你的提交和主干的历史关联起来了rebase 又重新应用了一遍内容相同的提交Git 认为它们是不同的提交内容一样但父提交不同哈希不同于是就变成了两份。这个状态很难看解决方法是git rebase --rebase-merges配合git rebase -i手动drop掉重复的那些或者干脆放弃这条分支重新拉一条干净的重做。教训是一条分支的生命周期内跟进主干只用一种方式。要么全程 merge要么全程 rebase别来回切。团队里最好对这个有明确约定比如特性分支用 rebase 跟进集成分支用 merge 汇合。第二个坑在共享分支上 rebase。这条基本算常识了但我还是见过。哈希变了意味着所有基于它工作的同事的本地分支都和远端分叉了他们下次 pull 会得到一个巨大的合并或者被迫强推。判断标准很简单这条分支除了你还有别人在用吗。有就别 rebase。没有随便玩。如果真的不小心 rebase 了公共分支最快的补救不是强推回去而是在群里说一声让大家git pull --rebase让大家本地重放而不是制造合并。第三个坑只检查编译通过就推了。rebase 的冲突解决是逐提交的这意味着中间某个提交解决完冲突后代码可能是编译不过的状态虽然最终结果是对的。如果你的 CI 是按提交逐个跑测试的或者要配合git bisect定位问题中间提交的质量就很关键。我现在会在交互式 rebase 里每隔两三个提交插一个exec npm run build确保每个阶段都能过。慢是慢一点但比事后发现某次提交是坏的、导致 bisect 全面失效要划算。6.3 给自己加几道护栏最后一组配置我基本是逢新机器就配一遍# 拉取时默认用 rebase避免无意义的合并提交 git config --global pull.rebase true # 交互式 rebase 自动整理 fixup!/squash! 提交 git config --global rebase.autosquash true # 自动更新下游分支引用 git config --global rebase.updateRefs true # 记住冲突解决方案 git config --global rerere.enabled true # 强推时默认带保险 git config --global push.default current第一条pull.rebase true有争议有人喜欢 merge 的显式记录。我的判断是个人分支上pull.rebase让历史干净共享主干上其实本来就该禁止直接提交。真遇到需要保留合并的场景单独用git pull --no-rebase覆盖一次就行。另外再分享一个小习惯执行任何有破坏性的 rebase 之前先在终端里敲git log --oneline -5看一眼当前状态rebase 之后再敲一遍对比。这两个五行的输出比任何复杂的验证流程都直观——你能立刻看出提交数量对不对、提交信息有没有被意外改动。我自己是靠这个笨办法发现过两次参数填错的问题都是提交数量少了一个一查发现--onto的起始点填成了共同祖先。真正让我对 rebase 从害怕变成离不开的转折点不是学会了某个高级参数而是把ORIG_HEAD、备份分支、range-diff这三样东西变成了肌肉记忆。有了这三层兜底动手时心理负担一下就没了反而能更大胆地去整理历史。历史干净带来的收益是复利的review 快了、定位问题快了、新人接手看懂的速度也快了。至于命令本身翻来覆去就那么几条用熟之后你会发现真正的功夫都在动手前想清楚要搬哪一段这一步上。