前面三篇都在同一个仓库里打转:暂存区、分支、合并。现在场景变了——同一份代码,服务器上有一份,你机器上一份,同事机器上还有一份。你敲的 git pushgit pull 到底在和谁说话、改变了哪里的什么?这一篇把这个问题讲清楚。

origin 只是一条记录

先破一个常见的错觉:origin 不是什么特殊的东西,更不是“服务器的名字”。它是你的仓库里一条普通配置,写在工作目录下 .git/config 的一个小节里:

1[remote "origin"]
2	url = https://github.com/you/project.git
3	fetch = +refs/heads/*:refs/remotes/origin/*

git remote -v 能直接看到这段内容。git clone 做的事就是:把代码下载下来,同时自动加上这一段配置,并且给这个 remote 起个名字叫 origin——名字是自动取的,你可以改(git remote rename),也可以一个仓库配好几个 remote,起别的名字 5

所以一句 git push origin main,前半截的 origin 只是让 Git 去查上面这段配置、拿到 URL 而已。名字换了,行为一样。

你手上并没有远程的分支,只有一份本地书签

再看上面那行 fetch。它的形式是 源:目的地

  • 冒号左边 refs/heads/* 指的是 远程那侧 的分支命名空间;
  • 冒号右边 refs/remotes/origin/* 指的是 你本地 的存放位置;
  • 开头的 + 表示即使更新不是快进也照更不误 4

换句话说,每次 fetch,Git 都在做一件事:把远程 refs/heads/ 下的每个分支,映射成你本地 refs/remotes/origin/ 下的同名引用。

还记得上一篇的结论吗——分支说到底就是 .git/refs/heads/ 里的一个文件,里面写着一个提交哈希。origin/main 用的是同一套机制,只是放在 .git/refs/remotes/origin/ 下。它和本地分支有两点关键区别:

  1. 你不直接改它,Git 只在你与远程通信时替你更新;
  2. 它记的 不是“远程现在是什么”,而是“我上次和远程通信时远程是什么”

这就是“远程跟踪分支”(remote-tracking branch)。Pro Git 里管它叫书签,很贴切 1。它可能已经过期:同事十分钟前推了新提交,只要你没 fetch,你的 origin/main 就还指着旧位置,你也完全看不见他的提交。

git branch -r 能看到它们,典型输出是:

1  origin/HEAD -> origin/main
2  origin/main

那个 origin/HEAD 是个符号引用,标记远程的默认分支是哪个。origin/main 本身是个不能在上面提交的引用,要基于它干活,用 git switch -c 新分支 origin/main;如果远程存在和本地分支同名的分支,git switch 那个名字 时 Git 也会自动帮你建好。

fetch 只做两件事,都不碰你的代码

git fetch origin 的行为可以拆成两步 2

  1. 问远程:你每个分支现在指向哪个哈希?把本地缺的提交对象下载进来;
  2. refs/remotes/origin/* 这些书签挪到新位置。

就这些。它 不碰 refs/heads/main,不碰工作区文件,也不碰暂存区(索引)。所以 fetch 完之后,你打开编辑器会发现一个字节都没变——只有书签动了。你能“看见”别人的提交,是因为书签指过去了,git log origin/main 这条路走得通了。这正好接上前两篇的那条主线:git log 只显示从某个起点能到达的提交,而能不能到达,取决于有没有引用指过去。

push 是反方向,而且有前提

git push origin main 展开写是 refs/heads/main:refs/heads/main:把我这些提交送上去,然后要求远程的 main 指针挪到它。这个“挪指针”必须能快进,否则会被拒绝并提示 non-fast-forward——因为硬挪会丢掉远程上别人已有的提交 4

push 成功之后,你本地的 origin/main 通常也会跟着更新,因为远程刚接受了什么,Git 心里有数。

pull = fetch + 第二步

这是本篇最需要分清的一处。

git pull 不带参数时,先执行一次 fetch(带上同样的参数,只是去掉合并相关的选项),然后 在当前分支上 决定要整合哪个远程分支——默认就是当前分支的 upstream。接着才动手 3

这里的 upstream(上游分支)就是配置里的两条:

1[branch "main"]
2	remote = origin
3	merge = refs/heads/main

clone 时自动写好,意思是“我的 main 对应 origin 的 main”。git branch -vv 能看到 [origin/main] 以及 ahead/behind 的数字 1

第二步具体干什么,由选项或配置决定:--ff-only--rebase--no-rebase(也就是 merge)、--squash 四条路 3。如果走 merge,那就是上一篇讲的那整套:能快进就挪指针,不能快进就三路合并,两头改同一块就冲突、停在半路等你去 git addgit commit

所以两者的区别可以一句话概括:

  • fetch 是只读的侦察,看完你自己决定怎么办;
  • pull 是侦察加立刻动手,而且是按默认策略直接改你的当前分支。

新手对远程的很多困惑,都来自这一条命令把两件性质完全不同的事捆在了一起。

“pull 会自己帮我合并好”这个印象已经过时

老版本的默认行为等价于“能快进就快进,不能快进就生成合并提交”。从 Git 2.27(2020 年)起,Git 开始要求你表明态度——没配过 pull.rebasepull.ff 时,它会打印一段提示:

1  git config pull.rebase false  # merge (the default strategy)
2  git config pull.rebase true   # rebase
3  git config pull.ff only       # fast-forward only

之后几个版本里这段提示调整过:改成只在历史真的分叉时才出现,并且从“警告”升级为直接中止:

1fatal: Need to specify how to reconcile divergent branches.

现在的官方手册把 --ff-only 列为“没有指定调和方式时的默认”:能快进就快进,不能快进就停下来报错 36。也就是说,默认不再替你生成合并提交了。

所谓“分叉”(diverged),就是你的 mainorigin/main 各自都有了对方没有的提交。这时候 Git 要你明确选:

  • git merge origin/main:生成一个合并提交,就是上一篇的 non-fast-forward 情形;
  • git rebase origin/main:把你的本地提交搬到远程提交之后,历史变成一条直线,但本地提交会被重写成新哈希(rebase 留到以后细讲);
  • git reset --hard origin/main:直接丢弃本地提交,只在确定不要的时候用。

想省掉每次表态,也可以配一次 git config --global pull.rebase false,让它继续按 merge 走。

对刚上手的人,更稳的流程是把两步拆开:先 git fetch,用 git statusgit branch -vvgit log --oneline --graph origin/main 看清差了多少,再自己决定 git merge origin/main 还是 git rebase origin/main。这样网络操作不会顺手改掉你的工作区。

三个位置之间的同步循环

说到底,远程协作里有三个各自独立的“main”:

  • 你本地的分支:.git/refs/heads/main
  • 你的书签:.git/refs/remotes/origin/main
  • 服务器上的分支:origin 那侧的 refs/heads/main
绘制中

git status 里那句 “Your branch is ahead of 'origin/main' by 2 commits”,比的就是本地分支和书签;换成 behind,就是书签跑到前面去了。这也解释了一个常见现象:你在 fetch 之后又写了很久代码,期间别人推了新提交,这时你 push 会被拒绝——因为书签已经过期,Git 判断不了你是不是会覆盖别人的东西。办法还是先 git fetch 再整合。

一句话收束

origin 只是一条“名字加 URL 加一条映射规则”的配置;origin/main 是你本地的书签,随时可能过期,只能靠 fetch 更新;fetch 只下载和更新书签,pull 则在 fetch 之后立刻按默认策略改动你的当前分支——而这个默认策略现在要求你先表态。把 fetch 和 pull 分开看,远程同步就不再是黑箱。