GitHub Pull Request 完整教程:从 Fork、提交 PR 到同步上游仓库
在参与 GitHub 开源项目时,一个非常常见的工作流是:
Fork 别人的项目 → 修改代码 → 提交 Pull Request → 作者 Review → Merge 或 Reject → 继续同步原项目
第一次接触这套流程时,很容易遇到一些问题:
- Fork 之后到底应该在哪个分支修改?
- Pull Request 被拒绝了,我写的代码会怎么样?
- 作者只接受我的部分修改怎么办?
- PR 合并以后,我自己的 Fork 怎么处理?
- 原作者继续更新项目后,我的 Fork 怎么同步?
- 如果我的 Fork 已经有自己的修改,还能不能继续跟原项目保持同步?
merge和rebase到底应该怎么选?
这篇文章从头梳理一遍 GitHub Fork + Pull Request 的完整工作流。
一、先理解三个仓库:upstream、origin 和 local
假设 GitHub 上有一个开源项目:
1 | alice/project |
我希望参与这个项目,于是在 GitHub 上点击 Fork。
GitHub 会在我的账号下创建一份副本:
1 | alice/project |
然后,我再把自己的 Fork 克隆到电脑:
1 | alice/project |
因此整个开发过程中,其实存在三个仓库:
1 | ① upstream |
这是理解整个 Pull Request 工作流最重要的基础。
通常约定:
1 | origin = 自己的 Fork |
可以简单记成:
origin 是我的,upstream 是上游作者的。
二、Fork 项目后的第一次配置
首先,在 GitHub 页面点击:
1 | Fork |
得到自己的仓库:
1 | pepper/project |
然后克隆自己的 Fork:
1 | git clone git@github.com:pepper/project.git |
此时执行:
1 | git remote -v |
通常只能看到:
1 | origin git@github.com:pepper/project.git (fetch) |
因为 Git 目前只知道自己的 Fork 在哪里,并不知道原作者仓库在哪里。
因此需要添加一个 upstream:
1 | git remote add upstream https://github.com/alice/project.git |
再次检查:
1 | git remote -v |
应该类似:
1 | origin git@github.com:pepper/project.git (fetch) |
以后只需要记住:
1 | origin → 我的 Fork |
三、最重要的原则:不要直接在 main 上开发
这是整个工作流中最值得养成的习惯:
自己的
main尽量永远与原作者的main保持一致。自己的功能修改全部放到单独的 branch。
假设准备增加:
1 | CSV 导出功能 |
不推荐:
1 | main |
更推荐:
1 | main |
首先切换到 main:
1 | git switch main |
获取原作者最新代码:
1 | git fetch upstream |
同步到本地:
1 | git merge upstream/main |
然后创建功能分支:
1 | git switch -c feature/csv-export |
此时提交历史可以理解成:
1 | A──B──C |
其中:
1 | A B C = 原作者代码 |
而:
1 | main → C |
这样 main 是干净的,而自己的功能独立存在。
四、修改完成后提交代码
修改完成后:
1 | git status |
查看发生了哪些变化。
然后:
1 | git add . |
提交:
1 | git commit -m "feat: add CSV export support" |
接下来把功能分支推送到自己的 GitHub Fork:
1 | git push -u origin feature/csv-export |
此时 GitHub 上自己的仓库就有:
1 | pepper/project |
注意,这一步只是:
1 | 本地 feature/csv-export |
代码还没有进入原作者的仓库。
五、创建 Pull Request
Push 完成后,GitHub 通常会提示:
1 | Compare & pull request |
点击以后,需要确认两个方向:
1 | base repository: |
它表达的意思是:
1 | pepper/project:feature/csv-export |
也就是:
请求原作者把我的
feature/csv-export修改合并进他的main。
这就是 Pull Request,简称 PR。
六、Pull Request 提交之后会发生什么?
作者可以对 PR 进行 Review。
常见结果包括:
1 | Approve |
实际开发中,大致可以分成下面几种情况。
七、情况一:作者完全接受 PR
假设原作者原来的代码是:
1 | A──B──C |
我的功能分支增加:
1 | A──B──C──D──E──F |
作者认为这些修改没有问题,于是:
1 | Approve |
那么这些修改就会进入原作者的 main。
概念上可以理解成:
1 | 原来: |
实际 Git 历史可能有所不同,因为 GitHub 支持:
- Create a merge commit
- Squash and merge
- Rebase and merge
但从代码结果来看,你的贡献已经进入 upstream。
八、情况二:PR 被拒绝会怎么样?
假设:
1 | upstream/main |
而我的分支:
1 | feature/csv-export |
提交 PR 后,作者认为这个功能不适合项目,于是关闭 PR:
1 | Close Pull Request |
这时候:
你的代码不会消失。
原作者还是:
1 | upstream/main |
而你的 Fork 仍然可以是:
1 | origin/main |
同时:
1 | origin/feature/csv-export |
所以:
PR 被拒绝,只代表作者没有把你的代码合并进他的项目,并不代表你的代码被删除。
你的 branch、commit 和代码都还属于你。
九、PR 被拒绝以后,不想要这些代码怎么办?
如果这个功能以后也不需要了,可以直接删除 branch。
首先回到:
1 | git switch main |
删除本地分支:
1 | git branch -d feature/csv-export |
如果 Git 因为分支没有被合并而拒绝删除,可以强制删除:
1 | git branch -D feature/csv-export |
然后删除自己 GitHub Fork 上的远程分支:
1 | git push origin --delete feature/csv-export |
这样就恢复干净了。
十、如果 PR 被拒绝,但我自己还想继续用这个功能呢?
那就什么都不用做。
继续保留:
1 | feature/csv-export |
即可。
这也是 Fork 非常重要的意义之一。
原作者可以维护:
1 | alice/project |
而你完全可以维护自己的魔改版本:
1 | pepper/project |
两者不完全一致没有任何问题。
十一、作者只接受部分修改怎么办?
假设一个 PR 里包含:
1 | A 功能 |
作者 Review 后认为:
1 | A 👍 |
一般不是简单地在 GitHub 上把 C “切掉”,更常见的工作流是作者:
1 | Request changes |
然后告诉你:
A、B 可以,但是请删除 C。
这时候不需要关闭 PR,也不需要重新创建 PR。
直接继续修改当前:
1 | feature/xxx |
把 C 删除。
然后:
1 | git add . |
因为 PR 跟踪的是这个 branch,所以:
继续向这个 branch push,原来的 PR 会自动更新。
于是 PR 最终可能只剩:
1 | A |
作者重新 Review:
1 | Approve |
即可。
十二、原作者更新项目以后,我的 Fork 怎么同步?
这是 Fork 项目以后最常见的操作之一。
假设最初:
1 | upstream/main |
自己的 Fork:
1 | origin/main |
两边完全一致。
后来作者继续开发:
1 | upstream/main |
但是自己的 Fork 还是:
1 | origin/main |
GitHub 此时可能显示:
1 | This branch is 3 commits behind |
也就是说自己的 Fork 落后于 upstream。
十三、通过 Git 命令同步 upstream
首先切换到 main:
1 | git switch main |
获取原作者最新信息:
1 | git fetch upstream |
这里要特别注意:
1 | git fetch upstream |
并不会直接修改自己的 main。
它只是告诉 Git:
去 upstream 看一下有没有新东西,有的话下载回来。
此时 Git 知道:
1 | upstream/main |
但是本地:
1 | main |
仍然没有变化。
接下来:
1 | git merge upstream/main |
本地 main 就变成:
1 | A──B──C──D──E──F |
最后:
1 | git push origin main |
把本地最新版同步到自己的 GitHub Fork。
于是:
1 | upstream/main |
重新保持一致。
所以,日常同步 upstream 最值得记住的是这四条:
1 | git switch main |
十四、也可以直接在 GitHub 网页同步 Fork
如果自己的 Fork 落后于原仓库,GitHub 通常会显示:
1 | This branch is X commits behind xxx:main |
并提供:
1 | Sync fork |
点击:
1 | Sync fork |
GitHub 就会帮助同步。
如果只是单纯希望自己的 main 与原作者保持一致,而且自己的 main 没有什么魔改,那么这种方式非常方便。
十五、为什么不要直接修改自己的 main?
现在就可以理解为什么前面一直强调:
main 尽量只用来同步 upstream。
假设:
1 | upstream/main |
你直接在自己的 main 修改:
1 | origin/main |
后来作者又提交:
1 | upstream/main |
此时历史就出现分叉:
1 | X──Y ← 我的修改 |
这时候再同步 upstream,就需要处理:
1 | merge |
如果双方恰好修改了同一部分代码,还可能出现:
1 | merge conflict |
随着时间越来越长,维护成本也会越来越高。
十六、正确方式:main 跟踪 upstream,功能放 branch
如果从一开始就创建独立分支:
1 | X──Y |
那么:
1 | X Y = 我的 feature |
此时可以直接更新 main:
1 | git switch main |
于是:
1 | main |
而自己的:
1 | feature/my-feature |
仍然独立存在。
两件事情互不干扰。
十七、正在开发 Feature 时,upstream 更新了怎么办?
这是另一个非常常见的情况。
假设开始开发时:
1 | A──B──C |
其中:
1 | X Y = 我的修改 |
但是在我开发过程中,原作者继续提交:
1 | A──B──C──D──E──F |
现在可以理解成:
1 | X──Y |
我希望自己的 Feature 也基于作者最新版开发。
最终希望达到:
1 | A──B──C──D──E──F──X──Y |
这时候主要有两种方法:
1 | merge |
或者:
1 | rebase |
十八、方法一:merge upstream/main
首先:
1 | git switch feature/my-feature |
获取 upstream:
1 | git fetch upstream |
然后:
1 | git merge upstream/main |
历史可能变成:
1 | X──Y────M |
其中:
1 | M = merge commit |
这种方式的优点是:
- 容易理解
- 不改写已有 commit 历史
- 相对安全
缺点是:
- commit graph 可能比较复杂
- feature branch 可能出现很多 merge commit
对于 Git 初学者,这通常是比较容易理解的方式。
十九、方法二:rebase upstream/main
另一种方式是:
1 | git switch feature/my-feature |
原来的:
1 | X──Y |
经过 rebase 后,可以理解成:
1 | A──B──C──D──E──F──X'──Y' |
也就是说 Git 把自己的:
1 | X |
重新放到了最新版:
1 | F |
后面。
可以把 rebase 理解成:
假装我当初就是从 upstream 最新版本开始写 X 和 Y 的。
因此历史会非常干净。
二十、为什么 rebase 后有时候要 Force Push?
因为 rebase 会重新生成 commit。
原来:
1 | X |
rebase 后实际上变成:
1 | X' |
它们虽然可能包含类似的代码,但 Git commit hash 已经不同。
所以如果之前已经:
1 | git push origin feature/my-feature |
rebase 后普通:
1 | git push |
可能会被 Git 拒绝。
此时通常需要:
1 | git push --force-with-lease |
推荐使用:
1 | --force-with-lease |
而不是直接:
1 | --force |
因为前者会多做一层检查,相对更加安全。
二十一、完整 Pull Request 工作流
整个过程可以总结为:
1 | GitHub |
其中最重要的是:
1 | upstream/main |
尽量保持一致。
而自己的工作:
1 | main |
全部独立开发。
二十二、一个完整的实战例子
假设我要给:
1 | github.com/alice/medical-ai |
贡献代码。
Fork 后,我自己的仓库是:
1 | github.com/pepper/medical-ai |
Step 1:Clone 自己的 Fork
1 | git clone git@github.com:pepper/medical-ai.git |
Step 2:添加 upstream
1 | git remote add upstream https://github.com/alice/medical-ai.git |
检查:
1 | git remote -v |
Step 3:同步 main
1 | git switch main |
Step 4:创建功能分支
1 | git switch -c feature/add-dicom-support |
Step 5:修改代码并 Commit
1 | git add . |
Step 6:Push 到自己的 Fork
1 | git push -u origin feature/add-dicom-support |
Step 7:创建 Pull Request
在 GitHub 创建:
1 | pepper/medical-ai:feature/add-dicom-support |
Step 8:根据 Review 修改
如果作者提出修改意见:
1 | # 修改代码 |
原来的 PR 会自动更新。
Step 9:作者 Merge
作者:
1 | Approve |
代码进入 upstream。
Step 10:重新同步 main
1 | git switch main |
Step 11:删除已经完成的 Feature Branch
1 | git branch -d feature/add-dicom-support |
最终重新回到:
1 | upstream/main |
整个工作区重新变得干净。
二十三、如果 PR 被拒绝,完整处理流程
如果作者关闭 PR:
1 | Pull Request |
原作者的代码没有变化。
首先同步自己的 main:
1 | git switch main |
如果这个 Feature 自己也不要了:
1 | git branch -D feature/add-dicom-support |
如果自己还需要这个功能,那么直接保留:
1 | feature/add-dicom-support |
即可。
它不会影响 main 继续跟踪 upstream。
二十四、常用命令速查
查看远程仓库
1 | git remote -v |
添加 upstream
1 | git remote add upstream https://github.com/作者/项目.git |
获取 upstream 最新代码
1 | git fetch upstream |
同步 main
1 | git switch main |
创建 Feature Branch
1 | git switch -c feature/xxx |
提交代码
1 | git add . |
第一次 Push Feature Branch
1 | git push -u origin feature/xxx |
后续更新 PR
1 | git add . |
Feature 同步 upstream:Merge
1 | git switch feature/xxx |
Feature 同步 upstream:Rebase
1 | git switch feature/xxx |
如果已经 Push:
1 | git push --force-with-lease |
删除本地 Feature Branch
1 | git branch -d feature/xxx |
强制删除:
1 | git branch -D feature/xxx |
删除远程 Feature Branch
1 | git push origin --delete feature/xxx |
二十五、最终需要建立的 Git 思维
如果只记住一条原则,那么就是:
main 用来追踪原作者,branch 用来保存自己的工作,Pull Request 用来把 branch 的修改贡献给原作者。
理想状态下:
1 | upstream/main |
而开发过程则是:
1 | main |
这样做最大的好处是:
main始终保持干净;- upstream 更新后很容易同步;
- 每一个功能互不干扰;
- PR 被拒绝不会影响其他工作;
- PR 被接受后可以直接删除对应 branch;
- 可以同时维护多个 PR;
- 即使自己的 Fork 长期存在,也不容易和 upstream 搞乱。
因此,一个比较成熟的 GitHub Fork 工作流可以概括成:
1 | Fork |
一旦把 upstream、origin、main、feature branch、Pull Request 这五个概念理清楚,GitHub 上绝大多数开源项目的日常贡献流程就已经基本掌握了。