最近团队里有个老仓库要拆分成多个独立仓库,其中一个核心模块要从原来的单体仓库迁到一个新建仓库里。同事问我:能不能只把那个模块的代码搬过去,历史提交也保留一下?我说能,但搬法不同,结果天差地别。这件事在圈子里有个统一叫法:跨仓库迁移部分代码。表面上看只是把文件复制过去,实际牵涉到历史记录、目录结构、依赖关系、敏感信息清理,还有一堆容易踩的Git坑。这篇文章就把我这些年拆仓库、迁模块的实操经验梳理一遍,从方案选型到完整命令,再到避坑清单,尽量让不同基础的同事都能照着做。
1. 迁移前先想清楚:你到底需不需要历史记录
1.1 需求决定方案:带历史迁移 vs 不带历史迁移
迁移方案的第一道分水岭,不是技术层面的目录和文件,而是需求层面的一句话:新仓库里的代码,需不需要保留旧仓库的提交历史?
需要保留历史,意味着在新仓库里能看到每个文件从引入到改动的完整commit记录、作者、提交说明。这对中大型项目很重要。模块迁移后一旦出bug,你想知道某一行代码是什么时候被改的,可以通过git log回溯;如果历史全丢,就只能靠记忆或同事口述。对于涉及长期维护、需要追溯变更原因的场景,历史几乎是硬需求。
不需要历史的情况其实也很多。比如代码已经严重腐化、准备重写;比如源仓库历史悠久、里面有很多早期的临时提交,带过去反而是负担;再比如合规要求不允许旧历史里的敏感信息流到新仓库。这时候直接复制文件,在新仓库里做一次干净的初始化提交,反而更合适。
我每次接手这种需求,都会先和业务方确认三件事:这个模块以后还改不改?有没有人会在新仓库里查旧历史?旧库里有没有不能外泄的密钥、ip、账号密码?这三个问题问完,方案基本就定了。
1.2 迁移范围:目录级还是文件级
第二道分水岭是迁移范围。你面对的是一个目录,还是散落在多个位置的一组文件?
目录级迁移最好办,典型场景是src/payment/这个模块整体搬迁。这类需求我用git subtree split最顺手,它能把指定目录连同相关提交历史完整抽取出来,操作简单,原仓库也不受影响。
文件级迁移就麻烦一些。比如要把core/utils/下的三个工具文件,加上tests/core/下的两个测试文件一起迁走,这些文件不在同一个目录下,subtree 就无能为力了。这时候我会用git filter-repo,它支持按任意路径组合过滤历史,保留指定文件、剔除其他一切内容,是散装文件迁移的正确工具。
还有一种情况需要特别留意:目录层级在迁移后要不要变。比如原来代码在src/payment/,到了新仓库希望直接放在payment/根目录。这个既可以在迁移后用git mv手动调整,也可以用 filter-repo 的--path-rename在历史重写阶段直接完成。差别在于后者会让历史里的路径从一开始就是新的位置,更干净,但操作门槛也更高。
1.3 迁移前的卫生检查
这一步很多人跳过,我建议别跳。尤其是在重写历史方案(subtree、filter-repo)之前,先花十分钟检查一下源仓库。
先看仓库体积。执行du -sh .git,如果.git目录已经有几百MB,历史里大概率藏了大文件或大量对象碎片。再看敏感信息,用grep -r "password\|secret\|api_key"扫一遍当前工作区,再用类似git log -S"password"的方式查历史里有没有明显的密钥提交。最后看分支结构,源仓库里哪些是长期分支、哪些tag值得保留、哪些临时分支可以直接放弃。
如果只是冷复制文件,这些检查可以省。但只要涉及历史重写,就必须提前做,因为一旦重写并推送,旧历史就在远端留底了,后面再想清理非常被动。
2. 四种主流迁移方案,按历史保留程度排个序
Git下做跨仓库代码迁移,基本逃不出下面这四种操作。我按历史保留程度和上手难度排个序:冷复制、patch文件、subtree split、filter-repo。选型没有绝对的好坏,只有合不合适。
2.1 方案A:冷复制——不带历史,最快
冷复制不算Git操作,就是最朴素的文件复制。把目标目录拷贝出来,到新仓库里粘贴,然后git add、git commit。唯一算得上Git技巧的是用rsync排除掉.git目录,别把旧仓库的元数据带过去。
它适合的场景:临时修复用的代码片段、已经停止维护的模块、纯技术预研的demo。优点当然是快,几分钟完成;缺点也很明显,历史全丢,模块的来龙去脉查不到了。
我一般会在提交信息里留下线索,比如:feat: 从 legacy-monorepo 迁移 payment 模块,原始提交 abc1234。这样至少后面人能追溯到源仓库。如果怕信息不够,再在仓库根目录放一个MIGRATION.md,记录迁移时间、源仓库地址、迁移范围。
2.2 方案B:patch文件——轻量带历史但不优雅
git format-patch可以把一段提交历史导出成一系列.patch文件,再到新仓库用git am导入。基本命令长这样:
# 在源仓库中,把最近5个提交导出成 patch 文件 git format-patch -5 -o /tmp/patches # 在新仓库中,依次应用这些 patch git am /tmp/patches/*.patch听起来简单,但实际用起来很不优雅。第一,patch不包含原仓库的分支结构,合并提交默认会被忽略;第二,如果提交里涉及二进制文件,patch可能无法干净应用;第三,逐条应用时遇到冲突就得手动处理,迁移几十个提交还好,上百个提交会让人崩溃。
我唯一推荐patch方案的场景,是迁移某个bug修复的连续几个提交,改动范围小、提交数量少。真要迁移一个完整模块的历史,用subtree或filter-repo才是正道。
2.3 方案C:git subtree split——目录拆分的利器
git subtree split是Git自带的子模块管理命令之一,用来把指定目录的历史抽取成一个独立分支。原理上,它会遍历仓库里的所有提交,筛选出接触过该目录的提交,重写成只包含该目录内容的提交序列。
它最大的优点是操作简单、速度快,而且是在source仓库之外生成新分支,原仓库本身完全不受影响。我把一个包含几百次提交的模块目录拆分出来,只需要一条命令,几秒钟完成。
唯一的限制是它只能处理一个目录前缀。如果迁移目标是散落的多个文件,subtree做不了。但如果是模块目录整体搬迁,我强烈建议优先用它。
2.4 方案D:git filter-repo——按路径重写历史的瑞士军刀
git filter-repo是目前官方推荐的历史重写工具,用来替代老旧的git filter-branch。它不仅能按路径过滤仓库内容,还能批量替换文本、删除大文件、修改作者信息,功能非常强大。
它的工作方式是在本地副本上重写整条历史。因为历史全部重写,commit hash一定会变化,所以它天然不适合继续在旧仓库协同工作,而是适合从一个仓库中“炼”出一个新仓库,和我们的迁移需求正好对口。
四张方案的对比可以浓缩成下面这张表:
| 方案 | 是否保留历史 | 是否支持指定文件 | 上手难度 | 适合场景 |
|---|---|---|---|---|
| 冷复制 | 否 | 是,手动复制 | 低 | 弃用模块、快速复用 |
| format-patch | 是,但无分支结构 | 按提交范围,不够灵活 | 中 | 少量提交迁移 |
| git subtree split | 是,保留目录内全部历史 | 仅单个目录前缀 | 中低 | 模块目录整体搬迁 |
| git filter-repo | 是,历史可清洗 | 支持任意路径组合 | 中高 | 多个分散文件迁移、历史清理 |
3. 实操篇:用 git subtree 把一个子目录迁到新仓库(保留历史)
3.1 第一步:在源仓库里拆分目录到临时分支
假设源仓库叫legacy-repo,要迁移的模块位于src/payment/,我们想把这个目录连同历史完整抽出来。
先在源仓库根目录下确认工作区干净,然后执行:
git subtree split --prefix=src/payment -b migrate-payment参数解释:--prefix指定要迁移的子目录,-b指定拆分后生成的新分支名。执行完后,migrate-payment分支上只包含src/payment/目录里的文件,并且完整保留了与该目录相关的提交历史。
这里有个细节要注意:拆分出来的目录结构仍然保留src/payment/前缀。如果你到了新仓库希望它变成payment/根目录,后面要手动调整,或者后续再用 filter-repo 的--path-rename重新处理。
拆分后建议验证一下历史是不是完整:
git log --oneline --decorate migrate-payment如果看到的历史比预期少很多,先别急着推送。常见原因有两个:一是源仓库目录曾经被重命名过,subtree可能无法自动跨路径追溯;二是存在复杂的合并提交,subtree可能会简化处理。这时候就需要结合filter-repo做补充方案了。
3.2 第二步:把临时分支推送到新仓库
如果目标仓库是全新的空仓库,操作最简单,直接在源仓库里添加新仓库的remote,然后推送临时分支:
git remote add new-origin git@example.com:team/payment-service.git git push new-origin migrate-payment:main第一个命令把新仓库地址登记为new-origin,第二个命令把本地migrate-payment分支推送到new-origin的main分支。因为临时分支里已经包含了完整历史,新仓库的main直接就拥有这些提交,不需要额外的merge操作。
如果目标仓库已经有内容,就不能直接覆盖main了。我曾经遇到过几次目标仓库已经初始化了README文件、License文件的情况,这时候直接--force推送会把已有内容冲掉。安全的做法是先把临时分支推到一个临时远端分支:
git push new-origin migrate-payment:import-payment然后到目标仓库里执行git merge --allow-unrelated-histories,或者先拉下来再手动处理冲突。两个独立历史的merge大概率会冲突,提前做好心理准备。
3.3 第三步:在目标仓库里整理目录结构和验证
推送完成后,到目标仓库执行git checkout main,再用git log --oneline --stat确认文件都在。
如果想让目录从src/payment/变成payment/,执行:
git mv src/payment payment然后提交。这里会多出一条“move”提交记录,内容本身没变,以后追查历史时仍然能从move提交之前的记录里看到原路径。如果你希望历史里的路径从一开始就是payment/,就不要用git mv,而是回到filter-repo的--path-rename去处理。
还要检查有没有其他模块依赖src/payment/里的相对路径。源仓库里如果其他代码用../payment/xxx的方式引用它,迁移到新仓库后这些路径可能全部失效。这个属于迁移范围设计的问题,应该在动手前就梳理清楚,而不是合入之后让同事踩坑。
我这里再补充一个容易忽略的坑:subtree split生成的分支默认不带原分支上的tag。如果模块的重要版本节点是通过tag标记的,需要单独处理。比如手动打tag,或者临时分支打包后单独推送。至少我在实操中,从来没见过subtree会顺手把tag也带过的。
4. 实操篇:用 git filter-repo 精准按文件迁移(适合散装目录)
4.1 安装 filter-repo 和准备环境
filter-repo需要Python 3.5以上,用pip安装:
pip install git-filter-repo安装后检查版本:
git filter-repo --version这里有个使用习惯上的坑:filter-repo默认会把origin remote移除,这是为了安全设计,防止你把重写后的历史直接推回原仓库造成混乱。很多第一次用的人会困惑,记住推送前必须重新添加remote即可。
对应的就是老工具git filter-branch。Git官方早已不建议使用filter-branch,它在处理大型仓库时又慢又容易出警告。我当年用filter-branch重写过一个历史很大的仓库,跑了半个多小时,还报了一堆看不懂的warning;换到filter-repo之后,几分钟就完事。所以如果你还在学filter-branch的教程,我建议直接跳过,学习filter-repo。
4.2 按路径过滤,重写历史
假设要从一个大仓库里提取src/payment/、src/common/utils.py、tests/payment/这三个分散的目标,命令可以写成:
git filter-repo --path src/payment --path src/common/utils.py --path tests/payment --force怎么理解这些参数?--path表示“只保留这些路径”。凡是历史中不涉及这些路径的提交会被删除,保留下来的提交在重写时也只会保存这些路径的改动。最终仓库就是这组文件的历史快照,其他一切都被清理干净。
如果希望迁移后路径换位置,比如从src/payment/变成根目录payment/,可以加参数:
git filter-repo --path src/payment --path-rename src/payment:payment--path-rename会在重写历史时顺便改写路径。这样历史里直接就是payment/,不需要额外跑git mv,也不会产生额外的move提交。
另外,filter-repo还有两个常用参数:--invert-paths表示“排除这些路径,保留其他所有内容”;--strip-blobs-bigger-than 10M表示丢弃历史中超过10MB的blob对象。这两个参数在清理历史时非常有用,后面避坑章节会再提到。
4.3 推送新仓库前的最后一步:清理并推送到远端
filter-repo在重写完成后会执行垃圾回收,旧历史对象会被清理,仓库体积相比重写前会显著变小。但remote被自动移除,你需要手动添加目标新仓库地址:
git remote add origin git@example.com:team/payment-service.git然后强制推送:
git push -u origin main --force因为历史重写过,commit hash全部变了,必须用--force。这里再次强调:目标仓库最好是空仓库,如果已经有内容,先推到一个临时分支再做merge,不要直接覆盖main。
推送后还有一个非常重要的沟通动作:所有曾经clone过源仓库的同事,都必须重新clone新仓库,不能基于旧历史继续提交。否则他们一旦push,新仓库会出现两条互不相干的历史,解决起来非常痛苦,只能再把主线重置一遍。这种事情我在团队里遇过一次,花了整整一天才理顺。
5. 迁移过程必读的避坑清单
5.1 浅克隆陷阱:本地克隆深浅导致历史不全
浅克隆(git clone --depth 1)常常出现在CI环境或个人图省事的场景里,它只拉取最新一次提交。如果你拿浅克隆的仓库去做跨仓库迁移,新仓库不会包含完整历史,只有孤零零的一条提交。
我踩过这个坑。同事把一个模块从浅克隆的repo里直接推到新仓库,结果新仓库git log只显示一条提交,整个模块的演进记录全部丢失。要补救,得回到源仓库执行git fetch --unshallow,先把本地历史补全,再重新迁移。
所以迁移之前,先检查一下当前仓库是不是浅克隆:
git rev-parse --is-shallow-repository如果是true,先执行git fetch --unshallow。这一步没有做好,后面所有针对历史的操作都会失真。
5.2 大文件与仓库体积
历史里的大文件不会因为你在工作区删除而变小,它会一直躺在.git对象库里。跨仓库迁移时,如果新仓库连这些大对象也一起带过去,仓库体积仍然很臃肿,clone时间会感人。
filter-repo提供了一招解决这个问题:--strip-blobs-bigger-than 10M。在重写历史时直接丢弃超过10MB的blob对象。如果只是某个大文件在历史中被提交过、后来又删了,这个参数能让你彻底摆脱它。
迁移完成后,也可以手动执行一次垃圾回收:
git gc --prune=now --aggressive不过filter-repo在重写后通常已经很干净,不需要额外gc。这个命令更多用在旧仓库自身的历史清理场景。
5.3 敏感信息与密钥
这是最容易出大事的地方。很多人以为把密钥文件从工作区删除就万事大吉,但历史里可能还留着明文。在新仓库里如果有人翻git log -p,密钥等于直接泄露。
filter-repo的--replace-text可以批量替换历史里的敏感串。先准备一个replace.txt,格式是匹配串和替换串成对出现:
AKIA123456789==>REDACTED password123==>REDACTED然后执行:
git filter-repo --replace-text replace.txt这样历史里所有匹配到的字符串都会被替换成REDACTED,重写过程作用于所有历史对象,非常彻底。
但也要清醒意识到:如果源仓库已经被推送到了公开或半公开的远端,旧历史实际上已经在网络留痕了。仅仅在新仓库里清理是不够的,源仓库本身也要做更替或处置。这种合规层面的问题,不在Git技术之内,但绝对要在迁移清单里写一条。
5.4 分支、Tag、Submodule 的迁移优先级
分支和tag要单独处理。subtree split出来的临时分支不含原tag;filter-repo默认保留当前分支,但tag也需要额外关注。推送时别忘了:
git push origin --all git push origin --tagssubmodule是最麻烦的部分。如果目标代码里引用了submodule,迁移后submodule的remote地址可能仍然指向旧仓库。要么把submodule也迁移到新位置并更新.gitmodules,要么干脆把submodule的内容直接并入主仓库,用vendor方式管理。否则别人clone新仓库时会因为submodule地址失效而拉取失败。
我实际遇到过一次:迁移时完全没有检查.gitmodules,结果同事clone后一直提示“unable to access”,排查了半小时才发现是submodule指向旧仓库地址。那次之后,我给自己定了一条规矩:任何迁移完成后,第一件事就是检查.gitmodules是否存在,存在就先验证所有submodule地址可访问性。
6. 常见问题排查实录
6.1 clone 时提示 connection refused(本地代理端口)
症状是git clone时报错failed to connect to 127.0.0.1 port 7890: connection refused。这类问题基本都是因为你在系统里或git配置中设置了HTTP代理,指向了本地某个代理端口,而现在对应的代理客户端根本没在运行。
排查方法很简单:
git config --global --list | grep -i proxy如果查到了http.proxy或https.proxy,并且你当前并不需要通过该代理访问远程仓库,直接取消:
git config --global --unset http.proxy git config --global --unset https.proxy如果取消后clone恢复正常,说明这确实是一个残留的代理配置。如果环境里必须使用代理,那就需要先启动代理客户端再继续操作,而不是取消配置。实际问题在于配置和运行状态不一致,把状态理顺就好。
6.2 push 时 SSH 认证失败
症状是git push报Permission denied (publickey)。常见原因有两种:一是新机器没有把公钥配置到远端账号,二是本地ssh-agent没有加载私钥。
先测一下认证是否通过:
ssh -T git@github.com或者换成你实际的git服务地址。提示认证成功会显示一段欢迎信息。如果认证失败,把~/.ssh/id_rsa.pub的内容添加到远端的SSH Keys配置里。
如果之前能正常push、突然失败,先检查agent:
ssh-add -l没有key的话加载一下:
ssh-add ~/.ssh/id_rsa这类问题在多人协作的环境里很常见,尤其换电脑之后。麻烦的不是解决,而是搞清楚几个组件之间的关系。Git使用SSH协议时需要三件套:远端有公钥、本地有私钥、ssh-agent能提供认证。
6.3 新仓库合并时出现历史不相关的冲突
两个仓库有自己的初始提交时,Git默认不允许直接merge,会报refusing to merge unrelated histories。意思是两个分支的根提交没有任何共同祖先。
这时候要显式放开限制:
git merge --allow-unrelated-histories origin/import-payment这个参数告诉Git:虽然根提交不同,但我清楚风险,允许合并。合并后如果出现冲突,按普通冲突处理。
我建议在合并前先看一眼两边文件是否会互相覆盖。比如旧仓库迁移过来的是src/payment/,新仓库里恰好也有同名目录,那就大概率会产生文件级冲突。如果事先有规划,把迁移模块放在一个独立的目录前缀下,可以大幅减少冲突数量。
6.4 Windows 下 CRLF 和路径大小写问题
Windows环境下,Git默认可能会自动转换换行符。如果迁移过程中是手动复制文件,不是通过Git的checkout机制拉取,文件换行符可能被改得乱七八糟,整个diff看起来全是改动。
解决办法是在仓库根目录建立.gitattributes,明确声明换行符策略:
* text=auto配合全局或仓库级的core.autocrlf设置,可以避免大部分换行符问题。这个问题看起来是小问题,但一旦触发,生成的git diff会非常难读,影响代码审查效率。
路径大小写问题同样阴险。Windows和macOS默认文件系统大小写不敏感,Linux则非常敏感。如果迁移前目录叫Src,迁移后代码里写成了src,在本地可能一切正常,但Linux CI上就直接编译失败。所以我每次迁移完,都会在Linux容器里跑一遍完整的构建和测试,确保路径没有被隐蔽地改变。
这类跨仓库迁移我前前后后做了十几次,最大的体会是:方案选对了,过程会很顺;方案选错,后面全是补救。个人建议是先做一个最小范围的试迁移,验证历史记录、文件路径、依赖关系都符合预期,再取全量数据。另外,迁移完成后千万不要急着删源仓库,至少保留一两个迭代版本作为备份。万一新仓库里发现某些历史细节缺失,回到旧仓库还能翻出来救场。这些小习惯帮我避免了很多事故,希望对你也有用。