实现这个目标最常用、最有效的方式就是使用 Git 的交互式变基(Interactive Rebase)。它能让你像编辑文本一样,轻松地改写、合并、重排最近的提交历史。
最核心的命令是 git rebase -i HEAD~N。这里的 N 代表你希望合并的最近 N 个提交。
为什么要合并提交?
保持一个清晰、有意义的 Git 提交历史至关重要。一个干净的提交历史就像一篇结构清晰的文章,它能帮助团队成员快速理解代码的演进过程,极大地方便了未来的代码审查、问题排查和版本回滚。
如果提交历史充斥着大量无意义的微小改动,不仅会干扰代码审查者的视线,也会让日后追溯某个功能或 Bug 的源头变得异常困难。因此,在将功能分支合并到主分支前,整理提交记录是体现开发者专业素养的一个重要环节。
如何一步步合并多个提交
合并提交的核心工具是交互式变基(Interactive Rebase)。下面我们通过一个具体的例子,将最近的 3 个提交合并成一个。
假设你的提交历史如下,从上到下为最新到最旧:
f3d4c5a (HEAD -> feature-branch) feat: add final logic a2b3d4e fix: correct a small typo c1d2e3f feat: add initial button component ... (更早的提交)
我们的目标是把 f3d4c5a、a2b3d4e 和 c1d2e3f 这三个提交合并为一个。
1. 启动交互式变基
在你的功能分支上,执行以下命令:
git rebase -i HEAD~3
这个命令的意思是:我要以交互模式(-i)重写从 HEAD(当前提交)开始往前数 3 个提交的历史。
2. 编辑变基任务列表
执行命令后,Git 会在你的默认文本编辑器(通常是 Vim)中打开一个文件,内容类似这样:
pick c1d2e3f feat: add initial button component pick a2b3d4e fix: correct a small typo pick f3d4c5a feat: add final logic # Rebase ... # # Commands: # p, pick <commit> = use commit # s, squash <commit> = use commit, but meld into previous commit # ... (其他命令说明)
这个列表展示了即将被处理的提交, 顺序与 git log 相反,最旧的提交在最上面。pick 是默认指令,表示“保留这个提交,不做任何改动”。
3. 修改指令以合并提交
要合并提交,你需要将 pick 修改为 squash(或简写 s)。我们将第二个和第三个提交合并到第一个提交上。修改后的文件内容如下:
pick c1d2e3f feat: add initial button component squash a2b3d4e fix: correct a small typo squash f3d4c5a feat: add final logic
这里的 squash 指令会把当前行的提交“挤压”或“融入”到它前一行的提交中。
4. 撰写新的提交信息
保存并关闭编辑器后,Git 会自动打开另一个编辑器窗口,让你为这个全新的、合并后的提交撰写提交信息(Commit Message)。
编辑器的内容会包含所有被合并提交的信息,你可以删除这些旧信息,然后写一个全新的、清晰描述这组改动最终目的的提交信息。例如:
feat: implement new feature with button component - Add the initial button component UI - Implement the core business logic for the feature - (A small typo was also corrected during development) # Please enter the commit message for your changes. Lines starting # with '#' will be ignored, and an empty message aborts the rebase. # ... (旧的提交信息)
撰写一条高质量的提交信息同样重要。完成后,保存并关闭编辑器。
至此,如果没有冲突,变基就完成了。你的提交历史现在会变成这样:
b4e5f6g (HEAD -> feature-branch) feat: implement new feature with button component ... (更早的提交)
原来的 3 个提交已经被一个全新的提交所取代。
squash 和 fixup 有什么区别?
在交互式变基的指令中,除了 squash,还有一个非常相似的指令叫 fixup(或简写 f)。它们都能合并提交,但处理提交信息的方式不同。
使用 squash:合并提交并保留信息
squash 会将当前提交的改动和提交信息都合并到前一个提交中。在撰写新提交信息时,Git 会把被 squash 的提交信息作为注释保留下来,供你参考。
这在你认为被合并的提交信息有一定参考价值时很有用。
使用 fixup:合并提交并丢弃信息
fixup 则更加“激进”。它只会将当前提交的改动合并到前一个提交中,并直接丢弃当前提交的信息,完全不会出现在最终的提交信息编辑界面里。
当你需要合并的提交是 “fix typo”、“wip” 或 “format code” 这种完全没有保留价值的信息时,fixup 是更高效的选择。它能让最终的提交信息编辑界面保持干净。
例如,对于上面的场景,使用 fixup 会是这样:
pick c1d2e3f feat: add initial button component fixup a2b3d4e fix: correct a small typo fixup f3d4c5a feat: add final logic
保存后,Git 会直接使用第一个 pick 的提交信息 feat: add initial button component 作为合并后提交的默认信息,并立即打开编辑器让你修改,而不会包含后面两个 fixup 提交的信息。
安全操作:合并提交后的注意事项
因为变基(Rebase)操作改写了提交历史,所以当你试图将本地分支推送到远程仓库时,会遇到问题。
为什么需要强制推送?
改写历史后,你的本地分支和远程分支的提交历史已经不再是线性关系,它们发生了分叉。因此,常规的 git push 会被拒绝,因为 Git 无法自动合并这两种不同的历史。
这时,你需要告诉 Git:“我知道本地历史和远程历史不一样,请以我本地的版本为准,强制覆盖远程分支。”
git push --force 的风险与替代方案
执行强制推送的命令是:
git push origin feature-branch --force
警告:--force 是一个危险的操作。 如果在你改写历史期间,有其他团队成员在你这个分支上提交了新的代码,你的强制推送会无情地覆盖掉他们的工作,导致代码丢失。
因此,一个更安全的替代方案是使用 --force-with-lease:
git push origin feature-branch --force-with-lease
--force-with-lease 在强制推送前会进行一次检查。如果远程分支的历史在你上次拉取之后没有发生任何变化,它才会执行推送。如果远程分支有了新的提交,推送就会失败。这给了你一个机会去处理他人的改动,而不是直接覆盖它们。
最佳实践是:只在你自己的、未被合并到公共分支(如 main 或 develop)的个人功能分支上进行历史改写和强制推送。
如果操作失误了怎么办?
不用担心,Git 提供了一个强大的“后悔药”——reflog。
reflog 记录了你在本地仓库中所有 HEAD 的移动记录,包括那些因历史改写而“消失”的提交。
如果你对变基的结果不满意,或者操作过程中搞砸了,可以按照以下步骤恢复:
查看操作日志:
git reflog
你会看到一个类似这样的列表,记录了你的每一步操作,以及操作前的 HEAD 指向。
找到变基前的状态: 在列表中找到 rebase (start): checkout HEAD~3 这样的记录,它上面的那一行就是你执行变基操作之前的状态。记下它的哈希值(如 f3d4c5a)。
硬重置到指定状态: 使用 git reset 命令,将你的分支强制恢复到变基之前的状态。
git reset --hard f3d4c5a
执行完毕后,你的分支就回到了最初的状态,可以重新开始操作。reflog 是你大胆尝试改写历史的坚实后盾。