资讯详情

资讯详情

Git push被拒与divergent branches分叉修复指南

下班前敲下最后一次git push终端没给面子push 被拒还甩回来一行hint: You have divergent branches and need to specify how to reconcile them。往上翻两行还能看到一句看着挺无辜的FETCH_HEAD [up to date] release - origin/release。很多人第一反应是“远端明明是最新的为什么会报错”然后就顺手git push -f了——这一下把别人的提交冲掉事故就开始了。这条报错的本质是你本地的release和远端的origin/release已经从同一个祖先点各自长出了新的提交两条历史真的分叉了Git 不敢替你决定用哪种方式合并。它跟网络、权限、远端有没有更新都没关系[up to date]恰恰说明拉取是成功的。下面我按“这条报错的每一行在说什么、怎么把现场测绘清楚、三条修复路线怎么选、分叉是怎么被日常操作制造出来的、以及一套可以照着敲的处理流程”这个顺序讲适合刚被这个提示卡住的新手也适合想给团队定规范的老人。1. 拆开这条报错三行输出各自在说什么大多数人看到报错就慌其实这条提示里混着好消息、坏消息和一句“你需要做决定”。把三行拆开看判断会清晰很多。1.1 FETCH_HEAD [up to date] 是唯一一句好消息FETCH_HEAD [up to date] release - origin/release这一行来自git pull前半段的 fetch 动作。它在说我去远端问过了origin/release这个引用指向的提交跟上次拉下来的时候一模一样没有新东西所以更新FETCH_HEAD之后标记为[up to date]。换句话说你现在遇到的不是“远端又变了”的突发状况而是一个已经存在的、稳定的分叉状态。这一点很重要如果远端一直在动你 rebase 到一半又有人推新提交处理起来会麻烦得多而[up to date]意味着你接下来做的任何操作面对的都是一张静止的图可以慢慢来。还有一种容易混淆的情况FETCH_HEAD有时候会显示成几个不同的分支名或者显示[new branch]、[new tag]。那是因为你配置了多分支的 refspec。真正需要盯的只有一件事——origin/release这个远端跟踪分支的哈希有没有变。1.2 divergent 的真实含义两条历史从某个点各自长出了新枝Git 的历史是一张有向无环图每个提交指向它的父提交。当本地release和origin/release指向同一个提交时叫“同步”当本地落后叫“behind”可以直接快进当本地超前叫“ahead”直接 push 就完事。问题出在第四种状态两边都从某个共同祖先往后各自提交了若干个 commit。这时候没有一条“快进”的直线路径能把两个端点连起来git pull就必须选择一种“合并方式”造一个合并提交把两条历史缝在一起merge把你本地的提交逐个“摘下来”重新接到远端提交之后rebase什么都不做直接报错ff onlyGit 从 2.27 版本开始在pull.rebase没有显式配置的情况下遇到分叉就不再默默替你选 merge 了而是停下来问你要哪一种。这就是那行 hint 的来源。它不是错误是一次强制你表态的交互。注意hint: You have divergent branches只出现在git pull也就是包含 fetch 的动作里如果你只跑git push而没跑 pull看到的是另一种提示Updates were rejected because the remote contains work that you do not have locally。两者根因相同措辞不同。1.3 push 被拒和 pull 提示是同一件事的两面顺序上通常是这样你先git push被拒non-fast-forward于是你去git pull然后看到 divergent 的提示。很多人误以为是 pull 出了错其实是 push 先失败pull 在告诉你“为什么失败、以及怎么补救”。判断 push 会不会被拒只看一个条件远端分支的当前提交是不是你本地提交的祖先。如果是快进推送成功如果不是无论内容有没有冲突都会被拒。这解释了为什么有时候你只改了一个文件、跟远端改的文件完全不重叠push 照样被拒——Git 拒绝的是“历史形状”不是“文本冲突”。理解这一点之后你就不会再去纠结“我改的地方跟他不冲突啊”而是会直接去检查两条历史的分叉点在哪里。这是从“碰运气修”到“按图修”的分水岭。2. 先别动手把分叉现场测绘清楚我见过太多人一看到 divergent 就立刻git pull --rebase然后在一片冲突里迷失。稳妥的做法是先花三十秒把图看清楚再决定动哪一刀。测绘阶段有四条命令按顺序敲不要跳。2.1 git status 那两行 ahead/behind 怎么读git status典型输出里有这么一行Your branch and origin/release have diverged, and have 3 and 5 different commits each, respectively.这行信息量很大但经常被读反。前一个数字是本地独有的提交数后一个数字是远端独有的提交数。上面这行意思是本地有 3 个提交远端没有远端有 5 个提交本地没有。知道这两个数字之后你就有了一个初步判断如果本地只有 1 个提交、远端有 20 个rebase 一下成本很低冲突面也小如果本地有 30 个提交、远端只有 2 个rebase 就要重放 30 次提交冲突可能反复出现这时候反而要考虑 merge 或者干脆建个新分支。想看更精确的计数用这条git rev-list --left-right --count HEAD...origin/release输出3 5这种格式左边是 HEAD 独有右边是远端独有跟 status 的数字对应但更适合塞进脚本里做判断。2.2 用一张提交图看清结构git log --graph --oneline --decorate --all -30--graph画出分叉和合并的连线--decorate标出分支名和远端跟踪分支--all把本地所有分支都画进来。你会看到类似这样的结构* 9f3c2a1 (origin/release) 修复导出时的空指针 * 4d8e77b 补充接口返回字段 | * b21a90c (HEAD - release) 调整分页参数默认值 | * 7c5de44 增加日志埋点 |/ * a10f3b9 上一次同步的共同祖先那根|/汇合的点就是 merge-base也就是两条历史最后一次相同的位置。所有“谁改了什么”的判断都要以这个点为基准而不是拿两个分支的 HEAD 直接比。如果图太长可以只看分叉点之后的部分git log --graph --oneline HEAD...origin/release三个点表示“从 merge-base 到两个端点的所有提交”天然过滤掉了共同历史。2.3 merge-base 与三点 diff确认到底谁改了哪块git merge-base HEAD origin/release这条命令返回共同祖先的完整哈希。拿到它之后你可以分别看两边各自动了哪些文件# 本地相对共同祖先改了什么 git diff --stat $(git merge-base HEAD origin/release) HEAD # 远端相对共同祖先改了什么 git diff --stat $(git merge-base HEAD origin/release) origin/release有一个更省事的写法git diff HEAD...origin/release注意是三个点语义是“从 merge-base 到远端”等价于看远端那边都改了什么。很多人习惯用两个点HEAD..origin/release那是直接比两个端点会把本地改动反向显示成“删除”非常容易误判。这一步的价值在于在动手合并之前先用文件列表判断冲突范围。如果两边改的是完全不同的目录rebase 大概率一路顺畅如果两边都动了同一批文件就要预留出解冲突的时间别在下班前五分钟开工。2.4 确认对方那几个提交是什么来路远端多出来的提交不一定都是同事的手写代码。常见的有三类提交来源典型提交信息对合并策略的影响同事手写提交有业务语义的描述正常 rebase 即可注意冲突语义自动化工具提交版本号递增、锁文件更新、构建产物建议让工具提交优先本地 rebase 时直接接受对方版本回滚或热修复提交revert、hotfix 相关必须逐个看 diffrebase 可能把回滚效果又“抵消”掉git log --oneline --no-merges HEAD..origin/release这条命令只列远端独有的非合并提交一眼就能扫完。看到chore: bump version、chore: update lockfile这类提交心里就有数了——这类文件的冲突基本不用手工解直接选一边即可。3. 三条修复路线merge、rebase、ff-only 的取舍Git 给的三个选项不是随便列的它们对应三种完全不同的历史观。选哪个取决于你的分支被谁消费是只给自己看的特性分支还是整个团队共用的集成分支答案完全不同。3.1 merge 路线历史真实但图会变复杂git pull --no-rebase # 或者长期配置 git config --global pull.rebase falsemerge 的做法是新建一个合并提交把两条历史缝起来。它的最大优点是不改写任何已有提交所有人的哈希都保持不变已经推出去的东西不会被替换。对于release、main这种多人共享、可能还有 CI 在追踪哈希的分支这是最安全的选择。代价是提交图会变成菱形长期累积下来git log --graph会很难看。不过说实话图难看只是观感问题真正麻烦的是合并提交本身会成为冲突的温床同一个文件如果反复被两个分支来回合并冲突会一次次重现。判断要不要用 merge我的经验标准是这个分支上的提交有没有被别人引用过。如果有人基于你的某个提交开了新分支、或者 CI 报告里贴了哈希那就别 rebase老老实实 merge。3.2 rebase 路线线性整洁但要改写提交git pull --rebase # 或 git config --global pull.rebase truerebase 的做法是找到 merge-base把你本地那几个提交依次“摘下来”暂存为补丁然后把分支指针移到origin/release再逐个把补丁应用上去。结果是你的提交全部换了新哈希但历史变成一条直线。优点很直观历史干净git log一眼看到底git bisect也更好用。代价有两个都必须提前知道第一哈希变了。如果你已经把本地提交推到过别的分支、或者贴进了某个文档rebase 之后那些引用全部失效。第二冲突可能反复出现。你本地有 10 个提交rebase 时每个提交都要跟新的基线做一次三方合并同一个文件的冲突可能被解 5 次。这时候rerere就非常有价值后面第 5 节会讲。还有一个容易被忽略的细节rebase 过程中如果出现冲突工作区会处于“detached HEAD”式的中间态此时千万不要再开一个新终端去git commit别的东西容易把状态搞乱。要么解完git rebase --continue要么git rebase --abort整体回退。3.3 pull.ff only最保守也最容易卡住git config --global pull.ff only这个配置的意思是只允许快进一旦出现分叉就直接失败不自动合并也不 rebase。它适合那种“我要人工审查每一个合并动作”的严格流程比如受保护的生产分支。但它也是最容易让人卡住的选项分叉时git pull会直接报fatal: Not possible to fast-forward, aborting.你就必须显式地git pull --rebase或git merge origin/release才行。对新手来说这等于把“选哪种方式”的问题推到了更靠后的位置反而更懵。三种路线的横向对比维度mergerebaseff only是否改写提交哈希否是否因为不做合并历史形状菱形有合并提交直线直线冲突次数一次合并时集中处理可能多次逐提交重放不适用需手动操作适合分支共享的 release/main个人特性分支受保护的生产分支误操作风险低中改写已推送历史风险高低3.4 冲突出现的完整处理链路无论选 merge 还是 rebase冲突的处理流程是共通的只是收尾命令不同。以 rebase 为例第一步先看清冲突清单。git status输出会分成Unmerged paths区块both modified是双方都改、需要人工判断的deleted by us/deleted by them是一方删除一方修改的这类最容易误操作必须逐个看。第二步别急着编辑先看三方差异。git diff冲突标记里的 HEAD部分是“当前基线”rebase 时是远端那边部分是你的提交。搞清楚哪边是哪边比盲目删标记重要得多。想看得更清楚可以用git log --merge -p -- file它只显示跟这个文件相关的、来自两条历史的提交和改动能帮你还原“对方为什么这么改”。第三步处理特殊类型的冲突。锁文件各类 package-lock、yarn.lock、go.sum不要手工合并直接选一边然后重新生成。比如git checkout --theirs lockfile之后跑一次安装命令。二进制文件图片、字体、压缩包Git 无法三方合并只能二选一。用git checkout --ours file或--theirs明确选边。格式化工具产生的整文件差异先确认是不是有人跑了格式化。如果是处理完冲突后统一跑一次格式化避免下一个人再踩。第四步标记已解决并继续。git add file git rebase --continuemerge 路线下则是git add之后git commit不带-m会打开编辑器里面预填了合并信息直接保存即可。第五步确认结果没有丢东西。git log --oneline -10 git diff origin/release --stat第二条命令在 rebase 完成后应该输出为空说明你的工作树内容跟远端一致如果你本来就没有额外改动的话。这一步很多人省掉结果推上去才发现少了个文件。提示rebase 中途实在解不动了git rebase --abort会把你完整送回 rebase 之前的状态一点都不用慌。这个命令是幂等的多按几次也没事。4. 分叉是怎么被日常操作制造出来的搞清楚怎么修之后更有价值的问题是为什么这东西总是反复出现我复盘过自己踩过的坑也看过不少团队的流程分叉基本都来自下面四种操作习惯。4.1 amend 与 reset --hard 之后的强推惯性最常见的场景提交完发现提交信息写错了git commit --amend改一下或者发现多提交了一个文件git reset --soft HEAD~1重来。这些操作本身没问题问题在于它们会改变已推送提交的哈希。一旦你 amend 了一个已经推到远端的提交本地和远端就从这里分叉了远端还留着旧哈希的那个提交本地换成了新哈希。此时git push必然被拒然后很多人顺手git push -f。用-f的危险在于它是“无条件覆盖”如果这期间有别人往同一个分支推了提交那些提交会被你的强推直接抹掉而且从远端的分支指针上再也找不到只能靠 reflog 或别人本地副本来救。正确做法是用--force-with-leasegit push --force-with-lease origin release它的逻辑是只有当远端分支仍指向我上次 fetch 时看到的那个提交才允许覆盖。如果中间有人推了新东西远端指针变了这个推送会被拒逼你先 fetch 看看发生了什么。这一个参数就能挡掉大部分强推事故。4.2 release 分支上多人同时提交的时序问题第二个高频来源是多人直接往同一个集成分支提交。时序是这样的A 同学git pull拿到最新的origin/release基于它改代码B 同学同样git pull也基于同一个提交改代码A 先提交并 push 成功origin/release前进了B 提交后 push被拒——因为 B 的父提交已经不是远端的最新提交这就是分叉的经典成因。注意 B 并没有做错任何事他只是“手慢了”。这类分叉的冲突通常不大因为两人改的文件往往不同rebase 一下基本一路顺畅。减少这种情况的办法不是“让大家手快点”而是缩短“pull 到 push”的时间窗口小步提交、频繁推送、改完立刻推。攒一天十几个提交在本地最后一次性推分叉概率会成倍上升。4.3 自动化工具提交与人工提交互撞如果仓库里配置了自动提交版本号递增、依赖锁文件更新、文档自动生成等它们会在你不知情的时候往分支上推提交。你本地还在埋头改代码远端已经前进了好几个自动提交等你要推的时候才发现分叉。这类分叉有个特点远端多出来的提交内容跟你的改动几乎不重叠但冲突偏偏出现在锁文件或版本号文件上。处理这类分叉我的经验是先用git log --oneline HEAD..origin/release确认多出来的都是自动化提交rebase 时遇到锁文件冲突直接git checkout --theirs lockfile然后在本地重新跑一次依赖安装让工具生成正确内容版本号文件同理接受远端版本或者干脆在最后统一改一次更彻底的办法是让自动化提交走独立的流程不要跟人工提交挤在同一条分支上或者用分支保护规则限制谁能往release推。这属于流程层面的治理比事后解冲突省力得多。4.4 squash merge 与 cherry-pick 造成的“同内容不同哈希”这一类最隐蔽。现象是你明明看到某个改动已经在远端的文件里了但 Git 就是认为你的提交跟远端分叉rebase 时还会把这个改动的冲突又拿出来让你解一遍。原因是squash merge 和 cherry-pick 都会生成新的提交哈希内容一样、哈希不同。Git 判断“这个补丁是否已经应用过”靠的是 patch-id内容指纹而不是哈希所以 rebase 通常能自动识别并跳过你会看到dropping hash -- patch contents already upstream但在某些情况下——比如同一文件在两边都被改动过——识别会失败。遇到这种情况不要硬解。先确认那个改动是否真的已经在远端了git log --oneline origin/release -- 相关文件如果确认在就在 rebase 时手动git rebase --skip跳过该提交或者用交互式 rebase 把它删掉git rebase -i origin/release在打开的编辑器里把对应的行改成drop保存退出。这比在冲突里纠缠快得多。5. 一套可以照着敲的处理流程与防呆配置前面讲的都是原理和判断这一节直接给可执行的步骤。我把自己常用的流程固化成五步遇到 divergent 就照着做基本不会有意外。5.1 遇到分叉时的五步标准动作第一步确认远端状态不要在旧信息上做决策。git fetch origin git statusfetch之后再statusahead/behind 的数字才是最新的。跳过这一步直接 rebase很可能 rebase 到一个过时的基线上。第二步看清远端多出来的提交判断冲突风险。git log --oneline HEAD..origin/release git diff --stat $(git merge-base HEAD origin/release) origin/release如果远端提交涉及的文件跟你本地改动完全不重叠放心 rebase。如果高度重叠先想清楚业务逻辑上谁的改动应该优先。第三步根据分支类型选择合并方式。个人特性分支用 rebasegit pull --rebase origin release共享的集成分支用 mergegit pull --no-rebase origin release这个判断不要凭手感按“这个分支有没有别人在用”来定规则清晰团队里也容易统一。第四步逐个解决冲突每次解决后立即验证。git add 已解决的文件 git rebase --continue每解决一个文件就git diff --cached看一眼暂存的内容确认没有残留冲突标记。批量git add .是事故高发动作因为冲突标记里的内容会被一起提交进去。第五步本地验证后再推送。git log --oneline --graph -10 git push origin release如果前面做过 rebase推送时不需要-f因为你是在本地把自己的提交接到远端之后远端是本地的祖先属于快进推送。只有当你确实需要覆盖远端历史时才用--force-with-lease。这五步里最容易省掉的是第一步和第五步但恰恰是这两步能挡住大部分“推上去才发现不对”的情况。5.2 团队级的配置约定与分支保护一个人用可以靠记忆一个团队用就必须靠配置。我倾向于在仓库级别而不是全局做约定这样不同项目可以有不同策略# 新克隆的仓库初始化时执行一次 git config pull.rebase true # 特性分支默认为 rebase git config rebase.autoStash true # rebase 前自动暂存未提交改动 git config rerere.enabled true # 记录冲突解决方案 git config push.default simple # 只推当前分支到同名远端分支rebase.autoStash true这个配置特别值得单独说。它解决了一个高频痛点你手头有改了一半的代码突然需要 pull普通的 rebase 会因为工作区不干净而拒绝执行你得先 stash、再 pull、再 stash pop。开了 autoStashGit 会在内部自动完成这套动作结束后原样恢复省掉三步操作也省掉“pop 时冲突”的二次麻烦。分支保护层面release这类分支建议配置成禁止强推、要求 PR 合并、必须通过 CI。这三条一上前面说的 4.1 和 4.3 那两类分叉基本被堵死。5.3 rebase 搞砸后的兜底reflog 与 ORIG_HEAD再怎么熟练也难免有把 rebase 做歪的时候。比如解冲突时选错了边一路--continue到结束推上去才发现逻辑反了。这时候千万别慌Git 几乎不会真的丢东西。先看 refloggit reflog它记录了 HEAD 的每一次移动包括 rebase 的开始、每一步的 continue、以及最终结果。找到 rebase 开始之前的那个位置通常是形如HEAD{5}: rebase (start): checkout origin/release的下一行也就是你原本的分支尖端git reset --hard HEAD{5}ORIG_HEAD是另一条捷径。Git 在很多“危险操作”merge、rebase、reset之前会自动把当时的 HEAD 记录到ORIG_HEAD里git reset --hard ORIG_HEAD这条命令在 rebase 刚做完、还没做别的操作时特别有效一条命令回到原点。但要注意再执行一次别的合并或重置ORIG_HEAD就会被覆盖所以要趁早。注意git reset --hard会丢弃工作区未提交的改动。执行前先git status确认没有正在写的代码或者先git stash一下。5.4 rerere让重复冲突只解一次最后分享一个很多人不知道但极其好用的功能rerere全称是 reuse recorded resolution。开启之后Git 会记住你每次是怎么解决冲突的下次遇到完全相同的冲突时自动套用。git config --global rerere.enabled true git config --global rerere.autoupdate true这个功能在 rebase 长串提交时价值最大。假设你有 12 个提交要 rebase其中 5 个都改到了同一个文件理论上你要解 5 次几乎一样的冲突。开了 rerere你解完第一次后面 4 次 Git 会自动帮你解好并git addautoupdate的作用你只需要--continue。它还有一个隐藏用法merge 一个分支之后反悔了git merge --abort过一会儿又想 merge 回来rerere 会自动把之前的解决方案重新应用上不用重解一遍。需要注意的边界rerere 是基于冲突文本的上下文匹配的如果中间有别的改动导致上下文变了它就不会自动套用会重新让你解。所以它不是万能的但能覆盖大部分“反复解同一个冲突”的场景。我自己的习惯是git pull之前一定先git fetch加git status看一眼数字把“拉取”和“决策”这两个动作分开。看起来多敲了一行命令但这一行能让你在动手之前就知道自己面对的是 3 比 5 还是 30 比 2后面的选择会踏实很多。至于pull.rebase到底设成 true 还是 false我的建议是别在全局拍板按仓库设——个人项目随便 rebase共享分支老老实实 merge规则简单到不用记执行起来才不会犹豫。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →