凌晨两点,运维群里弹出一条消息:下周机房调整,SVN 服务器要换机器,仓库根目录从原来的/svn挪到/repos,让大家提前把本地代码处理好。这句话看着轻描淡写,落到每个开发头上就是一场小型事故——有人手一抖把工作副本整个复制走,结果 IDEA 里一片红;有人直接在新地址重新检出,本地未提交的改动全没了;还有人做完服务端迁移后发现分支合并关系全乱,mergeinfo 指向一堆不存在的路径。
SVN 的"移库"这个词其实被用得很混乱。它至少包含三件完全不同的事:把版本库从一个服务器搬到另一个服务器(服务端搬运)、把仓库在服务器上的物理位置或对外 URL 换掉(更换根目录)、以及把某个子项目从一个大库里拆出来单独成库(部分迁移)。这三件事在客户端侧的表现完全不同,能用的命令也完全不同。这篇内容就是把这三种场景的操作链路、参数取舍、以及我自己踩过的坑一次性讲清楚,面向的是需要独立完成一次仓库迁移的后端/运维/客户端配置人员,也包括需要在 IDEA、PyCharm、VS Code 里重新对接 SVN 的开发者。不需要你对 SVN 内部机制了如指掌,但需要你敢在命令行里敲svnadmin。
1. 先搞清楚"移库"到底指哪件事:三种场景的边界
很多人一上来就问"SVN 怎么换地址",这个问题没有唯一答案,因为换地址的方式取决于服务端到底改了什么。我见过的翻车案例里,八成以上是场景判断错了,拿 relocate 去解决一个必须重新检出的问题,或者反过来,明明 UUID 没变却老老实实重新检出,白折腾半天还丢了本地改动。
1.1 服务端搬仓库和服务端改路径是两码事
"搬仓库"指的是版本库数据实体的迁移。老机器上的/data/svn/projectA要变成新机器上的/srv/svn/projectA,数据要从一块磁盘挪到另一块磁盘,可能还伴随着操作系统更换、SVN 版本升级、文件系统从 NTFS 换成 ext4。这种迁移必须走svnadmin dump导出再svnadmin load导入,或者用svnadmin hotcopy做整目录物理拷贝。
"改路径"指的是数据实体没动,只是仓库对外暴露的 URL 变了。典型的例子是把 Apache 配置里的SVNParentPath从/var/svn改成/var/www/svnroot,或者服务器换了域名、换了端口、加了 HTTPS。这种情况下仓库的 UUID 一点没变,客户端一条svn relocate就能解决,几秒钟的事。
关键在于:先确认 UUID 有没有变,再决定客户端怎么处理。UUID 是 SVN 用来标识"这是同一个仓库"的唯一字符串,它存在版本库的db/uuid文件里,也会在svn info输出里显示。只要新旧两边的 UUID 一致,客户端就可以放心地 relocate;只要 UUID 变了,relocate 一定会报错,必须重新检出。
1.2 UUID 是 SVN 认亲的唯一凭证
UUID 是一个形如a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d的字符串。它的作用机制其实很简单:客户端每次通过命令行或图形工具执行网络操作时,服务端会在响应的头部里带上自己的 UUID,客户端拿这个 UUID 和自己工作副本.svn/wc.db里记录的 UUID 做比对,不一致就直接拒绝操作。
这个设计的好处是防止你把一个工作副本莫名其妙地指到另一个仓库上去,坏处是让不熟悉机制的人在做迁移时反复撞墙。你执行svn relocate时,客户端会去连新 URL,读回 UUID,如果跟本地记录的不一样,就会抛出这样的错误:
svn: E155024: Invalid relocation destination: 'https://new.example.com/repos/projectA' (does not match 'a1b2c3d4-...')看到这个报错,不要怀疑命令拼错了,就是 UUID 不匹配。此时有两条路:重新检出,或者在服务端把新库的 UUID 手动改成旧库的 UUID(前提是新库确实是旧库的完整副本)。
1.3 一个决策表:什么情况能 relocate,什么情况只能重新检出
把常见的迁移场景整理成一张表,照着判断就行。
| 服务端变化 | UUID 是否变化 | 客户端处理方式 | 本地未提交改动 |
|---|---|---|---|
| 只换域名 / IP / 端口 / 协议 | 不变 | svn relocate | 保留 |
| 只改 Apache 的 SVNParentPath 目录 | 不变 | svn relocate | 保留 |
| 服务器上仓库目录改名,仍用同一份数据 | 不变 | svn relocate | 保留 |
| dump/load 到新库,load 时未改 UUID | 不变 | svn relocate | 保留 |
dump/load 到新库,加了--ignore-uuid | 变了 | 重新检出 | 需要先导出补丁 |
| svndumpfilter 拆子项目成新库 | 变了 | 重新检出 | 需要先导出补丁 |
| 换到 git 之类的其他系统 | 不适用 | 迁移工具转换 | 需要先提交 |
提示:判断 UUID 是否变化,最省事的办法是在服务端执行
svnadmin info /path/to/newrepo,或者直接用浏览器访问https://host/repos/projectA看返回的 XML 里有没有<uuid>节点。企业微信里问一句运维,往往比你自己猜半天快。
2. 动手之前的摸底:这几条信息不问清楚,后面必定返工
我做过大概七八次规模不一的 SVN 迁移,从几百个修订的小库到二十多万个修订、仓库体积上百 GB 的巨型库都有。每次返工的原因都不是技术难,而是前期信息没对齐。下面这几件事必须在动手前完成,哪怕上级催得紧也不要跳过。
2.1 把仓库的家底查清楚
先摸清楚你要搬的东西到底有多大、什么格式、有多少个修订、有多少分支和标签。这几个命令必须跑一遍:
# 仓库基本信息,包含 UUID、修订范围、FSFS 格式版本 svnadmin info /data/svn/projectA # 当前最大修订号 svnlook youngest /data/svn/projectA # 仓库物理体积 du -sh /data/svn/projectA # 顶层目录结构 svnlook tree /data/svn/projectA --non-recursive # 分支和标签有多少 svn ls https://old.example.com/svn/projectA/branches | wc -l svn ls https://old.example.com/svn/projectA/tags | wc -lsvnadmin info在 SVN 1.9 之前没有,老版本可以用cat /data/svn/projectA/db/format看格式版本,用cat /data/svn/projectA/db/uuid看 UUID。FSFS 格式版本决定了你能不能把库 load 到目标机器的 SVN 上——目标 SVN 版本必须支持源库的 FSFS 格式,否则要先在源端用svnadmin upgrade或svnadmin dump --compatible-version处理。
另外要统计的是仓库里有多少个空目录、多少符号链接、多少文件名只差大小写的路径。这三个数据直接决定目标平台能不能装得下。特别是从 Windows/macOS 迁到 Linux,大小写敏感这一项能让 load 直接中断,后面 7.2 节会细讲。
2.2 备份没有"差不多",只有"做过"和"没做"
迁移过程中的任何一步都可能毁掉原库。我没有一次例外地都会先做一份完整备份,哪怕只是改个 Apache 配置。备份方式按场景选:
- 整目录冷备:停掉 svnserve/Apache,
tar czf projectA-$(date +%F).tar.gz /data/svn/projectA。最土也最稳,缺点是停服期间客户端提交会失败。 - hotcopy 热备:
svnadmin hotcopy /data/svn/projectA /backup/projectA-copy --clean-logs。不中断服务,拷出来的是可独立使用的完整仓库,可以直接当恢复源。数据量大时耗时比较久,但值得。 - dump 全量备份:
svnadmin dump /data/svn/projectA > projectA-full.dump。体积通常是原库的 1.5 到 3 倍,优点是可以跨版本、跨平台恢复。 - freeze 配合脚本:SVN 1.9 之后可以用
svnadmin freeze /data/svn/projectA -- tar czf /backup/a.tgz /data/svn/projectA,在冻结期间保证没有并发写入,得到的一致性最好。
注意:备份文件一定要放在另一块物理磁盘或者另一台机器上。我见过把备份和原库放在同一个 LUN 上,结果存储挂了两个一起没的案例。
2.3 新旧环境差异核对表
这一步决定你需要准备哪些额外操作。把下面这张表填完,迁移方案基本就成型了。
| 核对项 | 源环境 | 目标环境 | 影响 |
|---|---|---|---|
| 操作系统 | Windows Server | CentOS | 大小写敏感、路径分隔符、权限模型 |
| SVN 服务端版本 | 1.8.x | 1.14.x | FSFS 格式、dump 格式兼容性 |
| 存储方式 | Berkeley DB | FSFS | BDB 已废弃,必须 dump/load |
| 文件系统 | NTFS | ext4 | 文件名编码、软链接支持 |
| 认证方式 | svnserve + passwd | Apache + LDAP | authz 格式、realm 变化 |
| 客户访问协议 | svn:// | https:// | 客户端需要换 URL 前缀 |
| 是否有 hooks | post-commit 发邮件 | 无 | hooks 不随 dump 走,需手工搬 |
Berkeley DB 这个坑现在很少遇到了,但老库还有。BDB 后端在 1.8 之后已经不再被支持,只能通过svnadmin dump导出再 load 到 FSFS 库里,hotcopy直接拷是没用的。
3. 服务端完整链路:dump、load 与根目录切换
假设你判断下来必须走 dump/load,那这一节就是主干流程。我把每一步的参数选择和理由都写出来,方便你按实际情况裁剪。
3.1 svnadmin dump 的参数怎么选
最基础的导出命令是:
svnadmin dump /data/svn/projectA > /tmp/projectA.dump这条命令会把从修订 0 到最新修订的全部内容按文本格式写出来,包含所有文件的完整内容和所有版本化属性。大仓库的 dump 文件会非常可观,一个 80 GB 的库导出来 200 GB 以上是常事,所以磁盘空间要提前留够。
如果需要减小体积,可以加--deltas:
svnadmin dump /data/svn/projectA --deltas > /tmp/projectA.dump--deltas的作用是让 SVN 输出版本间的增量而不是每个修订的完整快照。这会让文件显著变小,代价是 load 的时候需要重放每一次增量,导入时间会成倍增加。我的经验是:如果磁盘空间够,不加--deltas,load 速度能快好几倍;如果空间紧张或者要跨公网传输,加--deltas。
如果迁移期间不能停机,可以分段增量导出:
# 第一段 svnadmin dump /data/svn/projectA -r 1:100000 --incremental > part1.dump # 停机后补最后一段 svnadmin dump /data/svn/projectA -r 100001:HEAD --incremental > part2.dump--incremental的作用是让 dump 流里只包含指定范围内的修订,而不是每次都以修订 0 开头。不加这个参数分段导出会得到互相冲突的多个完整流。分段导入时按顺序 load 即可,修订号会被完整保留。
跨版本迁移的时候还要考虑--compatible-version:
svnadmin dump /data/svn/projectA --compatible-version 1.8 > projectA.dump这个参数让 dump 输出兼容旧版本 SVN 识别的格式,用在目标机器 SVN 版本比源机器低的情况。反过来的情况(目标版本更高)不需要加,高版本 SVN 能读低版本 dump。
3.2 svnadmin load 与 UUID 的两种处理方式
在新机器上先建空库,再导入:
svnadmin create /srv/svn/projectA svnadmin load /srv/svn/projectA < /tmp/projectA.dump这里的默认行为很多人搞不清楚,值得说清楚:往一个空库里 load,SVN 默认会采用 dump 流里携带的 UUID。也就是说,新库的 UUID 和老库一样,客户端的svn relocate能顺利成功。这通常是大家想要的结果。
如果出于某种原因需要新库有独立的 UUID,就加--ignore-uuid:
svnadmin load /srv/svn/projectA --ignore-uuid < /tmp/projectA.dump这样新库会自己生成一个全新 UUID,客户端必须重新检出。
还有一个反向的参数--force-uuid,用在往一个非空的库里 load 的场景。默认情况下,往已经有数据的库里 load 时 SVN 会忽略 dump 里的 UUID,保留目标库原有的;加--force-uuid会强制用 dump 里的 UUID 覆盖目标库的。
load 过程中可能碰到的两类报错要提前知道怎么处理:
svnadmin: E140001: Invalid revision number ...多半是分段 dump 的时候参数用错了,检查有没有漏加--incremental。
svnadmin: E165005: Invalid svn:author property ...说明历史里存在不合法的修订属性(空作者、非法日期等),可以加--bypass-prop-validation跳过校验。老库这种情况不算罕见,尤其是早年人工修改过svn:date的。
导入完成后立刻做一次校验:
svnadmin verify /srv/svn/projectA这个命令会遍历每一个修订并校验其内部一致性,大库跑起来很慢(几十万修订可能要几个小时),但对迁移来说是必须的,我一般放在后台跑,同时进行其他配置工作。
3.3 让客户端可以直接 relocate 的 setuuid 技巧
有一种场景很常见:你出于某种原因 load 的时候用了--ignore-uuid(比如运维给的脚本里就这么写的),或者走的是svndumpfilter拆分流程,结果新库 UUID 和老库不一样。这时候全公司几十号人要重新检出,成本很高。
如果你能确认新库就是老库的忠实副本,可以用svnadmin setuuid把新库的 UUID 改回老库的:
# 先查老库的 UUID cat /backup/oldprojectA/db/uuid # 假设输出 a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d # 把新库的 UUID 设成一样的 svnadmin setuuid /srv/svn/projectA a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d改完之后svnadmin info /srv/svn/projectA会显示新的 UUID,客户端执行svn relocate就能成功。这个技巧救过我好几次大迁移的时间。
注意:
setuuid的前提是旧库退役,不再对外提供服务。如果新旧两个库同时在线且 UUID 相同,客户端可能会把提交写到不在预期里的那个库上,排查起来非常麻烦。做完这一步之后,务必把旧库的对外访问入口关掉或者改成只读。
3.4 Apache 与 svnserve 配置里跟路径有关的那几行
仓库搬完了,路径要重新暴露。用 Apache + mod_dav_svn 的话,关心的是这两行:
# 单个仓库 SVNPath /srv/svn/projectA # 或者用一个父目录托管多个仓库 SVNParentPath /srv/svn SVNListParentPath OnSVNPath模式下 URL 是https://host/repo-name,跟目录名无关;SVNParentPath模式下 URL 是https://host/<子目录名>,目录名就是 URL 的一部分。这就是为什么在 SVNParentPath 模式下把目录改名会导致所有客户端 URL 失效——比如把/srv/svn下的oldname改成newname,URL 就从https://host/oldname变成了https://host/newname。
用 svnserve 的话,配置在conf/svnserve.conf,路径由启动参数-r指定:
svnserve -d -r /srv/svn --listen-port 3690-r是仓库的虚拟根目录,客户端 URL 形如svn://host/projectA。把-r从/data/svn改成/srv/svn,URL 部分通常不变,客户端不需要做任何事,这点比 Apache 下改目录名要友好。
改完配置记得重启服务并确认:
svn info https://new.example.com/repos/projectA输出里的Repository UUID和Revision两个字段必须和迁移前一致。任何一项对不上就先别通知客户端动手。
3.5 authz 权限文件的路径前缀陷阱
权限文件是迁移时最容易漏掉的一环,因为它不随 dump 走。先把旧的authz拷到新库的conf/authz(svnserve)或 Apache 配置指向的位置,然后逐个检查路径前缀。
authz 的路径写法有两种语境。用SVNPath或者 svnserve 单库模式,路径从仓库根开始写:
[projectA:/trunk] @dev = rw [projectA:/trunk/secret] @dev = r @release = rw用SVNParentPath托管多库时,必须带仓库名做前缀:
[/] * = [projectA:/] @dev = r [projectB:/] @dev = rw如果迁移过程中仓库的目录名变了(比如projectA改成proj-a),那 authz 里所有projectA:前缀都要同步改,否则权限会全部失效变成匿名访问——这是安全问题,不是可用性问题,优先级要提到最高。
另外提醒一句realm设置。svnserve.conf 里的realm字符串决定客户端凭据缓存的分组。如果迁移时顺手把 realm 改成了新名字,客户端会认为这是一个全新的认证域,所有人第一次操作都要重新输密码。想减少打扰就保持 realm 字符串不变。
4. 只搬一个子目录:svndumpfilter 的用法与它埋的雷
历史遗留的大库里经常塞着七八个不相关的项目。迁移时把其中某个项目拆出来独立成库,这种需求太常见了。svndumpfilter就是干这个的,但它有明确的适用边界,用不好会直接让 load 失败。
4.1 include / exclude 的写法与路径前缀
基本用法是从全量 dump 里过滤出指定路径:
svnadmin dump /data/svn/bigrepo > bigrepo.dump svndumpfilter include /projectA --drop-empty-revs --renumber-revs \ < bigrepo.dump > projectA.dump svnadmin create /srv/svn/projectA svnadmin load /srv/svn/projectA < projectA.dump路径必须写仓库根的绝对路径,前面带斜杠,不带仓库名。include /projectA会保留/projectA及其所有子路径的变更,其余全丢。
exclude是反过来用,把不想要的路径剔掉,其余保留:
svndumpfilter exclude /legacy /tmp-export < bigrepo.dump > cleaned.dump实际操作中我更喜欢exclude,因为大库里往往是一两个废弃项目要扔,其余全要。用include反而要枚举所有想留下的路径,容易漏。
4.2 三个关键参数到底做了什么
--drop-empty-revs的作用是:如果某个修订被过滤后变成了空修订(所有变更都被剔掉了),就把它从输出里删掉。不加这个参数,输出里会保留大量内容为空的修订,加载进去以后提交日志看起来会莫名其妙——一堆"空提交"夹杂在历史里。
--renumber-revs只在配合--drop-empty-revs时才有意义,它让保留下来的修订重新编号,从 1 开始连续排。不加的话修订号会保持原来的数字,中间出现空洞(比如 1, 2, 5, 6, 11...)。选哪个取决于团队习惯:如果你们内部文档、CHANGELOG 里有大量引用具体修订号的说明,保持原编号更安全;如果是新拆出来的库,重编号更干净。
--preserve-revprops控制的是修订属性要不要保留。默认情况下,被过滤掉的修订的属主和日志会被清空或者替换成占位文字,加这个参数会尽量保留原始属性。带过来的历史记录如果对审计有意义,一定要加这个参数。代价是如果原始属性里有不合法内容,可能需要在 load 时加--bypass-prop-validation。
4.3 copy 来源被截断导致 load 直接失败
这是 svndumpfilter 最容易翻车的地方。SVN 的分支和标签是通过svn cp这种服务端复制操作产生的,复制是廉价的——创建一个分支只记录"从某个路径的某个修订复制而来",不复制文件内容。
问题来了:如果你过滤掉了/otherproject,但历史上有过一条记录是"从/otherproject/trunk复制到/projectA/branches/xxx",那么这个复制操作的来源路径在 dump 流里就找不到了。load 的时候 SVN 会报:
svnadmin: E160013: File not found: revision 12345, path '/otherproject/trunk'这种报错在过滤的时候检不出来,只有 load 到一半才会爆,非常折磨人。
处理办法有两个。第一个是先用--targets加手工排查,把跨项目复制的路径也一起 include 进来:svndumpfilter include /projectA /otherproject/trunk,虽然不优雅但能跑通。第二个办法更彻底:先用分析工具扫描一遍 dump,找出所有 copyfrom 的来源路径,看有没有落在 include 范围外的。SVN 自带的工具做不了这件事,社区里有一些 Python 脚本可以做,思路是流式读取 dump,解析Node-copyfrom-path:行,收集所有唯一路径再和 include 列表做差集。
做过滤前,我建议先跑一遍这个排查,成本很低但能省掉一次失败的 load。如果没有脚本,也有土办法:把 dump 文件用grep扫一遍来源路径的分布,看有没有跨项目引用:
grep '^Node-copyfrom-path:' bigrepo.dump | sort -u | awk -F': ' '{print $2}' | cut -d/ -f2 | sort | uniq -c | sort -rn输出的第二列是路径的第一个目录名,如果/projectA的 dump 段里大量出现otherproject,那就要小心了。
5. 客户端换地址:relocate、重新检出与批量脚本
服务端搞定,客户端才是真正花时间的地方。一家公司几十个开发,每个人的本地环境都不一样,有命令行党,有小乌龟党,有 IDEA 党,还有 VS Code 加插件的。这一节把各种情况都覆盖一遍。
5.1 TortoiseSVN 图形化 relocate 的完整流程
小乌龟是 Windows 上最主流的客户端。操作路径是:在工作副本的根目录上右键 → TortoiseSVN → Relocate(中文版叫"重定位"),弹出的对话框里会列出当前目录的 URL,直接编辑 URL 文本框把前缀换掉即可。
几个实操要点:
- 一定要在最外层的工作副本根上操作。1.7 之前每个子目录都有
.svn,可以在任意层 relocate;1.7 之后.svn只在根目录,relocate 必须针对根。 - 对话框里 URL 改完之后点 OK,小乌龟会去连新地址读 UUID,比对通过才会真正改。如果 UUID 不匹配会当场报错。
- 有外链(externals)的工作副本,1.8 之后的 relocate 默认会一并处理指向同一仓库的外链。但指向其他仓库的外链不会被改动,需要单独处理。迁移前先用
svn propget svn:externals -R .把所有外链列出来看一眼。 - 如果工作副本里还有未提交的改动,relocate 不会丢失它们,因为本质上只是改了一下
.svn/wc.db里记录的仓库根 URL,工作文件一个字节都没动。
中文语言包的安装很简单:下载对应版本的语言包 msi 安装之后,在 Settings → General → Language 里选中文,重启即可。注意语言包的版本号必须和小乌龟主程序版本严格对应,差一个小版本都可能装不上。
5.2 命令行 relocate 与 switch --relocate 的区别
命令行下现在的标准命令是:
svn relocate https://old.example.com/svn https://new.example.com/repos /path/to/workingcopy三个参数分别是旧 URL 前缀、新 URL 前缀、工作副本路径。如果不给工作副本路径,默认是当前目录。也可以省略旧 URL,只给新 URL:svn relocate https://new.example.com/repos ./wc,SVN 会从工作副本里读旧 URL。
老教程里常见的svn switch --relocate OLD NEW是 1.7 之前的写法,现在虽然还能用但已经不推荐,而且它和svn switch的正常用法混在一起容易误操作——svn switch不带--relocate是做工作副本切换到另一个分支的,语义完全不同,敲错了会直接把你切到别的分支上去。
另外注意svn relocate对 file:// 协议的处理。Windows 上写本地路径要注意格式,正确写法是file:///D:/svn/repo,三个斜杠加盘符。写成file://D:/svn/repo会被解析成主机名是D,直接报错。
5.3 多工作副本批量 relocate 的脚本写法
一个人手上有五六个工作副本很常见,手动一个个改容易漏。给两个版本的脚本。
Windows 批处理,遍历某个目录下的所有工作副本:
@echo off setlocal set OLD=https://old.example.com/svn set NEW=https://new.example.com/repos set ROOT=D:\workspace for /d %%P in ("%ROOT%\*") do ( if exist "%%~fP\.svn" ( echo === %%~fP svn info "%%~fP" | findstr /b "URL:" svn relocate %OLD% %NEW% "%%~fP" ) ) pause类 Unix 环境用 shell:
OLD="https://old.example.com/svn" NEW="https://new.example.com/repos" find /data/workspace -maxdepth 4 -type d -name .svn -printf '%h\n' \ | sort -u \ | while read -r wc; do url=$(svn info --show-item url "$wc" 2>/dev/null) || continue case "$url" in "$OLD"*) echo "relocate -> $wc"; svn relocate "$OLD" "$NEW" "$wc" ;; *) echo "skip $wc ($url)" ;; esac done这段脚本的关键是先svn info拿到当前 URL,确认它确实以旧前缀开头再动手。直接把所有工作副本都 relocate 一遍的风险是:有些副本可能指向完全不同的仓库,硬改会出错。过滤后再改,安全得多。
提示:如果要处理的工作副本里存在嵌套(一个工作副本目录下又嵌了另一个独立工作副本),
find会把两层都列出来。处理顺序上要从外层往里层做,或者先把内层的排除掉。简单办法是加一个判断:如果某个.svn的父目录也在结果列表里,就跳过它。
5.4 externals、嵌套工作副本与凭据缓存
外链的处理要单独说。SVN 的外链有两种写法,绝对 URL 和相对 URL:
# 绝对 URL,迁移时不会自动跟随 https://old.example.com/svn/libs/common common # 相对 URL,以 ^ 开头表示仓库根 ^/libs/common common # 相对 URL,以 ../ 开头表示相对当前目录 ../common common用相对 URL 定义的外链天然免疫仓库迁移,因为它是相对于仓库根解析的,仓库根变了会自动跟着变。用绝对 URL 的就必须手工处理。所以我在建外链时一律推荐相对 URL,这是长期的省事做法。
对于已经存在的绝对 URL 外链,可以批量改写:
# 先看有哪些 svn propget svn:externals -R . # 把某个目录下的外链属性整体替换 svn propset svn:externals \ "^/libs/common common ^/libs/utils utils" \ /path/to/wc/libs改外链属性本身是一次提交,提交后其他人 update 才会生效。
再说凭据缓存。SVN 客户端把认证信息存在用户目录下,Linux 是~/.subversion/auth/,Windows 是%APPDATA%\Subversion\auth\。换了域名之后,新的认证域是全新的,客户端会重新弹密码框。如果换了协议(比如从 svn:// 换到 https://),老的缓存条目会一直留着但用不上。想清理的话直接删掉 auth 目录下的文件即可,或者用svn auth --remove精确删除。
6. IDEA / PyCharm / VS Code 里迁移后必须动的配置
命令行 relocate 成功了,IDE 里还是一片红,这种情况太常见了。原因通常不在 SVN 本身,而在 IDE 的缓存和配置识别。
6.1 vcs.xml 里有什么、没有什么
IntelliJ 系(IDEA、PyCharm、WebStorm)的版本控制配置在.idea/vcs.xml,典型内容长这样:
<?xml version="1.0" encoding="UTF-8"?> <project version="4"> <component name="VcsDirectoryMappings"> <mapping directory="$PROJECT_DIR$" vcs="svn" /> </component> </project>注意这里没有 URL。IDEA 不自己保存仓库地址,它每次都是从工作副本的.svn/wc.db里现读。这意味着只要你把工作副本 relocate 好了,IDEA 理论上应该自动跟上。
但实际不会那么顺。IDEA 有一层文件状态缓存,relocate 之后如果不刷新,侧边栏颜色、变更列表都是旧的。正确的操作顺序是:
- 先在命令行确认
svn info输出的是新 URL。 - 回到 IDEA,菜单 VCS → Refresh File Status(或按 Ctrl+Alt+Y)。
- 如果状态还是不对,File → Invalidate Caches → 勾选 Clear file system cache and Local History → Invalidate and Restart。
Local History 是 IDEA 自己的本地历史,跟 SVN 无关,但它记录的文件路径在项目目录变化后也对不上,所以迁移时一起清掉更干净。
如果你在 PyCharm 里做过整个项目文件夹的搬迁(比如从D:\old\proj挪到E:\new\proj),要特别注意:工作副本的根目录不能随便挪。1.7 以后.svn/wc.db里记录着工作副本的绝对路径,整个目录移动虽然大多数情况下 SVN 能自己适配,但如果移动过程中用工具只拷了部分文件、把隐藏的.svn目录漏掉,工作副本就废了,只能重新检出。
6.2 "version not under control" 的排查顺序
IDEA 里出现的版本控制报错五花八门,最常见的一类是"文件不在版本控制下"或者底层的svn: E155007: 'xxx' is not a working copy。这类问题有个稳定的排查顺序,照着走基本都能定位:
第一步,命令行验证。进到项目根目录执行svn info。如果能正常输出,说明工作副本本身没问题,问题在 IDE 侧;如果报 E155007,说明工作副本已经损坏或被移动过。
第二步,确认.svn的位置和项目根是否一致。IDEA 的项目根必须包含或者等于.svn所在的那一层。如果你在 IDEA 里打开的是D:\proj\src,而.svn在D:\proj,IDEA 通常会向上查找,但如果vcs.xml里的 mapping directory 写死成了$PROJECT_DIR$而项目根又在子目录,就可能识别失败。解决办法是调整项目根,或者在vcs.xml里把 mapping 指向正确的层级。
第三步,检查客户端版本。IDEA 默认使用内置的 SVNKit 库,也可以配置成调用系统的命令行 svn。如果工作副本是新版 SVN(比如 1.14)创建的,而 IDEA 内置的 SVNKit 版本偏老,就会读不懂工作副本格式,报出各种奇怪的错。可以在 Settings → Version Control → Subversion 里把 Use command line client 勾上,指向系统安装的svn.exe,问题往往立刻消失。
第四步,跑一次 cleanup。工作副本被中断的操作打断后会留下锁,svn cleanup能清掉。SVN 1.8 之后还可以加--vacuum-pristines顺便清理原始副本缓存,能省下不少磁盘:
svn cleanup --vacuum-pristines /path/to/wc第五步,实在不行就重新检出。先把本地改动导出成补丁:
svn diff > /tmp/local-changes.patch svn status | grep '^?' > /tmp/unversioned.txt然后删掉工作副本,重新svn checkout,再svn patch /tmp/local-changes.patch把改动贴回来,未版本控制的文件按清单从备份里拷回来。这套流程我走过很多次,比在工作副本里瞎折腾快得多。
6.3 重装系统之后:SVN 客户端、语言包与 IDE 的对接
重装系统是另一个高频触发点。开发机器重装之后,Visual Studio、IDEA、VS Code 全部要重新配 SVN,这时候最容易出的是"命令找不到"和"版本对不上"两类问题。
Windows 上推荐装 TortoiseSVN 并把 command line client tools 一起勾上。安装程序默认不装命令行工具,很多人装完发现svn命令不认,就是因为这个选项没勾。装完之后把安装目录(通常是C:\Program Files\TortoiseSVN\bin)加到 PATH 里。
IDEA 侧的配置在 Settings → Version Control → Subversion,两个关键项:
- Use command line client:填
C:\Program Files\TortoiseSVN\bin\svn.exe。 - 如果想让 IDEA 用内置 SVNKit,把这个勾去掉即可。
VS Code 用 SVN 标记文件需要装扩展(常见的是 "SVN" 这个扩展),装完之后在设置里配svn.path指向svn.exe。扩展的工作方式是定期跑svn status再给资源管理器加标记,仓库大了会有点卡,可以把刷新间隔调大一些。
Visual Studio 自带的 SVN 支持很有限,通常需要装 AnkhSVN 之类的插件,或者干脆用 TortoiseSVN 在资源管理器里操作。VS 新版本对第三方插件接口有变化,装之前确认插件支持的 VS 版本。
7. dump/load 搬不走的东西:逐项补齐
dump/load 能带走的是版本化数据:文件内容、目录结构、版本化属性、修订属性、修订号。带不走的东西列出来会有点长,这一节逐项过一遍。
7.1 hooks 脚本与它的换行符、执行权限
仓库根目录下的hooks/目录完全不参与 dump。post-commit 发通知邮件、pre-commit 做提交信息校验、pre-revprop-change 允许修改日志——这些脚本都要手工拷到新库的hooks/下,并且要处理两件事。
第一件是换行符。Windows 上写出来的.bat或.sh脚本如果带 CRLF,放到 Linux 上执行会报bad interpreter: /bin/sh^M。用dos2unix转一下,或者在编辑器里显式设置成 LF。
第二件是执行权限。拷过去的脚本默认可执行位是关的,chmod +x hooks/post-commit一下。这个错很隐蔽,提交成功了但邮件没发出去,很容易被忽略。
还有一个容易忘的:hooks 脚本里往往硬编码了旧服务器的路径、svnlook 的绝对路径、SMTP 配置、内部 API 地址。搬过去之后逐行看一眼,特别是$REPOS和$REV这两个变量是怎么用的。
7.2 大小写敏感、文件名编码与中文路径
这是从 Windows/macOS 迁到 Linux 时最容易炸的一类问题。Windows 的 NTFS 默认大小写不敏感,Readme.txt和readme.txt在同一个目录下是同一个文件;而 Linux 的 ext4 是区分大小写的,两个名字是不同文件。
如果历史里出现过这种情况(通常是开发者在 Windows 上把文件改了个大小写然后提交),dump 文件里就会包含两个只差大小写的路径,load 到 Linux 上的 FSFS 库里会直接失败:
svnadmin: E000002: Can't open file '.../revs/0/xx': ... svnadmin: E160020: File already exists: filesystem 'xxx', transaction ...解决办法按代价从低到高排列:
- load 到大小写不敏感的文件系统上。比如先在 Windows 机器上 load,或者用 macOS 的默认 APFS 配置,再把整个仓库目录同步过去——但这样治标不治本,后面在 Linux 上用还是会出问题。
- 提前在源库里修掉重名。在旧库上把冲突的文件重命名(加个后缀区分),提交成一个新修订,然后再 dump。这是最干净的做法,但需要提前扫描出所有冲突路径。
- 修改 dump 文件。dump 是文本格式,可以在文本层面把冲突路径替换掉,但涉及长度字段和校验,手工改很容易改坏,不推荐。
中文文件名的问题相对少一些,SVN 内部用 UTF-8 存储路径,正常情况下不会有问题。真正会出问题的是某些老工具生成的、编码混乱的文件名,load 之后在 Linux 上可能显示成乱码。这类问题基本只能个案处理。
7.3 行尾符、时间戳与构建产物
svn:eol-style属性如果设置成native,checkout 的时候 SVN 会把文件的行尾符转换成本地平台的默认值。Windows 上是 CRLF,Linux 上是 LF。这意味着从 Windows 迁到 Linux 之后,同一个文件在本地的工作副本内容会变,svn diff会显示整文件都是改动。
这本身不是迁移引入的问题,而是native属性的设计行为。但只要团队里有人的编辑器配置不一致,就会出现"明明没改代码,diff 却满屏红"的情况。处理办法是统一约定:要么所有文本文件都显式设一个固定的svn:eol-style(比如LF),要么在客户端配置里把 diff 忽略行尾差异。
时间戳的问题跟构建工具有关。checkout 会把文件的修改时间设成检出那一刻,make这类依赖时间戳的工具需要重新构建一次。如果迁移之后 CI 报出诡异的增量构建错误,先考虑清理构建缓存重来一次,别急着怀疑 SVN。
7.4 mergeinfo 与历史合并关系
svn:mergeinfo是版本化属性,会随 dump/load 一起过来,内容是一串形如/branches/feature-x:1000-1050的记录,指向具体路径和修订范围。
整库迁移时这没问题,因为分支结构和修订号都被完整保留了。但走svndumpfilter拆库的时候,mergeinfo 里指向被过滤掉的路径的记录就成了悬空引用。后续做合并操作时,SVN 可能报"路径不存在"或者合并结果不符合预期。
处理办法是拆库之后在新库里跑一次 mergeinfo 清理,把指向不存在路径的记录删掉。这个操作需要逐条检查,没有一键工具,规模大的话建议写脚本遍历所有带 mergeinfo 的路径再比对路径是否存在。
8. 验收:怎么证明这次迁移是干净的
迁移完成不代表结束。我给自己定的规矩是:在新库上跑完全套验收再通知团队切换。下面这些检查项,缺一项都可能在某天变成线上事故。
8.1 五个比对维度
第一,修订数比对。svnlook youngest /srv/svn/projectA的输出必须和迁移前一致。如果不一致,说明 dump/load 过程有修订丢失。
第二,UUID 比对。取决于你的策略:想让大家 relocate 就必须一致,想强制重新检出就必须不同。写在这里的意义是提醒你明确地做一次选择,而不是听天由命。
第三,最新版本的文件清单比对。这个方法最直接有效:
# 迁移前 svn ls -R https://old.example.com/svn/projectA@HEAD > /tmp/old-list.txt # 迁移后 svn ls -R https://new.example.com/repos/projectA@HEAD > /tmp/new-list.txt diff /tmp/old-list.txt /tmp/new-list.txt两个文件完全一致,说明最新版本的所有路径和文件都搬过来了。加@HEAD是 peg 修订的写法,避免因为 URL 变更导致的歧义。
第四,修订日志比对。抽查几个关键修订的提交信息和作者:
svn log -v -r 1000:1010 https://new.example.com/repos/projectA如果 dump 时用了--preserve-revprops,作者、时间、日志都应该原样保留。
第五,仓库自检。svnadmin verify跑完没报错,说明版本库内部结构完整。
8.2 灰度双轨与回滚预案
规模大的迁移我强烈建议做灰度。具体做法是:新库上线后,先让两三个核心开发在新地址上工作一到两周,旧库保持只读。这段时间里如果新库有任何问题,把客户端 relocate 回旧地址即可,损失可控。
只读怎么设?在 authz 里把写权限去掉,或者把 Apache 配置临时改成只读,也可以简单地停掉旧库的写入口只保留读。这样旧库的数据不会再变化,双轨期的差异是零。
回滚预案要在动手前就写好并演练一遍。回滚的本质是"把客户端 URL 换回去",如果你保留了旧库的完整备份和访问入口,回滚就是一条 relocate 命令。反过来说,如果你在迁移过程中把旧库删了或者覆盖了,就没有回滚这一说了,所以第 2.2 节的备份不是可选项。
8.3 别把 .svn 暴露到 Web 目录
最后提一个和迁移有关但常被忽略的安全问题。有一类部署方式是把工作副本直接 checkout 到 Web 服务器的站点根目录,通过 HTTP 对外提供静态资源。这种情况下,.svn目录会跟着一起暴露在 Web 根下面,任何人访问https://site/.svn/wc.db就可能下载到整个仓库的元数据。
正确做法是把构建产物和版本控制工作副本分开,部署目录只放构建结果,不放.svn。如果实在要在服务器上 checkout,至少要用 Apache 的<DirectoryMatch>或者 Nginx 的 location 规则把.svn请求全部拒绝掉:
location ~ /\.svn { deny all; return 404; }顺带说一句,环境变量、数据库连接串这类敏感文件本来就不该进版本库,不管仓库搬到哪都一样。迁移是一次很好的清理时机,可以顺便用svn ls -R扫一遍历史路径,把该进.gitignore式的忽略规则、该从库里去掉的垃圾文件一并处理掉。
我自己经手的几次大迁移,最深的体会是:慢就是快。前面多花两个小时摸底、备份、比对清单,后面就能少花两天排查莫名其妙的问题。真正的技术难点其实不多,难点在于流程里的每一个环节都要有人认真确认一遍。