你已经会 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 上去,效果和点按钮一样(前提是你有写权限,而且推得上去)。