type
status
date
slug
summary
tags
category
icon
password
wechat_gate
适用场景:基于开源项目(上游)做长期二次开发的私有 fork,需要持续吸收上游更新。 本文以 my-product(私有 Fork,remote 为 origin)跟踪 open-source-project(上游项目,remote 为 upstream)为匿名示例;文中的仓库地址和项目名均为占位符。

一、前置模型:Git 的远程仓库机制

1.1 remote 是什么

一个 remote 只是一个命名的 URL 别名,记录在 .git/config 里:
一个仓库可以有任意多个 remote。双 remote 是 fork 工作流的标准配置:
  • origin:你自己的私有仓库,日常 push 的目标
  • upstream:原项目,只读来源,从不 push
fetch = +refs/heads/*:refs/remotes/upstream/* 这行叫 refspec,含义是:把远端的所有分支(refs/heads/*)映射到本地的远程跟踪分支命名空间(refs/remotes/upstream/*)。开头的 + 表示允许非快进更新(远端历史被改写时也强制刷新本地跟踪引用)。

1.2 三种"分支"的区别

引用
位置
谁能改动它
main
refs/heads/main,本地分支
你的 commit / merge / reset
upstream/main
refs/remotes/upstream/main,远程跟踪分支
只有 git fetch 会更新它
远端的 main
存在于 GitHub 服务器上
上游作者的 push
关键认知:upstream/main 不是远端分支本身,而是本地对"上次 fetch 时远端状态"的快照。它是一个普通的本地引用(一个指向某 commit 的指针),离线也能查看和 diff。

二、git fetch upstream:原理

执行时发生三件事:
  1. 协商:本地与远端交换引用列表,计算出远端有而本地没有的 commit 集合
  1. 下载对象:把缺失的 commit、tree、blob 对象打包传输,存入 .git/objects(只增不减,不碰你的任何本地分支和工作区)
  1. 更新引用:按 refspec 把 refs/remotes/upstream/* 指针移到远端最新位置,同时写入 .git/FETCH_HEAD
fetch 的两条重要性质:
  • 绝对安全:fetch 只写对象库和远程跟踪引用,不改变 main、不碰工作区、不产生冲突。任何时候都可以放心执行
  • fetch ≠ 合并:fetch 之后你的 main 一步都没有动,新代码只是"到货入库"了,还没有"上架"
fetch 之后、merge 之前,是审查上游改动的黄金窗口
对商业 fork 来说这一步不是可选项:上游可能改了你已经深度定制的文件(品牌、付费逻辑所在处),合并前必须先知道会撞上什么。

三、git merge upstream/main:原理

merge 的输入是两个 commit:当前分支的 HEAD 和目标引用 upstream/main。Git 先找它们的最近共同祖先(merge base),然后分三种情况:

3.1 快进合并(fast-forward)

如果你的 main 自共同祖先以来没有任何自己的提交(HEAD 就是 merge base),Git 直接把 main 指针移到 upstream/main 的位置,不产生新 commit:

3.2 三方合并(three-way merge)

双方都有新提交时(fork 的常态),Git 以 merge base 为基准做三方对比,生成一个有两个父提交的 merge commit
三方对比的规则:对每个文件的每一处,如果只有一方相对 merge base 改了,采用改动方;双方都改且改法不同,标记为冲突,交给人处理。

3.3 冲突处理

冲突高发区是可预测的:你改过品牌信息的文件(Info.plistproject.pbxproj、本地化文件)几乎每次都会撞。冲突时的判断原则:功能代码尽量采上游,品牌/配置坚持采本地
project.pbxproj 这类机器生成文件,逐行手解冲突容易解坏,更稳的做法是整体取一方再手工补另一方的改动:

四、完整工作流

4.1 标准同步流程

4.2 建议的合并纪律

  • 小步快跑:每 2–4 周同步一次。攒半年再合,冲突规模会从"十分钟"膨胀到"两天"
  • 合并前打标记git tag before-sync-$(date +%Y%m%d),合并出问题可以随时 git reset --hard <tag> 整体回退
  • merge 后必须构建验证再 push:三方合并在文本层面成功不代表语义正确(上游改了函数签名、你这边的调用点不冲突但编译不过,是最常见的翻车方式)
  • 永远不要向 upstream pushgit remote set-url --push upstream DISABLED 可以物理封死误推

4.3 为什么不用 git pull

git pull upstream main = fetch + merge 一步到位,跳过了中间的审查窗口。对自己团队的仓库无所谓,对上游仓库不建议:你失去了在合并前查看"这次会进来什么"的机会。分开执行是 fork 维护的最佳实践。

五、merge 与 rebase 的选择

同步上游还有另一条路:git rebase upstream/main(把你的定制提交逐个重放到上游最新提交之上)。对比:
merge
rebase
历史形态
保留分叉与合并点,真实但有网状结构
线性,干净
你的提交
原样保留(hash 不变)
全部重写(hash 变化)
冲突处理
一次性集中解决
每个被重放的提交都可能触发一轮
push 到 origin
正常 push
需要 force push
定制提交多时
复杂度恒定
复杂度随提交数线性增长
  • *长期商业 fork 用 merge,不用 rebase。**理由:你的定制提交会越积越多,rebase 每次同步都要为全部历史重放解冲突,成本递增;merge 只处理增量。force push 对单人仓库虽可接受,但会破坏基于旧 hash 的一切(tag、CI 记录、本地其他分支的基准)。

六、特殊情况:上游重写了历史

如果上游在大版本升级期间重建了整个提交树,新旧历史可能没有共同祖先。此时的表现是:
merge base 不存在,三方合并的前提失效。处理选项:
  1. 重新基于新历史开始(推荐,本项目已采用):以新上游为基线重新 clone,把自己的定制作为补丁迁移过去。适用于自己的定制还不多的阶段
  1. git merge upstream/main --allow-unrelated-histories:强行合并两棵无关的树,Git 会把"文件相同路径不同内容"全部报为冲突,几乎等于手工对比整个仓库。定制量大、无法重来时的下策
  1. git diff 导出自己的全部定制为补丁,在新树上 git apply:定制集中在少数文件时比方案 2 干净
预防性习惯:每次 fetch 后如果发现 git log main..upstream/main 输出的提交数异常巨大,先检查 git merge-base main upstream/main 是否还存在,再决定动作。

七、常用排查命令速查

2026.07.22 21:44 沪·赵巷
📌 声明:本文由 AI 辅助完成