想象这样一个场景:你在 feature/order-system 分支上开发了一个新功能,主干 main 上也有不少新提交。为了保持历史整洁,你决定把分支 rebase 到 main 最新:
rebase 过程中遇到几个文件冲突,你逐一打开文件、选择保留哪段代码、删掉另一段。解决完后照常 git add + git rebase --continue,一路畅通,rebase 成功完成。
你以为万事大吉,直到同事 code review 时发出灵魂拷问:"你代码里怎么有 <<<<<<< HEAD 这种东西?"
打开文件一看,果然——冲突标记没删干净,就跟着 commit 一起被提交了。更糟的是,这个错误不在最近一次 commit 里,而是在 rebase 过程中的某个中间 commit 里。
你可能想:再 rebase 一次不就行了?但问题是——普通 git rebase 只会原样搬运每个 commit,不会给你机会"打开"某个 commit 修改它的内部内容。
你可以重新解决文件冲突,但你无法回到那个"已经带着冲突标记被提交"的 commit 去修改它——那个 commit 已经固化在历史里了,普通 rebase 只是把它从旧 base 搬到新 base,内容一字不改。
用 git commit --amend?它只能修改最近一次 commit,对历史中间的 commit 无能为力。
用 git reset --hard 回退再重做?可以,但你会丢失后续所有 commit 的改动,得全部重来。
这时候,你需要的是 Interactive Rebase。
git rebase -i(interactive rebase)和普通 rebase 的最大区别在于:它会先弹出一个编辑器,列出所有待搬运的 commit,让你逐个决定怎么处理。
编辑器里会看到类似这样的列表:
每行开头的命令可以修改,最常用的有:
| 命令 | 作用 |
|---|---|
pick |
原样保留(默认) |
edit |
搬到这个 commit 时暂停,让你修改后再继续 |
squash |
把这个 commit 合并到上一个 commit |
fixup |
同 squash,但丢弃这个 commit 的提交信息 |
reword |
保留改动,只修改提交信息 |
drop |
直接丢弃这个 commit |
其中 edit 就是我们需要的——它让 git 搬到这个 commit 时暂停,进入"时光隧道",让你从容地修改文件,然后继续。
假设带着冲突标记的 commit 是 d4e5f6g,它的父 commit 是 a1b2c3d:
编辑器弹出后,把 d4e5f6g 那行的 pick 改成 edit:
保存退出。git 开始搬运,搬到 d4e5f6g 时会暂停并提示:
此时你的 HEAD 就停在这个 commit 上,工作区和这个 commit 的内容完全一致——包括那些冲突标记。
把带着冲突标记的文件恢复到干净版本。有两种思路:
思路 A:如果你知道正确的内容是什么,直接编辑文件删掉冲突标记即可。
思路 B:如果这个文件本就不应该被你的分支修改,可以直接从主干拿干净版本:
这条命令的意思是:"把 origin/main 上这个文件的版本,覆盖到我的工作区和暂存区"。不影响其他文件,只改这一个。
--amend 表示修改当前 commit 而不是新建一个,--no-edit 表示保留原来的提交信息。此时 d4e5f6g 就变成了一个干净的新 commit(hash 也会变)。
git 继续把后续的 g7h8i9j、k0l1m2n 依次重新应用上去,最终输出 Successfully rebased,一切回到正轨。
到这里你可能有疑问:我之前已经 rebase 过一次了,怎么又能 rebase 一次?会不会出问题?
答案是:rebase 是对 commit 历史的操作,不是对"rebase 这个动作"的操作。每次 rebase 都是从某个起点开始,把后续 commit 逐个重新应用。你可以对同一段 commit 历史反复 rebase,每次都是"倒带重拍"。
打个比方:rebase 像是拍电影的"重新拍摄",edit 是"拍到第 3 个镜头时暂停,让你改一下道具再继续拍"。拍完之后发现还不满意?可以再倒带重来,这次改第 5 个镜头。
有一个重要细节:每次 amend 或 rebase 后,commit 的 hash 会变。因为 commit 的 hash 是由内容、父 commit、作者、时间等信息共同计算的——内容变了 hash 就变,父 commit 变了 hash 也变,后续所有 commit 的父 commit 都跟着变,形成链式反应。所以不要用 hash 去跟踪 commit,用提交信息去定位更可靠。
edit 只是 interactive rebase 的能力之一。日常开发中,squash 和 drop 同样高频:
开发过程中经常会产生"wip"、"fix typo"、"refactor"这样的临时 commit。合并到主干前,你可能想把它们整理成一个有意义的 commit:
保存后,三个 commit 合并成一个,只保留第一条的提交信息。提交历史从"一串碎片"变成"一个完整的 feat"。
如果你发现某个 commit 根本不该存在(比如误提交了测试文件),直接把 pick 改成 drop:
这个 commit 就会从历史中消失,后续 commit 自动接上。
代码改动没问题,但提交信息写得不好(比如"fix bug"这种没营养的信息),改成 reword 就可以在不改动代码的情况下修改提交信息。
| 能力 | 普通 rebase | Interactive rebase |
|---|---|---|
| 解决冲突 | 可以,逐个 commit 处理 | 同样支持 |
| 修改 commit 内容 | 不行,只能原样搬运 | 可以用 edit 暂停后修改 |
| 合并 commit | 不行 | squash / fixup |
| 丢弃 commit | 不行 | drop |
| 修改提交信息 | 不行 | reword |
| 调整 commit 顺序 | 不行 | 调换行顺序即可 |
核心认知:普通 rebase 是"自动搬运",interactive rebase 是"搬运时可以逐个开箱检查和修改"。当 rebase 过程中出了差错——冲突标记误提交、文件误修改、提交信息写错——git rebase -i 的 edit 模式就是你"时光倒流"回到那个 commit 去修正它的能力。
最后一条安全提示:rebase 会改写历史,已推送到公共分支的 commit 不要随意 rebase。万一操作失误,git rebase --abort 可以随时回到操作前的状态,git reflog 则是你最后的后悔药。