
1. 事故现场一个再常见不过的推送失败先说一下事情经过。那是一个周四下午我改完一个功能分支上的代码本地提交了两笔 commit准备推送到 origin 上的 feature 分支。git push按下去终端直接甩回来一段让我当时血压升高的报错! [rejected] feature/login - feature/login (non-fast-forward) error: failed to push some refs to gitgithub.com:xxx/xxx.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. hint: See the Note about fast-forwards in git push --help for details.我相信绝大多数 Git 使用者对这个报错不陌生。尤其是团队协作稍微频繁一点的项目这个non-fast-forward几乎是隔三差五就要见一次。但说实话很多人包括当时的我在内对这个错误的底层机制并不是真正理解只知道pull 一下再 push 就好了。但那次不一样因为我连续 pull 了两次还是推不上去而且本地历史被远程提交穿过了整个分支历史变得非常难看。这篇文章不是什么理论科普就是把我从报错到最终把分支历史整理成一条干净线性记录的完整过程复盘出来。里面会涉及 non-fast-forward 到底在说什么、fast-forward 和 merge commit 的分野、pull --rebase和普通pull的实质差异、冲突解决的现场操作、force-with-lease的安全边界以及我最后是怎么把那些来回交叉的提交整理成一条直线历史的。如果你是刚接触 Git 不久的新手这篇文章能帮你把冲突和非快进推送这两件事从根上理顺如果你已经用了一段时间 Git 但每次遇到报错只会条件反射地执行某几条命令这篇文章也许能让你多一层为什么的认知。毕竟 Git 的命令不算多但每一步背后的选择逻辑才是区分会用和用得明白的分水岭。2. 先搞清楚 non-fast-forward 到底在拒绝什么2.1 fast-forward 的语义为什么 Git 默认不让你覆盖远端提交Git 报错信息里那个non-fast-forward的措辞有点抽象但把它拆开理解其实很直观。所谓 fast-forward字面意思是快进指的是一种特殊的合并情形当前分支的历史是目标分支历史的直系子集。换句话说如果远程分支从上一次你拉取之后没有任何新的提交而你本地比远程领先了若干个提交那么远程分支的指针可以直接沿着你的提交链快进到最新的位置不会产生任何分叉也不用生成新的合并提交。我可以画一个大家都看得懂的时间线场景。假设远程分支feature/login指向提交 C1你本地从 C1 开始又写了 C2 和 C3远程 C1 本地 C1 --- C2 --- C3你要推送本地 C0、C1、C2 这三个提交远程的 C1 是本地历史中的祖先节点所以远程的指针可以从 C1 直接移动到 C3历史是一条直线这个过程就是 fast-forwardGit 直接允许。但如果在你写 C2 和 C3 的这段时间里你的同事往同一个分支推送了 C4情况就变成远程 C1 --- C4 本地 C1 --- C2 --- C3远程分支的 tip 是 C4它既不是 C3 的祖先C3 也不是 C4 的祖先。两个分支在 C1 之后分道扬镳了。这时候如果你的 Git 直接把本地的 C2、C3 覆盖到远端C4 这趟提交就会从远程分支的历史里消失同事的工作被覆写这是 Git 默认不允许的——因为非快进更新等同于重写远程历史在多人的协作分支上这是危险的。所以non-fast-forward这个报错本质上是一个安全机制Git 拒绝了一种可能导致他人在远端提交丢失的更新方式。它逼你先做一次整合把远端的新提交和本地的新提交通过某种方式合并到同一条时间线里。2.2 Git 为什么把非快进当作拒绝项而不是自动处理这里有一个值得深入想一下的设计哲学为什么 Git 不在push的时候自动帮你把远程分支合并一遍再推送Git 完全有这个能力它知道你本地和远端的共同祖先它可以自动创建一个 merge commit 把两个分支整合起来。答案是Git 的push被设计成了一个远端状态更新操作而不是远端分支整合操作。push的潜台词是我把本地 ref 指向的目标提交通知远端请让你的分支指针也移动到这个提交。如果你指向的提交不是远端 ref 的下一代提交那么这个移动就不是简单的指针滑动而是一个改写历史的操作。Git 默认不允许你这么安静地改写别人可能已经基于它开发的远端历史所以宁可报错把选择的主动权交还给用户。这背后的实际工程考量是在大多数协作场景里你的本地工作往往落后于远端分支。如果 Git 在 push 时自动整合那么每个人的本地历史里都会出现一堆自动合并的提交而且这个合并发生在谁都不知情的情况下一旦合并结果有问题排查起来非常麻烦。Git 的设计者把明确地整合这个动作留给了用户merge和rebase就是干这个的这样历史是怎么长出来的每个人心里都有数。2.3 三种常见的非快进触发场景我在不同的项目和团队里遇到过很多次 non-fast-forward触发场景来来去去基本是这三种触发场景具体情形问题特征多人协作同一分支同事往同一个分支推送了新提交你没有拉取就直接 push最常见解决成本低本地基于陈旧的远端状态做开发拉取分支后长时间没有更新期间远端已推进多个提交时间越久冲突面和复杂度越高修改或重置过本地历史使用过 rebase、commit --amend、reset 等命令本地分支与远端分支的历史分叉点发生变化最隐蔽可能涉及强推第三种场景最容易让人措手不及。比如你在本地对已经推送过的提交做了rebase -i调整或者用commit --amend修改了最近一次提交的信息那么即使你本地和远端的内容逻辑上可能完全一致Git 在 push 时依然会因为历史链上的提交对象 ID 完全不同而判定为 non-fast-forward。这也就是很多人困惑的我明明没有新增提交为什么推送被拒的原因——因为你修改的是已经存在于远端的历史这一改动本身就等于重写了历史。3. 第一次尝试pull 之后为什么还是推不上去3.1 我的第一反应和实际执行过程回到我自己的事故里。看到报错之后我当时的操作路径完全是肌肉记忆级别的执行git pull先把远端的新提交拉下来合并再推回去。这招在绝大多数情况下是管用的但那天不巧我拉下来之后遇到了合并冲突——同事改的文件和我改的文件恰好重叠了而且是在同一个函数里。git pull默认执行的是 merge 策略也就是说 Git 会多出一个Merge branch feature/login of ...这样的合并提交。我解决了冲突、提交了合并结果然后我又 push。结果这次报错变成了! [rejected] feature/login - feature/login (non-fast-forward) hint: Updates were rejected because the remote contains work that you do not have locally.这和前一次的报错信息还不太一样。第一次报错说的是你本地分支 tip 落后于远程第二次报错说的是远端包含你本地没有的工作。有点迷惑但仔细想想就明白了我第一次 pull 下来的其实只是当时远端的最新状态但在我解决冲突的那几分钟里我的一个同事又一次往同一个分支推送了新的提交。我解决完冲突想 push 的时候远端又前进了等于我这次 push 依然不是在最新远端基础上进行的推进所以再次被判定为非快进。这个情况在协作频繁的团队里并不少见。你 pull 了一次不代表你 push 的时候远端还停留在你 pull 的那个位置。3.2 普通 pull 的代价自动生成一个你不太想要的 merge commit这里要说一个很多人没注意过的点。默认的git pull等价于git fetch加git merge FETCH_HEAD。当远端领先于你且你们的分支点分叉时Git 会尝试做一次三方合并合并成功后生成一个 merge commit。这个 commit 本身无害但它有两个实在的代价第一你的提交历史里会多出一个没有实际意义内容的合并节点。团队里如果有人喜欢看git log --graph你的分支上就会出现一条交错的分叉线时间一长图形化日志会变得比较复杂不太容易一眼看出主干脉络。第二更重要的是如果你之后想把这一整段分支的工作整合回主分支这些多余的 merge commit 会让 rebase 和 cherry-pick 的操作变得啰嗦比如你在 rebase 时可能遇到空提交或者需要手动处理重复冲突的情况。我第二次 push 被拒之后冷静下来看了一下git log --graph发现自己这条分支上已经有六个提交、两条分叉线、一个合并提交而且还有一个正在进行的 merge 中间状态悬在那里。如果要在这个基础上继续往后整理历史只会越来越乱。所以我当时的决策是停止继续用merge去接这个局面改为用rebase把整个分支历史重放一遍。3.3 切换到 rebase 的第一轮操作我先确认了当前分支名然后执行了git fetch origin git rebase origin/feature/loginfetch先把远端最新状态拉到本地的origin/feature/login这个远程跟踪分支上rebase的意思是我要把当前分支上那些还不在 base 分支里的提交先摘下来重新在origin/feature/login的最新 tip 上按顺序再放一遍。这个操作和 merge 最本质的区别在于merge 会保留两个分支的分叉结构再用一个合并节点把它们连起来rebase 则是把我的分支历史抹平重放最终效果是本地这整条分支的历史变成以远端最新 tip 为直接祖先的一条直线。# 执行 rebase 后看到的提示 First, rewinding head to replay your work on top of it... Applying: fix: 登录态过期处理逻辑 Applying: feat: 记住我功能前端交互看到这两行说明 rebase 正在把我的两个提交往最新基础上重放。但紧接着就出事了——第一个提交重放的时候就报了冲突。4. 冲突处理现场三个文件、两个函数、一次不算难但很考验细心的合并4.1 冲突消息真正想告诉你的事rebase 进行到一半终端提示CONFLICT (content): Merge conflict in src/pages/Login/index.tsx error: could not apply 3f4c1d9... fix: 登录态过期处理逻辑 hint: Resolve all conflicts manually, mark them as resolved with hint: git add conflicted files and run git rebase --continue.这里要强调一个和 merge 冲突不同的地方rebase 的冲突是在逐个重放提交的过程中暴露的。也就是说如果你原本有 5 个提交可能第 1 个提交重放时就冲突了你解决完并 continue第 2 个提交可能又冲突需要再次解决。每一轮冲突解决都是针对那一个提交的变更内容来做的所以我们要看的是那个提交到底改了什么、和新的 base 上的哪部分重叠了。用git status看了一下冲突的文件一共三个both modified: src/pages/Login/index.tsx both modified: src/utils/auth.ts both modified: src/constants/index.ts4.2 我看到冲突标记后的处理逻辑打开src/pages/Login/index.tsx看到标准的冲突标记 HEAD const handleSubmit async (values) { const { token } await login(values); localStorage.setItem(token, token); window.location.href /dashboard; } const handleSubmit async (values) { const { token } await login(values); sessionStorage.setItem(token, token); window.location.href /home; } 3f4c1d9 (fix: 登录态过期处理逻辑)HEAD代表的是 rebase 之后新的基础版本上的代码也就是远端已经存在的内容3f4c1d9那一段是我自己的提交里要应用的变更。这里有个细节值得说rebase 过程中的HEAD和平时你在分支上理解的 HEAD 含义不完全一样在 rebase 进行期间HEAD临时指向了 rebase 的目标基线而不是你原来的分支 tip。所以冲突标记里HEAD那一侧代表的是新的基线上的代码而下面的那一侧是你这个提交带来的改动。我这次冲突的本质是同事在远端把登录后的存储从localStorage改成了sessionStorage并且跳转地址改成了/home而我的这个提交是基于更早的代码写的当初的写法还是localStorage加/dashboard。两边在同一个函数上做了修改Git 的三方合并算法无法自行判断哪一边是最终想要的于是交给人工裁决。还有一个文件src/utils/auth.ts的冲突也很有意思两边代码本身并不冲突只是 Git 对上下文文本的匹配过于严格 HEAD const TOKEN_KEY token_v2; const TOKEN_KEY token; 3f4c1d9 (fix: 登录态过期处理逻辑)这种往往是远端改过了 key 名而你本地基于旧 key 名做了一些逻辑引用。这种冲突的解决策略是以业务需求为准看看当前线上逻辑到底需要哪个 key而不是无脑保留某一侧。4.3 解决的步骤、清理和 continue 的完整命令序列我当时的解决思路是先按功能语义去判断登录态的持久化方式既然远端同事已经统一改成了sessionStorage说明这个改动的意图是关掉浏览器后要求重新登录那么我后续的过期处理逻辑也是在这个前提下设计的所以这处我保留sessionStorage。登录成功后跳转的地址/home是当时最新版的首页路由/dashboard已经是废弃页面自然选择新的路由。token 的 key需要看两端代码里读取 token 的地方是否统一我 grep 了一下整个项目发现项目中读 token 的地方已经被同事改成了token_v2所以我这份提交里所有使用token的地方也要一并改成token_v2否则登录态会在读写时对不上。三个文件全部处理完之后执行git add src/pages/Login/index.tsx src/utils/auth.ts src/constants/index.ts git rebase --continue值得注意的是冲突解决完成之后不要直接commit而是git add后走--continue。--continue做的事情是把解决完的结果作为那个被重放的提交的新版本提交上去然后继续重放后面的提交。如果你误执行了git commitrebase 流程会被打断提交信息也不是原来那个状态会变得混乱。4.4 第二轮冲突和那一刻的心态调整第一个提交重放通过之后第二个提交也报了冲突这次冲突点集中在测试文件里CONFLICT (content): Merge conflict in tests/login.spec.ts说实话那时候我的情绪已经不是怎么又来了而是明确知道这是 rebase 的正常工作方式。它在逐个应用提交每个提交都可能和新的基线冲突这并不能说明我代码写得有问题只能说明这段分支历史和远端代码重叠的修改面比较大。把测试文件里的两处断言统一成新的跳转路径之后继续git add tests/login.spec.ts git rebase --continue这次没有再报新冲突rebase 顺利完成提示如下Successfully rebased and updated refs/heads/feature/login.我执行了git log --oneline --graph看了一下之前那条乱糟糟的合并分叉线彻底消失了整条分支变成了以origin/feature/login最新 tip 为祖先的一条线性提交链* 9b8c5e2 (HEAD - feature/login) feat: 记住我功能前端交互 * 2f4a31b fix: 登录态过期处理逻辑 * 7e8b1c0 (origin/feature/login) refactor: 登录态存储策略调整 * a3d5f77 feat: 登录页样式升级这种历史看起来让人极度舒适——每一个提交都有明确的含义没有多余的合并节点而且整条链可以直接被 fast-forward 到远端。5. 关于 force push能不用就不用必须用的时候要带好安全气囊5.1 为什么 rebase 之后还是推不上去你可能会问我都 rebase 成线性历史了为什么我 push 的时候又遇到了问题因为 rebase 重写了本地提交的哈希 ID。原来我那两个提交 C2、C3 在新的基线上被重新应用之后生成了两个全新的提交 C2、C3它们的父提交已经变成了远端最新的那个 tip。从逻辑内容上看它们是原来那两个提交的延续但从 Git 对象的层面看它们是完全不同的两个提交对象。远程分支的 ref 现在指向的依然是我 rebase 之前推上去的那个旧 C3。要让远程接受 C3Git 需要把 ref 从旧 C3 强行移动到 C3这个过程不是 fast-forward所以又被拒绝。这种情况就是前面表格里说的第三种触发场景——本地历史被重写过push 被拒其实是 Git 在提醒你你要做的是重写远端历史想清楚再动手。于是问题很自然来到了 force push 的门口。5.2 --force 和 --force-with-lease 的差别是生死线很多刚开始用 Git 的朋友看到这里会想那直接git push --force不就行了吗没错这个命令能解决眼前的问题但它同时也是一个非常危险的操作因为它会无条件地把远端分支的 ref 移动到本地指定的位置而不检查远端 ref 在你 fetch 之后有没有被别人更新过。假设你 rebase 之后的本地历史是 C4你 push 的时候远端其实已经被同事推到了 C6。你执行git push --force远端分支会被直接重置到 C4同事的 C5、C6 这两次提交就从远端分支的引用里消失了。如果同事没有其他副本或者他过几天才发现自己的工作被覆盖恢复的成本会非常高。--force-with-lease就是为这个场景设计的安全机制。它的工作方式是这样在你 push 之前它会比对本地记录的远端分支当前指向和你实际要覆盖的远端 ref 是否一致。如果不一致说明远端在你 fetch 之后又被推进过那么 force push 就会被拒绝报错信息类似! [rejected] feature/login - feature/login (non-fast-forward) error: failed to push some refs to gitgithub.com:xxx/xxx.git hint: Updates were rejected because the remote contains work that you do not have locally.这一层检查本质上是在说我只允许自己覆盖我最后一次看到的状态如果远程在我看不见的地方发生了变化我不应该用自己的历史去覆盖它。5.3 我这次为什么敢用 force-with-lease回到我的场景。我确认过这个feature/login分支是团队里我和另一个同事在协作而同事的代码我已经通过git fetch全部拿到了本地rebase 之后我本地的提交历史已经完整包含了远端所有提交。也就是说我force push 之后远端的任何工作都不会丢失——它们本来就已经整合进了我的历史里。这种情况下使用git push --force-with-lease origin feature/login是合理的。它既允许我重写历史、把分支移动到我整理后的干净线性状态又保留了如果我在推送前的一瞬间远端又出现了新提交就拒绝的保护。这里必须强调一条我后来执行过很多次 repo 操作后总结出的边界情形是否允许 force push理由个人长期分支、未被他人拉取可以覆盖历史不会有协作风险团队协作但分支上唯一活跃开发者可以用 --force-with-lease仍要防止别人也在操作多人经常贡献的公共分支绝对不要会直接覆写他人提交主分支、发布分支绝对不要历史不可变原则破坏影响极大刚刚 rebase 过的、且已确认包含远端全部提交可以用 --force-with-lease属于有依据的历史重写5.4 实际操作后的验证我执行了git push --force-with-lease origin feature/login这次推送顺利通过。然后我拉取了远端状态确认了一下git fetch origin git status输出显示Your branch is up to date with origin/feature/login.说明远端分支已经被移动到和本地一致的位置两边历史完全对齐。6. 最后一次复盘我是怎么把整条分支历史拉成一条直线的6.1 推送成功之前的完整命令链到这里整个事故已经解决了但我觉得还有必要把最终这个干净的分支状态是怎么来的再做一次完整的命令链复盘。很多人看教程只记住了最后一条 force-with-lease但真正值得记住的是中间的每一步为什么存在。我这次操作从报错到最终恢复干净历史的完整链路是git fetch origin git rebase origin/feature/login # 遇到冲突逐个解决 git add 冲突文件 git rebase --continue # 再次遇到冲突再次解决 git add 冲突文件 git rebase --continue git push --force-with-lease origin feature/login有人说rebase 命令又长又不直观为什么不用git pull --rebase一步到位确实git pull --rebase是git fetch加git rebase的组合在大多数情况下一条命令就能完成更新远端 重放本地提交。我这次之所以拆开执行是因为我需要明确的中间状态遇到冲突时我要能拿到完整的冲突信息还要确认 rebase 的目标是origin/feature/login而不是某个过期的 remote-tracking branch。拆开执行可以让我在每一步都用git status和git log验证一遍避免把自动操作带来的黑盒风险带进排障过程。6.2 merge 和 rebase 在未来一周内给我带来的实际差异事后感想是最直观的。这次事件之后的五天内我基于这个分支继续开发了几个新功能期间团队其他成员也往主分支上推进了不少代码。我后来把这个feature/login分支合回主分支的时候历史图是下面这种干干净净的结构* 合并 feature/login 到 develop |\ | * 9b8c5e2 feat: 记住我功能前端交互 | * 2f4a31b fix: 登录态过期处理逻辑 | * 7e8b1c0 refactor: 登录态存储策略调整 |/ * 主分支上的最新提交这就是用 rebase 整理过之后的样子分支上的提交保持直线合并时只产生一个 merge commit而且 merge commit 的父节点关系非常清晰一条线是主干一条线是完整的功能分支。后续哪怕要 revert 这个功能分支上的某个改动也能精确地定位到某一个提交而不用在一堆交错的分叉线里翻找。如果当初我选择的是不断用 merge 去消化冲突最后合回主分支时历史图会变成一团乱麻。git log --graph里会出现几条线互相穿插尤其是多人同时提交过的文件那些 auto-merge 提交和人工解决冲突产生的节点会让半年后翻历史的同事相当头疼。所以说处理 non-fast-forward 这个报错表面上是解决 push 被拒的问题实际上的核心决策是你打算给这个项目留下什么样的历史。6.3 rebase 的一个隐藏副作用每个提交真实可单独 apply再补充一个 rebase 带来的非常好的工程属性因为 rebase 是按提交逐个把变更重放一遍所以重放成功后这条分支上的所有提交在逻辑上都是在最新基线基础上修改的增量。这意味着你之后可以单独把某个提交cherry-pick到其他分支冲突概率会小很多。相比之下如果一个分支的历史里有大量 merge 节点cherry-pick 时 Git 需要重复处理那些被合并进来的改动冲突几乎无法避免。这一点在你需要把一个修复单独发布到热修复分支时特别有用。7. 从这次事故里沉淀出的几条分支操作习惯7.1 push 之前先看状态三秒确认法经历这件事之后我养成了一个习惯在 push 之前先执行三秒钟的确认git status git log --oneline --graph -5第一条看当前分支和远端的关系第二条看最近五条提交是不是一条直线。如果发现分叉或者有可疑的 merge 节点就说明我本地的历史不是以远端最新状态为基础的这时候直接 push 大概率又要撞上 non-fast-forward。先花三秒钟看清自己的位置再决定要不要 pull 或 rebase比被报错逼着做决定要主动得多。7.2 我的个人分支 vs 团队共享分支的处理策略我现在对不同类型分支的处理策略已经非常固定个人长期开发分支本地随意 rebase、amendpush 时用--force-with-lease安全边界足够。拿来发 Pull Request 的功能分支只在 push 之后 rebase绝不在 push 之后 amend 已经推上去的提交。如果一定要修改历史用rebase -i整理完之后立刻 force push并口头或群里通知协作同事尽量减少在他看来远端分支忽然被换了一批哈希的时间窗口。主分支和保护分支从不直接 push走 PR/合并请求流程由 CI 把关。7.3 冲突解决的现场纪律不贪快确认两侧意图最后说一个几乎所有 Git 教程里都不太会强调、但实际项目里最重要的一点解决冲突的时候最忌讳的是凭直觉删掉一边保留另一边。我在这次冲突里把自己那边的代码大部分都保留了只在存储方式和跳转路径上做了取舍。但让我后怕的地方在于有些冲突里远端同事的代码和我的代码不光是改了一个函数而是改动了整个模块的调用约定。比如他改了一个工具函数的参数签名而我这个提交里恰好有对这个函数的调用那么我在解决冲突时不只是选择保留哪一侧的问题还需要检查调用处的参数是不是匹配新的签名。如果只是机械地取下方的代码编译都过不了。所以我的解决流程通常是打开冲突文件先看和两侧的代码理解各自在做什么。判断这两段代码是不是同一个改动意图如果是留下新的如果不是想清楚是否两段都需要保留、分别放到什么位置。检查冲突区域之外是否还有因为上下文变化而需要同步修改的引用点。本地跑一次编译或测试再git add和--continue。这种流程可能比直接改多花几分钟但能避免很多隐藏 bug。毕竟因为你偷懒留下的错误合并通常会在两周之后以最诡异的方式出现在线上。8. 从这次折腾中得到的最终结论事情彻底解决后我把完整过程和心理活动重新捋了一遍有一个比较深的体会non-fast-forward 报错本质上是 Git 在提醒我你还没有真正整合别人的工作就想让你的工作成为唯一的历史。这本身是一个极具协作精神的设计因为它保证了远端分支的历史不会因为某个人的单向提交而被静默覆盖。处理这类问题的核心不是背命令而是理解自己正处于什么样的历史拓扑里。如果你清楚本地、远端、共同祖先三者的关系那么每一次遇到这个报错你都能快速判断出路需要整合就 pull 或 rebase需要重写历史就做好确认后 force-with-lease需要取舍就手动解决冲突后在 continue 之前把代码编译跑通。最后分享一个我后来一直用的小技巧每次 rebase 完、push 之前我都会顺手执行一条git log --graph --oneline --decorate。当你的分支从密密麻麻的分叉变成一条干净直线的那一刻你会有一种难以言喻的舒适感——这个提示虽然简单但它构成了我接下来判断这段分支历史是否健康的第一直觉。