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:原理
执行时发生三件事:
- 协商:本地与远端交换引用列表,计算出远端有而本地没有的 commit 集合
- 下载对象:把缺失的 commit、tree、blob 对象打包传输,存入
.git/objects(只增不减,不碰你的任何本地分支和工作区)
- 更新引用:按 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.plist、project.pbxproj、本地化文件)几乎每次都会撞。冲突时的判断原则:功能代码尽量采上游,品牌/配置坚持采本地。对
project.pbxproj 这类机器生成文件,逐行手解冲突容易解坏,更稳的做法是整体取一方再手工补另一方的改动:四、完整工作流
4.1 标准同步流程
4.2 建议的合并纪律
- 小步快跑:每 2–4 周同步一次。攒半年再合,冲突规模会从"十分钟"膨胀到"两天"
- 合并前打标记:
git tag before-sync-$(date +%Y%m%d),合并出问题可以随时git reset --hard <tag>整体回退
- merge 后必须构建验证再 push:三方合并在文本层面成功不代表语义正确(上游改了函数签名、你这边的调用点不冲突但编译不过,是最常见的翻车方式)
- 永远不要向 upstream push:
git 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 不存在,三方合并的前提失效。处理选项:- 重新基于新历史开始(推荐,本项目已采用):以新上游为基线重新 clone,把自己的定制作为补丁迁移过去。适用于自己的定制还不多的阶段
git merge upstream/main --allow-unrelated-histories:强行合并两棵无关的树,Git 会把"文件相同路径不同内容"全部报为冲突,几乎等于手工对比整个仓库。定制量大、无法重来时的下策
- 用
git diff导出自己的全部定制为补丁,在新树上git apply:定制集中在少数文件时比方案 2 干净
预防性习惯:每次 fetch 后如果发现
git log main..upstream/main 输出的提交数异常巨大,先检查 git merge-base main upstream/main 是否还存在,再决定动作。七、常用排查命令速查
2026.07.22 21:44
沪·赵巷
📌 声明:本文由 AI 辅助完成