先聊个真实的场景。上周三下午,我改完一个功能,本地测试通过,commit 也写得清清楚楚,然后习惯性地执行git push。结果屏幕上一行红字直接让我愣住:
! [rejected] feature/login -> feature/login (non-fast-forward) hint: Updates were rejected because the remote contains work that you do not have locally. hint: This is usually caused by another repository pushing to the same ref. hint: You may want to first integrate the remote changes (e.g., 'git pull') before pushing again.如果你也经常用 Git 协作,这玩意儿你一定不陌生。团队里有人比你先推了代码,远端分支比本地领先,你的 push 被拒绝。这时候所有人都会问同一个问题:我该merge还是rebase?
这个问题的答案没有你想的那么简单——它不只是一个命令选择,背后涉及提交历史的组织方式、团队协作习惯、甚至代码 review 的流程。我这次就把整个决策过程、实操步骤、踩过的坑全部整理出来,希望能帮你在下次遇到同样情况时,不用再凭感觉做选择。
1. Push 被拒是怎么回事:从报错信息说起
很多人第一次看到non-fast-forward这个关键词就懵了。其实这句报错翻译成人话就是:你本地分支的历史和远端分支的历史“分叉”了,Git 无法直接把你的提交“接”到远端最新提交的后面。
1.1 为什么你的提交“接”不上去
Git 的分支本质上是一个指向提交对象的指针,push 的本质是把你本地分支上的新提交传输到远端,并让远端分支指针指向这些提交。正常情况下这是一条直线,我把每次 push 想象成在一条链子上挂新环:本地有提交 A → B → C,远端也是 A → B,那么把 C 挂上去就是fast-forward,Git 很开心。
但如果有同事在远端先挂了提交 D(远端的链变成 A → B → D),而你本地是 A → B → C,这时候两条链子已经在 B 处分叉了。远端不能直接把指针从 B 移到 C,因为那会丢掉 D。Git 无法确定你是想保留 D 还是丢弃 D,所以它选择拒绝推送,把选择权交还给你。
这个设计其实是 Git 的自我保护机制。换成早期的集中式版本管理工具(比如老古董 SVN),服务端可能直接强制覆盖,谁的代码后提交谁就赢了,前一个人的工作直接消失。Git 用这种“拒绝”逼你先处理分叉,虽然体验上多了一步,但至少没人会莫名其妙丢代码。
1.2 报错提示里的关键信息怎么读
Git 的报错里其实藏着不少线索,别只盯着红字发愁。我第一次遇到时也只会复制粘贴去搜,后来学会了逐行看:
! [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 remote contains work that you do not have locally.[rejected]后面的feature/login -> feature/login表示被拒绝的是哪个本地分支推送哪个远端分支;(non-fast-forward)告诉你拒绝的具体原因——历史分叉了;hint:部分其实是 Git 给出的处理建议,提示你“先整合远端变更再推送”。
注意 hint 原文用的是git pull,而不是直接告诉你该 merge 还是 rebase。原因在于git pull本身是一个组合命令,默认行为等价于git fetch+git merge,但你也可以让它执行git fetch+git rebase。也就是说,真正的选择发生在 pull 这一步。
1.3 先看一眼,别急着动手
我的习惯是 push 被拒之后,先做一件事:查看当前状态和分支分叉情况,而不是凭感觉直接 pull。
git status git log --oneline --graph --all -10git log --oneline --graph --all能非常直观地画出提交历史的分叉情况:*代表提交点,|和\代表分支走向,两条线岔开的位置就是你本地和远端分叉的起点。这一步花 10 秒钟,但能避免后面很多“搞不清自己 rebase 到哪了”的混乱。
确认分叉情况后,还会再跑一句:
git fetch originfetch只把远端的最新提交下载到本地(存在origin/feature/login这个远程跟踪分支里),不会动你的工作区,更不会自动合并。这是最安全的一步,相当于先“看一眼对手的牌”,再决定怎么打。
2. Merge 和 Rebase 的本质区别:两条完全不同的“合流”思路
先说结论:merge 和 rebase 都能解决“远端领先、本地落后”的问题,都能让你的代码与远端最新代码整合到一起,但它们对提交历史的处理方式完全不同。
2.1 Merge:把两条路“接”成一条岔路
git merge做的事情是创建一个新的“合并提交”(merge commit),这个提交有两个父提交:一个指向你本地的分支头,一个指向远端分支头。从图上看,历史会形成类似这样的结构:
* 合并提交(merge commit) |\ | * 同事的提交 D * | 你的提交 C |/ * 共同的祖先提交 B * 提交 A这个结构有两个特点:第一,你的原始提交 C 一个都不变,它们原封不动地保留在历史里;第二,多了一个合并提交,历史会出现一个明显的“分叉再合流”的节点。
merge 的优点是安全、可追溯。谁在什么时候把哪两条分支合到一起,一目了然。缺点是历史会“长毛”,如果团队里每个人都频繁 merge,log 图会变得极其复杂,像一团没理清的毛线。很多项目后期看提交历史,满屏都是Merge branch 'feature/xxx' into dev,真正的功能提交反而被淹没。
2.2 Rebase:把本地提交“重放”到远端最新提交后面
git rebase做的事情则是:把你本地分支上的提交先“摘下来”,然后以远端分支的最新提交为新的起点,一个一个重新应用上去。执行完后的历史是这样的:
* 你的提交 C'(新的提交哈希) * 同事的提交 D * 共同的祖先提交 B * 提交 A注意两个细节:第一,C 变成了 C',虽然是同样的改动内容,但提交哈希变了,因为它的父提交从 B 变成了 D;第二,整个历史呈现为一条直线,看起来就像你是先基于同事的提交 D,再提交了你的 C。
rebase 的优点是历史干净、线性,review 代码时逻辑清晰,git bisect(二分查找出问题的提交)也更好用。缺点也很明显:它改写了提交历史。如果那些提交已经被推到远端、被别人拉取过,rebasing 再强推会造成远端历史的“覆盖式”变化,轻则让同事产生重复提交,重则搞丢别人基于你旧提交所做的合并。
2.3 一个生活化的类比
我平时给新人讲这两个概念时,喜欢用“拼积木”来类比。
merge 就像你把两座积木搭好的房子并排放在一起,然后用一块新的积木(合并提交)把两座房子连接起来。两座房子本身原封不动,你一眼就能看出哪部分是自己的、哪部分是别人的。
rebase 则像是把你积木上的零件全部拆下来,捡起同事那座积木上最新的那一层作为新底座,然后把你的零件一个一个重新拼回上去。最终看起来只有一座完整的房子,但你原本那座房子的“组装痕迹”已经消失了。
核心差异就一句话:merge 保留“事实”,rebase 整理“叙事”。如果提交历史是一本账本,merge 是如实记录每笔流水,rebase 则是定期把账目重新誊写一遍,让账本看起来更清爽。
3. 我这次到底怎么选的:决策框架 + 完整实操
光讲理论没有用,我来还原我这次的实际决策过程。
3.1 判断标准:先看“这是谁的提交”
处理被拒的 push 时,第一件事不是选命令,而是判断:远端领先的那个提交是谁的?
再细分一下,实际上就是三种情况。
情况一:远端领先的提交是同事刚推上去的功能代码。这种最普遍,也是我这次遇到的。此时 merge 和 rebase 都可以用,就要看团队规范和个人偏好。我们团队的规范是:功能分支合入主分支用 rebase 保持线性,多人协作用的共享分支强制用 merge 避免改写公共历史。这次是feature/login这种多人共同开发的功能分支,所以理论上是 merge 更稳妥。
情况二:远端领先的提交只是别人在你 push 前几秒钟刚推上来的一点点小改动(比如一行配置修复)。这种场景我用 rebase 特别顺手,因为改动极小,冲突概率几乎为零,rebase 完直接强推,历史干净。
情况三:远端领先的提交是“不该出现”的,比如同事误推了敏感信息,或者推了一堆 WIP(半成品)提交。这种时候别急着整合,先和同事沟通,让他处理远端分支——该撤的撤、该修的修。你盲目 merge 或 rebase 只会把别人的问题“继承”到自己的分支里。
我这次遇到的情况是第一种,我们团队功能分支的历史比较乱,有个人风格非常明显的“随时提交”习惯,一堆fix typo、wip夹在功能提交中间。merge 的话,那个含着杂七杂八提交的分叉点会一直躺在历史里;rebase 的话,能把我的提交干净地放到所有乱七八糟的提交之后。
最终我选择了rebase + 强推。理由是:这是我们自己的功能分支,远端没有被其他人长期基于它开发,改写的风险可控;而且这个分支最终要合回 dev,我希望能用--no-ff合入 dev 时历史是清晰的。
3.2 Rebase 方案的完整命令流程
注意,这里有个坑:直接执行git pull默认走 merge 路线,不会 rebase。想走 rebase 路线,两种方式:
# 方式一:直接带参数 pull git pull --rebase origin feature/login # 方式二:先 fetch,再手动 rebase(我推荐这种) git fetch origin git rebase origin/feature/login我强烈推荐第二种方式,原因很简单:fetch和rebase分成两步,每一步的执行结果都能单独验证。万一 rebase 出问题,你更清楚是哪一步导致的,git rebase --abort也能快速回到 rebase 开始前的状态。
我这次实际执行的命令序列是:
# 假设当前在 feature/login 分支 git status # 确认工作区干净,没有未提交的改动 git fetch origin # 拉取远端最新状态 git rebase origin/feature/login # 把本地提交重放到远端最新提交之后执行 rebase 后,Git 会提示:
Successfully rebased and updated refs/heads/feature/login.看起来一切顺利,但注意——rebase 之后你本地的提交历史已经和远端“分叉”了,原来的提交 C 变成了 C',哈希值变了。这时候直接git push依然会被拒绝,因为远端还不知道你把历史“改写”了。你需要:
git push --force-with-lease origin feature/login这里又要多说一句,为什么是--force-with-lease而不是--force?--force是无条件强推,会把远端分支直接覆盖成你本地的样子,如果在你 fetch 之后又有别人往远端推了代码,那个人的提交会被你冲掉。--force-with-lease则带了一个“安全校验”:它会对比你本地记录的远端分支最新位置和远端实际位置,只有二者一致时才允许强推;如果不一致,它会拒绝执行。相当于给强推加了一把锁,防止误伤同事的提交。
实测下来,我在 rebase 后执行强推,没有遇到任何问题:
To git@github.com:xxx/project.git + 5a3f2b1...7c9e4d2 feature/login -> feature/login (forced update)3.3 Merge 方案的完整命令流程
如果你判断下来还是 merge 更稳妥,或者团队规范要求用 merge,命令同样简单:
git fetch origin git merge origin/feature/login然后 Git 会打开一个编辑器让你填写合并提交的信息,默认是Merge branch 'origin/feature/login' into feature/login这种格式,一般直接保存退出就行。merge 完成后,因为本地历史确实比远端领先(多了一个合并提交),普通git push就能通过,不需要强推:
git push origin feature/login我之前在一个老项目里用过一年 merge 路线的协作方式,最大的感受是:merge 的容错率确实高。就算你在合并时手滑选错了分支、或者合并到一半不想合并了,只要还没 push,用git merge --abort一下就能完全回到合并前的状态。rebase 虽然也有--abort,但在 rebase 中途解决冲突时,心理压力会明显更大——因为你知道自己在“改写历史”,一旦切错上下文容易把事情搞乱。
3.4 解决冲突:rebase 时的冲突处理细节
rebase 最让人头大的就是中途蹦出来的冲突。这次我也没能幸免——rebase 到一半,Git 报了一个冲突,文件是pom.xml。rebase 和 merge 处理冲突的流程大体一致,但有个关键区别要注意。
merge 的冲突解决思路是“一次性处理完所有冲突,然后创建一个合并提交”。rebase 则是“一个提交一个提交地重放”,每重放一个提交时都可能出现冲突,而且冲突要逐个解决。
rebase 冲突时的命令行提示:
error: could not apply 3e8f12d... feat: 登录接口对接 hint: Resolve all conflicts manually, mark them as resolved with hint: "git add/rm <conflicted_files>", then run "git rebase --continue".这时候我的标准操作流程是:
- 打开冲突文件,查找
<<<<<<<、=======、>>>>>>>标记; - 逐个判断该保留哪部分代码,必要时打开同事的提交记录看看上下文;
git add标记为已解决;- 执行
git rebase --continue; - 如果后续还有冲突,重复步骤 1-4;
- 全部完成后,
git log --oneline检查重放后的提交列表是否符合预期。
这次我的pom.xml冲突并不复杂,同事加了一个新依赖,我改了一个版本号,两边互不干扰,把两个改动都保留就行。真正麻烦的是那种“同一个文件同一段代码,两边都改了逻辑”的冲突,这种时候我一般会先看同事的提交说明,再结合需求判断谁对谁错,或者干脆约着对一下。
注意:rebase 过程中如果实在搞不定,别硬扛。
git rebase --abort可以完全退出 rebase,回到 rebase 开始前的状态。记住,只要还没 push,你随时可以反悔。
4. 实操中的常见问题与排查实录
merge 和 rebase 只是 git 协作中的一小块。结合我这次被拒的经历,把几个高频问题一起整理出来,方便你遇到时快速定位。
4.1 JSON 合并冲突:为什么总在这里翻车
离这次被拒不远,我还在另一个项目里遇到过json merge conflict。团队多人同时改同一个接口配置 JSON,rebase 时几乎必冲突。原因是 JSON 格式本身行数多、嵌套深,Git 的冲突标记是按行输出的,碰到一个键值对改动就可能让整个对象块标红。
我的处理技巧有三条:
第一,用 IDE 的 merge 工具而不是手改。IDEA 自带的 diff 工具会把冲突的两边上下排列,配合高亮和快捷键,比在终端里找<<<<<<<快得多。
第二,JSON 冲突往往不是“谁对谁错”的问题,而是“两边都需要保留”的问题。比如 A 加了timeout配置,B 改了retryCount,这时候直接把两边的内容都合并进最终文件即可。
第三,如果是配置文件(比如package.json、lock.json),我更倾向于先确认有没有人能“重新生成”这个文件。package.json 冲突时,我会把两边改动都保留,然后重新执行安装命令让 lock 文件自己恢复,不靠手改。
4.2 SSH 认证失败:push 被拒的另一大类原因
很多人把“push 被拒”笼统地归结为 merge/rebase 问题,其实被拒还有另一大类原因:SSH 认证失败。报错长这样:
git@github.com: Permission denied (publickey). fatal: Could not read from remote repository.这类报错和分支历史分叉完全没有关系,你 merge 或 rebase 得再熟练也解决不了。排查顺序我建议按下面的思路来:
- 先确认远程地址是不是对的:
git remote -v,看看是用 HTTPS 还是 SSH; - 再用
ssh -T git@github.com测试认证是否通过; - 检查本地有没有生成过 SSH key:
ls ~/.ssh/; - 确认 SSH key 是否已添加到 Git 平台账号中(GitHub/GitLab/Gitee 的 SSH keys 配置页);
- 在新电脑或改过系统的场景下,注意默认
id_rsa路径和权限问题。
我之前踩过一个坑:换电脑后把旧的id_rsa复制过去了,但权限太开放(不是 600),SSH 直接拒绝使用。chmod 600 ~/.ssh/id_rsa解决。这类问题表面看和 merge/rebase 无关,但如果你不清楚 push 被拒的完整原因清单,很容易在错误方向上浪费半小时。
4.3 强推后悔了:怎么撤销远端提交
git push --force-with-lease之后,或者更直接的git push --force之后,如果发现推错了怎么办?不用慌,Git 有后悔药,但要尽快吃。
第一种情况:刚 push 完,发现最后一次提交有问题,本地都还没继续动。可以修正本地提交后强推覆盖。比如:
git commit --amend -m "修正后的提交信息" git push --force-with-lease origin feature/login第二种情况:已经强推了一个错误的分支状态,你需要把远端恢复到之前的某个提交。前提是你本地还保留着那个提交的引用。
git refloggit reflog会列出你本地所有分支指针的历史移动记录,包括被 reset、rebase、强推覆盖前的旧位置。找到你想恢复的那个提交哈希,然后:
git reset --hard <旧提交哈希> git push --force-with-lease origin feature/login我这里要特别强调:reflog 只在本地有效,这条命令救不了“远端已经被别人拉取并基于新历史开发”的情况。远端历史被改写后,已拉取该分支的同事的本地仓库会与远端再次分叉,需要他们也做一次 reset 或 rebase 才能对齐。这也是我一直强调“不要随便强推公共分支”的根本原因。
4.4 IDEA 里怎么回退 merge 操作
很多同事习惯用 IntelliJ IDEA 的图形界面做 git 操作。merge 之后后悔了,想在 IDE 里回退,有两条路。
一条是“还没 commit”的路径:merge 冲突解决到一半或者刚解决完,还没提交合并结果,此时 IDEA 的 git 面板里会有 Abort Merge 的按钮(在 Git > Merge 相关菜单里),点击就回到 merge 前状态。
另一条是“已经 commit”的路径:在 IDEA 底部 Version Control 面板的 Log 标签页里,找到你那个合并提交,右键选择Undo Commit,它会执行git reset --soft,把合并提交撤销掉,但保留你的代码改动在工作区。如果想连改动一起撤销,用Reset Current Branch to Here...并选择Hard模式,把分支指针挪到合并前的提交。
IDEA 里的这些图形化操作本质上还是在调用 git 命令,所以决策逻辑和命令行完全一样,只是不用记命令了。我两条路都摸过,比较常用的是Undo Commit,因为它保留工作区改动,方便重新调整后再次提交。
5. 最后:我在实际协作中沉淀下来的几个规范
从头到尾梳理完这次 push 被拒的经历,我最想说的其实是:merge 和 rebase 的纠结,最好的解法不是“临时决策”,而是“提前立规矩”。如果团队里每个人遇到分叉都凭个人喜好选,历史一定会越来越乱。
我比较推荐在团队里达成下面几条约定:
第一,功能分支(feature/*)内,可以自由 rebase。因为功能分支通常只有你自己或少数人开发,改写历史风险低。提交前整理一下提交信息(合并零碎的 fix 提交),让 review 的人看得舒服。
第二,共享分支(dev、master、release/*)禁止强推。一旦分支被多个人拉取,它就成了“公共历史”。对公共历史做 rebase 和强推,后果就是让团队其他人强制同步你的节奏,这在多人协作中非常不友好。如果需要纠正公共分支上的错误,优先用 revert 而不是 reset。
第三,pull时优先带--rebase和--autostash。我自己现在习惯在配置文件里设置:
git config --global pull.rebase true git config --global rebase.autoStash true这样每次git pull默认走 rebase 路线,本地有未提交的改动时也不会被打断,而是先自动 stash、rebase 完再自动恢复。这个习惯特别适合“本地一直有零散改动不想 commit、又需要频繁同步远端”的工作模式。命令形式就是热搜里那句git pull --rebase --autostash的长期配置版。
第四,push 被拒不是意外,而是日常。我见过太多同事遇到non-fast-forward就慌,其实在多人协作的项目里,这几乎是每天都会发生的事。被拒只是 Git 在告诉你“远端有新东西了”,处理它就是一次普通的同步动作,不涉及任何“你的代码有问题”的判断。心态放平,按流程走一遍就好。
坦白说,我早期也在这上面走过弯路。有一段时间我在公共分支上用过一次git push --force,把同事刚推上去的提交直接冲掉了,虽然没有造成严重的代码丢失,但那位同事被迫花了一上午对着 reflog 找自己丢失的提交,从那以后我再也没在公共分支上用过无条件的 force。这次选择 rebase 而不是 merge,也是因为这些年在复盘场景中积累下来的判断——知道哪个分支可以动、哪个分支不能动,比会打一百条 git 命令都重要。如果你下次再碰到 push 被拒,不妨先别急着问“该 merge 还是 rebase”,而是问自己一句:这个分支的历史,值不值得我用一次改写去换一条更直的线。