Git迁移到SVN

Jun 12, 2025

背景

之前的一个项目使用Git进行源代码管理,现在项目中止,需要把代码迁移到公司大部分项目都在用的svn上进行存档。
迁移后的svn需要保留原始git的修改记录。
首先需要说明一下,因为目标svn是公司服务器,个人是没有服务器访问权限的,因此迁移过程困难重重,如果有服务器权限,那么最简单的方案是使用subgit,只需要在git端和svn服务器进行简单的配置,即可完美迁移。下面的讨论基于没有svn服务器权限的方案。
历经好几天的折腾终于找到了一个不算完美的解决方案,这里记录一下。

解决思路

经过了大量的搜索和AI的讨论,对于我的情况,最好的办法还是使用git svn。当然这中间进行了大量的妥协:

git子模块问题(submodules)

待迁移的git项目中使用到了子模块,目前我还是没有找到可以完美保留子模块的方法,最后采用的折衷方案是使用git-filter-repo移除子模块相关的提交。否则在使用git svn dcommit提交到svn时,会因为找不到子模块对应的提交hash链接而失败。

原始提交人问题(author)

由于svn提交的提交人,是由服务端根据提交账号强制设置的,所以,最终迁移到svn的时候,提交人都会变成你调用git svn dcommit的账号。

这里我贴一段Gemini 2.5 pro提供的解释和解决方案:

问题原因:Git 与 SVN 作者机制的根本区别
这个问题的根源在于 Git 和 SVN 记录提交者的方式完全不同。

Git 的作者机制
Git 在每一个 commit(提交)中,记录了两个信息:

Author (作者):实际编写代码的人。这是一个简单的文本字符串,如 "Zhang San <zhangsan@example.com>"。
Committer (提交者):将这次提交应用到代码库的人(例如,合并一个补丁的维护者)。这也是一个文本字符串。 在 Git 的世界里,这些信息是 commit 元数据的一部分,可以任意设置。
SVN 的作者机制
SVN 的作者(Author)是与身份验证绑定的。当一个用户向 SVN 服务器提交一个新版本(Revision)时,SVN 服务器会记录下当前成功登录并执行提交操作的那个用户的用户名。这个用户名就是该版本的作者(svn:author 属性),它与 SVN 的权限系统紧密相关。SVN 通常不允许用户 A 冒用用户 B 的名义去提交代码。

结论: 当你执行 git svn dcommit 时,你正在以你自己的身份登录到 SVN 服务器,并循环地将 Git 的 commit 一个个地创建为 SVN 的新版本。对于 SVN 服务器来说,所有这些操作都是由“你”完成的,所以它理所当然地将所有这些新版本的作者都记录为“你”的 SVN 用户名。
方案二:推倒重来,实现完美作者映射(复杂,且有前置条件)
如果你有严格的要求,必须让 SVN 的 author 字段也显示为原始的 Git 作者,那么唯一的办法就是删除 SVN 上已有的所有提交,然后用一种更高级的方式重新迁移。

这种方式不仅复杂,而且有一个关键前提:

⚠️ 前提条件:需要 SVN 服务器管理员的配合
你必须联系你的 SVN 管理员,请他/她配置服务器,允许修改版本属性(特别是 svn:author)。这通常需要设置一个名为 pre-revprop-change 的钩子脚本(Hook Script)。如果没有管理员的配合,以下所有步骤都无法成功。

如果满足了前提条件,你可以按以下步骤操作:

清空 SVN 仓库:让管理员删除所有版本,或者干脆新建一个空的 SVN 仓库。并按照之前的最终方案,手动创建 trunk, branches, tags 目录。

创建作者映射文件:
在你的 Git 仓库根目录,创建一个名为 authors.txt 的文件。这个文件的作用是告诉 git-svn,Git 里的“张三”对应于 SVN 里的哪个用户。

首先,获取所有 Git 作者列表,执行以下命令可以生成一个模板:
Bash

git log --format='%an <%ae>' | sort -u > authors-template.txt
然后,编辑这个文件,将其格式修改为: Git用户名 <Git邮箱> = SVN用户名
示例 authors.txt 内容:
Zhang San <zhangsan@example.com> = zhangsan
Li Si <lisi@example.com> = lisi
Wang Wu <wangwu@company.com> = wang.wu
这里的 zhangsan, lisi, wang.wu 必须是 SVN 系统中真实存在的、有效的用户名。
重新执行迁移:
在之前我们最终确定的迁移流程中,仅需在 git svn init 步骤加入 --authors-file 参数即可。

Bash

# 在纯净的 Git 仓库中
# 注意新增的 --authors-file 参数
git svn init <SVN_REPO_URL> -s --no-metadata --authors-file=authors.txt

# 后续步骤不变
git svn fetch
git rebase remotes/origin/trunk
git svn dcommit
这一次,当 dcommit 运行时,它会读取 authors.txt 文件,并尝试为你每一个提交设置对应的 svn:author 属性。如果 SVN 服务器的钩子配置正确,这次迁移就能完美保留作者信息。

但是基于我的情况获取修改svn:author的权限是不可能的,所以我并没有验证上述方案是否可行,请自行判断。

那么我怎么解决这个问题呢?
使用git-filter-repo来修改所有的git历史提交信息,将每条提交信息的最后加上"---commit by [origin_author]"。这样至少能够查到最初的提交人。

空提交(没有文件修改的提交)

svn默认是不允许空提交的,遇到空提交会导致git svn dcommit报错中止,因此需要使用git-filter-repo移除空提交。

OK,如果以上这些妥协你都能接受的话,那么可以继续看下面的详细步骤。

详细步骤

安装git-filter-repo

git-filter-repo下载最新的release并解压,需要先安装python。

准备python脚本,用于git-filter-repo的commit-callback

# 文件名: run_filter.py (最终版 - 抹除子模块并追加作者信息)
# 功能: 1. 移除子模块变更。
#       2. 强制移除所有空提交。
#       3. 在每个提交信息的末尾追加原始作者信息。

import subprocess
import sys

# -----------------------------------------------------------------------------
# 这是我们将要传递给 git-filter-repo 的回调代码。
# -----------------------------------------------------------------------------
COMMIT_CALLBACK_CODE = """
# 任务一:使用列表推导式,高效地过滤掉所有子模块链接。
commit.file_changes = [
    change for change in commit.file_changes
    if change.mode != b"160000"
]

# 任务二:在提交信息末尾追加作者信息。
# `commit` 对象包含了我们需要的 `author_name` 和 `message` 属性。
# 注意:这些属性都是字节字符串 (bytes),我们需要先解码(decode)再操作,最后编码(encode)回去。

# 为了安全地操作字符串,并避免重复添加,我们先进行解码。
author_name_str = commit.author_name.decode('utf-8', 'replace')
message_str = commit.message.decode('utf-8', 'replace').strip() # strip() 移除末尾空白

# 构造我们希望添加的“页脚”
footer_to_add = f"---commit by {author_name_str}"

# 检查提交信息是否已经包含这个页脚,防止重复执行脚本时重复添加。
if not message_str.endswith(footer_to_add):
    # 将原始信息和新页脚拼接起来,并编码为字节字符串,然后赋值回去。
    new_message_bytes = (message_str + "\\n\\n" + footer_to_add).encode('utf-8')
    commit.message = new_message_bytes
"""

# -----------------------------------------------------------------------------
# 主执行函数
# -----------------------------------------------------------------------------
def main():
    """构建并执行 git-filter-repo 命令"""
    print("=============================================")
    print("准备执行 git-filter-repo (抹除子模块、追加作者并移除空提交)...")
    print("=============================================")

    # 构建命令列表
    command = [
        "git-filter-repo",
        "--commit-callback",
        COMMIT_CALLBACK_CODE,
        "--prune-empty=always",       # 强制移除空提交
        "--prune-degenerate=always",  # 强制移除退化的空合并提交
        "--force"
    ]

    try:
        # 在shell中执行命令
        subprocess.run(command, check=True)
        print("=============================================")
        print("脚本执行成功!Git 历史已重写。")
        print("所有子模块的痕迹和所有空提交都已从历史中抹除。")
        print("原始作者信息已追加到每个提交信息的末尾。")
        print("现在你可以继续执行 git svn 相关命令了。")
        print("=============================================")

    except FileNotFoundError:
        print("!!!致命错误:找不到 `git-filter-repo` 命令。")
        print("请确认你已经正确安装了 git-filter-repo,并且它的路径在系统的 PATH 环境变量中。")
        sys.exit(1)
    except subprocess.CalledProcessError as e:
        print(f"!!!致命错误:git-filter-repo 执行失败,返回码: {e.returncode}")
        sys.exit(1)
    except Exception as e:
        print(f"!!!发生未知错误: {e}")
        sys.exit(1)

if __name__ == "__main__":
    main()

改写原始git仓库

  1. 克隆一个新的原始git仓库
git clone <PATH_TO_YOUR_GIT_REPO>
  1. 改写仓库,移除子模块相关提交和空提交,修改提交信息。
    复制上面准备的脚本到仓库根目录,并运行
python run_filter.py

脚本运行完毕可能会报一个标准输出相关的错误,可以无视,脚本已经正确运行并返回结果了。

  1. 执行git log确认脚本执行结果
git log

如果输出的log里面,每次提交结尾都有---Commit by xxx,那么就是修改成功了。

准备迁移仓库

  1. 使用git svn目标克隆svn仓库
git svn clone <SVN_REPO_URL> --no-metadata -s temp-svn-repo
cd temp-svn-repo
  1. 创建一个分支用于后续将迁移代码提交到svn
git checkout --no-track git-svn remotes/git-svn --
  1. 查看svn最新的提交并记录下commit-hash
git log
  1. 将改写后的git仓库添加为remote
git remote add source-git <PATH_TO_YOUR_REWRITE_GIT_REPO>
git fetch source-git
  1. 新开一个分支用于rebase进行历史嫁接
git checkout -b main_migrate --track source-git/main
  1. 进行rebase
    注意,如果你的svn是空仓库,可以不需要加--force-rebase--reapply-cherry-picks参数。这里是因为我之前的其他尝试遗留的失败svn提交才加上的。
    如果你希望手动解决冲突而不是无条件使用theirs解决,可以去掉-X theirs
git rebase -X theirs --root --force-rebase --onto [第三步记录的commit-hash] --reapply-cherry-picks

这一步耗时很长,取决于原始git有多少提交,可能持续数分钟到数小时。
这一步执行完成以后,你的git仓库上的所有提交,就被嫁接到了svn仓库上最新的提交之后了。

  1. 切换到git-svn分支
git checkout git-svn
  1. 合并main_mgirate分支
git merge main_migrate

这里因为main_migrate分支看起来就像是svn的"未来",所以这里会是一个fast-forward merge.

9.提交到svn

git svn dcommit

这会是一个及其漫长的过程,可能要数小时到数天。

  1. 用svn拉取你的代码吧!

如果你像我一样,原始git仓库有用到子模块,可以在手动把子模块复制到svn代码对应的目录中。然后提交即可。

一个小插曲

我们的项目基于windows开发,使用的是visual studio。按照上面步骤处理完后,不知道为什么部分代码文件变成了utf-8(无bom)编码格式。这就会导致编译莫名奇妙的报错。解决方案很简单,将所有utf-8(无bom)编码的文件转换为utf-8(带bom)的编码格式即可。下面是一个批量转换脚本:

import os
import chardet

# UTF-8的BOM标记
UTF8_BOM = b'\xef\xbb\xbf'

def convert_to_utf8_with_bom(filepath):
    """
    检查文件是否为 UTF-8 (无BOM),如果是则转换为 UTF-8 (带BOM)。

    :param filepath: 文件的完整路径。
    """
    try:
        # 1. 读取文件原始二进制数据
        with open(filepath, 'rb') as f:
            raw_data = f.read()

        # 2. 如果文件为空,则跳过
        if not raw_data:
            print(f"ℹ️  文件 '{filepath}' 为空,跳过。")
            return
            
        # 3. 检查是否已存在BOM
        if raw_data.startswith(UTF8_BOM):
            print(f"ℹ️  文件 '{filepath}' 已是 UTF-8 with BOM 格式,跳过。")
            return

        # 4. 使用 chardet 检测编码
        result = chardet.detect(raw_data)
        original_encoding = result['encoding']
        confidence = result['confidence']

        # 5. 检查编码是否为 'utf-8' (chardet通常将无BOM的UTF-8识别为'utf-8')
        #    且置信度较高
        if original_encoding == 'utf-8' and confidence > 0.9:
            print(f"检测到文件 '{filepath}' 的编码为 UTF-8 (无BOM),正在添加BOM...")
            
            # 使用 'utf-8-sig' 编码重新写入文件,它会自动在文件开头添加BOM
            # 首先需要用 utf-8 解码
            content = raw_data.decode('utf-8')
            with open(filepath, 'w', encoding='utf-8-sig') as f:
                f.write(content)
            
            print(f"✅ 文件 '{filepath}' 已成功转换为 UTF-8 with BOM。")
        else:
            print(f"ℹ️  文件 '{filepath}' 的编码为 {original_encoding} (置信度: {confidence:.2f}),不符合转换条件,跳过。")

    except Exception as e:
        print(f"❌ 处理文件 '{filepath}' 时发生错误: {e}")

def find_and_convert_files(root_dir, extensions):
    """
    在指定目录及其子目录中查找特定扩展名的文件,并进行编码转换。

    :param root_dir: 要开始搜索的根目录。
    :param extensions: 一个包含文件扩展名的元组或列表,例如 ('.txt', '.csv')。
    """
    print(f"🔍 开始在目录 '{root_dir}' 中搜索 {extensions} 文件...")
    for subdir, _, files in os.walk(root_dir):
        for file in files:
            if file.lower().endswith(tuple(ext.lower() for ext in extensions)):
                filepath = os.path.join(subdir, file)
                convert_to_utf8_with_bom(filepath)
    print("\n🎉 所有文件处理完毕!")

if __name__ == "__main__":
    # --- 配置区 ---
    # 1. 指定要搜索的根目录 ('.' 代表当前目录)
    target_directory = '.'
    
    # 2. 指定要转换的文件扩展名 (请确保是小写)
    target_extensions = ('.txt', '.html', '.css', '.js', '.csv', '.md', '.java', '.py') 
    # --- 配置区结束 ---
    
    find_and_convert_files(target_directory, target_extensions)