上周有个同事跑来找我,说他那个维护了两年多的老项目里,有一套做权限校验的代码,现在新项目也要用,能不能直接从旧仓库把这块代码搬过去。我第一反应是问他:你们要不要保留提交历史?他说当然要,以后出Bug还得靠git log往回翻,看是哪次提交把人带沟里的。于是这事就成了一个非常典型的“跨仓库迁移部分代码”需求:源仓库和目标仓库是两个独立的Git仓库,要搬走的只是其中一个子目录,同时还得把跟这个目录相关的提交历史一并带走。
这种需求在平时开发里一点也不少见。单体应用拆微服务的时候,要把某个模块单独拎出去;产品线扩张的时候,要把公共组件抽成独立仓库;团队调整的时候,代码归属要跟着组织架构一起搬家。很多人第一反应是把文件夹直接拷过去,提交一次完事。但如果那块代码已经沉淀了几百条提交记录,直接拷贝就意味着把这些历史全部丢掉。后面想查一个配置项为什么这么写、一个逻辑是谁在什么背景下加的,全都查不到了。本文就把我做这类迁移的完整思路和实操步骤写下来,从方案选型到命令细节,再到各种踩坑记录,一次说清楚。
1. 为什么需要专门做“跨仓库迁移部分代码”
1.1 三种最常见的需求场景
第一种是抽组件。项目里有一段代码被多个业务方盯上了,今天这个产品要复用,明天那个后端服务也要引用,继续放在原来的单体仓库里,别的仓库拉取很不方便。于是把它单独抽成一个独立仓库,通过依赖管理工具引用。这类场景最看重“历史能不能带上”,因为组件本身的演进过程,对使用方判断兼容性很有价值。
第二种是拆仓库。老系统发展了好几年,代码越堆越多,仓库越来越臃肿。每次git clone都要拉半天,CI构建也卡在代码量上。团队决定按领域边界把仓库拆开,某些模块要整体挪到新仓库里。这种拆法比抽组件更复杂,因为模块之间往往有千丝万缕的依赖,迁移前得先理清楚边界。
第三种是组织归属调整。比如某个业务线被划到了另一个部门,对应的代码仓库也要整体移交。移交的时候不能只给一个快照,接管方需要完整的历史记录来继续运维。如果直接把.git目录打包带走,里面可能掺杂了其他部门不相关的历史分支,并不合适。
1.2 直接复制粘贴为什么行不通
我见过太多人图省事用cp -r或者直接在文件管理器里拖文件夹。文件是过去了,但至少丢掉四样东西。
第一,提交历史。Git里所有关于这段代码的演进轨迹全没了。哪天线上出问题,git blame指向的第一行是“initial commit”,等于没有信息。
第二,提交信息里的上下文。很多提交信息会写“修复了某次重构引起的缓存失效”“调整了接口超时策略以适配网关改造”,这些信息对后来接手的同事是无价之宝。直接拷贝后,这段代码背后的决策理由就断档了。
第三,分支和标签的关联。如果这个模块有独立的维护分支,有发版标签,直接拷贝全都丢了。后续需要回溯某个版本行为的时候,完全无从下手。
第四,评审记录和讨论。现代开发基本都有MR/PR流程,代码合入时候的讨论、review意见都挂在提交记录上。历史一丢,相当于一段代码的“病历本”没了,出了状况只能重新摸索。
所以只要这个模块还有继续维护的价值,我都强烈建议用正规的迁移手段,把历史和代码一起搬过去。Git本身提供了对应的能力,关键看你会不会用。
2. 动手前,先想清楚这三件事
2.1 确认迁移边界,划分文件清单
迁移最忌讳边界模糊。你得先把“要迁哪部分”精确到目录级别,而不是凭感觉说“大概就那块”。建议操作前在源仓库里跑一遍git ls-files,把模块涉及的所有路径列出来,逐项确认。
比如要迁移的是src/permission模块,那至少要检查这些路径:
src/permission/主代码目录src/permission/__tests__/配套测试docs/permission.md相关文档scripts/permission-tools/构建辅助脚本
特别容易漏的是两类文件:一类是模块内部引用了但放在模块外面的公共工具函数,一旦迁走引用就断了;另一类是模块的配置文件,比如eslint局部配置、.env.example里的对应变量,不一起迁走就是一堆坑。我建议迁移前先跑一次全量构建和测试,把依赖关系摸清楚,再确定最终的路径清单。
2.2 决定历史保留策略
两条路线,没有中间态。
第一,保留全量历史。用git filter-repo或者git filter-branch把源仓库中指定路径下的完整提交历史重写出来。好处是迁移后的代码依然可以用git log回溯任何一次改动,坏处是所有涉及的提交哈希都会变,原来挂在这些提交上的外部引用、CI通知、Issues链接会部分失效。
第二,只保留当前快照。用git clone --depth 1再删掉.git,或者干脆直接拷贝工作区文件。好处是干净利落,坏处是历史全部丢失。我刚入行时也干过这种“一刀切”的事,但后来发现,一旦业务方说“帮我看看这个配置是哪个版本开始变的”,就只能傻眼。
怎么选?我的判断标准很直接:只要这段代码还在活跃维护,就值得保留历史。只有当模块是临时交付、对方只关心当前状态,或者原仓库马上就要废弃时才选快照方案。
2.3 确认目标仓库形态
目标仓库是空的,还是已经有大量代码?这个问题决定迁移后要不要处理合并冲突。
如果是空仓库,最省事。把迁移后的代码推上去,作为主干继续开发就行。如果目标仓库已经存在,情况就复杂了。比如里面已经有一个src/common/目录,你要迁入的也是src/common/,那合并时必然大量冲突。我的习惯是先用--path-rename把要迁入的代码放到一个带命名空间的新目录里,比如src/modules/permission/,等运行稳定后再考虑要不要合并进公共目录。这样做冲突少,回溯也清晰。
另外,还要提前想好源仓库里那块代码将来怎么办。常见策略是迁移完成后从源仓库中删除,避免两边双份维护。但如果两边还需要并行开发一段时间,那就得约定一个“唯一事实来源”,避免两边各改各的,最后又得合并一次。
3. 主方案:用 git filter-repo 保留完整提交历史
3.1 安装 filter-repo 与前置检查
git filter-repo是目前官方比较推荐的仓库重写工具,Python写的,安装很简单。
pip install git-filter-repo装完确认版本,Git版本最好在2.24以上,太老的情况下某些功能会不正常。
git filter-repo --version这里有一个非常重要的前置安全逻辑:filter-repo默认不允许在非clone出来的仓库上直接跑,因为它会重写历史,误操作很伤。所以第一步一定要先clone一份镜像仓库到临时目录,所有重写操作都在这个副本上完成,源仓库保持原样,改坏了随时重来。
git clone --no-hardlinks /path/to/old-repo /tmp/migration-work cd /tmp/migration-work用--no-hardlinks是为了避免硬链接共享对象文件,否则后续重写时可能出现意外关联。
3.2 核心操作步骤与参数解读
假设要迁移的是packages/permission目录以及它的测试目录packages/permission-test,先在副本仓库上执行:
git filter-repo --path packages/permission --path packages/permission-test --force--path参数可以传多次,每个路径代表你要保留的目录或文件。它的意思是:改写后的提交历史里,只保留这些路径下的文件,其余全部丢掉。注意,filter-repo默认会重写所有分支和标签,包括工作区中未提交的内容,所以操作前确认没有未提交的改动。
还有一个很有用的参数是--path-rename,它可以在迁移的同时把目录改成目标位置。比如:
git filter-repo \ --path packages/permission \ --path-rename packages/permission:src/permission \ --force这样历史里这个目录就从packages/permission变成了src/permission。如果你希望迁移到目标仓库后路径保持独立,尽量在这里就规划好,比迁移后再移动要省事得多。
执行过程会输出一堆信息,核心是Re-written history和它统计的commit数。如果看到某个分支在重写后commit数量大幅减少,不要慌,那说明有些commit里本来就只涉及其他路径的文件,在过滤后变成了空提交,被自动清理掉了。
3.3 迁移后清理与提交历史检查
filter-repo跑完之后有一个容易让人懵的行为:它会把远程仓库配置删掉,git remote -v输出是空的。这是故意的,因为重写后的历史已经和源仓库不匹配了,留着remote容易有人误操作把重写结果推回源仓库。此时需要手动添加新目标仓库的remote。
git remote add origin git@your-host:new-project/repo.git git remote -v提交之前一定要先看历史。我会习惯性地跑一条:
git log --oneline --graph --all | head -60确认只保留了目标路径相关的提交,确认--path-rename生效。再用一条命令检查目录结构是否符合预期:
git ls-files | head -30把这两步都确认好,再考虑推送。还有一个细节:如果源仓库有些分支是模块的独立维护分支,filter-repo默认也会一并重写。如果你只想要主干分支,可以用--refs refs/heads/master限定范围,避免把一堆实验分支也搬过去。
4. 备用打法:filter-branch 的老套路
4.1 filter-branch 基本命令
在没有filter-repo的年代,大家用的一般是git filter-branch。如果你手头的环境装不了新工具,或者公司统一要求用老命令,那至少得知道怎么用。
最常见的是subdirectory-filter,它的作用是把某个子目录提升为整个仓库的根,非常适合“整个模块独立出去”的场景。
git filter-branch --subdirectory-filter packages/permission -- --all这条命令会把packages/permission这个目录下的内容变成新仓库的根目录。比如原来仓库的路径是packages/permission/index.js,过滤之后仓库根目录直接就有一个index.js,所有涉及这个目录的提交历史都会被保留,但提交哈希全部重写。
如果只想删掉某些目录,保留其他内容,可以配合--tree-filter使用:
git filter-branch --tree-filter 'rm -rf private-code' -- --all注意,--tree-filter会把每个commit都checkout出来,执行命令后再提交回去,速度非常慢。仓库稍微大一点,跑一两个小时都是正常的。
4.2 两个方案的取舍
这里直接给一个对比表,帮大家快速决策。
| 对比维度 | filter-repo | filter-branch |
|---|---|---|
| 维护状态 | 官方推荐,持续更新 | 官方已提示不建议在新项目使用 |
| 执行速度 | 快,pygit2优化,直接操作对象 | 慢,每个commit都要checkout一遍 |
| 路径过滤 | 支持多个路径、路径改名 | 支持子目录提升,复杂场景需组合 |
| 安全性 | 默认禁止非clone仓库,强制参数可覆盖 | 默认就允许直接跑,容易误伤源仓库 |
| 学习成本 | 参数直观,大概5分钟上手 | 参数多,组合逻辑容易绕晕 |
我的建议很简单:新项目一律用filter-repo。老脚本如果是基于filter-branch写的,能跑就继续跑,但不要在这上面再投入新逻辑了。GitHub上关于filter-branch的文档页开头就挂着“警告”标识,说它没有经过性能和安全方面的充分评审,属于旧时代留下的工具。
5. 不需要历史时的快速迁移打法
5.1 基于 clone --depth 1 的快照迁移
如果确定历史不重要,只想要当前代码状态,最省事的办法是:
git clone --depth 1 git@your-host:old-project/repo.git /tmp/snapshot cd /tmp/snapshot rm -rf .git--depth 1表示只拉取最新一次提交的快照,不包含历史对象。删掉.git后,这目录就变成了一个和Git完全无关的普通文件夹。把它拷到目标仓库对应位置,提交一次,迁移完成。
另一个更优雅的方式是用git archive,直接导出干净的快照包:
git archive --format=tar --output=permission-snapshot.tar HEAD packages/permission这种方式不会残留任何Git元数据,特别适合跨网络环境传递代码。注意,git archive支持指定路径,可以只导出某一个子目录,非常适合“只要某一小块”的场景。
5.2 什么时候选择快速打法
这事得有个分寸感。我遇到过不少团队,一开始说“历史不要了”,结果迁移过去一个月,要追一个线上故障,跑过来问能不能恢复历史。此时快照方案已经完全回不去了。
所以选快照方案前,至少满足这几个条件之一:模块生命周期短,大概率不会再大规模演进;或者原仓库即将废弃,不会再产生新提交;或者业务上确实对历史没有审计和追溯要求。
即使选了快照方案,我还是建议在目标仓库的README里记一笔:Source commit: abc123def。把原来commit的完整SHA写进去,将来真要追溯,还能顺着这个SHA回原仓库查。多写一行字,省掉之后一大段求人时间。
6. 汇入目标仓库:remote、分支与LFS
6.1 用 remote add 拉取并合并
历史数据整理好之后,接下来要把它并入目标仓库。常见做法是把迁移后的仓库作为远端拉取,然后用merge合并。假设目标仓库叫new-repo,迁移仓库在本地的/tmp/migration-work。
cd /path/to/new-repo git remote add migration /tmp/migration-work git fetch migration git merge migration/master --allow-unrelated-histories很多初学者第一次见--allow-unrelated-histories会疑惑:加了这参数,Git才允许合并两段没有“共同祖先”的历史。迁移过去的代码和新仓库原本的代码,是两条独立时间线,如果不加这个参数,Git会直接拒绝合并。
如果不想立刻合并,而是想把迁入代码作为一个独立分支先放着,可以这样:
git branch permission-module migration/master后续等代码review完、测试通过,再决定是否合并进主干。这种渐进式接入,在组件抽取场景里非常实用。
合并之后,记得清掉那个临时remote:
git remote remove migration6.2 处理合并冲突与 LFS 大文件
冲突主要发生在同名文件上。假如目标仓库已经有一个src/utils.js,迁入代码里也有一个src/utils.js,合并时Git就不知道该保留哪一份。这种时候我的建议是,不要纠结于解决大量逐文件冲突,而是回到上一步,重新用--path-rename把迁移代码放到一个独立目录,比如src/vendor/permission/。目录天然隔离,冲突立刻少了一大半。
合并时还有个隐形问题:如果源仓库用了Git LFS管理大文件,直接fetch下来会先拿到一堆指针文件,真正的文件内容需要在新仓库里重新拉取。
git lfs install git lfs fetch --all否则你打开文件看到的是一行指针文本,完全没法用。如果只是想排除某些大文件不下载,可以调整自身的lfs.fetchexclude配置,但迁移场景里不太建议,因为模块往新仓库走,文件得带齐全。
6.3 迁移后的分支处理与旧仓库归档
代码合并到新仓库后,第一件事是确认旧仓库那边不再有人继续提交旧模块。实际操作中,我会在旧仓库加一个ARCHIVED.md说明,写明“此模块已迁移至XX仓库,后续提交请走新地址”。同时建议把旧仓库设置成只读状态,或者至少在模块目录顶部留个README提示。
如果是自建的平台,比如Gitea、Gogs,它们后台一般都提供“转移仓库/归档仓库”的入口,本质上也是把仓库置为只读,避免两边同时写。真正迁移只靠Git命令就够了,平台功能只是给你加一层管理便利。
大仓库迁移还容易忽略一件事:更新CI和文档里的仓库地址。开一个全局搜索,把旧仓库地址统统替换成新地址。这个环节容易遗漏的是各种脚本配置里硬编码的URL,比如部署脚本里的git clone git@old-host:...,不替换的话,下次CD就跑叉了。
7. 实战中的高频踩坑与排查实录
7.1 clone时连上本机代理端口 7890
报错长这样:
fatal: unable to access 'https://git.example.com/xxx/repo.git/': Failed to connect to 127.0.0.1 port 7890: Connection refused看到127.0.0.1:7890基本能想到,这台机器之前配置过全局的HTTP代理设置,而现在本机并没有对应的代理服务在监听。查看确认一下:
git config --global --list | grep -i proxy如果找到类似http.proxy=http://127.0.0.1:7890的配置,而当前开发环境又不需要走代理,直接清掉即可。
git config --global --unset http.proxy git config --global --unset https.proxy如果还有all_proxy之类的环境变量,也得一并检查。这类问题在多人协作的电脑上特别多,上一个开发者留下的配置,会让后面的人排查半天。
7.2 SSH 认证失败排查
迁移时大家喜欢用SSH协议,因为免密方便。常见的报错是:
Permission denied (publickey). fatal: Could not read from remote repository.排查思路按顺序来。先测连通性:
ssh -T git@github.com如果提示Hi xxx! You've successfully authenticated,说明SSH链路没问题;如果提示权限拒绝,检查本地公钥是否已经配置到目标平台。确认公钥内容用:
cat ~/.ssh/id_ed25519.pub如果电脑上有多个SSH Key,比如一个用于公司GitLab,一个用于Gitee,那就得在~/.ssh/config里针对不同域名分别指定不同的IdentityFile,否则Git会默认用第一个Key去连所有平台,很容易连不上或认证错账号。
7.3 CRLF换行符导致整个文件标红
迁移完成后发现git status下一大片文件都是已修改状态,滚上去一看,其实内容没变,就是行尾全变了。这是典型的换行符问题。源仓库用的是CRLF,目标仓库的core.autocrlf设置在Linux下,于是一拉一提交,整个文件都红了。
处理方式明确一下换行方案:
git config core.autocrlf input然后在代码库里统一执行一次换行符转换,再提交。如果团队跨Windows和Linux协作,强烈建议在仓库根目录放.gitattributes文件,显式声明哪些文件用LF、哪些用CRLF。没有这个文件,换行符的坑会反复踩。
7.4 filter-repo 提示当前仓库不是 clone 出来的
git filter-repo执行时可能会报类似“need to run from a fresh clone”的错。这是安全保护机制,防止你在原始仓库上直接重写历史。新手容易犯的错是觉得自己已经在拷贝目录里了,其实那个目录可能是直接复制过来的,缺少clone标记。
解决办法:
git clone --no-hardlinks /path/to/original /tmp/temp-copy cd /tmp/temp-copy git filter-repo --path YOUR_PATH --force如果用--force在原仓库上硬跑,虽然能执行,但风险是重写失败了原仓库已经面目全非,不建议这么干。
7.5 提交信息需要回溯修改
迁移完成后经常有团队想统一提交信息格式。比如原来提交信息乱七八糟,想规范成“模块名: 描述”。如果是迁移后的最新一条提交,直接用git commit --amend改就行:
git commit --amend如果要批量改历史提交信息,可以用filter-repo的消息回调函数。先准备一个脚本文件,比如rename-messages.py:
#!/usr/bin/env python3 import re def message_callback(message, encoding): if message.startswith('permission:'): return message return 'permission: ' + message然后指定它执行重写:
git filter-repo --message-callback 'from rename_messages import message_callback' --force注意,批量改信息会再次改掉所有提交哈希,所以在主历史还在漂移阶段就一次做完。等代码推到新仓库主干,再要批量改历史,代价就很大了。
7.6 迁移完成后这样验证最稳妥
很多人迁完就急着推送,推送完才发现少了文件或者目录结构不对。建议推送前用三招验证一遍。
第一招,比对目录树。把迁移前的源仓库那个模块的目录树和迁移后仓库的目录树输出,做一次对比。
git ls-tree -r migration/master --name-only | sort > /tmp/new-tree.txt git ls-tree -r old/master --name-only -- packages/permission | sort > /tmp/old-tree.txt diff /tmp/old-tree.txt /tmp/new-tree.txt第二招,对比目标路径的文件数量。数量对不上,说明过滤条件有问题,别急着推。
第三招,抽几个关键历史提交,逐个看git show <commit>内容是否正常。如果能看到当初的改动、提交说明、作者信息,基本可以确认历史迁移成功。
这套验证流程,花的时间不超过十分钟,但能避免推送后才发现历史断裂的尴尬。
做代码迁移这件事,工具命令只是表面,真正难的是迁移前把边界和历史策略想清楚。我个人的经验是,宁可迁移前多花半天理清路径和依赖,也不要在代码推上去之后再返工。跨仓库迁移虽然会重写一堆提交哈希,但保留了代码真正的演进脉络,这些都是钱买不来的信息资产。如果你也有类似的迁移任务,照着上面这套流程走一遍,会少踩很多我踩过的坑。