第 8 章 工作区与检出:unpack-trees 与并行 checkout
8.1 三种"切树"操作,一个统一引擎
git checkout、git reset、git merge、git stash、git read-tree......这些命令看似毫无关系,内部却共享同一个引擎:unpack-trees.c(约 2679 行) 。它的核心抽象是"把若干棵树同时摊进工作区/索引,并处理它们之间的差异 "。调用方通过 struct unpack_trees_options 声明要摊开几棵树、遇到冲突怎么办:
- 两棵树(切换分支) :
HEAD与目标提交。git checkout <branch>、git switch都走这条路径; - 三棵树(合并) :base、ours、theirs。
git merge的核心三路合并在这里展开; - 单棵树(重置) :
git reset --hard把索引与工作区整体对齐到一棵树。
unpack_trees() 的入口是"上一棵树(通常是索引)→ 目标树"的逐条目归并(merge walk):三个游标(base/ours/theirs 各自的树遍历器)同步前进,每到一个路径就做一次三方决策。决策逻辑的表面积很大(unpack-trees.c 的 merged_entry()、twoway_merge()、threeway_merge() 与 verify_absent() 等),要处理的情形包括:文件→目录的 D/F 冲突、符号链接与目录的冲突、未跟踪文件挡路、gitignore 与 sparse 模式下的跳过、submodule(gitlink)的指针更新等。
一个关键设计:unpack-trees 默认"尽量不动工作区" 。它先计算"目标树与现状的差异集合",凡是会覆盖未提交改动的路径,都交给上层(checkout 的安全检查)拒绝或要求用户 -f。verify_absent() / verify_clean() 系列函数保证:不经过用户确认,checkout 绝不会吃掉工作区里的未提交内容。
8.2 条目落盘:entry.c 与文件写入细节
树/索引层面的决策完成后,真正把文件内容写到工作区的是 entry.c 。checkout_entry() 负责单条路径的落地,考虑如下细节:
- 符号链接 :不写文件内容,而是
symlink()创建链接(Windows 上退化为特殊文本文件或真实链接,取决于core.symlinks); - 可执行位 :按
core.filemode决定是否恢复 100755 权限; - 换行符转换 :
convert.c的 clean/smudge 过滤器(core.autocrlf、.gitattributes的text/eol)在"对象内容 ⇄ 工作区内容"之间做双向转换------对象库存的是仓库内规范格式,工作区拿到的是平台格式,索引条目则永远记录规范化后的 OID; - 大文件 :超过
core.bigFileThreshold的走流式写(streaming.c); - 崩溃安全 :
checkout_entry()先写临时文件再 rename,避免半截文件(配合core.fsync配置决定 fsync 策略)。
8.3 并行 checkout:把"十万文件落地"做成多线程
在超大仓库上,git checkout 最大的瓶颈是文件写入的 I/O 延迟 ------百万文件逐个 open/write/close,单线程要花上分钟级。parallel-checkout.c 把这块变成多进程流水线:
- 主进程用
run-command.c拉起若干 checkout worker 子进程 (数量由checkout.workers配置,默认取 CPU 数),通过管道(pipes)分发"写文件任务"; - 每个 worker 独立完成 解压对象 → 转换过滤器 → 写临时文件 → fsync/rename 的整条链路;
- 主进程只保留轻量的调度与校验(
parallel_checkout.c的pc_queue队列)。
注意一个微妙的正确性问题:过滤器(filter)与转换是有状态的 (例如 LFS 的 smudge 需要按文件逐个调外部进程),因此并行 worker 与主进程必须共享一致的转换语义------实现上,任务包(struct pc_item)里携带了完整的路径、OID、模式与转换上下文,worker 侧用同一套 convert.c 逻辑执行。对普通用户,感知到的只是"checkout 变快了";对实现者,这是"无守护进程 Git"里少见的长期存活子进程协作(sub-process.c 提供了统一的生命周期管理)。
8.4 检出后的索引刷新与 fsmonitor
文件落地后,索引必须与新的工作区状态重新对齐:refresh_index() 重新 stat 全部条目(或使用 preload:preload-index.c 用线程池并行做 lstat,把扫描时间摊到多核)。在超大型仓库上,每次 status/checkout 全量 stat 仍是成本大头,于是 Git 引入了文件系统监视器(fsmonitor):
- 客户端(
fsmonitor.c)向索引写入 FSMN 扩展,记录"自上次以来哪些路径被修改"; git status只需 stat"监视器报告的变更路径",未变更目录整体跳过;- 服务端形态是
fsmonitor--daemon(一个常驻进程,fsmonitor-ipc.c提供 IPC;Windows 上用 ReadDirectoryChangesW,macOS 用 FSEvents,Linux 用 inotify/fsmonitor hook)。
这是 Git 架构里罕见的一次"从无守护进程到引入常驻进程"的突破------为了规模,Git 谨慎地允许了一个"可选、可回退、不影响数据正确性"的常驻组件。
8.5 sparse-checkout 与 skip-worktree
稀疏检出(第 21 章详述)在工作区层表现为:未选中的路径在索引里带 CE_SKIP_WORKTREE 标志,unpack-trees 遇到这些路径时只更新索引、不写工作区 (skip_worktree 语义,unpack-trees.c 的 apply_sparse() 专门处理稀疏范围的收窄与扩大)。这里有一个长期存在的"陷阱"值得一提:用户对 skip-worktree 路径的手工修改,Git 的 stat 缓存默认看不到(这正是 git update-index --skip-worktree 的初衷------"这文件别管我,我自己管"),而 sparse-checkout 语义则要求"不检出就不存在"。二者在 CE_SKIP_WORKTREE 上的历史纠缠,是理解 git sparse-checkout reapply 为什么存在的钥匙。
8.6 一条完整的 checkout 时间线
把整章串起来,git checkout feature 的内部时间线大致是:
builtin/checkout.c解析参数,确定目标提交与分支;- 安全检查:目标分支是否已合并、工作区是否有未提交改动会冲突(
checkout的"abort if dirty"逻辑); unpack_trees()两树归并:算出需要"新增/更新/删除/保留"的路径集合,更新索引(含 REUC、TREE 等扩展的维护);- 对需要写盘的路径,逐条走
checkout_entry()(并行模式走 worker 队列),应用过滤器、写临时文件、rename; refresh_index()(可 preload)刷新 stat 缓存;- 更新
HEAD(符号引用)与 reflog,post-checkout钩子触发; - 命令退出------整个过程不启动任何守护进程(除非启用 fsmonitor),不锁全仓库,不依赖数据库。
8.7 git worktree:并行的多个工作区
git worktree 是"一个仓库、多个工作区"的实现:git worktree add ../hotfix hotfix-branch 会在新目录里建立第二个检出,而对象库与引用完全共享(refs.c 的 ref_store 只初始化一次)。实现上,每个链接工作区在 .git/worktrees/<name>/ 下有自己的小目录:gitdir(指向主仓库的引用)、commondir(指向共享的 .git)、自己的 HEAD、index 与 reflog。多个工作区可以同时在不同分支上工作,互不干扰,也免去了频繁 stash 的往返。
两个与核心机制相关的细节值得注意。其一,分支独占 :一个分支同时只能在一个工作区被检出------refs.c 会检查"目标分支是否已被其他工作区占用",防止两个工作区并发写同一引用造成状态错乱;其二,共享与隔离的边界 :对象、引用、配置、钩子共享,而 HEAD、index、工作区、per-worktree 引用(refs/worktree/*)隔离。这一"共享什么、隔离什么"的划分,与第 2 章"gitdir 与 worktree 分离"的模型一脉相承------worktree 只是把"一个仓库"扩展为"多个并行的 gitdir+worktree 对"。
8.8 代码深读:unpack_trees_options 与五种 merge_fn
8.1 说调用方通过 struct unpack_trees_options 声明意图,但这个结构的真实面目(本快照 unpack-trees.h)比"几棵树"丰富得多。把关键位域列出来,能看清"同一个引擎如何适配 checkout / reset / merge / stash 四种命令":
c
struct unpack_trees_options {
unsigned int merge, /* 是否做合并(而非纯覆盖) */
update, /* 是否真的更新工作区文件 */
preserve_ignored,
clone, /* clone 场景的特殊处理 */
index_only, /* 只更新索引,不碰工作区 */
trivial_merges_only, /* 只接受平凡合并,否则报错 */
verbose_update, aggressive,
skip_unmerged,
initial_checkout, /* 首次检出(新克隆) */
diff_index_cached,
skip_sparse_checkout,
quiet, exiting_early, dry_run,
skip_cache_tree_update;
enum unpack_trees_reset_type reset;/* HARD / SOFT / MIXED 等 */
merge_fn_t fn; /* ★ 关键:用哪个合并回调 */
struct index_state *dst_index, *src_index;
struct checkout_metadata meta; /* 写入文件的 metadata(过滤用) */
/* ... internal:错误收集、稀疏模式等 ... */
};
真正把五种命令区分开的,是那个函数指针 fn------它指向每到一个路径该怎么裁决 。unpack-trees.h 末尾直接声明了五个候选:
| 回调 | 适用场景 | 语义 |
|---|---|---|
bind_merge() |
read-tree 多树绑定 |
多棵树"并列"读入,不做修改 |
oneway_merge() |
reset --hard 单树 |
粗暴地把索引/工作区对齐到一棵树 |
twoway_merge() |
checkout 两树 |
在 HEAD 与目标树之间做保留性切换 |
threeway_merge() |
merge 三树 |
base/ours/theirs 三方归并,产 stage |
stash_worktree_untracked_merge() |
stash |
专门处理未跟踪文件的特殊合并 |
这套"数据结构 + 函数指针表"的设计,是把一族相似命令收编进同一引擎的经典手法:unpack_trees() 本身只负责"多树同步游标前进 + 收集错误",而每到一个路径怎么决策,全部下放给 fn 。于是 checkout 的"保留未提交改动"、reset 的"强制覆盖"、merge 的"产冲突 stage",本质上只是同一个游标语境下挂了不同的 fn。这也是为什么 8.1 强调"表面积很大"------merged_entry() 里要按 fn 的类型、按每条路径在三棵树里的存在与否(共 2³=8 种存在组合)逐一分派,组合爆炸正是这段代码难读却重要的原因。
此外 index_only 位很关键:像 git read-tree 这种"只动索引、不写工作区"的命令置上它,unpack-trees 会跳过所有 checkout_entry() 调用,只更新内存索引与磁盘索引------这是 checkout 与 read-tree 最大的行为分野。
8.9 代码深读:checkout_entry() 的落盘协议
8.2 列了 checkout_entry() 要考虑的细节,但"它到底怎么保证不写坏半个文件"值得单独拆。本快照 entry.c 约 613 行,核心流程可还原为:
- 取内容 :根据条目 OID 读出 blob(小对象走常规读,超
core.bigFileThreshold的走streaming.c流式读,避免把几百 MB 文件一次性读进内存); - smudge/转换 :调用
convert.c的过滤器链,把"库内规范内容"转成"工作区内容"(CRLF 转换、LFS smudge、自定义 filter); - 决定落点临时文件:在同目录下建临时文件(同目录 rename 才能保证原子性------跨目录 rename 不是原子的);
- 写 + fsync :写完按
core.fsync策略决定是否 fsync; - rename 成目标名 :用
rename(2)原子替换; - 回写 metadata :按
core.filemode/core.symlinks恢复权限与链接,并把刚落地的 stat 信息回填给索引条目(这样紧接着的refresh_index()就不会发现"文件刚被自己写过却 stat 对不上")。
两个细节值得记:
临时文件为什么要和目标同目录 。因为 rename() 在同一文件系统内是原子的(目录项交换),跨设备则会退化成"拷贝+删除",一旦中途断电就可能两头都不完整。Git 把临时文件放在目标目录,就是为了拿到那个原子 rename 语义------与第 7 章索引的"临时文件 + fsync + rename"是同一套崩溃安全哲学,只不过这次保护的是工作区文件而不是元数据。
symlink 与 executable 的平台退化 。在不支持符号链接的文件系统(core.symlinks=false)上,Git 不创建真链接,而是写一个"内容为链接目标路径"的普通文件;在不支持可执行位区分的系统(core.filemode=false)上,100755 与 100644 的差别被抹平。这些退化都通过 checkout_metadata 与 ce_mode_from_stat()(第 7 章见过的内联函数)统一收口,保证不同平台上同一棵树检出后,索引记录的模式仍然一致,不会因平台能力差异而反复出现"我这边文件模式变了"的假脏。
8.10 机制推演:并行 checkout 的任务分包与 worker 生命周期
8.3 给了并行 checkout 的三步骨架,但"为什么用多进程而不是多线程"、"任务怎么切"才是它的精髓。本快照 parallel-checkout.c 约 678 行,preload-index.c 约 189 行。
用多进程而非多线程 的根本原因:Git 历史上大量核心代码假设"单线程、一次性进程",对象读取、过滤器调用(尤其外部 filter 进程、LFS)的全局状态难以线程安全隔离。与其把整个核心库改造成线程安全,不如在"写文件"这一个边界上用进程级隔离------worker 是全新进程,各自打开自己的对象句柄、各自 spawn filter,互不干扰;主进程则继续持有它的单线程世界。这是一种"把并行限制在最安全的边界"的保守策略。
任务分包 :主进程把待落地条目切进 pc_queue(一个带锁的队列),每个任务包 struct pc_item 携带路径、OID、mode、转换上下文。worker 从管道领任务、独立跑完 8.9 的整条落盘链路、把结果(成功/失败、新 stat)回传。主进程只做调度与最终的索引回填。这样 I/O 等待时间被多个 worker 重叠------单线程时"解压一个、写一个、fsync 一个"是串行的,多 worker 时磁盘队列被喂满,吞吐接近设备上限。
与 preload-index 的分工 :注意并行 checkout 解决的是"写盘",而 preload-index.c 解决的是"stat 扫描"------它用一个线程池并行对所有条目做 lstat(),把"扫全盘"也摊到多核。两者是 checkout 性能优化的两个正交面:一个压写入延迟,一个压 stat 延迟。启用条件也不同:并行 checkout 需 checkout.workers>1(默认开),preload 则在 git status/add 这类要 stat 全盘的路径上自动生效。
回退的正确性 :worker 与主进程必须跑出逐字节相同 的工作区内容。为此任务包携带了完整的转换上下文(checkout_metadata、attributes 命中结果),worker 不允许"自己再去猜该用什么过滤器"------这就避免了主进程和 worker 因 .gitattributes 解析顺序不同而写出不同 CRLF 的文件。任何一步 worker 失败,主进程会收集错误并让整个 checkout 以非零退出,而不是留下"一半文件并行写好了、一半没写"的半吊子状态。
8.11 典型案例:checkout 被拒的安全矩阵
8.1 说"绝不未经同意破坏工作区",这条原则在用户面前具体表现为一组可复现的拒绝行为。下面是几组真实命令演示:
情形一:未提交改动会被覆盖
powershell
# 在 feature 分支上改了某个文件但没提交
git switch main
# error: Your local changes to the following files would be overwritten by checkout:
# src/foo.c
# Please commit your changes or stash them before you switch branches.
这是 verify_uptodate() / verify_clean() 在起作用:unpack-trees 发现目标树要写的路径,在工作区里有与索引不一致的本地修改,于是拒绝。用户要么 commit、要么 stash、要么加 -f(明确知情)。
情形二:未跟踪文件挡路
powershell
# 目标分支要新增 config.yaml,但你本地已有一个未跟踪的 config.yaml
git switch with-config
# error: The following untracked working tree files would be overwritten by checkout:
# config.yaml
注意这里挡路的是未跟踪文件------它不在索引里,unpack-trees 无法知道它"该不该保留",所以同样拒绝,而不是默默覆盖。
情形三:D/F 冲突(目录与文件换位)
powershell
# 目标树把 src/foo/ 从目录变成文件 src/foo
git switch other-branch
# error: Untracked working tree files would be overwritten ...
# 或: Entry 'src/foo' would be overwritten by merge. Cannot merge.
8.1 提到的 D/F 冲突就落在这:游标发现一边是目录、一边是文件,df_conflict_entry(见 8.8 结构里的输出字段)被填上,命令中止。
把这三组摆在一起,能提炼出一条规律:unpack-trees 的安全检查本质是"目标树要写的每个路径,是否与工作区现状存在无法自动调和的冲突" 。凡涉及"可能丢用户数据"的,一律先拒绝、给人话、让用户决定;只有 -f/-m 这类显式标志才会越过这道闸。这也是为什么新手常抱怨"Git 怎么这么啰嗦"------那份啰嗦,正是 8.6 时间线第 2 步存在的意义。
8.12 扩展讨论:convert.c 的 clean/smudge 与换行符边界
8.2 提到过滤器,但"对象库存规范格式、工作区拿平台格式"这条边界,恰恰是跨平台协作时最常见的混乱来源,值得收个尾。本快照 convert.c 约 2062 行,是 checkout 与 add 共享的转换中枢:
- smudge(检出方向) :blob OID → 工作区文件。
core.autocrlf=true时把 LF 转成 CRLF(Windows),text=auto按 attributes 决定,LFS 则把"指针文本"换成真实大文件内容; - clean(add 方向) :工作区文件 → blob OID。把 CRLF 转回 LF,LFS 则把大文件抽成指针、原件存到
.git/lfs。
关键不变量是:索引与对象库永远存 LF 规范版的 OID。这意味着不管你在 Windows 还是 Linux 检出,同一个提交里的 blob OID 永远相同------否则同一个提交在两个平台会算出不同树,历史就裂了。CRLF 只活在"工作区"这一层,从不进入对象名。这也是为什么"改了换行符却没改代码"在 diff 里常常显示成"整文件都变了"------clean 方向没正确归一化时,Git 会把它当成真实内容差异。
与并行 checkout(8.10)的衔接点在于:filter 可能是外部进程(自定义 filter 或 LFS),每个文件都要 spawn 一次。在并行 checkout 里,这个 spawn 发生在 worker 内部;在串行 checkout 里则在主进程。无论在哪一侧,转换语义必须与 clean 方向互为逆操作------smudge(clean(x)) == x 是跨平台一致性的数学保障。理解了这条边界,再看第 10 章 diff 为什么有时对"纯换行符差异"敏感,就有了底层依据。
8.13 代码深读:preload-index 与 fsmonitor 的脏位图协同
8.4 把 fsmonitor 说成"增量通知",但它与 stat 缓存、preload-index 是怎么协同的,值得用真实结构串一遍。第 7 章的 index_state 里有两个字段:struct ewah_bitmap *fsmonitor_dirty 与 char *fsmonitor_last_update。这正是 fsmonitor 客户端的落点。
EWAH 位图(第 15 章讲位图时会展开)在这里被当作"哪些路径脏了"的压缩集合:索引里每个条目有一个序号,位图的第 N 位为 1 表示"第 N 个条目对应的路径,被监视器报告为可能变更"。EWAH 用游程编码把这块位图压得很小------在一个百万条目的仓库里,一次 status 通常只动几十个文件,位图绝大部分是 0,压缩后只有几百字节。
协同流程可以还原成:
- 上次 Git 运行结束时,把"监视器查询到的截止时间"写进
fsmonitor_last_update,并把这段时间内变化的路径置位到fsmonitor_dirty; - 下次
git status启动,fsmonitor.c向守护进程问"自fsmonitor_last_update以来哪些路径变了",拿到新一批脏路径,更新位图与时间戳; - 走 7.13 那条
ie_match_stat()快检时:未被置脏的条目 直接标记CE_FSMONITOR_VALID(第 7 章位域表里的 1<<21 位),连lstat()都不调;被置脏的条目 才走真正的lstat()比对。
这就把 8.4 说的"只 stat 变更路径"具体化了:省下的不是少数 stat,而是"对所有未变目录连 lstat 都跳过"。配合 preload-index 的线程池,真正需要 stat 的那几十个变更条目还能并行扫------两层优化叠加,巨型仓库的 git status 从秒级降到亚秒级。
守护进程的平台分叉 也值得一记:fsmonitor-ipc.c(约 179 行)只定义"如何与守护进程收发消息",具体监听机制在各平台后端------Windows 用 ReadDirectoryChangesW、macOS 用 FSEvents、Linux 则可接 inotify 或用户自实现的 hook。Git 自己不替你跑 inotify 循环,而是约定一个"能回答自某时间点以来哪些路径变了"的常驻进程;这是它"可选、可回退"的关键------守护进程挂了,Git 退回到全量 stat,绝不会因此判错文件状态。
8.14 典型案例:git worktree add 的目录布局与分支独占
8.7 讲了 worktree 的共享/隔离边界,用一组真实命令能把布局看实。假设主仓库在 E:\E\git-master:
powershell
# 在主仓库里,基于 hotfix 分支新建一个并行工作区到 ../git-hotfix
git -C E:\E\git-master worktree add E:\E\git-hotfix hotfix
执行后磁盘上出现:
perl
E:\E\
├─ git-master\.git\
│ ├─ HEAD ← 主工作区的 HEAD(仍在原分支)
│ ├─ index ← 主工作区的索引
│ ├─ objects\ ← ★ 对象库,被所有工作区共享
│ ├─ refs\ ← ★ 引用,被共享
│ └─ worktrees\
│ └─ hotfix\ ← 链接工作区的"私有小 .git"
│ ├─ gitdir ← 文本文件,内容 "gitdir: E:/E/git-hotfix/.git"
│ ├─ commondir ← 文本文件,内容 "../.."(指向共享的 .git)
│ ├─ HEAD ← 该工作区自己的 HEAD(= hotfix)
│ └─ index ← 该工作区自己的索引
└─ git-hotfix\ ← 新工作区的实际检出目录
└─ .git ← 一个文本文件,指向 .git\worktrees\hotfix
对象与引用共享、HEAD/index 隔离 这张图,就是 8.7 说的边界的落盘形态。新工作区里敲 git log,能看到与主仓库完全相同的提交历史(共享 objects);但在新工作区里 git checkout -b 切分支,主工作区不受影响(各自的 HEAD)。
分支独占也能现场验证:
powershell
# 主工作区当前若正检出 hotfix 分支,再在别处想把它检出会被拒
git -C E:\E\git-hotfix2 worktree add E:\E\git-hotfix2 hotfix
# fatal: 'hotfix' is already checked out at 'E:/E/git-hotfix'
这个 fatal 来自 refs 的占用检查:一个分支同一时刻只能在一个 worktree 被 checkout,防止两个工作区并发 commit 推进同一引用造成二义。要绕开它,只能 git worktree move、git worktree remove 释放原工作区,或用 --force(明确知情)。理解了这一点,再看第 6 章引用体系里 refs/worktree/* 这类 per-worktree 命名空间,就知道它是为"工作区私有引用"预留的地盘------共享的分支引用独占,私有的工作流引用隔离。
8.15 扩展讨论:initial_checkout 与 clone 的特殊路径
8.8 的位域表里有一个 initial_checkout,它对应的就是 git clone 刚拉完对象、第一次把树铺到磁盘的场景。这条路径之所以特殊,是因为它没有"现状"需要保护 :工作区是空的,索引是空的,不存在"会覆盖未提交改动"的问题。于是 unpack-trees 在 initial_checkout 下可以走最激进的快路:
- 跳过几乎所有
verify_absent()/verify_clean()安全检查(没有用户数据会被破坏); - 走
clone相关的快速路径,把整棵树一次性写入; - 大仓库下这正是 clone 后"检出阶段"能全速跑满并行 checkout(8.10)的原因------没有安全闸拖慢,worker 队列喂多快就写多快。
把这条路径与 8.11 的"安全矩阵"对照着看很有意思:同一套 unpack-trees 引擎,在空仓库 clone 时全速冲刺,在日常 checkout 时步步为营 ,差别全在那几个位域(initial_checkout / update / index_only)的组合。这也是"统一引擎"设计的回报------clone、checkout、reset、merge、stash 这些表面差异巨大的命令,本质是同一台机器拨了不同开关,安全逻辑只写一遍,却处处一致。
8.16 扩展讨论:checkout 的两副面孔与 gitlink 处理
git checkout 在用户面前有两副完全不同的面孔,理解它们分道扬镳的点,能避免很多误用:
面孔一:切分支 (git checkout <branch>)。这是 8.6 那条完整时间线------两树归并、更新 HEAD、跑 post-checkout 钩子。它改变的是"整个工作区朝向哪个提交"。
面孔二:从索引/提交恢复文件 (git checkout -- <path> 或 git checkout <tree> -- <path>)。这条路径不碰 HEAD ,只把指定路径的内容写回工作区(可选连带更新索引)。它走的是 unpack-trees 的 pathspec 限定模式(8.8 结构里的 struct pathspec *pathspec):游标不再遍历全树,只处理命中 pathspec 的路径,因而又快又安全------它只覆盖你点名的那几个文件。新手最常踩的坑就是把"想放弃某文件的本地修改"(面孔二)误写成"切分支"(面孔一),结果整个工作区被翻了个面。
gitlink(子模块)在 unpack-trees 里的处理 也值得单列。子模块不是普通文件,它在父仓库的树里只是一个指向某提交的特殊条目(mode 160000)。unpack-trees 遇到 gitlink 时,不写文件内容 (子模块的内容在它自己的仓库里),只更新父仓库索引里的那个指针 OID。至于子模块工作区要不要同步到新指针,是 --recurse-submodules 的事------那是另一套递归逻辑(submodule.c),不在 unpack-trees 主循环里。这种"父仓库只跟踪指针、子模块内容自治"的划分,与 8.9 普通文件落盘形成对照:unpack-trees 对 gitlink 的"写",其实只是改索引里一个 20 字节的 OID。
把这两点合起来看,unpack-trees 的"统一"是有层次的:它统一处理普通文件、符号链接、gitlink、D/F 冲突,但在"切全树"与"按路径恢复"之间用 pathspec 划了一道线,在"父仓库"与"子模块"之间用递归边界划了另一道线。真正的复杂,都藏在这两道边界的交界处。
8.17 扩展讨论:reset 的语义分层与钩子时机
8.8 结构里有个 enum unpack_trees_reset_type reset,它定义了 unpack-trees 在"遇到未跟踪文件挡路"时的两种决断:
c
enum unpack_trees_reset_type {
UNPACK_RESET_NONE = 0, /* 旧式 false,仍合法 */
UNPACK_RESET_INVALID = 1, /* 旧式 true 已废弃 */
UNPACK_RESET_PROTECT_UNTRACKED, /* 保护未跟踪文件(默认安全) */
UNPACK_RESET_OVERWRITE_UNTRACKED /* 覆盖未跟踪文件(--reset 类强写) */
};
这个枚举正好解释了 git reset --hard 与普通 git checkout 在"未跟踪文件挡路"上的行为差异:普通 checkout 走 PROTECT_UNTRACKED(8.11 情形二会被拒),而 reset --hard 的语义是"我就是要把工作区对齐到目标树",于是可以走 OVERWRITE_UNTRACKED。注意它只覆盖"会被目标树写入的未跟踪文件" ,而不是删光所有未跟踪文件------真正"清空所有未跟踪文件"是 git clean 的职责,两者不要混。reset 的软/硬/混合(soft/mixed/hard)分层也由此清晰:soft 只动 HEAD,mixed 动 HEAD+索引,hard 再追加工作区------unpack-trees 只在 hard 这一层被调用。
钩子时机 则把"检出完成"这一事件暴露给用户脚本。8.6 时间线第 6 步的 post-checkout 钩子,在 HEAD 真正切换后触发,三个参数分别是:前一个 HEAD、新 HEAD、以及"这是分支切换还是单路径检出"的标志。它常被用来在切分支后自动生成文档、重装依赖、刷新 IDE 索引。与之相邻的还有 post-merge(merge 完成后)、post-commit(提交完成后)------它们都不在 unpack-trees 主循环里,而是在 porcelain 层"动作全部落盘之后"触发,保证钩子跑起来时看到的已经是最终一致状态。这套"核心引擎不关心钩子,porcelain 在收尾时统一触发"的分工,与第 12 章钩子系统的生命周期模型是一致的。
8.18 机制推演:unpack-trees 是怎么"算差异"的
最后补一个被 8.1 一笔带过、却最能体现功力的问题:unpack-trees 在写任何文件之前,怎么知道哪些路径需要变。它不是"把目标树整棵重写",而是做一次三路游标归并。
设三棵树各自被展开成按路径排序的条目流 (tree 对象本来就是按名字排序的)。三个游标 base/ours/theirs 各自指向当前最小路径,每轮取三者中路径名最小的那个"处理"。对每个路径,按它在三棵树里的存在与否落到 2³=8 个格子之一:
- 只在 theirs 有(ours 与 base 都没有):新增文件;
- ours 与 theirs 相同、base 没有:我们这边本来就有,保留;
- base 与 ours 相同、theirs 变了:别人改了------取 theirs 的版本;
- base 相同、ours 与 theirs 都改了且改法一致:自动合并成功,取共同结果;
- base 相同、ours 与 theirs 都改了但改法不同:真正的冲突,落 stage 1/2/3(第 12 章展开);
- 一边文件、另一边目录 :D/F 冲突,
df_conflict_entry记录(8.11 情形三)。
这套"按序归并 + 存在性笛卡尔积"的妙处在于:它全程不需要在内存里建哈希表 ------因为树本身已排序,三个游标同步推进就是 O(总条目数),且天然按路径有序产出结果。这也是为什么 unpack-trees 能在百万条目仓库里不爆内存:它是流式的,一次只看几个路径。merged_entry() 那个庞大的 switch,本质就是这 8 个格子(再叠加 symlink/gitlink/sparse 的细分)的逐一翻译。
把 8.8 的 fn、8.9 的落盘、8.11 的安全闸放回这个归并框架,整章就闭环了:游标负责"算出该干什么",fn 负责"按命令类型裁决",checkout_entry 负责"安全地写下去",verify_ 负责"动手前先确认不毁数据"。值得强调的是,这套归并是*惰性且流式**的:unpack-trees 不会把三棵树全读进内存再求差,而是边读边比边产出,最后把 8.6 那条时间线也放回这个框架校准一遍:第 2 步的安全检查本质是"动手前跑一遍 verify_*",第 3 步是"游标归并 + fn 裁决",第 4 步是"checkout_entry 落盘",第 5 步是"回写 stat 缓存为下一轮快检做准备"------四步各自独立、各司其职,合起来才是用户眼中一次顺滑的切换。
本章小结 :检出不是"拷贝文件",而是一套精心编排的树归并决策:unpack-trees 负责"差异计算与冲突裁定",entry.c 负责"逐文件安全落盘",parallel-checkout 把写盘并行化,fsmonitor 把 stat 扫描替换成增量通知,sparse-checkout 用 skip-worktree 让"未检出"成为一等公民。工作区层设计的全部心法可以浓缩为一句话:绝不未经用户同意破坏工作区内容,同时把扫描与写盘成本压到最低。
顺着这条主线再往深走一层:五种命令(bind/oneway/twoway/threeway/stash)只是同一个多树游标挂上的不同
fn(8.8);单条路径的落盘靠"同目录临时文件 + fsync + 原子 rename"拿到崩溃安全(8.9);并行化刻意被限制在"写文件"这一个进程边界上,并用完整任务包保证 worker 与主进程逐字节一致(8.10);安全闸则把所有"可能丢数据"的情形抽象成一张 checkout 被拒的矩阵(8.11);而 clean/smudge 的双向转换保证了"对象库存 LF、工作区拿平台格式"这条跨平台不变量(8.12)。合起来看,工作区是 Git 里唯一面向用户文件系统、却被层层安全边界包起来的一层------它既给了用户"随便改"的自由,又用 unpack-trees 的每一次拒绝,守住了"改坏了能找回"的底线。
对工程师而言,这一章最值得带走的不是某个函数名,而是一种架构品味:把一族表面迥异的操作(切分支、重置、合并、暂存、克隆)收敛到一个流式游标引擎,再用函数指针与位域把差异降到最小。当你自己设计"多输入、逐条目裁决、还要兼顾安全与性能"的系统时,unpack-trees 几乎是一份可以直接临摹的范本。它教给人的三句话是:先把输入排成有序流,再用游标归并避免建表,最后把"怎么裁决"与"怎么落地"拆成两层------前者随命令变,后者只写一遍。而这一切之所以能成立,前提是第 7 章那张"提交的清单"------没有索引在中间承上启下,工作区与树之间的每次往返都将是灾难------这也是为什么第 7 章被放在了本章之前------它是理解工作区一切行为的钥匙,也是下一章"如何高效地在历史图里游走"的物理前提------没有可靠的工作区落盘,所有历史遍历的结果都无从落地检验,也就无从谈起了。这正是下一章的起点。
顺便给一个排障视角的收尾:当你遇到 "error: Your local changes to the following files would be overwritten by checkout"、"would be lost" 这类经典报错时,它本质上是 unpack-trees 的安全闸(8.11)在替你拦------某个被切换目标修改的文件,在你工作区里又有未暂存改动,而那改动既不在索引、也不在任何提交里,一旦强切就真的会丢。Git 的态度是"宁错杀、不放过":它宁可拒绝,也不替你做"丢弃还是保留"的决定。正确的解法永远是先git stash(第 12 章把它做成了临时提交+合并)或git commit到一个临时分支,把改动变成可寻址的对象,再切。理解了安全闸的存在,你就不会再把这类报错当成"Git 跟我过不去"------它是在履行本章那句心法:绝不未经用户同意破坏工作区内容。
再补一个现代仓库的性能视角:在 monorepo(几十万文件)里,git checkout/git switch第一次变慢,几乎都是"写盘"与"stat 扫描"两道瓶颈。unpack-trees 的解法是双管齐下------写盘交给 parallel-checkout(8.10)用多 worker 并行,stat 扫描交给 fsmonitor(8.13 附近)由文件系统事件增量通知。两者叠加后,一次"只改了几个文件"的分支切换,真正触碰磁盘的文件从"全量几十万"降到"受影响的几百个"。这也是为什么现代 Git 在大仓库上切分支能做到秒级------不是 CPU 快了,而是 unpack-trees 这套流式归并框架,把"该写哪些、该查哪些"算得越来越准,把多余的 I/O 一刀刀切掉了。理解了这一点,再遇到大仓库切分支卡顿,你就知道该从 parallel-checkout 与 fsmonitor 这两个旋钮入手,而不是怀疑 Git 本身------这份判断力,正是读懂本章换来的------下一章我们离开工作区,进入历史图的遍历------那是 revision 引擎的主场,我们将看到 Git 如何在一张可能上百万节点的 DAG 上,飞快地回答"哪些提交该出现、按什么顺序出、怎么更快"。这正是下一章要拆解的 revision 引擎,也是本章工作区之上的下一块拼图,我们下一章见------revision 引擎。