前面三篇都在同一个仓库里打转:暂存区、分支、合并。现在场景变了——同一份代码,服务器上有一份,你机器上一份,同事机器上还有一份。你敲的 git push 和 git 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/ 下。它和本地分支有两点关键区别:
- 你不直接改它,Git 只在你与远程通信时替你更新;
- 它记的 不是“远程现在是什么”,而是“我上次和远程通信时远程是什么”。
这就是“远程跟踪分支”(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:
- 问远程:你每个分支现在指向哪个哈希?把本地缺的提交对象下载进来;
- 把
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/mainclone 时自动写好,意思是“我的 main 对应 origin 的 main”。git branch -vv 能看到 [origin/main] 以及 ahead/behind 的数字 1。
第二步具体干什么,由选项或配置决定:--ff-only、--rebase、--no-rebase(也就是 merge)、--squash 四条路 3。如果走 merge,那就是上一篇讲的那整套:能快进就挪指针,不能快进就三路合并,两头改同一块就冲突、停在半路等你去 git add 和 git commit。
所以两者的区别可以一句话概括:
- fetch 是只读的侦察,看完你自己决定怎么办;
- pull 是侦察加立刻动手,而且是按默认策略直接改你的当前分支。
新手对远程的很多困惑,都来自这一条命令把两件性质完全不同的事捆在了一起。
“pull 会自己帮我合并好”这个印象已经过时
老版本的默认行为等价于“能快进就快进,不能快进就生成合并提交”。从 Git 2.27(2020 年)起,Git 开始要求你表明态度——没配过 pull.rebase 或 pull.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),就是你的 main 和 origin/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 status、git branch -vv 或 git 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 分开看,远程同步就不再是黑箱。