
上个月我接到一个有点“零碎”的需求要把某个后端仓库里的支付模块迁移到另一个新建的仓库去。这个模块大概有四十几个文件牵扯到几十个 commit 的改动历史旧仓库的 CI、Dockerfile、依赖清单里到处都有它的影子。听到“迁移部分代码”很多人的第一反应是复制粘贴或者直接把整个目录拷过去提交了事。但如果团队里几十个人都在用这个仓库代码历史又关联着需求单、bug 修复记录和上线审计简单复制就会把所有上下文丢掉。反过来直接把整个仓库搬过去也不现实旁边还躺着订单、库存、用户中心一堆无关代码。这种场景就是典型的“跨仓库迁移部分代码”把指定目录或一组文件从源仓库搬到目标仓库尽量保留有意义的提交记录同时不让两边之后的开发互相踩脚。这篇文章不想讲那种“整体仓库搬家”的教程而是专门针对“只搬一部分”的情况把我完整跑通的流程、踩过的坑、以及为什么在四个方案里最终选了 filter-repo一次说清楚。1. 先别急着敲命令跨仓库迁移部分代码前要确定的三件事1.1 这次迁移到底想保留什么历史能不能丢迁移之前我习惯先问需求方一个问题搬过去的代码历史记录重要吗如果你的场景是“把临时写的脚本丢到新仓库”“把某个开源项目的单文件拿过来改改”那直接复制粘贴完全没问题。但多数真实业务模块不是这样。支付模块里的每一次提交都可能是某次事故的修复、某个客户需求的落点或者审计时要用到的凭证。这种时候保留 commit 历史就不是“锦上添花”而是“基本要求”。不同保留程度对应完全不同的实现手段完全不要历史直接在目标仓库git checkout oldrepo/master -- src/payments然后 commit 一次。保留部分提交用git format-patch把某几个 commit 导成补丁文件再到目标仓库git am打进去。保留指定目录下所有历史用git subtree split或者git filter-repo。保留所有 commit 但只挑出相关文件filter-repo是当前最可控的方式。我见过不少团队第一次做这种事拍脑袋选了最快的方式等到半年后查线上问题、翻 blame 时发现历史是空的只能对着代码猜当时的上下文那才叫痛苦。所以先谈“历史要不要”后面所有技术选型才有意义。1.2 迁移边界目录、文件还是“某次提交开始”的全部代码第二个要理清的是边界。这里的边界不只是“src/payments 这个目录要不要包括 tests/payments”还包括代码是否还引用同一仓库里其他模块的类、函数、配置某个接口是只被你迁移的模块用还是旁边好几个模块都在用数据库迁移 SQL、资源文件、配置文件、Dockerfile 是否也要一起搬那部分代码在旧仓库里可能不在同一个目录下src/main/java/com/example/payments只是核心代码config/payment.yml和scripts/migrate_payment_db.sh分别散在别的位置。我的做法是先把所有相关路径列在一张纸上宁可多列出来再逐个跟团队确认“哪些必须搬、哪些可以留着”。这一步不要省因为后面filter-repo的--path参数就是拿这张清单当输入。漏了一个路径搬过去之后编译不过重新跑一遍 filter-repo 会把所有 commit hash 再改一次影响一堆人。还有一个容易被忽略的边界有些 commit 是在目录重命名之前提交的。比如支付模块最早叫src/checkout/payment后来才改名为src/payments。如果直接用--path src/payments提取很可能会丢掉重命名之前的历史。这时候要么把旧路径也加进--path要么用--path-rename把新旧路径统一起来。这个细节后面实操章节会展开。1.3 依赖边界代码移走后旧仓库和新仓库各自缺了什么迁移代码本质上不是“把文件从一个格子挪到另一个格子”而是“把一个节点从旧网络里拆出来再连到新网络里”。代码本身只是节点依赖关系才是真正的主干。迁移前建议做一次依赖扫描把下面几类东西全部找出来import / require / include 语句中指向旧仓库其他模块的引用通过 Maven / Gradle / npm / pip 声明但实际由本地模块提供的包Docker 构建上下文里引用的其他目录CI 配置中预设的环境变量、SSH key、私有仓库地址硬编码的路径比如/app/payments/这种部署路径同一个 git 仓库里共享的公共库模块。这一步的心情起伏很大。你以为搬的是一个模块最后发现搬的是一个“隐形的微服务”——它依赖了公共库里 5 个类而公共库又不能整个搬过去。遇到这种情况最好的选择不是强行把公共库也搬过去而是在新仓库里抽出需要的公共代码或者把公共库单独拆成独立包然后两边都依赖这个包。2. 四种迁移路径的取舍从最小改动到彻底重写历史在做具体命令之前我把四种主流做法摆开对比过一遍。很多文章只推其中一种但实际工程里没有银弹关键看你的边界和历史要求。2.1 直接复制文件零历史最快适合一次性搬运这是最原始也是最常见的方法。到旧仓库把目标目录拷下来粘到新仓库提交完事。简单到不需要解释。但不建议在“代码还在活跃开发”的业务模块上这么干原因有三历史被清零git blame、git log全变成一条空提交之后排查问题全靠记忆。复制过程中容易把.gitignore忽略掉的本地文件或者构建产物一起带过去。你没法区分“哪些文件是应该搬的”等于把所有文件混成一团交过去。它真正适合的场景是从一个不打算再维护的参考项目里提取样例代码或者搬一个基本冻结的旧模块过去做归档。如果你正在迁移一个活模块请直接跳到下面几个方案。2.2 format-patch git am按提交粒度搬运但边界容易漏git format-patch能把一个或多个 commit 导出成补丁文件然后在目标仓库用git am重新应用。这样做的好处是保留了每个提交的改动内容和原始 message作者信息也能带上。实际操作时可以先在旧仓库里看目标目录的提交记录cd old-repo git log --oneline -- src/payments然后确定一个起始提交把从它到当前分支的所有相关提交导出git format-patch start-commit..HEAD -- src/payments --stdout payments.patch再到新仓库cd new-repo git am payments.patch这个方案的第一个坑是它只跟踪“对路径有直接改动”的提交。如果某个提交把公共类从common/util.py移动到了payments/util.py它对两个路径都有操作但导出-- src/payments时会把这个文件的新版本算进去却不会带上它在旧路径下的历史。你可能最终能看到文件但它的“前世”丢了。第二个坑是如果目标文件路径在新仓库已经存在git am会产生冲突而 conflict 解决起来比普通 merge 还麻烦。所以我的结论是format-patch适合迁移少量、边界清晰、路径稳定的提交比如把某个模块过去三天的 bug 修复同步到新仓库不适合做大面积目录级迁移。2.3 subtree split保留了目录历史但可能夹带“路过”的提交git subtree是 git 官方支持的一种子项目管理方式核心思路是把一个仓库的子目录当作另一个仓库的完整历史来做。要把旧仓库的src/payments目录拆成一个独立分支cd old-repo git subtree split -P src/payments -b payments-branch这样payments-branch里就只有src/payments下的文件以及这些文件的历史。然后可以把它推到新仓库git push ../new-repo payments-branch:master它的优势是历史完整、操作命令少、不依赖额外工具。但我也遇到过不太顺的地方subtree split是按树对象一层层拆的只要某次提交里改动过src/payments下面任何一个文件这个提交就会被算进去。如果同一个提交里既改了支付模块又改了订单模块那么payments-branch的这次提交虽然不包含订单文件但提交 message 里可能同时提到订单历史看起来是“夹带”的。大多数情况下能接受可如果你要求每条历史都干净到只跟支付相关那就得靠 filter-repo。另外subtree split产生的新分支和原仓库不是同一个 commit hash它内部通过映射关系维持关联这个关联关系在把分支推给新仓库后并不会自动带过去。所以别指望在新仓库里还能用git subtree pull跟旧仓库无缝同步。2.4 filter-repo外科手术式提取最接近“完整迁移”的解法git filter-repo是官方推荐的历史重写工具近些年被很多人用来清理仓库大文件、删除敏感信息、按路径拆分目录。它比老一代的filter-branch快得多规则也更清晰。在“跨仓库迁移部分代码”这个场景里它做的是扫描整个仓库的提交图把所有跟目标路径无关的文件从每个提交里删掉再把因此产生的空提交清理掉最后得到一段干干净净、只包含目标目录历史的提交链。它对历史的重写比 subtree 更彻底。subtree 只是“提取”filter-repo 是“重写”。如果旧仓库的目标目录下原来有一个README.md提到了整体系统架构filter-repo 不会替你把内容改掉它只是保留文件本身。如果你的目标是“支付模块被独立成一个项目”你可能需要自己再对文档做一次清理。这也是我最终选 filter-repo 的原因它能精确控制哪些路径进入新仓库、哪些全部丢弃还能顺便解决路径重命名、作者改写、空提交清理等问题。代价是它改变了所有 commit hash所以操作前必须把影响半径想清楚。2.5 方案对比总表方案是否保留历史历史完整度操作复杂度适用场景直接复制文件否无极低一次性参考代码、冻结模块归档format-patch am是有限易漏路径边界中少量提交、短期同步git subtree split是较完整可能夹带无关 message中不想额外装工具、接受少量杂质git filter-repo是最可控可精确切割较高真正把模块“独立成仓”、需要干净历史3. filter-repo实操把支付模块从旧仓库完整搬进新仓库下面这段是我实际操作中跑通的完整链路。假设旧仓库叫monolith-server目标新仓库叫payment-server要搬的目录是src/payments和tests/payments。3.1 前置准备clone一份全新的源仓库git filter-repo刻意设计成“只有在 fresh clone 的仓库上才允许运行”目的就是防止有人直接在原仓库上执行并破坏远端历史。所以第一步永远是克隆一份全新的副本。git clone --no-single-branch /path/to/monolith-server monolith-migrate cd monolith-migrate git branch -a--no-single-branch很关键。默认 clone 只拉取远端默认分支如果支付模块的历史散落在release/2.0、feature/payment-v3等分支里单分支克隆会把这些分支的历史全部漏掉。加了这个参数Git 会拉取远端所有分支和 tag 的 commit 对象后面重写时才能覆盖到完整历史。克隆好之后先把 origin 这个远程配置记下来因为 filter-repo 运行后会自动删掉它。3.2 用 --path 提取目标目录并删除无关历史执行下面这条命令把仓库重写成只包含src/payments和tests/payments两个路径git filter-repo --path src/payments --path tests/payments --force--force是因为这份克隆已经被我加过参数或者看过分支filter-repo 会判断它不是“刚 clone 完、从未改过”的仓库需要用 force 覆盖这个保护机制。如果你严格在刚 clone 完的仓库里直接执行可以不加 force但实际流程里很少有人能忍住不先看一眼分支所以干脆加上。执行完之后仓库里的所有文件应该只剩下这两个目录所有提交也变成只涉及这两个目录的提交。你可以检查一下git log --oneline --all | head -20 git ls-files | head -50如果发现某些提交变成了空提交原提交只改了支付模块之外的文件被过滤后没有任何文件变化可以再补一次清理git filter-repo --path src/payments --path tests/payments --prune-emptyalways--prune-emptyalways会删掉所有空提交。默认的 auto 模式通常也会清理但如果遇到保留空提交用于维持 merge 结构的情况可以手动加强。3.3 处理路径前缀保留原路径还是放到新仓库根目录迁移到新仓库后代码所在位置不一定要沿用原来的目录结构。如果要同时把src/payments平移到新仓库根目录可以在 filter-repo 命令里加--path-renamegit filter-repo \ --path src/payments \ --path tests/payments \ --path-rename src/payments:payment \ --path-rename tests/payments:tests/payment \ --force这里--path-rename只对匹配到的路径生效而且可以和--path组合使用。它会重写历史里所有相关路径比迁移完再git mv更好因为后者会留下一整段“移动文件”的历史噪声。不过我不建议一次性做太多改名。如果新仓库本身已经有一些结构先保持原有路径导入成功后再用git mv移动会更容易排查问题。只有在新仓库是全新空仓、路径又明显不合理的时候才值得在 filter-repo 阶段顺手改名。3.4 导入目标仓库并保留提交信息重写完源仓库的临时副本后回到目标仓库把这份历史拉进来cd /path/to/payment-server git remote add migration /path/to/monolith-migrate git fetch migration master:refs/heads/import/payments我习惯先拉到独立分支而不是直接合并到主干。这样可以在合并前做一次完整的 diff 和 reviewgit log --oneline import/payments git diff import/payments..master --stat确认没问题后再合并进来。由于两个仓库之前没有共同祖先必须使用--allow-unrelated-historiesgit merge import/payments -m feat(payments): import payment module from monolith如果目标仓库已经有同名文件这个 merge 会产生冲突。解决冲突后提交然后删除临时 remote 和分支git remote remove migration git branch -D import/payments注意git merge会用三分量合并因为两份历史的根 commit 不同很多文件会被当作新增而不是冲突。这通常是合理的但如果有少量文件恰好同名且内容相似一定要在合并前先看 diff。3.5 迁移后的验证不只对比文件还要对比commit id代码merge进去后至少要验证三件事。第一文件完整性。在旧仓库克隆副本里生成一个文件清单再在新仓库里生成同一份清单diff 一下cd monolith-migrate git ls-files | sort /tmp/old-files.txt cd /path/to/payment-server git ls-files | sort /tmp/new-files.txt diff /tmp/old-files.txt /tmp/new-files.txt第二提交内容是否对得上。由于 filter-repo 改写了提交 hash不能用 commit ID 直接找对应关系但可以通过提交 message、作者、时间戳这些维度抽样核对。比如拿旧副本里的某个中间提交的 message 在新仓库的 git log 里搜索找到它看它下面改动的文件是不是也在迁移范围内。第三构建验证。这一步比前面任何检查都重要因为代码迁移光是“文件对得上”没用跑不通就是废的。拉到最新分支之后立刻构建编译错误会非常诚实地告诉你还有哪些依赖没搬过来。4. 迁移之后最容易翻车的四件事依赖、CI、模块路径和团队同步4.1 依赖关系梳理用一把好用的 grep 把所有隐性引用找出来filter-repo 能把文件和历史搬过来但它不会自动改写代码里的 import。迁移完第一件事就是全项目搜索旧仓库的包名、模块名、路径前缀。假设旧仓库 Redis 连接工具类叫com.example.monolith.common.cache支付模块里大量使用它。迁移后要么把这个工具类也搬过来要么把它替换为新仓库里已有的实现。我通常会用一个临时清单grep -R com.example.monolith --include*.java . grep -R from common --include*.py . grep -R require\(.*monolith --include*.js . grep -R monolith-server --include*.gradle --includepom.xml .凡是搜出来的结果逐个确认是“应该替换为新仓库的等价引用”还是“这个依赖本来就不该被支付模块使用迁移前已经被宿主的旧仓库默默继承了”。后者往往是被隐藏的最深的问题。4.2 构建与CI脚本里的绝对路径和分支依赖这个坑我印象特别深。之前迁移一个 Python 服务代码搬完编译都过了一执行部署就报“找不到配置文件”。最后发现是部署脚本里写死了/opt/monolith/payments/config.yml而新仓库的部署路径是/opt/payment/config.yml。CI 同样会有问题。旧仓库的 GitLab CI 或 GitHub Actions 里可能把构建依赖的某个私有仓库地址写在环境变量里也可能把上传制品包的路径写成了带旧项目名的地址。迁移后需要把所有 CI 配置文件翻一遍.gitlab-ci.yml.github/workflows/*.ymlJenkinsfile.circleci/config.ymlDockerfile 中的WORKDIR和COPY路径集群部署用的 helm values 或 k8s manifest我的建议是不要等 CI 报错再改而是在迁移后的第一次 CI 运行前就手动把所有写死的旧项目名、旧路径列出来做一个全局替换。替换之后还要确认分支策略旧仓库的 master 不等于新仓库的 master触发流水线的分支名、tag 规则都要同步调整。4.3 模块名和导入路径的重构如果旧仓库是单体服务模块名往往跟仓库名强绑定。进入新仓库后最怕的就是代码里到处都是旧仓库的包名却还叫“新服务”。这不仅仅是风格问题。Java 的package、Go 的module、Python 的import、Node 的package.jsonname 都会影响依赖解析。比如 Go 服务迁移后如果不修改go.mod里的 module 路径本地能跑但其他同事拉下来后一旦有跨包引用就会因为路径对不上而无法编译。拿 Go 举例迁移前是package monolith/payments/service迁到payment-server后至少要改成package payment/service并且go.mod第一行也要同步改。对于大型代码库这种改名别全手动最好用 IDE 的 rename 功能或者配合脚本做批量替换。改完之后跑一次完整测试不要只编译。模块名改动的真正难点在于它会破坏“历史连续性”。代码原来叫monolith/payments/something.go现在叫payment/something.go将来git blame时看到的是迁移合并那条消息而不是具体每一次改动的原始记录。这个心理预期要提前跟团队讲清楚。4.4 团队协作hash变化和PR重新提交迁移完旧仓库里可能还浮现着二三十个未合并的 MR/PR里面都改到了支付模块的代码。filter-repo 改写了所有 commit hash旧 PR 里的 commit 跟新仓库的历史已经对不上了。如果你把新仓库远程地址告诉了团队让他们直接往新仓库发 MR这些分支过去之后大概率会因为“历史不连续”出现诡异冲突。我的做法是给团队留两到三天的“缓冲期”期间旧仓库允许继续小幅度改动但属于支付模块的改动都要同时登记到迁移清单里迁移合并完成后所有人基于新仓库 master 重新拉分支旧 PR 能关就关不能关的重新在新仓库里提一版。另外因为 filter-repo 会删除 origin重写后仓库里的所有 commit hash 都变了任何基于旧 hash 的自动化脚本都要失效重配。像是部署系统里记录“上次构建版本号 某 commit hash”、监控面板里链接到某个 commit 的功能都需要同步更新。5. 没有filter-repo也能干活read-tree和checkout路径导入的轻量方案虽然 filter-repo 是当前最推荐的方案但不是所有环境都能安装它。Python 环境受限、公司安全策略禁止装第三方工具、或者只是想快速同步一下代码的时候Git 自带命令也能承担一部分工作。5.1 git checkout -- path最常见的无历史导入这是最轻量的文件级导入方案cd /path/to/new-repo git fetch /path/to/old-repo master git checkout FETCH_HEAD -- src/payments git commit -m chore: import current payments code这条命令会把旧仓库src/payments目录下的最新文件直接写到新仓库的工作区和索引里。它不保留任何历史但它有一个额外优点如果新仓库里已经有一份同名文件checkout会提示冲突你可以先备份再决定怎么覆盖。缺点也很明显如果把模块是分阶段开发出来的比如 v1 有几十个文件v2 把其中 5 个文件改名了git checkout FETCH_HEAD -- src/payments只会把当前分支对应的整个目录拿过来目录下被删掉的文件并不会自动从新仓库里清除。所以这条命令更适合“新仓库里本来没有这个目录”的场景。5.2 read-tree --prefix把旧仓库的某个tree塞进新仓库的子目录如果想把旧仓库的某个目录完整地导入到新仓库的指定子目录下并且新仓库里已经有了别的内容用read-tree会更精准。cd /path/to/new-repo git fetch /path/to/old-repo master git read-tree --prefixmodules/payments/ FETCH_HEAD:src/payments git checkout-index -a git commit -m chore: import payments tree into modules/payments这里FETCH_HEAD:src/payments是取旧仓库最新提交里src/payments这个 tree 对象--prefixmodules/payments/决定把这个 tree 放到新仓库的什么位置。read-tree默认只更新 index所以后面还需要checkout-index -a把文件展开到工作区。这条命令不会保留提交历史但它能做到“只把指定 tree 塞进指定位置”比粗暴复制整个目录要干净。适合在小型团队、工具受限、也不在乎历史的情况下快速完成目录级迁移。5.3 轻量方案的适用场景和局限这类方案最大的局限是它们做的都是“状态迁移”不是“过程迁移”。代码最终长什么样能保证但代码是怎么一步步变成这样的过程信息全部丢失。如果迁移目的只是为了归档或者新仓库只是运行代码的容器不需要做审计和复盘那没问题。但如果是核心业务模块以后还要继续在上面积累需求、排查线上事故我强烈建议至少用subtree split预算允许就上filter-repo。毕竟git filter-repo唯一真正的门槛是历史重写需要理解 commit hash 变化的含义而不是它有多难安装。6. 长期维护视角迁完之后是“一次性结算”还是“持续同步”代码搬过去只是开始。真正影响后续开发体验的是迁完之后选择什么样的仓库关系。6.1 subtree push/pull想双向同步时的配置方式如果你迁移出去的模块以后还要从旧仓库拉回新代码或者反过来旧仓库要用新仓库的改动那么在迁移阶段就应该优先用git subtree而不是filter-repo。原因很简单filter-repo把两个仓库共同的“根”彻底打掉了之后再想让两者产生交叉合并Git 找不到共同祖先会非常痛苦。使用git subtree的持续同步流程大致是在旧仓库中把src/payments拆分推给新仓库git subtree push -P src/payments /path/to/payment-server master之后新仓库的代码变更想要合并回旧仓库cd /path/to/payment-server git subtree push -P src/payments /path/to/monolith-server some-branch在旧仓库侧拉取git subtree pull -P src/payments /path/to/payment-server master这套机制能工作但每次 push/pull 都会把整棵子树的历史做一次三路合并commit 会越来越多冲突也容易出现在“两边同时改了同一处”的情况下。所以它适合“代码仍然高度耦合、暂时拆不了”的场景如果你已经定了长期分家这种双向同步反而会拖累两边独立演进的速度。6.2 发布成独立包/组件比源码搬运更干净的模块化跨仓库迁移部分代码的尽头往往不是“把代码放进新仓库就完事”而是“让这段代码变成可以被多个仓库引用的独立单元”。这时候更好的做法是把它打包成独立制品。Java 可以打 jar 传到私有 Maven 仓库Node 可以发 npm packagePython 可以发到私有 PyPIGo 可以直接用 git tag go mod 引用另一个仓库的模块。把代码变成包之后消费方不需要关心代码仓库在哪里只需要在构建配置里声明依赖版本。这不是否定代码迁移而是说迁移应该和“模块化”同时进行。我见过太多团队把代码从一个仓库搬到另一个仓库内部还是一团纠缠的依赖结果只是换了个地方继续耦合。迁移时如果不顺便把依赖边界切干净下一次拆分必然还会来一次。6.3 从代码迁移到仓库治理一份迁移前检查清单最后分享一份我每次做跨仓库迁移都会认真过的检查清单照着打勾能省掉大量返工[ ] 明确历史保留要求完全保留 / 部分保留 / 可不保留[ ] 列出全部待迁移路径包括配置、脚本、测试、资源和CI文件[ ] 扫描代码中所有对源仓库其他模块的依赖并给出处理方案[ ] 确认旧仓库分支和tag中哪些也需要迁移绑定[ ] 准备新仓库的分支结构、命名规范和初始README[ ] 确定是否需要在迁移过程中改包名、模块名和路径前缀[ ] 检查新仓库是否已有同名文件以及是否有下一步冲突预案[ ] 执行迁移后做文件清单diff、提交历史抽样diff和构建验证[ ] 更新CI、部署脚本、监控面板、文档中对旧仓库路径的引用[ ] 通知团队旧仓库停止对迁移模块的新改动给出新仓库的PR入口我在实际迁移里体会最深的一件事命令本身十分钟就能跑完真正花时间的永远是“边界到底在哪里”和“搬完后面还有什么跟着一起断掉”。所以如果你现在准备做类似的事别急着找工具先把上面这份清单填完填的过程中你就已经知道该怎么做了。