Apache Subversion 1.15 源码深度剖析-第 8 章 工程实践与演进思考

1 阅读1小时+

Apache Subversion 1.15 源码深度剖析-第 8 章 工程实践与演进思考

本章是「Apache Subversion 1.15.0 源码深度解析」系列的第 8 章。前七章我们沿着"配置文件 → 工作副本 → 仓库 → 版本化模型 → 合并 → 网络层 → 客户端库"一路向内拆解,看的是运行时的代码。这一章换一个视角:看这套代码是如何被生产、被验证、被发布、被约束、并被历史所拖累的。

换句话说,前面是"Subversion 怎么工作",这一章是"Subversion 这个项目怎么活下去"。一个已经存在二十余年、兼容承诺极其严苛、同时还要在集中式与分布式范式之间定位自己的项目,它的工程实践本身就是一份值得解剖的样本。

8.0 导读、涉及文件清单与阅读目标

本章的写作方式与前七章保持一致:所有结论都可以在仓内文件里被定位。凡是涉及文件名、脚本名、配置键、目录结构、修订号(r 号)的陈述,都来自本次真实的 Read/Glob/Grep,而不是凭记忆。凡是"演进"与"取舍"的判断,都尽量挂在具体证据上,而不是空泛的经验总结。

8.0.1 本章涉及的核心文件

构建与生成:

版本与格式:

测试:

发布、backport 与 CI:

工程纪律脚本:

8.0.2 读完本章你将能够

  1. 说清 Subversion 到底有几层"版本号":产品版本、工作副本(WC)格式、FSFS 格式、svndiff 编解码版本,各自的权威文件在哪里、当前取值是多少、它们之间谁约束谁。
  2. 读懂 build.conf:知道 type/path/libs/install/sources/when/msvc-* 每个字段的含义,知道这一份声明式配置是如何被 gen-make.py + build/generator/ 翻译成三种构建系统输出的。
  3. 理解测试体系的"统一契约":一条 (PASS|XPASS|FAIL|XFAIL|SKIP): argv[0] argv[1]: description 输出行如何让 C 测试与 Python 测试共用同一套回归统计;test_funcs[]、svn_test_descriptor_t、tests.conf 的 [caches] fail-stop 都扮演什么角色。
  4. 读懂 release-lines.yaml:recommended_release/supported_release_lines/lts_release_lines 三个概念的区别,以及 tool_versions 为什么要钉住 autoconf/libtool/swig 的精确版本和校验和。
  5. 把 CI 三个工作流映射到"双构建路径":autoconf 与 CMake 各自的 matrix 维度、覆盖的 OS 与统计口径,以及 backportbot 如何自动检测冲突。
  6. 诚实地评估"兼容性三承诺"的代价与历史包袱:deprecated 函数、#ifdef 迁移、双构建系统、BDB、混合修订、svn:mergeinfo、mod_dav_svn 的十余个自定义 REPORT,各自是怎样从"当年正确的决定"变成"今天的成本"的。

8.1 四层版本号解耦:谁约束了谁

Subversion 最容易被误解的一点,是把它当成"只有一个版本号"的软件。事实上,svn --version 打印的那个 1.15.0 只是产品版本,它背后至少还叠着三层彼此独立、节奏不同的"格式版本"。把这四层分清,是理解后面所有兼容性讨论的地基。

8.1.1 第一层:产品版本(svn_version.h)

产品版本定义在 svn_version.h。本次读取到的宏值为:

/* svn_version.h#L53-L73 */
#define SVN_VER_MAJOR      1
#define SVN_VER_MINOR      15
#define SVN_VER_PATCH      0
#define SVN_VER_MICRO      SVN_VER_PATCH     /* @deprecated */
#define SVN_VER_LIBRARY    SVN_VER_MAJOR     /* @deprecated */

同文件里还有一组"标签"宏,它们的取值规律非常能说明发布工程的运作方式:

/* svn_version.h#L96-L120 */
#define SVN_VER_TAG        " (r1937767)"
#define SVN_VER_NUMTAG     ""
#define SVN_VER_REVISION   1937767

注释里写得很明确(svn_version.h#L83-L119):SVN_VER_TAG 在仓库里始终保持 " (under development)" 之类的形态,只有在打包 tarball 时才会被自动替换成 " (r1234)"(终版)或 " (Alpha 1)"(预发布);SVN_VER_NUMTAG 平时是 "-dev",打包时替换为 "" 或 "-alpha1"。这两个宏"必须在同一时间一起改",因为它们共同构成给 HTTP 请求用的版本串——注意 SVN_VER_NUMTAG 不能含空格,因为它是被塞进 HTTP 头部的标识符。

产品版本的语义由 SVN_VER_NUM / SVN_VER_NUMBER / SVN_VERSION 三个派生宏串起来(svn_version.h#L125-L134):

/* svn_version.h#L125-L134 */
#define SVN_VER_NUM        APR_STRINGIFY(SVN_VER_MAJOR) \
                           "." APR_STRINGIFY(SVN_VER_MINOR) \
                           "." APR_STRINGIFY(SVN_VER_PATCH)

/** Version number with tag (contains no whitespace) */
#define SVN_VER_NUMBER     SVN_VER_NUM SVN_VER_NUMTAG

/** Complete version string */
#define SVN_VERSION        SVN_VER_NUMBER SVN_VER_TAG

文件头部的注释(svn_version.h#L44-L51)声明版本规则遵循 APR 的版本策略(http://apr.apache.org/versioning.html):MAJOR 只在"破坏已发布接口"时改,MINOR 在"新增功能与接口但仍向后兼容"时改,PATCH 每发一个补丁就改。这正是"兼容性三承诺"里 ABI/API 承诺的字面依据——它是写在头文件注释里的项目法条。

一个容易被忽略的细节:SVN_VER_MICRO 与 SVN_VER_LIBRARY 被标注为 @deprecated Provided for backward compatibility with the 1.0 API。也就是说,连"版本号宏"本身都在承载兼容包袱:老代码里写 SVN_VER_MICRO 仍然能编译,但新代码应该用 SVN_VER_PATCH。这是本书通篇要反复出现的模式——不删,只弃。

8.1.2 第二层:工作副本(WC)格式(wc.h)

工作副本格式版本定义在 wc.h。本次读取到的三个关键宏是:

/* wc.h#L177 */
#define SVN_WC__VERSION 32

/* wc.h#L187 */
#define SVN_WC__SUPPORTED_VERSION 31

/* wc.h#L191 */
#define SVN_WC__DEFAULT_VERSION SVN_WC__SUPPORTED_VERSION

三者的分工是整段设计的核心:

  • SVN_WC__VERSION 32:本客户端能写入/升级到的最高格式,也就是 svn_wc__max_supported_format() 的返回值。
  • SVN_WC__SUPPORTED_VERSION 31:本客户端仍能读写的最低格式,即"我还能处理 N-1 代"。在 1.15 中它是 31,意味着 1.15 的客户端仍然可以使用 1.14 时代的工作副本格式。
  • SVN_WC__DEFAULT_VERSION:默认创建/升级到的格式,直接等于 SUPPORTED_VERSION。这解释了 1.15 的一个行为:尽管它"认识"格式 32,默认不会把工作副本升到 32,svn upgrade 的默认目标仍落在 31,除非用户显式指定。

第 8.1.3 节之后我们会看到,--compatible-version 这类开关正是建立在这三个宏的分层之上。

wc.h 的另一大价值,是它把 WC 格式从 1 到 32 的逐次变更历史完整写进了注释(wc.h#L46-L171),其中同时给出了"某个 Subversion 版本以哪个格式发布"的对照表。节选几条最能体现"格式即契约"的:

/* wc.h#L61-L160 摘录 */
* == 1.0.x shipped with format 4
* == 1.4.x shipped with format 8
* == 1.5.x shipped with format 9
* == 1.6.x shipped with format 10
* == 1.7.x shipped with format 29
* == 1.8.x shipped with format 31
* == 1.9.x shipped with format 31
* == 1.10.x shipped with format 31
* == 1.11.x shipped with format 31

逐条读,你会发现几条极其重要的工程事实:

  1. 格式升级几乎都伴随一次结构性重组,且注释里往往直接写着 Bumped in rXXXXXX。例如格式 12 是"从 entries 文件切换到 SQLite 的 wc.db"(这是 WC-NG 的分水岭),格式 14 把锁从文件系统迁移进 wc.db,格式 17 引入 .svn/pristine 并移除 /.svn/text-base,格式 19 引入"每个工作副本单一 DB"(Bumped in r991236),格式 20 引入 NODES 表并废弃 BASE_NODE/WORKING_NODE(Bumped in r1005388),格式 23 引入 NODES 的多层 op_depth(Bumped in r1044384),格式 28 把残留的 MD5 引用全面转成 SHA1(Bumped in r1095214)。这些 r 号是可以直接回溯的坐标。
  2. 格式 31 长期稳定:1.8/1.9/1.10/1.11 四个发布线都停在 31,说明 WC 格式一旦成型,稳定性极高。
  3. 格式 32 是 1.15 的新增:注释明确写着"adds support for optional pristine contents; see the docstring of STMT_UPGRADE_TO_32",并且 == 1.15.x shipped with format 32 and multi-wc-format support。这与 CHANGES 顶部记录的两条"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)
    
    也就是说,"可选 pristine"(SVN_WC__HAS_OPTIONAL_PRISTINE 32、wc.h#L222-L224)与"单 DB 存储 per-WC 设置"(SVN_WC__HAS_SETTINGS 32、wc.h#L226-L227)是同一个格式跳变带来的两个特性。

除了"当前版本"三宏,wc.h 还散布着一批按版本分段的行为开关,它们本质上是在 C 代码里表达"格式 < N 时用旧逻辑、>= N 时用新逻辑":

/* wc.h#L193-L220 摘录 */
#define SVN_WC__NO_REVERT_FILES            4
#define SVN_WC__WCPROPS_MANY_FILES_VERSION 7
#define SVN_WC__CHANGED_CANONICAL_URLS     10
#define SVN_WC__NON_ENTRIES                12
#define SVN_WC__NON_ENTRIES_STRING          "12\n"
#define SVN_WC__WC_NG_VERSION              12
#define SVN_WC__WCPROPS_LOST               12
#define SVN_WC__HAS_WORK_QUEUE             13
#define SVN_WC__ENSURE_STAT1_TABLE         31

SVN_WC__NON_ENTRIES_STRING "12\n" 这个看起来有点怪的小宏特别值得一说(wc.h#L203-L206):它的作用是"把 12 这个数字写进 wc-ng 工作副本,好让旧客户端能识别出这是'较新 Subversion'的工作副本"。这是一种向下兼容的"恐吓机制"——老客户端看到这个数字就知道自己不该动它,从而避免用旧代码破坏新格式。

8.1.3 第三层:FSFS 格式(libsvn_fs_fs/structure)

FSFS 的格式版本不写在 C 头文件里,而是写在仓库的 db/format 文件里,其权威文档是 subversion/libsvn_fs_fs/structure。文档给出了完整的 1–8 对应表:

/* libsvn_fs_fs/structure#L144-L153 */
Format 1, understood by Subversion 1.1+
Format 2, understood by Subversion 1.4+
Format 3, understood by Subversion 1.5+
Format 4, understood by Subversion 1.6+
Format 5, understood by Subversion 1.7-dev, never released
Format 6, understood by Subversion 1.8
Format 7, understood by Subversion 1.9
Format 8, understood by Subversion 1.10

紧接着是一张"格式之间差异"的清单(structure#L155-L209),这是全仓最有信息密度的段落之一,逐条拆开看:

  • Delta representation:格式 1 只有 svndiff0;格式 2–7 支持 svndiff0 或 svndiff1;格式 8 起支持 svndiff0/svndiff1/svndiff2。这直接把第 8.1.4 节的 svndiff 三版本钉在了 FSFS 格式 8 上。
  • Format options:格式 1–2 不允许任何选项;格式 3+ 允许 layout;格式 7+ 允许 addressing。也就是说"逻辑寻址"这个能力是从格式 7 才开始有的。
  • Transaction name reuse:格式 1–2 事务名可复用;格式 3+ 用 txn-current 文件生成,保证不复用。这是"崩溃恢复"正确性的一个关键分界。
  • Proto-rev 位置:格式 1–2 放在 transactions/<txnid>/rev;格式 3+ 移到 txn-protorevs/<txnid>.rev。
  • Node-ID/copy-ID 生成:格式 1–2 用全局单调递增 base36 序列(靠 current 文件的计数器);格式 3+ 用新修订号来保证唯一性,current 只需保存 youngest 修订号。
  • Mergeinfo metadata:格式 1–2 不存 minfo-here/minfo-count,svn_fs_get_mergeinfo 直接报错;格式 3+ 才维护这两个字段、svn_fs_get_mergeinfo 才工作。这条特别有 irony——合并信息的元数据是"仓库格式"级别的能力,不是纯客户端功能。
  • Changed paths list:格式 1–3 不含节点 kind;格式 4+ 含 kind;格式 7+ 含 mergeinfo-mod 标志。
  • Shard packing:格式 4 只对修订数据打包;格式 5 曾设想 revprops 独立打包(未发布);格式 6+ 修订数据与 revprop 数据同等打包。
  • Addressing:格式 1+ 物理寻址(rev 文件内固定位置);格式 7+ 逻辑寻址(item index 在读取时转换为实际 rev/pack 位置,且是新建 7+ 仓库的默认值)。
  • Repository IDs:格式 1+ 的 db/uuid 第一行是仓库 UUID;格式 7+ 第二行是 instance ID。

一个必须指出的"历史幽灵":格式 5 从未被任何正式发布采用。文档在 structure#L150 明确写 Format 5, understood by Subversion 1.7-dev, never released,并在"Packing revision properties (format 5: SQLite)"一节(structure#L335-L341)里保留了指向 r1143829 的历史快照链接。这意味着 FSFS 的格式谱系里存在一个被跳过的一级——项目为了不给用户留下"半成品格式",宁可让版本号出现断档。这是"格式一旦发布就几乎不可撤销"这一约束的极端体现。

再看格式 7 引入的"逻辑寻址"的技术细节(structure#L266-L311)。物理寻址把每个 item 表示为 <rev> <item_index>,其中 item_index 就是字节偏移(对非 packed 文件即绝对偏移)。这个方案"简单但脆弱":移动任何一个 item 都可能破坏后续修订对它的引用。逻辑寻址则引入 log-to-phys(l2p)与 phys-to-log(p2l)两张索引,允许在生成 pack 文件时重排 item。文档还固定了几个全局 item_index:1 = changed path list,2 = root node revision,0 保留为空/无效(structure#L291-L296)。反过来,p2l 索引里定义了六种 item 类型:1 文件表示、2 目录表示、3 文件属性表示、4 目录属性表示、5 节点修订、6 changed paths list(structure#L299-L307)。

物理寻址与逻辑寻址共存,直接导致了"格式爆炸"和管理复杂度上升:同样一个 7+ 仓库,可能是 sharded + logical,也可能保留 physical;pack 文件的形态也随寻址模式不同(物理寻址用 per-shard manifest 记录 ASCII 十进制偏移,逻辑寻址则把索引数据追加在修订内容之后,无 manifest,structure#L314-L332)。"逻辑寻址是默认值、旧格式仍须支持"这一条,就是仓库层向后兼容成本的具体形状。

8.1.4 第四层:svndiff 编解码版本(v0/v1/v2)

第四层是 wire/磁盘层面上的 svndiff 版本。它定义在 svn_delta.h。关键函数签名是:

/* svn_delta.h#L546-L557 摘录 */
/*  @since New in 1.7.  Since 1.10, @a svndiff_version can be 2 for the
 *  svndiff2 format.  @a compression_level is currently ignored if
 *  @a svndiff_version is set to 2. */
svn_error_t *
svn_txdelta_to_svndiff3(svn_txdelta_window_handler_t *handler,
                        void *handler_baton,
                        svn_stream_t *output,
                        int svndiff_version,
                        int compression_level,
                        apr_pool_t *pool);

同处还定义了压缩级别常量(svn_delta.h#L56-L70):

/* svn_delta.h#L56-L70 摘录 */
#define SVN_DELTA_COMPRESSION_LEVEL_NONE     0
#define SVN_DELTA_COMPRESSION_LEVEL_MAX      9
#define SVN_DELTA_COMPRESSION_LEVEL_DEFAULT  5

这四层的版本节奏各有其"约束关系":

  • svndiff0 从 Subversion 1.0 就有;svndiff1 增加了每窗口的 zlib 压缩;svndiff2 从 1.10 起可用,且忽略 compression_level(因为它在协议层换成了另一套压缩策略)。svndiff2 同时需要 FSFS 格式 >= 8 与服务器端 REPORT 支持(SVN_DAV_NS_DAV_SVN_SVNDIFF2,见 svn_dav.h#L413)。
  • 因此 svndiff 版本的可用性被两层东西同时门控:服务器 FSFS 格式(决定仓库能否产生 svndiff2)与协议能力协商(决定客户端与服务器是否同意用 svndiff2 传输)。

8.1.5 "谁约束了谁"——一张约束关系的读数

把四层放在一起,可以给出这样的约束链:

  1. 产品版本约束"能做什么":1.15 的客户端实现了格式 32 的 WC 与格式 8 的 FSFS 逻辑寻址、svndiff2。低于 1.15 的客户端不认识这些。
  2. WC 格式约束"工作副本能被谁动":SVN_WC__SUPPORTED_VERSION 31 说的是"1.15 还能读 1.14 的工作副本";SVN_WC__VERSION 32 说的是"1.15 能升到 32,但不默认升"。--compatible-version 允许调用方把目标格式压回更低的 N。
  3. FSFS 格式约束"仓库能被谁读":格式 1–8 的 understood by Subversion X.Y+ 表就是读权限矩阵。格式只增不减:每个发布线只增加可理解的最高格式,从不取消对旧格式的读取。
  4. svndiff 版本被 FSFS 格式与协议能力双向门控:格式 8 才可能落盘 svndiff2;协议层要双方都声明支持。

这正是"兼容性三承诺"能够在工程上落地的机制:版本号不是装饰,而是能力的坐标系。release-lines.yaml 里同时维护 1.14 与 1.15 两条线、lts_release_lines 里还保留 1.10,就是这套坐标系在发布侧的直接映射(详见 8.4 节)。


8.2 构建系统:一份声明,三种输出

如果说第 8.1 节讲的是"版本坐标",那么这一节讲的是"这套代码是怎么被编译出来的"。Subversion 的构建系统有一个非常鲜明的设计哲学:只有一份声明式的目标清单,却要支撑三种完全不同的构建后端。理解了这一点,就能理解为什么它既要维护 build.conf,又要维护 gen-make.py + build/generator/,还要在 1.15 新增 CMake。

8.2.1 build.conf:不是 Makefile,而是"目标的 IR(中间表示)"

打开 build.conf,文件开头(build.conf#L23-L31)就把它的定位说得很清楚:

# This file is processed by gen-make.py, creating build-outputs.mk
#
# PREDEFINED SECTION
#
# This [options] section is global in scope, providing information to the
# process, rather than defining a build target, as all other sections do.

也就是说,build.conf 是 INI 格式的中立 IR:它不说"用什么编译器、什么 flag",只说"有哪些目标、每个目标由哪些源文件组成、依赖哪些库、装到哪个区、要不要生成 manpage"。把它翻译成具体的 Makefile/CMakeLists.txt/.vcproj 是 gen-make.py 的职责。这个分层最直接的好处是:新增一个可执行文件只需要在 build.conf 里加一节,不需要同时改三种构建系统。

[options] 全局段里最值得注意的是三个键(build.conf#L34-L56):

[options]
includes = subversion/include/*.h
include-wildcards = *.h *.i *.swg
private-includes =
        subversion/include/private/*.h
        subversion/bindings/swig/include/*.swg
        ...
private-built-includes =
        subversion/svn_private_config.h
        subversion/libsvn_fs_fs/rep-cache-db.h
        subversion/libsvn_wc/wc-queries.h
        ...
  • includes / include-wildcards:告诉生成器"哪些是公共头、用通配符怎么匹配"。include-wildcards = *.h *.i *.swg 里的 .i 与 .swg 就是 SWIG 接口文件的后缀,说明生成器需要理解多语言绑定。
  • private-includes:私有头清单。注意它同时列了 subversion/libsvn_subr/utf8proc/utf8proc_internal.h、utf8proc.c 等——第三方 vendored 代码也被纳入了构建系统视野。
  • private-built-includes:构建时才生成的头文件。subversion/svn_private_config.h 由 autoconf 生成,libsvn_fs_fs/rep-cache-db.h 与 libsvn_wc/wc-queries.h 由 SQL 文件生成(见 8.2.4 的 sql-header),bindings/swig/proxy/*.swg 由 SWIG 生成,一大批 org_apache_subversion_javahl_* 由 JNI 头生成器产出。

[options] 里还有 test-scripts 与 bdb-test-scripts(build.conf#L98-L101):

test-scripts =
        subversion/tests/cmdline/*_tests.py

bdb-test-scripts =

test-scripts 用通配符把 cmdline/*_tests.py 全部纳入 make check(这与 subversion/tests/README 的说明一致)。而 bdb-test-scripts 是空的——这是本书反复出现的一个信号:BDB 后端的测试已经不再默认运行。

SWIG 相关选项(build.conf#L103-L116)同样是 [options] 级:

swig-python-opts = $(SWIG_FEATURES) $(SWIG_PY_OPTS) $(SWIG_PY_FEATURES)
swig-perl-opts = $(SWIG_FEATURES) -perl $(SWIG_PL_FEATURES) -nopm -noproxy
swig-ruby-opts = $(SWIG_FEATURES) -ruby $(SWIG_RB_FEATURES)
swig-languages = python perl ruby

这是"三种语言绑定共享一份 build.conf"的实现方式:语言相关的入口选项集中在这里,具体目标在文件后半的 TARGETS FOR SWIG SUPPORT 段逐条声明。

8.2.2 逐目标解读(一):可执行程序的字段语义

先看客户端与仓库工具的五个 exe 目标。字段含义可以从这几节里归纳出来(每一节都用同样的键):

# build.conf#L145-L223 摘录
[svn]
description = Subversion Client
type = exe
path = subversion/svn
libs = libsvn_client libsvn_wc libsvn_ra libsvn_delta libsvn_diff libsvn_subr
       apriconv apr
manpages = subversion/svn/svn.1
install = bin
msvc-libs = wsetargv.obj

[svnadmin]
description = Subversion Repository Administrator
type = exe
path = subversion/svnadmin
install = bin
manpages = subversion/svnadmin/svnadmin.1
libs = libsvn_repos libsvn_fs libsvn_delta libsvn_subr apriconv apr
msvc-libs = wsetargv.obj

[svnserve]
description = Subversion Server
type = exe
path = subversion/svnserve
install = bin
manpages = subversion/svnserve/svnserve.8 subversion/svnserve/svnserve.conf.5
libs = libsvn_repos libsvn_fs libsvn_delta libsvn_subr libsvn_ra_svn
       apriconv apr aprutil sasl
msvc-libs = advapi32.lib ws2_32.lib

[svnsync]
description = Subversion repository replicator
type = exe
path = subversion/svnsync
libs = libsvn_ra libsvn_delta libsvn_subr apr
install = bin
manpages = subversion/svnsync/svnsync.1

[svnrdump]
description = Subversion remote repository dumper and loader
type = exe
path = subversion/svnrdump
libs = libsvn_client libsvn_ra libsvn_repos libsvn_delta libsvn_subr aprutil apr
install = bin
manpages = subversion/svnrdump/svnrdump.1

[svnmucc]
description = Subversion Multiple URL Command Client
type = exe
path = subversion/svnmucc
libs = libsvn_client libsvn_ra libsvn_subr libsvn_delta apriconv apr
install = bin
manpages = subversion/svnmucc/svnmucc.1

逐字段解读如下。description 是给人看的,也用于 Windows 工程里的显示名。type = exe 决定生成器把这一节映射到 TargetExe 类(见 gen_base.py#L648)。path 既是源目录,也是构建时的相对基准。libs 是链接依赖:它决定生成器在图里连出"本目标 → 这些库"的边,进而决定链接顺序和增量重编范围。注意 svn 依赖 libsvn_client libsvn_wc libsvn_ra libsvn_delta libsvn_diff libsvn_subr,而 svnadmin 换成 libsvn_repos libsvn_fs ...——从依赖列表就能读出"客户端"与"服务端"两条链在架构上是分开的。manpages 指向 man 源文件;svnserve 有 .8(服务进程)+ .5(svnserve.conf 配置格式)两个 manpage,这是命令行工具里唯一带配置文件手册的。install = bin 表示装到安装树的二进制目录,也决定 TargetExe 里的 self.install 取值(gen_base.py#L667 附近有 'test'/'bdb-test' 等特例分支)。msvc-libs 只在 MSVC 路径生效:wsetargv.obj 提供宽字符 argv 支持(Windows 下支持 Unicode 路径的前提),svnserve 需要 advapi32.lib ws2_32.lib(体面地暴露了它用了 Windows 服务 API 与 winsock)。

svnmucc 的依赖特别能说明它的本质:它只依赖 libsvn_client libsvn_ra libsvn_subr libsvn_delta——它不做工作副本操作,所以不需要 libsvn_wc。这就是"Multiple URL Command Client"能对纯 URL 做多个原子的提交前操作的架构原因。(CHANGES 里还记录了一串 svnmucc 的 1.15 修复,如 r1925905、r1925907、r1925913、r1926023、r1926034,全部与它直接操作 URL、不经过 WC这一特性相关。)

8.2.3 逐目标解读(二):库与"模块"的类型区分

库目标的核心是 type 字段,它决定了生成器的类映射。本次实读到的类型有:lib、fs-module、ra-module、apache-mod、sql-header、i18n、swig、shared-only-lib、shared-only-cxx-lib。

三个文件系统模块:

# build.conf#L283-L324 摘录
[libsvn_fs_base]
description = Subversion Filesystem Base Library
type = fs-module
path = subversion/libsvn_fs_base
sources = *.c bdb/*.c util/*.c
install = bdb-lib
libs = libsvn_delta libsvn_subr aprutil apriconv apr bdb libsvn_fs_util
add-install-deps = $(SVN_FS_LIB_INSTALL_DEPS)
msvc-delayload = yes

[libsvn_fs_fs]
description = Subversion FSFS Repository Filesystem Library
type = fs-module
path = subversion/libsvn_fs_fs
install = fsmod-lib
libs = libsvn_delta libsvn_subr aprutil apriconv apr libsvn_fs_util
msvc-delayload = yes
msvc-libs = ws2_32.lib

[libsvn_fs_x]
description = Subversion FSX Repository Filesystem Library
type = fs-module
path = subversion/libsvn_fs_x
install = fsmod-lib
libs = libsvn_delta libsvn_subr aprutil apriconv apr libsvn_fs_util
msvc-delayload = yes
msvc-libs = ws2_32.lib

type = fs-module 对应 gen_base.py 里的 TargetFsModule 类(在 TargetLib 之后派生)。这类目标的特殊语义是:它们不是被主程序直接链接的,而是被 libsvn_fs 在运行时按需定位(dlopen/loadmodule)加载的。这解释了 msvc-delayload = yes 的意义——Windows 上需要对模块做"延迟加载"处理,让 libsvn_fs 能动态找到并加载具体后端。

libsvn_fs_base 的字段最值得逐条看:

  • sources = *.c bdb/*.c util/*.c:只有它显式声明了 bdb/*.c——BDB 后端把 Berkeley DB 的适配层单独放在 bdb/ 子目录。本次 Glob 确认 subversion/libsvn_fs_base/ 下存在 bdb/ 相关结构(目标类里 libsvn_fs_base 的实现文件涵盖 fs.c、dag.c、reps-strings.c、trail.c、revs-txns.c、key-gen.c、lock.c、id.c、uuid.c、err.c、node-rev.c 等)。
  • install = bdb-lib:独占一个安装区。这是"BDB 是可选组件"在安装布局上的体现。
  • libs 里出现 bdb——即链接 Berkeley DB 这个外部库,而 FSFS/FSX 都不需要。
  • add-install-deps = $(SVN_FS_LIB_INSTALL_DEPS):把安装期依赖委托给 autoconf 变量。这是"构建期声明"与"配置期探测"的接缝。

再看仓库访问(RA)模块:

# build.conf#L337-L378 摘录
[libsvn_ra]
type = lib
libs = libsvn_delta libsvn_subr ra-libs apriconv apr aprutil
add-deps = $(SVN_RA_LIB_DEPS)
add-install-deps = $(SVN_RA_LIB_INSTALL_DEPS)

[libsvn_ra_serf]
type = ra-module
install = serf-lib
libs = libsvn_delta libsvn_subr aprutil apriconv apr serf zlib

[libsvn_ra_svn]
type = ra-module
install = ramod-lib
libs = libsvn_delta libsvn_subr aprutil apriconv apr sasl

[libsvn_ra_local]
type = ra-module
libs = libsvn_repos libsvn_fs libsvn_delta libsvn_subr apriconv apr
install = ramod-lib

libsvn_ra 是抽象层,它的 libs 里出现了一个占位符 ra-libs,并通过 add-deps = $(SVN_RA_LIB_DEPS) 把"实际有哪几个 RA 后端"的决定权交给配置阶段。这是"通用 RA + 可选后端"的经典分层。三个后端 ra-module 类型分别对应三条协议链:libsvn_ra_serf(HTTP/WebDAV,依赖 serf zlib)、libsvn_ra_svn(svn://,依赖 sasl)、libsvn_ra_local(本地直连 FS,依赖 libsvn_repos libsvn_fs)。注意 install 区各不相同:serf-lib、ramod-lib、ramod-lib——RA 后端可以按需安装/裁剪,这是给发行版打包者留的口子。

libsvn_client 与 libsvn_wc:

# build.conf#L243-L251 摘录
[libsvn_client]
description = Subversion Client Library
type = lib
path = subversion/libsvn_client
libs = libsvn_wc libsvn_ra libsvn_delta libsvn_diff libsvn_subr apriconv apr
install = lib
msvc-export = svn_client.h private/svn_client_mtcc.h private/svn_client_private.h
              private/svn_client_shelf.h private/svn_client_shelf2.h
              ../libsvn_client/mergeinfo.h ../libsvn_client/client.h

msvc-export 是只在 Windows 生成 DLL 导出列表时使用的字段:它列出需要从 DLL 里导出的头。注意它把 private/svn_client_shelf.h、private/svn_client_shelf2.h 也列了进去——这是因为 shelf(搁置)相关的 API 在 1.15 里仍处于实验阶段(CMakeLists.txt 与 CI 里能看到 SVN_EXPERIMENTAL_COMMANDS: shelf2/shelf3),但它们的一部分符号需要被测试程序访问,所以出现在导出清单里。libsvn_wc 的 msvc-export 同样暴露了 ../libsvn_wc/wc.h、wc-queries.h、workqueue.h 等"半私有"头,因为 WC 的测试程序需要它们。

8.2.4 逐目标解读(三):特殊类型与"生成物目标"

三类"特殊形态的库":

# build.conf#L226-L240 摘录
[libsvn_auth_gnome_keyring]
description = Subversion GNOME Keyring Library
type = shared-only-lib
install = gnome-keyring-lib
path = subversion/libsvn_auth_gnome_keyring
libs = libsvn_subr apr gnome-keyring

[libsvn_auth_kwallet]
description = Subversion KWallet Library
type = shared-only-cxx-lib
install = kwallet-lib
path = subversion/libsvn_auth_kwallet
libs = libsvn_subr apr kwallet
link-cmd = $(LINK_CXX_LIB)

shared-only-lib 与 shared-only-cxx-lib 对应 gen_base.py 里的 TargetSharedOnlyLib 与 TargetSharedOnlyCxxLib:这两个库只以共享库形式构建,从不做静态库。原因是它们要 dlopen 外部系统库(gnome-keyring、kwallet),静态链接没有意义。libsvn_auth_kwallet 的 link-cmd = $(LINK_CXX_LIB) 与 type = ...-cxx-lib 一起说明它是 C++ 实现(KWallet 是 KDE/Qt 的库),因此链接命令要用 C++ 链接器。CI 里 cmake.yml 的 Linux matrix 专门打开了 -DSVN_ENABLE_AUTH_KWALLET=ON -DSVN_ENABLE_AUTH_GNOME_KEYRING=ON 并安装了 libkf5wallet-dev、libsecret-1-dev 等包——这两个目标在 CI 里被显式覆盖,否则默认路径很可能不构建它们。

Apache 模块目标:

# build.conf#L435-L461 摘录
[mod_dav_svn]
description = Subversion plug-in for the Apache DAV module
when = INSTALL_APACHE_MODS
type = apache-mod
path = subversion/mod_dav_svn
sources = *.c reports/*.c posts/*.c
libs = libsvn_repos libsvn_fs libsvn_delta libsvn_subr libhttpd mod_dav
nonlibs = apr aprutil
install = apache-mod

[mod_authz_svn]
when = INSTALL_APACHE_MODS
type = apache-mod
...
nonlibs = mod_dav_svn apr aprutil
libs = libsvn_repos libsvn_subr libhttpd

[mod_dontdothat]
when = INSTALL_APACHE_MODS
type = apache-mod
path = tools/server-side/mod_dontdothat
nonlibs = mod_dav_svn apr aprutil
libs = libsvn_subr libhttpd
install = tools

type = apache-mod → TargetApacheMod 类。三个 Apache 模块共享 when = INSTALL_APACHE_MODS(条件构建开关)。mod_dav_svn 的 sources = *.c reports/*.c posts/*.c 直接对应目录结构:reports/ 下是十来个自定义 REPORT 实现(log.c、list.c、file-revs.c、replay.c、mergeinfo.c、update.c、inherited-props.c、get-locks.c、get-locations.c、get-location-segments.c、deleted-rev.c、dated-rev.c——本次 Glob 实证),posts/ 下是 HTTPv2 的 POST 事务接口。mod_dontdothat 的 path 在 tools/server-side/ 而非 subversion/,却仍以 apache-mod 构建、install = tools 安装——这是"通用工具仓"与"主代码仓"共用一份构建声明的一个具体例证。nonlibs 字段的语义是"这些依赖不作为链接库,仅供构建期使用/顺序依赖":mod_authz_svn 与 mod_dontdothat 的 nonlibs = mod_dav_svn apr aprutil 表示它们依赖 mod_dav_svn 的存在但不链接它,这是 Apache 模块"A 模块扩展 B 模块"的典型需求。

"生成物目标"(constructed headers):

# build.conf#L477-L505 摘录
[rep_cache_fs_fs]
description = Schema for the FSFS rep-sharing feature
type = sql-header
path = subversion/libsvn_fs_fs
sources = rep-cache-db.sql

[rep_cache_fs_x]
type = sql-header
path = subversion/libsvn_fs_x
sources = rep-cache-db.sql

[wc_queries]
description = Queries on the WC database
type = sql-header
path = subversion/libsvn_wc
sources = wc-queries.sql

[subr_sqlite]
description = Internal statements for SQLite interface
type = sql-header
path = subversion/libsvn_subr
sources = internal_statements.sql

type = sql-header → TargetSQLHeader(gen_base.py#L969)。它的作用是把 .sql 文件"编译"成 C 头文件(如 wc-queries.h、rep-cache-db.h)。这与 gen-make.py 里 generator.write_sqlite_headers() 的调用(gen-make.py#L68)直接对应。这就是为什么 private-built-includes 里会出现 libsvn_wc/wc-queries.h——它在源码树里不存在,是构建期生成的。

此外还有 i18n([locale],path = subversion/po,external-project = svn_locale)与 swig([swig_core]、[swig_client] 等)两类目标,分别对应 TargetI18N 与 TargetSWIG。

8.2.5 gen-make.py 与 build/generator/:生成流程

gen-make.py 的流程可以精确地按代码读出来:

# gen-make.py#L44-L53
sys.path.insert(0, os.path.join('build', 'generator'))
sys.path.insert(1, 'build')

gen_modules = {
  'cmake' : ('gen_cmake', 'CMake build system'),
  'make' : ('gen_make', 'Makefiles for POSIX systems'),
  'vcproj' : ('gen_vcnet_vcproj', 'VC.Net project files'),
  }

三种后端在同一个字典里并列,这就是"一份声明、三种输出"的字面实现。main() 的主体同样简洁(gen-make.py#L55-L70):

# gen-make.py#L55-L70 摘录
def main(fname, gentype, verfname=None,
         skip_depends=0, other_options=None):
  if verfname is None:
    verfname = os.path.join('subversion', 'include', 'svn_version.h')

  gen_module = __import__(gen_modules[gentype][0])
  generator = gen_module.Generator(fname, verfname, other_options)

  if not skip_depends:
    generator.compute_hdr_deps()

  generator.write()
  generator.write_sqlite_headers()
  generator.write_errno_table()
  generator.write_config_keys()

这里有三个值得注意的点:

  1. verfname 默认指向 subversion/include/svn_version.h。生成器读取版本号用来自动命名产物(如 Windows DLL 的文件版本),这又和第 8.1.1 节的产品版本宏连上了。
  2. compute_hdr_deps() 是默认步骤,-s 只是"跳过依赖生成"的开发选项。它的作用是扫描 #include 关系,为增量编译建出精确的头文件依赖图(build/generator/gen_base.py 里的 IncludeDependencyInfo 类,gen_base.py#L1136)。省略它(-s)能加快配置速度,代价是 Makefile 失去精确的增量重编能力——这正是 autogen.sh -s 的语义。
  3. 产物不止一个文件:write()(主构建文件)、write_sqlite_headers()、write_errno_table()(错误码表)、write_config_keys()(配置键表)。后两者分别对应生成的错误码映射和配置键头——CHANGES 里还专门记录了一条"Don't use full path in generated config_keys.inc(r1936038, r1936039)",说明这一类生成物本身也在被持续打磨。

命令行选项(gen-make.py#L100-L223)覆盖了三种后端:

  • 通用:-s(跳依赖)、--debug、--release、--reload(复用 gen-make.opts)、-t TYPE。
  • Makefile 专用:--assume-shared-libs(假设共享库,跳过可执行文件对库的依赖,加快频繁重建,注释里三令五申"*** Do not use unless you understand the consequences. ***")。
  • UNIX 专用:--installed-libs(用已经安装好的库而不是构建它们,给打包者用)。
  • Windows 专用:--with-apr=DIR、--with-apr-util=DIR、--with-berkeley-db=DIR、--with-serf=DIR、--with-httpd=DIR、--with-sqlite=DIR、--with-sasl=DIR、--vsnet-version=VER 等一长串。
  • 注意代码里对 --with-neon / --without-neon 的处理是"打印一条忽略警告"(gen-make.py#L298-L300):print("Ignoring no longer supported argument '%s'" % opt)。这是又一个"历史包袱但不删"的细节——neon 曾经是 HTTP 后端,后来被 serf 取代,但脚本仍礼貌地接受这个参数以免旧脚本报错。

选项最终被写进 gen-make.opts(gen-make.py#L321-L326),供 --reload 与其它脚本复用。这是一个小而实用的"配置持久化"设计。

build/generator/ 目录(本次 Glob 实证)包含:gen_base.py、gen_make.py、gen_cmake.py、gen_win.py、gen_vcnet_vcproj.py、gen_win_dependencies.py、extractor.py、ezt.py、__init__.py。其中 ezt.py 是内置的模板引擎(STATUS.ezt 这类模板也用它),extractor.py 负责解析 build.conf。gen_base.py 定义了整套目标类体系:从 DependencyNode 派生 ObjectFile/SWIGObject/HeaderFile/SourceFile/SWIGSource,再派生 Target,再派生 TargetLinked(install 字段处理,gen_base.py#L594-L616)、TargetExe、TargetLib、TargetApacheMod、TargetSharedOnlyLib、TargetSharedOnlyCxxLib、TargetRaModule、TargetFsModule、TargetDoc、TargetI18N、TargetSWIG、TargetSWIGLib、TargetProject、TargetSWIGProject、TargetJava、TargetSQLHeader。这份类层次就是 build.conf 里 type 字段的"值域定义":写 type = exe 就实例化 TargetExe,写 type = ra-module 就实例化 TargetRaModule。

8.2.6 三条构建路径

(a)autoconf 路径。 入口是 autogen.sh:先 cp -f aclocal.m4.in aclocal.m4(--release 时追加 AC_DEFUN([SVN_RELEASE_MODE],[1]),autogen.sh#L55-L63),跑 ./build/buildcheck.sh 检查依赖,调 libtoolize 把 libtool.m4、ltoptions.m4、ltsugar.m4、ltversion.m4、lt~obsolete.m4、config.guess、config.sub 拷进 build/(autogen.sh#L86-L165),用 ./build/find_python.sh 找 Python 并 export PYTHON,跑 gen-make.py build.conf 生成 build-outputs.mk(autogen.sh#L197-L203),最后用 autoheader 生成 svn_private_config.h.in、用 autoconf 生成 configure。所以 autogen.sh 既生成 configure,也生成 Makefile 片段。

configure.ac 里两个细节值得注意:版本号来自 esyscmd($PYTHON build/getversion.py SVN subversion/include/svn_version.h)(configure.ac#L25-L27),即 autoconf 路径复用 getversion.py 从同一个 svn_version.h 抽版本;另外,--enable-dot-clangd 会在源码树根生成 .clangd(configure.ac#L52-L69),这与 CHANGES 里 "Support generation of .clangd file with '--enable-dot-clangd'(r1925652)" 一致。

(b)CMake 路径(1.15 新增)。 顶层 CMakeLists.txt(本次实读)。它的版本号来源与 autoconf 不同——不是调用 getversion.py,而是在 CMake 侧用正则解析 svn_version.h:

# CMakeLists.txt#L22-L49 摘录
function(read_version path var major minor patch)
  ...
    file(
      STRINGS ${path} VERSION_STRINGS
      REGEX "#define (${major}|${minor}|${patch})"
    )
    string(REGEX REPLACE ".*${major} +([0-9]+).*" "\\1" VER_MAJOR ${VERSION_STRINGS})
    ...
endfunction()

cmake_minimum_required(VERSION 3.20)

read_version(
  "subversion/include/svn_version.h" SVN_VERSION
  SVN_VER_MAJOR SVN_VER_MINOR SVN_VER_PATCH
)

project("Subversion"
  VERSION "${SVN_VERSION}"
  LANGUAGES C
)

选项定义在紧接其后(CMakeLists.txt#L56-L60):SVN_ENABLE_SVNXX、SVN_ENABLE_PROGRAMS、SVN_ENABLE_TOOLS、SVN_ENABLE_TESTS、SVN_TEST_EXPAND。CMake 路径的产物不是手写 CMakeLists.txt,而是由 gen-make.py -t cmake 重新生成的——cmake.yml 里就能看到这一步(见 8.4):run: python ./gen-make.py -t cmake。这意味着 CMake 路径与 Makefile 路径尽可能共用同一份 build.conf 语义,而不是各维护一套源清单。CHANGES 里 Support building using CMake (r1920717 et al) 就是这条路径的起点。

(c)Windows MSVC 路径。 生成器用 -t vcproj(或 --vsnet-version=VER 隐含切换,gen-make.py#L213-L215)产出 VC 工程文件,核心实现是 build/generator/gen_vcnet_vcproj.py 与 build/generator/gen_win.py(辅以 gen_win_dependencies.py)。测试驱动是 win-tests.py(本次实读到它的 Usage: python win-tests.py [option] [test-path],选项包括 -r/--release、-d/--debug、--bin=PATH、-u URL、-f/--fs-type、-t/--test、--log-level 等)。Windows 专用的辅助文件放在 build/win32/ 下(本次 Glob 实证):compatibility.manifest(对应 CHANGES 的 "Add Windows manifest with compatibility information to executable files(r1933257)")、svn.rc(Windows 资源脚本)、svn.ico、make_dist.py、make_dist.conf.template、empty.c、make_gem.rb。

8.2.7 build/getversion.py:一份极小的公共基础设施

build/getversion.py 只有一百来行,却是 autoconf 版本探测的唯一来源。它的做法是:用正则 r'^\s*#\s*define\s+(\w+)\s+(\d+)' 逐行扫 svn_version.h,把命中的宏按预先登记的映射(SVN_VER_MAJOR → major、SVN_VER_MINOR → minor、SVN_VER_PATCH → patch)存进一个结果对象,最后 sys.stdout.write("%d.%d.%d" % (r.major, r.minor, r.patch))(getversion.py#L44-L73)。

值得记录的是它注册的另一个抽取值 sqlite_extractor(getversion.py#L75-L88):从 sqlite3.h 里读 SQLITE_VERSION_NUMBER,再按 major = num/1000000、minor = (num - major*1000000)/1000、micro = 余数 解出三段版本。但 extractors 字典里它被注释掉了:

# getversion.py#L91-L94
extractors = {
  'SVN' : svn_extractor,
  # 'SQLITE' : sqlite_extractor, # not used
  }

# not used——这段代码留着但不启用。这是 Subversion 项目常见的处理方式:保留能力、停用入口,而不是直接删除(因为迁移到 CMake 后 SQLite 的版本探测改由 CMake 的 find_package 完成)。

把本节的结论收束成一句:Subversion 的构建系统是"声明式中立清单 + 可插拔生成器后端"的组合。它的最大收益是让三种构建路径共享同一份语义;它的最大成本是——三种后端都必须同时被维护。当 CMake 于 1.15 加入时,代价就出现了:同一份 build.conf 现在要有第四个理解它的程序(gen_cmake.py),而且 CMake 与 Makefile 的行为差异(不是所有选项都能一一对应)会成为持续的分类负担。这就是下一节之后要展开的"双构建系统"代价。


8.3 测试体系:三目标与一条统一契约

构建系统保证"代码能编译",测试体系保证"代码能工作,而且下次改动时还能知道有没有坏"。Subversion 的测试体系有一个极其鲜明的特征:它用一条格式化的输出行,把 C 语言测试和 Python 黑盒测试统一到了同一套回归统计口径之下。这一节我们把这条契约逐字读出来,并对照仓内的真实文件验证它。

8.3.1 三目标与"测试程序"的定义

subversion/tests/README 开宗明义地给出自动化测试套件的三个目标:

The three goals of Subversion's automated test-suite:

      1.  It must be easy to run.
      2.  It must be easy to understand the results.
      3.  It must be easy to add new tests.

这三条不是口号,而是被后续每一条设计决策兑现的约束。"easy to run" 对应 make check 与 win-tests.py;"easy to understand" 对应那条统一的输出行与 make check 只在失败时打印;"easy to add" 对应 test_funcs 表与模板化的 Python 测试。

紧接着,README 定义了什么叫一个"SVN 测试程序"(README#L15-L43),这一节是整份文档的"接口规范":

A Subversion test program is any executable that contains a number of
sub-tests it can run.  It has a standard interface:

1.  If run with a numeric argument N, the program runs sub-test N.

2.  If run with the argument `--list', it will list the names of all sub-tests.

3.  If run with no arguments, the program runs *all* sub-tests.

4.  The program returns either 0 (success) or 1 (if any sub-test failed).

5.  Upon finishing a test, the program reports the results in a format
    which is both machine-readable ... and human-readable ...:

      (PASS | XPASS | FAIL | XFAIL | SKIP): (argv[0]) (argv[1]): (description)

这里有五个可验证的契约点。其一,参数语义:N 跑单个、--list 列举、无参跑全部。其二,退出码语义:0 表示成功,1 表示"任一子测试失败"。其三,输出格式——原文就是 (PASS | XPASS | FAIL | XFAIL | SKIP): (argv[0]) (argv[1]): (description),也就是五个状态词之一,加冒号,加 argv[0](程序名)、argv[1](子测试号)、description。README 还贴心给了一个译本(README#L36-L40):

  [sussman@newton:~] ./frobtest 2
  PASS: frobtest 2: frobnicating fragile data
  [sussman@newton:~]

其四,"无语言限制":README 特别加了一句 "Note that no particular programming language is required to write a set of tests; they just need to export this user interface."——这条设计让 C 测试与 Python 测试可以在同一套统计脚本下混合,这正是 Subversion 能同时维护两套测试生态的原因。其五,"统一契约"是机器可读的:make check 会扫描输出中的 FAIL 码,并把全部子测试输出记进顶层 tests.log(README#L239-L256)。

8.3.2 状态词的五种取值与 XFAIL 的语义

五个状态词各有精确含义,README 在 C 测试一节里解释了 XFAIL/XPASS(README#L75-L85):

Instead of SVN_TEST_PASS, you can use SVN_TEST_XFAIL to declare that a
test is expected to fail. The status of such tests is then no longer
marked as PASS or FAIL, but rather as XFAIL (eXpected FAILure) or
XPASS (uneXpected PASS).

The purpose of XFAIL tests is to confirm that a known bug still
exists. When you see such a test uneXpectedly PASS, you've probably
fixed the bug it tests for, even if that wasn't your intention. :-)
XFAIL is not to be used as a way of testing a deliberately invalid
operation that is expected to fail when Subversion is working
correctly, nor as a place-holder for a test that is not yet written.

这段文字里藏着两个工程纪律。第一,XPASS 是一个"意外的好消息"信号:当一个 XFAIL 测试突然 PASS,通常意味着你顺带修掉了它对应的已知 bug——这是一个"回归测试反向告诉你修复已发生"的正反馈机制。第二,README 明确禁止把 XFAIL 当"故意无效操作"或"还没写的占位测试"用,因为那会让 XFAIL 的语义失真。SKIP 则用于"由于环境原因(如缺后端、平台不适用)而跳过"。

8.3.3 C 测试:test_funcs[] 与 svn_test_descriptor_t

C 测试的写法被 README 规定得非常具体(README#L51-L105)。核心是你不写自己的 main,而是定义一个外部可见的 test_funcs 数组:

/* README#L59-L67 */
struct svn_test_descriptor_t test_funcs[] =
{
  SVN_TEST_NULL,
  SVN_TEST_PASS(test_a),
  SVN_TEST_PASS(test_b),
  SVN_TEST_PASS(test_c),
  SVN_TEST_NULL
};

README 对数组两端的 SVN_TEST_NULL 给了很"Subversion 式"的解释:

The first and last elements of the array must be SVN_TEST_NULL.  The
first SVN_TEST_NULL is there to leave room for Buddha.  The standard
`main' function searches for the final SVN_TEST_NULL to determine the
size of the array.

"The first SVN_TEST_NULL is there to leave room for Buddha."——这句半开玩笑的注释其实揭示了一个实际索引约定:把索引 0 空出来,使"子测试号"从 1 开始,与人给的数字参数(./frobtest 2 是第二个测试)一一对应。没有这条约定,./prog N 的语义就会和数组下标错位。main 则通过"找到末尾的 SVN_TEST_NULL"来确定数组长度——这是一种"哨兵终止"的 C 惯用法。

每个测试函数的原型也是固定的(README#L87-L92):

/* README#L89-L91 */
svn_error_t *f (const char **MSG,
                svn_boolean_t MSG_ONLY
                apr_pool_t *POOL);

函数被调用时,先给 *MSG 写入一句半行长的描述;若 MSG_ONLY 为真则立刻返回 SVN_NO_ERROR(这就是 --list 的实现方式——只取描述、不跑逻辑);否则执行测试,成功返回 SVN_NO_ERROR,失败返回一个 svn_error_t。最后,源码链接 libsvn_tests_main.la 提供的 main(README#L100-L105),它还提供了一个 libsvn_tests_editor.la(一个把编辑器调用打印到 stdout 的测试用编辑器,README#L222-L237)。

README 还给出了"不要测什么"的规范(README#L174-L219),它的核心是:回归测试测的是"接口承诺",不是实现细节。正例是"测 svn_fs_txn_name 兑现了它的承诺(返回非 NULL)";反例是"测第一个事务的名字是 "0""——因为事务名的具体取值是实现细节,可以合法地改变。README 还建议:如果确实要临时测实现细节,请加显式注释标出,以便后来者放心删除。这是对"测试也要可维护"的直接回应。

8.3.4 Python 测试:对命令行客户端的黑盒验证

Python 测试在 subversion/tests/cmdline/ 下,与 C 测试"结构相似但不同框架"。README(cmdline 版)说得很清楚(cmdline/README#L6-L13):

The cmdline client test suite doesn't use the C-level testing
framework, but is structured similarly.  Instead of testing library
APIs, it drives the client just like a user would, examining the
output and the on-disk results (i.e., the working copy) carefully as
it goes.  In other words, this is "black box" testing of the
command-line client.  It has no access to code internals; it never
looks inside the .svn/ directory; it only performs actions that a
human user would do.

"它从不看 .svn/ 目录内部"——这条约束把 Python 测试定位成只验证用户可观察契约的测试。它的调用形式也与 C 测试同构(cmdline/README#L30-L46):./basic_tests.py 跑全部;./basic_tests.py 7 13 17 跑指定编号;./basic_tests.py basic_mkdir_wc_with_parents ... 按函数名跑;./basic_tests.py --list 2 3 4 列举信息。

本次 Glob 实证,cmdline/ 下的测试文件是一个六十余个的集合,覆盖了几乎全部命令面。举一批有代表性的真实文件名:basic_tests.py、commit_tests.py、authz_tests.py、update_tests.py、merge_tests.py、mergeinfo_tests.py、merge_tree_conflict_tests.py、svnadmin_tests.py、svnlook_tests.py、svndumpfilter_tests.py、svnsync_tests.py、svnrdump_tests.py、svnauthz_tests.py、svnmucc_tests.py、dav_tests.py、mod_dav_svn_tests.py、copy_tests.py、move_tests.py、externals_tests.py、lock_tests.py、tree_conflict_tests.py、upgrade_tests.py、entries_tests.py、store_pristine_tests.py、shelf_tests.py、shelf2_tests.py、svnfsfs_tests.py、checkout_tests.py、prop_tests.py。

这个清单本身就是一张"功能地图":从 store_pristine_tests.py 可以读出 1.15 的 pristineless 特性,从 shelf_tests.py/shelf2_tests.py 可以读出"搁置"功能的实验性状态(对应 CI 里 SVN_EXPERIMENTAL_COMMANDS: shelf2/shelf3),从 svnfsfs_tests.py 可以读出 svnfsfs 这个低层仓库操作工具也有测试覆盖。

8.3.5 tests.conf:memcached 与 fail-stop

subversion/tests/tests.conf 是测试的运行期配置文件,它有两个用途,文件注释写得很明白:

### This config file configures some aspects of the Subversion test
### suite.  Pass --config-file FILENAME to test programs if running
### them manually; "make check" passes this file in automatically.

### Currently, it is used for two purposes: it is used to configure
### memcached for direct svn_cache/memcached tests in
### libsvn_subr/cache-test; and it is copied into new FSFS
### repositories as fsfs.conf (to configure their use of memcached as
### well).

[memcached-servers]
### Run memcached servers and enter lines like the following (the key
### is ignored):
# key = 127.0.0.1:11211

[caches]
### In the test suite, we should make FSFS cache failures into actual
### test failures:
fail-stop = true

两个可回溯的事实:其一,这份文件会被拷贝进新建的 FSFS 仓库当作 fsfs.conf——这就是"测试配置同时是产品配置样例"的做法。其二,[caches] fail-stop = true 是一个重要的测试哲学:让缓存失败变成真正的测试失败,而不是被静默地降级成"缓存未命中所以直接读盘"。如果没有 fail-stop,一个缓存层的 bug(比如缓存返回脏数据)可能被"缓存没命中"掩盖过去,测试就会假绿。把缓存故障设为 fail-stop,等于强制测试去验证缓存路径的正确性。[memcached-servers] 这一节默认是注释掉的(# key = 127.0.0.1:11211),这意味着默认测试不依赖外部 memcached,只有在显式填条目时才启用 memcached 相关测试。

8.3.6 davautocheck.sh 与"多后端同测"

Subversion 的仓库访问有三个后端(ra_local/ra_svn/ra_serf),理想情况下同一套 Python 测试应该能在三个后端下都跑通——这就是"多后端同测"目标。测试体系提供了两条路径:

  • 同一份测试脚本 + URL 参数:cmdline 测试接受一个 URL 参数,当传了 URL 时就跑远程仓库(cmdline/README#L55-L60 的 "Running over mod_dav_svn" 一节)。
  • davautocheck 目标:CI 里 autoconf.yml 的 matrix 直接把 check-target 设为 check 或 davautocheck(见 8.4),也就是说 davautocheck 是一条独立于普通 make check 的、自动拉起 httpd 并跑 DAV 后端测试的目标。

工程上有一条耐人寻味的记录:CHANGES 提到 tests: Fix an error when running 'davautocheck USE_SSL=1'(r1931266)——说明 davautocheck 还支持 USE_SSL=1 这样的参数,用以在 TLS 下跑 DAV 测试。同样地,autoconf.yml 里 httpd 维度取 system/trunk/2.4.x 三个值,配合 APACHE_MPM=event,说明 DAV 后端测试确实在与真实 httpd 的组合矩阵上运行,而不是只在模拟环境里。

8.3.7 build/run_tests.py 与 build/list_tests.py

这两个脚本是测试体系的"驱动层"。build/list_tests.py 的作用是把一个 Python 测试脚本里定义的所有测试列举成 TESTPATH#TESTNUM 形式:

# list_tests.py#L85-L89
for progabs in tests:
  modname = os.path.basename(progabs)[:-3]
  testlist = _load_py_test_module(progabs, modname).test_list
  for testnum in range(1, len(testlist)):
    print(progabs + "#" + str(testnum))

注意 range(1, len(testlist)) 从 1 开始——test_list[0] 是保护位置(呼应 C 测试的"留位"约定)。list_tests.py 还处理了 Python 2/3 的模块加载差异:sys.version_info < (3, 5) 用 imp.load_module,否则用 importlib.util.spec_from_file_location(list_tests.py#L44-L64)。这是一份"还在兼容 Python 2.7"的代码遗迹。

build/run_tests.py 是真正跑测试的驱动器,其用法本身就很能说明问题:

'''usage: python run_tests.py
            [--parallel=<n>] [--url=<base-url>] [--fs-type=<fs-type>]
            [--fsfs-packing] [--fsfs-sharding=<n>] [--list]
            [--server-minor-version=<version>] [--wc-format-version=<version>]
            <abs_srcdir> <abs_builddir> <prog ...>

这份 usage 清单几乎是一张"测试维度总表":--fs-type 选后端文件系统、--fsfs-packing/--fsfs-sharding=<n> 控制 FSFS 的打包与分片、--wc-format-version=<version> 明确允许用不同 WC 格式跑同一套测试(对应 1.15 的 multi-wc-format)、--parallel 支持并行、--server-minor-version 支持模拟老服务器(对应"前后向兼容"测试)。run_tests.py 还会把 LC_ALL 强制设为 C(run_tests.py#L63),注释里解释:"Ensure the compiled C tests use a known locale(Python tests set the locale explicitly)"——这是一个"让测试结果可 reproduce"的标准动作。

8.3.8 数值化的运行说明与 1.15 的测试改动

把这些契约落到"怎么真的跑起来",可以给出一组数值化的说明:

  • 普通 C + Python 全量测试:make check(test-scripts 里的全部 cmdline/*_tests.py 加上编译出来的 C 测试程序)。README 明确说 make check 只在失败时打印、把全部输出写进顶层 tests.log。
  • 只跑 C 测试并启用 Valgrind:make check VALGRIND=C(subversion/tests/README#L265-L303)。README 给出了三个具体的调用形态:
    make check VALGRIND=C
    make check SKIP_C_TESTS=1 VALGRIND=svn
    make check VALGRIND=svnlook TESTS=subversion/tests/cmdline/svnlook_tests.py
    make check PARALLEL=1 VALGRIND=C,svn,svnlook
    
    并且给出了代价的数值化描述:"expect more than an order of magnitude slowdown if using valgrind on svn during the python tests",即开启 valgrind 后慢一个数量级以上。README 还提醒:用 valgrind 时最好先编译带上 -DAPR_POOL_DEBUG(池调试);C 测试会被加上 --error-exitcode=1,所以"子测试 PASS 但整程序可能 FAIL";stderr 的 valgrind 诊断会进 tests.log。
  • DAV 后端测试:make davautocheck(CI 里作为独立的 check-target),并支持 USE_SSL=1。
  • CI 里的实际命令:autoconf.yml 的测试步骤是 make ${{matrix.check-target}} PARALLEL=16 APACHE_MPM=event CHECK_XML_SCHEMA=1(autoconf.yml#L116-L119)。PARALLEL=16 是并行度,APACHE_MPM=event 指定 Apache 用 event MPM,CHECK_XML_SCHEMA=1 打开 XML schema 校验(对应 CHANGES 的 "tests: Verify command XML output(r1925717, r1935776)")。

1.15 在测试侧有几条明确的演进,都能从 CHANGES 的 "Developer-visible changes" 段逐条对上:

# CHANGES#L88-L93 摘录
* Drop support for Python 2.x in test scripts (r1899944, r1925713)
* Support building for Windows on ARM64 (r1890668, r1890673)
* Support Visual Studio 2022 project generation (r1899028)
* Enable multi-processor compilation (/MP) on Windows (r1906011)
* Support shared RA modules on Windows with CMake (r1921006 et al)
* Support running Valgrind during 'make check' (r1908546)

其中 "Drop support for Python 2.x in test scripts (r1899944, r1925713)" 是一条分水岭:进入 1.15,测试脚本正式以 Python 3 为默认。这与我们前面在 run_tests.py/list_tests.py 里读到的"仍保留 Python 2 分支"的代码形成了有趣的对照——声明层面的支持已停止,但兼容代码尚未清理,这是大型项目"迁移期"的常态。"Support running Valgrind during 'make check' (r1908546)" 则把上面那段 README 里的 valgrind 用法正式固化成 CI 能力。

另外,CHANGES 里还有一组测试质量修复值得注意:tests: Stop encoding a test's number in the svntest library(r1897443)——这条说明测试框架不再把"测试编号"固化进库代码,这与我们前面在 C 测试里看到的"用哨兵 NUL 让编号语义与人给的数字对齐"是同一个设计原则的两次出现:编号是运行期概念,不该被硬编码进实现。tests: Use 2048-bit RSA for the server certificate and key(r1931265) 与 tests: Allow testing ra_serf using Digest authentication(r1926790) 则说明测试用证书与认证方式也在随安全基线更新。

本节小结:Subversion 的测试体系之所以能同时养活 C 与 Python 两套测试,靠的正是那条 (PASS|XPASS|FAIL|XFAIL|SKIP): argv[0] argv[1]: description 契约——它是测试框架的"协议",把"可运行、可理解、可添加"三个目标变成可执行的形式。而 1.15 的测试演进(Python 3 默认、支持 Valgrind、XML schema 校验)说明:测试基础设施本身也是需要持续投入的产品,不是写完就不动的脚手架。


8.4 发布、backport 与 CI:从代码到发行物的流水线

8.4.1 release-lines.yaml:把"有哪些线、钉什么工具"写进一个文件

tools/dist/release-lines.yaml 是整个发布基础设施的"单一事实来源"。它的开头注释就把用途讲清楚了:

# release-lines.yaml#L1-L4
# This file specifies metadata that may vary between release lines, that
# is usually common to all releases in a release line, for all potentially
# interesting release lines.
---

文件里有两块内容。第一块是 tool_versions(release-lines.yaml#L5-L23):

tool_versions:
  '1.8':
    autoconf: ['2.69', '954bd69b391edc12d6a4a51a2dd1476543da5c6bbf05a95b59dc0dd6fd4c2969']
    libtool: ['2.4.3', '36b4881c1843d7585de9c66c4c3d9a067ed3a3f792bc670beba21f5a4960acdf']
    swig: ['2.0.9', '586954000d297fafd7e91d1ad31089cc7e249f658889d11a44605d3662569539']
  '1.9':
    autoconf: ['2.69', '954bd69b391edc12d6a4a51a2dd1476543da5c6bbf05a95b59dc0dd6fd4c2969']
    libtool: ['2.4.6', 'e3bd4d5d3d025a36c21dd6af7ea818a2afcd4dfc1ea5a17b39d7854bcd0c06e3']
    swig: ['2.0.12', '65e13f22a60cecd7279c59882ff8ebe1ffe34078e85c602821a541817a4317f7']
  '1.10': &TOOL_VERSIONS_1_10
    autoconf: ['2.69', '954bd69b391edc12d6a4a51a2dd1476543da5c6bbf05a95b59dc0dd6fd4c2969']
    libtool: ['2.4.6', 'e3bd4d5d3d025a36c21dd6af7ea818a2afcd4dfc1ea5a17b39d7854bcd0c06e3']
    swig: ['3.0.12', '7cf9f447ae7ed1c51722efc45e7f14418d15d7a1e143ac9f09a668999f4fc94d']
  '1.11': *TOOL_VERSIONS_1_10
  '1.12': *TOOL_VERSIONS_1_10
  '1.13': *TOOL_VERSIONS_1_10
  '1.14': *TOOL_VERSIONS_1_10
  '1.15': *TOOL_VERSIONS_1_10
  'trunk': *TOOL_VERSIONS_1_10

这段 YAML 有五个可验证的工程要点。其一,每个工具的值都是 [版本号, SHA-256 校验和] 的二元组。把校验和写进发布元数据,意味着"用哪一版 autoconf/libtool/swig 构建发布包"是可核验的、可复现的,而不是"碰巧装了哪版就用哪版"。这是**可重现构建(reproducible build)**在发布工程上的一次务实落地:对工具链本身做 pin。

其二,YAML 锚点与别名被用来表达"多条线共享同一套工具版本":'1.10' 节点标记为 &TOOL_VERSIONS_1_10(锚点),'1.11' 到 '1.15' 乃至 'trunk' 都写成 *TOOL_VERSIONS_1_10(别名)。这不是炫技,而是让"哪些线共享工具版本"这件事在文本里一目了然:从 1.10 起,autoconf 一直是 2.69、libtool 一直是 2.4.6,只有 swig 从 2.0.12(1.9)升到 3.0.12(1.10)后就稳定了。

其三,从 1.8 到 1.9 的 libtool 从 2.4.3 升到 2.4.6、swig 从 2.0.9 升到 2.0.12——这说明工具版本也会随发布线演进,但演进被显式记录,而不是隐式发生。

其四,1.8 与 1.9 各自单独列(不复用锚点),1.10 起才复用——这条断点大约与项目基础设施的一次整理同步。

其五,trunk 也在文件里——发布元数据不只服务已发布线,也服务开发主干。这让"trunk 上将发布成哪个 1.15"这件事有据可依。

8.4.2 recommended/supported/LTS:三个容易混淆的概念

文件末尾的三个键是本节的重点(release-lines.yaml#L25-L30):

# The version that is our current recommended release
recommended_release: '1.14'
# For clean-dist, a whitelist of artifacts to keep, by version.
supported_release_lines: ['1.14']
# Long-Term Support (LTS) versions
lts_release_lines: ['1.10', '1.14']

这三个概念服务的是三件不同的事,不能互换:

  1. recommended_release(当前为 '1.14')是面向用户的:"我们建议新用户装哪条线"。它出现在下载页与公告里,是"最该被选择的那条线"。注意它的取值是 '1.14' 而不是正在开发的 1.15:这说明"主推版本"与"最新开发线"是两个不同判断——推荐版本要的是成熟稳定,不是最新。(对照 CHANGES 顶部,1.15.0 的发布日期写作 21 Sep 2026,而这份 release-lines.yaml 仍把 recommended 指向 1.14,正体现了"发布元数据会随发布节奏逐步推进"这一事实。)
  2. supported_release_lines(当前为 ['1.14'])的注释明确写着 "For clean-dist, a whitelist of artifacts to keep, by version."——它是发布基础设施的视角:在 clean-dist(清理 dist 目录)时,保留哪些版本线的产物。这是一份"白名单",而不是"我支持这些版本"的口号。
  3. lts_release_lines(当前为 ['1.10', '1.14'])是承诺的视角:哪些线被承诺做长期支持。它是三者中"义务最重"的一个,因为 LTS 意味着更长的维护窗口。

三者的实际差异可以这样概括:recommended 是"推荐谁",supported 是"保留谁的产物",LTS 是"承诺养谁"。同一个 1.14 同时出现在三者里(它既被推荐、又被保留、又是 LTS);1.10 只出现在 LTS 里(它仍是承诺维护的 LTS,但已不是推荐线、产物也不在 clean-dist 白名单里)。这种"三张清单部分重叠"的形态,恰恰说明发布策略不是"一条线一刀切",而是按用途分层管理。

8.4.3 tools/dist/:发布工具链

发布相关的脚本集中在 tools/dist/。本次 Glob 实证,这个目录包含:

  • dist.sh——生成发行 tarball 的 shell 脚本。它的 usage 直接写明了参数契约(dist.sh#L23-L34):
    # USAGE: ./dist.sh -v VERSION -r REVISION -pr REPOS-PATH
    #                  [-alpha ALPHA_NUM|-beta BETA_NUM|-rc RC_NUM|-pre PRE_NUM]
    #                  [-apr PATH-TO-APR ] [-apru PATH-TO-APR-UTIL]
    #                  ...
    #      ./dist.sh -v 1.4.0 -r ????? -pr branches/1.4.x
    
    即"版本号 + 修订号 + 仓库路径"三要素,加可选的预发布后缀与依赖路径。这正是第 8.1.1 节里 SVN_VER_TAG 被"打包时替换"的那一步的实际发生处。
  • release.py——发布自动化(rolling/posting/announcing,release.py#L23-L32),兼容 Python 2/3(release.py#L44-L47)。
  • checksums.py——替代 md5sum/sha1sum/sha512sum 的校验和工具,用于没有这些命令的系统(macOS、Windows)(checksums.py#L22-L25、checksums.py#L39-L44)。
  • changes-to-html.py——把纯文本 CHANGES 转成 HTML。它用正则把 URL 与 issue 号变成链接(changes-to-html.py#L41-L45):
    url_rx = re.compile(r'(?:^|(?<=\W))(https?://[\w\d./?&=]+)')
    url_sub = r'<a href="\1">\1</a>'
    issue_rx = re.compile(r'(?<=\W)(#(\d+))')
    issue_sub = r'<a href="https://issues.apache.org/jira/browse/SVN-\2">\1</a>'
    
    注意 issue 链接是 jira/browse/SVN-\2——它把 #3657 这类的写法映射到 JIRA 的 SVN-3657。这正是 CHANGES 顶层注释里写的 "To view an issue listed as (issue #XXXX), visit ... subversion.apache.org/issue-XXXX" 的对应实现(CHANGES#L1-L5)。
  • 其余:advisory.py、manage-backports.py、nightly.sh、test.sh、make-keys.sh、extract-for-examination.sh、rat-excludes、README.backport、README.advisory。
  • templates/ 目录下有 STATUS.ezt、release-notes.ezt、release-notes-lts.ezt、stable-release-ann.ezt、stable-news.ezt、rc-news.ezt、rc-release-ann.ezt、download.ezt(本次 Glob 实证)。

其中 templates/STATUS.ezt 是"发布候选变更"的模板,它给出的三段结构是:

# templates/STATUS.ezt#L3-L24
      *  THIS RELEASE STREAM IS OPEN FOR STABILIZATION.     *
...
This file tracks the status of releases in the [major-minor].x line.
...
Candidate changes:
==================


Veto-blocked changes:
=====================


Approved changes:
=================

这三段(Candidate/Veto-blocked/Approved)就是 Subversion 的逐条投票式 backport 流程的文本载体:一个变更先被提名进 Candidate,被表决通过后进 Approved,被否决则进 Veto-blocked。"Veto"(否决)在 Subversion 的发布流程里是一等公民,这是它比很多项目更严格的体现。

8.4.4 backport 工具链:从 backport.pl 到 backport.py

Subversion 的稳定线(1.14.x、1.15.x)不是靠"把 trunk 拉过去"维护的,而是靠逐条挑选修订(cherry-pick)并投票。支撑这套流程的脚本构成了 tools/dist/ 里的 backport 工具链:

  • nominate-backport.py——交互式地把修订提名进 STATUS,可选地直接提交。它的 docstring 说得很清楚(nominate-backport.py#L20-L28):
    Nominate revision(s) for backport.
    
    This script should be run interactively, to nominate code for backport.
    ...
    This script will add an entry to the STATUS file and optionally commit the changes.
    
    并且它 assert sys.version_info[0] == 3——强制 Python 3。
  • merge-approved-backports.py——把 Approved 段里的条目录进稳定分支。
  • detect-conflicting-backports.py——自动检测冲突。它的 docstring(detect-conflicting-backports.py#L20-L51)说明:
    Run this script from the root of a stable branch's working copy (e.g.,
    a working copy of /branches/1.9.x).  This script will iterate the STATUS file,
    attempt to merge each entry therein (nothing will be committed), and exit
    non-zero if any merge produced a conflict.
    
    Conflicts caused by entry interdependencies
    -------------------------------------------
    ... the dependent entry may be annotated by a "Depends:" header, to signal
    to this script that the conflict is expected.  Expected conflicts never
    cause a non-zero exit code.
    
    这条 Depends: 头部的设计很值得记录:它让"预期内的冲突"(例如 r42 在文本上依赖 r40)能被脚本识别并放过,而不会误报成失败。它体现了"自动化检查也要给人留合法的例外通道"这一务实原则。
  • backport_tests.py——backport 工具本身的测试。它有一段很有意思的迁移期注释(backport_tests.py#L28-L39):
    # We'd like to test backport.pl and backport.py the same way, and to reuse
    # the svntest Python harness. ...
    # ### temporarily until we switch over to backport.py and remove backport.pl.
    
    这段话把"从 Perl 的 backport.pl 迁移到 Python 的 backport.py"的历史现场保留了下来——两个驱动脚本(backport_tests_pl.py 与 backport_tests_py.py)曾同时存在,共享同一份测试逻辑。

而 CHANGES 正式宣告了这次迁移的完成:

# CHANGES#L67-L70 摘录
* Remove the wc-format.py script (r1936209)
* Remove the build-svn-deps-win.pl script (r1936226)
* Remove the backport.pl script (r1936310 et al)

"Remove the backport.pl script (r1936310 et al)" 是这套工具链在 1.15 的一个里程碑:旧的 Perl 实现被彻底删除,backport.py 成为唯一实现。注意同一批还有 wc-format.py(r1936209)、build-svn-deps-win.pl(r1936226)、以及 CHANGES 里 "Remove the get-deps.sh script(r1937667)" 的删除。这一波"删脚本"动作说明:当新实现稳定后,项目愿意真的把旧实现删掉,而不是永远留着两份。(这与第 8.2.5 节 --with-neon 被"礼貌忽略"的处理形成了对照:对外的接口参数要留,对内的实现脚本要删——两条规则分得很清。)

8.4.5 CI:三个工作流与"双构建路径"的映射

Subversion 1.15 的 CI 放在 .github/workflows/ 下,本次 Glob 实证有三个工作流:autoconf.yml、cmake.yml、detect-backport-conflicts.yml。它们恰好构成"两条构建路径 + 一条发布自动化"的格局。

(1)autoconf.yml——autoconf 构建路径

文件声明(autoconf.yml#L22-L48):

name: autoconf

on:
  push:
    branches: ["*"]
    paths-ignore:
      - 'doc/**'
      - 'notes/**'
      - CHANGES
      - COMMITTERS
      - INSTALL
      - STATUS
      - README
  pull_request:
    branches: ["*"]
    paths-ignore:
      - 'doc/**'
      - 'notes/**'
      ...

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: false

paths-ignore 是一个降低 CI 浪费的设计:纯文档改动(doc/**、notes/**、CHANGES、COMMITTERS、INSTALL、STATUS、README)不触发构建。cancel-in-progress: false 说明它不取消正在进行的旧构建(与 cmake.yml 一致),这对发布线这种"每次 push 都想拿到完整结果"的场景是合理选择。

它的 matrix(autoconf.yml#L50-L66)是三个维度的笛卡尔积,外加两条排除:

    strategy:
      fail-fast: false
      matrix:
        check-target: [check, davautocheck]
        os: [ubuntu-latest, ubuntu-22.04-arm]
        httpd: [system, trunk, 2.4.x]
        # Only run davautocheck for httpd trunk/2.4.x
        exclude:
          - check-target: check
            httpd: trunk
          - check-target: check
            httpd: 2.4.x

即:测试目标 ×{check, davautocheck}、OS ×{ubuntu-latest, ubuntu-22.04-arm}、httpd ×{system, trunk, 2.4.x}。排除规则让 check(非 DAV 的普通测试)只跑在系统 httpd 上,davautocheck 才跑在 trunk/2.4.x 的 httpd 上。这是"多后端同测 + 多架构(含 arm64)+ 多 httpd 版本"的具体数值化矩阵。

关键步骤(本次实读):安装 apt 依赖(libapr1-dev、libaprutil1-dev、libserf-dev、libsqlite3-dev、liblz4-dev、libutf8proc-dev、libsecret-1-dev、python3-lxml、python3-rnc2rng 等);对非 system 的 httpd 走 git clone --depth=1 -b ${{matrix.httpd}} https://github.com/apache/httpd 后 ./buildconf/./configure --enable-mpm-shared=event --enable-mods-shared=most --enable-dav/make -j/make install;然后 ./autogen.sh;./configure --enable-maintainer-mode --with-apxs=...;make -j;make ${{matrix.check-target}} PARALLEL=16 APACHE_MPM=event CHECK_XML_SCHEMA=1;归档 tests.log 与 subversion/tests/cmdline/httpd**/*_log;最后 sudo make install。

注意 --enable-mods-shared=most --enable-dav 与 APACHE_MPM=event 的前后呼应:构建 httpd 时就打开了 DAV 模块与 event MPM,测试时也用 event MPM。这条链把"依赖 httpd"这件事从头到尾贯通。

(2)cmake.yml——CMake 构建路径

文件声明(cmake.yml#L22-L52):name: CMake,同样的 paths-ignore 策略,但 concurrency.group: ${{ github.ref }}(少了一层 workflow 前缀),并且有一行很关键的默认值:

defaults:
  run:
    shell: pwsh

默认 shell 是 PowerShell(pwsh)——因为这条路径最关注的平台是 Windows(用 vcpkg 装依赖)。

它的 matrix(cmake.yml#L55-L108)用 include 方式显式列出了六个组合,每个都带 name:

  • Windows, shared, x64, Ninja(windows-2022,build_shared: ON,vcpkg_triplet: x64-windows,run_tests: true)
  • Windows, shared, x64, VS2022(cmake_generator: 'Visual Studio 17 2022',run_tests: true)
  • Windows, shared, x86(vcpkg_triplet: x86-windows,arch: x86,无 run_tests)
  • Windows, static, x64, Ninja(build_shared: OFF,vcpkg_triplet: x64-windows-static-md,run_tests: true)
  • Linux, shared, with tests(ubuntu-latest,带 extra_config_opts:-DSVN_ENABLE_AUTH_KWALLET=ON -DSVN_ENABLE_AUTH_GNOME_KEYRING=ON,并额外装 libkf5wallet-dev、libsecret-1-dev 等)
  • Mac OS(macos-latest,run_tests: true)

这条 matrix 的信息量很大:它同时覆盖 shared/static、x64/x86、Ninja/VS2022 生成器,以及 Linux/macOS/Windows 三平台,并且专门在 Linux 上打开了 KWallet 与 GNOME Keyring 这两个 shared-only-lib 目标(呼应第 8.2.4 节)。Windows 上依赖用 vcpkg 装:C:\vcpkg\vcpkg.exe install --triplet ${{ matrix.vcpkg_triplet }} apr apr-util serf expat zlib sqlite3,并用 CMAKE_TOOLCHAIN_FILE=C:/vcpkg/scripts/buildsystems/vcpkg.cmake 接入。

关键步骤:先 python ./gen-make.py -t cmake(这一步把 build.conf 翻译成 CMake 能读的产物),再 cmake -B out -DBUILD_SHARED_LIBS=... -DSVN_ENABLE_TESTS=ON -DSVN_ENABLE_RA_SERF=ON -DSVN_TEST_CONFIGURE_FOR_PARALLEL=ON -DSVN_TEST_CHECK_XML_SCHEMA=ON ...,然后 cmake --build out --config Release、cmake --install out --config Release,最后 ctest --output-on-failure --verbose -C Release --parallel 16。矩阵里还额外跑两个实验特性测试:

      - name: Test shelf2
        if: matrix.run_tests
        working-directory: out
        env:
          SVN_EXPERIMENTAL_COMMANDS: shelf2
        run: ctest -R shelf2 --verbose -C Release

      - name: Test shelf3
        if: matrix.run_tests
        working-directory: out
        env:
          SVN_EXPERIMENTAL_COMMANDS: shelf3
        run: ctest -R shelf3 --verbose -C Release

这与 build.conf 的 libsvn_client 导出清单里出现 svn_client_shelf.h/svn_client_shelf2.h、以及 cmdline 目录里存在 shelf_tests.py/shelf2_tests.py 一一对应。"实验性功能"通过环境变量 SVN_EXPERIMENTAL_COMMANDS 门控,只有在 CI 里显式打开时才参与测试。

(3)detect-backport-conflicts.yml——发布自动化的 CI 化

第三个工作流是"backport 冲突检测"的机器人化,文件说明它的用途是 "Configuration for backportbot GitHub Action workflow"(detect-backport-conflicts.yml#L19-L31):

name: backportbot

on:
  push:
    branches: ["1.14.x", "1.15.x"]

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: false

它只在 1.14.x/1.15.x 两个稳定分支被 push 时触发——这正是第 8.4.2 节里 supported_release_lines: ['1.14'](以及正在成为 1.15)在 CI 层面的映射。它的步骤是:

      - name: Install dependencies (Linux, apt-get)
        run: >
          sudo apt-get update && sudo apt-get install subversion
      - name: Obtain tools/dist
        run: svn export https://svn.apache.org/repos/asf/subversion/trunk/tools/dist tools-dist
      - name: Checkout backport branch
        run: svn co https://svn.apache.org/repos/asf/subversion/branches/${{ github.ref_name }} branch-wc
      - name: Detect conflicting backports
        run: cd branch-wc && ../tools-dist/detect-conflicting-backports.py

值得注意的是 svn export .../trunk/tools/dist:CI 里的检测脚本是从 ASF 的 trunk 仓库实时导出的,而不是从 GitHub 上这份源码树里取。这说明 Subversion 的"权威仓库"历史上是 SVN 服务器(svn.apache.org/repos/asf/subversion/),GitHub 更多是镜像/协作面——一个用 Subversion 管理自己源码的项目,在 CI 里仍用 svn export 去取工具,这个细节本身就是"吃自己的狗粮"的具体形态。同时它需要本机的 subversion 客户端(apt-get install subversion),所以这个 workflow 实际上在用一份发布版的 Subversion 去验证另一份开发中的 Subversion 的发布流程。

把三者和"双构建路径"对上

至此,"双构建路径"在 CI 里的映射就很清楚了:

CI 工作流对应构建路径覆盖重点
autoconf.ymlautoconf(autogen.sh + configure + make)Linux ×{x64, arm64}、httpd ×{system, trunk, 2.4.x}、check/davautocheck
cmake.ymlCMake(gen-make.py -t cmake + cmake + ctest)Windows(shared/static, x64/x86, Ninja/VS2022)、Linux、macOS
detect-backport-conflicts.yml(不构建)发布自动化稳定分支的 backport 冲突检测

两条构建路径都跑测试,而且都跑真实测试(autoconf.yml 跑 make check/davautocheck,cmake.yml 跑 ctest),这正是"双构建"的代价与收益并存之处:收益是同一个 bug 会在两条路径下各暴露一次(例如 Windows 上的链接差异、CMake 与 Makefile 的选项差异);代价是每次改动都要在两条路径上维护两套逻辑一致的行为。1.15 的 CMake 路径是"新增能力",但随之而来的是一条永久性的维护义务——这与第 8.2 节的结论完全一致:引入第二个构建系统不是一次性工程,而是长期成本。


8.5 兼容性三承诺与它的代价

8.5.1 三条承诺是什么

Subversion 官方宣称的兼容性承诺有三条,都可以在源码里找到对应的实现机制:

  1. API/ABI 兼容:已有的公开函数签名、结构体布局、符号不改变;新增能力通过新增函数(用数字后缀)而不是改签名来实现。
  2. 前后向兼容(wire/协议):新客户端能连旧服务器(能力降级),旧客户端尽量能连新服务器(遇到不认识的能力时报错或降级)。
  3. 格式兼容:仓库格式(FSFS)只增不减——新版能读所有旧格式;工作副本格式支持上一代(N-1)。

这三条不是口号:它们各自有具体、可读的实现证据。

8.5.2 API/ABI 承诺:数字后缀与 deprecated 函数

最直观的证据是 svn_client.h 里的函数家族。本次 Grep 实证了同一功能的多代并存(svn_client.h):

/* svn_client.h#L1273 / L1295 / L1316 / L1336 */
svn_client_checkout4(...);   /* 最新 */
svn_client_checkout3(...);
svn_client_checkout2(...);
svn_client_checkout(...);    /* 最老,无后缀 */

/* svn_client.h#L1425 / L1446 / L1467 / L1484 */
svn_client_update4(...);
svn_client_update3(...);
svn_client_update2(...);
svn_client_update(...);

/* svn_client.h#L2657 / L2683 / L2707 / L2730 / L2755 / L2780 */
svn_client_status6(...);
svn_client_status5(...);
svn_client_status4(...);
svn_client_status3(...);
svn_client_status2(...);
svn_client_status(...);

checkout 4 代、update 4 代、status 6 代。 这就是"API 兼容"的字面代价:每加一个参数(如给 status6 增加过滤选项),旧函数必须原样保留,只能新增更高编号的兄弟函数——svn_client_status 家族是一份诚实的"接口演进化石记录"。旧版本通常被实现为"填好默认参数、转调新版本"的薄封装,源码里因此同时存在"当代实现"与"历史外壳"。

这种外壳的规模可以通过一个数字感知:本次对 svn_client.h 统计 @deprecated 标记,得到 162 处。这里的 @deprecated 注释通常是这样的(svn_client.h#L98):* @deprecated Provided for backward compatibility with the 1.3 API. 也就是说,仅一个头文件里就有 162 个函数/类型/字段被标记为"为了兼容而保留"。它们不是死代码,而是活的、必须正确工作的兼容层。

8.5.3 前后向兼容:能力协商与降级

"新客户端能连旧服务器"靠的是能力协商:客户端与服务器在握手时交换各自支持的特性,只有双方都支持才启用高级能力。这套机制在 svn_dav.h 里体现为一组头部与选项常量:

/* svn_dav.h 摘录 */
#define SVN_DAV_OPTIONS_HEADER   "X-SVN-Options"      /* L71 */
#define SVN_DAV_OPTION_NO_MERGE_RESPONSE "no-merge-response"  /* L78 */
#define SVN_DAV_OPTION_LOCK_BREAK        "lock-break"         /* L79 */
#define SVN_DAV_OPTION_LOCK_STEAL        "lock-steal"         /* L80 */
#define SVN_DAV_OPTION_RELEASE_LOCKS     "release-locks"      /* L81 */
#define SVN_DAV_OPTION_KEEP_LOCKS        "keep-locks"         /* L82 */
#define SVN_DAV_SUPPORTED_POSTS_HEADER "SVN-Supported-Posts"  /* L193 */
#define SVN_DAV_ALLOW_BULK_UPDATES "SVN-Allow-Bulk-Updates"   /* L199 */
#define SVN_DAV_REPOSITORY_MERGEINFO "SVN-Repository-MergeInfo" /* L206 */

这些常量构成了一张"能力广播/请求"表:客户端可以用 X-SVN-Options 请求诸如 lock-break/lock-steal 这类行为;服务器用 SVN-Supported-Posts 告诉客户端支持哪些 HTTPv2 POST 类型;SVN-Allow-Bulk-Updates 与 SVN-Repository-MergeInfo 则是服务器侧的"我能不能做批量更新/仓库级 mergeinfo"的声明。

注意 SVN_DAV_OPTIONS_HEADER 与 SVN_DELTA_COMPRESSION_LEVEL_* 是同一类设计:能力被拆成一个个可协商的开关,而不是"新客户端一律用新协议"。只有这样,一台跑了旧 Subversion 的服务器才能被新客户端正确对待——新客户端会看到服务器没有声明某能力,从而退回到旧路径。这是"渐进增强、优雅降级"在协议层面的实现。

wire 格式层面还有一个具体的"很像能力协商"的例子:svndiff2(第 8.1.4 节)。它是 1.10 才有的,只有在 FSFS 格式 >= 8 且双方协商通过时才使用,否则退回 svndiff1 或 svndiff0。

8.5.4 格式兼容:只增不减与 N-1

格式承诺有两个方向。仓库方向:FSFS 格式只增不减。 structure 的 "Format 1, understood by Subversion 1.1+ ... Format 8, understood by Subversion 1.10" 表就是这份承诺:新版 Subversion 永远能读格式 1;旧版 Subversion 读到不认识的更高格式会明确报错(文档在 structure#L141-L142 写 "Clients should raise an error if they encounter an option not permitted by the format number in use.")。

工作副本方向:支持 N-1。 wc.h 的三个宏正是这份承诺的编码:

#define SVN_WC__VERSION           32   /* 能升到的最高格式 */
#define SVN_WC__SUPPORTED_VERSION 31   /* 仍能读的最低格式 = N-1 */
#define SVN_WC__DEFAULT_VERSION   SVN_WC__SUPPORTED_VERSION

它带来的用户可见行为,可以从 svn_fs.h/svn_config.h 与 svnadmin.c 的常量里读到:

/* svn_fs.h#L215 */
#define SVN_FS_CONFIG_COMPATIBLE_VERSION  "compatible-version"
/* svn_config.h#L175 */
#define SVN_CONFIG_OPTION_COMPATIBLE_VERSION "compatible-version"

svnadmin.c 里既能读到 --compatible-version,也能读到一个历史现场:几个旧选项的说明是 N_("deprecated; see --compatible-version")(svnadmin.c#L248-L254)。也就是说,"指定兼容版本"这件事本身也经历了一次接口收敛:从若干专用开关(如历史上的 --pre-1.4-compatible 之类)统一成一个 --compatible-version,但旧开关仍被保留为"deprecated 别名"。用户在某些错误路径里会看到这样的提示(来自 zh_CN.po 里的翻译条目):"要升级到其他版本,可用 '--compatible-version=1.15'"——即客户端会告诉用户"这个工作副本的格式高于我,如果你想用我这个客户端,请用 --compatible-version 把它降到你支持的版本"。这就是 N-1 与 --compatible-version 的联合语义。

1.15 的宏侧还新增了函数来暴露这套能力(svn_client.h#L4464-L4496):

/* svn_client.h#L4469-L4475,@since New in 1.15 */
svn_error_t *
svn_client_default_wc_version(const svn_version_t **version_p,
                              svn_client_ctx_t *ctx,
                              apr_pool_t *result_pool,
                              apr_pool_t *scratch_pool);

/* svn_client.h#L4496 */
svn_client_get_wc_formats_supported(apr_pool_t *result_pool);

wc.h 的注释(wc.h#L183-L186)甚至特意提醒:"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!"——一个宏的改动,被明确要求同步到三处实现(两处库函数 + 一处测试框架)。这是"格式版本不是普通常量"的最佳注脚:它是散布在库、CLI 与测试三层里的共享契约。

8.5.5 代价清单:三承诺长的尾巴

把上面的机制汇总,三承诺的"成本"可以列成一张具体的清单:

  • deprecated 函数:svn_client.h 单文件 162 处 @deprecated;checkout 4 代、update 4 代、status 6 代。每一个 deprecated 函数都必须(a)继续编译、(b)继续正确工作、(c)在文档里保留。这是纯粹为兼容支付的代码体积与理解成本。
  • 迁移 #ifdef:从 BDB 到 FSFS、从 entries 到 wc.db、从 neon 到 serf、从 Python 2 到 Python 3,每一次迁移都在代码里留下条件分支。run_tests.py/list_tests.py 里 sys.version_info < (3, 5) 的分支、release.py 里 urllib.request/urllib2 的二选一,都是这类残留。
  • 双构建系统:build.conf 一份声明要同时喂给 gen_make.py(Makefile)、gen_cmake.py(CMake)、gen_vcnet_vcproj.py(MSVC),三条路径都要在 CI 里跑(第 8.4.5 节)。引入 CMake 后,维护负担从"两份"(Makefile + MSVC)变成"三份"。
  • 能力协商代码:为了前后向兼容,客户端与服务器都要实现"探测对方能力并降级"的逻辑,这部分代码在正常路径上几乎不被执行,却必须一直正确。
  • 格式兼容代码:FSFS 要同时能读格式 1 到 8;WC 要同时能读格式 31 与 32。每加一个格式,读取路径的条件分支就多一层。structure 文档里那句 "Delta representation: Format 1: svndiff0 only / Formats 2-7: svndiff0 or svndiff1 / Formats 8: svndiff0, svndiff1 or svndiff2" 就是这条负担的缩影。

一句话:兼容性不是免费的,它是一份"永久工程债务",以代码分支、测试矩阵与文档维护的形式持续付息。Subversion 选择承担它,因为它服务的是"仓库寿命以十年计"的用户;但也正因为这份债务,它每一次加特性都要比"从零设计的系统"更谨慎。


8.6 历史包袱:诚实地看几处"当年正确、今天昂贵"的决定

上一节讲的是"承诺的代价",这一节讲的是"具体几处结构性的历史包袱"。它们当年几乎都是正确的决定,只是在时间尺度上暴露了成本。我按"看得到的源码证据"逐条列,并用 CHANGES 与前文读到的 r 号串联。

8.6.1 BDB(libsvn_fs_base)的现状

Berkeley DB 后端是 Subversion 最早的文件系统实现。它在仓内的痕迹非常清晰:build.conf 里有一个独立的 [libsvn_fs_base] 目标,type = fs-module、sources = *.c bdb/*.c util/*.c、install = bdb-lib、libs = ... bdb ...;源码目录 subversion/libsvn_fs_base/ 里仍然完整地躺着 fs.c、dag.c、reps-strings.c、trail.c、revs-txns.c、key-gen.c、lock.c、id.c、uuid.c、err.c、node-rev.c 等文件(本次 Glob 实证)。

但三条信号说明它已被事实上"冷藏":(1)build.conf 的 bdb-test-scripts = 是空的(build.conf#L101),BDB 不默认进 make check;(2)1.15 的服务器侧改动几乎全在 fsfs/fsx(CHANGES#L53-L65),无任何 "base:" 条目;(3)两条"Major new features"都与 FSFS/WC 有关,与 BDB 无关。

BDB 仍在被编译、仍独占 bdb-lib 安装区、仍占一份 fs-module 规则,但已不是主线。这份"保留但边缘化"本身也是兼容承诺的一部分——老仓库仍可能用 BDB,不能一刀切删掉;代价是 libsvn_fs 必须继续支持"可插拔后端"抽象,build.conf 必须继续维护 BDB 专属的 sources/libs/install。

8.6.2 混合修订(mixed-revision)与 WC-NG 的关系

工作副本的"混合修订"能力——同一个工作副本里不同目录处于不同修订——是 Subversion 早期的一个决定性设计。它在 WC 格式史上留下了很深的印迹:从格式 1 到 32,每一次 WC 格式跳变几乎都在处理"如何在一个数据结构里同时表达多修订、多操作层级的状态"。wc.h 的历史注释里有几条特别能说明这件事:格式 15 "从 depth='exclude' 改为 presence='exclude'";格式 20 "引入 NODES 并废弃 BASE_NODE/WORKING_NODE,op_depth 恒为 0 或 2"(Bumped in r1005388);格式 23 "为 NODES 引入多层 op_depth 处理"(Bumped in r1044384)。

op_depth 从"0 或 2"成长到"多层",正是"混合修订 + 嵌套操作"复杂度在数据模型上的累积。WC-NG(以格式 12 的 wc.db 为界)用关系数据库来管理这些状态,是一次巨大的成功(见 8.8 节),但它管理的东西本身很复杂——复杂的是"要表达什么",数据库只是把它表达得更清楚。

8.6.3 svn:mergeinfo 的复杂度

合并追踪是 Subversion 最著名、也最被诟病的复杂点。证据散布在好几处:

  • FSFS 结构文档把它提升为仓库格式级别的能力:Mergeinfo metadata: Format 1-2: minfo-here and minfo-count node-revision fields are not stored. svn_fs_get_mergeinfo returns an error. / Format 3+: minfo-here and minfo-count node-revision fields are maintained. svn_fs_get_mergeinfo works.(structure#L185-L189)。也就是说,合并信息不是纯客户端概念,它被持久化进了节点修订。
  • FSFS 节点修订字段里有 minfo-cnt 与 minfo-here 两个专门字段(structure#L577-L579)。
  • mod_dav_svn 有专门的 reports/mergeinfo.c 实现 mergeinfo-report(见 8.6.5)。
  • 客户端侧 cmdline 测试里有 mergeinfo_tests.py。

1.15 还专门修了一处与 mergeinfo 相关的行为:CHANGES 记录 "Warn on merges from foreign repositories if UUIDs match but URLs differ (r1888474 et al, issue #4874)",以及服务器侧的 "fsfs: Reduce spurious conflicts when file content and directory properties are changed in parallel (r1908595)"。这些都说明 mergeinfo 的边界情况仍在被反复打磨。复杂度来源很清楚:svn:mergeinfo 把"合并历史"作为可版本化的属性存进仓库,于是它既要参与合并语义、又要参与 diff/merge 的冲突判定、还要参与服务器的 REPORT——一个信息被三处消费,任何一处不精确都会出问题。

8.6.4 FSFS 的双寻址与"格式爆炸"

第 8.1.3 节已经点出:格式 7 引入逻辑寻址后,FSFS 同时存在 physical 与 logical 两种寻址,pack 文件在两种模式下形态不同(物理寻址用 manifest 记录 ASCII 十进制偏移;逻辑寻址把索引追加在内容后、无 manifest)。再加上 layout 的 linear/sharded <max-files-per-directory> 两种布局,以及"1.7-dev 跳过、从未发布"的格式 5,FSFS 支持的组合是一个不小的网格。

这份"格式爆炸"的成本,最直白地体现在文档的自我说明上:structure 里那张差异表末尾写着 "# Incomplete list. See SVN_FS_FS__MIN__FORMAT"*(structure#L211)——连权威文档都承认自己是一份"不完整列表",并指向源码里的 SVN_FS_FS__MIN_*_FORMAT 宏才是完整真相。这是"格式组合太多、文档追不上实现"的直接坦白。

8.6.5 mod_dav_svn 的十余个自定义 REPORT

mod_dav_svn 的 REPORT 数量是"协议层历史包袱"的集中体现。本次在 dav_svn.h 读到了完整的清单:

/* dav_svn.h#L695-L709 */
static const dav_report_elem dav_svn__reports_list[] = {
  { SVN_XML_NAMESPACE, "update-report" },
  { SVN_XML_NAMESPACE, "log-report" },
  { SVN_XML_NAMESPACE, "dated-rev-report" },
  { SVN_XML_NAMESPACE, "get-locations" },
  { SVN_XML_NAMESPACE, "get-location-segments" },
  { SVN_XML_NAMESPACE, "file-revs-report" },
  { SVN_XML_NAMESPACE, "get-locks-report" },
  { SVN_XML_NAMESPACE, "replay-report" },
  { SVN_XML_NAMESPACE, "get-deleted-rev-report" },
  { SVN_XML_NAMESPACE, SVN_DAV__MERGEINFO_REPORT },
  { SVN_XML_NAMESPACE, SVN_DAV__INHERITED_PROPS_REPORT },
  { SVN_XML_NAMESPACE, "list-report" },
  { NULL, NULL },
};

十二个自定义 REPORT。 它们的实现分散在 reports/ 目录下(log.c、list.c、file-revs.c、replay.c、mergeinfo.c、update.c、inherited-props.c、get-locks.c、get-locations.c、get-location-segments.c、deleted-rev.c、dated-rev.c——本次 Glob 实证),加上 posts/ 目录下的 HTTPv2 POST 处理。

这份清单本身就是"边界与演进"的教科书案例:

  • 为什么有这么多 REPORT?因为 WebDAV/DeltaV 本身没有"拉取日志""查询合并信息""按时间找修订"这些操作,Subversion 只能发明自己的 REPORT 来扩展协议。log-report、dated-rev-report、get-locations、get-location-segments、file-revs-report、get-deleted-rev-report 全都属于这一类"DeltaV 缺口补丁"。
  • 为什么它们都叫 "*-report"?因为 HTTP/WebDAV 的 REPORT 方法要求请求体里带一个 XML 根元素来标识"要执行哪种报告"——REPORT 名就是那个根元素的本地名。dav_svn.h 里还专门注释说 "### should move these report names to a public header to share with the client (and third parties)."(dav_svn.h#L693-L694)——作者自己都知道这份清单应该被抽出成公共头供客户端与第三方共享,但它至今散落在服务端头文件里。这是一处被明确记下的技术债。
  • 其中两个 REPORT 名(mergeinfo-report、inherited-props-report)被抽到了 include/private/svn_dav_protocol.h:
    /* Names for the custom HTTP REPORTs understood by mod_dav_svn, sans ... */
    #define SVN_DAV__MERGEINFO_REPORT       "mergeinfo-report"
    #define SVN_DAV__INHERITED_PROPS_REPORT "inherited-props-report"
    
    ——"部分迁移" 的状态:有人已经动手把两个最常用的 REPORT 名搬到了公共/私有头,但其余的仍在服务端维护。这正是"历史包袱清理到一半"的真实样子。

8.6.6 _checkout3 式 API 与"双构建系统"

最后两处包袱,前面都出现过,这里只做"归位":

  • _checkout3 式 API(第 8.5.2 节):svn_client.h 里 162 处 @deprecated、status 六代同堂。这是一种有意为之的兼容债务,好处是"老代码永远能编过",坏处是"读者永远要判断该用哪一代"。
  • 双构建系统(第 8.2.6、8.4.5 节):build.conf 要同时被 gen_make.py 与 gen_cmake.py 正确理解;CI 里两条路径都跑。这两条包袱有一个共同点:都是"新增一条平行路径"而非"替换旧路径"。Subversion 处理演进的一贯风格就是"加新的,不删旧的"——在 API 上是数字后缀,在构建上是并存路径,在格式上是"只增不减"。这份风格带来了极强的稳定性,也带来了持续增长的维护面。

把它们串起来看,CHANGES 就是一串"在还这些债"的记录:r1897443(不再把测试编号固化进库)、r1899944/r1925713(测试脚本停用 Python 2)、r1936209/r1936226/r1936310(删除 wc-format.py、build-svn-deps-win.pl、backport.pl)、r1936775(删除 ctypes 绑定)、r1937667(删除 get-deps.sh)。这是一份"1.15 在做的减法清单",它的存在恰恰反证了:包袱是被项目自己清楚认识的,清理是主动的,只是清理的节奏必然是缓慢的——因为每条减法都要先确认"没有用户在依赖它"。


8.7 工程纪律:tools/dev 与 tools/server-side

一个项目的"工程成熟度",往往不在于它有多少功能,而在于它为保持代码质量准备了哪些自动化的、可重复执行的纪律脚本。Subversion 把这类脚本集中在 tools/dev/ 下,本次 Glob 与 Read 实证了其中一批的作用。

8.7.1 tools/dev/ 的纪律脚本

  • check-license.py——检查每个文件是否带有正确的许可证块(check-license.py#L1-L17):"check if a file has the proper license in it ... If the license cannot be found, then the filename is printed to stdout." 它支持 -C 开关"修改许可证",且代码里有一句很坦诚的注释:"Note: Right now, OLD_LICENSE and NEW_LICENSE are the same, because r878444 updated all the license blocks."(check-license.py#L21-L23)——一次历史性批量改写(r878444)被永久留在了工具里,这样将来若再改许可证,只需动 NEW_LICENSE。这是"工具承载历史"的典型。
  • find-bad-style.py——查找"函数调用左括号前误加空白"的坏风格(find-bad-style.py#L23-L30):
    # Find places in our code where whitespace is erroneously used before
    # the open-paren on a function all. This is typically manifested like:
    #
    #   return svn_some_function
    #     (param1, param2, param3)
    # USAGE: find-bad-style.py FILE1 FILE2 ...
    
    它的实现只有两条正则:re_call = re.compile(r'^\s*\(') 与 re_func = re.compile(r'.*[a-z0-9_]{1,}\s*$')(find-bad-style.py#L36-L37)——用最小的规则守住一条格式约定。
  • min-includes.sh——估算每份头文件的"最小包含集"(min-includes.sh#L23-L34):"Attempt to figure out the minimum set of includes for our header files." 它把头文件按"无依赖"到"依赖很多"排序,并"逐个头文件试编译以确认它有足够的包含"。脚本自己还诚实地标注了局限:"### this is incomplete" 与 "Of course, this could be false-positive because another header ... has further included something"。一个脚本愿意在注释里承认自己会误报,本身就是高质量工程纪律的一部分。
  • find-unmoved-deprecated.sh——找出被标为 SVN_DEPRECATED 但尚未被搬进对应 deprecated.c 的函数(find-unmoved-deprecated.sh#L23-L26):
    # Find functions marked a SVN_DEPRECATED, but which have not been moved
    # to their associated deprecated.c file.
    # Run this from within the subversion/include/ directory.
    
    这条纪律直接对应第 8.5.2 节的兼容债务:deprecated 函数应当被集中隔离到 deprecated.c,而不是散布在主线实现里。这个脚本就是"隔离工作是否完成"的验收器。
  • warn-ignored-err.sh——给"返回 Subversion 错误但返回值可能被忽略"的函数插/删 GCC 属性 warn_unused_result(warn-ignored-err.sh#L22-L28):
    Usage: $0 [--remove] [FILE...]
    Insert or remove the GCC attribute "warn_unused_result" on each function
    that returns a Subversion error, in the specified files or, by default,
    *.h and *.c in the ./subversion and ./tools trees.
    
    这是一个非常 Subversion 的问题:它的 API 普遍返回 svn_error_t *,而"忘记检查错误"是这类代码最典型的 bug。用 warn_unused_result 把这个错误变成编译期警告,是把"约定"编码进编译器。
  • lock-check.py——对某个仓库获取排他锁,并检查锁计数是否累积(lock-check.py#L47-L56):
    Usage: lock-check.py [OPTIONS] REPOS-PATH
    Options:
      --help (-h)    : Show this usage message
      --non-blocking : Don't wait for a lock that can't be immediately obtained
    Obtain an exclusive lock ... then check its lock usage counts.  If there is
    any accumulation present, report that accumulation to stdout.
    
    "锁计数累积"是一个并发正确性的信号——如果每次读写都留下未释放的计数,长期运行就会出问题。这个脚本把这种隐性缺陷暴露出来。
  • datecheck.py——调试用:检查仓库里修订的日期顺序(datecheck.py#L23-L38):
    ### This is a debugging script to test date-ordering in an SVN repository.
    '''Tell which revisions are out of order w.r.t. date in a repository.
    Takes "svn log -q -r1:HEAD" output ...
          r47           OK  2003-06-10 15:19:47 -0500
          r48       NOT OK  2003-06-02 23:33:13 -0500
    
    它输出 OK/NOT OK——因为回溯(如 svn blame/svn log 的日期过滤)依赖修订时间单调,一旦顺序乱了,功能就会出错。
  • which-error.py——把数值错误码翻译成语义名字(which-error.py#L4-L5):"Print semantic Subversion error code names mapped from their numeric error code values"。它在调试时极其有用——用户报的是数值码,开发者要看的是名字。
  • mklog.py——从 diff 生成提交日志模板(mklog.py#L23-L28):"Read a diff from stdin, and output a log message template to stdout. Hint: It helps if the diff was generated using 'svn diff -x -p'"。它按 Index: 行分节、按 diff 的 @@ ... @@ funcname 输出 (funcname): 小标题。脚本还自我警告 "Don't completely trust the generated log message."——一个"自动生成但仍需人审"的辅助工具,边界划得很清。

除上述脚本,tools/dev/ 下还有本次 Glob 实证的 find-control-statements.py、verify-history.py、random-commits.py、scramble-tree.py、trails.py、merge-graph.py、benchmarks/ 等,以及 C 工具 hash-test.c、fsfs-access-map.c、x509-parser.c。共同点是:每个脚本解决一个具体的、可重复的工程问题。

8.7.2 tools/server-side/

服务端工具在 tools/server-side/ 下,本次 Glob 实证包括:

  • svnauthz.c——检查 authz 文件规则的命令行工具(对应 subversion/tests/cmdline/svnauthz_tests.py 的测试覆盖)。
  • svn-backup-dumps.py——用 svnadmin dump 做增量备份的脚本。
  • fsfs-reshard.py——调整 FSFS 仓库的分片布局(对应 structure 文档里的 sharded <max-files-per-directory> 布局)。
  • svnpubsub/——一个完整的发布订阅守护进程组件集,包含 svnpubsub/(client.py、server.py、util.py)、svnwcsub.py、watcher.py、commit-hook.py、revprop-change-hook.py、daemonize.py、irkerbridge.py、svntweet.py、svnpubsub.tac、testserver.py,以及一组 init 脚本 rc.d/(svnpubsub.debian、svnpubsub.freebsd、svnpubsub.solaris、svnwcsub.* 等)与 svnwcsub.conf.example、README.txt。
  • 其余:svn-populate-node-origins-index.c、svn-server-log-parse/svn_server_log_parse.py、svnpredumpfilter.py、test_svn_server_log_parse.py、mod_dontdothat/(mod_dontdothat.c + README)。

svnpubsub 的存在本身说明了一件事:Subversion 的仓库不只是给"开发者提交"的,它还是"把提交广播出去"的基础设施。commit-hook.py 是服务端钩子,svnwcsub.py 维护只读镜像工作副本,watcher.py 监听变更,server.py 对外发布——这是一套"提交即发布"的流水线。它与 tools/dist/ 的发布脚本一起,构成了 Subversion 作为"发布平台"的两条腿。


8.8 设计遗产:从 Subversion 学到的"正向"经验

前面几节讲的都是成本与包袱。但要公正,Subversion 也留下了一批值得被后来者借鉴的正向设计:

  1. 编辑器抽象(delta editor):用"编辑器调用序列"描述树的变化,把"计算差异"与"应用差异"解耦。libsvn_delta 因此能同时服务提交、更新、导出、重放(replay)等场景——mod_dav_svn 的 replay-report 就是它的直接产物。
  2. 常量内存的流式处理:svn_txdelta_* 与 svndiff 的窗口化设计让大文件差异按窗口流式计算,不必整文件进内存。1.15 的 "Optimize checkout, update and export by simultaneously streaming data to both pristine and working files (r1886490 et al)" 是这条思路的延续。
  3. 能力协商与优雅降级(第 8.5.3 节):把新特性表达为可协商开关,使新旧客户端/服务器安全共存,这是二十多年 wire 兼容的核心机制。
  4. WC-NG 的关系数据库化:WC 格式 12 从 entries 切到 SQLite 的 wc.db,格式 19 统一为"每工作副本单一 DB"(Bumped in r991236)。把复杂状态交给关系数据库与 SQL 管理,是一次巨大成功。
  5. 池与 result/scratch 约定:函数普遍区分 result_pool 与 scratch_pool,明确"返回值生命周期"与"临时分配",把内存管理从手工 free 变成作用域化(-DAPR_POOL_DEBUG 就是为验证这套约定而生)。
  6. 内容寻址与去重:FSFS 的 rep-cache.db(structure#L115-L122)以 SHA-1 做表示共享键;WC 侧的 .svn/pristine 以 SHA1 命名存储 pristine 内容。"内容哈希寻址"在文件系统与工作副本两侧都被实践(与后来的 Git 相通)。
  7. 测试契约的统一(第 8.3 节):用一条输出协议把不同语言的测试统一进同一套统计,轻量且对扩展友好。

这些遗产的共同点是:把复杂度收进一个清晰的抽象,而不是摊给使用者。Subversion 的问题从来不是"没有抽象",而是"抽象要同时喂饱太多历史"。


8.9 与分布式 VCS(Git)的取舍

把 Subversion 与 Git 对比,最容易被写成"价值观之争"。这一节只讲落在源码证据上的取舍:

  1. 全局修订号 vs 内容寻址。 Subversion 的修订号是整仓库的线性序号(FSFS 的 current 文件在格式 3+ 里就是 <youngest-revision>\n,structure#L73-L83),一个整数代表整个仓库快照,体验直观(r1234),代价是必须由服务器集中分配。而内容寻址在 FSFS 里用于另一件事:rep-cache.db 用 SHA-1 做表示共享的键(structure#L115-L122),WC 的 pristine 以 SHA1 命名。Subversion 是"修订号 + 内容哈希"并用——序号管拓扑,哈希管去重,比"非此即彼"更精确。
  2. 目录版本化与属性一等公民。 FSFS 把目录也当作版本化对象(node-rev type 取 file/dir,目录内容以 hash dump 存于文本表示,structure#L553-L562);svn:mergeinfo、svn:keywords 等属性被持久化为节点修订的 props 字段(structure#L563-L573)。"对目录本身做修改"与"属性是版本化对象"因此都是一等操作。
  3. 原子提交。 提交被 write-lock 串行化,新 current 被原子替换后新修订才可见(structure#L84-L92)。
  4. 集中式模型本身。 "权威副本在服务器"意味着权限、hook、authz 都能在服务端统一做。

取舍的落点:Subversion 选择了"直觉可读的全局修订号 + 服务端集中控制",代价是"离线能力弱、分支成本高";Git 选择了"内容寻址 + 分布式",代价是"哈希不直观、权限难集中"。两者没有高下,只有"服务的工作流是什么"。值得注意的是,1.15 做的 pristineless、multi-wc-format、内容哈希判修改(r1892645)是在集中式模型内吸收分布式经验,而不是改成分布式——它没有试图变成 Git,它在试图把自己做得更好。


8.10 1.15 CHANGES 归类

最后,把 CHANGES 顶部(1–130 行,对应 Version 1.15.0)按"构建 / 测试 / 功能 / 客户端 / 服务端 / 工具"归类,逐条给出关键 r 号,作为本章的"索引式"收束。

A. 构建(Developer-visible / General)

  • Support building using CMake(r1920717 et al)——本章 8.2.6/8.4.5 的主线。
  • Support building for Windows on ARM64(r1890668, r1890673)。
  • Support Visual Studio 2022 project generation(r1899028)。
  • Enable multi-processor compilation (/MP) on Windows(r1906011)。
  • Support shared RA modules on Windows with CMake(r1921006 et al)。
  • Remove the get-deps.sh script(r1937667);Remove the use of deprecated autoconf macros(r1908543);Replace obsolete AC_HELP_STRING construct in configure.ac(r1934466)。
  • Support generation of .clangd with '--enable-dot-clangd'(r1925652);Support C++11 and later(r1883715);Allow selecting C++ standard with '--enable-c++'(r1883715)。
  • Fix 'make clean' in out-of-tree builds(r1883939);Default AR_FLAGS to 'cr'(r1884094);Fix building against static Serf on Windows(r1888560);Fix building with zlib 1.3.2+(r1934466);Fix finding libexpat on static library builds(r1888565)。
  • Don't use full path in generated config_keys.inc(r1936038, r1936039);Use snprintf() instead of sprintf()(r1934788 et al);Silence GCC warnings with const string pointers(r1932158)。
  • Require --with-junit when configuring with --enable-javahl(r1936939)。

B. 测试

  • Drop support for Python 2.x in test scripts(r1899944, r1925713)。
  • Support running Valgrind during 'make check'(r1908546)。
  • tests: Stop encoding a test's number in the svntest library(r1897443);Verify command XML output(r1925717, r1935776);Allow testing ra_serf using Digest authentication(r1926790);Fix an error in 'davautocheck USE_SSL=1'(r1931266);Avoid DNS reverse name lookups during configuration(r1933475);Close file descriptor using the context manager(r1884279);Fix TypeError in some test cases(r1897708);Use 2048-bit RSA for the server certificate and key(r1931265)。

C. 功能(Major / Minor new features)

  • New 'pristineless' mode for working copies(r1905955, issue #525)。
  • Support for multiple working copy formats(r1898187, issue #4883)。
  • Re-enable plaintext credential store by default on Unix(r1909351)。
  • Optimize checkout, update and export by simultaneously streaming data to both pristine and working files(r1886490 et al)。

D. 客户端

  • Disable best-fit Unicode mapping for Windows command-line arguments(r1927953)。
  • Optimize checkout/update/export on Windows by reducing file reopens(r1886490 et al)。
  • Use a cryptographic checksum comparison to determine if a file has been modified in a WC(r1892645)。
  • Warn on merges from foreign repositories if UUIDs match but URLs differ(r1888474 et al, issue #4874)。
  • Fix assertion on 'svn move [URL] [URL]/subdir'(r1909127, issue #4913)。
  • ra_serf: Fix segfault on unexpected server responses(r1929793);Optimize array filling for inherited properties(r1878084)。
  • svn: Print character encoding in 'svn --version --verbose'(r1926252);Show new macOS versions(r1882127 et al);Document trailing-@ trick(r1910826)。
  • svnlook: Fix handling of negative --limit arguments(r1919535)。
  • svnmucc: 一系列修复(r1925905、r1925907、r1925913、r1926023、r1926034)。
  • Use distinct temp file names for regular and revision property edits(r1885997);Expand ~username directories in tab completion(r1896619, issue #3714);Reimplement macOS Keychain auth provider with SecItem* API(r1926564);Add Windows manifest(r1933257)。

E. 服务端

  • fsfs: Fix uninitialized read in logical addressing(r1880374);Reduce spurious conflicts(r1908595)。
  • fsx: 同上(r1880374, r1934786, r1908595)。
  • mod_dav_svn: Use mod_dav's DAVBasePath setting(r1926683)。
  • svnadmin: Minor optimization in 'svnadmin load'(r1908751)。
  • svnserve: Add SIGTERM/SIGINT handling(r1908547, r1934784)。
  • Fix possible crash when parsing malformed repository dumps(r1936337)。

F. 工具与绑定

  • Remove the wc-format.py script(r1936209);build-svn-deps-win.pl(r1936226);backport.pl(r1936310 et al)。
  • case-insensitive.py / svn_apply_autoprops.py / svn_load_dirs.pl / mailer.py / fsfsverify.py / fsfsfixer / check-mime-type.pl 等一批 Python 工具更新(r1881774、r1911931、r1917382、r1917397、r1917446、r1912002、r1914915、r1917168、r1917169、r1909337、r1886928、r1887170)。
  • Bindings: Remove the ctypes bindings(r1936775);JavaHL 一系列修复(r1886280、r1930973、r1934367、r1933796、r1933877、r1933892、r1936070)。
  • General: Guard experimental Serf logic with SVN__SERF_EXPERIMENTAL(r1930808);Add 'reverse-merge' attribute to the log schema(r1892455);Guard from accessing uninitialized memory in conflict resolver(r1934787)。

归类后的一个观察:1.15 的改动在"构建"与"测试"两侧的密度异常高(CMake、ARM64、VS2022、/MP、Python 3、Valgrind、XML 校验),而"服务端"侧几乎全是 fsfs/fsx 的 bug 修复与 svnserve 信号处理——这符合"一个成熟项目在 1.15 这个阶段的主要工作是把工程底座现代化,而不是加新特性"。两条"Major new features"(pristineless、multi-wc-format)也都偏向"降低开销/增加兼容灵活性",而非"新的用户功能"。这是一条很有信息量的信号:当一个大项目开始大量投入构建与测试基础设施时,通常意味着它在为下一个十年的维护做准备。


8.11 结语

从第 8.1 节的四层版本号,到 8.10 节的 CHANGES 归类,本章试图回答一个比"Subversion 怎么实现"更难的问题:Subversion 这个项目是怎么在二十余年的时间里,一边保持兼容、一边持续演进的?

答案由几块拼成:

  • 版本号是能力坐标系:产品版本、WC 格式(32/31)、FSFS 格式(1–8)、svndiff(0/1/2)四层解耦,各自独立演进,"谁约束谁"是明确的。
  • 一份声明、多条构建路径:build.conf 是中立 IR,gen-make.py + build/generator/ 把它翻译成 Makefile/CMake/vcproj;CMake 是 1.15 的新增,也是新增的长期维护义务。
  • 一条契约养活两套测试:(PASS|XPASS|FAIL|XFAIL|SKIP): argv[0] argv[1]: description 让 C 与 Python 测试共享统计口径;tests.conf 用 fail-stop = true 逼出缓存 bug。
  • 发布是一套有纪律的流水线:release-lines.yaml 管理 recommended/supported/LTS 三种视角与工具链 pin;backport 工具链支撑逐条投票;CI 用 autoconf.yml/cmake.yml 覆盖双构建路径。
  • 兼容有代价,包袱要诚实看:162 处 @deprecated、status 六代同堂、BDB 的边缘化、mergeinfo 的三处消费、FSFS 双寻址、十二个自定义 REPORT、双构建——这些不是缺陷,而是选择长期兼容必然要付的息。
  • 正向遗产值得记住:编辑器抽象、流式常量内存、能力协商、WC-NG 的关系数据库化、池约定、内容寻址去重、统一测试契约。
  • 与 Git 的取舍是清醒的:Subversion 用"修订号 + 内容哈希"两者并用,服务"需要中心权威与线性历史"的工作流;它没有试图变成 Git,而是在自己的模型内做得更好。

如果要用一句话概括 Subversion 的工程性格,那就是:它在每一个分岔口都选择了"让用户的旧东西继续能用",然后用大量严谨的工程基础设施去承受这份选择带来的重量。 这份性格让它的源码读起来总能找到"为什么是这样"的答案——每一个 #ifdef、每一个 _checkout3、每一行 Bumped in rXXXXXX,都是同一条信念留下的痕迹。