衔接上篇Git 与 GitHub:仓库、版本历史与 Git 对象模型: 上篇已建立 Repository、Commit、Tree、Blob、路径与版本历史的对象模型。本篇继续解释 Git 怎样利用这些对象与引用建立不同开发线,并最终整合修改。


文章目录
- 九、Branch:为什么一个仓库能够拥有多条开发线?
- 十、HEAD:当前工作区怎样确定提交上下文?
- 十一、HEAD 是否决定所有 Commit 操作的目标?
- 十二、Detached HEAD、提交可达性与垃圾回收
- 十三、Git 提交图与并行开发
- 十四、Pull Request:为什么 GitHub 需要修改审查流程?
- 十五、Merge Base 与三方合并:为什么不能只比较两个最新版本?
- 十六、PR 的 Files changed 与实际 Merge 为什么比较范围不同?
- 十七、Ahead 与 Behind:提交可达集合的差异
- 十八、Merge Commit:合并后 Git 创建了什么?
- 十九、Create a merge commit、Squash 与 Rebase
- 二十、本次实际 GitHub 练习的完整操作链
九、Branch:为什么一个仓库能够拥有多条开发线?
1. 为什么需要分支?
假设商城项目的主分支 main 已经能够正常运行。
现在需要并行开发:
- 支付功能。
- 登录 Bug 修复。
- 订单查询优化。
如果将全部尚未完成的修改直接提交到 main,主分支可能会包含开发到一半的代码。
Git 允许不同开发任务沿不同提交历史推进,并在适当时机整合。
例如:
main 稳定项目版本
feature/payment 支付功能开发
fix/login 登录问题修复
这些名称只是分支命名约定。
feature/ 和 fix/ 不是 Git 强制规定的特殊分支类型。
它们仍然是普通 Git 分支引用。
2. Branch 的本质:可移动的提交引用
假设已有三个提交:
A ← B ← C
↑
main
箭头朝左表示较新的提交通过 parent 引用较早的提交。
此时:
main → C
创建新分支:
feature
正常情况下可以得到:
A ← B ← C
↑
├── main
└── feature
也就是:
main → C
feature → C
创建新分支并不要求复制一套独立的 A、B、C 对象。
两个不同的分支引用可以指向同一个 Commit。
因此,创建分支本身通常不会产生新 Commit,也不会因为新增一个分支就自动复制整个项目的所有文件内容。
3. 新分支为什么一开始拥有相同的文件?
因为两个分支都指向 C。
Commit C 又通过根 Tree 引用定位同一个项目版本。
因此,这两个分支此时展示的是同一个文件树状态。
例如:
C 对应的项目:
├── README.md
└── Main.java
刚创建 feature 时:
main 对应 C:
├── README.md
└── Main.java
feature 对应 C:
├── README.md
└── Main.java
文件相同的直接原因是两个分支指向同一个 Commit,而 Commit 对应一个确定的文件树状态。
4. 功能分支产生新提交以后会发生什么?
假设在 feature 上增加:
Payment.java
然后创建 Commit D。
提交关系变为:
A ← B ← C ← D
↑ ↑
main feature
现在:
main → C
feature → D
D.parent = C
因此:
main 对应的版本:
README.md
Main.java
feature 对应的版本:
README.md
Main.java
Payment.java
它们显示的文件状态不同,是因为各自指向的 Commit 不同。
这并不意味着 feature 的项目内容永久归属于一个独立文件夹。
不同分支依然可以引用相同的历史 Commit 和 Blob、Tree 等对象。
5. 为什么功能分支可以访问共同历史,而 main 不能沿历史找到 D?
关键在 Commit 的 parent 引用方向。
D.parent = C
C.parent = B
B.parent = A
feature 从 D 开始,可以沿 parent 找到:
D → C → B → A
main 从 C 开始,可以沿 parent 找到:
C → B → A
但是 C 中通常不记录一个自动更新的子提交列表:
C.children = [D]
D 是后来新创建的不可变 Commit,它记录了自己的父提交是 C。
创建 D 不会回头修改 C。
因此,从 C 沿着 parent 关系向前追溯历史,不会自动走到未来才创建的 D。
这里必须区分:
- D 能否从 main 当前历史中被访问到。
- D 是否存在于仓库对象数据库中。
即使 main 不能沿其 parent 历史到达 D,D 依然可能通过 feature、其他引用或已知对象 ID 被访问。
6. Commit 不具有永久的排他性分支归属
假设 D 最初是在 feature 上创建的。
这不意味着 D 永远只能属于 feature。
如果后来 main 通过合并形成可以到达 D 的历史,那么 D 也会出现在 main 可达的提交集合中。
Git 不必为 main 重新创建一个相同的 D。
同一个 Commit 对象能够通过多条不同分支的历史被访问。
因此,分支不是为一批 Commit 设置永久的独占所有权。
它只是一个会随着操作改变指向位置的引用。
十、HEAD:当前工作区怎样确定提交上下文?
1. HEAD 是什么?
HEAD 是 Git 的一种特殊引用。
在普通本地分支工作状态下,HEAD 通常是一个符号引用,指向当前检出的分支名称。
例如:
HEAD → main → C
其中:
- HEAD 引用 main。
- main 引用 Commit C。
- C 引用该版本根 Tree 和父提交。
因此,HEAD 与 Branch、Commit 处于不同的层次。
HEAD 表示当前工作区的检出位置。
Branch 表示一个可移动的提交引用。
Commit 则记录一次具体的版本状态和提交历史关系。
2. 切换分支与创建提交修改的是什么?
假设:
main → C
feature → C
HEAD → main
如果切换到 feature:
HEAD → feature → C
主要改变的是 HEAD 的符号引用目标。
两个分支仍然可以都指向 C。
如果随后在 feature 上正常创建提交 D:
HEAD → feature → D
main → C
主要变化为:
- 创建新 Commit D。
- 记录
D.parent = C。 - 让 feature 更新为指向 D。
- HEAD 仍然指向 feature。
因此,在普通附着分支状态下,正常提交通常移动当前分支引用,而 HEAD 继续指向当前分支。
3. HEAD 是否能直接指向 Commit?
可以。
当 HEAD 直接指向某个提交 ID,而没有通过一个普通本地分支引用定位它时,称为 Detached HEAD(分离头指针)状态。
例如:
main → C
HEAD → B
此时 HEAD 直接指向 B。
这种状态可以用于临时检出某个历史版本。
4. GitHub 网页切换分支是否等于修改本地 HEAD?
不等于。
在 GitHub 网页上点击分支选择器:
main
feature/branch-practice
主要是在选择网页当前展示和操作的远程分支上下文。
它并不会自动切换你 Windows 本地某个 Git 仓库的 HEAD。
网页分支选择器与本地 Git HEAD 可能都用于决定“当前所看的版本”,但它们不是同一个状态对象。
十一、HEAD 是否决定所有 Commit 操作的目标?
这是后续追问中多次深化的一个问题。
1. 正常提交使用当前 HEAD 上下文
假设:
HEAD → main → C
feature → D
如果在这个本地工作区正常执行 git commit,那么在常见条件下:
- Git 使用当前索引准备提交的文件状态。
- 新提交以 C 为父提交。
- main 更新为指向新提交 E。
HEAD → main → E
feature → D
E.parent = C
所以,普通 git commit 的确与 HEAD 所确定的当前提交上下文有关。
如果希望沿 feature 继续正常开发,通常需要切换到 feature。
2. 查看旧 Commit 不必修改 HEAD
例如:
git show <D的提交ID>
可以直接查看 D。
即使:
HEAD → main → E
依然可以定位 D,只要对象存在且能够访问。
同理:
git diff <C的提交ID> <D的提交ID>
可以比较两个明确指定的提交,而不要求 HEAD 指向其中任何一个。
因此,HEAD 不决定 Git 是否有能力读取所有其他 Commit。
3. 从旧提交创建分支也不必先移动 HEAD 到旧提交
例如:
git branch experiment <B的提交ID>
可以创建:
experiment → B
当前 HEAD 仍然可以指向 main。
新分支建立后,可以在需要时切换过去开发。
如果想创建并立即切换到这个分支:
git switch -c experiment <B的提交ID>
这会在通常条件下以 B 为起点创建新分支,并将当前工作区切换过去。
4. 普通 Commit 与直接创建底层提交对象不同
Git 还存在:
git commit-tree
这类底层命令可以根据指定的 Tree 和父提交创建新的 Commit 对象,而不要求先将 HEAD 切换到该父提交。
但直接创建 Commit 对象与更新某个分支引用是两个步骤。
如果新对象没有被任何保留引用定位,后续仍可能出现可达性和清理问题。
所以不能将“普通 git commit 依赖当前 HEAD”扩大为“Git 的一切提交对象操作都必须通过 HEAD 才能完成”。
5. 移动分支不等于立即销毁 Commit
例如:
A ← B ← C ← D
↑
main
如果通过某种引用更新操作让 main 回到 B:
main → B
旧提交 C、D 不会因为引用变化而立即被原地删除。
它们是否仍可访问、是否最终被回收,取决于其他引用和保留机制。
因此,Git 中需要区分:
- 修改文件内容。
- 创建 Commit 对象。
- 移动分支引用。
- 撤销某次提交造成的内容变化。
- 改写某条分支可达的历史。
- 删除底层不可达对象。
这些操作具有不同的效果。
十二、Detached HEAD、提交可达性与垃圾回收
1. Detached HEAD 如何产生?
假设:
A ← B ← C
↑
main
↑
HEAD
执行:
git switch --detach <B的提交ID>
得到:
A ← B ← C
↑ ↑
HEAD main
HEAD 直接指向 B。
main 仍然指向 C。
Git 没有因为这次操作创建一个名为 Detached HEAD 的普通分支。
Detached HEAD 是一种 HEAD 引用状态。
2. 在 Detached HEAD 下创建 Commit
假设从 B 检出的文件版本出发进行修改,并创建新提交 D。
结果:
C ← main
/
A ← B
\
D ← HEAD
其中:
D.parent = B
HEAD → D
main → C
这里,HEAD 会移动到 D。
但没有普通分支因此自动指向 D。
所以 Detached HEAD 状态并不禁止创建提交。
3. 切回 main 后,D 会怎样?
执行:
git switch main
HEAD 重新通过 main 指向 C。
HEAD → main → C
D.parent = B
如果没有其他分支、标签等引用能够访问 D,那么 D 可能失去普通引用入口。
但 D 不会因为切回 main 就被立即物理删除。
它仍可能暂时存在于 Git 对象数据库中。
4. 什么是 Reachability(可达性)?
可达性描述从某个引用或对象出发,沿 Git 对象引用关系能否到达另一个对象。
例如:
main → E → D → B
虽然 main 没有直接指向 D,但它可以通过 E 的父提交关系到达 D。
D 因此仍然可以从 main 的历史访问。
与之不同:
main → C → B
D → B
从 main 指向的 C 沿父提交关系找不到 D。
虽然 D 自己知道父提交是 B,但 B 不保存一个自动更新的 D 子提交引用。
引用方向不能颠倒。
D 引用 B,并不能推出从 B 出发沿 parent 引用可以找到 D。
5. 什么是 Reflog?
Reflog 是 Git 用于记录 HEAD 或某些引用更新历史的机制。
例如:
HEAD 曾指向 B
HEAD 后来指向 D
HEAD 又切换回 main
本地 Reflog 可能仍然保存相关变化记录。
即使 D 暂时不再能从普通分支访问,也可能通过 Reflog 找到其对象 ID。
但 Reflog 并不是永久历史保留机制。
记录可能过期,Git 也会在满足条件时清理不可达对象。
常见配置中,Reflog 普通记录和不可达记录具有不同的过期时间设置;是否真正清除对象还取决于垃圾回收和对象保留条件。
因此,不能假设 Reflog 一定永久保留所有丢失的提交。
6. 如何保留 Detached HEAD 中创建的提交?
如果 HEAD 目前直接指向 D,可以创建一个新分支:
git switch -c save-experiment
结果:
HEAD → save-experiment → D
如果已经离开 D,但 D 对象仍然存在,并且知道其提交 ID,也可以执行:
git branch save-experiment <D的提交ID>
为 D 建立一个普通分支引用。
因此,在 Detached HEAD 中提交的风险主要来自:离开之前没有建立希望长期保留的引用,之后不容易找回新提交。
Detached HEAD 本身并不意味着无法提交,也不意味着提交后立刻被回收。
7. 分支是否只能向前移动?
分支引用可以通过不同 Git 操作指向不同的已有提交。
普通提交通常使当前分支沿新提交向前发展。
但 reset 等操作可以修改分支指向,例如让当前分支回到旧提交。
示例:
git reset --hard <B的提交ID>
会重置当前分支、索引和工作区到指定版本。这类操作可能丢失尚未保存的工作区修改,不应在重要项目中随意执行。
因此,“分支引用可移动”不等于“分支只能通过创建新提交向前移动”。
十三、Git 提交图与并行开发
1. 提交历史是有向无环图
普通提交可以通过 parent 引用更早提交。
例如:
A ← B ← C ← D
如果两条分支分别从 C 发展:
E ← F
/
A ← B ← C
\
D ← G
就形成了分叉。
如果以后通过 Merge Commit M 将两条历史整合,M 可以同时关联 F 与 G:
M.parent1 = F
M.parent2 = G
因此,Git 提交历史可以形成有向无环图(DAG)。
普通的单父提交历史看起来像一条线,但整个 Git 仓库不必始终只有一条线。
2. HEAD 只表示当前工作区,不限制仓库的全部分支
一个普通本地工作区的 HEAD 通常只能选择一个当前分支。
但仓库可以拥有多个其他分支。
其他协作者也可以在各自的本地仓库中推进不同分支。
Git 还允许多个 Worktree,各工作区具有自己的 HEAD。
因此,当前工作区选择哪个分支,并不意味着仓库的其他分支不能被其他人或其他工作区更新。
3. 两个分支分别提交,是否会互相覆盖?
假设:
main → C
feature → C
main 创建提交 E,feature 创建提交 D。
E ← main
/
A ← B ← C
\
D ← feature
E 与 D 是不同的 Commit 对象。
创建 E 不会原地修改 D。
创建 D 也不会原地修改 E。
所以,两个分支能够独立推进。
但这不代表它们以后合并时一定没有冲突。
因为合并需要决定两个分支分别修改的内容如何整合。
十四、Pull Request:为什么 GitHub 需要修改审查流程?
1. Pull Request 是什么?
Pull Request,简称 PR,是 GitHub 提供的协作与变更审查机制。
假设:
main 稳定项目
feature/payment 支付功能
开发者完成支付功能后,希望把修改加入 main。
可以创建一个 PR,提出:
将 feature/payment 的修改整合进 main
PR 用于展示、讨论和检查这一组修改。
Git 自身的 Merge 负责版本历史与文件内容的整合机制;PR 则提供 GitHub 平台上的协作管理流程。
创建 PR 并不等于自动完成合并。
2. Base 与 Head
一个普通 PR 具有一组明确的源、目标分支。
例如:
Base:main
Head / Compare:feature/payment
Base 是准备接收修改的目标分支。
Head 是提出修改的源分支。
如果将二者颠倒,请求的方向也会变化。
单个普通 PR 通常只有一组 Base 和 Head。
但一个 PR 可以包含源分支上的多个 Commit。
例如:
feature/payment:
D → E → F
PR 可以包含 D、E、F 等多次提交所构成的变更。
还可以创建多个 PR,共同以 main 为目标:
PR #1:feature/payment → main
PR #2:fix/login → main
这是多个 PR,而不是一个 PR 同时拥有两个独立 Head 分支。
3. PR 页面主要内容
GitHub 的 PR 页面通常包含:
| 区域 | 作用 |
|---|---|
| Conversation | 讨论、审查意见与 PR 事件 |
| Commits | 查看 PR 包含的提交 |
| Checks | 查看可用自动检查结果 |
| Files changed | 查看源分支的文件变化 |
PR 还可能显示合并状态,例如是否存在无法自动处理的冲突。
Able to merge 表示 GitHub 当前判断可以按相应方式执行自动合并,并不等于软件功能已经验证正确。
PR 还可能被设置为需要代码审查或自动检查通过后才能合并。
4. PR 的状态
典型状态包括:
- Open:PR 仍然打开。
- Closed:PR 已关闭,但未必合并。
- Merged:PR 已合并。
如果源分支在 PR 打开期间继续产生新提交,PR 通常会更新,纳入源分支中的相关新变化。
因此,PR 不是某个永远固定不变的提交差异截图,而是与相应分支和提交状态关联的协作对象。
十五、Merge Base 与三方合并:为什么不能只比较两个最新版本?
这一部分是整个学习过程中多次深入追问的重点。
1. 从一个完整项目例子开始
假设共同祖先 Commit C 对应两个 Java 文件:
Project/
├── A.java
└── B.java
内容:
// A.java
int a = 1;
// B.java
int b = 1;
此时:
main → C
feature → C
随后分支独立开发。
main 创建 E,再创建 F,将 A.java 修改为:
int a = 2;
feature 创建 D,再创建 G,将 B.java 修改为:
int b = 2;
最终提交关系:
E ← F (main)
/
A ← B ← C
\
D ← G (feature)
当前三个关键提交是:
- C:双方的共同祖先。
- F:main 当前最新提交。
- G:feature 当前最新提交。
C 可以作为本例的 Merge Base。
Merge Base 是两条提交历史的最佳共同祖先。复杂情况下可能存在多个最佳共同祖先,但当前例子只需要一个。
2. 三个 Commit 的文件树状态
| 文件 | C(祖先) | F(main) | G(feature) |
|---|---|---|---|
| A.java | a = 1 | a = 2 | a = 1 |
| B.java | b = 1 | b = 1 | b = 2 |
注意,每个 Commit 对应一个完整项目文件树。
即使 F 只改变了 A.java,它的文件树依然包含 B.java。
即使 G 只改变了 B.java,它的文件树依然包含 A.java。
3. 为什么要先比较 C 与 F?
比较:
C → F
可以知道 main 相对于共同祖先进行了什么修改。
结果:
A.java:1 → 2
B.java:1 → 1
因此,main 对 A.java 形成了最终内容变化,对 B.java 没有形成最终内容变化。
这里说“没有最终内容变化”,不等于中间 E、F 中从来没有编辑过 B.java。
即使中途修改后又改回原来的内容,最终 C 与 F 的比较也可能显示没有差异。
4. 为什么还要比较 C 与 G?
比较:
C → G
可以知道 feature 相对于共同祖先进行了什么修改。
结果:
A.java:1 → 1
B.java:1 → 2
因此,feature 对 B.java 形成了最终内容变化,对 A.java 没有形成最终内容变化。
有了两组变化,Git 就能够分析双方分别修改了什么。
5. 怎样决定最终合并结果?
对于 A.java:
C:a = 1
F:a = 2
G:a = 1
只有 main 形成了修改。
因此,可以保留:
int a = 2;
对于 B.java:
C:b = 1
F:b = 1
G:b = 2
只有 feature 形成了修改。
因此,可以保留:
int b = 2;
最终得到:
Merge Result
A.java:a = 2
B.java:b = 2
这就是典型三方合并的基本思路。
三方合并利用共同祖先以及两边当前版本所对应的文件状态,识别各自的修改并尝试整合。
6. 为什么不能仅仅比较 F 与 G?
如果仅比较 F 与 G:
| 文件 | F | G |
|---|---|---|
| A.java | a = 2 | a = 1 |
| B.java | b = 1 | b = 2 |
只能确定两个最终版本有差异。
但无法仅凭这些内容可靠判断:
- A.java 是 main 将 1 改成 2,还是 feature 将 2 改回 1?
- B.java 是 feature 将 1 改成 2,还是 main 将 2 改回 1?
需要共同祖先 C 提供双方分开修改之前的版本状态。
有了 C,就能知道:
main 的变化:C → F
feature 的变化:C → G
因此,C 的作用是帮助判定双方修改的来源。
F 和 G 则分别表示两条开发线当前的项目状态。
7. 是否必须逐个比较中间的 E、D 等提交?
对于上面的典型三方合并,不需要将两条分支上“相同位置”的提交一一配对。
例如:
main:C → E → F
feature:C → D → G
不存在 Git 必须把 E 与 D、F 与 G 配成两组并分别合并的通用规则。
Git 可以通过:
- C 的文件树。
- F 的文件树。
- G 的文件树。
计算当前两条开发线之间的合并结果。
E、D 仍然具有历史意义,但典型三方合并不必通过逐项配对所有中间提交完成。
这是因为 Commit 具有完整版本状态的语义,Git 可以直接从所需提交定位对应文件树。
8. 两边修改同一个文件会发生什么?
假设共同祖先 C:
int timeout = 30;
main 的 F:
int timeout = 60;
feature 的 G:
int timeout = 10;
那么:
C → F:30 → 60
C → G:30 → 10
两边都改变了同一处内容,而且修改结果不同。
Git 可能无法自动选择最终内容,从而产生 Merge Conflict。
9. 修改同一个文件一定冲突吗?
不一定。
例如 C:
int timeout = 30;
int retries = 3;
main 修改第一行:
int timeout = 60;
int retries = 3;
feature 修改第二行:
int timeout = 30;
int retries = 5;
双方修改区域没有重叠时,Git 通常可以自动整合:
int timeout = 60;
int retries = 5;
同一个文件发生修改,并不必然冲突。
同样,如果双方将同一处改成完全相同的内容,Git 通常也能自动整合。
冲突是否产生,需要看具体修改的内容、位置和合并算法能否确定兼容结果。
10. 自动合并成功是否保证代码正确?
不能保证。
例如:
main 将函数返回类型由整数改为字符串。
feature 在另一个文件中继续按照整数返回值使用该函数。
Git 可能认为这两个文件的文本修改没有冲突,从而自动合并。
但合并后的代码可能无法编译或无法正确运行。
所以 Git 的文本合并和程序逻辑正确性属于不同问题。
项目还需要测试、编译、代码审查等手段。
十六、PR 的 Files changed 与实际 Merge 为什么比较范围不同?
1. PR 的目标是什么?
一个 PR:
Base:main → F
Head:feature → G
其 Files changed 主要用于向审查者展示源分支引入了哪些修改。
因此,GitHub 的普通 PR 通常采用 Three-dot Comparison(三点比较)。
在当前简单提交图中:
Merge Base:C
Head:G
主要比较:
C → G
所以在前面的例子中,PR 显示:
B.java:1 → 2
而 A.java 没有显示为源分支修改,因为 feature 相对于 C 并没有改变 A.java。
2. Two-dot 与 Three-dot 的区别
如果直接比较当前两个版本:
F ↔ G
得到的是两个最新版本之间的文件差异。
例如:
A.java:2 ↔ 1
B.java:1 ↔ 2
但这个差异不能直接解释每项修改来自哪一侧。
Three-dot 则利用共同祖先,主要展示源分支相对于共同基础的变化。
因此,两种比较回答不同问题:
| 比较 | 主要回答 |
|---|---|
| Two-dot | 两个指定版本的文件状态有什么不同? |
| Three-dot | 源分支相对于双方共同基础引入了哪些变化? |
这里特别要区分 Git 命令的语法:对于 git diff,A..B 基本相当于直接比较 A 与 B;A...B 使用 Merge Base(A,B) 与 B 比较。不能把它机械套用到 git log 的同名双点、三点语法上,因为两者语义不同。
3. Files changed 不显示 main 的 A.java,实际合并会漏掉吗?
不会仅仅因为 PR 的三点差异视图没有列出 A.java,就让 Git 自动忽略 main 中已有的修改。
实际三方合并会考虑:
共同祖先 C
目标分支 F
源分支 G
所以在当前例子中,可以得到:
A.java:a = 2
B.java:b = 2
PR Files changed 与真正的 Merge Result 属于不同的计算结果。
但 PR 差异视图本身也不能保证揭示所有合并后可能出现的逻辑兼容性问题,因此仍需要测试和审查。
十七、Ahead 与 Behind:提交可达集合的差异
假设:
E ← F ← H (main)
/
A ← B ← C
\
D ← G (feature)
main 可以沿父提交历史访问:
H、F、E、C、B、A
feature 可以访问:
G、D、C、B、A
相对于 main:
feature 独有的可达提交:
D、G
共两个。
main 独有的可达提交:
E、F、H
共三个。
因此,feature 相对于 main 可以显示:
2 commits ahead
3 commits behind
含义:
- ahead 2:feature 有两个 main 尚不可达的提交。
- behind 3:main 有三个 feature 尚不可达的提交。
这表示提交历史关系。
不表示文件多出两个、代码多出三行,也不表示某条分支开发进度一定更好。
即使两条分支当前文件树相同,也仍然可能因为提交对象及历史关系不同而具有 ahead/behind 计数。
十八、Merge Commit:合并后 Git 创建了什么?
1. Merge Commit 具有多个父提交
假设合并前:
main → F
feature → G
执行 Create a merge commit 后,可以创建新提交 M。
M.parent1 = F
M.parent2 = G
同时:
main → M
M 记录合并后的文件树状态。
F 和 G 仍然是原来的 Commit 对象,并不会因为 M 创建就被原地修改。
所以 Merge Commit 不是创建在 F 的对象内部,也不是把原来的 G 改造成一个新的合并版本。
它是一个新的 Commit。
2. Merge Commit 怎样让 main 能访问功能分支的历史?
假设此前:
main → C
feature → D
D.parent = C
完成 Merge Commit 后:
main → M
M.parent1 = C
M.parent2 = D
现在从 main 指向的 M 出发:
- 沿第一父提交关系可以找到 C。
- 沿第二父提交关系可以找到 D。
- 从 D 又可以继续找到其原来的父提交 C。
因此,D 已经可以从 main 的提交历史访问到。
这不要求将 D 再完整复制创建一个新对象。
Git 使用 M 的两个父提交引用连接两条历史。
3. M 与 D 是否可以对应完全相同的文件树?
可以。
假设:
C:
notes/
└── 01-github-basics.md
功能分支 D:
D:
notes/
├── 01-github-basics.md
└── 02-branch-practice.md
而 main 在 C 之后没有额外文件修改。
合并后 M:
M:
notes/
├── 01-github-basics.md
└── 02-branch-practice.md
此时 D 与 M 的项目文件状态相同。
它们可以引用相同的根 Tree。
但它们的提交对象不同:
D.parent = C
M.parent1 = C
M.parent2 = D
所以 D 和 M 仍具有不同的提交身份。
提交相同的文件树,不意味着提交对象相同。
4. 为什么 notes 的 History 可能没有显示 M?
本次实际练习中,曾经观察到:
在 main 的 notes/ 目录 History 页面里,显示:
Add branch practice notes
adcbea7
却没有看到预期中的新 Merge Commit。
这涉及 Git 的 Path History Simplification(路径历史简化)。
当某个 Merge Commit 对指定路径的文件树状态与其中一个父提交相同时,Git 的路径历史查询可能省略该合并节点,沿对应父提交继续简化展示。
例如:
C:notes 中只有文件 01
D:notes 中有文件 01、02
M:notes 中有文件 01、02
M 与第二父提交 D 的 notes/ 状态相同。
因此,针对 notes/ 的路径相关历史可能重点显示 D,而不显示 M。
这并不表示 M 一定没有创建。
它只表示该路径历史视图不一定展示仓库所有可达提交。
5. 如何验证 Merge Commit 是否真实存在?
应进入仓库整体的 main 提交历史,而不是只查看 notes/ 目录 History。
或者打开已合并的 PR,查看其中的合并事件与 Merge Commit 信息。
本次截图能够确认:
adcbea7是Add branch practice notes对应的源分支提交。- 合并后
main已经能够显示新增的笔记文件。
但此前没有独立读取实际新 Merge Commit 的完整 ID 与父提交数据,因此不能仅凭目录 History 截图断言实际 M 的具体 SHA。
需要把“根据标准 Merge Commit 机制推导的结果”与“实际仓库已经核验的对象 ID”区别开。
十九、Create a merge commit、Squash 与 Rebase
1. Create a merge commit
假设:
main:C
feature:C ← D ← E
通过 Merge Commit 整合后,通常会创建 M:
M.parent1 = C
M.parent2 = E
main → M
D、E 仍然是原来的提交对象。
M 让 main 可以通过第二父提交关系访问源分支历史。
这种方式保留了两条历史之间的合并关系。
2. Squash and merge
还是:
main:C
feature:C ← D ← E
Squash 会将 PR 的综合修改整合为目标分支上的一个新提交 S:
main:C ← S
feature:C ← D ← E
S 表达综合修改后的项目版本。
D 和 E 不会作为两个独立提交原样进入 main 这段新历史。
但它们也不会因为执行 Squash 就立即被销毁。
如果源分支仍存在,就仍可能通过源分支访问 D、E。
只有在对象失去相关保留引用,并且满足后续清理条件时,才可能被垃圾回收清除。
3. Rebase and merge
假设:
main:C ← F
feature:C ← D ← E
Rebase and merge 可以在 F 的基础上重新应用 D、E 的修改,生成新的提交对象:
main:C ← F ← D' ← E'
D’、E’ 是新的提交。
它们具有不同的父提交关系,因而对象 ID 通常与原来的 D、E 不同。
这里的重新应用与新建提交,不等于原地修改旧 Commit 对象。
原 D、E 是否仍然存在于可访问历史中,取决于源分支及其他引用是否保留。
4. 三种方式的核心区别
| 合并方式 | 目标分支新增历史 | 原提交处理 |
|---|---|---|
| Merge Commit | 新的 M 连接两条历史 | 原提交保持不变并通过合并历史可达 |
| Squash | 一个新提交 S 表达综合修改 | 源提交不作为独立提交原样进入目标历史 |
| Rebase and merge | 重新应用修改,形成新提交 D’、E’ | 旧对象不被原地修改,是否保留取决于引用 |
另外,Git 的普通合并还可能采用 Fast-forward(快进合并):如果目标分支指向的提交本来就是源分支当前提交的祖先,可以直接将目标分支引用前移,而不一定要创建 Merge Commit。
这些合并方式涉及不同的历史组织策略,后续系统教学还需要正式讲授操作条件、冲突处理和选择标准。
二十、本次实际 GitHub 练习的完整操作链
此次教学从网页端开始,逐步把抽象概念与仓库实际变化对应起来。
1. 创建练习仓库
github-study-lab
设置为 Private,并初始化 README。
形成初始化提交。
2. 修改 README
加入 GitHub 学习说明。
提交:
Update learning notes
仓库累计两次提交。
3. 创建笔记目录与文件
创建:
notes/01-github-basics.md
提交:
Add GitHub basics notes
仓库累计三次提交。
4. 上传、重命名、移动、删除与恢复
围绕:
upload-demo.txt
依次练习:
上传
↓
重命名为 github-upload-example.txt
↓
移动到 examples/
↓
删除文件
↓
从历史中找到旧内容
↓
通过新提交重新添加文件
如果每步各创建一次提交,累计可以形成八次提交。
不同操作在版本文件树中体现为不同的路径或内容状态,不会回头修改过去的 Commit。
5. 创建功能分支
从 main 创建:
feature/branch-practice
创建分支后:
main → C
feature → C
随后在功能分支创建:
notes/02-branch-practice.md
提交:
Add branch practice notes
真实练习中该提交曾显示短 ID:
adcbea7
此时功能分支与 main 指向不同提交,对应不同文件状态。
6. 创建 Pull Request
使用:
Base:main
Compare:feature/branch-practice
检查 Files changed,再申请将功能分支修改整合到 main。
本次练习涉及:
Create a merge commit
合并后 main 中能够看到新增文件。
后续又通过观察目录 History,进一步学习了 Merge Commit、原源分支提交以及路径历史简化之间的区别。
这些实际操作为后面的 Git 本地操作课程提供了可参考的仓库状态。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/2401_86112610/article/details/168453951




