1. 一次提交失败引发的连锁思考
如果你日常用 Git 管代码,大概率遇到过这个场景:本地改了半天,信心满满敲下git push,结果终端甩回来一串红字,大意是"远端有你本地没有的提交,推送被拒绝"。这时候很多人的第一反应是git pull,然后 Git 又弹出一个合并提交的编辑器界面,或者更糟——直接报冲突,把工作区搅成一团。我见过不少同事在这个环节反复折腾,最后干脆把本地改动复制出来、git reset --hard、重新粘贴,一套操作下来半小时没了,还容易丢东西。
这篇随笔聊的就是这个高频痛点:本地分支与目标分支不一致导致提交失败时,怎么用git stash配合git pull --rebase干净利落地解决。标题里带了个"三",说明这是系列文章的第三篇,前两篇大概率铺垫了 Git 的基础操作和分支概念,这一篇聚焦的是"提交失败"这个具体故障场景的处置手法。适合谁看?刚上手 Git 一两个月、能完成基本 add/commit/push 但对分支同步机制还犯迷糊的同学;也适合用了几年 Git 但一直靠"暴力 reset"解决问题的老手——说不定能改掉一个坏习惯。
先把核心结论摆出来:当本地有未提交改动、同时远端分支又领先于本地时,正确姿势是先stash暂存工作区,再pull --rebase把本地提交"垫"到远端最新提交之上,最后stash pop恢复改动。这套组合拳的价值在于:不产生多余的合并提交、不丢失任何本地改动、历史线保持线性整洁。下面我把这套流程背后的原理、每一步的意图、以及踩过的坑,掰开揉碎讲清楚。
2. 提交失败的根因拆解与方案选型
2.1 为什么 push 会被拒绝:两条分叉的历史线
要理解失败原因,得先明白 Git 的分支本质是一条提交链。假设远端main分支上有提交 A→B→C,你本地从 B 拉下来后继续开发,产生了提交 D,此时本地是 A→B→C→D 吗?不是。你本地是 A→B→D,而远端在你拉取之后又被别人推了 C 上去,变成 A→B→C。两条线在 B 之后分叉了。
Git 在push时会做一次"快进检查"(fast-forward check):它要求你的本地分支必须是远端分支的直接后代,也就是远端的所有提交你本地都得有。现在远端有 C 而你没有,Git 无法判断 D 和 C 谁先谁后、会不会互相覆盖,于是直接拒绝。这个设计是保护机制,防止你无意中把别人的提交"顶掉"。报错信息通常长这样:
! [rejected] main -> main (fetch first) error: failed to push some refs to 'origin' hint: Updates were rejected because the remote contains work that you do hint: not have locally.看到fetch first或non-fast-forward字样,基本就是这个病。诊断方法很简单,先git fetch origin把远端最新状态拉下来(注意 fetch 只更新远端跟踪分支,不动你的工作区),然后git log --oneline --graph --all看一眼分叉点在哪,心里就有数了。
2.2 三种解法对比:merge、rebase、stash+rebase
面对分叉,常见有三条路,我做个对照表,方便你按场景选:
| 方案 | 操作 | 历史形态 | 适用场景 | 风险点 |
|---|---|---|---|---|
| 直接 merge | git pull(默认 merge) | 产生一个合并提交,历史呈网状 | 团队协作、公共分支、需要保留真实合并痕迹 | 合并提交噪音多,历史不直观 |
| 直接 rebase | git pull --rebase | 线性历史,本地提交被重放到远端之上 | 个人分支、追求整洁历史 | 工作区有未提交改动时会失败 |
| stash + rebase | git stash→git pull --rebase→git stash pop | 线性历史,且改动完整保留 | 本地有未提交改动、又想保持线性 | 多一步 stash 管理,pop 可能冲突 |
为什么我推荐第三种?因为现实里"提交失败"往往伴随"工作区还有一堆没提交的改动"。你直接pull --rebase,Git 会报cannot pull with rebase: You have unstaged changes,因为它不敢在脏工作区上做重放操作。这时候要么先 commit(但改动没写完不想提交),要么 stash。stash 就是那个"临时抽屉",把工作区收拾干净再操作,操作完再拿出来。
至于为什么不选 merge:在个人开发分支上,merge 产生的"Merge branch 'main' of ..."提交除了污染历史没有任何价值,review 的时候看着也烦。rebase 把本地提交"搬"到远端最新提交之后,历史是一条直线,git log一眼看到底,这是很多团队对个人分支的默认要求。当然,公共分支(比如团队共享的 develop)绝对不要 rebase,这是铁律,后面会细说。
2.3 stash 与 rebase 的底层机制速览
git stash的本质是把工作区和暂存区的改动打包成两个(有时三个)提交对象,存到.git/refs/stash这个特殊引用下,然后把工作区恢复到 HEAD 的干净状态。你可以把它理解成"给当前改动拍个快照,塞进抽屉,桌面擦干净"。git stash list能看到抽屉里所有快照,git stash pop是"取出最近一个并从抽屉删除",git stash apply是"取出但保留在抽屉里"。
git pull --rebase则是两步合一:先fetch拿到远端最新提交,再rebase——把你本地独有的提交逐个"摘下来",暂存到一边,把本地分支指针移到远端最新提交,然后把这些提交一个个重新"贴"上去。贴的过程中如果某个提交和远端改动冲突,rebase 会暂停让你解决,解决完git rebase --continue继续。理解了这个"摘下-移动-重贴"的过程,你就明白为什么 rebase 会改写提交哈希了——因为父提交变了,哈希必然变。
3. 完整实操流程与关键参数
3.1 第一步:确认状态,别急着动手
动手前先做体检,这一步很多人跳过,结果后面抓瞎。三条命令足够:
git status git stash list git log --oneline --graph --all -10git status告诉你工作区有哪些改动、哪些已暂存、当前在哪个分支。git stash list看看抽屉里是不是已经有旧快照(有的话先想清楚要不要,别 pop 错了对象)。git log --graph看分叉结构,确认远端确实领先。我踩过的坑:有一次 stash 里躺着三天前的半成品,我 pop 的时候没注意,直接把旧改动和新改动混在一起,排查了半天。所以动手前先git stash list扫一眼,是保命习惯。
3.2 第二步:stash 暂存工作区
git stash push -m "feat: 用户模块半成品,待rebase后恢复"带-m加备注是个好习惯,尤其你一天 stash 好几次的时候,stash@{0}、stash@{1}这种编号根本分不清谁是谁。默认git stash只存已跟踪文件的改动,如果你有新创建但没git add的文件,它们不会被 stash,rebase 后还在工作区里杵着。要连未跟踪文件一起存,用:
git stash push -u -m "包含未跟踪文件"-u是--include-untracked的缩写。还有个-a(--all)连被 ignore 的文件也存,一般用不上,慎用。stash 完再git status确认工作区干净,这是进入下一步的前提。
3.3 第三步:pull --rebase 拉取并重放
git pull --rebase origin main这里有个细节值得说:git pull默认行为在不同 Git 版本里不一样,老版本默认 merge,新版本可能提示你配置。与其依赖默认值,不如显式写--rebase,意图明确,也避免换台机器行为不一致。如果你嫌每次敲麻烦,可以设全局默认:
git config --global pull.rebase true设完之后git pull就等价于git pull --rebase。但注意,这个配置是全局的,如果你有些仓库确实需要 merge 行为,记得在那些仓库里单独git config pull.rebase false覆盖掉。
rebase 过程中如果本地提交和远端改动冲突,终端会停下来提示CONFLICT,此时git status会列出冲突文件。解决冲突后:
git add <冲突文件> git rebase --continue如果想放弃这次 rebase 回到操作前,git rebase --abort一键还原,这是 rebase 的"后悔药",记住它心里不慌。
3.4 第四步:stash pop 恢复改动并收尾
git stash poppop 会把最近一个 stash 取出并应用,同时从 stash 列表删除。如果应用时和当前代码冲突(比如 rebase 后远端改动碰巧改了你 stash 里的同一行),Git 会报冲突并保留 stash 不删除,这时候你解决完冲突,手动git stash drop清理即可。这个设计挺贴心——冲突没解决干净,它不敢删你的快照。
恢复完改动,git status再确认一遍,然后正常git add、git commit、git push。此时本地历史已经是线性的,push 一路绿灯。
3.5 参数速查与命令清单
把上面流程浓缩成一张速查表,贴在显示器边上都行:
| 步骤 | 命令 | 作用 | 备注 |
|---|---|---|---|
| 体检 | git status/git stash list/git log --graph --all | 确认工作区与分叉状态 | 必做 |
| 暂存 | git stash push -u -m "备注" | 打包工作区改动 | -u含未跟踪文件 |
| 拉取重放 | git pull --rebase origin main | 本地提交垫到远端之上 | 冲突则解决后--continue |
| 恢复 | git stash pop | 取出改动 | 冲突时 stash 保留 |
| 推送 | git push | 推送线性历史 | 此时应成功 |
4. 高频翻车现场与排查实录
4.1 stash pop 冲突了怎么办
这是最常见的翻车点。现象:git stash pop后终端报CONFLICT (content): Merge conflict in xxx,文件里出现<<<<<<<标记。别慌,这跟普通合并冲突处理逻辑一样:打开文件,保留你要的代码,删掉冲突标记,git add标记已解决。区别在于——stash 冲突解决后不会自动 drop,你得手动git stash drop,否则快照一直躺在列表里,下次 pop 又冒出来。
我个人的习惯是:pop 冲突解决完,先git stash list确认那个快照还在,再git stash drop stash@{0}。如果解决到一半发现搞砸了,git checkout -- .丢弃工作区,stash 还在,可以重来。这个"快照不自动删"的特性,反而是个安全网。
4.2 rebase 到一半想放弃
rebase 冲突解决到第三个文件,突然发现方向错了,想回到操作前。git rebase --abort直接还原到 rebase 开始前的状态,本地提交、工作区都回来。但注意:abort 之后你之前 stash 的改动还在抽屉里,需要手动 pop。有些人 abort 完发现工作区是干净的,以为改动丢了,其实是忘了 pop,虚惊一场。
还有一种情况:rebase 已经--continue完成了,但发现搞错了想回退。这时候用git reflog找到 rebase 前的 HEAD 位置,git reset --hard <哈希>回去。reflog 是 Git 的"黑匣子",记录所有 HEAD 移动,rebase、reset、checkout 都能追溯,强烈建议每个用 Git 的人都学会看 reflog。
4.3 误在公共分支上 rebase
这是最严重的问题,必须单独拎出来说。假设你和同事共用develop分支,你本地 rebase 后强推git push -f,远端历史被改写,同事本地的develop和远端彻底分叉,他们下次 pull 会看到一堆莫名其妙的冲突,甚至可能把别人的提交搞丢。
判断标准很简单:这个分支是不是只有你一个人在用?是,随便 rebase;不是,老老实实 merge。已经误操作了怎么办?赶紧通知所有相关同事,让他们git fetch后git reset --hard origin/develop重新对齐(前提是他们本地没有未推送的提交)。预防手段:给公共分支加保护,很多代码托管平台都支持"禁止强推"设置,打开它。
4.4 常见问题速查表
| 现象 | 可能原因 | 处置 |
|---|---|---|
cannot pull with rebase: unstaged changes | 工作区脏 | 先git stash |
push rejected (fetch first) | 远端领先本地 | stash+pull --rebase |
stash pop报冲突 | stash 与当前代码同行冲突 | 手动解决后git stash drop |
| rebase 卡住不动 | 有冲突未解决 | git status看冲突文件,解决后--continue |
| 改动"消失"了 | 忘了stash pop | git stash list找回 |
| 历史乱成一团 | 误 rebase 公共分支 | 通知团队,reset --hard对齐远端 |
4.5 几条压箱底的经验
第一条:stash 不是长期存储。有人把 stash 当草稿箱,攒了十几个快照,最后自己都忘了哪个是哪个。stash 适合"几小时内临时周转",超过一天的建议开个临时分支git checkout -b wip/xxx提交上去,比 stash 可靠得多。
第二条:rebase 前先git fetch看一眼远端。有时候远端改动很大,你 rebase 上去冲突一堆,不如先看看远端改了什么,评估一下要不要先合并再继续。git log HEAD..origin/main --oneline能列出远端比你多的提交,扫一眼心里有底。
第三条:养成pull --rebase前先 commit 或 stash 的肌肉记忆。我现在敲git pull之前手会自动先git status,脏的话先 stash,这个习惯帮我省了无数次cannot pull的报错。
第四条:冲突解决完,跑一遍测试再 push。rebase 重放提交时,虽然每个提交的改动内容没变,但上下文变了,可能出现"单独看没问题、合起来编译不过"的情况。尤其是涉及依赖版本、接口签名的改动,rebase 后务必本地构建一次,别把问题推给 CI。
这套 stash + pull --rebase 的组合,我从早期被push rejected搞得手忙脚乱,到现在闭着眼睛都能走完流程,中间踩的坑基本都在这了。核心就一句话:动手前先看状态,脏工作区先 stash,拉取用 rebase 保线性,恢复后跑测试再推。把这四步变成条件反射,提交失败就不再是事故,而是一个三十秒能解决的小插曲。