这个 Monorepo 最开始什么都往里面放,网站、文章、桌面工具,还有一个客户端和它的服务端。项目少的时候没觉得有什么问题,后来客户端、服务端和设计资料已经是一套完整的东西了,继续留在这里反而有点奇怪。
每次安装依赖都要带上它们,提交的时候也要反复确认范围。更麻烦的是,打开仓库以后上下文太多,我明明只想改一个游戏规则,却还要面对一堆完全没有关系的应用。
所以我决定把这几个目录拆出去,单独放进一个仓库。
我一开始想得很简单:复制目录,git init,然后提交。文件是过去了,但以前为什么这样改、哪次同时动了客户端和服务端、某个问题是从哪一版开始出现的,这些全没了。既然原来的 Git 历史还在,就没有必要把新仓库做成一张只有当前状态的快照。
这次先不改名字
拆分时我原本还想顺手把目录名改成 client 和 server,后来还是停下来了。
拆仓库本身已经要重写历史,改目录名又是另一件事。两件事一起做,最后文件对不上时很难判断到底是过滤错了,还是重命名漏了引用。反正名字以后随时能改,我决定先原样拆出去。
这里还有一个容易误会的地方:迁移后的 commit SHA 一定会变。
Git 的提交 ID 和提交内容、父提交、作者等信息有关。现在把原提交里的其他项目删掉了,提交内容已经不是原来那份,SHA 不可能继续一样。能保留的是作者、时间、提交说明和前后关系。
如果一定要保留旧 SHA,那就只能把整个 Monorepo 的历史搬到新仓库,再在最后删除不需要的目录。这样历史里还是能看到其他项目,我不想要这种“看起来拆了,其实什么都带过去了”的结果。
我先检查了旧仓库里到底有什么
动手前先确认工作区是干净的,也确认当前不是浅克隆:
git status --short
git rev-parse --is-shallow-repository然后只看准备迁出的几个目录:
git rev-list --count HEAD -- apps/client apps/server apps/design
git ls-files -- apps/client apps/server apps/design这还不够。Git 跟踪的文件很好处理,真正容易被忘掉的是未跟踪和被忽略的文件:
git ls-files --others --exclude-standard -- apps/client apps/server apps/design
git ls-files --others --ignored --exclude-standard -- apps/client apps/server apps/design这次未跟踪文件是 0,但被忽略的文件有 650 个。
看到 650 这个数字时我也愣了一下。仔细看以后,大部分是 node_modules、构建输出和编辑器缓存,删了还能生成。里面也混着 .env、运行日志和编辑器 profile,这些不能跟缓存一起扔掉。
真正拆分时只动新克隆
我没有在原仓库里运行历史过滤。先克隆一份,后面的操作全部在新目录里做:
git clone --no-local --single-branch --branch main --no-tags \
https://github.com/OWNER/MONOREPO.git ../NEW_REPO这里我只迁移 main,旧仓库另外几个远程分支没有这些目录的独有提交,原来的标签也属于整个 Monorepo,带过去反而会让人误会。
接着使用 git-filter-repo 保留需要的路径:
cd ../NEW_REPO
git filter-repo --force \
--path apps/client \
--path apps/server \
--path apps/design过滤以后,新仓库只剩这些目录相关的提交。原来某次提交如果同时修改了客户端、服务端和其他应用,新历史会保留客户端与服务端的部分,其他应用的改动不会跟过来。
git-filter-repo 还会把原来的 origin 移除。我觉得这个处理挺合理,不然手一快,很可能把重写后的历史推回旧仓库。确认结果以后再添加新地址:
git remote add origin https://github.com/OWNER/NEW_REPO.git
git push -u origin main根目录文件我没有从旧仓库硬搬
过滤完成后,新仓库只有那几个应用目录。旧仓库根目录的 package.json 和 lockfile 还管理着网站、博客等其他 workspace,不能直接复制。
我重新创建了一个很小的根 workspace,把客户端和服务端接回来,再生成新的 lockfile。README、仓库规则和编辑器配置也单独提交。
这样在 Git 历史里很好认:前面的提交是从旧仓库提取出来的,后面的提交才是新仓库自己的配置。以后改目录名,也从这里继续,不去碰已经迁好的历史。
我不太相信“文件看起来都在”
目录能打开,文件数量差不多,并不能说明迁移没有漏东西。我最后是直接比较 Git tree。
$oldTree = @(
git -C ../MONOREPO ls-tree -r ARCHIVE_BRANCH -- `
apps/client apps/server apps/design
)
$newTree = @(
git -C ../NEW_REPO ls-tree -r HEAD -- `
apps/client apps/server apps/design
)
Compare-Object $oldTree $newTreegit ls-tree 给出的不只是文件名,还有文件模式和 blob ID。Compare-Object 没有输出,才说明两边的受跟踪文件真的一样。
提交历史也做了一次类似的比较。SHA 已经重写,我就比较作者时间、作者、邮箱和提交说明:
$oldHistory = @(
git -C ../MONOREPO log --reverse `
--format='%aI`t%an`t%ae`t%s' ARCHIVE_BRANCH -- `
apps/client apps/server apps/design
)
$newHistory = @(
git -C ../NEW_REPO log --reverse `
--format='%aI`t%an`t%ae`t%s' MIGRATED_HISTORY_HEAD
)
Compare-Object $oldHistory $newHistory最后对上了 202 个文件和 89 条提交,两边差异都是 0。以前有几次提交同时修改了客户端和服务端,过滤后它们仍然是一次提交,没有变成两段各说各话的历史。
那 650 个忽略文件怎么办
我没有为了追求数字上的“一个都不少”,把缓存也搬过去。
依赖目录、编译产物、编辑器生成的类型声明和临时资源都能重新生成。把它们复制到新路径,反而可能留下旧绝对路径和过期缓存。
真正保留的是 .env、对局日志和编辑器 profile,一共 70 个文件。我把它们复制到新仓库对应的忽略目录,然后逐个算 SHA-256,结果也是 0 差异。
这些文件没有提交。特别是 .env,不能因为这次叫“完整迁移”,就把本地配置塞进 Git。日志里也可能有请求和模型输出,继续保持忽略更合适。
剩下的 580 个文件都是缓存、依赖或构建结果,旧项目删除后也就一起清掉了。
我到最后才敢删除旧目录
新仓库已经推送,文件和历史也对上了,但我还是先给旧仓库建了一个存档分支:
git branch codex/archive-before-split-YYYYMMDD HEAD
git push -u origin codex/archive-before-split-YYYYMMDD只建本地分支我觉得不算备份。真要是本地仓库出了问题,这个分支也会一起没掉。推送后我又比较了本地和远程的提交 ID:
git rev-parse codex/archive-before-split-YYYYMMDD
git ls-remote origin refs/heads/codex/archive-before-split-YYYYMMDD两个值一样以后,我才回到 main 删除原目录:
git rm -r -- apps/client apps/server apps/designgit rm 不会处理被忽略的文件,所以旧目录当时还剩下一堆缓存和本地数据。前面那 70 个文件已经复制并核对过,剩余目录才可以删。
根 lockfile 也重新生成了一次:
npm install --package-lock-only --ignore-scripts
npm install --ignore-scripts
npm ls --depth=0第一次只更新 lockfile,第二次清理本地已经没用的 workspace 依赖。最后再搜一遍,确认 lockfile 里没有旧目录和包链接。
删除提交的正文里我直接写了新仓库地址:
git commit \
-m "chore: migrate extracted projects to standalone repository" \
-m "Client、server 和 design 已迁移至:https://github.com/OWNER/NEW_REPO.git"以后从旧仓库的日志里看到这条提交,至少能马上知道项目去了哪里,不用再翻聊天记录。
最后的结果
新仓库本地和远程 main 指向同一条提交,旧仓库的存档分支也已经推送。旧仓库 main 里三个目录和 lockfile 引用都没了,工作区是干净的。
服务端重新跑了类型检查和 80 个测试,全部通过。客户端的情况不一样,它依赖游戏编辑器在新目录里生成本地类型文件,所以刚克隆完直接检查会报缺少生成文件。这个报错不能说明迁移丢了源码,但也不能拿文件对比来代替编辑器导入和实际画面检查。新路径还是要让编辑器重新打开一次。
这次最费时间的其实不是 git filter-repo,命令几分钟就跑完了。真正让我不放心的是旧目录到底什么时候能删。
如果以后再拆一次,我还是会按这个顺序来:先保持原目录名过滤历史,把新仓库跑起来;再比较文件和提交,单独处理忽略数据。新远程和存档分支都确认以后,最后才回旧仓库删除目录。
目录改名放到下一次提交。别顺手。