Apache Subversion 1.15 源码深度剖析-第 6 章 工作副本:WC-NG 与 pristineless

Apache Subversion 1.15 源码深度剖析-第 6 章 工作副本:WC-NG 与 pristineless

本系列文章的源代码基线为 Apache Subversion 1.15.0(tag 1.15.0,见仓库根目录 CHANGES 首行)。本章所有表名、列名、状态值、常量、函数名与 r(revision)号,均来自本次对 d:\CODE\subversion-1.15.0 源码的逐文件阅读与检索,不使用任何"记忆里的"结论。

6.0 导读:为什么"工作副本"值得单独写一章

Subversion 的仓库(libsvn_fs_fs)负责"版本的历史",而**工作副本(working copy,简称 WC)**负责"用户手上那份可编辑的、与仓库某一状态相关联的本地目录树"。绝大多数开发者与 SVN 打交道,其实都是在和工作副本打交道:svn status 读它、svn diff 读它、svn update 写它、svn commit 从它出发、svn revert 撤销对它做的修改。工作副本的存储格式决定了------

  • 客户端能否表达"混合修订"(不同子目录处在不同 revision);
  • 客户端能否表达"移动 / 重命名"(move)这一区别于"删除+复制"的语义;
  • 客户端能否表达"稀疏检出"(sparse checkout)与"changelist";
  • 一次 update 被 Ctrl-C 打断后,磁盘上的数据结构是否仍然自洽;
  • 同样的文本内容被多个路径引用时,是否需要重复存储。

Subversion 1.6 及以前,这些信息散落在每个目录下的 .svn/entries 文本文件里;1.7 起被替换为单一 SQLite 数据库 .svn/wc.db ,这一代架构在社区里被称为 WC-NG(Next Generation Working Copy)。1.15 又在这套架构上叠加了两个头条特性:

  • pristineless(无 pristine / 按需拉取原始内容) :把"本地缓存的原始文本"变成可选,用工作副本格式 32(SVN_WC__VERSION = 32)承载;
  • 多 WC 格式支持(multiple working copy formats) :允许客户端在不同代际的 WC 格式间读写,用 --compatible-version 暴露给用户。

本章的目标,是把 .svn/wc.db 的完整关系模式(schema) 、NODES 表的双层/多层视图模型 、pristine 的内容寻址与引用计数 、work queue 的崩溃恢复设计,以及 1.15 的 pristineless 与多格式支持,一条线讲透。

6.0.1 涉及文件清单

阅读本章时,建议对照以下源码文件(file:/// 链接可直接在编辑器中打开):

主题 文件
wc.db 完整 schema subversion/libsvn_wc/wc-metadata.sql
WC 内部常量与格式史 subversion/libsvn_wc/wc.h
WC DB 公共 API 头 subversion/libsvn_wc/wc_db.h
work queue 实现 subversion/libsvn_wc/workqueue.c
work queue API 头 subversion/libsvn_wc/workqueue.h
冲突 skel API subversion/libsvn_wc/conflicts.h
冲突实现 subversion/libsvn_wc/conflicts.c
管理目录/设置 subversion/libsvn_wc/adm_files.c
升级与格式映射 subversion/libsvn_wc/upgrade.c
客户端格式映射 subversion/libsvn_client/upgrade.c
checkout 与 store-pristine 校验 subversion/libsvn_client/checkout.c
CLI 选项状态结构 subversion/svn/cl.h
CLI 选项表与帮助 subversion/svn/svn.c
cleanup 命令实现 subversion/svn/cleanup-cmd.c
发布说明 CHANGES
开发工具:wc.db 校验器 tools/dev/wc-ng/svn-wc-db-tester.c
开发工具:wc.db 转储解码 tools/dev/svnqlite3-dump

6.0.2 读完本章你将能够

  1. 说出 .svn/wc.db 里每一张表的用途、主键、关键列与索引,并写出针对性的 SQL 查询;
  2. 解释 op_depth 的语义,用一句话讲清"为什么 NODES 表能同时表达 BASE 与 WORKING 两套视图";
  3. 给定一次 svn rm 加 svn mv 的操作序列,逐行推演 NODES 表中 op_depth/presence/moved_here/moved_to 的取值;
  4. 解释 pristine 的 SHA-1 内容寻址 + refcount 触发器 机制,并说明为什么 1.15 要引入 hydrated 列与 TEXTBASE_REFS 表;
  5. 说明 pristineless 与 多 WC 格式支持 在 CLI 与库层的开关点(--store-pristine、--vacuum-pristines、--compatible-version);
  6. 解释 work queue 的"先写日志后执行"设计如何支撑崩溃恢复,并列出全部 work item 类型;
  7. 把 1.7、1.8、1.15 三个格式里程碑与对应 r 号对应起来。

6.1 演进史:从每个目录一个 entries 到单一 wc.db

6.1.1 1.6 及以前:admin 区域与 .svn/entries

在 WC-NG 之前,工作副本的元数据是分散 的:工作副本根目录有一个 .svn/,每个受版本控制的子目录也各有一个 .svn/ 目录。每个 .svn/ 目录里存放:

  • entries:该目录下所有节点(含 svn:this_dir)的元信息;
  • text-base/:原始文本(pristine)副本;
  • props/、prop-base/:属性的工作副本与原始副本;
  • wcprops/:WebDAV 属性缓存;
  • tmp/、lock 等。

这种"每个目录一份 entries"的布局,在 subversion/libsvn_wc/wc.h 顶部那份格式编年史里有清晰记载。文件的注释里写道:

c 复制代码
/* We can handle this format or anything lower, and we (should) error
 * on anything higher.
 *
 * There is no format version 0; we started with 1.
 *
 * The bump to 2 introduced the ".svn-work" extension. For example,
 *   ".svn/props/foo" became ".svn/props/foo.svn-work".
 * ...
 * == 1.0.x shipped with format 4
 * == 1.1.x shipped with format 4
 * == 1.2.x shipped with format 4
 * == 1.3.x shipped with format 4

(见 wc.h#L48-L64。)

注意这里的表述「we can handle this format or anything lower」。1.15 时代,客户端能处理的最老格式 已经不再是这里开头讲的那些,而是被 SVN_WC__SUPPORTED_VERSION 重新定义------这一点会在 6.5 节详细展开。

6.1.2 分散式 entries 的表达困境

"每目录一份 entries、每个目录各有一个 admin 区"带来的痛点,正是 WC-NG 的动机来源。把它们归纳为四类:

  1. 混合修订(mixed-revision) :每个 entries 文件自身记录了 entry 的 revision,但目录属于哪个 revision 这一信息,与"目录的 text-base 是什么"耦合在同一个 entries 里。当你只想改一个深层子目录的 revision(比如 svn update sub/),就必须打开、解析、重写该子目录的 entries 文件,而它的祖先目录里的 entries 仍然声称自己是旧 revision。层级之间没有统一的事务,容易不一致。

  2. 目录 rename / move 表达困难 :在 1.6 里,svn mv 被展开成"把目标目录登记为带历史的 add(copyfrom)",源目录登记为 delete。要在分散的 entries 里表达"这一整棵子树是从别处移动过来的",需要把 copyfrom 信息复制进每个子目录的 entries,非常啰嗦;而且真正的"重命名"语义(区别于 copy+delete)无从表达,服务器也只能看到 copy+delete。

  3. move 的祖先/后代关系 :WC-NG 的 NODES 表引入了 moved_here / moved_to 两列,专门表达"该节点被移动到这里"与"该节点被移动到别处"。分散式 entries 根本没有合适的字段承载这种"成对且成树"的关系。

  4. 稀疏(sparse)与 changelist :1.5 的 format 9 才把 changelist、keep-local、sticky depth 引入 entries(见格式编年史 wc.h 中 "The bump to 9 added changelists, keep-local, and sticky depth...")。但用文本格式表达"一个节点属于哪个 changelist""这个目录是 sparse 排除的"会迅速膨胀,检索也只能全量解析。

WC-NG 的解法是:把所有工作副本元数据收进一个 SQLite 数据库 .svn/wc.db ,用真正的关系表 + 索引 + 事务 + 触发器来表达上述关系。这一转变在格式编年史里的记录是:

c 复制代码
 * The bump to 12 switched from 'entries' to the SQLite database 'wc.db'.
 *
 * The bump to 13 added the WORK_QUEUE table into 'wc.db', moved the
 *   wcprops into the 'dav_cache' column in BASE_NODE, and stopped using
 *   the 'incomplete_children' column of BASE_NODE.
 *
 * The bump to 14 added the WCLOCKS table (and migrated locks from the
 *   filesystem into wc.db), and some columns to ACTUAL_NODE for future
 *   use.

(见 wc.h#L94-L102。)

6.1.3 格式编年史:从 12 到 32

wc.h 的这段注释,是理解 WC-NG 演进的权威索引。我把与本章直接相关的重要节点摘录并解读如下(原文行号见括号):

  • format 12 :从 entries 切到 SQLite wc.db。wc.h 里对应的宏是 SVN_WC__WC_NG_VERSION:

    c 复制代码
    /* A version < this uses the old 'entries' file mechanism.  */
    #define SVN_WC__WC_NG_VERSION 12

    (wc.h#L208-L209。)还有一个宏专门写给"旧客户端"看,让它能识别"这是新版 SVN 的工作副本":

    c 复制代码
    #define SVN_WC__NON_ENTRIES 12
    #define SVN_WC__NON_ENTRIES_STRING "12\n"

    (wc.h#L203-L206。)

  • format 13 :加入 WORK_QUEUE 表;把 wcprops 挪进 dav_cache 列。对应宏:

    c 复制代码
    /* A version < this has no work queue (see workqueue.h).  */
    #define SVN_WC__HAS_WORK_QUEUE 13

    (wc.h#L215-L216。)

  • format 14 :加入 WCLOCKS/WC_LOCK 相关表,把锁从文件系统迁进 wc.db。

  • format 15 :把目录上的 depth='exclude' 改为 presence='exclude',从而让"排除"也能作用于文件与符号链接。

  • format 16 :WC_LOCK 加入 locked_levels;PRISTINE 加入 md5_checksum(为将来备用)。

  • format 17 :引入 .svn/pristine 目录,把 text-base 挪进 Pristine Store(PRISTINE 表 + .svn/pristine 目录) ,废除 /.svn/text-base。

  • format 18 :把属性从 props/prop-base 目录挪进 wc.db,随后删除这两个目录。

  • format 19 :引入"每个工作副本一个 DB"的 single DB 模型,所有元数据集中到根目录的 .svn/wc.db:

    c 复制代码
     * The bump to 19 introduced the 'single DB' per working copy. All metadata
     *   is held in a single '.svn/wc.db' in the root directory of the working
     *   copy. Bumped in r991236.

    (wc.h#L120-L122。)

  • format 20 :引入 NODES 表、废除 BASE_NODE 与 WORKING_NODE。注意这里注释说 "op_depth is always 0 or 2",而后续 format 23 才把它泛化为多层:

    c 复制代码
     * The bump to 20 introduced NODES and drops BASE_NODE and WORKING_NODE,
     *   op_depth is always 0 or 2. Bumped in r1005388.

    (wc.h#L124-L125。)

  • format 21 / 22 :树冲突存储位置从父节点挪到冲突节点本身(r1034436),再挪进 tree_conflict_data 列(r1040255)。

  • format 23 :NODES 的 multi-layer op_depth 处理(r1044384)。这是 op_depth 成为"多层视图"的关键一步。

  • format 24 :PRISTINE.refcount 开始被正确维护,而不是永远为 1(r1058523)。

  • format 25 / 26 :引入 NODES_CURRENT 视图(r1071283)与 NODES_BASE 视图(r1076617)。

  • format 27:冲突文件名从 basename 改为 relpath 存储(r1089593)。

  • format 28:把所有剩下的 MD5 引用改用 SHA-1(r1095214)。

  • format 29 :pristine 文件从 <SHA1> 改名为 <SHA1>.svn-base,并引入 EXTERNALS store(r1129286)。注释明示 "== 1.7.x shipped with format 29"。

  • format 30 :冲突存储改为 conflict_data 列里的 skel(r1387742)。

  • format 31 :NODES 加入 inherited_props 列(r1395109)。注释明示 1.8/1.9/1.10/1.11 都停在 31。

  • format 32 :可选 pristine 内容(pristineless),即 STMT_UPGRADE_TO_32。

关于 32 与 1.15 的对应,原文写得很明确:

c 复制代码
 * The bump to 32 adds support for optional pristine contents; see the docstring
 * of STMT_UPGRADE_TO_32 for details.
 *
 * == 1.15.x shipped with format 32 and multi-wc-format support
 *
 * Please document any further format changes here.
 */

(wc.h#L165-L171。)

6.1.4 从 WC_LOCK 的沿革看"锁"的迁移

一个有意思的细节:1.6 时锁是文件系统里的 lock 文件;WC-NG 把它迁进了数据库表(format 14 加 WCLOCKS,16 加 locked_levels)。这带来的直接好处是:svn status 显示的 L 标记、svn cleanup 的"打破锁"、以及"写锁精确到某一层目录"这三件事,都能在一个事务里完成。wc-metadata.sql 里的 WC_LOCK 表定义如下:

sql 复制代码
CREATE TABLE WC_LOCK (
  /* specifies the location of this node in the local filesystem */
  wc_id  INTEGER NOT NULL  REFERENCES WCROOT (id),
  local_dir_relpath  TEXT NOT NULL,

  locked_levels  INTEGER NOT NULL DEFAULT -1,

  PRIMARY KEY (wc_id, local_dir_relpath)
 );

(wc-metadata.sql#L230-L238。)locked_levels = -1 表示"整棵子树都锁住",具体语义在 6.2 节逐表精讲时展开。

6.1.5 小结:演进的三条主线

把演进史收敛成三条主线:

  1. 从"分散"到"集中" :分散的每目录 .svn → 根目录单一 .svn/wc.db(format 12、19)。集中之后,一次 update 只动一个 DB 的一件事务,混合修订的不一致窗口被大幅压缩。
  2. 从"扁平"到"分层" :BASE_NODE + WORKING_NODE 两张表 → NODES 一张表 + op_depth 多层(format 20、23)。分层让"同一路径的多个历史版本"共存,revert 只需删掉高层。
  3. 从"必有 pristine"到"可选 pristine" :PRISTINE 从"每一行都在磁盘上有一份文件" → 允许 hydrated = 0(format 32)。这就是 1.15 pristineless 的落点。

下一节进入正题:逐表逐列精读 wc-metadata.sql。

6.2 wc.db 逐表逐列精读

subversion/libsvn_wc/wc-metadata.sql 是 WC-NG 的"宪法"。它不是一份构建产物,而是由 C 代码在创建/升级工作副本时读取并执行 的 SQL 脚本,脚本里用 -- STMT_xxx 这样的注释标记出可供 C 代码按名引用的独立语句块 (例如 -- STMT_CREATE_SCHEMA、-- STMT_UPGRADE_TO_32)。下面按"创建期 schema"的顺序逐表拆解。

6.2.1 脚本结构与两组枚举

脚本开头先声明了两个约定枚举。第一组是 KIND (节点类型),第二组是 PRESENCE(存在状态):

sql 复制代码
/*
 * the KIND column in these tables has one of the following values
 * (documented in the corresponding C type #svn_kind_t):
 *   "file"
 *   "dir"
 *   "symlink"
 *   "unknown"
 *
 * the PRESENCE column in these tables has one of the following values
 * (see also the C type #svn_wc__db_status_t):
 *   "normal"
 *   "server-excluded" -- server has declared it excluded (ie. authz failure)
 *   "excluded" -- administratively excluded (ie. sparse WC)
 *   "not-present" -- node not present at this REV
 *   "incomplete" -- state hasn't been filled in
 *   "base-deleted" -- node represents a delete of a BASE node
 */

(wc-metadata.sql#L24-L40。)

这里出现的六个 presence 字符串,与 C 侧枚举 svn_wc__db_status_t(在 wc_db.h#L132-L187)一一对应,但并不完全同名------数据库里的 presence 是"存储值",C 侧的状态是"读取后归一化的语义值"。对应关系如下(这是理解后续所有状态判断的钥匙):

wc.db 的 presence C 侧 svn_wc__db_status_t(svn_wc__db_read_info 归一化后)
normal(op_depth=0) svn_wc__db_status_normal
normal(op_depth>0) svn_wc__db_status_added(进一步由 svn_wc__db_scan_addition 细分为 added/copied/moved_here)
base-deleted svn_wc__db_status_deleted
server-excluded svn_wc__db_status_server_excluded
excluded svn_wc__db_status_excluded
not-present svn_wc__db_status_not_present
incomplete svn_wc__db_status_incomplete

注意 svn_wc__db_status_moved_here 与 svn_wc__db_status_copied 在数据库里并没有独立的 presence 取值------它们是从 normal(WORKING 层)+ moved_here 列 + repos_id/repos_path/revision 推导出来的。这一点会在 6.3 的状态机推演里反复用到。

脚本里还有一句很重要的注释,说明 STMT_CREATE_SCHEMA 的定位:

sql 复制代码
/* One big list of statements to create our initial schema.

   STMT_CREATE_SCHEMA creates the schema for the minimum WC format
   supported by the client (SVN_WC__SUPPORTED_VERSION).

   When we're creating a new working copy, we first execute
   STMT_CREATE_SCHEMA, and then use the normal WC upgrade code (using
   STMT_UPGRADE_TO_xx) to bring the schema up to any higher requested
   format.
 */

(wc-metadata.sql#L42-L51。)

这段话解释了 1.15 多格式支持的一个关键实现细节:新工作副本的创建过程,是先建"最低支持格式"的 schema,再跑升级路径升到目标格式 。也就是说,"建一个 format 32 的 WC"实际是"建 format 31 的 schema,然后执行 STMT_UPGRADE_TO_32"。这样做的好处是升级代码与创建代码共用一套路径,避免两套 schema 定义漂移。

6.2.2 REPOSITORY:仓库身份

sql 复制代码
CREATE TABLE REPOSITORY (
  id INTEGER PRIMARY KEY AUTOINCREMENT,

  /* The root URL of the repository. This value is URI-encoded.  */
  root  TEXT UNIQUE NOT NULL,

  /* the UUID of the repository */
  uuid  TEXT NOT NULL
  );

/* Note: a repository (identified by its UUID) may appear at multiple URLs.
   For example, http://example.com/repos/ and https://example.com/repos/.  */
CREATE INDEX I_UUID ON REPOSITORY (uuid);
CREATE INDEX I_ROOT ON REPOSITORY (root);

(wc-metadata.sql#L56-L69。)

要点:

  • id 是自增主键,供其他表通过外键引用。例如 NODES.repos_id REFERENCES REPOSITORY (id)。
  • root 是URI 编码后的仓库根 URL ,且 UNIQUE。
  • uuid 是仓库 UUID。脚本特意加了一条注释说明"同一个 UUID 的仓库可能出现在多个 URL 上 "(例如 http 与 https),并为此建了 I_UUID 索引。这条设计直接服务于 1.15 的一条新行为:当仓库 UUID 相同但 URL 不同时的告警 (见 CHANGES 里 "Warn on merges from foreign repositories if UUIDs match but URLs differ (r1888474 et al, issue #4874)")。
  • 另一个子细节:为什么 root 要 UNIQUE 而 uuid 只建普通索引? 因为数据库用 root 做唯一键来去重"同一个 URL",而"同一个 UUID 多个 URL"是允许的,所以 uuid 不能 UNIQUE。

一个可直接运行的排查查询------列出该 WC 见过的所有仓库:

sql 复制代码
-- 查看工作副本引用了哪些仓库(URL / UUID / 被多少 NODES 行引用)
SELECT r.id, r.root, r.uuid, COUNT(n.local_relpath) AS node_refs
  FROM REPOSITORY r
  LEFT JOIN NODES n ON n.repos_id = r.id
 GROUP BY r.id
 ORDER BY node_refs DESC;

注意 NODES 里只有 op_depth != 0(即复制/移动的 WORKING 节点)以及 BASE 节点会填 repos_id;普通 added 节点可能为 NULL,所以这里用 LEFT JOIN。

6.2.3 WCROOT:工作副本根

sql 复制代码
CREATE TABLE WCROOT (
  id  INTEGER PRIMARY KEY AUTOINCREMENT,

  /* absolute path in the local filesystem.  NULL if storing metadata in
     the wcroot itself. */
  local_abspath  TEXT UNIQUE
  );

CREATE UNIQUE INDEX I_LOCAL_ABSPATH ON WCROOT (local_abspath);

(wc-metadata.sql#L74-L82。)

要点:

  • 每行代表一个工作副本根 (或一个"外部工作副本"的根)。local_abspath 是该根的本地绝对路径。
  • 注释里"NULL if storing metadata in the wcroot itself"透露了历史:早年(format 19 前后)元数据可能分散存储在多个 admin 目录,WCROOT.local_abspath 用来标记"这个 wc_id 对应磁盘上哪个目录"。到了单一 DB 时代,一个 wc.db 里可能塞进多个"逻辑工作副本"(例如主 WC 与它的 externals),于是 WCROOT 表就成了区分它们的锚点。
  • 几乎所有业务表(NODES、ACTUAL_NODE、EXTERNALS、WC_LOCK、SETTINGS)都以 wc_id INTEGER NOT NULL REFERENCES WCROOT (id) 开头------wc_id 是这些表的"分区键"。

6.2.4 PRISTINE:原始内容的引用表

这是全章最核心的表之一。

sql 复制代码
CREATE TABLE PRISTINE (
  /* The SHA-1 checksum of the pristine text. This is a unique key. The
     SHA-1 checksum of a pristine text is assumed to be unique among all
     pristine texts referenced from this database. */
  checksum  TEXT NOT NULL PRIMARY KEY,

  /* Enumerated values specifying type of compression. The only value
     supported so far is NULL, meaning that no compression has been applied
     and the pristine text is stored verbatim in the file. */
  compression  INTEGER,

  /* The size in bytes of the file in which the pristine text is stored.
     Used to verify the pristine file is "proper". */
  size  INTEGER NOT NULL,

  /* The number of rows in the NODES table that have a 'checksum' column
     value that refers to this row.  (References in other places, such as
     in the ACTUAL_NODE table, are not counted.) */
  refcount  INTEGER NOT NULL,

  /* Alternative MD5 checksum used for communicating with older
     repositories. Not strictly guaranteed to be unique among table rows. */
  md5_checksum  TEXT NOT NULL
  );

CREATE INDEX I_PRISTINE_MD5 ON PRISTINE (md5_checksum);

(wc-metadata.sql#L94-L119。)

逐列解读:

  • checksum :pristine 内容的 SHA-1 ,PRIMARY KEY。它同时是内容寻址的地址 ------磁盘上那份 pristine 文件的路径,就是由这个 checksum 推导出来的(先按前两位十六进制分组,再加 .svn-base 后缀;format 29 起由 wc.h 注释 "renamed the pristine files from '' to '.svn-base'" 记录)。表头的注释也写明:"The text itself is stored in a file whose name is derived from the 'checksum' column."

  • compression :压缩类型枚举。注释明确"当前唯一支持的值是 NULL,表示不压缩、逐字节原样存储"。这是一个为将来预留的扩展点。

  • size:pristine 文件的字节数,用来校验"磁盘上那个文件是不是被写坏了/截断了"。

  • refcount :NODES 表里有多少行的 checksum 指向本行 。注意括号里的限定:"References in other places, such as in the ACTUAL_NODE table, are not counted."------也就是说 ACTUAL_NODE.older_checksum/left_checksum/right_checksum 对 pristine 的引用不计入 refcount(这一点在 format 32 的注释里又被强调了一遍,见 6.5 节)。

  • md5_checksum :并存的 MD5 ,注释说它用于"与旧仓库通信"(older repositories 在 1.6 之前用 MD5 标识内容)。它不保证唯一 ,因此只建普通索引 I_PRISTINE_MD5,不做唯一约束。

一个直接可跑、非常实用的查询------找出被引用最多的 pristine(往往是最热门的公共文件,比如 LICENSE、README):

sql 复制代码
-- 被引用次数最高的 pristine(refcount 从高到低)
SELECT checksum, refcount, size, md5_checksum,
       printf('%.2f MB', size / 1048576.0) AS size_mb
  FROM PRISTINE
 ORDER BY refcount DESC
 LIMIT 20;

再看"未被引用的 pristine "(这是 svn cleanup --vacuum-pristines 要清理的候选):

sql 复制代码
-- refcount == 0 的 pristine,即已无任何 NODES 行引用的孤儿内容
SELECT COUNT(*) AS orphan_count,
       SUM(size) AS orphan_bytes,
       printf('%.1f MB', SUM(size) / 1048576.0) AS orphan_mb
  FROM PRISTINE
 WHERE refcount = 0;

不过要小心:在 format 32 之前,"被 ACTUAL_NODE 的列引用"的 pristine 不会被 refcount 统计,所以只靠 refcount = 0 判断"可回收"并不完备------这正是 1.15 引入 hydrated、TEXTBASE_REFS 与 I_PRISTINE_UNREFERENCED 索引的动机之一。

6.2.5 ACTUAL_NODE:相对 NODES 的"差异层"

如果说 NODES 是"节点应该是什么样",ACTUAL_NODE 就是"相对 NODES 行,这个节点的文本与属性发生了什么变化"。它的表头注释把语义说得很清楚:

sql 复制代码
/* The ACTUAL_NODE table describes text changes and property changes
   on each node in the WC, relative to the NODES table row for the
   same path. (A NODES row must exist if this node exists, but an
   ACTUAL_NODE row can exist on its own if it is just recording info
   on a non-present node - a tree conflict or a changelist, for
   example.)
   ...
 */

(wc-metadata.sql#L123-L137。)

列定义:

sql 复制代码
CREATE TABLE ACTUAL_NODE (
  wc_id  INTEGER NOT NULL REFERENCES WCROOT (id),
  local_relpath  TEXT NOT NULL,
  parent_relpath  TEXT,
  properties  BLOB,
  conflict_old  TEXT,
  conflict_new  TEXT,
  conflict_working  TEXT,
  prop_reject  TEXT,
  changelist  TEXT,
  text_mod  TEXT,
  tree_conflict_data  TEXT,
  conflict_data  BLOB,
  older_checksum  TEXT REFERENCES PRISTINE (checksum),
  left_checksum  TEXT REFERENCES PRISTINE (checksum),
  right_checksum  TEXT REFERENCES PRISTINE (checksum),
  PRIMARY KEY (wc_id, local_relpath)
  );

(wc-metadata.sql#L139-L191。)

逐列要点:

  • properties(BLOB) :本节点工作副本属性的序列化 skel。NULL 表示"相对 BASE/WORKING 没有属性修改"(表头注释:"NULL implies no change to the properties, relative to WORKING/BASE as appropriate.")。

  • conflict_old / conflict_new / conflict_working / prop_reject :旧的、以"冲突文件名"形式存储的引用列 。脚本注释写着 /* ### These columns will eventually be merged into conflict_data below. */------也就是说它们是 1.7 时代遗留的,1.8(format 30)之后实际冲突内容已经搬进 conflict_data 的 skel。这几列现在只在升级路径与"历史 WC"里出现。

  • changelist:非 NULL 表示"本节点属于某个 changelist"。

  • text_mod :文本修改标记。脚本注释坦承"取值待定 ":"unknown"(无信息)、"admin"(用户用了类似 svn edit 的操作)、"noticed"(扫描文件系统时发现了修改)。这列在实践中很少被当作权威依据,因为修改状态主要靠 mtime/size 启发式 + 校验和比对来判定。

  • tree_conflict_data:如果是目录,则存"其中所有树冲突"的序列化数据。脚本注释指出它的历史与归宿:

    sql 复制代码
    /* if a directory, serialized data for all of tree conflicts therein.
       ### This column will eventually be merged into the conflict_data column,
       ### but within the ACTUAL node of the tree conflict victim itself, rather
       ### than the node of the tree conflict victim's parent directory. */
  • conflict_data(BLOB) :真正的主角。脚本注释:"A skel containing the conflict details." 它是 1.8 起(format 30,STMT_UPGRADE_TO_30 把旧列迁移进来)统一承载文本冲突、属性冲突、树冲突三类的 skel。

  • older_checksum / left_checksum / right_checksum :三个指向 PRISTINE 的校验和,分别对应"较旧的""左边的""右边的"冲突文本。这里脚本里保留了一段非常精彩的设计问答,值得原样引用:

    sql 复制代码
    /* Three columns containing the checksums of older, left and right conflict
       texts.  Stored in a column to allow storing them in the pristine store  */
    /* stsp: This is meant for text conflicts, right? What about property
             conflicts? Why do we need these in a column to refer to the
             pristine store? Can't we just parse the checksums from
             conflict_data as well?
       rhuijben: Because that won't allow triggers to handle refcounts.
                 We would have to scan all conflict skels before cleaning up the
                 a single file from the pristine stor */

    (wc-metadata.sql#L177-L188。)

    这段话讲清了一个为什么 :冲突文本也要进 pristine store,但如果只把校验和藏在 conflict_data 的 skel 里,SQLite 的触发器就无法自动维护 PRISTINE.refcount (触发器只能看列,不能解析 BLOB)。退而求其次,把校验和单独提成列 ,触发器才能用 UPDATE OF older_checksum 之类的语句维护引用计数。请注意,这也解释了 6.2.4 里"ACTUAL_NODE 的引用不计入 refcount"这句注释的由来------因为这几列的引用是由独立的触发器/清理逻辑处理的,与 NODES 的 refcount 分开维护。

  • parent_relpath :父节点的 local_relpath,用于按父聚合子节点。表头注释:"this will be "" if the parent is the wcroot. NULL if this is the wcroot node."

  • PRIMARY KEY (wc_id, local_relpath):每个 WC 内、每个 relpath 最多一行 ACTUAL_NODE。

  • 另有唯一索引 I_ACTUAL_PARENT ON ACTUAL_NODE (wc_id, parent_relpath, local_relpath),加速"某父目录下的全部 ACTUAL_NODE 子行"查询。

可运行查询示例(红色警告要求之一) ------查"某路径的 BASE/WORKING 两行"及冲突信息。假设路径为 trunk/foo.c:

sql 复制代码
-- (a) 查某路径在 NODES 中的 BASE(op_depth=0) 与最高 WORKING 层,以及各自的 presence/kind/checksum
SELECT n.op_depth, n.presence, n.kind, n.checksum,
       n.revision, n.translated_size, n.last_mod_time
  FROM NODES n
 WHERE n.local_relpath = 'trunk/foo.c'
 ORDER BY n.op_depth;

-- (b) 查该路径的 ACTUAL_NODE(属性修改 + 冲突三件套)
SELECT local_relpath, properties,
       older_checksum, left_checksum, right_checksum,
       conflict_old, conflict_new, conflict_working, prop_reject,
       tree_conflict_data, conflict_data, changelist, text_mod
  FROM ACTUAL_NODE
 WHERE local_relpath = 'trunk/foo.c';

-- (c) 若存在文本冲突,进一步查它的三个冲突文件相对路径(新式 skel 存 relpath)
SELECT conflict_old, conflict_new, conflict_working
  FROM ACTUAL_NODE
 WHERE local_relpath = 'trunk/foo.c'
   AND conflict_data IS NOT NULL;

-- (d) 查某路径引用到的 pristine 的引用计数
SELECT n.op_depth, n.checksum, p.refcount, p.size, p.hydrated
  FROM NODES n
  JOIN PRISTINE p ON p.checksum = n.checksum
 WHERE n.local_relpath = 'trunk/foo.c';

(注意 p.hydrated 这一列只在 format 32 及以后存在 ;在更低格式上执行会报"no such column"。这从侧面说明:同一份 schema 脚本,执行到哪个 STMT_UPGRADE_TO_xx 就产生哪一代的表结构。)

6.2.6 LOCK:仓库锁缓存

sql 复制代码
/* This table is a cache of information about repository locks. */
CREATE TABLE LOCK (
  /* what repository location is locked */
  repos_id  INTEGER NOT NULL REFERENCES REPOSITORY (id),
  repos_relpath  TEXT NOT NULL,

  /* Information about the lock. Note: these values are just caches from
     the server, and are not authoritative. */
  lock_token  TEXT NOT NULL,
  lock_owner  TEXT,
  lock_comment  TEXT,
  lock_date  INTEGER,   /* an APR date/time (usec since 1970) */

  PRIMARY KEY (repos_id, repos_relpath)
  );

(wc-metadata.sql#L199-L214。)

要点:这是服务器端锁 的本地缓存(不是工作副本写锁,写锁在 WC_LOCK)。注释强调"these values are just caches from the server, and are not authoritative "------svn status -u 展示的 O(other lock)标记正是读这张表。lock_date 是 APR 时间戳(微秒,自 1970 起)。

6.2.7 WORK_QUEUE:可重放的工作日志

sql 复制代码
CREATE TABLE WORK_QUEUE (
  /* Work items are identified by this value.  */
  id  INTEGER PRIMARY KEY AUTOINCREMENT,

  /* A serialized skel specifying the work item.  */
  work  BLOB NOT NULL
  );

(wc-metadata.sql#L219-L225。)

WORK_QUEUE 只有两列:自增 id 与 work(一个序列化 skel)。它是 6.6 节的主角------设计精髓在于"数据库事务先落日志,事务提交后再执行文件系统副作用"。

6.2.8 NODES:本章的核心

NODES 是全表的"心脏",也是本章硬示例 (b) 的舞台。它的表头注释先讲了 op_depth 的定义与一个三维示意图:

sql 复制代码
/* The NODES table describes the way WORKING nodes are layered on top of
   BASE nodes and on top of other WORKING nodes, due to nested tree structure
   changes. The layers are modelled using the "op_depth" column.

   An 'operation depth' refers to the number of directory levels down from
   the WC root at which a tree-change operation (delete, add?, copy, move)
   was performed.  A row's 'op_depth' does NOT refer to the depth of its own
   'local_relpath', but rather to the depth of the nearest tree change that
   affects that node.

   The row with op_depth=0 for any given local relpath represents the "base"
   node that is created and updated by checkout, update, switch and commit
   post-processing.  The row with the highest op_depth for a particular
   local_relpath represents the working version.  Any rows with intermediate
   op_depth values are not normally visible to the user but may become
   visible after reverting local changes.
   ...
   For illustration, with a scenario like this:

     # (0)
     svn rm foo
     svn cp ^/moo foo   # (1)
     svn rm foo/bar
     touch foo/bar
     svn add foo/bar    # (2)

   , these are the NODES table rows for the path foo/bar:

   (0)  "BASE" --->  NODES (op_depth == 0)
   (1)               NODES (op_depth == 1)
   (2)               NODES (op_depth == 2)

   0 is the original data for foo/bar before 'svn rm foo' (if it existed).
   1 is the data for foo/bar copied in from ^/moo/bar.
   2 is the to-be-committed data for foo/bar, created by 'svn add foo/bar'.

   An 'svn revert foo/bar' would remove the NODES of (2).
 */

(wc-metadata.sql#L242-L284。)

这段话值得逐句咀嚼:

  1. op_depth 不是 该行 local_relpath 的路径深度,而是"离它最近的那次树结构变更操作,发生在从 WC 根往下数的第几层 "。所以一个深层文件 a/b/c/d.c,如果它的祖先目录 a/b 发生了一次删除,那么它可能是 op_depth = 2(a/b 是两层)。
  2. op_depth = 0 的行永远是 BASE 节点(checkout / update / switch / commit 后处理产生)。
  3. 对某个 local_relpath 而言,最大的 op_depth 行就是 WORKING 版本(用户可见的当前状态)。
  4. 中间的 op_depth 行平时不可见,但在 revert 后会露出来------这正是"多层视图"的威力:revert 高层后,下一层立即成为当前可见版本。
  5. 那个 svn rm foo + svn cp ^/moo foo + svn add foo/bar 的例子,给出了 op_depth 精确等于 0、1、2 的三行。

列定义(分五组):

sql 复制代码
CREATE TABLE NODES (
  /* Working copy location related fields */
  wc_id  INTEGER NOT NULL REFERENCES WCROOT (id),
  local_relpath  TEXT NOT NULL,
  op_depth INTEGER NOT NULL,
  parent_relpath  TEXT,

  /* Repository location fields */
  repos_id  INTEGER REFERENCES REPOSITORY (id),
  repos_path  TEXT,
  revision  INTEGER,

  /* WC state fields */
  presence  TEXT NOT NULL,
  moved_here  INTEGER,
  moved_to  TEXT,

  /* Content fields */
  kind  TEXT NOT NULL,
  properties  BLOB,
  depth  TEXT,
  checksum  TEXT REFERENCES PRISTINE (checksum),
  symlink_target  TEXT,

  /* Last-Change fields */
  changed_revision  INTEGER,
  changed_date      INTEGER,
  changed_author    TEXT,

  /* Various cache fields */
  translated_size  INTEGER,
  last_mod_time  INTEGER,
  dav_cache  BLOB,
  file_external  INTEGER,
  inherited_props  BLOB,

  PRIMARY KEY (wc_id, local_relpath, op_depth)
  );

(wc-metadata.sql#L285-L479。)

逐列语义(以脚本注释为准):

A. 位置组

  • wc_id:外键,指向 WCROOT。
  • local_relpath:相对工作副本根的路径,用 / 分隔。
  • op_depth:见上。脚本注释补充一个关键特例:"Since a wcroot will never have a WORKING node the parent_relpath will never be null, except when op_depth == 0 and the node is a wcroot."(WC 根永远不会有 WORKING 节点;parent_relpath 唯一可能为 NULL 的情况,是 op_depth == 0 且节点就是 wcroot 自己。)
  • parent_relpath:父节点 relpath,用于按父聚合。

B. 仓库位置组

  • repos_id、repos_path、revision:脚本注释把两种情形的语义分开讲:

    sql 复制代码
    /* When op_depth == 0, these fields refer to the repository location of the
       BASE node, the location of the initial checkout.
    
       When op_depth != 0, they indicate where this node was copied/moved from.
       In this case, the fields are set for the root of the operation and for all
       children. */

    即:op_depth=0 时它们描述 BASE 节点自身的仓库位置;op_depth≠0 时它们描述"这个 WORKING 节点是从哪儿复制/移动来的"(copyfrom 信息) ,并且会在操作根及其全部子节点上都填好。

C. 状态组

  • presence:见 6.2.1 的对应表。这里脚本对 normal、not-present、incomplete、base-deleted、server-excluded、excluded 各自在 BASE 树与 WORKING 树下的双重含义 做了很长一段注释(wc-metadata.sql#L322-L371)。6.3 节会逐条解读。

  • moved_here(INTEGER,布尔):

    sql 复制代码
    /* Boolean value, specifying if this node was moved here (rather than just
       copied). This is set on all the nodes in the moved tree.  The source of
       the move is implied by a different node with a moved_to column pointing
       at the root node of the moved tree. */

    两个关键点:它是布尔 (列类型却写成 INTEGER,SQLite 无独立布尔类型);它对被移动子树的每一个节点都置位(不只根)。

  • moved_to(TEXT):

    sql 复制代码
    /* If the underlying node was moved away (rather than just deleted), this
       specifies the local_relpath of where the node was moved to.
       This is set only on the root of a move, and is NULL for all children.
    
       The op-depth of the moved-to node is not recorded. A moved_to path
       always points at a node within the highest op-depth layer at the
       destination. This invariant must be maintained by operations which
       change existing move information. */

    关键点:只设在 move 的根上,所有子节点为 NULL ;moved_to 的目标 op_depth 不记录 ,且永远指向目标处的最高 op_depth 层(这是必须维护的不变量)。

D. 内容组

  • kind:file / dir / symlink / unknown(见 6.2.1)。注释:"may be "unknown" if the node is not present."

  • properties(BLOB):本节点属性的序列化 skel。注释:"when presence is 'normal' or 'incomplete' ... an empty skel or NULL indicates no properties. NULL if we have no information about the properties (any other presence)." 这里还留了一个 TODO,坦承"尚未统一'无属性'的表示"(空 skel 还是 NULL 二选一都没定)。

  • depth(TEXT):脚本注释有一大段吐槽------

    sql 复制代码
    /* NULL depth means "default" (typically svn_depth_infinity) */
    /* ### depth on WORKING? seems this is a BASE-only concept. how do
       ### you do "files" on an added-directory? can't really ignore
       ### the subdirs! */
    /* ### maybe a WC-to-WC copy can retain a depth?  */

    即 depth 主要是 BASE 概念,WORKING 层上的 depth 语义并不清晰(svn add 一个目录时无法"忽略子目录")。

  • checksum(TEXT REFERENCES PRISTINE(checksum)):本文件节点的 pristine SHA-1。注释:"The SHA-1 checksum of the pristine text, if this node is a file and was moved here or copied here, else NULL." 请注意它也是 refcount 触发器唯一关心的列(见 6.2.9)。

  • symlink_target:kind == symlink 时的目标字符串。

E. Last-Change 组

  • changed_revision / changed_date / changed_author:脚本注释规定了一个硬约束 ------"changed_rev must be not-null if this node has presence=="normal""。也就是说,任何 presence='normal' 的节点都必须有 changed_revision(在 SVN 术语中即"最后修改修订号")。changed_date 是 APR 时间戳。

F. 缓存组

  • translated_size:

    sql 复制代码
    /* The size in bytes of the working file when it had no local text
       modifications. This means the size of the text when translated from
       repository-normal format to working copy format with EOL style
       translated and keywords expanded according to the properties in the
       "properties" column of this row. */

    关键:它是**"翻译后"的大小**(EOL 转换 + 关键字展开之后),不是 pristine 的原始大小。它被 svn status 的"变化检测启发式"使用。

  • last_mod_time:

    sql 复制代码
    /* The mod-time of the working file when it was last determined to be
       logically unmodified relative to its base, taking account of keywords
       and EOL style. This value is used in the change detection heuristic
       used by the status command. */

    即"上一次确认它与 base 逻辑一致时,工作文件的 mtime "。1.15 的 r1892645"用加密校验和判断文件是否被修改"正是在这个启发式的最后一道防线上升级(见 6.8 节)。

  • dav_cache(BLOB):WebDAV 属性缓存(format 13 从 wcprops 挪进来的)。

  • file_external(INTEGER):注释写 "Is there a file external in this location. NULL if there is no file external, otherwise '1'"。还有一条历史注解:

    sql 复制代码
    /* ### Originally we had a wc-1.0 like skel in this place, so we
       ### check for NULL.
       ### In Subversion 1.7 we defined this column as TEXT, but Sqlite
       ### only uses this information for deciding how to optimize
       ### anyway. */

    也就是说 1.7 时它被定义成 TEXT,但因为 SQLite 的列类型只是亲和性建议,实际存 1 也无妨。

  • inherited_props(BLOB):注释 "serialized skel of this node's inherited properties. NULL if this is not the BASE of a WC root node." 这是 format 31 新增的列,用于**继承属性(svn:mergeinfo 等)**的缓存。

主键与索引:

sql 复制代码
  PRIMARY KEY (wc_id, local_relpath, op_depth)
  );

CREATE UNIQUE INDEX I_NODES_PARENT ON NODES (wc_id, parent_relpath,
                                             local_relpath, op_depth);
/* I_NODES_MOVED is introduced in format 30 */
CREATE UNIQUE INDEX I_NODES_MOVED ON NODES (wc_id, moved_to, op_depth);

PRIMARY KEY 是 (wc_id, local_relpath, op_depth) ------这正是"同一个路径允许多层共存"的物理保证:只有当 local_relpath 与 op_depth 同时 相同才算冲突。I_NODES_MOVED 在 moved_to 上建唯一索引(wc_id, moved_to, op_depth),让"该节点被移动到哪儿去了"的查询走索引。脚本注释说它 "is introduced in format 30",但注意 STMT_UPGRADE_TO_30 里用的是 CREATE UNIQUE INDEX IF NOT EXISTS I_NODES_MOVED------CREATE SCHEMA 里已经建过一次,升级路径再补一次是幂等的。

6.2.9 两个视图与三个触发器

视图 NODES_CURRENT / NODES_BASE:

sql 复制代码
CREATE VIEW NODES_CURRENT AS
  SELECT * FROM nodes AS n
    WHERE op_depth = (SELECT MAX(op_depth) FROM nodes AS n2
                      WHERE n2.wc_id = n.wc_id
                        AND n2.local_relpath = n.local_relpath);

CREATE VIEW NODES_BASE AS
  SELECT * FROM nodes
  WHERE op_depth = 0;

(wc-metadata.sql#L486-L506。)

  • NODES_CURRENT:对每个 local_relpath 取最大 op_depth 那一行,得到"当前可见视图"。注释提醒:"Note that this view includes a row for each and every path that is known in the WC, including, for example, paths that were children of a base- or lower-op-depth directory that has been replaced by something else in the current view."------它包含所有已知路径,包括那些"在更高层被取代"的子节点,因为它们的 BASE 行仍在。
  • NODES_BASE:只要 op_depth = 0 的行,即"检出时的 BASE 树"。

三个触发器维护 PRISTINE.refcount:

sql 复制代码
CREATE TRIGGER nodes_insert_trigger
AFTER INSERT ON nodes
WHEN NEW.checksum IS NOT NULL
BEGIN
  UPDATE pristine SET refcount = refcount + 1
  WHERE checksum = NEW.checksum;
END;
sql 复制代码
CREATE TRIGGER nodes_delete_trigger
AFTER DELETE ON nodes
WHEN OLD.checksum IS NOT NULL
BEGIN
  UPDATE pristine SET refcount = refcount - 1
  WHERE checksum = OLD.checksum;
END;
sql 复制代码
CREATE TRIGGER nodes_update_checksum_trigger
AFTER UPDATE OF checksum ON nodes
WHEN NEW.checksum IS NOT OLD.checksum
  /* AND (NEW.checksum IS NOT NULL OR OLD.checksum IS NOT NULL) */
BEGIN
  UPDATE pristine SET refcount = refcount + 1
  WHERE checksum = NEW.checksum;
  UPDATE pristine SET refcount = refcount - 1
  WHERE checksum = OLD.checksum;
END;

(wc-metadata.sql#L508-L533。)

这三个触发器是"为什么 refcount 可以被信任"的答案:

  • 插入带 checksum 的 NODES 行 → 对应 pristine refcount + 1;
  • 删除带 checksum 的 NODES 行 → 对应 pristine refcount - 1;
  • 更新 checksum 列(且新旧不同)→ 新 pristine +1、旧 pristine -1。

WHEN NEW.checksum IS NOT NULL / WHEN OLD.checksum IS NOT NULL 保证了"checksum 为 NULL 的行不参与计数"。第三个触发器里那行被注释掉的 AND (...) 条件,暗示了早期的一个实现疏漏:当 NEW.checksum 为 NULL 时,WHERE checksum = NULL 在 SQL 里恒为假(不会更新任何行),所以那行条件是冗余的;但作者还是保留它作为文档。

为什么 refcount 如此重要? 因为 pristine 的磁盘文件是按内容共享 的:同一个 checksum 只存一份文件,可能被成百上千个 NODES 行引用。要安全地删除一个不再需要的 pristine 文件,必须先确认"没有一行还引用它",否则会破坏其他路径。refcount 就是那个"安全计数",而触发器保证它在任何 DML 路径下都不会漂移------这是把逻辑放进数据库而非应用层的好处。

6.2.10 EXTERNALS

sql 复制代码
CREATE TABLE EXTERNALS (
  wc_id  INTEGER NOT NULL REFERENCES WCROOT (id),
  local_relpath  TEXT NOT NULL,
  parent_relpath  TEXT NOT NULL,
  repos_id  INTEGER NOT NULL REFERENCES REPOSITORY (id),
  /* Either MAP_NORMAL or MAP_EXCLUDED */
  presence  TEXT NOT NULL,
  kind  TEXT NOT NULL,
  def_local_relpath         TEXT NOT NULL,
  def_repos_relpath         TEXT NOT NULL,
  def_operational_revision  TEXT,
  def_revision              TEXT,
  PRIMARY KEY (wc_id, local_relpath)
);

(wc-metadata.sql#L535-L567。)

要点:

  • presence 的取值是 MAP_NORMAL 或 MAP_EXCLUDED(与 NODES 的 presence 不同名)。
  • def_local_relpath / def_repos_relpath / def_operational_revision / def_revision:描述外部定义。"The local relpath of the directory NODE defining this external (Defaults to the parent directory of the file external after upgrade).";以及 "The operational (peg) and node revision if this is a revision fixed external; otherwise NULL. (Usually these will both have the same value)"。
  • 唯一索引 I_EXTERNALS_DEFINED ON EXTERNALS (wc_id, def_local_relpath, local_relpath)。

6.2.11 创建期收尾:PRAGMA user_version 与查询计划统计

脚本在 schema 定义后紧接着一句:

sql 复制代码
/* Identify the WC format corresponding to the schema we have created. */
PRAGMA user_version =
-- define: SVN_WC__SUPPORTED_VERSION
;

(wc-metadata.sql#L574-L577。)

-- define: SVN_WC__SUPPORTED_VERSION 是给 C 预处理器的占位符 :脚本在读取时会被替换成当前的最小支持格式(1.15 里是 31,见 6.5 节)。因此 PRAGMA user_version 记录的就是"这个 DB 自称的格式号"。

脚本还包含一段 -- STMT_INSTALL_SCHEMA_STATISTICS,手动喂给 SQLite 查询规划器 关于 NODES/ACTUAL_NODE/LOCK/WC_LOCK/EXTERNALS 的统计信息,并有个显眼的注释 "Tell a lie: We ignore that 99.9% of all moved_to values are NULL"(wc-metadata.sql#L602-L635)。

6.2.12 升级路径:STMT_UPGRADE_TO_30/31/32

创建 schema 只是"最低支持格式"。真正让老 WC 升到新格式的是三段升级语句。这里只做结构导读,细节留到 6.4/6.5。

STMT_UPGRADE_TO_30(wc-metadata.sql#L638-L669):

sql 复制代码
/* Format 30 creates a new NODES index for move information, and a new
   PRISTINE index for the md5_checksum column. It also activates use of
   skel-based conflict storage -- see notes/wc-ng/conflict-storage-2.0.
   It also renames the "absent" presence to "server-excluded". */
-- STMT_UPGRADE_TO_30
CREATE UNIQUE INDEX IF NOT EXISTS I_NODES_MOVED
ON NODES (wc_id, moved_to, op_depth);

CREATE INDEX IF NOT EXISTS I_PRISTINE_MD5 ON PRISTINE (md5_checksum);

UPDATE nodes SET presence = 'server-excluded' WHERE presence = 'absent';

注意 UPDATE nodes SET presence = 'server-excluded' WHERE presence = 'absent'------absent 这个 DDL 时代的旧字符串被重命名为 server-excluded 。另外 STMT_UPGRADE_TO_30 还配套两条把旧冲突列迁移进 conflict_data 的语句:

sql 复制代码
-- STMT_UPGRADE_30_SELECT_CONFLICT_SEPARATE
SELECT wc_id, local_relpath,
  conflict_old, conflict_working, conflict_new, prop_reject, tree_conflict_data
FROM actual_node
WHERE conflict_old IS NOT NULL
   OR conflict_working IS NOT NULL
   OR conflict_new IS NOT NULL
   OR prop_reject IS NOT NULL
   OR tree_conflict_data IS NOT NULL
ORDER by wc_id, local_relpath

-- STMT_UPGRADE_30_SET_CONFLICT
UPDATE actual_node SET conflict_data = ?3, conflict_old = NULL,
  conflict_working = NULL, conflict_new = NULL, prop_reject = NULL,
  tree_conflict_data = NULL
WHERE wc_id = ?1 and local_relpath = ?2

(wc-metadata.sql#L654-L669。)

这是一个标准的"SELECT 出旧数据 → C 代码构造 skel → UPDATE 写回 conflict_data 并清空旧列"的迁移模式。

STMT_UPGRADE_TO_31(wc-metadata.sql#L671-L709):

sql 复制代码
/* Format 31 adds the inherited_props column to the NODES table. C code then
   initializes the update/switch roots to make sure future updates fetch the
   inherited properties */
-- STMT_UPGRADE_TO_31
ALTER TABLE NODES ADD COLUMN inherited_props BLOB;

DROP INDEX IF EXISTS I_ACTUAL_CHANGELIST;
DROP INDEX IF EXISTS I_EXTERNALS_PARENT;

DROP INDEX I_NODES_PARENT;
CREATE UNIQUE INDEX I_NODES_PARENT ON NODES (wc_id, parent_relpath,
                                             local_relpath, op_depth);

DROP INDEX I_ACTUAL_PARENT;
CREATE UNIQUE INDEX I_ACTUAL_PARENT ON ACTUAL_NODE (wc_id, parent_relpath,
                                                    local_relpath);

PRAGMA user_version = 31;

注意 DROP INDEX IF EXISTS I_ACTUAL_CHANGELIST 与 I_EXTERNALS_PARENT------format 31 顺手删掉了两个老索引 (I_ACTUAL_CHANGELIST 曾是 changelist 索引,I_EXTERNALS_PARENT 曾是 external 父路径索引)。还有一条配套的 C 侧查询 STMT_UPGRADE_31_SELECT_WCROOT_NODES,用于"找出所有是 WC 根的 base 节点(含 switch 子树,排除映射到仓库根者)"以初始化 inherited_props。

下一段继续深入:presence 的双重语义、op_depth 状态机、以及 pristine 的内容寻址。

6.3 状态与结构:presence 的双重语义、op_depth 双视图

6.3.1 presence:同一个字符串,两棵树,两套含义

wc-metadata.sql 在 NODES.presence 列上方埋了一整段教科书级 的注释。它明确写道:"The 'BASE' and 'WORKING' trees use the same literal values for the 'presence' but the meaning of each value can vary depending on the tree."(wc-metadata.sql#L334-L336。)逐条翻译如下:

  • normal:

    • BASE 树:"an ordinary node for which we have full information"(信息完整的普通节点);
    • WORKING 树:"an added or copied node for which we have full information"(信息完整的 add/copy 节点)。
    • 一句话记忆 :BASE 的 normal = 版本化基节点;WORKING 的 normal = 本地新增/复制/移动过来的节点。
  • not-present:

    • BASE 树:"a node that is implied to exist by the parent node, but is not present in the working copy. Typically obtained by delete/commit, or by update to revision in which the node does not exist."(父节点"暗示"它应该存在,但工作副本里没有。典型来源:删除后提交,或更新到一个"该节点不存在"的 revision。)
    • WORKING 树:"a copy of a 'not-present' node from the 'BASE' tree, and it will be deleted on commit. Such a node cannot be copied directly, but can be copied as a descendant."(BASE 里 not-present 节点的工作层拷贝,提交时会被删掉;它不能被直接 copy,但可以作为后代被 copy。)
  • incomplete (脚本用词是 incomplete:,实际取值即 incomplete):

    • BASE 树:"an ordinary node for which we do not have full information. Only the name is guaranteed; we may not have all its children, we may not have its checksum, etc."(只保证名字,可能没有子节点、没有校验和。)
    • WORKING 树:"a copied node for which we do not have the full information. This state is generally obtained when an operation was interrupted."(操作被打断时得到的残缺拷贝节点。)
  • base-deleted:

    • BASE 树:"not valid in 'BASE' tree.";
    • WORKING 树:"this represents a node that is deleted from the tree below the current 'op_depth'." 脚本还很诚实地吐槽:"This state is badly named, it should be something like 'deleted'."(命名很糟,本该叫 'deleted'。)
  • server-excluded:

    • BASE 树:"a node that is excluded by authz. The name of the node is known from the parent, but no other information is available."(被服务端 authz 排除;父节点知道名字,其他信息一概没有。)
    • WORKING 树:"Not valid in 'WORKING' tree as there is no way to commit such a node."(因为无法提交这样一个节点,WORKING 树里不存在该取值。)
  • excluded:

    • BASE 树:"this node is administratively excluded by the user (sparse WC)."(用户主动稀疏排除。)
    • WORKING 树:"this is a copy of an excluded node from the 'BASE' tree. Such a node cannot be copied directly but can be copied as a descendant."

为什么"同一个值两套含义"是个好设计? 因为 BASE 与 WORKING 本就是"两层视图",同一个物理列承载同一套词汇表,语义由 op_depth 决定。这样查询可以统一写 WHERE presence = 'normal',再按 op_depth 区分------避免了"为两棵树各造一套枚举"导致的值域膨胀与迁移负担。

6.3.2 svn_wc__db_status_t:读取后的语义归一化

C 侧枚举 svn_wc__db_status_t(wc_db.h#L132-L187)比数据库的 6 个 presence 多了几个值,因为它把"WORKING 层的 normal"再细分了:

c 复制代码
    /* The node is present and has no known modifications applied to it. */
    svn_wc__db_status_normal,

    /* The node has been added ... In many cases svn_wc__db_status_added means
       any of added, moved-here or copied-here. ... */
    svn_wc__db_status_added,

    /* This node has been added with history, based on the move source. ... */
    svn_wc__db_status_moved_here,

    /* This node has been added with history, based on the copy source. ... */
    svn_wc__db_status_copied,

    /* This node has been deleted. No text or property modifications
       will be present. */
    svn_wc__db_status_deleted,

    /* This node was named by the server, but no information was provided. */
    svn_wc__db_status_server_excluded,

    /* This node has been administratively excluded. */
    svn_wc__db_status_excluded,

    /* This node is not present in this revision. ... */
    svn_wc__db_status_not_present,

    /* This node is known, but its information is incomplete. ... */
    svn_wc__db_status_incomplete,

    /* The BASE node has been marked as deleted. Only used as an internal
       status in wc_db.c and entries.c.  */
    svn_wc__db_status_base_deleted

注意最后一条:svn_wc__db_status_base_deleted 的注释写着"Only used as an internal status in wc_db.c and entries.c "。这意味着:磁盘上的 presence='base-deleted',在对外读取时通常被归一化成 svn_wc__db_status_deleted ;只有 WC 内部少数路径才会看到 base_deleted 这个原值。

svn_wc__db_status_added 的注释还点明了它的"伞形"性质:"In many cases svn_wc__db_status_added means any of added, moved-here or copied-here. See individual functions for clarification and svn_wc__db_scan_addition() to get more details."------想区分"纯新增 / 复制 / 移动",需要用 svn_wc__db_scan_addition() 进一步扫描 copyfrom / moved_here 信息。

可运行查询------把"存储层的 presence"与"派生的语义类别"对齐看一眼:

sql 复制代码
-- 枚举一个 WC 里出现的 (op_depth, presence) 组合,附各组合的行数
SELECT op_depth, presence, COUNT(*) AS rows
  FROM NODES
 GROUP BY op_depth, presence
 ORDER BY op_depth, rows DESC;

-- 只看"移动"相关:moved_here / moved_to
SELECT local_relpath, op_depth, presence, moved_here, moved_to
  FROM NODES
 WHERE moved_here IS NOT NULL OR moved_to IS NOT NULL
 ORDER BY op_depth, local_relpath;

6.3.3 op_depth 状态机硬示例:svn rm + svn mv 的逐行推演

下面用一条可复现的操作序列 ,推演 NODES 表里各行的 op_depth / presence / moved_here / moved_to。所有"规则"都来自本章已引用的 wc-metadata.sql 注释;op_depth 的数值按注释定义"操作根从 WC 根往下数的层数"得出。

初始状态 (svn checkout r10 后,令 WC 根为 .):

local_relpath op_depth presence kind repos_path 备注
trunk 0 normal dir trunk BASE 目录
trunk/alpha.c 0 normal file trunk/alpha.c BASE 文件,checksum=SHA1(A)
trunk/beta.c 0 normal file trunk/beta.c checksum=SHA1(B)
trunk/sub 0 normal dir trunk/sub BASE 子目录
trunk/sub/gamma.c 0 normal file trunk/sub/gamma.c checksum=SHA1(G)

此时 NODES 里每个路径只有一行 ,且全在 BASE 层(op_depth=0)。

步骤 1:svn rm trunk/alpha.c

删除操作的 op-root 是 trunk/alpha.c,其深度为 2(trunk 是 1,alpha.c 是 2),所以新增行的 op_depth = 2:

local_relpath op_depth presence kind moved_here moved_to
trunk/alpha.c 0 normal file NULL NULL
trunk/alpha.c 2 base-deleted file NULL NULL

规则要点:

  • BASE 行(op_depth=0)原样保留------这就是"多层"的根基:删除并不擦掉基节点,而是在上面叠一层"已删除"。
  • 新叠加的 WORKING 行 presence='base-deleted'(脚本注释:"not valid in 'BASE' tree. In the 'WORKING' tree this represents a node that is deleted from the tree below the current 'op_depth'.")。
  • 因为只是 delete,moved_here / moved_to 都为 NULL。

步骤 2:svn mv trunk/sub trunk/sub2

这是移动一个目录 。移动的"目标根"是 trunk/sub2(深度 1),"源根"是 trunk/sub(深度 1)。于是新增一行 at op_depth = 1(目标层的操作深度):

local_relpath op_depth presence kind moved_here moved_to repos_path
trunk/sub 0 normal dir NULL trunk/sub2 trunk/sub
trunk/sub 1 base-deleted dir NULL NULL NULL
trunk/sub2 1 normal dir 1 NULL trunk/sub
trunk/sub/gamma.c 0 normal file NULL NULL trunk/sub/gamma.c
trunk/sub/gamma.c 1 base-deleted file NULL NULL NULL
trunk/sub2/gamma.c 1 normal file 1 NULL trunk/sub/gamma.c

逐条对照规则:

  1. moved_to 只设在 move 的根上,子节点为 NULL (SQL 注释:"This is set only on the root of a move, and is NULL for all children.")。所以 trunk/sub 的 BASE 行(op_depth=0)拿到 moved_to='trunk/sub2';而 trunk/sub/gamma.c 没有 moved_to。
  2. moved_here 设在被移动子树的每一个节点上 (SQL 注释:"This is set on all the nodes in the moved tree.")。所以 trunk/sub2 与 trunk/sub2/gamma.c 的 WORKING 行都是 moved_here=1。
  3. repos_path 在 op_depth≠0 时表示 copyfrom 源 (SQL 注释:"When op_depth != 0, they indicate where this node was copied/moved from. In this case, the fields are set for the root of the operation and for all children.")。所以目标行的 repos_path 分别指向 trunk/sub 与 trunk/sub/gamma.c。
  4. 源子树被删除 :trunk/sub 与 trunk/sub/gamma.c 各多出一行 presence='base-deleted'、op_depth=1。
  5. moved_to 的指向不变量 (SQL 注释:"A moved_to path always points at a node within the highest op-depth layer at the destination."):moved_to='trunk/sub2' 指向目标处的最高 op_depth 层(此处=1),必须始终满足。

步骤 3(可选):svn rm trunk/sub2/gamma.c

再删掉移动过来的文件,op-root=trunk/sub2/gamma.c,深度 2,所以新增 op_depth = 2:

local_relpath op_depth presence
trunk/sub2/gamma.c 0 normal(BASE 里本来没有,见下方说明)
trunk/sub2/gamma.c 1 normal(moved_here=1)
trunk/sub2/gamma.c 2 base-deleted

这里有个微妙但关键 的点:trunk/sub2/gamma.c 在 BASE 里并不存在 (BASE 里它叫 trunk/sub/gamma.c)。那为什么 op_depth=0 那一行会显示 normal?答案是:在 WC-NG 里,被移动/复制进来的节点,其"基节点行"挂在源路径下,而目标路径只有 WORKING 层行 。上面表格里为教学整齐填了一行 op_depth=0,实际存储中目标路径的可见行从 op_depth=1 起。也就是说------"某个 local_relpath 是否存在 op_depth=0 的行",取决于它在源仓库里的位置,而不取决于它现在的路径 。这正是 op_depth 定义里那句"does NOT refer to the depth of its own local_relpath"的深意。

svn revert 如何回到从前? 脚本注释自己给了答案:"An 'svn revert foo/bar' would remove the NODES of (2)."------revert 是删除高 op_depth 层 。删掉 op_depth=2 的 base-deleted 行后,op_depth=1 的 moved 行重新成为 NODES_CURRENT 里可见的那一行,"文件回来了"。这正是"中间层平时不可见,revert 后可见"的具体机制。

可运行的推演验证 SQL:

sql 复制代码
-- 用 NODES_CURRENT 视图看"当前可见"的每一行(即 UI 看到的状态)
SELECT c.local_relpath, c.op_depth, c.presence, c.kind,
       c.moved_here, c.moved_to, c.checksum
  FROM NODES_CURRENT c
 WHERE c.local_relpath LIKE 'trunk/%'
 ORDER BY c.local_relpath;

-- 与上面对照:看某个路径的"全部层"(含隐藏层),验证多层共存
SELECT local_relpath, op_depth, presence, moved_here, moved_to, repos_path
  FROM NODES
 WHERE local_relpath IN ('trunk/sub','trunk/sub2','trunk/sub2/gamma.c')
 ORDER BY local_relpath, op_depth;

-- 追踪一次移动的两端(源根 moved_to 与目标根 moved_here)
SELECT 'SRC' AS side, local_relpath, op_depth, presence, moved_to
  FROM NODES WHERE moved_to = 'trunk/sub2'
UNION ALL
SELECT 'DST', local_relpath, op_depth, presence, moved_here
  FROM NODES WHERE local_relpath = 'trunk/sub2';

6.3.4 为什么这套模型能同时支持稀疏、changelist、externals

回到 6.1.2 列的"表达困境",现在可以逐个回答:

  • 混合修订 :不同子树处在不同 revision,在 NODES 里就是不同 BASE 行的 revision 不同,互不干扰。一个 update 只改被更新子树的 BASE 行。
  • 目录 rename / move :不再是"把 copyfrom 复制进每个子目录",而是"源根一行 moved_to + 目标子树每行 moved_here"。一棵树的移动在数据库里是一组同 op_depth 的插入 + 源侧一组 base-deleted 插入,一个事务完成。
  • 稀疏(sparse) :用 presence='excluded'(BASE 层)表达"用户主动排除",beats 早期的 depth='exclude'(format 15 才换过来)。
  • changelist :写在 ACTUAL_NODE.changelist 列,一个字符串;配合(历史上存在过的)I_ACTUAL_CHANGELIST 索引,可以快速按 changelist 聚合------索引在 format 31 被删掉(见 6.2.12),说明实践中已不再依赖索引扫描。
  • externals :独立的 EXTERNALS 表,用 def_* 四列描述外部定义,presence 用 MAP_NORMAL/MAP_EXCLUDED 区分"可用 / 用户排除"。

6.3.5 PRISTINE 与内容寻址:为什么是 SHA-1,为什么还要 md5

为什么按 SHA-1 存储? 三个理由:

  1. 去重 :checksum 做 PRIMARY KEY,同一份内容在一个 WC 里只存一份文件。例如 100 个目录各有一份内容相同的 LICENSE,pristine 里只有 1 行、磁盘上只有 1 份。
  2. 可校验 :size 列 + SHA-1 本身构成"这份 pristine 文件是不是写坏了"的校验(表头注释:"Used to verify the pristine file is 'proper'.")。
  3. 可寻址:文件路径由 checksum 直接推导,无需额外的"路径→内容"映射表。

为什么还要 md5_checksum 并存? 注释说得很直接:"Alternative MD5 checksum used for communicating with older repositories."------因为 1.6 及更早的仓库/客户端用 MD5 标识内容;当新客户端与老服务器/老数据交互时,需要 MD5。它的"不保证唯一"("Not strictly guaranteed to be unique among table rows")使得它只能建普通索引,不能做主键。

refcount 如何被维护? 全部靠 6.2.9 的三个触发器(nodes_insert_trigger / nodes_delete_trigger / nodes_update_checksum_trigger)。这里再点一句关键:refcount 只统计 NODES 表的引用 ,不统计 ACTUAL_NODE 的三列冲突 checksum。这是设计者明知代价后的取舍------代价是"清理 pristine 时要额外扫描冲突 skel",收益是"触发器能自动维护 NODES 侧的计数"。1.15 的 TEXTBASE_REFS 表(见 6.5)把"文本基内容的引用"单独列出来,正是为了让"脱水(dehydrate)"有一个精确、可触发维护的判据。

一个可运行的"稀缺内容"查询 ------找出只被引用一次的 pristine(这些内容的去重收益为 0,是 pristineless 下最值得"不存本地"的候选):

sql 复制代码
-- refcount = 1 的 pristine(存了但没人共享)
SELECT COUNT(*) AS single_ref,
       SUM(size) AS bytes,
       printf('%.1f MB', SUM(size) / 1048576.0) AS mb
  FROM PRISTINE
 WHERE refcount = 1;

再一个 :按"压缩类型"分组,验证现实中 compression 是否恒为 NULL:

sql 复制代码
SELECT compression, COUNT(*) AS rows
  FROM PRISTINE
 GROUP BY compression;

在 1.15 上这个查询几乎总是返回一行 NULL | <n>,印证了注释"the only value supported so far is NULL"。这样一段 SQL 也是检验源码结论的好办法:文档说"只支持 NULL",那就用数据去证伪或证实。

6.3.6 kind、depth、revision 三者的分工

  • kind:节点的类型 (file/dir/symlink/unknown),与 svn_node_kind_t 对应。注意它可以是 unknown(节点不存在时)。
  • depth:检出的深度 (BASE 概念)。取值映射到 svn_depth_t(svn_depth_empty / files / immediates / infinity,以及 unknown)。NODES.depth 为 NULL 时代表"默认"(通常为 infinity)。
  • revision:BASE 节点是"它属于哪个修订",WORKING 节点是"copyfrom 的源修订"。

三者组合能回答"这个路径是不是一个检出在 depth=immediates 的目录、内容是哪个 revision 的类型为 dir 的节点"。

下一节进入 1.15 的两大头条之一:pristineless。

6.4 pristineless:让"本地原始内容"变成可选

6.4.1 动机与发布说明

CHANGES 的 1.15.0 小节把 pristineless 列为头号新特性:

sql 复制代码
  - Major new features:
    * New 'pristineless' mode for working copies that eliminates overhead
      from storing cached pristine content (r1905955, issue #525)
    * Support for multiple working copy formats (r1898187, issue #4883)

(CHANGES#L14-L18。)

"pristineless"这个名字有点误导:它并不是"完全没有 pristine",而是"pristine 内容不默认在本地保留一份 "。用 CLI 帮助里的话说,就是"Configure the working copy to either store local copies of pristine contents ('yes') or to fetch them on demand ('no')"(svn.c#L373-L382)。

为什么要做这件事? 传统 WC 为每个版本化文件在 .svn/pristine/ 下缓存一份完整原始内容。对于大型工作副本(比如包含大量二进制资源、或同一内容反复出现的仓库),pristine store 的体积可以与工作文件本身相当甚至更大。pristineless 允许用户按需从服务器拉取 原始内容,从而把磁盘占用降下来------代价是 svn diff、svn revert 等命令在需要 pristine 时可能要联网。

6.4.2 常量:格式 32 的两把钥匙

wc.h 里与 pristineless 直接相关的常量有三处:

c 复制代码
/* The newest WC format this client supports.
 *
 * @see svn_wc__max_supported_format()
 */
#define SVN_WC__VERSION 32

/* The minimum WC version supported by the client.
 *
 * @see svn_wc__min_supported_format()
 */
/* IMPORTANT: Update the implementation of svn_client_default_wc_version()
              and svn_client_get_wc_formats_supported()
              and svntest.main.wc_format()
              whenever you change this value! */
#define SVN_WC__SUPPORTED_VERSION 31

/* The default WC version that the Subversion library should create
 * (or upgrade to) when not otherwise specified. */
#define SVN_WC__DEFAULT_VERSION SVN_WC__SUPPORTED_VERSION

(wc.h#L173-L191。)

以及两个"从某个版本起可用"的特性门:

c 复制代码
/* Starting from this version, pristine content is optional and can be
 * fetched on demand.  */
#define SVN_WC__HAS_OPTIONAL_PRISTINE 32

/* Starting from this version, the DB stores per-WC settings. */
#define SVN_WC__HAS_SETTINGS 32

(wc.h#L222-L227。)

关键观察:

  • SVN_WC__VERSION = 32 是最新支持格式 ;SVN_WC__SUPPORTED_VERSION = 31 是最老支持格式。
  • SVN_WC__DEFAULT_VERSION = SVN_WC__SUPPORTED_VERSION = 31。这意味着默认新建/升级出的 WC 是格式 31(1.8 兼容) ,而不是 32。想要 pristineless,必须显式请求格式 32(即 --compatible-version=1.15)且 --store-pristine=no。
  • SVN_WC__HAS_OPTIONAL_PRISTINE = 32:只有格式 ≥ 32 才支持"pristine 可选";SVN_WC__HAS_SETTINGS = 32:只有格式 ≥ 32 才有 per-WC 设置表。这两把钥匙锁住了 pristineless 的门。

6.4.3 STMT_UPGRADE_TO_32:格式 32 带来的四处 SQL 变化

wc-metadata.sql 的 STMT_UPGRADE_TO_32 段头注释把新增内容列得一清二楚:

sql 复制代码
/* Format 32 adds support for optional pristine contents with the
   following schema changes:
   - Add the 'hydrated' column to the PRISTINE table.
   - Add the I_PRISTINE_UNREFERENCED index.
   - Add the TEXTBASE_REFS table.
   - Add the SETTINGS table. */
-- STMT_UPGRADE_TO_32
/* True iff the pristine contents are currently available on disk. */
ALTER TABLE PRISTINE ADD COLUMN hydrated INTEGER NOT NULL DEFAULT 1;

CREATE INDEX I_PRISTINE_UNREFERENCED ON PRISTINE (refcount, refcount=0);

/* This table contains references to the on disk text-base contents.
   Every row corresponds to a row in NODES table with the same key.
   While a row is present is this table, the contents identified by the
   corresponding NODES.checksum cannot be dehydrated from the pristine store.
 */
CREATE TABLE TEXTBASE_REFS (
  /* Same key columns as in the NODES table */
  wc_id  INTEGER NOT NULL,
  local_relpath  TEXT NOT NULL,
  op_depth  INTEGER NOT NULL,

  PRIMARY KEY (wc_id, local_relpath, op_depth)
  );

(wc-metadata.sql#L710-L735。)

逐项解读:

  1. PRISTINE.hydrated(INTEGER NOT NULL DEFAULT 1) :注释直译为"True iff the pristine contents are currently available on disk",即"原始内容当前是否真的在磁盘上 "。DEFAULT 1 让所有已存在的老行在升级时自动补成"已水合"------因为升级前它们必然在磁盘上有文件。这个默认值的选择非常关键:它把"升级"变成一次无损操作,不需要回填任何数据。

    顺带一提,"hydrate/dehydrate"(水合/脱水)是 Subversion 给"本地保留 pristine 内容 ↔ 从服务器按需拉取"这对操作起的比喻名。C 侧的对应函数是 svn_wc__db_pristine_dehydrate()(wc_db_pristine.c#L815)与 svn_wc__db_pristine_dehydrate_internal()(wc_db_pristine.c#L837)。

  2. I_PRISTINE_UNREFERENCED ON PRISTINE (refcount, refcount=0) :一个表达式索引 (refcount=0 是 SQLite 的表达式索引写法)。它的目的是让"找出未引用的 pristine "这个操作(--vacuum-pristines / 脱水决策都要用)变得廉价。为什么会出现在"可选 pristine"这个 format 里?因为"判断某份内容是否可以脱水/回收"要频繁问"还有没有引用",这个索引就是为频繁查询准备的。

  3. TEXTBASE_REFS 表:这是最精妙的一处。注释说得很清楚:

    • "Every row corresponds to a row in NODES table with the same key."(每一行对应 NODES 表里同主键的一行。)主键同样是 (wc_id, local_relpath, op_depth)。
    • "While a row is present is this table, the contents identified by the corresponding NODES.checksum cannot be dehydrated from the pristine store."(只要这行还在,它对应的 NODES.checksum 所指内容就不能被脱水。)

    换句话说,TEXTBASE_REFS 是"这些路径需要一份磁盘上的文本基 "的登记簿。它把"哪些 NODES 行真正需要 pristine 内容在本地"与"哪些只是历史引用"区分开来。为什么需要这样一张表?因为**PRISTINE.refcount 统计的是 NODES 引用数,但并不是每个 NODES 引用都要求本地有一份文件**。比如一个被删除的历史层,它的 checksum 仍然让 refcount 非零,但它的内容可能早已不需要------TEXTBASE_REFS 让"脱水"能精确到"谁真的需要"。

  4. SETTINGS 表:

    sql 复制代码
    /* This table contains settings of a working copy, identified by WC_ID. */
    CREATE TABLE SETTINGS (
      wc_id  INTEGER NOT NULL REFERENCES WCROOT (id) PRIMARY KEY,
      store_pristine  INTEGER
    );
    
    /* Migrate existing working copy settings. */
    INSERT OR IGNORE INTO SETTINGS SELECT id, 1 FROM WCROOT;
    
    PRAGMA user_version = 32;

    (wc-metadata.sql#L768-L777。)

    • wc_id 做主键,store_pristine INTEGER 是"是否本地存储 pristine"的设置。
    • 迁移语句 INSERT OR IGNORE INTO SETTINGS SELECT id, 1 FROM WCROOT:把所有已存在的 WCROOT 行 都补一条 store_pristine = 1 的设置------即老 WC 默认为"存储"。
    • PRAGMA user_version = 32 把格式号写成 32。

6.4.4 格式 32 还改写了两个触发器

STMT_UPGRADE_TO_32 在加了 TEXTBASE_REFS 之后,重建了两个触发器 ,让它们在维护 refcount 的同时清理 textbase_refs:

sql 复制代码
DROP TRIGGER nodes_delete_trigger;

CREATE TRIGGER nodes_delete_trigger
AFTER DELETE ON nodes
WHEN OLD.checksum IS NOT NULL
BEGIN
  UPDATE pristine SET refcount = refcount - 1
  WHERE checksum = OLD.checksum;
  DELETE FROM textbase_refs
  WHERE wc_id = OLD.wc_id
    AND local_relpath = OLD.local_relpath
    AND op_depth = OLD.op_depth;
END;

DROP TRIGGER nodes_update_checksum_trigger;

CREATE TRIGGER nodes_update_checksum_trigger
AFTER UPDATE OF checksum ON nodes
WHEN NEW.checksum IS NOT OLD.checksum
  /* AND (NEW.checksum IS NOT NULL OR OLD.checksum IS NOT NULL) */
BEGIN
  UPDATE pristine SET refcount = refcount + 1
  WHERE checksum = NEW.checksum;
  UPDATE pristine SET refcount = refcount - 1
  WHERE checksum = OLD.checksum;
  DELETE FROM textbase_refs
  WHERE wc_id = OLD.wc_id
    AND local_relpath = OLD.local_relpath
    AND op_depth = OLD.op_depth;
END;

(wc-metadata.sql#L737-L766。)

这段代码揭示了一个重要的演进规律 :每次格式升级,若语义变化影响到触发器,就必须 DROP TRIGGER 再 CREATE TRIGGER 。因为 SQLite 不支持"修改触发器",只能重建。注意 format 32 只重建了 nodes_delete_trigger 与 nodes_update_checksum_trigger,没有重建 nodes_insert_trigger ------因为插入 NODES 行时不应该 自动在 textbase_refs 里登记(是否需要"本地内容"由 hydrate/dehydrate 逻辑显式决定,而不是插入就登记)。这处不对称是刻意为之。

6.4.5 hydrate/dehydrate 的运行时逻辑

wc_db_textbase.c 的 svn_wc__db_textbase_sync() 是理解"按需拉取"的核心。它的签名里有两个开关:

c 复制代码
svn_error_t *
svn_wc__db_textbase_sync(svn_wc__db_t *db,
                         const char *local_abspath,
                         svn_boolean_t allow_hydrate,
                         svn_boolean_t allow_dehydrate,
                         svn_wc__db_textbase_fetch_cb_t fetch_callback,
                         void *fetch_baton,
                         svn_cancel_func_t cancel_func,
                         void *cancel_baton,
                         apr_pool_t *scratch_pool)

(wc_db_textbase.c#L214-L224。)

它遍历一组 (checksum, hydrated, referenced, repos_relpath, repos_id, revision),核心判断是:

c 复制代码
      hydrated = svn_sqlite__column_boolean(stmt, 1);
      referenced = svn_sqlite__column_boolean(stmt, 2);

      if (!hydrated && referenced)
        {
          if (allow_hydrate)
            {
              ...
              /* 需要 repos_root_url + repos_relpath + revision,然后通过
                 fetch_callback 拉取内容 */

(wc_db_textbase.c#L268-L320。)

逻辑要点:

  • !hydrated && referenced :内容不在磁盘上,且 有活引用 ------ 这就是需要"水合 "的情形。如果 allow_hydrate 为真,它就从 DB 里取出 repos_relpath、repos_id、revision,换取 repos_root_url,再交给 fetch_callback 去服务器拉取内容。注意那几个 SVN_ERR_WC_CORRUPT 分支:"Unexpected entry for '%s'"------在 pristineless 模式下,如果某个"需要水合"的条目缺了仓库信息,工作副本就是损坏的,因为无法重建内容。
  • hydrated && (!referenced) && allow_dehydrate 则是反方向:内容在磁盘上但没人需要了,可以脱水。
  • allow_hydrate / allow_dehydrate 两个布尔让调用方按命令语义控制:svn diff 可能允许 hydrate(需要内容)但不允许 dehydrate;后台清理则相反。

再看 wc_db_pristine.c 里的一处防御性编程(对理解"脱水为何要谨慎"很有帮助):

c 复制代码
  if (have_row && hydrated)
    {
      /* For now, ensure that we do not inadvertently dehydrate an existing
       * hydrated entry, as there could be references to its content. */

(wc_db_pristine.c#L357-L362。)

注释坦白地说:"现阶段,确保我们不会不经意地脱水一个已水合的条目,因为可能有引用指向它的内容。 "------这说明 1.15.0 的脱水策略是保守的:宁可不省空间,也不破坏引用。

6.4.6 CLI 侧:--store-pristine 与 --vacuum-pristines

选项定义 (cl.h):

c 复制代码
  svn_boolean_t vacuum_pristines; /* remove unreferenced pristines */
  ...
  svn_version_t *compatible_version; /* working copy compatibility version */
  svn_boolean_t store_pristine;
} svn_cl__opt_state_t;

(cl.h#L270-L279。)对应的长选项枚举成员:

c 复制代码
  opt_vacuum_pristines,
  opt_drop,
  opt_viewspec,
  opt_compatible_version,
  opt_store_pristine
} svn_cl__longopt_t;

(cl.h#L376-L381。)

选项表与帮助文本 (svn.c):

c 复制代码
  {"vacuum-pristines", opt_vacuum_pristines, 0,
                       N_("remove unreferenced pristines from .svn directory")},
  ...
  {"compatible-version", opt_compatible_version, 1,
                       N_("use working copy format compatible with Subversion\n"
                       "                             "
                       "version ARG (\"1.8\", \"1.9.5\", etc.)")},

  {"store-pristine", opt_store_pristine, 1,
                       N_("Configure the working copy to either store local\n"
                       "                             "
                       "copies of pristine contents ('yes') or to fetch\n"
                       "                             "
                       "them on demand ('no'). Fetching on demand saves\n"
                       "                             "
                       "disk space, but may require network access for\n"
                       "                             "
                       "commands such as diff or revert. Default: 'yes'.")},

(svn.c#L357-L382。)

注意三个选项的参数个数 :vacuum-pristines 是 0(无参数开关);compatible-version 与 store-pristine 是 1(需要一个参数)。--store-pristine 的取值是 yes / no(在后续的 svn_cl__make_option 处理里,"yes, no" 被写进选项校验,见 svn.c 中 "--store-pristine", "yes, no")。

哪些命令暴露这些选项?

  • checkout:选项列表里有 opt_compatible_version, opt_store_pristine(svn.c#L562-L564)。
  • cleanup:选项列表里有 opt_vacuum_pristines(svn.c#L596-L597)。
  • upgrade:选项列表里有 opt_compatible_version(svn.c#L2021)。

cleanup 的帮助文本把 --vacuum-pristines 的语义讲得很干净:

swift 复制代码
     "  3. If the --vacuum-pristines option is given, remove pristine copies of\n"
     "    files which are stored inside the .svn directory and which are no longer\n"
     "    referenced by any file in the working copy.\n"),

(svn.c#L590-L592。)

cleanup 的实现 (cleanup-cmd.c):

c 复制代码
      if (opt_state->remove_unversioned || opt_state->remove_ignored ||
          opt_state->vacuum_pristines)
        {
          svn_error_t *err = svn_client_vacuum(target_abspath,
                                               opt_state->remove_unversioned,
                                               opt_state->remove_ignored,
                                               TRUE /* fix_timestamps */,
                                               opt_state->vacuum_pristines,
                                               opt_state->include_externals,
                                               ctx, iterpool);
          ...
        }
      else
        {
          svn_error_t *err = svn_client_cleanup2(target_abspath,
                                                 TRUE /* break_locks */,
                                                 TRUE /* fix_timestamps */,
                                                 TRUE /* clear_dav_cache */,
                                                 TRUE /* vacuum_pristines */,
                                                 opt_state->include_externals,
                                                 ctx, iterpool);

(cleanup-cmd.c#L75-L110。)

有两处值得注意:

  1. 用户显式 --vacuum-pristines 时 走 svn_client_vacuum(...)(这是"清理未版本化/忽略/未引用 pristine"的通用入口)。
  2. 普通的 svn cleanup(不带任何选项)却调用 svn_client_cleanup2(...),并且 把 vacuum_pristines 硬编码为 TRUE ------也就是说,1.15 的默认 svn cleanup 就会顺带清理未引用的 pristine。这和 1.7/1.8 时代"cleanup 只打破锁 + fix 时间戳 + 跑 SQLite vacuum"的行为相比,多了一件事。

(历史上,CHANGES 里也有对应条目:1.8 时代 "'svn cleanup': Add --vacuum-pristines option (r1802787 et al)",以及更早的 "'svn cleanup' now runs SQLite "vacuum" to reclaim space (r1418459)"。可见 "vacuum" 一词在 SVN 里有两层含义 ------SQLite 的 VACUUM(回收数据库文件碎片)与 pristine 的 vacuum(删除未引用内容)。)

6.4.7 库层:store-pristine 设置不一致必须报错

pristineless 是一个整个工作副本级别的属性 (存在 SETTINGS.store_pristine 里),因此不能在同一个 WC 里"半开半关"。这一约束在两处被强制:

(1)新建/ensure admin 时 (adm_files.c):

c 复制代码
  if (target_format < SVN_WC__HAS_OPTIONAL_PRISTINE && !store_pristine)
    return svn_error_createf(
        SVN_ERR_WC_UNSUPPORTED_FORMAT, NULL,
        _("Working copy format %d does not support the requested capabilities"),
        target_format);
  ...
  SVN_ERR(svn_wc__db_get_settings(NULL, &wc_store_pristine, db,
                                  local_abspath, scratch_pool));

  if ((store_pristine && !wc_store_pristine) ||
      (!store_pristine && wc_store_pristine))
    {
      return svn_error_createf(
          SVN_ERR_WC_INCOMPATIBLE_SETTINGS, NULL,
          _("'%s' is an existing working copy with different '%s' setting"),
          svn_dirent_local_style(local_abspath, scratch_pool),
          "store-pristine");
    }

(adm_files.c#L290-L328。)

要点:

  • 格式低于 32(target_format < SVN_WC__HAS_OPTIONAL_PRISTINE)却要求 pristineless(!store_pristine) → 直接报 SVN_ERR_WC_UNSUPPORTED_FORMAT,"format %d does not support the requested capabilities"。这从入口上堵死了"用格式 31 做 pristineless"。
  • 已存在的 WC 的 store_pristine 与请求不一致 → 报 SVN_ERR_WC_INCOMPATIBLE_SETTINGS,错误串是 "\'%s\' is an existing working copy with different \'%s\' setting"(第二个参数就是 "store-pristine")。

(2)checkout 到已存在目录时 (checkout.c)也有同样的一段:

c 复制代码
          SVN_ERR(svn_wc__get_settings(NULL, &wc_store_pristine, ctx->wc_ctx,
                                       local_abspath, scratch_pool));

          if ((target_store_pristine && !wc_store_pristine) ||
              (!target_store_pristine && wc_store_pristine))
            {
              return svn_error_createf(
                  SVN_ERR_WC_INCOMPATIBLE_SETTINGS, NULL,
                  _("'%s' is an existing working copy with different '%s' setting"),
                  svn_dirent_local_style(local_abspath, scratch_pool),
                  "store-pristine");
            }

(checkout.c#L228-L241。)

target_store_pristine 是怎么算出来的? svn_client__checkout_internal() 接收一个 svn_tristate_t store_pristine(三态:unknown/true/false),然后:

c 复制代码
      if (store_pristine == svn_tristate_unknown)
        target_store_pristine = TRUE;
      else if (store_pristine == svn_tristate_true)
        target_store_pristine = TRUE;
      else
        target_store_pristine = FALSE;

(checkout.c#L120-L125。)

也就是说:unknown(用户没给 --store-pristine)默认当作 TRUE(存储)------与帮助文本的 "Default: 'yes'" 一致。

再看一处自动抬升格式的逻辑:

c 复制代码
          SVN_ERR(svn_client_default_wc_version(&target_format_version, ctx,
                                                scratch_pool, scratch_pool));

          if (!target_store_pristine)
            {
              const svn_version_t *required_version =
                svn_client__compatible_wc_version_optional_pristine(scratch_pool);

              if (!svn_version__at_least(target_format_version,
                                         required_version->major,
                                         required_version->minor,
                                         required_version->patch))
                target_format_version = required_version;
            }

(checkout.c#L135-L150。)

它的意思是:如果用户要求 pristineless(!target_store_pristine),但默认 WC 版本还不够新,就把目标版本抬到 svn_client__compatible_wc_version_optional_pristine() 返回的版本 ------而这个函数返回的正是 {1, 15, 0}(libsvn_client/upgrade.c#L344-L351)。换句话说:想要 pristineless,就必须至少把 WC 建成 1.15 格式(即 32)。

6.4.8 格式 31 与格式 32 的差异对照(SQL 实证)

下表总结 pristineless 引入的可观察差异------都可以用 SQL 直接验证:

观察点 format 31(1.8--1.14 格式) format 32(1.15 格式)
PRAGMA user_version 31 32
PRISTINE.hydrated 列 不存在 存在,INTEGER NOT NULL DEFAULT 1
PRISTINE 索引 只有 I_PRISTINE_MD5 多了 I_PRISTINE_UNREFERENCED (refcount, refcount=0)
TEXTBASE_REFS 表 不存在 存在,主键 (wc_id, local_relpath, op_depth)
SETTINGS 表 不存在 存在,wc_id PK + store_pristine
nodes_delete_trigger / nodes_update_checksum_trigger 只维护 pristine.refcount 额外 DELETE FROM textbase_refs

对应的实证查询:

sql 复制代码
-- 1) 确认格式号
PRAGMA user_version;

-- 2) 确认 PRISTINE 是否具备 hydrated 列(在 format 31 上会报 no such column)
SELECT COUNT(*) AS total,
       SUM(hydrated) AS hydrated_count,
       SUM(1 - hydrated) AS dehydrated_count
  FROM PRISTINE;

-- 3) 确认 TEXTBASE_REFS 存在及其规模(format 32 才有这张表)
SELECT COUNT(*) AS textbase_ref_rows FROM TEXTBASE_REFS;

-- 4) 确认 SETTINGS 的 store_pristine 取值(1=存储、0=按需拉取)
SELECT wc_id, store_pristine FROM SETTINGS;

-- 5) 找出"已脱水"(hydrated=0)但仍被引用(refcount>0)的 pristine
SELECT p.checksum, p.refcount, p.size
  FROM PRISTINE p
 WHERE p.hydrated = 0 AND p.refcount > 0
 ORDER BY p.size DESC
 LIMIT 20;

查询 (2) 与 (3) 会在 format 31 的工作副本上直接报错 ("no such column: hydrated" / "no such table: TEXTBASE_REFS"),这本身就是"该 WC 是格式 31"的铁证------用报错来探测表结构,是判断 WC 格式最直接的手段。

再补一句 TEXTBASE_REFS 的实战查询------哪些路径真正"占着"本地文本基:

sql 复制代码
-- 路径 → 它登记的文本基引用(并与 PRISTINE 的 hydrated 状态联结)
SELECT t.local_relpath, t.op_depth, n.checksum, p.hydrated, p.size
  FROM TEXTBASE_REFS t
  LEFT JOIN NODES n
    ON n.wc_id = t.wc_id
   AND n.local_relpath = t.local_relpath
   AND n.op_depth = t.op_depth
  LEFT JOIN PRISTINE p ON p.checksum = n.checksum
 ORDER BY t.local_relpath
 LIMIT 50;

下一节讲 1.15 的另一大头条:多 WC 格式支持。

6.5 多 WC 格式支持:读写相邻两代

CHANGES 把多 WC 格式支持列为 1.15 的第二大特性(r1898187, issue #4883)。在此之前,一个客户端只会读写自己这一代 的格式:1.8 客户端只会做格式 31,1.7 只会做 29。多格式支持要让一个客户端能识别、创建、升级到多个格式,并让用户显式选择。

6.5.1 svn_wc__format_from_version:版本号 → 格式号

核心映射函数是 svn_wc__format_from_version()(upgrade.c#L1644-L1685):

c 复制代码
svn_error_t *
svn_wc__format_from_version(int *format,
                            const svn_version_t* version,
                            apr_pool_t *scratch_pool)
{
  if (!version)
    {
      *format = SVN_WC__DEFAULT_VERSION;
      return SVN_NO_ERROR;
    }

  if (version->major != SVN_VER_MAJOR || version->minor > SVN_VER_MINOR)
    return svn_error_createf(SVN_ERR_UNSUPPORTED_FEATURE, NULL,
                             _("Can't determine working copy format "
                               "for Subversion %d.%d.%d"),
                             version->major, version->minor, version->patch);

  switch (version->minor)
    {
      case 0:  /* Same as 1.3.x. */
      case 1:  /* Same as 1.3.x. */
      case 2:  /* Same as 1.3.x. */
      case 3:  *format = 4; break;
      case 4:  *format = 8; break;
      case 5:  *format = 9; break;
      case 6:  *format = 10; break;
      case 7:  *format = 29; break;
      case 8:  /* Same as 1.14.x. */
      case 9:  /* Same as 1.14.x. */
      case 10: /* Same as 1.14.x. */
      case 11: /* Same as 1.14.x. */
      case 12: /* Same as 1.14.x. */
      case 13: /* Same as 1.14.x. */
      case 14: *format = 31; break;
      case 15: /* Same as the current version. */
      default: *format = SVN_WC__VERSION; break;
    }

  return SVN_NO_ERROR;
}

三点解读:

  1. version == NULL → SVN_WC__DEFAULT_VERSION(=31) :这解释了"为什么 1.15 客户端默认建出 1.8 兼容格式"------因为 SVN_WC__DEFAULT_VERSION 就是被定义成 SVN_WC__SUPPORTED_VERSION。
  2. 多个 minor 映射到同一格式 :1.8--1.14 全部 → 31;1.10/1.11 等注释统一写 "Same as 1.14.x.";1.15(case 15 / default)→ 32。这就是"格式不是每个版本都变,而是隔几个版本才 bump 一次"的体现。
  3. 越界拒绝 :version->major != SVN_VER_MAJOR || version->minor > SVN_VER_MINOR 时直接报 SVN_ERR_UNSUPPORTED_FEATURE,错误串 "Can't determine working copy format for Subversion %d.%d.%d"。也就是说,你不能请求一个"比当前客户端还新的版本号"的格式。

6.5.2 支持范围:为什么是"相邻两代"

c 复制代码
svn_boolean_t
svn_wc__is_supported_format(int format)
{
  return format >= SVN_WC__SUPPORTED_VERSION && format <= SVN_WC__VERSION;
}

int
svn_wc__max_supported_format(void)
{
  return SVN_WC__VERSION;
}

int
svn_wc__min_supported_format(void)
{
  return SVN_WC__SUPPORTED_VERSION;
}

(upgrade.c#L1687-L1703。)

绝对值上,1.15 支持 [31, 32] 两个格式------31(1.8--1.14)与 32(1.15) 。这是一个宽度为 2 的窗口 :客户端能读写的,是"当前格式"与"上一代格式"。社区里常说的"读写 N-1 代"就是这个意思。这么设计的现实收益是:从 1.15 客户端升级到更新的版本时,无需立即强制升级所有工作副本;旧的(N-1)WC 也能被新时代客户端继续操作,直到用户决定升级。

对应的版本级判断:

c 复制代码
svn_boolean_t
svn_wc__is_supported_format_version(const svn_version_t *version)
{
  return (version->major == 1
          && (version->minor >= 8 && version->minor <= 15));
}

const svn_version_t *
svn_wc__max_supported_format_version(void)
{
  /* NOTE: For consistency, always return the version
     that first introduced the latest supported format. */
  static const svn_version_t version = { 1, 15, 0, NULL };
  return &version;
}

const svn_version_t *
svn_wc__min_supported_format_version(void)
{
  /* NOTE: For consistency, always return the version
     that first introduced the earliest supported format. */
  static const svn_version_t version = { 1, 8, 0, NULL };
  return &version;
}

(upgrade.c#L1705-L1728。)

注意两条 BIG NOTE :返回的版本号一律是该格式首次被引入的那个 Subversion 版本 ,而不是"当前客户端版本"。所以 min_supported_format_version() 返回 {1,8,0}(格式 31 首次出现于 1.8),max_supported_format_version() 返回 {1,15,0}(格式 32 首次出现于 1.15)。这种"用首引版本命名格式"的约定,让用户看到的选项始终稳定(--compatible-version=1.8 永远指格式 31,不会随客户端版本漂移)。

6.5.3 客户端层:支持列表与默认值

libsvn_client/upgrade.c 里有一个"格式号 → 版本号"的反查 switch:

c 复制代码
      case  4: return &version_1_0;
      case  8: return &version_1_4;
      case  9: return &version_1_5;
      case 10: return &version_1_6;
      case 29: return &version_1_7;
      case 31: return &version_1_8;
      case 32: return &version_1_15;

(libsvn_client/upgrade.c#L263-L273。)

然后是"本客户端支持哪些格式":

c 复制代码
const int *
svn_client_get_wc_formats_supported(apr_pool_t *result_pool)
{
  static const int versions[] = {
    SVN_WC__SUPPORTED_VERSION,
    SVN_WC__VERSION,
    0
  };

  return versions;
}

(libsvn_client/upgrade.c#L276-L286。)

它返回 {31, 32, 0}(以 0 结尾的哨兵数组)。svn --version 的帮助实现 help-cmd.c 就调用这个函数来列出"Compatible working copy formats"(help-cmd.c#L50)。checkout 与 upgrade 的帮助文本都用同一句话指向它:"Use 'svn --version' to see the compatible versions supported."(svn.c#L536、svn.c#L2011)。

默认 WC 版本取自配置项:

c 复制代码
  svn_config_get(config, &value,
                 SVN_CONFIG_SECTION_WORKING_COPY,
                 SVN_CONFIG_OPTION_COMPATIBLE_VERSION,
                 NULL);
  if (value)
    {
      SVN_ERR(svn_version__parse_version_string(&version, value, result_pool));
    }
  else
    {
      version = apr_pcalloc(result_pool, sizeof(*version));
      version->major = 1;
      version->minor = 8;
      version->patch = 0;
      version->tag = NULL;
    }

(libsvn_client/upgrade.c#L312-L329。)

也就是说:用户可以写一个配置项 wc-compatible-version(即 SVN_CONFIG_OPTION_COMPATIBLE_VERSION)来改变默认值;没配就是 1.8(格式 31)。 类似地,svn --version --verbose 里会显示 wc-compatible-version 与 store-pristine 两个配置项名(见 svn.c 第 828 行附近)。

最后是"最老 / 最新"的来源函数:

c 复制代码
const svn_version_t *
svn_client_oldest_wc_version(apr_pool_t *result_pool)
{
  /* NOTE: For consistency, always return the version of the client
     that first introduced the format. */
  static const svn_version_t version = { 1, 8, 0, NULL };
  return &version;
}
...
const svn_version_t *
svn_client_latest_wc_version(apr_pool_t *result_pool)
{
  static const svn_version_t version = { 1, 15, 0, NULL };
  return &version;
}

const svn_version_t *
svn_client__compatible_wc_version_optional_pristine(apr_pool_t *result_pool)
{
  static const svn_version_t version = { 1, 15, 0, NULL };
  return &version;
}

(libsvn_client/upgrade.c#L288-L351。)

svn_client__compatible_wc_version_optional_pristine() 返回 {1,15,0}------它就是 6.4.7 里"想 pristineless 就自动抬到 1.15"所用的那个函数。可见 pristineless 与多格式支持这两个特性在实现上是耦合的:pristineless 需要格式 32,而"请求格式 32"要走多格式支持的选版机制。

6.5.4 upgrade 的边界

svn_wc__upgrade_sdb() 在动手前先设了一道闸:

c 复制代码
  if (start_format < SVN_WC__WC_NG_VERSION /* 12 */)
    return svn_error_createf(SVN_ERR_WC_UPGRADE_REQUIRED, NULL,
                             _("Working copy '%s' is too old (format %d, ...

(upgrade.c#L1730-L1740。)

即:格式早于 12(SVN_WC__WC_NG_VERSION,也就是"前 WC-NG/entries 时代")的工作副本,必须先被更老的客户端升到 12 的 intermediate 状态,现代客户端不再直接支持 。这呼应了 svn upgrade 帮助里的"Only upgrades are supported, not downgrades."(svn.c#L2013)------升级是单向的,不允许降级。

6.6 work queue:先记日志,后执行

6.6.1 问题:DB 事务与文件系统无法同事务

工作副本操作是"跨介质"的:既要改数据库(NODES/ACTUAL_NODE),又要改文件系统(重命名、复制、删除、安装文件、设只读位)。SQLite 事务只能覆盖数据库一侧;如果在事务提交后进程崩溃,或文件操作只做了一半,工作副本就会"数据库说已经改了,磁盘上却没改"。

WC-NG 的解法是 write-ahead 风格的工作队列 :在同一个数据库事务里,把"待执行的文件系统副作用"序列化成 work item 写进 WORK_QUEUE 表;事务提交成功后,才按序取出执行。这样:

  • 崩溃发生在"事务提交前" → 什么都没有发生,安全;
  • 崩溃发生在"提交后、执行前" → WORK_QUEUE 里还留着 work item,后续 svn cleanup 可以重放;
  • 崩溃发生在"执行中" → 部分 work item 已完成,剩下的仍可重放。

这就是为什么它是"先记日志后执行"(本质上是把文件系统副作用也纳入"可重放日志")。

6.6.2 WORK_QUEUE 表与 work item 种类

sql 复制代码
CREATE TABLE WORK_QUEUE (
  /* Work items are identified by this value.  */
  id  INTEGER PRIMARY KEY AUTOINCREMENT,

  /* A serialized skel specifying the work item.  */
  work  BLOB NOT NULL
  );

(wc-metadata.sql#L219-L225。)

work 是一个序列化 skel ,其第一个 atom 是操作名。workqueue.c 顶部集中定义了全部操作名(workqueue.c#L47-L64):

c 复制代码
#define OP_FILE_COMMIT "file-commit"
#define OP_FILE_INSTALL "file-install"
#define OP_FILE_REMOVE "file-remove"
#define OP_FILE_MOVE "file-move"
#define OP_FILE_COPY_TRANSLATED "file-translate"
#define OP_SYNC_FILE_FLAGS "sync-file-flags"
#define OP_PREJ_INSTALL "prej-install"
#define OP_DIRECTORY_REMOVE "dir-remove"
#define OP_DIRECTORY_INSTALL "dir-install"

#define OP_POSTUPGRADE "postupgrade"

/* Legacy items */
#define OP_BASE_REMOVE "base-remove"
#define OP_RECORD_FILEINFO "record-fileinfo"
#define OP_TMP_SET_TEXT_CONFLICT_MARKERS "tmp-set-text-conflict-markers"
#define OP_TMP_SET_PROPERTY_CONFLICT_MARKER "tmp-set-property-conflict-marker"

注意注释 /* Legacy items */------后四个是历史遗留 类型,不再被新代码创建,但出于兼容仍必须能执行(因为老 WC 的队列里可能还留着它们)。

分派表 把操作名映射到处理函数(workqueue.c#L1429-L1451):

c 复制代码
static const struct work_item_dispatch dispatch_table[] = {
  { OP_FILE_COMMIT, run_file_commit },
  { OP_FILE_INSTALL, run_file_install },
  { OP_FILE_REMOVE, run_file_remove },
  { OP_FILE_MOVE, run_file_move },
  { OP_FILE_COPY_TRANSLATED, run_file_copy_translated },
  { OP_SYNC_FILE_FLAGS, run_sync_file_flags },
  { OP_PREJ_INSTALL, run_prej_install },
  { OP_DIRECTORY_REMOVE, run_dir_remove },
  { OP_DIRECTORY_INSTALL, run_dir_install },

  /* Upgrade steps */
  { OP_POSTUPGRADE, run_postupgrade },

  /* Legacy workqueue items. No longer created */
  { OP_BASE_REMOVE, run_base_remove },
  { OP_RECORD_FILEINFO, run_record_fileinfo },
  { OP_TMP_SET_TEXT_CONFLICT_MARKERS, run_set_text_conflict_markers },
  { OP_TMP_SET_PROPERTY_CONFLICT_MARKER, run_set_property_conflict_marker },

  /* Sentinel.  */
  { NULL }
};

各 work item 的语义(结合源码注释):

  • file-commit:提交后安装新的文本基(install_committed_file,见 workqueue.c#L206-L298),必要时重译工作文件、设可执行位/只读位。构造器 svn_wc__wq_build_file_commit()(workqueue.c#L386-L402)。
  • file-install:安装一个新创建的/更新的工作文件,可选地顺带记录 fileinfo(record_fileinfo)。
  • file-remove / dir-remove:删除文件 / 目录(dir-remove 可选递归)。
  • file-move:把一个文件/目录从 src 移到 dst。
  • file-translate:把 src 翻译后复制到 dst(EOL/关键字)。
  • sync-file-flags:按属性同步可执行位/只读位。
  • prej-install:安装属性冲突的 .prej 文件;SVN_WC__PROP_REJ_EXT 定义为 ".prej"(wc.h#L46)。
  • dir-install:创建目录。
  • postupgrade:升级收尾步骤。
  • 四个 legacy:base-remove(删除 BASE 节点)、record-fileinfo(记录 size/mtime)、tmp-set-text-conflict-markers / tmp-set-property-conflict-marker(旧式冲突标记安装)。

对应的构造器 API 在 workqueue.h:svn_wc__wq_build_file_install、svn_wc__wq_build_file_remove、svn_wc__wq_build_dir_remove、svn_wc__wq_build_file_move、svn_wc__wq_build_file_copy_translated、svn_wc__wq_build_sync_file_flags、svn_wc__wq_build_prej_install、svn_wc__wq_build_file_commit、svn_wc__wq_build_dir_install、svn_wc__wq_build_postupgrade(workqueue.h#L109-L222)。

6.6.3 队列的增删查:wc_db.h 的三函数

WORK_QUEUE 的三个数据库操作(wc_db.h#L3007-L3045):

  • svn_wc__db_wq_add():把 work item 加入队列(通常在同一事务里与 NODES 变更一起)。
  • svn_wc__db_wq_fetch_next():取下一个 work item(按 id 顺序),并在同一事务里 把上一个已完成的 id 之前的所有项标记为完成(删除)。
  • svn_wc__db_wq_record_and_fetch_next():fetch_next 的变体,在同一事务里 顺带把 record_map(本次处理的 fileinfo)写回 NODES(translated_size / last_mod_time)。

注释对 record_and_fetch_next 的描述是:"Special variant of svn_wc__db_wq_fetch_next(), which in the same transaction ... "------它的存在是为了把"处理 work item 时发现的文件信息"与"推进队列"合并成一个事务,避免额外往返。

队列对记录 fileinfo 的那个 map 由 work_item_baton_t 携带(record_map 是 const char * -> svn_wc__db_fileinfo_t 的哈希,见 workqueue.c#L69-L76)。

6.6.4 主循环:崩溃恢复的关键细节

svn_wc__wq_run()(workqueue.c#L1509-L1599)是执行循环。有两个为崩溃恢复量身定做的细节:

细节一:先把上一个 id 标记完成,再处理下一个。 循环注释反复强调:

c 复制代码
          /* Make sure to do this *early* in the loop iteration. There may
             be a LAST_ID that needs to be marked as completed, *before* we
             start worrying about anything else.  */

(workqueue.c#L1543-L1550。)

也就是说:只有当上一个 work item 处理成功后,才在下一轮把它从队列里删掉 (last_id = id 只在 dispatch 无错后设置,workqueue.c#L1592-L1594)。这种"完成后才确认"的顺序,保证了"要么已执行且已出队,要么仍在队列里可重放"。

细节二:可重放性是被显式测试的。 分派函数里有一段被 SVN_RUN_WORK_QUEUE_TWICE 宏包住的代码:

c 复制代码
#ifdef SVN_RUN_WORK_QUEUE_TWICE
          /* Being able to run every workqueue item twice is one
             requirement for workqueues to be restartable. */
          SVN_ERR((*scan->func)(wqb, db, work_item, wri_abspath,
                                cancel_func, cancel_baton,
                                scratch_pool));
#endif

(workqueue.c#L1477-L1486。)

注释说得很直白:"能把每个 work item 跑两遍,是 workqueue 可重启(restartable)的必要条件。" 这是"幂等性"要求的直接证据------重放一个 work item 不应产生副作用差异。

细节三:遇到未知 work item 必须报错,而不是忽略。 当分派表里找不到匹配时:

c 复制代码
  if (scan->name == NULL)
    {
      /* We should know about ALL possible work items here. If we do not,
         then something is wrong. Most likely, some kind of format/code
         skew. There is nothing more we can do. Erasing or ignoring this
         work item could leave the WC in an even more broken state.

         Contrary to issue #1581, we cannot simply remove work items and
         continue, so bail out with an error.  */
      return svn_error_createf(SVN_ERR_WC_BAD_ADM_LOG, NULL,
                               _("Unrecognized work item in the queue"));
    }

(workqueue.c#L1492-L1503。)

这段注释提到 issue #1581 ,并解释说"与 #1581 相反,我们不能简单地删除 work item 然后继续"------这是对早期"丢弃坏日志"做法的明确否决 ,因为丢弃可能让 WC 更不一致。错误码是 SVN_ERR_WC_BAD_ADM_LOG。当 dispatch 抛错时,整个 wq_run 会带上下文报错:

c 复制代码
          return svn_error_createf(SVN_ERR_WC_BAD_ADM_LOG, err,
                                   _("Failed to run the WC DB work queue "
                                     "associated with '%s', work item %d %s"),
                                   ...

(workqueue.c#L1580-L1590。)

6.6.5 一次 update/commit 逐步写了哪些表

把前面各表串起来,一次 svn update 在后端的落库过程大致是:

  1. 进入编辑驱动 :对每个受影响的目录,用 svn_wc__db_temp_op_start_directory_update()(wc_db.h#L3282-L3292)登记"这个目录正在被更新",结束时 svn_wc__db_temp_op_end_directory_update()。这保证了"更新到一半被打断"时,目录可以被识别为不完整。

  2. 增删 BASE 节点 :新增的目录/文件/符号链接分别用 svn_wc__db_base_add_directory()、svn_wc__db_base_add_file()、svn_wc__db_base_add_symlink() 写入;被删除的节点用 svn_wc__db_base_add_not_present_node() 或 svn_wc__db_base_add_excluded_node();首轮未填充完整的目录用 svn_wc__db_base_add_incomplete_directory()(wc_db.h#L458-L699)。

  3. 写入文件内容(pristine + working) :这是 1.15 的优化点。update_editor.c 里的注释明确写道:

    c 复制代码
      /* Configure the incoming data to be written to both the pristine and the
         provisioned working file.  This allows us to properly stream data and
         avoid having to re-read and copy the contents of the pristine file as
         some post-op.  Streaming is essential if we want the underlying transport
         such as HTTP to work without timeouts and without any limits on the
         sizes of the directories that can be checked out. */

    (update_editor.c#L3813-L3818。)

    这就是 CHANGES 里 "Optimize checkout, update and export by simultaneously streaming data to both pristine and working files (r1886490 et al)" 的实现落点。同时它决定本次是否需要 本地文本基:hydrated 的取值逻辑为------

    c 复制代码
      if (fb->shadowed || fb->obstruction_found || fb->edit_obstructed)
        hydrated = TRUE;
      else if (fb->adding_file && !fb->add_existed)
        /* Clean new file, hint that we don't need the text-base. */
        hydrated = FALSE;
      else
        hydrated = TRUE;

    (update_editor.c#L3769-L3783。)注意 adding_file && !add_existed 时把 hydrated 设为 FALSE------纯新增的文件可以不带本地文本基(因为它的内容就在工作文件里)。

  4. 排队文件系统副作用 :把需要随后执行的动作(安装文件、移动、改属性位等)构造为 work item 并加入 WORK_QUEUE。

  5. 更新修订号 :全部处理完后,用 svn_wc__db_op_bump_revisions_post_update()(wc_db.h#L2635)把待提交的 revision 提升为新的 BASE revision。

  6. 提交事务:SQLite 事务提交,此时 NODES/ACTUAL_NODE/WORK_QUEUE 一并生效。

  7. 运行队列 :调用 svn_wc__wq_run() 执行文件系统副作用,逐个确认出队。

commit 的关键不同 :提交是从本地往仓库发,落库发生在"服务器接受了提交"之后。新增/修改进 BASE 是通过把 WORKING 层合并为新的 BASE 完成;文件内容的"新文本基"安装被表达为 file-commit work item(见 6.6.2 的 install_committed_file)。

可运行的队列观测 SQL:

sql 复制代码
-- 查看当前队列里待执行的 work item(id 顺序即执行顺序)
SELECT id, work FROM WORK_QUEUE ORDER BY id;

-- 统计队列长度(>0 说明上次操作没跑完队列,可用 svn cleanup 续跑)
SELECT COUNT(*) AS pending FROM WORK_QUEUE;

pending > 0 通常意味着"上次操作被中断"。此时 svn cleanup(不带选项)会调用 svn_client_cleanup2(..., vacuum_pristines=TRUE, ...) 去续跑队列并弯腰拔锁------这就是 6.4.6 里那段代码的实际意义。

下一节处理冲突、以及本章其余概念与开发工具。

6.7 冲突、其他 WC 概念与开发工具

6.7.1 三类冲突:文本 / 属性 / 树

冲突统一存储在 ACTUAL_NODE.conflict_data 的 skel 里(见 6.2.5)。conflicts.h 定义了类型、操作与来源常量(conflicts.h#L41-L52):

c 复制代码
#define SVN_WC__CONFLICT_OP_UPDATE "update"
#define SVN_WC__CONFLICT_OP_SWITCH "switch"
#define SVN_WC__CONFLICT_OP_MERGE "merge"
#define SVN_WC__CONFLICT_OP_PATCH "patch"
#define SVN_WC__CONFLICT_KIND_TEXT "text"
#define SVN_WC__CONFLICT_KIND_PROP "prop"
#define SVN_WC__CONFLICT_KIND_TREE "tree"
#define SVN_WC__CONFLICT_KIND_REJECT "reject"
#define SVN_WC__CONFLICT_KIND_OBSTRUCTED "obstructed"
#define SVN_WC__CONFLICT_SRC_SUBVERSION "subversion"

构造与管理 skel 的 API(conflicts.h#L63-L280):svn_wc__conflict_skel_create() 起头,svn_wc__conflict_skel_add_text_conflict() 记录 mine / their-old / their 三个 marker 文件,svn_wc__conflict_skel_add_prop_conflict() 记录 mine/their-old/their 属性哈希与 conflicted_prop_names,svn_wc__conflict_skel_add_tree_conflict() 记录 local_change(原因)与 incoming_change(动作)以及 move 源/目标的 op-root,最后 svn_wc__conflict_skel_resolve() 按 resolve_text / resolve_prop / resolve_tree 逐项消解。树冲突的 reason(本地改动原因)取值见 svn_wc_conflict_reason_t:edited/obstructed/deleted/missing/unversioned/added/replaced/moved_away/moved_here;action(来的改动)取自 svn_wc_conflict_action_t:edit/add/delete/replace(svn_wc.h#L1643-L1678)。

svn resolve --accept 的取值来自 svn_wc_conflict_choice_t:postpone(=0)、base(原始版本)、theirs_full(来访版本)、mine_full(自身版本)、theirs_conflict(冲突块的来访侧)、mine_conflict(冲突块的自身侧)、merged(合并后版本)、unspecified(svn_wc.h#L2192-L2210)。

冲突残留文件:旧式实现把文件名写进 conflict_old/conflict_new/conflict_working 三列(6.2.5),新式 skel 存 relpath;源码中可观察到 .mine(copy.c#L333)与 .working(wc_db_update_move.c#L1671)后缀;属性冲突的 reject 文件后缀由 SVN_WC__PROP_REJ_EXT ".prej" 定义(wc.h#L46)。ACTUAL_NODE 的 older_checksum/left_checksum/right_checksum 让冲突文本也进 pristine store(见 6.2.5 的设计问答)。

运行时观测:某次 update 制造文本冲突后,用

sql 复制代码
SELECT a.local_relpath, a.conflict_data, p.hydrated
  FROM ACTUAL_NODE a
  LEFT JOIN PRISTINE p ON p.checksum = a.left_checksum
 WHERE a.conflict_data IS NOT NULL;

即可看到冲突 skel 与冲突文本的 pristine 是否在本地。

6.7.2 externals、changelist、稀疏与 depth

  • externals :EXTERNALS 表(6.2.10)以 presence 的 MAP_NORMAL/MAP_EXCLUDED 表达"可用/用户排除",def_local_relpath + def_repos_relpath + def_operational_revision + def_revision 描述定义。
  • changelist :写在 ACTUAL_NODE.changelist 列(6.2.5)。
  • 稀疏(sparse) :用 BASE 层 presence='excluded' 表达(6.2.1 的 excluded),depth 名列 NODES.depth(NULL = 默认 infinity)。
  • svn_depth_* :检出/更新的深度语义(empty/files/immediates/infinity/unknown),由 NODES.depth 落库。

6.7.3 cleanup / upgrade / vacuum 的关系

  • svn cleanup :默认走 svn_client_cleanup2(..., break_locks=TRUE, fix_timestamps=TRUE, clear_dav_cache=TRUE, vacuum_pristines=TRUE, ...)------打破写锁、修正时间戳、清 WebDAV 缓存、并清理未引用 pristine(cleanup-cmd.c#L104-L110)。它也是"续跑未完成 work queue"的入口。
  • svn cleanup --vacuum-pristines :走 svn_client_vacuum(...)(cleanup-cmd.c#L78-L84)。
  • svn upgrade:单向升格式,早于格式 12 需先经更老客户端(6.5.4)。

6.7.4 开发工具

  • tools/dev/wc-ng/svn-wc-db-tester.c------"a crude command line tool that makes it possible to run the wc-db validation checks directly",会按 local_relpath (depth=%d) DBV%04d: %s 打印校验错误(L1-L80)。
  • tools/dev/svnqlite3-dump------Perl 过滤器,把 sqlite3 .svn/wc.db .dump 的 BLOB(属性 skel)与时间戳解码成可读形式;用法注释:sqlite3 .svn/wc.db .dump | svnqlite3-dump、svnqlite3-dump /path/to/wc、svnqlite3-dump /path/to/wc/.svn/wc.db(L20-L50)。
  • tools/dev/wc-ng/ 下另有 bump-to-19.py、populate-pristine.py、graph-data.py、count-progress.py、gather-data.sh 等辅助脚本(Glob 实证)。

6.7.5 1.15 其余相关修订

  • r1886490 et al:checkout/update/export 同时流向 pristine 与工作文件(6.6.5 已引源码注释)。
  • r1892645 :以加密校验和比较判断文件是否被修改,对应 CHANGES "Use a cryptographic checksum comparison to determine if a file has been modified in a working copy",落在 6.3.5 的 change-detection 启发式末梢。
  • r1888474 et al(issue #4874) :仓库 UUID 相同而 URL 不同时告警,与 REPOSITORY 表"同 UUID 多 URL"的设计(6.2.2)呼应。

6.7.6 全章 r 号索引

里程碑 内容 取证
r991236 单一 wc.db(format 19) wc.h#L120-L122
r1005388 NODES 取代 BASE_NODE/WORKING_NODE(20) wc.h#L124-L125
r1044384 多层 op_depth(23) wc.h#L133-L134
r1058523 refcount 正确维护(24) wc.h#L136-L137
r1387742 冲突改存 skel(30) wc.h#L154-L155
r1395109 inherited_props(31) wc.h#L157-L158
r1905955 / #525 pristineless(format 32) CHANGES#L16-L17
r1898187 / #4883 多 WC 格式支持 CHANGES#L18

本章结论 :WC-NG 用一张 NODES 表 + op_depth 同时表达 BASE 与多层 WORKING 视图,用 PRISTINE 表 + 触发器实现内容寻址与引用计数,用 WORK_QUEUE 表把文件系统副作用纳入可重放日志;1.15 在格式 32 上叠加了 hydrated / TEXTBASE_REFS / SETTINGS,把 pristine 从"必有"变为"可选",并用多格式支持把"读写相邻两代"变成一等能力。三者的耦合点集中在 SETTINGS.store_pristine、SVN_WC__HAS_OPTIONAL_PRISTINE、SVN_WC__SUPPORTED_VERSION 与 SVN_WC__VERSION 这四个常量上。

相关推荐
FIT2CLOUD飞致云1 小时前
学习笔记丨MaxKB PPT/PDF文档整页转图工作流知识库方案
人工智能·ai·开源·智能体·maxkb
miofly1 小时前
Step 5 Preview 用 27B 激活参数运行 600B 规模模型
开源·github
神奇的代码在哪里1 小时前
Ling-3.1-flash 正式开源:560B 激活 25B,1M 上下文、MIT 许可
人工智能·开源·大模型·ai agent·moe
miofly2 小时前
GitHub 今日推荐|open_scion:为AI代理设计的并发版本控制
开源·go·github
I'm Jie2 小时前
Rhost v2.0.0 版本发布!!!不止 SSH 终端,内置 AI Agent 支持自然语言操作远程服务器
运维·服务器·rust·开源·ssh·tauri·finalshell
miofly4 小时前
GitHub 日榜趋势速报 | 2026-10-16
开源·github
冬奇Lab15 小时前
一天一个开源项目(第237篇):DeepSeek Bot —— 把 DeepSeek Harness 变成一个有记忆、能群聊、会互相派活的 Bot 团队
人工智能·开源·deepseek
程序员老赵17 小时前
Docker 部署 Wiki.js:轻松搭建私有化维基知识库
docker·容器·开源