Git迁移到SVN
背景
之前的一个项目使用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仓库
- 克隆一个新的原始git仓库
git clone <PATH_TO_YOUR_GIT_REPO>
- 改写仓库,移除子模块相关提交和空提交,修改提交信息。
复制上面准备的脚本到仓库根目录,并运行
python run_filter.py
脚本运行完毕可能会报一个标准输出相关的错误,可以无视,脚本已经正确运行并返回结果了。
- 执行git log确认脚本执行结果
git log
如果输出的log里面,每次提交结尾都有---Commit by xxx
,那么就是修改成功了。
准备迁移仓库
- 使用git svn目标克隆svn仓库
git svn clone <SVN_REPO_URL> --no-metadata -s temp-svn-repo
cd temp-svn-repo
- 创建一个分支用于后续将迁移代码提交到svn
git checkout --no-track git-svn remotes/git-svn --
- 查看svn最新的提交并记录下commit-hash
git log
- 将改写后的git仓库添加为remote
git remote add source-git <PATH_TO_YOUR_REWRITE_GIT_REPO>
git fetch source-git
- 新开一个分支用于rebase进行历史嫁接
git checkout -b main_migrate --track source-git/main
- 进行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仓库上最新的提交之后了。
- 切换到git-svn分支
git checkout git-svn
- 合并main_mgirate分支
git merge main_migrate
这里因为main_migrate分支看起来就像是svn的"未来",所以这里会是一个fast-forward merge.
9.提交到svn
git svn dcommit
这会是一个及其漫长的过程,可能要数小时到数天。
- 用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)