你已经会 add、commit、push 了。真正进团队那天,第一件事通常是“开个 PR 吧”。

在 GitHub 上填个标题点一下,页面出现编号 #7,底下写着 feature → main,还有一串检查状态和一个绿按钮 Merge pull request。你点了,再回本地,git status 说你的 main 落后 1 个提交。

两个地方想不通。第一,Git 的命令表里没有 pull-request,那这个按钮是谁在合并?第二,合都合完了,为什么我的电脑什么都不知道?

这篇就回答这两个问题。答案的关键是一句话:PR 是托管平台上的东西,合并仍然是 Git 的合并,只是执行的人换成了服务器。

Git 在两台机器之间只会干两件事

先回想你已经用熟的部分。push 是把你这边对方没有的提交送过去,然后请求对方挪一个指针;fetch 是把对方的提交取下来,然后挪你本地的 origin/main。都是“传提交 + 挪指针”,方向相反而已。

这里没有任何“申请”“评审”“批准”的位置。Git 是一个传输协议,它不关心你为什么要挪这个指针。历史上这套流程是靠邮件完成的:写好改动、导出成补丁发到邮件列表、等维护者回一句“应用了”。(git request-pull 是那个年代的遗留物,只负责拼一段说明文字。)

PR 是平台在仓库边上多加的一层记录

GitHub、GitLab 除了跑一个 Git 服务,还跑着一个网站和一个数据库。你点“New pull request”时,它做的事是:在数据库里新建一条记录,写明 base 分支(要合进哪儿)和 head 分支(从哪儿来),给它编号 7,然后把之后的评论、评审意见、CI 结果统统挂在这条记录上。

这条记录不属于 Git。GitLab 管它叫 merge request,指的是同一个位置上的同一样东西,只是名字不同。

但它也不是一张纯表单。你开 PR 的那一刻,平台顺手在仓库里造了两条 只读 引用:

  • refs/pull/7/head,指向 head 分支的最新提交;
  • refs/pull/7/merge,指向一个“假装现在就合并”的模拟合并提交(有冲突时这条不生成)。

所以别人的 PR 你能拿到本地跑:

1git fetch origin pull/7/head:review7
2git switch review7

这说明 PR 里的内容仍然是提交和引用,只是平台替你多存了一份,还给它编了个号。这两条引用只读,你往里推会被拒(deny updating a hidden ref)。

“能不能合并”是服务器自己算的

PR 页面上那句 “This branch has no conflicts with the base branch”,不是装饰。服务器在它自己那份仓库里算了一遍:head 是不是 base 的后代?能快进吗?如果不能,两边的共同祖先是谁,三路合并有没有冲突?

就是第三篇讲合并时的那套算法,只是执行的人从你的笔记本换成了服务器。

注意它算的是 此刻。别人的 PR 先合进去,你这个判断就过期了,页面会变成有冲突,或者提示你 Update branch。这不是平台在故意刁难,是共同祖先确实变了。

点下去那一下,服务器动了哪个指针

点 Merge pull request,服务器在自己的仓库上执行一次合并,然后挪 refs/heads/main。三种按钮的区别,就在于它执行哪一种:

绘制中
  • Create a merge commit 是默认项,等于 --no-ff:哪怕能快进,也硬造一个有两个 parent 的合并提交,你原来那些提交全部保留。
  • Squash and merge 把 PR 里所有提交压成一个新提交放到 base 头上。这个新提交的父提交就是当前 base 的头,所以指针直接前移,属于快进。
  • Rebase and merge 把每个提交挨个重放到 base 上,也是快进,但每个提交都换了新哈希。它和本地 git rebase 还有一点细微差别:GitHub 会重写 committer 信息、丢掉原本就是空的提交。

三者共同的地方是:真正被改写的只有服务器仓库里的 refs/heads/main。“PR 被合并了”在 Git 层面就是一句话——服务器上那个指针挪到了一个包含你改动的位置。

所以本地必须自己同步一次

合并发生在服务器的仓库里,跟你这台机器没有任何通信。合并那一刻,你本地什么都没有变:main 还指着开分支之前那个提交,origin/main 还停在上次通信时的位置。

后一件事在讲远程仓库那篇里说过:origin/main 只是本地书签,只在 fetch 和 push 时更新(远程仓库到底连的是什么)。服务器挪了指针,不会有人来通知你,也没有谁该通知——两个仓库之间没有这种推送关系。

1git switch main
2git pull            # 拉下新提交,把 main 指针前移

如果你当时还在功能分支上,只跑 git fetch origin,那更新的只是 origin/main,你的本地 main 依旧是旧的,得再把它快进过去才会动。

一个跟着来的坑:squash 之后,你本地的提交还在

用合并提交那种方式,你原来的提交对象进了 main,从 main 能走过去。squash 和 rebase and merge 都是造了新提交,你本地那些旧提交则留在原地,从 main 一条路也走不到它们——虽然内容已经在里面了。

这会造成几个很别扭的现象:

  • git branch -d feature 可能拒绝删除,因为它判断的是“这些提交对象是否已被合并”,而不是“内容是否已在里面”;
  • 你如果接着在这个分支上改,再开一个 PR,之前已经合过的提交会重新出现在新 PR 的提交列表里,还可能重复撞上同样的冲突。GitHub 官方文档专门提醒了这一点;
  • 想强推清理,就回到推送被拒那篇的规则:分支是你自己的,--force-with-lease 可以;分支是共享的,就老实重新开一条。

省事的做法是:合并后就在页面上把远端分支删掉,本地切回 main、pull,再 git branch -D feature,下次从最新的 main 重新开分支。

收束

PR 是平台上的流程记录:编号、描述、评论、评审、检查,这些都真实存在,也确实是团队协作真正在用的东西。但“合并”不是它的特权。它替你做的还是 merge 那套事——找共同祖先、比对三方、挪指针——只是发生在服务器上。

想通这一点,那一整串现象就好解释了:合并后本地要自己 pull;PR 关闭不等于分支被删;反过来,你在本地手工 merge 再 push 上去,效果和点按钮一样(前提是你有写权限,而且推得上去)。