Git:分布式协作——远程、Fork 与上下游管理

git push 失败,提示 “rejected… non-fast-forward”。直觉上觉得是”服务器拒绝了我的代码”——但实际发生的是两个独立的对象数据库在同步时发现了分歧,Git 默认不允许覆盖另一端已经存在的历史。

把 remote 理解成”服务器上的文件夹”,push 被 reject 的原因就永远是谜。理解了 Git 的分布式模型,push/pull/fetch 的每一个行为都有直接的解释。

每个 clone 都是一个完整仓库

SVN 这类集中式版本控制系统的模型是:有一个中央服务器存储完整历史,开发者把需要的版本”签出”到本地,本地只有工作副本,没有完整历史。提交、查历史、对比版本——所有操作都需要和服务器通信。

Git 的模型是另一回事。git clone 做的不是”签出”,是完整复制——把远程仓库的所有 objects 和 refs 全部下载到本地,本地得到的是一个功能完整的仓库,而不是某个版本的工作副本。

这个设计的推论:

断网可以提交,可以查整个历史,可以切换任意分支,可以 rebase。所有这些操作只和本地的 objects 库交互,不依赖网络。网络只在需要和其他仓库同步时才用到。

任意两个 Git 仓库之间都可以互相同步,不需要通过”中央服务器”中转。所谓的 GitHub 仓库在概念上和你本地的仓库没有区别,只是一个恰好被所有人约定为”权威”的副本,技术层面它和你本地的仓库是平等的。

“远程仓库”这个词本身有一点误导性——remote 不是一个特殊的东西,是另一个仓库。git remote add origin <url> 做的只是把那个仓库的地址存成一个名字,后续 push/fetch 的时候用这个名字作为目标。

对象如何在两个仓库之间流动

理解了每个 clone 是完整仓库,push/pull/fetch 的机制就清楚了:它们是两个 KV 对象库之间的同步操作。

git fetch 的工作:把远程有、本地还没有的 objects 下载过来,然后更新本地的远程追踪引用(refs/remotes/origin/*)。本地的分支不受影响,工作区不受影响。fetch 是纯粹的”获取”,不会改变你当前的任何状态。

git pull 是 fetch 加上一步合并:先把远程的新 objects 下载到本地,再把远程分支的最新 commit 合并进当前分支。git pull --rebase 把合并改成 rebase,把本地还没推送的提交接到远程最新处,保持线性历史。

git push 的工作:把本地有、远程还没有的 objects 传过去,然后请求更新远程的 refs。“请求”这个词是关键——远程可以拒绝。

push 被拒绝最常见的原因是 fast-forward 检查失败:远程分支已经有了本地没有的 commit。如果 Git 直接把远程的指针移动到本地的最新 commit,远程那些新 commit 就会失去引用,等于被静默丢弃了。Git 默认拒绝这种操作,要求你先把远程的新内容整合进来,再推送。

# 先整合远程的新提交,再推送
git pull --rebase origin main
git push
 
# 或者 fetch 之后手动 rebase
git fetch origin
git rebase origin/main
git push

SSH 认证:连接远程仓库的基础

Git 支持 HTTPS 和 SSH 两种协议连接远程仓库。HTTPS 每次操作都需要输入账号密码(或者用凭证管理工具缓存),SSH 用密钥对认证,配置一次之后不需要重复输入,是团队开发里更常用的方式。

生成 SSH 密钥对

ssh-keygen -t ed25519 -C "[email protected]"
# 推荐用 ed25519,比 RSA 更短、更快、安全性更好
# -C 是注释,方便识别密钥用途,填邮箱是惯例

执行后在 ~/.ssh/ 生成两个文件:id_ed25519(私钥,绝不分享给任何人)和 id_ed25519.pub(公钥,要添加到 GitHub/GitLab)。

添加公钥到 GitHub

复制公钥内容,在 GitHub Settings → SSH and GPG keys → New SSH key 里粘贴。添加完之后验证连接:

ssh -T [email protected]
# 成功时返回:Hi username! You've successfully authenticated...

ssh-agent:密钥的生命周期管理

如果密钥设置了密码(推荐),每次使用时都需要输入。ssh-agent 在内存里缓存解密后的私钥,同一个会话里只需要输入一次:

eval "$(ssh-agent -s)"     # 启动 agent
ssh-add ~/.ssh/id_ed25519  # 添加密钥,输入一次密码

macOS 的 Keychain 集成可以让这个缓存跨会话持久化,在 ~/.ssh/config 里加一行 UseKeychain yes 即可。

多账号场景

同一台机器需要同时使用个人账号和工作账号时,~/.ssh/config 可以为不同的主机配置不同的密钥:

Host github-personal
    HostName github.com
    User git
    IdentityFile ~/.ssh/id_ed25519_personal

Host github-work
    HostName github.com
    User git
    IdentityFile ~/.ssh/id_ed25519_work

克隆时把 github.com 替换成对应的别名:git clone git@github-work:org/repo.git

origin 和 upstream:fork workflow 的上下游模型

开源项目的贡献流程和在自己项目里工作不同,涉及两个 remote。

fork 和 clone 的区别

fork 是在平台上(GitHub/GitLab)创建一个仓库的副本,这个副本属于你,你有完整的写权限。clone 是把仓库下载到本地。贡献开源项目的标准流程是:先 fork 原始仓库到自己的账号,再 clone 自己的 fork 到本地。

这样你有两个关联的仓库:你的 fork(在平台上,你有写权限)和原始仓库(你没有直接推送的权限)。

两个 remote 的配置

# clone 自己的 fork(此时 origin 自动指向你的 fork)
git clone [email protected]:your-username/repo.git
 
# 添加原始仓库作为 upstream
git remote add upstream [email protected]:original-owner/repo.git
 
# 查看当前的 remote 配置
git remote -v
# origin    [email protected]:your-username/repo.git (fetch)
# origin    [email protected]:your-username/repo.git (push)
# upstream  [email protected]:original-owner/repo.git (fetch)
# upstream  [email protected]:original-owner/repo.git (push)

origin 是你自己的 fork,你随时可以推送。upstream 是原始仓库,通常只用来 fetch,不推送。

从 upstream 同步最新代码

原始仓库在持续更新,你的 fork 不会自动同步。需要手动从 upstream 拉取:

git fetch upstream                    # 获取 upstream 的最新内容
git switch main                       # 切到本地 main 分支
git rebase upstream/main              # 把本地 main 接到 upstream 最新处
git push origin main                  # 把同步后的 main 推到自己的 fork

贡献代码的完整路径

# 1. 从最新的 upstream/main 新建功能分支
git fetch upstream
git switch -c feature/my-change upstream/main
 
# 2. 开发、提交
git add .
git commit -m "feat: implement new feature"
 
# 3. 推送到自己的 fork
git push -u origin feature/my-change
 
# 4. 在 GitHub 上从 your-fork:feature/my-change 向 original:main 提 PR
# 5. PR 审查通过后由原始仓库的维护者合并

PR 合并之后,功能分支通常可以删除,下次从 upstream 同步 main 就能看到自己的改动已经在里面了。

分叉历史的处理

本地分支和远程分支都有各自的新提交时,直接 push 会被拒绝。这时有几种处理方式,选哪种取决于分支的性质和历史整洁度的要求。

git pull(fetch + merge)

拉取远程新提交,和本地分支做 merge,产生一个 merge commit。历史完整保留了”本地和远程曾经分叉又汇合”这个事实,但如果发生频繁,历史图会有很多合并节点。

git pull —rebase

拉取远程新提交,把本地还没推送的提交接到远程最新处。历史保持线性,看起来像是本地的提交本来就是在远程最新基础上做的。适合个人分支的日常维护,保持历史干净。

force push

有时候不是因为远程有新提交,而是因为本地做了 rebase 或 amend——改写了历史,导致本地和远程的 SHA 不一致,普通 push 会被拒绝。这时需要 force push:

git push --force-with-lease    # 推荐:比 --force 更安全
git push --force               # 不推荐,有覆盖他人工作的风险

--force-with-lease 在推送前检查远程分支的当前状态——如果远程已经被别人更新了(和你上次 fetch 时的状态不一样),它会拒绝推送。--force 没有这个检查,会直接覆盖远程,不管中间有没有人推过新的提交。

force push 的使用边界:只在自己独占的分支上使用。私有功能分支、个人 fork 的分支——可以。主干、任何其他人已经基于它工作的分支——绝对不行。force push 到共享分支会导致其他人的本地历史与远程出现不可调和的分歧,他们不得不重新 clone 或者手动修复。

跨仓库依赖:Submodule 与 Subtree

项目依赖另一个 Git 仓库(共享库、设计资源、第三方组件)时,有两种管理方式,核心取舍是指针还是副本

Submodule 在主仓库里记录另一个仓库的特定 commit SHA,嵌入的是一个指针,不是代码本身。主仓库的 clone 默认不包含 submodule 的内容,需要额外初始化:

git submodule add [email protected]:org/lib.git libs/shared
git clone --recurse-submodules <url>    # clone 时同时初始化 submodule
git submodule update --init --recursive # 已有 clone 时初始化

Submodule 的版本锁定是精确的——主仓库记录的是具体的 commit SHA,不会随依赖库的更新自动变化。适合需要精确锁定版本、不打算修改依赖代码的场景。代价是操作流程复杂,协作者经常忘记初始化或更新 submodule。

Subtree 把另一个仓库的代码直接合并进主仓库的子目录,代码完全在主仓库里,普通 clone 就能得到所有内容,没有额外操作:

git subtree add --prefix=libs/shared [email protected]:org/lib.git main --squash
git subtree pull --prefix=libs/shared [email protected]:org/lib.git main --squash

Subtree 对协作者透明,不需要知道哪部分代码来自外部仓库。代价是依赖代码混入主仓库历史,更新时需要手动同步,双向修改(在主仓库修改依赖代码并推回)操作复杂。

两种方案都没有绝对优劣:不需要修改依赖、需要精确版本控制——用 Submodule;需要简单的协作者体验、偶尔可能修改依赖——用 Subtree。大多数现代项目用包管理工具(npm、pip、cargo)管理代码依赖,Submodule 和 Subtree 更多用于非代码资源或者特殊的跨仓库场景。

关联