看到这个标题你应该就能感觉出来,这绝不是一篇“我们换上了更酷的代码托管平台”的炫耀帖,而是一段被反方向迁移需求逼出来的实战记录。事情是这样的:公司内部维护了三年多的一个Git仓库,因为项目交接后新来的同事只熟悉SVN,再加上客户验收和内部审计流程都锁死在SVN这套体系上,最终老大拍板——把整个仓库迁到公司的SVN服务器,提交记录(log)必须跟着走,历史丢了就等于白迁。听见这个需求我第一反应是拒绝的:让一辆跑车改成卡车,怎么看都不合理。但活儿就是活儿。代码迁移本身倒不复杂,真正把我困住差不多两天的是log,SVN服务器上revision号都正常生成了,用svn log一看却是一片空白。这篇文章把标准流程、失败现象、完整排查链路和最后兜底方案都写清楚,给同样被这种“反方向脏活”折磨的人一个参考。
1. 反向迁移的真实需求:谁会把Git仓库迁回SVN
先说结论:这种东西在纯互联网团队里很少见,但在传统行业、长期外包项目、高校实验室和部分硬件研发团队里,比想象中更常见。Git再香,也架不住整个交付链条里的其他环节只认SVN。我这次遇到的情况就是典型的“组织惯性压过工具先进性”:项目文档、发布流程、权限控制、审计留痕全部挂在SVN上,代码如果继续放在Git里,反而会变成流程上的孤岛。架构选择从来不是“哪个工具更先进”,而是“哪个工具能让上下游都不掉链子”。
除了团队技能原因,还有几类需求也特别容易触发这个迁移。一类是合规要求,某些行业的代码交付物验收时需要SVN基线,而且审计工具只对接SVN的仓库格式;另一类是权限管理,SVN在路径级权限控制上比Git细得多,可以让“主干只能合并、分支可写、tags只读”这种策略直接在服务端强制执行;还有一类其实是备份策略,老员工离职后Git仓库变成了没人敢动的黑盒,SVN至少直观很多。
但真正决定这次迁移“麻烦程度”的,是下面这个关键问题:你要保留的“提交记录”到底包含什么。对照一下Git和SVN的字段,你会发现每一样都对应得上,但迁移的难度完全不同:
| 需要保留的维度 | Git侧字段 | SVN侧对应 | 迁移难度 |
|---|---|---|---|
| 提交说明 | commit message | svn:log属性 | 中等,编码和格式容易翻车 |
| 作者 | Author | revision的author | 较高,需要作者映射 |
| 提交时间 | Author Date / Commit Date | revision的date | 高,SVN默认取提交时刻 |
| 变更范围 | 文件改动列表 | changed paths | 较高,需要逐revision重建 |
| 分支结构 | 多分支/tag | SVN目录复制 | 高,合并提交表达不完整 |
如果只要求“代码能编译通过、最新代码进SVN”,那这篇笔记的后面大部分内容你都不用看。但如果像我这次一样,用户明确说“历史log必须保留,我们要溯源”,那就要做好心理准备,这活儿的难点从头到尾都在log上。
动手之前还建议确认三件事:SVN服务端的版本和形态(VisualSVN Server还是Apache Subversion,这决定了后续能不能用某些钩子脚本)、仓库目录规划(用标准trunk/branches/tags布局还是单一目录)、以及一个很反直觉的问题——你要迁移的是“整个历史”还是“最近N次提交”。这次我就是在第三件事上没想清楚,导致第一轮方案直接跑偏。
2. 第一轮快照导入:代码全过去了,log一条不剩
如果是第一次干这种数据迁移,很容易先选最省事的路径:把Git工作目录里的文件抽出来,用svn import或先add再commit一次性灌进SVN。我当时也是这么想的,毕竟代码总得先过去,log的事之后再想办法。
操作很简单,先把Git仓库的当前快照整理到一个干净目录:
cd /data/git_repos/old_project git checkout master rsync -a --exclude=.git ./ /tmp/svn_import/ cd /tmp/svn_import svn import ./ svn://192.168.1.10/git_migrate/trunk/project -m "import from git"或者用更常规的checkout方式,在svn客户端里建好目录结构后:
svn mkdir svn://192.168.1.10/git_migrate/trunk/project -m "创建项目目录" svn checkout svn://192.168.1.10/git_migrate/trunk/project cd project copies over files manually svn add --force . svn commit -m "import from git"这两条路的结果完全一样:文件过去了,然后在SVN服务器上产生一个全新的revision,从r1开始,前面Git仓库那几百条提交历史全部消失。svn log输出特别干净,干净到让人心慌:
------------------------------------------------------------------------ r1 | admin | 2025-11-12 ... A /trunk/project/README.md A /trunk/project/src/main.go import from git ------------------------------------------------------------------------为什么会丢?因为SVN的每个revision本质是“一次变更+一段元数据”,而Git的全部历史都存在.git目录里,SVN服务器端根本读不到。svn import把工作目录里的文件当作全新内容提交,它并不知道这些文件在Git里经历过多少次修改。所以只要走“快照导入”这条路,log丢失就是必然结果,不存在任何捷径能“隐式保留”Git历史。
这一轮失败的真正价值是让我确认了一件事:如果要把log捞回来,必须从revision层面做文章,让Git里的每一条commit在SVN里对应一个独立revision。快照导入只能作为保底方案,等所有技术路线都失败之后再考虑。
顺便提醒一句,如果log对你来说只是“有个记录就行”,快照导入是最佳选择。别被各种迁移工具吓到,先想清楚你的历史记录到底有没有业务价值。很多项目的历史其实没有审计价值,那就不必为了“形式上的完整”把迁移复杂度拉满,因为后面你会看到,完整的迁移是一条遍布坑的路。
3. 第二轮标准做法:git-svn全历史迁移与它踩到的坑
在动真格之前,先讲清楚git-svn的核心原理。SVN是中心化服务器,git-svn的思路很简单粗暴:把一个SVN仓库地址当作一个远程Git分支来用,本地维护一个叫refs/remotes/git-svn的特殊分支来记录“SVN服务器当前已经收到了哪些提交”。git svn dcommit会把本地在这个特殊分支之后的提交,逐个转成SVN的commit提交到服务器,每个提交对应一个revision。
听起来很顺,实际操作容易在半路翻车。下面是标准的完整流程。
3.1 服务端准备:先建仓库再建目录
在SVN服务器上新建仓库,我这里用的是Linux下的Subversion:
svnadmin create /data/svn/repos/git_migrate然后在客户端建标准目录。注意这个动作本身会产生revision,所以SVN仓库不是从0开始的:
svn mkdir svn://192.168.1.10/git_migrate/trunk -m "创建trunk" svn mkdir svn://192.168.1.10/git_migrate/branches -m "创建branches" svn mkdir svn://192.168.1.10/git_migrate/tags -m "创建tags"3.2 本地初始化git-svn关联
有两种方式。一种是直接克隆一个空的svn镜像仓库再拉代码,另一种是在旧Git仓库里直接初始化。我推荐后一种,省一次fetch的功夫:
cd /data/git_repos/old_project git svn init -s svn://192.168.1.10/git_migrate git svn fetch --revision HEAD:HEAD-s是标准布局的意思,让git-svn知道trunk/branches/tags分别对应哪个路径。--revision HEAD:HEAD这个参数很关键,因为服务端刚建好目录,历史里只有那几个mkdir操作,这些操作对迁移毫无意义,只拉HEAD可以避免git-svn把一堆垃圾提交当作有效历史。
3.3 把旧Git提交接到git-svn分支上
现在旧仓库里有两个世界:一个是原来的master(完整历史),一个是刚拉下来的refs/remotes/git-svn(空壳)。要做的就是把master的提交整体搬到git-svn分支上:
git svn fetch git checkout -b migrate master git rebase --onto remotes/git-svn --root migrate这一步用--root是因为要把master根提交之后所有提交全部重放到git-svn之上。rebase会重写commit hash,但这是预期内的,因为我们要的是“基于SVN基础的连续提交序列”。
3.4 配置作者映射文件
git-svn从SVN拉历史时,需要把SVN用户名映射成Git的用户名和邮箱,否则提交信息会缺失。虽然我们这个场景是空仓库新建,但养成习惯,配置留着没有坏处:
cat > svn-authors.txt <<'EOF' admin = Admin <admin@example.com> zhangsan = Zhang San <zhangsan@example.com> EOF git config svn.authorsfile "$(pwd)/svn-authors.txt"注意一个很多人第一次没搞清楚的事:这个authorsfile只影响fetch方向(SVN到Git),也就是从SVN拉取时把SVN账号名翻译成Git的Author信息。而dcommit方向(Git到SVN)提交时,SVN revision里的author是“当前SVN认证用户”,git-svn并不会把Git commit的Author字段翻译成SVN的author。这意味着如果你想在SVN上保留每个历史提交的原作者,光靠git-svn做不到,需要在SVN服务端用不同账号逐个提交,或者接受SVN侧作者统一变成迁移操作者。这也是后面“log丢失”这个坑的隐形伏笔之一。
3.5 dcommit推送
git svn dcommit正常情况下输出会是这样:
Committing to svn://192.168.1.10/git_migrate ... A README.md A src/main.go Committed r4 A docs/design.md Committed r5 ...看到每个Committed rN都成功,一般人都会松一口气。我也是。但接下来去SVN客户端验证的时候就发现问题了,svn log里历史提交的message全是空的,文件变更在,revision在,但commit message不见踪影。这就是标题里说的“迁移提交log失败”。
3.6 标准流程里的其他高发坑
没走到log失败这一步的人,通常先被下面几个问题拦住:
merge commit是头号杀手。SVN是线性版本模型,没有对应的merge概念。如果你的Git仓库里存在merge提交,git svn dcommit会直接报错。解决方案比较粗暴:要么用git rebase把merge展平成线性,要么只迁移first-parent后的主线提交。我这次仓库分支不算复杂,直接rebase展平就解决了。
空提交也会出问题。Git允许git commit --allow-empty提交一个没有内容变化的commit,但SVN里正常的commit必须有文件变化(除非强制空revision)。git-svn遇到空的本地提交,可能会出现“revision生成但log对应不上”的诡异现象。确定有这种提交的话,要在rebase之前就过滤掉。
.gitignore和svn:ignore属性是完全不同的东西,不要指望自动转换。Git忽略规则基于路径模式,SVN的忽略属性则是每个目录逐级设置的,迁移后要重新在SVN端配置忽略规则,否则下个同事一执行svn status就是满屏的垃圾文件。切记。
4. 排查链路:从“svn log为空”一路倒逼到revprop层
log显示为空,第一反应是怀疑客户端显示问题,第二反应是怀疑git-svn没把message发上去。按这个顺序做排查,不要跳步。
4.1 确认现象:svn log到底显示成什么样
在任意一台SVN客户端上执行:
svn log -v svn://192.168.1.10/git_migrate我看到的输出是这样的:
------------------------------------------------------------------------ r21 | admin | 2025-11-12 ... (no log message) ------------------------------------------------------------------------ r20 | admin | 2025-11-12 ... (no log message) ------------------------------------------------------------------------ r1 | admin | 2025-11-12 ... (no log message) ------------------------------------------------------------------------所有revision都生成了,但message全是(no log message)。由于-v能看到changed paths,所以确认文件确实提交到了对应revision里。注意这里有个容易被忽略的点:r21是我最后手工提交的“import from git”,它有message;中间那些r5到r20则是git-svn推送的历史提交,全是空的。也就是说,问题不在SVN服务端,而在git-svn那一段。
4.2 服务端直接验证:svnlook log查revprop原值
客户端显示的log来自SVN仓库里每个revision的svn:log属性(revprop)。为了排除客户端问题,直接到SVN服务器的仓库目录上用svnlook查看原始的revprop:
svnlook log -r 15 /data/svn/repos/git_migrate输出同样是空行,确认服务端保存的svn:log属性就是空的。到这里可以下结论:不是显示问题,是提交时log根本没进SVN。
4.3 核对本地Git侧的message是否还在
回到git仓库,检查对应的commit message是否完好:
git log --oneline -30 git log -1 --format=%B <对应commit>本地Git侧message都在。这说明git-svn在dcommit时,没有把Git的commit message写进SVN的svn:log。为了进一步确认每个revision和Git commit之间的映射关系,用git svn自带的功能:
git svn log --show-commit --oneline这个命令会同时显示SVN revision号和对应的Git commit hash,能清楚看到哪些revision的message丢了。
4.4 检查服务端钩子:pre-commit与pre-revprop-change
SVN有两类型钩子容易在这里搅局。一类是pre-commit,它在提交发生前运行,可以用svnlook log检查提交信息是否满足某种格式(比如必须包含bug编号),不满足就拒绝整个提交。如果这个钩子在某些条件下“放行但不保留log”,我还没见过这种实现,但值得检查仓库的hooks目录。另一类就是后面补救要用到的pre-revprop-change,它控制revision属性能否被修改,跟log写入失败无关,但如果你后续想回填log,默认情况下它会把所有修改revprop的请求全部拒绝。
查看服务端仓库的hooks目录:
ls -la /data/svn/repos/git_migrate/hooks/检查是否存在pre-commit脚本,以及脚本内容是否对log格式做了强校验。我这边确认了没有自定义钩子,这个方向排除。
4.5 根因定位:git-svn在什么情况下会写出空的svn:log
排除钩子之后,问题收缩到git-svn的提交行为上。翻了不少资料,也做了小仓库复现,最后锁定了几个真实原因。最典型的一个是编码问题:Git的commit message如果不是UTF-8(国内很多老仓库是GBK),且执行dcommit的终端locale也没有正确设置为UTF-8时,git-svn读取message后转码失败,发到SVN服务端的内容就可能被当作非法UTF-8丢弃,最终落库为空。
还有一类是revision属性被服务器端规则处理掉了。某些VisualSVN Server版本或第三方SVN管理面板,会在commit时自动校验svn:log的编码,非法内容会被忽略而不是报错,这比Subversion原生行为更隐蔽。另外,如果你用的是TortoiseSVN或IDE的“Import”菜单,那本来就是快照导入逻辑,根本没有逐条提交Git历史的能力,log为空再正常不过。
我这次遇到的情况属于编码和git-svn版本行为叠加。确认根因很费时间,但最终真正解决我问题的不是修正编码后重新跑一遍,因为我在Git端的历史提交信息里已经混合了多种编码,想全部清理干净成本很高,还不如直接在SVN端兜底。
5. 终极补救:用svn propset --revprop逐条回填svn:log
既然revision都在,log属性是空的,那就直接在SVN端把svn:log属性补回去。这个思路叫“revprop回填”,原理是SVN的revision属性在服务端允许修改(只要钩子放行),我们可以把Git仓库里对应的commit message,逐条set到SVN对应revision的svn:log属性上。它不依赖git-svn当时为什么没写成log,是一个可靠的兜底方案。
5.1 先放行服务端的revprop修改权限
SVN默认禁止远程修改revprop,需要仓库hooks目录下的pre-revprop-change脚本放行。最简单的方式,在/data/svn/repos/git_migrate/hooks/下新建脚本:
#!/bin/sh # 只有svnadmin用户允许修改revprop,其他用户一律拒绝 if [ "$USER" = "svnadmin" ]; then exit 0 fi exit 1注意Linux下还要给脚本执行权限:
chmod +x /data/svn/repos/git_migrate/hooks/pre-revprop-change如果SVN服务端是VisualSVN Server,可以直接在服务端管理界面或Hook脚本里配置,Windows环境下钩子脚本是批处理或者PowerShell文件。放行之后,客户端才能顺利执行下面的svn propset命令。
5.2 先跑通一条回填命令
用svn命令行对某一个revision做测试,比如r15:
svn propset --revprop -r 15 svn:log "fix: 修改登录鉴权逻辑" svn://192.168.1.10/git_migrate执行完再验证:
svn propget --revprop -r 15 svn:log svn://192.168.1.10/git_migrate能看到刚刚设置的message就说明基础链路通了。如果这里没通,绝大多数情况是pre-revprop-change钩子没放行,先去查钩子。
5.3 批量生成映射关系并自动回填
到这一步的核心工作,是把Git的commit message和SVN的revision编号一一对应起来。用git svn log --show-commit就能拿到映射表,输出类似:
r15 8a5f223 fix: 修改登录鉴权逻辑 r14 2e991b1 feat: 增加订单模块 r13 0f0aa10 refactor: 重构配置读取能把结果导出成文件更好,然后写一个循环脚本执行回填。注意commit message可能带引号、中文、换行,直接用svn propset的字符串参数很容易被shell误解。最稳的办法是把每条message写到临时文件,然后用--file参数读取:
#!/bin/bash SVN_URL="svn://192.168.1.10/git_migrate" # 假设mapping.txt格式为: r15 8a5f223 while read rev hash; do msg=$(git log -1 --format=%B "${hash}") printf '%s' "${msg}" > /tmp/msg_${hash}.txt svn propset --revprop -r ${rev#r} svn:log --file /tmp/msg_${hash}.txt "${SVN_URL}" rm -f /tmp/msg_${hash}.txt done < mapping.txt如果担心每条命令都打印一堆输出,可以在propset后面加--quiet参数。整个迁移仓库如果历史有几百条commit,跑几分钟很正常,逐个revision操作本身开销不大。我执行完大概花了三分钟,主要时间花在打印和网络往返上。
命令执行完,立刻验证一下SVN客户端侧的log显示:
svn log -l 10 -v svn://192.168.1.10/git_migrate正常的话,每条revision下面都会显示对应的Git commit message,以及本次变更涉及的文件路径。到这里,标题里“迁移提交log失败”的问题就算真正解决了。
5.4 回填过程中容易翻车的几个细节
首先是引号问题。commit message里如果含单双引号、$符号,直接拼在shell命令里大概率出问题。用临时文件配合--file避开这一劫。其次,如果SVN客户端版本较老,propset --revprop的--file参数可能不支持,那就只能手动逐条赋值,需要特别注意shell转义。
还有一个特别容易忽略的坑:不要在SVN服务器上的同一个仓库目录里,用svnadmin工具和客户端工具同时操作revprop。客户端工具走网络协议,svnadmin直接改仓库文件,混用会把仓库头搞乱。回填统一走客户端命令,也就是svn propset。
再有,如果commit message是乱码,在回填前先确认目标字符编码。SVN 1.7以后强制要求svn:log是UTF-8,如果原数据是GBK,要在提取message时先转成UTF-8:
iconv -f GBK -t UTF-8 /tmp/msg_${hash}.txt -o /tmp/msg_${hash}.utf8.txt不转码的话,回填完在svn log里看到的可能是乱码,等于没修好。
5.5 根治方案:如果重来一次,我会怎么做
回填虽然能解决log为空,但本质是修数据,不是优雅的方案。如果以后还要做Git到SVN的完整迁移,我更推荐直接用git-svn全历史推送,但提前做好三件事:第一,在源仓库检查并统一commit message编码,尽量全部转成UTF-8;第二,提前处理掉merge commit和空commit,保证提交序列是线性的;第三,先在SVN服务器上搭一个临时仓库,拿一小段历史做dcommit演练,确认log落地正常后再动正式仓库。这三步能规避掉绝大多数log丢失问题。
6. 迁移之后的SVN落地:权限、目录和团队习惯
log补回来只是迁移的一半,SVN仓库面向团队开放前,还得把权限、目录和操作习惯理顺,否则等于从Git这个坑跳进另一个坑。
6.1 用户权限与路径授权
SVN的权限控制比Git细,非常贴合“主干必须走评审、分支可以随意折腾、tags只读”的研发流程。svnserve.conf和authz是核心配置文件,一个典型的最小配置如下。
svnserve.conf:
[general] anon-access = none auth-access = write password-db = passwd authz-db = authzauthz里做路径级授权:
[groups] developers = zhangsan, lisi release_manager = wangwu [git_migrate:/] @release_manager = rw * = r [git_migrate:/trunk/project] @developers = rw * = r [git_migrate:/branches/dev_xxx] @developers = rw * = [git_migrate:/tags] * = r这套配置的意思是:普通成员在trunk下可读写、对tags只有读权限,仓库根路径其他人只读,防止有人误删目录结构。
6.2 迁移过来的目录怎么摆
Git仓库如果原来有多个分支,推上SVN后需要想清楚怎么组织。我的建议是:主线代码放trunk/project,把Git仓库里的主干历史通过svn log保留在trunk上;老版本快照或非主线分支统一放到branches/下,用branches/project-v1.2这种命名方式;重要里程碑直接在tags/下打一个SVN tag。注意SVN的tag本质是目录拷贝,不是Git的指针,所以打tag要明确“复制trunk当前状态到tags”,不要拖着一堆中间revision。
SVN里还有个Git用户经常漏掉的东西:svn:ignore属性。Git的.gitignore不会自动转成SVN忽略规则,要在迁移后的仓库根目录设置:
svn propset svn:ignore -F .gitignore .或者手动设置,比如svn propset svn:ignore "dist" "build" .。不设这个属性,每次svn status都会刷出一堆构建产物和IDE配置文件,用不了几天大家就烦了。
6.3 老Git用户转入SVN后最容易犯的三个错
第一,习惯性拉分支。Git的分支是轻量指针,SVN的branch是目录拷贝,频繁创建SVN分支会胀大仓库并让log树变得混乱。迁移后建议收敛分支使用频率,主干小步提交才是正道。第二,用svn merge反向合并主干。SVN老手都知道,合并前要svn merge --reintegrate或者先svn switch到目标分支,新人容易直接一封邮件让所有人手工合并,这是SVN协作里最常见的灾难现场。第三,用--force覆盖冲突。SVN遇到冲突会明确提示,Git用户习惯git checkout --theirs之类的强制操作,在SVN里一强制就可能把别人的提交抹掉,冲突必须手工解决。
6.4 这次迁移的个人实操体会
最后说点实在的。这次迁移最花时间的不是敲命令,而是“被log为空的现象误导到错误的方向”。如果你也被同样的现象卡住,记住一个原则:SVN的log显示为空,永远先查revision的svnLock,不,是先查revision的svn:log属性本身,不要先在客户端和git-svn参数里反复折腾。属性为空就用svn propset --revprop回填,属性不为空就去看编码和显示。方向对了,半天就能收工。
另外一个小技巧:动手迁移正式仓库前,务必在SVN服务器上新建一个临时测试仓库,用一小段历史完整跑一遍git-svn dcommit到回填验证的全流程,确认每一步的输出都符合预期。我这次如果先演练十分钟,后面就不会白白熬夜排查了。迁移这类脏活,稳比快重要得多。