项目介绍
本模块把整个系列学到的内容用在一个小项目上,从第一次提交一直到发布一个版本。Alex 和 Sam 要共同编写一本家庭食谱:一个 README、一份食谱索引,以及每份食谱一个文件。用到的命令你都已经学过。新的地方在于,要按照团队的工作方式,把所有这些步骤按顺序一起完成。
这个项目有五个里程碑:
- 共享仓库。 Alex 创建项目并共享出去;Sam 克隆它。
- 同时开发两项功能。 Alex 加入一份汤的食谱,Sam 加入一份面包的食谱,各自在一个分支上进行,各自用一个拉取请求提出。
- 一次冲突。 Alex 的拉取请求先被合并,于是 Sam 的拉取请求与之冲突。Sam 把
main带进自己的分支并解决冲突。 - 审查与合并。 Alex 审查并合并 Sam 的拉取请求,大家各自整理。
- 发布版本。 Alex 把成果标记为 1.0 版并发布。
团队遵循第 7 模块中的规则:没有人直接向 main 提交,每项改动都经过分支和拉取请求,并由作者以外的人审查和合并。
请在 GitHub 上完成这个项目。有两种方式:
- 和朋友一起。 你们各有一个 GitHub 账号和一台电脑。一人扮演 Alex 并拥有仓库,另一人扮演 Sam。
- 一个人完成。 你一人分饰两角,像第 6 模块那样使用两个文件夹和两个终端窗口。在 GitHub 上,两个人都是你。GitHub 不允许任何人批准自己的拉取请求,所以项目中说到批准的地方,请改为留下一条审查评论,然后合并。
动手环节用第 6 模块中的替身服务器展示每一个步骤。如果某一步要在 GitHub 上进行,例如创建仓库、发起或合并拉取请求,正文会说明怎么做。
里程碑 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'.
git clone /srv/git/cookbook.git
Cloning into 'cookbook'...
done.
cd cookbook
git log --oneline
db5abad Start the family cookbook
这里的每一步都来自前面的模块。git add 一次列出三个文件。第一次提交是根提交,git push -u 在服务器上创建 main 并把它设为上游。随后 Sam 的克隆中也有了同一次提交 db5abad。
团队常常让 GitHub 来强制执行这些规则。对 main 设置规则集(ruleset)或分支保护(branch protection),可以要求在合并任何东西之前,必须有拉取请求和一次批准的审查。关于受保护分支解释了这些选项。对本项目来说,这是可选的,而且只适合多人合作:一个人做时,要求批准会挡住每一次合并,因为没有人能批准自己的拉取请求。使用免费账号时,GitHub 只在公开仓库中强制执行这些规则。
里程碑 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'.
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
每人的提交都涉及两个文件:新的食谱和 index.md。提交图显示的是 Sam 的仓库。Sam 的分支 add-bread 比 main 领先一次提交。Sam 的仓库还没有获取 Alex 的分支,所以图中没有它。
两处改动都在 index.md 的同一个位置,也就是紧接在煎饼之后,加了一行。两个人都还不知道,但正如第 5 模块所解释的,这注定会产生冲突。
里程碑 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 上操作。
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 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 上,拉取请求会自动更新,冲突警告也随之消失。
里程碑 4:审查与合并
Alex 在 Files changed 标签页中审查拉取请求 #2,那里现在只显示 Sam 的改动:新的面包文件和解决冲突后的 index.md。Alex 批准并合并了它,再用 Delete branch 删除分支。然后两个人都把自己的 main 更新到最新,并像第 7 模块那样删除已合并的分支。
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
Sam 切换到 main,拉取拉取请求 #2 的合并,删除本地分支,并清理 origin/add-bread。cat index.md 显示 index.md 中已有全部三份食谱。
这张提交图是本系列中最复杂的一张,所以请从下往上读:
- 第一次提交
db5abad; - 两次功能提交
0070943(面包)和7c82c56(汤); - 拉取请求 #1 的合并;
- Sam 把
main合并进add-bread; - 最上面是拉取请求 #2 的合并。
每一条工作线都在,每一次合并都说明了来自哪里。对于使用合并的团队来说,这样的历史很正常;第 8 模块介绍了如何用变基让它更直一些。
里程碑 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
git fetch
From /srv/git/cookbook
c9c95dc..0dba4f9 main -> origin/main
* [new tag] v1.0 -> v1.0
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 项目,无论多大,都是这样运作的。
练习
这些拓展任务是这个项目的延续。请在 GitHub 上你的食谱项目中完成。
修正版本。 煎饼食谱里没有盐。请以 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
在动手环节中,Sam 像在 GitHub 上合并那样合并了拉取请求 #3。一个不改动其他内容的修正,得到下一个修订号 v1.0.1。
避免冲突。 里程碑 3 中的冲突是可以预见的:每加一份新食谱,都要在 index.md 的同一个位置加一行。请提出两种办法,让团队今后避免这样的冲突。
查看解答
以下任意两种:
- 约定每次合并之后由一个人更新
index.md,这样食谱分支就永远不碰它。 - 让
index.md保持字母顺序,这样不同的食谱通常位于不同的行(不过相邻的行仍可能冲突)。 - 在发起拉取请求之前,先把
main合并进分支,这样后提交的人就能在对方的改动还很小时看到它。 - 用脚本根据食谱文件自动生成
index.md,而不是手工编辑。
这是谁加的? 一位新成员问:苏打面包是谁加进 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
-S "Soda bread" 找到了加入这段文字的提交 0070943。--format 随后打印出它的作者和说明。git blame index.md 能逐行给出类似的答案:它为每一行显示最后一次改动它的提交。
误推送的标签。 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
已经获取了 v2.0 的人会保留他们的那一份,因为获取从不删除标签。标签本应是永久的,所以当某个已发布的版本有问题时,请发布一个新版本,例如 v1.0.2,而不要移动或删除别人可能已经拿到的标签。
用哪种标签? 为什么这个项目的发布版本使用附注标签而不是轻量标签?什么时候轻量标签就够了?
查看解答
附注标签记录了是谁发布的、什么时候,以及一段说明,git show 等工具都会显示这些信息。它还可以被签名。轻量标签只是某次提交的一个名字:用作不打算分享的私人书签(例如 before-big-change)就足够了。
保护 main。 在里程碑 3 中,没有任何东西能阻止 Sam 不看内容就合并拉取请求 #1,或者直接推送到 main。团队可以在 GitHub 上设置什么?设置之后,每个人的做法会有什么不同?
查看解答
对 main 设置规则集或分支保护,要求必须有拉取请求,并且至少有一次批准的审查。这样,直接推送到 main 会被拒绝,而在作者以外的人批准之前,Merge pull request 按钮一直不可用。使用传统的分支保护时,还要勾选 Do not allow bypassing the above settings,否则这些规则不约束仓库所有者;规则集则约束绕过列表(bypass list)以外的所有人。每个人的工作方式与这个项目中完全一样:创建分支、推送、发起拉取请求、审查。
自测
main 并解决冲突之后,拉取请求会自动更新。v1.0 标签并运行了 git push。为什么 GitHub 上没有这个标签?--tags 一次全部推送。v1.4.2 中某个缺陷的版本编号为: