Git 用了几年之后我越来越觉得一件事真正拉开团队协作效率差距的往往不是谁记的命令多而是对提交历史的管理思路。rebase 就是最典型的一个话题它让新手疑惑让老手爱不释手也是代码评审时最容易引发争论的命令之一。这篇不打算只贴命令我想把 rebase 的原理、高频用法、完整实操和踩坑经验一次性讲透适合刚接触 Git 的开发者也适合已经被 rebase 坑过几次、想系统搞明白的人。先说结论rebase 这个词翻译过来叫“变基”你可以把它理解为“把当前分支的提交重新放到另一个基点上去”。它不是复制文件内容而是把整个提交链迁移过去。理解了这句话后面所有操作都不会乱。1. 先弄懂 rebase 核心逻辑merge 差在哪1.1 从一次“变基”说起很多人第一次听说 rebase是因为它经常和 merge 放在一起对比。我见过不少项目里的历史是一团乱麻各种 merge commit 堆在一起没人说得清某个功能到底是什么时候合并进来的。问题往往就出在大家无脑用了 merge而不是说 merge 不好得看场景。举个例子。你从 main 分支的 E 提交点拉了一个 feature 分支自己提交了 A、B、C 三个提交与此同时 main 上别人合入了 F 提交。这时候分支图大概是这样的A---B---C feature / D---E---F main如果走 mergeGit 会把两条线的改动合并起来产生一个新的 merge commit GA---B---C / \ D---E---F-------G main或 featuremerge 的特点很明确它保留了两条分支“分叉又汇合”的真实轨迹。这在某些场景下是优点因为完整记录了并行的过程但在频繁协作的项目里这种“真实”会变成噪音历史图越来越复杂代码评审时很难看清一个功能分支到底改了哪些东西。如果走 rebase命令是git rebase main假设当前在 feature 分支Git 会做一件很“暴力”的事把 A、B、C 这三个提交先拆下来以 F 作为新的基点重新放上去变成 A、B、CA---B---C feature / D---E---F main注意一个关键点A、B、C 虽然内容上和 A、B、C 一致但 commit id 全部变了。这是很多新手第一次 rebase 后最困惑的地方明明代码没变为什么提交哈希完全不一样因为 Git 计算提交哈希时不只看文件快照内容还要看父提交的信息。你换了基点父提交变了后续每个提交的哈希就全部重新计算。这也是 rebase 被称为“重写历史”的根本原因它没有复制提交而是生成了一批新提交。1.2 为什么 rebase 能整理出线性历史理解了 rebase 的底层行为你就能明白它的核心价值让分支历史变得线性、干净、可读。我一直觉得提交历史本质上是给未来的人包括三个月后的自己看的说明书。如果你每个功能分支上都留着十来个“wip”“fix typo”“改一下”的提交拉到主干后历史会变成一长串没有信息量的流水账。代码 review 的时候同事只能看到“这个文件反复改了五次”完全看不懂你的实现思路。rebase 天然适合解决这个问题。因为它可以非常精细地控制提交的合并、拆分、改信息把一段凌乱的开发过程整理成一个逻辑完整的提交链。很多团队使用 rebase并不是因为 merge 不能合并代码而是希望主干上的每一个提交都有清晰的意图。但这同时也意味着rebase 不能乱用。它是对“历史”的修改一旦这条历史已经被推送到远程、被别人拉取过重写就会引发连锁反应。后面我会专门讲哪些场景绝对不能 rebase这里先记住核心概念提示rebase 的适用对象是“还没推送到公共远程的分支”。在私有功能分支上你想怎么整理都行在公共分支上请管住自己的手。2. 三种高频用法从拉取代码到整理提交2.1 用 git pull --rebase 告别无意义的 merge commit日常开发中最常见的 rebase 使用场景其实是拉取远端代码。默认情况下git pull等价于git fetch加git merge。如果你本地有提交远端也有新提交一次普通的git pull就会产生一个 merge commit。单人开发还好多人协作时每次同步都留一个“Merge remote-tracking branch”的提交历史会很快变得没法看。我自己的做法是把 pull 的默认行为直接改成 rebasegit config --global pull.rebase true设置之后git pull会变成git pull --rebase等价于先把本地提交放在一边拉取远端最新内容到本地再把你的本地提交重新放到最新提交之后。效果就是你的本地提交永远“站在”远端最新代码之上历史是一条漂亮的直线不会多出乱七八糟的 merge commit。如果你的本地还有未提交的改动rebase 会抱怨工作区不干净。解决办法也不是非得手动 stash可以顺手把自动暂存打开git config --global rebase.autoStash true这样执行git pull --rebase时Git 会自动把你的未提交改动暂存起来rebase 完成后再自动恢复。我实测下来这个配置非常省心几乎感觉不到它的存在但避免了大量“啊又忘记 stash 了”的打断。要注意的是pull --rebase并不是银弹。如果本地只有一个提交、远端也只前进了一次它和 merge 的差别仅仅在于历史形态但当本地有多个提交、又和远端改到同一处代码时rebase 会让冲突一块块蹦出来你得一个一个解决而 merge 是把所有冲突集中到一次。这个差异后面实操部分专门讲。2.2 交互式 rebase把 10 个临时提交整理成 3 个正经提交如果说pull --rebase只是同步代码的姿势那git rebase -i才是 rebase 的真正杀手级功能。交互式 rebase 的命令形式常见有这么几种git rebase -i HEAD~3 git rebase -i commit-id git rebase -i --root第一种表示操作最近 3 个提交第二种表示从指定提交之后开始操作第三种表示对整个仓库的所有提交进行操作。实际项目里前两种用得最多。执行后 Git 会打开一个文本编辑器列出你选中的提交pick a1b2c3 完成登录模块 pick d4e5f6 修一下样式 pick g7h8i9 补充单元测试这里的pick是默认动作意思是“保留这个提交”。交互式界面的价值在于你可以把每一行的pick改成其他关键字从而对提交做不同的操作。对我个人来说最高频的有四个reword保留提交内容但修改提交信息适合给那些“临时提交”改成正经描述。squash把这个提交合并到上一个提交中同时合并提交信息。适合把多个小改动归并成一个逻辑完整的提交。fixup和 squash 类似但会丢弃被合并进来的提交信息只保留第一个提交的信息。适合“把这个补丁悄悄补进上一个提交”。drop直接删除这个提交。举个例子。我开发一个新功能时常常会先随手提交然后再接着改日志经常是“wip”“改bug”“格式化一下”。功能开发完以后我不会直接 push而是用git rebase -i HEAD~5把这些乱七八糟的提交整理一下。假设有 4 个提交pick 111111 临时提交 pick 222222 又改了一下 pick 333333 登录接口联调 pick 444444 补充测试我想把前两个合并成一个正式的“实现登录接口”操作就是把第二行的pick改成fixup保存退出pick 111111 临时提交 fixup 222222 又改了一下 pick 333333 登录接口联调 pick 444444 补充测试因为fixup不会保留被合并提交的 message所以最终保全的是第一行的“临时提交”信息我只需要在这一步顺手把第一行改成reword或者干脆在保存后重新提交信息reword 111111 实现登录接口 fixup 222222 又改了一下 pick 333333 登录接口联调 pick 444444 补充测试保存退出后Git 会先让你修改第一个提交的 message然后继续处理后面的提交。等整个流程走完原来的 4 个提交变成 3 个历史清晰了很多。交互式 rebase 还有一个容易被忽略的能力调整提交顺序。你可以在编辑器里直接调整行的上下顺序Git 会按照新顺序重新应用这些提交。这个特性在“想把某个独立提交提前合入主干”或“把相关提交组合到一起”的场景下非常实用。但要注意调换顺序后极有可能触发冲突因为不同提交改动的上下文变了这个属于正常现象。2.3 功能分支 rebase 到主干再做快进合并第三种高频场景发生在功能开发完成后准备把分支合并回主干之前。假设你和同事同时在开发不同功能你的分支已经落后 main 好几条提交。直接 merge 会有两种后果要么产生 merge commit要么因为主干新增了文件导致冲突。更干净的做法是先把功能分支 rebase 到最新 main 上再进行合并。操作流程是这样的git checkout feature git rebase main这会把 feature 分支上的所有提交重新落到 main 的最新提交之后。此时 main 和 feature 的关系就变成了“feature 完全包含 main而且没有分叉”。接着切到 main 执行 mergeGit 会直接快进git checkout main git merge feature因为 feature 已经基于 main 的最新提交了这次 merge 会触发 fast-forward不会生成 merge commitmain 的历史等于直接往后延伸了一截干净利落。我通常在两种情况下会做这一步一是功能开发到一半需要 pull 最新的主干来保持同步这时会用git rebase main代替 merge二是功能全部开发完毕准备提代码评审前用 rebase 把个人分支整理好并同步到最新主干确保评审者看到的提交是基于最新代码的。不过这里有一个很大的坑往下看之前建议先记住一句话这条流程只适用于“这个 feature 分支还没有被其他人共用”的情况。如果你已经把 feature push 到远程并且同事也拉取过、甚至在上面开发就绝对不能再做 rebase否则后果非常酸爽。3. 完整实操一个能复现的 rebase 冲突演练3.1 先造一个用于演练的仓库光看不练很容易眼高手低。我见过不少同事命令背得贼溜一遇真实冲突就手忙脚乱。所以我建议所有人都自己搭一个模拟仓库把冲突触发一遍、解决一遍再说自己会用 rebase。先做准备工作mkdir rebase-demo cd rebase-demo git init如果你还没初始化过用户信息顺手配一下git config user.name demo git config user.email demoexample.com然后创建初始提交echo 第一行 file.txt git add file.txt git commit -m init commit接着从 main 拉出一个功能分支git checkout -b feature echo feature 的改动 file.txt git commit -am add feature work切回 main模拟另一个人的提交git checkout main echo main 的改动 file.txt git commit -am add main work这时候用git log --oneline --graph --all看一下分支图形大概是* 2f4a5b6 (main) add main work | * 1a2b3c4 (feature) add feature work |/ * 7d8e9f0 init commit两条分支同时修改了 file.txt 的末尾这就是接下来触发冲突的根源。3.2 触发冲突解决冲突继续 rebase现在切回 feature执行 rebasegit checkout feature git rebase main由于两边都在 file.txt 的末尾追加了内容Git 没法自动合并你会看到类似这样的提示CONFLICT (content): Merge conflict in file.txt error: could not apply 1a2b3c4... add feature work hint: Resolve all conflicts manually, mark them as resolved with hint: git add/rm conflicted files, then run git rebase --continue.打开 file.txt会看到冲突标记第一行 HEAD main 的改动 feature 的改动 1a2b3c4 (add feature work)这里必须强调一个新手最容易迷惑的地方在 rebase 过程中冲突标记里的HEAD并不是你的 feature 分支而是你正在变基过去的“目标分支”内容。因为 rebase 在执行时名义上会把当前 HEAD 临时切换到目标基点上然后逐个应用你的提交所以 HEAD那部分代表的是 main 上的内容而后面跟的才是你自己的提交。这个认知特别重要。我见过有人把 HEAD 直接当成本地最新代码然后乱删一通最后 rebase 出来的分支丢掉了一大堆 main 上的改动等发现时又得靠 reflog 往回捞。正确的做法是想清楚这一行最终应该保留什么。如果两个改动都需要就把文件改成第一行 main 的改动 feature 的改动手动删除冲突标记保存文件然后git add file.txt git rebase --continue这时 Git 可能会弹出编辑器让你确认提交信息因为一个提交被“重新应用”了一次信息默认保留原来的。保存退出后rebase 就完成了。注意在 rebase 过程中绝对不要用git commit来结束一个冲突的解决。正确顺序永远是 add 标记为已解决然后git rebase --continue。如果你不小心执行了 commit会进入 detached HEAD 状态处理起来麻烦得多。3.3 验证结果线性历史长什么样rebase 完成后再查看提交图git log --oneline --graph --all输出会变成* c9f0a1b (HEAD - feature) add feature work * 2f4a5b6 (main) add main work * 7d8e9f0 init commit原来分叉的两条线变成了一条直线。feature 的提交哈希从原来的1a2b3c4变成了c9f0a1b这是因为它的父提交已经变成了 main 的最新提交。如果你还想确认 feature 是否完全包含 main 的改动可以这样检查git diff main feature没有输出就说明两边代码完全一致。现在你切到 main再执行git merge feature会发现它直接快进一个多余的 merge commit 都不产生git checkout main git merge feature输出会显示Fast-forwardmain 的头直接指向了 feature。这个效果就是 rebase 想给你带来的核心体验合并过程不需要额外记录“分支何时汇合”因为历史本来就是一条直线。到这里你已经把 rebase 最常见的完整流程走了一遍。建议你在自己的机器上把init commit、两个分支同时改同一个文件这件事复现一下亲手触发一次冲突再亲手解决比看十篇文章都管用。4. 高频报错与事故抢救这些都是真实踩过的坑4.1 “fatal: not a git repository”其实是个定位问题这个报错太常见了网络上关于它的搜索量一直居高不下而且它不仅出现在 rebase 中任何 Git 操作都可能遇到。报错信息一般是fatal: not a git repository (or any of the parent directories): .git翻译成人话就是你在一个不是 Git 仓库的目录里执行了 Git 命令或者你的父目录里也没有仓库。最典型的原因有三种第一当前目录压根没执行过git init第二你 clone 的仓库被误删了.git目录第三你身处子目录但子目录不属于任何仓库。第三种最迷惑人比如你cd到了/tmp下面随便一个文件夹那里没有 Git 仓库自然报错。排查时做两件事pwd git rev-parse --show-toplevelgit rev-parse --show-toplevel会输出当前仓库的根目录。如果它不是你想的那样说明当前命令行所在路径不对往上层走就行。很多时候报错并不是 Git 坏了而是“你根本不在那个仓库里”。如果你是在 rebase 过程中遇到这个报错还要检查一下是否误删了.git目录或者手动把仓库文件夹移动到了别的地方。这里提个醒.git目录是整个仓库的灵魂删了它的后果约等于失忆提交记录、分支、配置全部蒸发而且没有后悔药。所以没事不要动它。4.2 冲突解决到一半想反悔abort 永远是你的退路rebase 冲突解决到一半发现自己思路全乱、文件被改得面目全非心态快崩了怎么办别犹豫直接跑git rebase --abort这条命令会立刻终止 rebase把所有文件恢复到 rebase 开始之前的状态。你之前做的所有冲突修改都会被丢弃但你的分支、提交、本地修改都会原样回来。这就是 rebase 和某些“危险操作”不同的地方它给了你一个随时反悔的退路只要还没执行git rebase --continue并完成都可以 abort。和 abort 经常一起出现的是git rebase --skip它的意思是“跳过当前这个提交”。乍一听很省事但强烈建议别乱用。--skip会直接把当前正在应用的提交整个丢掉等于这个提交的改动全部不要了。除非你非常确定这个提交就是写错了、完全没用否则宁可 abort 从头再来也别 skip 一个自己没看懂的提交。我自己的经验是rebase 压到尾部几个提交时最容易因为想省事而 skip结果最后发现某个功能缺了一整块代码又得去 reflog 里扒。所以冲突多的时候慢就是快一个一个解决比自己骗自己高效得多。4.3 rebase 后代码凭空消失reflog 就是后悔药前面说过rebase 会生成一批新提交旧的提交不再被任何分支引用看起来很像是“代码没了”。实际上它们并没有被立刻删除而是变成了孤儿提交只是普通的分支视图看不见了而已。这时候 Git 的 reflog 就是你的救命稻草。reflog 全称是 reference log它记录了所有分支引用和 HEAD 在过去一段时间内的移动历史。换句话说它记得你的仓库“曾经指向过哪里”。查看方法git reflog输出会列出你最近的一串操作每行都有操作对应的提交哈希和操作说明c9f0a1b (HEAD - feature) rebase (finish): returning to refs/heads/feature c9f0a1b (HEAD - feature) rebase (pick): add feature work 2f4a5b6 (main) rebase (start): checkout main 1a2b3c4 checkout: moving from main to feature如果你 rebase 后发现自己代码不对想找回 rebase 之前的 feature 分支状态只需要在 reflog 里找到那条1a2b3c4 checkout: moving from main to feature或任何 rebase 开始前的记录然后git checkout -b feature-recover 1a2b3c4这样就基于旧提交新建了一个分支你的“失踪”代码就回来了。如果不想新建分支也可以git branch -f feature 1a2b3c4 git checkout feature但这里要提醒一句用git branch -f强行移动分支指针属于危险动作一定要确认这个旧哈希确实是你想要的状态最好是先把当前状态用另一个分支名字备份一下再去做恢复。很多初学者听到 reflog 就觉得高级其实它的本质就是一个本地操作日志不推送到远程也不参与协作。正因为它记录全面所以是 rebase 事故后最快速的救援工具。4.4 git commit --amend 和 rebase 怎么配合热词里经常有人搜git commit --amend它其实是 rebase 的一个“小号版本”专门用来修改最近一次提交。常见场景就两个一是最近一次提交信息写错了想改 message二是刚才漏提交了一个文件希望并进上一个提交里。改提交信息git commit --amend -m 新的提交信息漏提交文件时先 add 再 amendgit add forgotten.txt git commit --amend --no-edit这里的--no-edit表示保持不变的信息只把新文件并入提交。我和团队同学说过很多次这个命令不是魔法它也是创建了一个全新的提交来替换旧提交所以哈希同样会变。如果你把最近一次的提交已经 push 到了公共分支改写它就会产生和 rebase 公共分支一样的风险。它和 rebase 的配合场景最常见的是在git rebase -i里对某个早期提交使用edit。操作流程是git rebase -i HEAD~5把需要修改的那个提交的pick改成edit。保存退出Git 会停在那个提交被应用之后。此时你处于一个临时的分离头指针状态直接修改文件git add后执行git commit --amend。最后git rebase --continue继续完成剩下的提交。这个组合拳非常强大它让你能回头修改任意一个历史提交的内容和 message而不仅仅是最近一次。不过使用门槛也更高建议先在练习仓库里跑通再应用到真实分支。4.5 公共分支上千万别乱来如果说前面几条是“怎么救”这一条就是“怎么防”。我把它放在最后但它的重要性其实最高。典型事故是这样的同事把feature-a分支 push 到远程另一个同事基于它拉了feature-b继续开发。结果feature-a的作者觉得分支太乱直接git rebase -i整理一通又用git push --force覆盖了远程分支。于是feature-b的作者一 pull发现一堆冲突甚至出现“同样的提交出现了两次”的假象。为什么会这样因为 rebase 之后feature-a上的提交哈希全变了而feature-b仍然基于旧的提交链。旧提交并没有消失它和新提交同时存在于两个分支的合并基线里Git 会误认为这是两条不同的提交线于是给你制造出大量重复冲突。所以务必记住这条铁律警告已经推送到远程且可能被别人拉取过的分支不要 rebase不要 force push。如果实在要整理历史请先和所有共用人打好招呼确认大家都能接受。那公共分支怎么保持干净思路很简单公共分支永远只往前加提交不要回头改功能分支在你 push 之前想怎么 rebase 都行push 之后如果有变化尽量用普通 commit 或 merge 来补充而不是改写历史。如果确实需要更新远程一个私有 feature 分支又用了 rebase那推荐用--force-with-lease代替--forcegit push --force-with-lease origin feature--force-with-lease会先检查远程分支在你上次 fetch 之后有没有被其他人更新过如果有就拒绝推送。它比裸奔的--force安全得多。但注意它只能防止“你不知情时覆盖别人的新提交”并不代表“公共分支可以 rebase”。5. 团队协作rebase 用得好review 效率翻倍5.1 分支策略和 rebase 怎么搭配聊完命令本身我想聊聊更高一层的东西团队该用 merge 还是 rebase。这个问题在社区里吵了很多年我的观点很明确按照分支角色来决定而不是一刀切。主干分支main、develop 等公共长期分支只接受合并不接受 rebase。合并方案可以根据团队偏好选普通 merge 还是 squash merge。普通 merge 保留完整历史适合需要精确定位“每次合并的上下文”的场景squash merge 会把整个特性分支压缩成一个提交主干上的历史更像“发布日志”适合追求简洁的团队。功能分支feature/xxx、fix/xxx在推送前建议先 rebase 到最新主干再用 merge 或 PR 合并回去。这能保证功能分支的提交是基于最新代码review 时不会看到“自动合并”带来的杂音。发布分支或热修复分支也属于公共分支同样禁止 rebase。这类分支的提交往往对应版本号如果被人改写后续追溯版本就会对不上。这套规则的好处是既享受了 rebase 带来的线性历史又守住了公共分支的安全底线。不用每天都争论“该用哪个”规则写清楚就行。5.2 这 3 个 Git 配置建议直接加到全局有几个配置我真心建议所有 Git 使用者都配置上它们能显著减少日常摩擦。第一个是自动 stash 配置前面提过git config --global rebase.autoStash true第二个是开启 rerere。这个名字很拗口全称是 reuse recorded resolution意思是“复用已记录的冲突解决方案”。Git 会把你解决过的冲突方式记下来下次再遇到相同冲突时自动应用。对频繁 rebase 的人来说这个配置简直是神器。git config --global rerere.enabled true第三个是 pull 的默认策略git config --global pull.rebase true这三个配置组合在一起之后git pull --rebase基本就成了无脑操作自动暂存未提交改动、自动解决历史上的重复冲突、不会产生多余 merge commit。如果你习惯用别名还可以加两条git config --global alias.rb rebase git config --global alias.rbi rebase -i命令行短一点使用频率自然会高一点。5.3 我每天在用的功能分支工作流最后分享一套我实际每天都在用的工作流它不是银弹但对于大多数“一个需求一个分支”的开发模式来说很顺手。从主干拉功能分支git checkout -b feature/xxx。开发过程中随手提交临时信息随意但要有别让工作区一直脏着。功能写完后先整理提交git rebase -i origin/main注意是 rebase 到远端主干不是本地可能过期的 main。在交互式界面里把 wip、fix typo 之类的临时提交用fixup合并成真正的逻辑提交给每个提交写清楚说明。再同步一次最新主干git pull --rebase origin main确保没有冲突。推送到远程git push origin feature/xxx然后发起代码评审。评审通过后合入主干用什么方式合由团队规则决定我一般在主干上用 squash merge 或普通 merge。这套流程的关键在于所有 rebase 行为都发生在分支 push 之前一旦 push就不再改写。如果评审过程中其他人在主干合入了新代码我会用 merge 或再次 rebase 来同步但前提是确认当前分支只有我在用并且我不会 force push 覆盖别人的改动。可能有人觉得这套流程繁琐但它能换来一个极其重要的东西你 push 上去给评审者看的历史是经过整理的、有逻辑的、能讲故事的历史。评审者不需要在一堆“改一下”“再改一下”的提交里猜你的意图评审速度自然就上来了。最后分享一段我自己的体会rebase 不是炫技的工具它本质上是“对自己的历史负责”。我见过很多团队吵 rebase 和 merge 哪个好其实真正该吵的是“你的提交信息有没有写清楚”“你的功能分支有没有整理过”。Git 只是工具真正让协作顺畅的是每个人都愿意花几分钟把历史整理得干净完整。如果你还在犹豫要不要用 rebase我的建议是先从git pull --rebase开始这个风险最低、收益最直观等习惯了线性历史的清爽感再试着用git rebase -i整理自己的提交。千万别第一次就直接拿公共分支练手那个学费太贵了。