1. 为什么 push 之后还要合并 commit
先把场景摆出来:你吭哧吭哧写了一下午功能,分两次提交到了本地仓库,然后git push推到了远程。推完一回头,发现这两个 commit 一个写的是"fix: 修复登录接口超时",另一个写的是"fix2: 改了一下超时时间",看着就难受。或者更常见的情况是,你本地改了 10 次才把需求写完,本地历史乱成一锅粥,结果因为赶工期直接全部 push 上去了,远程仓库的提交记录比你的桌面还乱。
这个笔记就是解决这个问题的:已经 push 到远程了,现在想把最近的两个 commit 合并成一个,并且用 reword 和 fixup 这两个操作把提交历史收拾干净。
先说清楚一件事,很多人一听到“改已 push 的 commit”就慌,觉得会搞坏仓库,其实没这么玄乎。Git 的历史改写能力本来就是设计好的功能,你只要搞清楚其中的原理、知道哪些操作是安全的,就能放心用。合并 commit 本质上就是git rebase -i这个交互式命令的一个典型应用场景,搭配 reword 和 fixup 两个指令,分别负责“改提交信息”和“把当前 commit 的内容并进上一个 commit 并丢弃自身”,就能实现把多个提交压成一个、同时修正最终提交信息的效果。
这篇笔记适合谁?刚用 Git 三个月、还在被各种命令绕晕的新手,以及用 Git 三五年、但一直没搞清楚 rebase 交互式界面到底怎么用的老油条。读完你不仅能解决“合并两个已 push 的 commit”这个具体问题,还能把 reword、fixup、squash、edit 这几个常见的 rebase 指令彻底弄明白,以后遇到类似的提交历史整理需求,都不用手忙脚乱去翻文档。
先说结论:操作不复杂,核心就三条命令——git log看提交记录、git rebase -i HEAD~2进入交互式整理、git push --force-with-lease同步远程。但每条命令背后的原理和坑,值得展开细细讲一遍。
2. 动已推送的 commit 之前,先把这五件事想清楚
2.1 强推的本质是在“改写历史”
如果你只是本地提交没 push,随便怎么 rebase 都无所谓,因为影响范围只有你一个人。但一旦 commit 已经推送到远程,你再对本地提交做变基合并,本地的提交 hash 就会变化,和远程的提交记录就“分叉”了。这个时候,普通的git push会被 Git 拒绝,因为 Git 认为你的本地历史落后于远程历史,或者至少不是远程历史的线性延伸。你只能用git push --force这类强推命令,把远程的提交历史硬生生改成你本地的样子。
“改写历史”这四个字,往轻了说是让提交记录变得整洁,往重了说就是:如果别人已经从远程拉取过这些 commit,你的强推会直接导致别人的本地仓库和远程不一致,严重的时候别人 pull 下来会看到一堆莫名其妙的重复提交甚至冲突。所以操作之前必须确认:这些 commit 是你一个人私有的分支上的,还是多人共享的开发分支上的。私有分支随便改,共享分支务必谨慎。
2.2 适合合并 commit 的典型场景
我用下来最合适的场景,其实是这三种。第一种是功能分支还没合并进主干前,你想把开发过程中产生的各种“临时提交”“调试提交”“格式化提交”压成几个有意义的正式提交,这样将来 code review 或者回看历史的时候一目了然。第二种是已经合并进主干但还没发布、其他同事还没开始基于这个版本开发,你可以抓紧时间修正一下提交信息,比如补上需求单号、修正拼写错误。第三种是个人维护的小项目,整个仓库只有你一个人提交,那你想怎么整理历史都随心所欲。
不适合动的场景也很明确:主干分支上已经有一堆人基于它开发了,你贸然强推改历史,轻则让同事产生无谓的合并冲突,重则直接把别人的提交覆盖掉,这种事故在团队里出一次就够你在周会上做一次深刻检讨了。
2.3 改历史之前先留一条后路
我在实际操作中养成了一个习惯:做任何 rebase 之前,先把当前分支的状态备份成一个临时分支。命令很简单,git branch backup/my-branch-before-rebase,这行命令就是在当前 commit 上建一个分支指针,如果后面操作搞砸了,直接git reset --hard backup/my-branch-before-rebase就能回到操作前的状态,比在 rebase 过程中抢救要省心得多。
另外用git rebase的时候,Git 会有一个 reflog 机制,你在当前分支上的每一次 HEAD 变动都会被记录在.git/logs/HEAD这个文件里。即使你 rebase 到一半觉得不对,用git reflog也能找到变基前的 commit hash,然后切回去。不过对新手来说,git reflog看起来有点晦涩,不如备份分支来得直观。所以别嫌多此一举,备份分支真的能救命。
2.4 明确 reword、fixup 和几个相近指令的边界
很多人在网上查资料,看到 reword、squash、fixup 这几个词放在一起就开始晕。我先把它们几个的区别理清楚:
reword:保留当前 commit 的内容和变更,只修改这条 commit 的提交信息。好比同一本书换了个封面。squash:把当前 commit 合并到上一个 commit,并把当前 commit 的提交信息保留下来供你编辑,最终你可以把两条提交信息合成一条。好比两杯水倒进一个杯子里,两杯水的标签都还在,你要决定最终贴哪个。fixup:把当前 commit 合并到上一个 commit,但直接丢弃当前 commit 的提交信息,只保留上一个 commit 的信息。同样是两杯水倒一起,fixup 的做法是撕掉第二杯的标签,只保留第一杯的标签。edit:停下来,让你修改这个 commit 的更多细节,比如改文件内容、拆分成多个 commit,不只是改提交信息。
你注意看标题里的组合:reword 配合 fixup。为什么不是只用 squash?因为 squash 在合并时会打开编辑器让你重新编辑提交信息,而 fixup 完全使用上一个 commit 的信息。如果我想保留第一个 commit 的提交信息不变,直接用 fixup 就可以了,连编辑器都不用进。但如果我想要最终合并后的 commit 采用一个全新的信息,那就得先 fixup 把第二个 commit 的内容并进去,然后再 reword 修改第一个 commit 的信息。
2.5 一个核心概念:为什么 rebase 会改变 commit hash
要理解合并 commit 为什么需要强推,你得先弄明白 commit hash 是怎么生成的。Git 里每个 commit 的 hash 不只是根据这次改了什么内容算出来的,而是根据一系列元数据算出来的,其中就包括父 commit 的 hash、作者信息、提交时间、提交信息、代码内容快照。所以只要父 commit 变了,自己就会跟着变;提交信息改了,hash 也会变;就算时间和内容一样,只要父引用不同,最终 hash 就不同。
rebase 的本质,是把一系列 commit 在一个新的父节点上重新“打地基”,然后一个接一个重新生成。所以哪怕你只改了其中一个 commit 的提交信息,这个 commit 之后的每一个 commit 的 hash 都会连锁改变。这就是为什么合并 commit 后,本地历史会和远程历史“长得完全不一样”,必须强推才能对齐。
3. 合并两个已推送 commit 的完整实操过程
3.1 先查看提交记录,确定操作范围
打开终端,进入你的项目目录,先跑一条git log看提交历史。我习惯用git log --oneline,每条提交只显示一行,输出类似这样:
a1f2e3d (HEAD -> feature/login) fix: 调整登录超时参数 b4c5d6e feat: 实现登录接口 f7a8b9c chore: 初始化项目结构假设我想把最新的两个 commit(a1f2e3d和b4c5d6e)合并成一个,那我的操作目标就是这两条。请注意,这里的HEAD指向当前最新提交,我要操作的其实就是HEAD和它的上一个提交HEAD~1,用git rebase -i HEAD~2可以一次把这两个提交放到编辑列表里。
在开始之前,我再确认一下这两个提交是否已经 push 到远程了。可以用git status查看分支领先远程几个提交,更直接的办法是用git log origin/分支名 --oneline对比两边记录。我开发中常用的是git log --oneline --decorate,在提交记录后面直接显示origin/feature/login等远程引用指向哪里,一眼就能看出来哪些 commit 是已经推过的。
这一条确认非常关键。如果你把没 push 的 commit 和已 push 的 commit 混在一起 rebase,涉及范围会扩大,冲突风险也更高。所以先明确:我只动最近两个已 push 的 commit,其他的一概不碰。
3.2 创建备份分支,给自己留退路
在执行任何正事之前,先跑一条保险命令:
git branch backup/feature-login-before-rebase这条命令不会切换分支,只是把当前 commit 位置记录到一个新分支上。等操作全部完成,确认没问题之后,你可以把备份分支删掉:
git branch -D backup/feature-login-before-rebase注意这里用的是大写-D,因为备份分支没有被合并到当前分支,普通的小写-d会拒绝删除。其实这也是一种保护机制,Git 担心你误删了包含未合并提交的分支。
3.3 进入交互式变基界面
接下来执行核心命令:
git rebase -i HEAD~2命令中的HEAD~2表示要操作的提交范围是 HEAD 之前(包含 HEAD)的 2 个提交。Git 会打开一个文本编辑器(默认一般是 vim),内容类似这样:
pick b4c5d6e feat: 实现登录接口 pick a1f2e3d fix: 调整登录超时参数 # Rebase f7a8b9c..a1f2e3d onto f7a8b9c (2 commands) # # Commands: # p, pick = use commit # r, reword = use commit, but edit the commit message # e, edit = use commit, but stop for amending # s, squash = use commit, but meld into previous commit # f, fixup = use commit, but discard commit message and meld into previous commit # x, exec = run command (the rest of the line) using shell # d, drop = remove commit注意看这列表是“从旧到新”排序的,最上面是最老的那一条,也就是b4c5d6e,然后才是更新的a1f2e3d。这个顺序和git log正好相反,初学者经常在这里栽跟头,把新老顺序搞反了。
我要把这两个 commit 合并成一个,并且把最终提交信息改成一条更简洁的说明。按照标题里的思路,用 reword 和 fixup,操作步骤是:
- 第一条(较老的
b4c5d6e)前缀pick改为reword,表示这条 commit 的内容保留,但我回头要重新编辑它的提交信息。 - 第二条(较新的
a1f2e3d)前缀改为fixup,表示这条 commit 的内容直接合并进上一条,并且它的提交信息直接丢弃。
修改完后的列表长这样:
reword b4c5d6e feat: 实现登录接口 fixup a1f2e3d fix: 调整登录超时参数保存并退出编辑器。如果你用的是 vim,按Esc,然后输入:wq回车。
3.4 填入最终提交信息
退出第一个编辑器之后,因为上面第一条标记了 reword,Git 马上会再次打开编辑器,让你重新填写这条提交的提交信息。此时你可以把原来那句feat: 实现登录接口改成更能概括这次合并结果的话,比如:
feat: 实现登录接口并调整超时参数保存退出。到此为止,rebase 的主体工作已经完成。刚才那一对提交已经合并成了一个新 commit,这个新 commit 的 hash 和原来两个都不相同。
我们来验证一下效果:
git log --oneline输出应该是:
x9y8z7a (HEAD -> feature/login) feat: 实现登录接口并调整超时参数 f7a8b9c chore: 初始化项目结构原来最新的两个提交已经合体成一个,提交信息也被修正了。本地的操作到此结束,接下来是同步远程。
3.5 同步到远程的两种强推方式
这一步是很多新手最纠结的地方。直接执行git push大概率会遇到这样一段报错:
! [rejected] feature/login -> feature/login (non-fast-forward) error: failed to push some refs to 'git@github.com:xxx/project.git' hint: Updates were rejected because the tip of your current branch is behind hint: its remote counterpart. If you want to integrate the remote changes, hint: use 'git pull' before pushing again.这个报错完全符合预期,因为远程的提交历史和本地已经分叉了。你本地已经没有原来那两个 commit 了,取而代之的是一个新的合并后的 commit,所以远程认为你“丢失”了两个提交,拒绝接受你的推送。
正确的做法是强制推送。Git 提供了两种写法:
git push --force git push --force-with-lease我强烈建议你用第二种--force-with-lease,而不是第一种--force。原因很简单:--force是无条件的,它不管远程分支现在是什么状态,直接把远程历史改成你本地的样子。万一在你 rebase 和强推之间,有同事往这个分支推了新代码,你这一下会把同事的提交直接抹掉。
--force-with-lease则是带了一个“预期值校验”,它会检查远程分支是否还是你刚才看到的样子。如果远程在你操作期间被别人推了新提交,它就拒绝强推,让你重新 pull 整合后再推。说白了,--force是拿着推土机往前冲,--force-with-lease是推土机前面装了一个感应器,发现障碍物会自动刹车。日常开发中,除非你知道这个分支绝对无人使用,否则一律用带 lease 的版本。
3.6 如果需要保留未推送的提交,把 reword 换成 edit
有人可能会问,如果我想合并的两个 commit 并不是最顶部的两条,中间还隔着一条没推送的新提交,怎么办?这种场景其实也很常见,比如你这条分支上之前有一个提交已经 push 了,之后你又写了两个新提交还没推,现在你想把已经 push 的那两个旧提交合并一下,但不想碰最新的未推送提交。
这种情况下,git rebase -i HEAD~3(把最新三条提交拉进编辑列表),然后把对应的旧提交行改成 reword 和 fixup,最后一条未推送的新提交保持 pick 不动。这样 rebase 完,未推送的新提交也会被“重放”到新的历史之上,hash 会变,但内容不变。你强推之后,远程拿到的是合并后的旧提交+最新提交,效果完全符合预期。
4. 深入理解 reword 和 fixup 的底层逻辑
4.1 为什么 fixup 比 squash 更适合“丢弃信息”
从前面的对比表格可以看出来,fixup 和 squash 的功能非常接近,都是把当前 commit 的内容合并进上一个 commit。唯一的区别在于合并后提交信息的处理方式:squash 会把你带进编辑器,列出所有参与合并的提交信息,让你重新编排;fixup 则直接用上一个 commit 的信息,连编辑机会都不给你。
大多数人推荐 fixup 的原因,是它在非交互式操作中特别好用。比如你可以这样写:
git commit --fixup=a1f2e3d这条命令会创建一个特殊的临时提交,提交信息会自动以fixup! a1f2e3d开头。然后你再执行:
git rebase -i --autosquash HEAD~5Git 会自动识别以fixup!开头的提交,把对应的提交行自动调整到目标提交的下方,并标记为 fixup,完全不需要手动编辑。这个组合操作在处理“修改某个提交中的个别文件”这个场景时非常高效,可以说是 Git 里隐藏的效率神器。
4.2 reword 在交互界面里的执行顺序
你注意观察,在git rebase -i的编辑器中,所有命令的执行顺序是严格的从旧到新,逐条应用。标记了 reword 的那一行,Git 会在处理到这一步时暂停,打开编辑器让你改提交信息;标记了 fixup 的那一行,Git 会安静地把这个 commit 的内容合并进前一个 commit,然后继续往下走。
按照我前面的配置:
reword b4c5d6e feat: 实现登录接口 fixup a1f2e3d fix: 调整登录超时参数整个 rebase 实际发生的事情是:先基于f7a8b9c这个老提交重新生成一条新提交,内容是原来b4c5d6e的内容,但提交信息等你重新输入;然后立刻把a1f2e3d的内容合并进这条新提交,并丢弃它自己的提交信息。如果你在 reword 步骤填入了新的提交信息,最终合并出来的这个新 commit 就采用你填的新信息。
如果你懒得用 reword,其实还有一个更快捷的变通方案:把第一行改成pick,第二行改成fixup,Git 会直接使用b4c5d6e的原始提交信息,你连第二个编辑器窗口都不用打开。但这样一来,最终提交信息就固定成了feat: 实现登录接口,没办法体现这次合并把“调整超时参数”也包含进去了。所以标题里使用 reword + fixup 的组合,就是为了在合并的同时,把最终提交信息改成更贴切的一句话。
我个人的习惯是:如果两者信息差异不大,直接用 pick + fixup 最省事;如果合并后想重新提炼一句更完整的信息,就 reword + fixup。
4.3 合并 commit 之后,改动行记录会发生什么
这是很多人忽略的一个细节。Git 对每次提交的记录,不只是提交信息,还有这个提交相对于它父提交的“差异”(diff)。当你把两个 commit 合并成一个,新的提交和它父提交之间的 diff,是两个原始提交 diff 的累加结果。从代码变更的维度看,最终工作区内容是一样的,但变更历史被打包成了一份。
这意味着:如果第一个 commit 改了文件 A,第二个 commit 又改了文件 A 的同一处地方,那么在合并后的单条提交里,Git 不会保存“先改成 X、再改成 Y”的过程,只会保存“最终是 Y”的结果。所以在 rebase 过程中,如果两个 commit 改动了同一行代码,前面的提交要合并到后面时,Git 需要做三路合并,有可能产生冲突。
5. 实操中一定会遇到的四个问题与排查方法
5.1 rebase 过程中出现冲突怎么办
两个 commit 虽然都是你自己写的,但一样有可能在合并时产生冲突。最常见的场景是:第一个 commit 新增了一个函数,第二个 commit 又修改了同一个函数的同一行。当你把第二个提交 fixup 到第一个提交时,Git 要判断到底以哪一版为准。
出现冲突时,rebase 会停下来,终端会提示类似于:
error: could not apply a1f2e3d... fix: 调整登录超时参数 hint: Resolve all conflicts manually, mark them as resolved with hint: "git add/rm <conflicted_files>", then run "git rebase --continue".这时你可以用git status查看哪些文件处于冲突状态,打开这些文件,你会看到类似这样的冲突标记:
<<<<<<< HEAD function login(timeout = 30) { ... } ======= function login(timeout = 60) { ... } >>>>>>> a1f2e3d (fix: 调整登录超时参数)处理思路很简单:<<<<<<<和=======之间是当前提交(第一个 commit)的内容,=======和>>>>>>>之间是被合并进来(第二个 commit)的内容。你根据业务需要保留其中一份,或者手动改成一份新的内容,然后把冲突标记行删除。保存文件后执行:
git add 冲突文件 git rebase --continue如果你在 rebase 过程中发现怎么都不对劲,想回到操作之前的状态,可以执行:
git rebase --abort这个命令会把分支完全恢复到 rebase 开始之前的状态,配合前面创建的备份分支,双保险。
5.2 强推被拒绝,报错提示 remote has unexpected content
使用--force-with-lease的好处在这里就体现出来了。如果在你 rebase 的期间,别人向同一个远程分支推送了新提交,--force-with-lease会给出类似这样的提示:
! [rejected] feature/login -> feature/login (stale info) error: failed to push some refs to 'git@github.com:xxx/project.git' hint: Updates were rejected because the remote contains work that you do hint: not have locally.这个报错的意思是:你提交时的“期望值”和远程的实际状态对不上了。这时千万不要不管三七二十一换成--force硬推,正确的做法是先停下来,执行git fetch拉取远程最新的引用,再对比一下远程分支多出来的提交,跟同事确认这些新提交是否可以和你正在整理的历史共存。
如果确认同事的提交不应该被覆盖,你就要调整策略,比如把同事最新的提交先合并到你的分支头部,再重新整理你自己的提交。整理完再强推。
5.3 合并操作完成后发现改动丢了怎么办
有一些极端情况,比如在 rebase 过程中误操作,删除了某一行代码,最后 commit 也提交了,push 也推了,才发现代码不对劲。这时候不要慌,用 reflog 找回来。
git reflog输出会列出当前分支每次 HEAD 移动的历史,每一行左边是 hash 值,右边是操作描述。找到 rebase 操作之前的那个 hash,执行:
git reset --hard 那个hash就能回到 rebase 之前的状态。这个操作会把你刚才合并的成果丢进“未引用”的区域,但不会立刻被 Git 垃圾回收。如果你在恢复后发现还有代码散落在刚才的合并提交里,可以用 cherry-pick 或者手动复制的方式找回来。虽然有些繁琐,但至少数据不会凭空蒸发。
5.4 分享几个我踩过的真实坑
第一个坑:忘记--force-with-lease,直接用了--force,结果把一个同事刚推上去的热修复覆盖了,幸好那个同事留了本地备份,花了一下午才恢复现场。从那以后我在所有脚本和文档里统一改用--force-with-lease,并且把这条规则写进了团队规范。
第二个坑:rebase 顺序看反了。第一次操作时我盯着git log的显示顺序,把交互界面里的新提交误当成旧提交来处理,直接把标签反了,合并完之后提交信息完全错乱,只能 abort 重来。改历史的操作,一定要先把屏幕上的列表顺序搞清楚。
第三个坑:合并 commit 后没有及时更新远程分支的本地追踪引用,导致下一次git status显示的分支领先/落后状态非常混乱。后来我养成了一个习惯:强推成功之后立刻跑一条git fetch --prune,把远程引用同步一下,避免后续误判。
6. 把这些命令串成一套实用的工作流程
写到最后,我把整篇的核心拆成一套可以直接抄的工作流程,每次需要合并已推送的 commit 时照着走一遍就行。
git log --oneline --decorate确认要合并的提交范围,并确认它们已推送到远程。git branch backup/分支名-before-rebase创建备份分支。git rebase -i HEAD~N进入交互式变基,N 按需要调整。- 在编辑器中把较老的提交前缀改为
reword,较新的提交前缀改为fixup,保存退出。 - 在随后打开的编辑器中填写合并后的最终提交信息,保存退出。
git log --oneline检查合并结果是否符合预期。git push --force-with-lease同步到远程。- 核对远程分支状态,确认没问题后删除备份分支。
这套流程从第一次手动执行到最后形成肌肉记忆,大概需要两三次实际操作。等你熟悉了 reword 和 fixup 的组合用法,还可以进一步探索--autosquash和git commit --fixup的组合,用 git 的自动编排把整个 rebase 流程进一步压缩成一条命令。不过那是另外一个阶段的事了,先把基础流程跑通,你就能在提交历史的整理上游刃有余了。