参与 GitHub 上的开源项目时,我们通常没有原仓库的直接写权限。标准的协作方式不是把代码直接推送到原作者的仓库,而是先将项目 Fork 到自己的 GitHub 账号,再把自己的 Fork 克隆到本地。完成开发后,将功能分支推送到自己的 Fork,最后向原仓库发起 Pull Request。

本文不再孤立地罗列 Git 命令,而是通过一个完整案例,把从 Fork 到 Pull Request 合并后的整个流程串联起来。


1. 本文的协作场景

假设 GitHub 上有一个任务管理项目:

1
2
3
4
原作者仓库:https://github.com/octo-org/task-board
默认分支:main
待修复问题:Issue #128,创建任务时允许提交空标题
贡献者账号:xiaoming

我们要完成的任务是:修复空标题校验,补充测试,并向原项目提交 Pull Request。

最终会涉及三份仓库:

位置 仓库 作用
GitHub 原仓库 octo-org/task-board 项目的正式仓库,通常称为上游仓库
GitHub 个人 Fork xiaoming/task-board 自己拥有写权限的远程仓库
本地仓库 电脑中的 task-board/ 编写、测试和提交代码的地方

完整的数据流如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
octo-org/task-board(原仓库)

│ Fork

xiaoming/task-board(个人远程仓库)

│ Clone

本地 task-board(创建功能分支并开发)

│ Push

xiaoming/task-board:fix/issue-128-empty-title

│ Pull Request

octo-org/task-board:main

后文中的仓库名和账号都是示例。实际操作时,需要替换成真实的项目地址和自己的 GitHub 用户名。


2. 开始前的准备

2.1 安装并配置 Git

确认 Git 已经安装:

1
git --version

首次使用时,配置提交者姓名和邮箱:

1
2
git config --global user.name "Xiao Ming"
git config --global user.email "xiaoming@example.com"

这些信息会记录在每次提交中。如果希望 GitHub 将提交关联到自己的账号,应使用 GitHub 账号中已经验证的邮箱,或者 GitHub 提供的 noreply 邮箱。

2.2 配置 GitHub 认证

本文使用 SSH 地址操作远程仓库。先测试当前计算机能否连接 GitHub:

1
ssh -T git@github.com

如果尚未配置 SSH,可以生成密钥:

1
ssh-keygen -t ed25519 -C "xiaoming@example.com"

然后将 ~/.ssh/id_ed25519.pub 中的公钥添加到 GitHub 的 Settings → SSH and GPG keys。私钥 ~/.ssh/id_ed25519 只能由自己保管,不能上传到 GitHub 或发送给他人。

也可以使用 HTTPS 地址,但 GitHub 的命令行 Git 操作不能使用账号密码认证,需要使用 GitHub CLI、凭据管理器或 Personal Access Token。

2.3 阅读项目的贡献规则

在 Fork 之前,先查看原仓库中的以下文件:

1
2
3
4
README.md
CONTRIBUTING.md
CODE_OF_CONDUCT.md
.github/PULL_REQUEST_TEMPLATE.md

不同项目对分支名、提交格式、测试命令和 Pull Request 内容可能有不同要求。项目自身的贡献规范应优先于本文中的通用示例。

同时阅读准备处理的 Issue,确认它没有被其他人认领,修改方案符合维护者预期。对于较大的功能,最好先在 Issue 中讨论,避免完成开发后才发现方向不一致。


3. 第一步:Fork 原仓库

打开原项目页面:

1
https://github.com/octo-org/task-board

点击页面右上角的 Fork,选择自己的 GitHub 账号作为目标所有者,然后创建 Fork。

完成后,自己的账号下会出现:

1
https://github.com/xiaoming/task-board

此时两个仓库的关系是:

1
2
3
octo-org/task-board       原仓库

└── fork ──> xiaoming/task-board

Fork 发生在 GitHub 服务器上,它复制的是仓库,不会自动在本地创建项目目录。我们对个人 Fork 有写权限,但原仓库仍然由原作者维护。

如果是组织内部协作,并且自己已经拥有原仓库写权限,通常不需要 Fork,可以直接克隆原仓库并推送功能分支。本文讨论的是没有原仓库写权限的开源贡献场景。


4. 第二步:Clone 自己的 Fork 到本地

应克隆自己的 Fork,而不是原作者的仓库:

1
2
git clone git@github.com:xiaoming/task-board.git
cd task-board

克隆完成后查看远程配置:

1
git remote -v

输出类似:

1
2
origin  git@github.com:xiaoming/task-board.git (fetch)
origin git@github.com:xiaoming/task-board.git (push)

git clone 会自动将被克隆的仓库命名为 origin。因为这里克隆的是个人 Fork,所以:

1
origin = xiaoming/task-board = 自己的远程仓库

后续执行 git push origin ... 时,代码会被推送到自己的 GitHub 仓库。

如果误将原仓库克隆为 origin,推送时很可能出现权限错误,因为普通贡献者没有向原仓库写入代码的权限。


5. 第三步:添加原仓库为 upstream

只配置个人 Fork 还不够。其他贡献者的代码会不断合并到原仓库,我们需要能够从原仓库获取这些更新。

将原仓库添加为名为 upstream 的远程:

1
git remote add upstream git@github.com:octo-org/task-board.git

再次检查:

1
git remote -v

此时应该看到:

1
2
3
4
origin    git@github.com:xiaoming/task-board.git (fetch)
origin git@github.com:xiaoming/task-board.git (push)
upstream git@github.com:octo-org/task-board.git (fetch)
upstream git@github.com:octo-org/task-board.git (push)

虽然列表中会显示 upstream 的 push 地址,但没有原仓库写权限时,实际推送仍会被 GitHub 拒绝。

这两个名称只是 Git 的本地别名,但社区通常遵循以下约定:

远程名 指向 常用操作
origin 自己的 Fork 拉取和推送
upstream 原作者的仓库 获取最新代码

记忆方式是:从 upstream 获取项目的新变化,把自己的代码推送到 origin。


6. 第四步:从最新上游代码创建功能分支

Fork 创建一段时间后,个人 Fork 的 main 可能落后于原仓库。开始开发前,应先同步上游代码。

6.1 获取上游更新

1
git fetch upstream

fetch 只下载远程提交并更新 upstream/main 等远程跟踪分支,不会立刻修改当前工作区。

查看当前分支和状态:

1
2
git status
git branch --show-current

切换到本地 main,将它快进到上游最新位置:

1
2
git switch main
git merge --ff-only upstream/main

--ff-only 表示只接受快进更新。如果本地 main 包含上游没有的提交,命令会停止,而不是自动制造一次合并。这也是不在 main 上直接开发的原因之一。

6.2 同步个人 Fork 的主分支

本地 main 更新后,将它推送到个人 Fork:

1
git push origin main

此时三处主分支保持一致:

1
2
3
4
5
upstream/main

├── 本地 main

└── origin/main

6.3 创建任务分支

从最新的 main 创建本次修复分支:

1
git switch -c fix/issue-128-empty-title

确认当前分支:

1
git branch --show-current

输出应该是:

1
fix/issue-128-empty-title

不要直接在 main 上开发。独立功能分支具有以下好处:

  • 一条分支只对应一个 Issue 或任务;
  • 可以随时同步干净的 main
  • Pull Request 只包含与当前任务有关的提交;
  • 多个任务可以互不干扰地并行进行。

7. 第五步:在本地开发和验证

现在开始修复 Issue #128。假设我们修改了表单校验和对应测试:

1
2
src/components/TaskForm.js
tests/TaskForm.test.js

开发过程中随时查看状态:

1
git status

查看尚未暂存的具体变化:

1
git diff

完成修改后,执行原项目要求的测试和格式检查。例如项目使用 npm 时,可能是:

1
2
npm test
npm run lint

实际命令应以仓库的 README.mdCONTRIBUTING.mdpackage.json 为准。

这一阶段所有文件都只存在于本地工作区。GitHub 上的个人 Fork 和原仓库都还没有发生变化。


8. 第六步:检查并提交本地修改

先查看有哪些文件发生变化:

1
git status

明确暂存本次任务涉及的文件:

1
git add src/components/TaskForm.js tests/TaskForm.test.js

检查即将进入提交的内容:

1
git diff --staged

这一步非常重要,可以发现调试输出、无关格式化、临时文件或不应该公开的配置是否被误加入提交。

确认无误后创建提交:

1
git commit -m "fix: reject tasks with an empty title"

查看最近的提交历史:

1
git log --oneline --decorate --graph -5

此时提交只存在于本地功能分支:

1
2
3
main:                              A---B---C
\
fix/issue-128-empty-title: D

其中 D 是刚刚创建的修复提交。

一个 Pull Request 可以包含多个提交,但每个提交都应尽量完成一件清晰、完整的事情。不要使用“update”“修改一下”等无法说明目的的提交信息。


9. 第七步:推送前再次同步上游

如果开发只用了几分钟,这一步可能没有新内容;如果任务持续了几天,上游 main 很可能已经发生变化。推送前再次检查可以尽早发现冲突。

1
git fetch upstream

查看自己的分支与最新上游之间的提交关系:

1
git log --oneline --graph --decorate --all -15

由于该功能分支目前只有自己使用,可以把本地提交变基到最新的 upstream/main

1
git rebase upstream/main

如果上游没有新提交,Git 会提示当前分支已经是最新状态。如果发生冲突,需要手工编辑冲突文件,然后执行:

1
2
git add path/to/conflicted-file
git rebase --continue

想放弃本次变基并恢复到操作前:

1
git rebase --abort

冲突文件通常会用 <<<<<<< HEAD=======>>>>>>> 三组标记分隔两边内容。解决冲突时不能只机械选择某一边,应理解双方修改目的,组合出正确结果,并重新运行测试。

如果项目贡献指南要求使用 merge 而不是 rebase,应遵守项目规范。不要对 upstream/main 或其他人的共享分支执行变基和强制推送。


10. 第八步:Push 功能分支到个人 Fork

将本地功能分支推送到 origin

1
git push -u origin fix/issue-128-empty-title

这里的每个参数都有明确含义:

1
2
3
origin                       目标是自己的 Fork
fix/issue-128-empty-title 要推送的功能分支
-u 建立本地与远程分支的跟踪关系

推送完成后,GitHub 上的个人 Fork 会出现这个分支:

1
xiaoming/task-board:fix/issue-128-empty-title

原仓库的 main 此时仍然没有改变。

建立跟踪关系后,如果继续在这个分支提交,只需执行:

1
git push

可以使用下面的命令检查跟踪关系:

1
git branch -vv

11. 第九步:向原仓库发起 Pull Request

推送成功后,打开个人 Fork 页面:

1
https://github.com/xiaoming/task-board

GitHub 通常会显示 Compare & pull request 按钮。点击后,必须仔细核对 Pull Request 的方向:

选项 应选择的值 含义
base repository octo-org/task-board 希望代码最终进入的原仓库
base main 原仓库中接收修改的目标分支
head repository xiaoming/task-board 包含修改的个人 Fork
compare fix/issue-128-empty-title 刚刚推送的功能分支

可以把方向理解为:

1
2
3
4
5
xiaoming/task-board:fix/issue-128-empty-title

│ Pull Request

octo-org/task-board:main

如果 base 和 compare 选反,就变成请求把原仓库代码合并到自己的 Fork,这不是我们想要的结果。

11.1 编写 Pull Request 标题

标题应简洁说明最终效果,例如:

1
fix: reject tasks with an empty title

不要只写 Fix bugUpdate code,否则维护者无法快速判断修改内容。

11.2 编写 Pull Request 描述

一份清楚的描述可以这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
## 修改原因

创建任务时,标题只包含空格也能通过校验,导致列表中出现空任务。

## 修改内容

- 提交前对标题执行 trim
- 拒绝清理后为空的标题
- 增加空字符串和纯空格输入的测试

## 验证方式

- npm test
- npm run lint

Fixes #128

Fixes #128 可以把 Pull Request 与 Issue 关联起来,并在满足 GitHub 的关闭条件后自动关闭对应 Issue。

提交前还应检查 GitHub 展示的 Files changed,确认 Pull Request 中没有混入其他任务的文件、生成物、密钥或个人配置。

如果代码还未完成,但希望维护者提前讨论方案,可以创建 Draft Pull Request;完成后再将它标记为 Ready for review。


12. 第十步:根据 Review 继续修改

维护者可能会提出修改意见,例如要求补充边界测试。此时不需要重新 Fork、不需要创建新分支,也不需要关闭当前 Pull Request。

回到原来的本地功能分支:

1
git switch fix/issue-128-empty-title

修改并测试后继续提交:

1
2
3
git add tests/TaskForm.test.js
git commit -m "test: cover whitespace-only task titles"
git push

由于本地分支已经跟踪 origin/fix/issue-128-empty-title,新的提交会被推送到个人 Fork,并自动显示在原来的 Pull Request 中。

Pull Request 实际上持续关注的是两个分支之间的差异,而不是只保存创建瞬间的那一个提交。因此,向源分支继续推送后,不需要新建 PR。

处理完 Review 后,应在对应讨论中回复变更内容,并等待检查和审批重新完成。


13. Pull Request 期间上游发生变化怎么办

等待审查期间,其他代码可能已经合并到 upstream/main。如果 GitHub 提示分支落后或产生冲突,可以在本地整合最新上游代码。

先获取上游更新:

1
2
git fetch upstream
git switch fix/issue-128-empty-title

对于已经推送并进入审查的分支,使用 merge 不会改写现有提交:

1
git merge upstream/main

如果有冲突:

  1. 使用 git status 查看冲突文件;
  2. 手工编辑并删除冲突标记;
  3. 重新运行测试;
  4. 暂存解决后的文件;
  5. 完成合并提交并推送。
1
2
3
git add path/to/conflicted-file
git commit
git push

更新会自动进入同一个 Pull Request。

如果项目明确要求线性历史,也可以 rebase,但 rebase 会改写功能分支的提交 ID。已经推送后需要使用:

1
git push --force-with-lease

--force-with-lease--force 更安全,但仍会改写远程历史。只有确认分支由自己独占,并且项目允许时才能使用。多人共用分支时优先 merge,避免覆盖他人刚推送的提交。


14. Pull Request 合并后的清理

维护者批准并合并 Pull Request 后,修复已经进入:

1
octo-org/task-board:main

但本地 main 和个人 Fork 的 main 不会自动更新,需要再次同步。

14.1 更新本地主分支

1
2
3
git switch main
git fetch upstream
git merge --ff-only upstream/main

14.2 更新个人 Fork

1
git push origin main

现在原仓库、本地主分支和个人 Fork 的主分支重新一致,可以作为下一个任务的起点。

14.3 删除已经完成的功能分支

删除本地分支:

1
git branch -d fix/issue-128-empty-title

删除个人 Fork 上的远程分支:

1
git push origin --delete fix/issue-128-empty-title

如果已经在 GitHub 的 Pull Request 页面点击 Delete branch,远程分支可能已经删除,可以省略第二条命令。

如果维护者使用 Squash and merge,功能分支原来的提交不会成为 main 的直接祖先,git branch -d 可能拒绝删除。这时应先确认 Pull Request 已合并、修改已存在于最新的 upstream/main,再执行:

1
git branch -D fix/issue-128-empty-title

最后清理已经失效的远程跟踪引用:

1
2
git fetch --prune origin
git fetch --prune upstream

15. 下一次贡献时从哪里开始

Fork 和 Clone 通常只需要做一次。以后参与同一个项目,不需要重复创建 Fork,也不需要重新克隆仓库。

每次开始新任务时执行:

1
2
3
4
5
git switch main
git fetch upstream
git merge --ff-only upstream/main
git push origin main
git switch -c feature/new-task

然后重复以下循环:

1
2
3
4
5
6
7
8
9
10
11
开发与测试

git add / git commit

git push -u origin 功能分支

向 upstream/main 创建 Pull Request

处理 Review

合并后同步 main 并删除功能分支

需要特别注意:每个新任务都应从最新的 main 创建新分支,不要继续复用上一次已经合并的功能分支。否则新的 Pull Request 可能混入旧提交。


16. 常见错误与排查方法

16.1 克隆了原仓库,Push 时没有权限

错误信息可能包含:

1
Permission denied

或者:

1
Write access to repository not granted

先查看 origin

1
git remote -v

如果 origin 指向 octo-org/task-board,将它改为自己的 Fork,再把原仓库配置为 upstream

1
2
git remote set-url origin git@github.com:xiaoming/task-board.git
git remote add upstream git@github.com:octo-org/task-board.git

如果已经存在 upstream,不要重复添加,使用 git remote set-url upstream ... 修改即可。

16.2 在 main 上完成了开发

如果修改尚未提交,可以直接从当前位置创建功能分支:

1
git switch -c fix/issue-128-empty-title

未提交的工作区修改会跟随到新分支,然后可以正常提交。

如果已经在本地 main 创建提交,但还没有推送,可以先创建功能分支保存当前提交,再将本地 main 恢复到上游位置。重置分支可能丢失工作,操作前应确认提交已经能从功能分支找到。

16.3 Pull Request 方向选反

正确方向始终是:

1
自己的 Fork:功能分支  →  原仓库:main

创建前核对 base repository、base、head repository 和 compare 四个选项。

16.4 Push 到了个人 Fork 的 main

只要没有强制推送,一般不会影响原仓库,但会让个人 Fork 的主分支包含任务提交。应为贡献创建功能分支,并让个人 main 只用于跟踪上游。

不要为了快速恢复而随意执行强制推送。先使用 git log --graph --oneline --all 理解分支关系,再决定如何处理。

16.5 推送被拒绝:non-fast-forward

说明远程同名分支包含本地没有的提交。先获取并查看差异:

1
2
git fetch origin
git log --oneline --graph --decorate --all -15

如果分支只有自己使用,可能是另一台电脑推送了更新,应先 merge 或 rebase。不要看到错误后立即使用 git push --force

16.6 GitHub 上没有出现功能分支

检查当前分支、最近提交、远程地址和跟踪关系:

1
2
3
4
5
git status
git branch --show-current
git log -1 --oneline
git remote -v
git branch -vv

常见原因是只执行了 git commit 而没有 git push,或者把代码推送到了另一个远程或分支。

16.7 Pull Request 混入了其他提交

常见原因包括:

  • 从旧功能分支创建了新分支;
  • 在同一分支连续处理多个任务;
  • base 分支选择错误;
  • 本地 main 本身包含未合并到上游的提交。

最稳妥的习惯是每次先同步 upstream/main,然后从干净的本地 main 创建一个全新功能分支。


17. 完整命令清单

首次参与项目

先在 GitHub 页面完成 Fork,然后执行:

1
2
3
4
git clone git@github.com:xiaoming/task-board.git
cd task-board
git remote add upstream git@github.com:octo-org/task-board.git
git remote -v

开始 Issue #128

1
2
3
4
5
git switch main
git fetch upstream
git merge --ff-only upstream/main
git push origin main
git switch -c fix/issue-128-empty-title

开发并提交

1
2
3
4
5
6
7
8
git status
git diff

# 修改代码并执行项目规定的测试

git add src/components/TaskForm.js tests/TaskForm.test.js
git diff --staged
git commit -m "fix: reject tasks with an empty title"

推送到个人 Fork

1
2
3
git fetch upstream
git rebase upstream/main
git push -u origin fix/issue-128-empty-title

随后在 GitHub 上创建:

1
2
3
xiaoming/task-board:fix/issue-128-empty-title

octo-org/task-board:main

Pull Request 合并后

1
2
3
4
5
6
7
8
git switch main
git fetch upstream
git merge --ff-only upstream/main
git push origin main
git branch -d fix/issue-128-empty-title
git push origin --delete fix/issue-128-empty-title
git fetch --prune origin
git fetch --prune upstream

18. 总结

在没有原仓库写权限的情况下,一次完整的 GitHub 协作流程是:

  1. 在 GitHub 上将原仓库 Fork 到自己的账号;
  2. Clone 自己的 Fork 到本地;
  3. 将个人 Fork 配置为 origin,原仓库配置为 upstream
  4. 从最新的 upstream/main 创建独立功能分支;
  5. 在本地开发、测试并提交;
  6. 将功能分支 Push 到自己的 origin
  7. 从个人 Fork 的功能分支向原仓库的 main 发起 Pull Request;
  8. 在同一分支继续处理 Review 和冲突;
  9. PR 合并后同步主分支并清理功能分支。

最重要的是始终分清两个远程:

1
2
upstream:原作者仓库,负责提供最新代码
origin:自己的 Fork,负责接收自己推送的分支

只要围绕这条主线理解命令,Fork、Clone、Fetch、Push 和 Pull Request 就不再是互相割裂的操作,而是一条清晰的开源协作链路。


参考资料

[1] GitHub Docs:Fork 仓库

[2] GitHub Docs:配置 Fork 的远程仓库

[3] GitHub Docs:同步 Fork

[4] GitHub Docs:从 Fork 创建 Pull Request

[5] Git 官方文档