尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

跨仓库迁移Git代码:保留历史的subtree与filter-repo实操指南

跨仓库迁移Git代码:保留历史的subtree与filter-repo实操指南 最近团队里有个老仓库要拆分成多个独立仓库其中一个核心模块要从原来的单体仓库迁到一个新建仓库里。同事问我能不能只把那个模块的代码搬过去历史提交也保留一下我说能但搬法不同结果天差地别。这件事在圈子里有个统一叫法跨仓库迁移部分代码。表面上看只是把文件复制过去实际牵涉到历史记录、目录结构、依赖关系、敏感信息清理还有一堆容易踩的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 -Spassword的方式查历史里有没有明显的密钥提交。最后看分支结构源仓库里哪些是长期分支、哪些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 方案Bpatch文件——轻量带历史但不优雅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 方案Cgit subtree split——目录拆分的利器git subtree split是Git自带的子模块管理命令之一用来把指定目录的历史抽取成一个独立分支。原理上它会遍历仓库里的所有提交筛选出接触过该目录的提交重写成只包含该目录内容的提交序列。它最大的优点是操作简单、速度快而且是在source仓库之外生成新分支原仓库本身完全不受影响。我把一个包含几百次提交的模块目录拆分出来只需要一条命令几秒钟完成。唯一的限制是它只能处理一个目录前缀。如果迁移目标是散落的多个文件subtree做不了。但如果是模块目录整体搬迁我强烈建议优先用它。2.4 方案Dgit 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 --prefixsrc/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 gitexample.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 gitexample.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 --prunenow --aggressive不过filter-repo在重写后通常已经很干净不需要额外gc。这个命令更多用在旧仓库自身的历史清理场景。5.3 敏感信息与密钥这是最容易出大事的地方。很多人以为把密钥文件从工作区删除就万事大吉但历史里可能还留着明文。在新仓库里如果有人翻git log -p密钥等于直接泄露。filter-repo的--replace-text可以批量替换历史里的敏感串。先准备一个replace.txt格式是匹配串和替换串成对出现AKIA123456789REDACTED password123REDACTED然后执行git filter-repo --replace-text replace.txt这样历史里所有匹配到的字符串都会被替换成REDACTED重写过程作用于所有历史对象非常彻底。但也要清醒意识到如果源仓库已经被推送到了公开或半公开的远端旧历史实际上已经在网络留痕了。仅仅在新仓库里清理是不够的源仓库本身也要做更替或处置。这种合规层面的问题不在Git技术之内但绝对要在迁移清单里写一条。5.4 分支、Tag、Submodule 的迁移优先级分支和tag要单独处理。subtree split出来的临时分支不含原tagfilter-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 gitgithub.com或者换成你实际的git服务地址。提示认证成功会显示一段欢迎信息。如果认证失败把~/.ssh/id_rsa.pub的内容添加到远端的SSH Keys配置里。如果之前能正常push、突然失败先检查agentssh-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明确声明换行符策略* textauto配合全局或仓库级的core.autocrlf设置可以避免大部分换行符问题。这个问题看起来是小问题但一旦触发生成的git diff会非常难读影响代码审查效率。路径大小写问题同样阴险。Windows和macOS默认文件系统大小写不敏感Linux则非常敏感。如果迁移前目录叫Src迁移后代码里写成了src在本地可能一切正常但Linux CI上就直接编译失败。所以我每次迁移完都会在Linux容器里跑一遍完整的构建和测试确保路径没有被隐蔽地改变。这类跨仓库迁移我前前后后做了十几次最大的体会是方案选对了过程会很顺方案选错后面全是补救。个人建议是先做一个最小范围的试迁移验证历史记录、文件路径、依赖关系都符合预期再取全量数据。另外迁移完成后千万不要急着删源仓库至少保留一两个迭代版本作为备份。万一新仓库里发现某些历史细节缺失回到旧仓库还能翻出来救场。这些小习惯帮我避免了很多事故希望对你也有用。
返回列表