Ran Wei/Git 系列
English
Git 与版本控制 — Ran Wei

综合项目:团队协作

把整个系列学到的内容付诸实践:两个人、两个克隆和一个共享仓库,从第一次提交到打上标签的发布版本,共同完成一本家庭食谱,途中经历分支、拉取请求、一次冲突和一次审查。

3 小时3 个时段5 个动手环节Git 2.50.1 (Apple Git-155)

完成后你能够

  • 为团队建立共享仓库,每位成员在自己的克隆中工作。
  • 在分支上并行开发两项功能,每项都通过拉取请求提出。
  • 通过把 main 带进分支,解决拉取请求中的冲突。
  • 审查、合并并整理,让每个人的 main 都保持同步。
  • 用附注标签为发布版本打标签,推送它,并在 GitHub 上发布。

开始之前

第 1 至第 8 模块,以及一个 GitHub 账号(第 6 模块)。欢迎找一位有 GitHub 账号的朋友一起做,但这不是必需的。

目录

学习计划

3 小时

时间为估计值,包含输入命令的时间。进度保存在当前浏览器,中英文版本共享。

时段 160 分钟
共同起步
时段 260 分钟
整合与发布
时段 360 分钟
更进一步
1

项目介绍

本模块把整个系列学到的内容用在一个小项目上,从第一次提交一直到发布一个版本。Alex 和 Sam 要共同编写一本家庭食谱:一个 README、一份食谱索引,以及每份食谱一个文件。用到的命令你都已经学过。新的地方在于,要按照团队的工作方式,把所有这些步骤按顺序一起完成。

这个项目有五个里程碑:

  1. 共享仓库。 Alex 创建项目并共享出去;Sam 克隆它。
  2. 同时开发两项功能。 Alex 加入一份汤的食谱,Sam 加入一份面包的食谱,各自在一个分支上进行,各自用一个拉取请求提出。
  3. 一次冲突。 Alex 的拉取请求先被合并,于是 Sam 的拉取请求与之冲突。Sam 把 main 带进自己的分支并解决冲突。
  4. 审查与合并。 Alex 审查并合并 Sam 的拉取请求,大家各自整理。
  5. 发布版本。 Alex 把成果标记为 1.0 版并发布。

团队遵循第 7 模块中的规则:没有人直接向 main 提交,每项改动都经过分支和拉取请求,并由作者以外的人审查和合并。

请在 GitHub 上完成这个项目。有两种方式:

  • 和朋友一起。 你们各有一个 GitHub 账号和一台电脑。一人扮演 Alex 并拥有仓库,另一人扮演 Sam。
  • 一个人完成。 你一人分饰两角,像第 6 模块那样使用两个文件夹和两个终端窗口。在 GitHub 上,两个人都是你。GitHub 不允许任何人批准自己的拉取请求,所以项目中说到批准的地方,请改为留下一条审查评论,然后合并。

动手环节用第 6 模块中的替身服务器展示每一个步骤。如果某一步要在 GitHub 上进行,例如创建仓库、发起或合并拉取请求,正文会说明怎么做。

2

里程碑 1:共享仓库

Alex 创建项目:一个新文件夹、git init、三个文件和第一次提交。请从主文件夹开始(cd ~),不要在 recipes 里面创建。然后 Alex 把它放到服务器上并推送 main,Sam 再克隆它。

在 GitHub 上:

  • Alex 像第 6 模块那样创建一个名为 cookbook 的空仓库,并推送到它的地址,而不是 /srv/git/cookbook.git。
  • 和朋友一起做时,Alex 接着按照邀请协作者访问个人仓库邀请 Sam 加入仓库。Sam 接受邀请之后,就可以向它推送分支了。
  • 一个人做时,不需要邀请。把仓库克隆到 Sam 的文件夹中,并像第 6 模块那样在这个克隆中设置 Sam 的姓名和邮箱。
动手试试启动项目并共享它
mkdir cookbook
cd cookbook
git init
输出
Initialized empty Git repository in /home/alex/cookbook/.git/
创建或编辑 README.md,使其内容为:
# Family cookbook

Our family's recipes, collected by Alex and Sam.
创建或编辑 index.md,使其内容为:
# Recipes

- [Pancakes](pancakes.md)
创建或编辑 pancakes.md,使其内容为:
# Pancakes

- 200 g flour
- 2 eggs
- 300 ml milk
git add README.md index.md pancakes.md
git commit -m "Start the family cookbook"
输出
[main (root-commit) db5abad] Start the family cookbook
 3 files changed, 11 insertions(+)
 create mode 100644 README.md
 create mode 100644 index.md
 create mode 100644 pancakes.md
git init --bare /srv/git/cookbook.git
输出
Initialized empty Git repository in /srv/git/cookbook.git/
git remote add origin /srv/git/cookbook.git
git push -u origin main
输出
To /srv/git/cookbook.git
 * [new branch]      main -> main
branch 'main' set up to track 'origin/main'.
现在以 Sam Lee 的身份操作,在其自己的文件夹中。
git clone /srv/git/cookbook.git
输出
Cloning into 'cookbook'...
done.
cd cookbook
git log --oneline
输出
db5abad Start the family cookbook
上面的输出来自 git version 2.50.1 (Apple Git-155),在构建本页时实际运行得到。其他版本的 Git 提示措辞可能略有不同。

这里的每一步都来自前面的模块。git add 一次列出三个文件。第一次提交是根提交,git push -u 在服务器上创建 main 并把它设为上游。随后 Sam 的克隆中也有了同一次提交 db5abad。

提示

团队常常让 GitHub 来强制执行这些规则。对 main 设置规则集(ruleset)或分支保护(branch protection),可以要求在合并任何东西之前,必须有拉取请求和一次批准的审查。关于受保护分支解释了这些选项。对本项目来说,这是可选的,而且只适合多人合作:一个人做时,要求批准会挡住每一次合并,因为没有人能批准自己的拉取请求。使用免费账号时,GitHub 只在公开仓库中强制执行这些规则。

3

里程碑 2:同时开发两项功能

现在 Alex 和 Sam 同时工作,各自在自己的分支上。每人加入一个食谱文件,并在 index.md 中煎饼下面为它加上一行。然后每人推送分支,并像第 7 模块那样在 GitHub 上发起拉取请求:Alex 的成为拉取请求 #1,Sam 的成为 #2。请为每个拉取请求写一个标题和一段简短的描述。

动手试试同时开发两项功能
git switch -c add-soup
输出
Switched to a new branch 'add-soup'
创建或编辑 soup.md,使其内容为:
# Tomato soup

- 1 kg tomatoes
创建或编辑 index.md,使其内容为:
# Recipes

- [Pancakes](pancakes.md)
- [Tomato soup](soup.md)
git add soup.md index.md
git commit -m "Add a tomato soup recipe"
输出
[add-soup 7c82c56] Add a tomato soup recipe
 2 files changed, 4 insertions(+)
 create mode 100644 soup.md
git push -u origin add-soup
输出
To /srv/git/cookbook.git
 * [new branch]      add-soup -> add-soup
branch 'add-soup' set up to track 'origin/add-soup'.
现在以 Sam Lee 的身份操作,在其自己的文件夹中。
git switch -c add-bread
输出
Switched to a new branch 'add-bread'
创建或编辑 bread.md,使其内容为:
# Soda bread

- 450 g flour
创建或编辑 index.md,使其内容为:
# Recipes

- [Pancakes](pancakes.md)
- [Soda bread](bread.md)
git add bread.md index.md
git commit -m "Add a soda bread recipe"
输出
[add-bread 0070943] Add a soda bread recipe
 2 files changed, 4 insertions(+)
 create mode 100644 bread.md
git push -u origin add-bread
输出
To /srv/git/cookbook.git
 * [new branch]      add-bread -> add-bread
branch 'add-bread' set up to track 'origin/add-bread'.
* 0070943 (HEAD -> add-bread, origin/add-bread) Add a soda bread recipe
* db5abad (origin/main, origin/HEAD, main) Start the family cookbook
上面的输出来自 git version 2.50.1 (Apple Git-155),在构建本页时实际运行得到。其他版本的 Git 提示措辞可能略有不同。

每人的提交都涉及两个文件:新的食谱和 index.md。提交图显示的是 Sam 的仓库。Sam 的分支 add-bread 比 main 领先一次提交。Sam 的仓库还没有获取 Alex 的分支,所以图中没有它。

两处改动都在 index.md 的同一个位置,也就是紧接在煎饼之后,加了一行。两个人都还不知道,但正如第 5 模块所解释的,这注定会产生冲突。

4

里程碑 3:一次冲突

Sam 审查拉取请求 #1,批准后用 Merge pull request 合并它,再用 Delete branch 删除分支。现在 main 中有了汤,而 Sam 的拉取请求 #2 已经无法顺利合并了。GitHub 会在拉取请求的页面上显示:This branch has conflicts that must be resolved。

修复应该在分支上进行,所以这是 Sam 作为分支作者的工作:把新的 main 带进 add-bread,解决冲突,然后推送。这就是第 7 模块中的“让分支保持最新”,中间再加上第 5 模块的冲突解决。

一个人跟着做时,请先在 GitHub 上合并拉取请求 #1。然后在 Sam 的文件夹中,在 add-bread 上操作。

动手试试让分支跟上最新进展,并解决冲突
现在以 Sam Lee 的身份操作,在其自己的文件夹中。
git fetch
输出
From /srv/git/cookbook
   db5abad..c9c95dc  main       -> origin/main
git merge origin/main
输出
Auto-merging index.md
CONFLICT (content): Merge conflict in index.md
Automatic merge failed; fix conflicts and then commit the result.
cat index.md
输出
# Recipes

- [Pancakes](pancakes.md)
<<<<<<< HEAD
- [Soda bread](bread.md)
=======
- [Tomato soup](soup.md)
>>>>>>> origin/main
创建或编辑 index.md,使其内容为:
# Recipes

- [Pancakes](pancakes.md)
- [Soda bread](bread.md)
- [Tomato soup](soup.md)
git add index.md
git commit
输出
[add-bread 702089a] Merge remote-tracking branch 'origin/main' into add-bread
git push
输出
To /srv/git/cookbook.git
   0070943..702089a  add-bread -> add-bread
*   702089a (HEAD -> add-bread, origin/add-bread) Merge remote-tracking branch 'origin/main' into add-bread
|\
| *   c9c95dc (origin/main, origin/HEAD) Merge pull request #1 from alex/add-soup
| |\
| | * 7c82c56 Add a tomato soup recipe
| |/
* / 0070943 Add a soda bread recipe
|/
* db5abad (main) Start the family cookbook
上面的输出来自 git version 2.50.1 (Apple Git-155),在构建本页时实际运行得到。其他版本的 Git 提示措辞可能略有不同。

git fetch 取回了合并后的 main。git merge origin/main 因 index.md 中的冲突而停下。冲突标记把 Sam 的那一行(HEAD,即 Sam 所在的分支)与来自 origin/main 的汤那一行对照显示。两行都应该留在 index.md 中。Sam 按字母顺序保留两行,并删除标记。然后是 git add、git commit(接受现成的说明 Merge remote-tracking branch 'origin/main' into add-bread)和 git push。

提交图显示了结果:add-bread 现在既包含面包的提交,也包含 main 上的一切,其中有 Alex 已合并的拉取请求。在 GitHub 上,拉取请求会自动更新,冲突警告也随之消失。

5

里程碑 4:审查与合并

Alex 在 Files changed 标签页中审查拉取请求 #2,那里现在只显示 Sam 的改动:新的面包文件和解决冲突后的 index.md。Alex 批准并合并了它,再用 Delete branch 删除分支。然后两个人都把自己的 main 更新到最新,并像第 7 模块那样删除已合并的分支。

动手试试合并第二个拉取请求并整理
现在以 Sam Lee 的身份操作,在其自己的文件夹中。
git switch main
输出
Switched to branch 'main'
Your branch is behind 'origin/main' by 2 commits, and can be fast-forwarded.
  (use "git pull" to update your local branch)
git pull
输出
From /srv/git/cookbook
   c9c95dc..0dba4f9  main       -> origin/main
Updating db5abad..0dba4f9
Fast-forward
 bread.md | 3 +++
 index.md | 2 ++
 soup.md  | 3 +++
 3 files changed, 8 insertions(+)
 create mode 100644 bread.md
 create mode 100644 soup.md
git branch -d add-bread
输出
Deleted branch add-bread (was 702089a).
git fetch --prune
输出
From /srv/git/cookbook
 - [deleted]         (none)     -> origin/add-bread
cat index.md
输出
# Recipes

- [Pancakes](pancakes.md)
- [Soda bread](bread.md)
- [Tomato soup](soup.md)
*   0dba4f9 (HEAD -> main, origin/main, origin/HEAD) Merge pull request #2 from alex/add-bread
|\
| *   702089a Merge remote-tracking branch 'origin/main' into add-bread
| |\
| |/
|/|
* |   c9c95dc Merge pull request #1 from alex/add-soup
|\ \
| * | 7c82c56 Add a tomato soup recipe
|/ /
| * 0070943 Add a soda bread recipe
|/
* db5abad Start the family cookbook
上面的输出来自 git version 2.50.1 (Apple Git-155),在构建本页时实际运行得到。其他版本的 Git 提示措辞可能略有不同。

Sam 切换到 main,拉取拉取请求 #2 的合并,删除本地分支,并清理 origin/add-bread。cat index.md 显示 index.md 中已有全部三份食谱。

这张提交图是本系列中最复杂的一张,所以请从下往上读:

  • 第一次提交 db5abad;
  • 两次功能提交 0070943(面包)和 7c82c56(汤);
  • 拉取请求 #1 的合并;
  • Sam 把 main 合并进 add-bread;
  • 最上面是拉取请求 #2 的合并。

每一条工作线都在,每一次合并都说明了来自哪里。对于使用合并的团队来说,这样的历史很正常;第 8 模块介绍了如何用变基让它更直一些。

6

里程碑 5:发布版本

食谱已经可以发布第一个版本了。标签(tag)给一次提交起一个永久的名字,例如 v1.0,它不会像分支那样随着新工作的到来而移动。

Git 有两种标签:

  • 轻量标签(lightweight tag),用 git tag <name> 创建,只是某次提交的一个名字,就像一个永远不动的分支;
  • 附注标签(annotated tag),用 git tag -a <name> -m "<message>" 创建,本身就是一个对象(第 9 模块)。它记录了谁创建了它、什么时候、为什么。

发布版本请使用附注标签。

git push 不会推送标签;请按名字逐个推送,例如 git push origin v1.0。许多项目用语义化版本(Semantic Versioning)给发布版本编号:修正问题用 v1.0.1,增加不改变现有内容的新功能用 v1.1.0,做出破坏别人所依赖内容的改动则用 v2.0.0。

动手试试为第一个版本打标签
git log --oneline --graph
输出
*   0dba4f9 Merge pull request #2 from alex/add-bread
|\
| *   702089a Merge remote-tracking branch 'origin/main' into add-bread
| |\
| |/
|/|
* |   c9c95dc Merge pull request #1 from alex/add-soup
|\ \
| * | 7c82c56 Add a tomato soup recipe
|/ /
| * 0070943 Add a soda bread recipe
|/
* db5abad Start the family cookbook
git tag -a v1.0 -m "Family cookbook 1.0"
git push origin v1.0
输出
To /srv/git/cookbook.git
 * [new tag]         v1.0 -> v1.0
git tag
输出
v1.0
git show v1.0 --no-patch
输出
tag v1.0
Tagger: Alex Smith <alex@example.com>
Date:   Mon Jan 5 09:49:00 2026 +0000

Family cookbook 1.0

commit 0dba4f9bab9f4e6ee71716da3e6bd40bf54fb4fc
Merge: c9c95dc 702089a
Author: Alex Smith <alex@example.com>
Date:   Mon Jan 5 09:39:00 2026 +0000

    Merge pull request #2 from alex/add-bread
*   0dba4f9 (HEAD -> main, tag: v1.0, origin/main, origin/HEAD) Merge pull request #2 from alex/add-bread
|\
| *   702089a Merge remote-tracking branch 'origin/main' into add-bread
| |\
| |/
|/|
* |   c9c95dc Merge pull request #1 from alex/add-soup
|\ \
| * | 7c82c56 Add a tomato soup recipe
|/ /
| * 0070943 Add a soda bread recipe
|/
* db5abad Start the family cookbook
现在以 Sam Lee 的身份操作,在其自己的文件夹中。
git fetch
输出
From /srv/git/cookbook
   c9c95dc..0dba4f9  main       -> origin/main
 * [new tag]         v1.0       -> v1.0
上面的输出来自 git version 2.50.1 (Apple Git-155),在构建本页时实际运行得到。其他版本的 Git 提示措辞可能略有不同。
  • git log --oneline --graph 显示即将发布的历史。git tag -a v1.0 -m "Family cookbook 1.0" 给最新的提交,即拉取请求 #2 的合并,打上标签。
  • git push origin v1.0 发送这个标签:* [new tag] v1.0 -> v1.0。git tag 列出所有标签。
  • git show v1.0 --no-patch 显示标签对象(打标签者、日期和说明),后面是它所指向的提交。
  • 提交图在那次提交上用一个黄色标签显示它。
  • Sam 获取时,Git 会把新标签一起带来:* [new tag] v1.0 -> v1.0。

要在 GitHub 上发布这个版本,请打开仓库的 Releases 页面,选择 Draft a new release,选中标签 v1.0,再写一个标题和一份简短的内容清单,最后选择 Publish release。管理仓库中的发行版展示了具体步骤。发行版是给项目使用者看的页面,建立在 Git 记录的标签之上。

核心思想

这就是完整的循环:分支、提交、推送、拉取请求、审查、合并、更新、打标签。本系列的每一部分内容都服务于其中的某一步,你将来加入的每个 Git 项目,无论多大,都是这样运作的。

7

练习

这些拓展任务是这个项目的延续。请在 GitHub 上你的食谱项目中完成。

练习 1★★★编程10 分钟

修正版本。 煎饼食谱里没有盐。请以 Alex 的身份通过拉取请求修正它,并把这次修正发布为 v1.0.1。

查看解答

创建分支、提交、推送并发起拉取请求。合并之后,更新 main,然后打标签并推送标签:

参考解答一个修正版本
git switch -c fix-pancakes
输出
Switched to a new branch 'fix-pancakes'
创建或编辑 pancakes.md,使其内容为:
# Pancakes

- 200 g flour
- 2 eggs
- 300 ml milk
- a pinch of salt
git commit -am "Add the salt the pancakes were missing"
输出
[fix-pancakes ff0ea75] Add the salt the pancakes were missing
 1 file changed, 1 insertion(+)
git push -u origin fix-pancakes
输出
To /srv/git/cookbook.git
 * [new branch]      fix-pancakes -> fix-pancakes
branch 'fix-pancakes' set up to track 'origin/fix-pancakes'.
git switch main
输出
Switched to branch 'main'
Your branch is up to date with 'origin/main'.
git pull
输出
From /srv/git/cookbook
   0dba4f9..458c69d  main       -> origin/main
Updating 0dba4f9..458c69d
Fast-forward
 pancakes.md | 1 +
 1 file changed, 1 insertion(+)
git tag -a v1.0.1 -m "Family cookbook 1.0.1"
git push origin v1.0.1
输出
To /srv/git/cookbook.git
 * [new tag]         v1.0.1 -> v1.0.1
git tag
输出
v1.0
v1.0.1
上面的输出来自 git version 2.50.1 (Apple Git-155),在构建本页时实际运行得到。其他版本的 Git 提示措辞可能略有不同。

在动手环节中,Sam 像在 GitHub 上合并那样合并了拉取请求 #3。一个不改动其他内容的修正,得到下一个修订号 v1.0.1。

练习 2★★★概念6 分钟

避免冲突。 里程碑 3 中的冲突是可以预见的:每加一份新食谱,都要在 index.md 的同一个位置加一行。请提出两种办法,让团队今后避免这样的冲突。

查看解答

以下任意两种:

  • 约定每次合并之后由一个人更新 index.md,这样食谱分支就永远不碰它。
  • 让 index.md 保持字母顺序,这样不同的食谱通常位于不同的行(不过相邻的行仍可能冲突)。
  • 在发起拉取请求之前,先把 main 合并进分支,这样后提交的人就能在对方的改动还很小时看到它。
  • 用脚本根据食谱文件自动生成 index.md,而不是手工编辑。
练习 3★★★编程8 分钟

这是谁加的? 一位新成员问:苏打面包是谁加进 index.md 的?在哪次提交中?请用 git log 找出答案。

查看解答

git log -S 列出增加或删除了某段文字的提交:

参考解答这一行是谁加的?
git log --oneline -S "Soda bread"
输出
0070943 Add a soda bread recipe
git log -n 1 --format="%an, %s" 0070943
输出
Sam Lee, Add a soda bread recipe
上面的输出来自 git version 2.50.1 (Apple Git-155),在构建本页时实际运行得到。其他版本的 Git 提示措辞可能略有不同。

-S "Soda bread" 找到了加入这段文字的提交 0070943。--format 随后打印出它的作者和说明。git blame index.md 能逐行给出类似的答案:它为每一行显示最后一次改动它的提交。

练习 4★★★编程8 分钟

误推送的标签。 Alex 误打并推送了 v2.0 标签。请在本地和服务器上删除这个标签。要练习,请先像解答中的前两条命令那样,创建并推送 v2.0 标签。

查看解答

用 git tag -d 在本地删除标签,用 git push origin --delete 在服务器上删除:

参考解答删除误推送的标签
git tag -a v2.0 -m "Family cookbook 2.0"
git push origin v2.0
输出
To /srv/git/cookbook.git
 * [new tag]         v2.0 -> v2.0
git tag -d v2.0
输出
Deleted tag 'v2.0' (was 2f63022)
git push origin --delete v2.0
输出
To /srv/git/cookbook.git
 - [deleted]         v2.0
git tag
输出
v1.0
上面的输出来自 git version 2.50.1 (Apple Git-155),在构建本页时实际运行得到。其他版本的 Git 提示措辞可能略有不同。

已经获取了 v2.0 的人会保留他们的那一份,因为获取从不删除标签。标签本应是永久的,所以当某个已发布的版本有问题时,请发布一个新版本,例如 v1.0.2,而不要移动或删除别人可能已经拿到的标签。

练习 5★★★概念6 分钟

用哪种标签? 为什么这个项目的发布版本使用附注标签而不是轻量标签?什么时候轻量标签就够了?

查看解答

附注标签记录了是谁发布的、什么时候,以及一段说明,git show 等工具都会显示这些信息。它还可以被签名。轻量标签只是某次提交的一个名字:用作不打算分享的私人书签(例如 before-big-change)就足够了。

练习 6★★★概念7 分钟

保护 main。 在里程碑 3 中,没有任何东西能阻止 Sam 不看内容就合并拉取请求 #1,或者直接推送到 main。团队可以在 GitHub 上设置什么?设置之后,每个人的做法会有什么不同?

查看解答

对 main 设置规则集或分支保护,要求必须有拉取请求,并且至少有一次批准的审查。这样,直接推送到 main 会被拒绝,而在作者以外的人批准之前,Merge pull request 按钮一直不可用。使用传统的分支保护时,还要勾选 Do not allow bypassing the above settings,否则这些规则不约束仓库所有者;规则集则约束绕过列表(bypass list)以外的所有人。每个人的工作方式与这个项目中完全一样:创建分支、推送、发起拉取请求、审查。

8

自测

1
GitHub 提示你的拉取请求 has conflicts that must be resolved。你该怎么做?
2
拉取请求中的冲突通常由谁来解决?
3
标签和分支有什么区别?
4
你打了 v1.0 标签并运行了 git push。为什么 GitHub 上没有这个标签?
5
按照语义化版本,一个只修正了 v1.4.2 中某个缺陷的版本编号为:
6
附注标签记录了哪些轻量标签没有记录的东西?
7
哪种顺序符合这个项目所遵循的循环?
9

延伸阅读