第 8 章 工作区与检出:unpack-trees 与并行 checkout

第 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 把这块变成多进程流水线:

  1. 主进程用 run-command.c 拉起若干 checkout worker 子进程 (数量由 checkout.workers 配置,默认取 CPU 数),通过管道(pipes)分发"写文件任务";
  2. 每个 worker 独立完成 解压对象 → 转换过滤器 → 写临时文件 → fsync/rename 的整条链路;
  3. 主进程只保留轻量的调度与校验(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 的内部时间线大致是:

  1. builtin/checkout.c 解析参数,确定目标提交与分支;
  2. 安全检查:目标分支是否已合并、工作区是否有未提交改动会冲突(checkout 的"abort if dirty"逻辑);
  3. unpack_trees() 两树归并:算出需要"新增/更新/删除/保留"的路径集合,更新索引(含 REUC、TREE 等扩展的维护);
  4. 对需要写盘的路径,逐条走 checkout_entry()(并行模式走 worker 队列),应用过滤器、写临时文件、rename;
  5. refresh_index()(可 preload)刷新 stat 缓存;
  6. 更新 HEAD(符号引用)与 reflog,post-checkout 钩子触发;
  7. 命令退出------整个过程不启动任何守护进程(除非启用 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 行,核心流程可还原为:

  1. 取内容 :根据条目 OID 读出 blob(小对象走常规读,超 core.bigFileThreshold 的走 streaming.c 流式读,避免把几百 MB 文件一次性读进内存);
  2. smudge/转换 :调用 convert.c 的过滤器链,把"库内规范内容"转成"工作区内容"(CRLF 转换、LFS smudge、自定义 filter);
  3. 决定落点临时文件:在同目录下建临时文件(同目录 rename 才能保证原子性------跨目录 rename 不是原子的);
  4. 写 + fsync :写完按 core.fsync 策略决定是否 fsync;
  5. rename 成目标名 :用 rename(2) 原子替换;
  6. 回写 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,压缩后只有几百字节。

协同流程可以还原成:

  1. 上次 Git 运行结束时,把"监视器查询到的截止时间"写进 fsmonitor_last_update,并把这段时间内变化的路径置位到 fsmonitor_dirty;
  2. 下次 git status 启动,fsmonitor.c 向守护进程问"自 fsmonitor_last_update 以来哪些路径变了",拿到新一批脏路径,更新位图与时间戳;
  3. 走 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 这些表面差异巨大的命令,本质是同一台机器拨了不同开关,安全逻辑只写一遍,却处处一致。

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 引擎。

相关推荐
xing-xing4 小时前
git 相关命令总结
git
A-刘晨阳6 小时前
GitLab + ArgoCD 实现 Kubernetes GitOps 自动化部署
运维·人工智能·git·kubernetes·自动化·云计算·argocd
ControlRookie17 小时前
SyncBridge:一个 CODESYS 工程与 AI 之间工程师级的同步工具
git·ai编程·codesys·工业软件·controlrookie
鬼手点金1 天前
opencode-性能优化建议
java·人工智能·git·自动化·nanogpt
网络毒刘1 天前
开源 Aider 工作流实战:Git 提交驱动的 AI 结对,与 Cursor 何时互补
git·ai编程·cursor·aider
HRTOS1 天前
HRTOS 4.0 将长期维护:为什么版本号不再频繁变化
经验分享·git·单片机·51单片机
驭渊的小故事2 天前
linux 基础命令 + git 仓库创建和配置命令
linux·git
行者-全栈开发2 天前
华为云码道 CodeArts 实测:让 AI 用 Rust 写一个 Git 仓库健康度体检台「仓衡」
git·rust·tauri·桌面应用·ai 编程·华为云码道·codearts 代码智能体
挖掘狂人4 天前
Git 从 0 到 1:用一个小项目走完 add / commit / reset / merge / rebase / push
git·后端·github