资讯动态

Git多仓库合并实战:保留完整提交历史的三种方案

发布时间:2026/10/22 3:58:40 来源:尧图企业网站定制
先给大家还原一个经常遇到的场景手头攒了好几个 Git 仓库逻辑上同属一条产品线或者就是当年把项目拆得太碎现在想收拢。某天一句把它们合并成一个仓库历史别丢任务就落到了你头上。一开始你可能觉得这不难无非 remote add、fetch、merge 三板斧但真正动手会发现Git 默认会拒绝合并两个互不相识的仓库同名文件在根目录互相打架合并完之后还得验证每条 commit 是不是真的都在。这篇文章就把合并多个项目并保留所有 commit 提交记录这件事彻底讲透我会给出三种可落地方案直接 merge、filter-repo 改写历史再 merge、git subtree把每一步命令、背后的原理、以及我踩过的坑全部摊开。适合正在做多仓库收敛、准备搭 monorepo或者想把几个小项目归档成一个仓库的开发者。1. 先想清楚保留全部提交记录到底要保留什么1.1 常见的合并诉求多仓库合并这事主要来自三类需求仓库收敛成单仓库monorepo 转型本来前端一个仓库、后端一个仓库、公共组件再一个仓库管理成本高、跨仓库改代码要开好几个 PR决定统一收进一个大仓库。项目归档几个相关项目停止独立演进想合并成一个仓库长期保存方便以后统一检索和回溯。接手历史包袱老项目因历史原因拆成多个仓库新团队觉得不合理想在保持历史可查的前提下重构结构。不管哪一类诉求内核都一样文件要并到一起历史不能丢。1.2 保留全部提交记录的验收标准在动手之前我建议先把不丢量化成可验收的标准否则合并完没法判断成没成功git log能完整看到所有提交包括各仓库最早的 root commit每条提交的作者、提交时间、commit message 原样保留提交对应的改动可以正常git blame、git diff、git show追溯特殊情况下还要求 commit hash 不变比如 CI、发布脚本以 hash 做标识。前三个标准是几乎所有场景都要求的第四个是最高标准。后面三种方案的核心差异恰恰就落在这里。1.3 三种路线总览很多人一上来会把被合并仓库的文件夹直接复制过来再git add .提交一次。文件确实并进来了但原来的上百次提交只剩一个节点以后排查问题git log直接空白。这不叫合并叫搬家。用 submodule子模块也不满足需求主仓库里只有一个指针真正历史还是留在各自子仓库里。真正满足保留全部 commit 记录的方案只有三条路线直接 merge保留两个仓库各自完整历史用一次合并提交把两棵提交树焊起来。filter-repo 改写历史再 merge先把被合并项目的所有文件整体挪进一个子目录再接到主仓库。git subtree一条命令完成改路径 拉历史 合并之后还能继续和上游同步。这三条路线各有各的适用场景下面一节一节说。2. 前置知识为什么 Git 会拒绝合并两个互不相识的仓库2.1 每个仓库都有自己的根提交Git 的提交历史是一张有向无环图DAG。一个仓库从第一条提交开始这条提交没有父提交叫作 root commit。正常情况下一个仓库里的所有提交往上追溯最终都会汇聚到同一个 root。而两个独立初始化的仓库各自有各自的 root。在 Git 眼里它们就是两条没有任何血缘关系的分支。普通合并时Git 要做的第一件事不是对比文件差异而是先找合并基点merge base也就是两个分支的公共祖先。如果找不到公共祖先Git 2.9 之后会直接拒绝执行fatal: refusing to merge unrelated histories我第一次看到这个报错是在合并第二个仓库的时候当时还以为是命令写错了查了一圈才发现这是 Git 的默认安全策略。2.2 --allow-unrelated-histories 到底改变了什么这个参数的字面意思很直白允许合并没有共同祖先的历史。它不会去伪造一个公共祖先而是在两棵提交树之间新建一个合并提交这个合并提交有两个父提交——一个来自目标仓库的最新提交一个来自被合并仓库的最新提交。两个仓库的历史都被原原本本保留不存在谁吞并谁。理解这一点后后面很多现象就顺理成章了合并后git log --graph会看到两条平行线最后汇成一个点合并提交的快照本质上是两个仓库文件系统的直接叠加如果两个仓库根目录有同名文件Git 就按普通冲突处理需要你手工裁决。2.3 一个关键认知commit hash 会不会变先说结论只要不修改文件路径原仓库的 commit hash 就不会变只要改了路径hash 就一定会变。原因在于一次提交commit的哈希是由它的完整内容决定的包括父提交哈希、作者、时间、提交说明以及它指向的树对象tree的哈希。你把文件从根目录挪到某个子目录下tree 对象就变了依赖它的后续所有提交会连锁变化。但注意提交说明、作者、日期这些人关心的记录仍然原样保留。所以方案二、三确实保留了 commit 记录只是不保留原 hash 而已。如果验收标准是hash 必须一字不变那唯一选择就是不动路径的直接 merge 方案如果想要目录隔离就得接受 hash 变化。这两件事在 Git 的机制里天然冲突。3. 方案一直接 merge --allow-unrelated-histories三板斧完成合并3.1 完整操作步骤假设要把仓库 B 合并进仓库 A两个仓库都在本机同一父目录下# 在仓库 A 中操作 git remote add repo-b ../repo-b git fetch repo-b git merge --allow-unrelated-histories repo-b/main --no-edit三步完成。第二步fetch把 repo-b 的分支、标签、完整历史全部拉到本地但不改动工作区第三步才真正把repo-b/main的历史接到当前分支上生成一个合并提交。--no-edit表示直接用 Git 生成的默认合并信息不弹编辑器。注意分支名如果被合并仓库的默认分支是master就把命令里的main换成master。如果合并过程中出现冲突解决完冲突后git add . git commit --no-edit合并完成后用git log --oneline --graph --all --decorate验证。你能看到仓库 A 的旧历史、仓库 B 的旧历史各是一条独立线最后在一个 merge commit 汇合。3.2 这个方案适合什么情况直接 merge 最大的优点是简单、快、不改动被合并仓库的任何 commit hash。适合以下场景两个仓库文件基本不会重名比如一个是纯代码仓库一个是文档仓库合并后根目录直接铺开只是想快速把历史串起来归档不关心目录归属后续不需要把其中一个仓库的更新再同步回来。缺点也很明显。项目一多根目录必然出现 README.md、LICENSE、package.json、.gitignore 这些同名文件撞车更麻烦的是如果两个仓库都有src/目录直接 merge 会把两个目录里的文件按文件名合并到一起而不是自动创建src-a/和src-b/两套目录。将来想单独看某个项目的历史也麻烦因为没有目录边界git log全混在一起。3.3 冲突现场同名文件怎么处理实际合并中最常撞车的是根目录那些默认文件README.md、LICENSE、.gitignore、CI 配置、package.json 等。Git 会按正常文件冲突处理需要你决定留哪一份、还是融合。如果已经明确某个文件永远以哪一方为准可以用合并策略参数省去手工操作。比如希望冲突文件统一采用被合并仓库repo-b的版本git merge --allow-unrelated-histories -X theirs repo-b/main但-X theirs是文件层面的偏好遇到两边都有实质内容差异的文件要慎用它可能把仓库 A 的版本整个覆盖掉。我的建议是默认还是手工合数量多时再针对根目录几个已知文件集中处理。一条实用技巧合并前先把两个仓库的根目录文件列出来对比预先知道会撞哪些。ls -1A ../repo-b | sort /tmp/files-b.txt ls -1A | sort /tmp/files-a.txt diff /tmp/files-a.txt /tmp/files-b.txt这个 30 秒的对比能让你在真正执行git merge之前就对冲突范围心里有数。4. 方案二用 git filter-repo 改写历史让项目落户独立子目录4.1 为什么要把历史搬进子目录直接 merge 无法回避的痛点是多个仓库合并后文件全部平铺在根目录。假设 A 是后端、B 是前端、C 是公共组件库你真正想要的结构是my-app/ ├── backend/ ├── frontend/ └── common/而且每个子目录底下都要保留完整历史这样git log -- frontend/能只看前端的提交流git blame frontend/xxx.js能顺着路径追责。要做到这一点不能直接 merge必须先对被合并仓库的历史做一次整体搬家。4.2 filter-repo 是什么为什么选它早期这类需求一般用git filter-branch但它又慢又容易踩坑官方文档已经明确建议改用git filter-repo。filter-repo 是 Python 写的速度更快操作更安全而且运行后会主动清理掉一些隐患。安装很容易任选其一pip3 install git-filter-repo # 或者 brew install git-filter-repo有两个特性需要提前记牢它默认只允许在全新克隆的干净仓库上运行如果仓库有 remote 或不是新鲜克隆会报错并要求加--force。运行后会把 remote 配置移除防止你把改写后的历史误推回远程。所以我强烈建议一律采用克隆到临时目录 → 改写 → 合并 → 删除临时目录的流程绝对不要在原仓库直接动手。4.3 完整合并流程含备份依然是把仓库 B 合并进仓库 A但这次要求 B 的所有文件最终落在project-b/子目录下# 第一步备份并克隆仓库 B cp -r ../repo-b /tmp/repo-b-backup git clone ../repo-b /tmp/repo-b-rewrite # 第二步在全新克隆里改写历史把所有文件挪进 project-b/ cd /tmp/repo-b-rewrite git filter-repo --to-subdirectory-filter project-b # 第三步回到仓库 A 合并 cd ~/repo-a git remote add repo-b /tmp/repo-b-rewrite git fetch repo-b git merge --allow-unrelated-histories repo-b/main --no-edit # 第四步清理临时 remote git remote remove repo-b第二行命令是整个方案的核心--to-subdirectory-filter project-b会重写仓库 B 的每一条 commit把原本位于根目录的所有文件全部挪到project-b/下。提交说明、作者、日期都不变但因为路径变了commit hash 全部改变。备份这步不要跳过。filter-repo 不会像 filter-branch 那样自动生成refs/original备份引用一旦改写结果不理想唯一的救场手段就是那份整目录备份。4.4 合并完成后怎么验收改写完、合并完在仓库 A 里做三件事确认# 看全貌两个项目的完整历史都在 git log --oneline --graph --all | head -30 # 只看 project-b 子目录的历史 git log --oneline -- project-b/ # 抽查某一行代码的来路 git blame project-b/src/core.py | head从实际合并效果看合并后仓库 A 会有两个独立 root从 merge commit 往上一条追溯仓库 A 的老 root一条追溯仓库 B 重写后的新 root。那些fix: 修复xx问题的老提交如今都带着project-b/路径前缀用git show commit --stat也能正常看到当时的变更文件。多个项目合并时强烈建议一个一个来先把 B 合进 A验证没问题再合 C。一次合并两个以上项目冲突会成倍增加排查起来非常痛苦。5. 方案三git subtree add一条命令把外部仓库吸进指定目录5.1 subtree 的定位它替你完成了改写 合并git subtree 是官方提供的高层工具核心场景正是把另一个仓库的完整历史挂到当前仓库的某个子目录下。方案二需要五六个命令subtree 一条命令到位git subtree add --prefixproject-b ../repo-b main注意--prefix的值不要带结尾斜杠。这条命令内部做了三件事fetch 外部仓库历史把所有文件路径加上project-b/前缀相当于 filter-repo 的--to-subdirectory-filter再生成一次合并提交。最终效果和方案二几乎一样子目录里能看到完整历史提交说明和作者全部保留。5.2 不传 --squash 才是真保留历史subtree add 默认会保留被合并仓库的全部历史这正是我们要的。但如果你在社区或者同事的脚本里见过--squash选项要格外小心# 这样会压缩成一条提交历史就没了 git subtree add --prefixproject-b ../repo-b main --squashsquash的意思是只把最终文件快照合进来生成一条类似 Add project-b/ from commit xxx 的提交原本几十上百条 commit 全部丢弃。对保留所有 commit 记录的需求来说这是反操作。所以默认不带--squash就对了。5.3 后续还能双向同步这是它最大的价值方案二合并完成之后源仓库和主仓库基本就断了联系subtree 则保留了继续同步的能力# 拉取上游最新提交到子目录 git subtree pull --prefixproject-b ../repo-b main # 把主仓库里 project-b/ 的改动推回上游仓库 git subtree push --prefixproject-b ../repo-b main这个能力很关键。如果仓库 B 还在独立演进过几天你想让主仓库同步它的最新改动subtree pull一次搞定如果你在主仓库里改了 B 的逻辑想贡献回上游subtree push也只提交project-b/下的变更。相比方案二只能重新走 fetchmerge 老流程这一点是本质差异。5.4 用之前要知道的代价subtree 方便但不是没有成本每次 add/pull/push 都会产生额外的合并提交历史会变得冗余大仓库执行速度明显比 filter-repo 慢因为要做额外的树拆分subtree 同样会改写路径所以原 commit hash 不保留。从一次性归档合并的角度看subtree 和 filter-repo 最终结果相近从长期内嵌公共库并保持同步的角度看subtree 优势明显。6. 三个方案怎么选一张表看清适用边界6.1 核心差异对比把三个方案放在一起差异主要体现在四个维度文件是否自动隔离到子目录、是否保留原 hash、命令成本、后续能否跟上游同步。对比维度直接 mergefilter-repo mergegit subtree文件是否自动进入子目录否是是是否保留原 commit hash是否路径变了否路径变了命令数量3 步5~6 步含备份1 步后续拉取上游更新手动 fetchmerge手动 fetchmergesubtree pull将子目录改动推回上游不支持需要额外操作subtree push大仓库速度快快偏慢适合场景文件不冲突的快速合并monorepo 收拢、归档长期内嵌公共库6.2 选型判断流程三个问题定方向按优先级排一个判断流程要不要子目录隔离不要选直接 merge要进入下一步。被合并项目以后还会不会独立更新会选 subtree不会选 filter-repo。项目特别大、特别多推荐 filter-repo 分批合并如果只是把一个公共库嵌进多个项目subtree 最省事。补充一个反例如果验收方明确要求commit hash 一个都不能变那就只有方案一能满足为此你要接受目录平铺和更多冲突处理。不要既想目录隔离、又想 hash 不变这不是工具不够强而是 Git 的机制本身就决定了两件事相互矛盾。还有一个容易忽略的点合并方向。把历史更悠久、提交更多的仓库作为接收方A把年轻的仓库作为被合并方B通常能让主分支历史更清晰后续合并 C/D 时冲突也更可控。6.3 三个典型组合案例案例一业务仓库收拢成 monorepo。前端 3 个仓库、后端 2 个仓库后续不打算单独演进。选 filter-repo逐个合并目录按frontend-a/、frontend-b/、backend/规划每合一个就验证一次git log。案例二公共组件库内嵌到多个产品仓库。组件库还在活跃开发希望每个产品仓库随时同步。选 subtreegit subtree add放进common/之后定期git subtree pull。案例三个人项目归档。两个仓库都不再开发只是想让历史不丢、结构简单。选直接 merge接受根目录平铺换来的是最快的速度和原样的 hash。7. 实战避坑remote、标签、GC 与大小写敏感问题7.1 filter-repo 之后 remote 没了别慌这是 filter-repo 的默认行为改写历史后origin 以及所有 remote 都会被移除防止你把改写后的历史误推上去。如果你在临时克隆里执行我推荐的做法那无所谓合并完删掉临时目录就行。如果你直接在原目录跑且用了--force记得重新git remote add origin url确认无误后再推送。7.2 同名标签合并前一定要清理两个仓库如果都有v1.0、v0.9这类标签fetch 之后本地会出现同名引用推送时轻则覆盖、重则被拒绝。合并前把两边标签列出来对比git tag -l | sort /tmp/tags-a.txt git tag -l | sort /tmp/tags-b.txt # 在仓库 B 里执行冲突的同名标签要么改名要么只保留接收方的。我个人的习惯是合并时默认不带标签只保留下游要用的发布标签冲突的按项目前缀重命名比如project-b-v0.9比强行覆盖安全得多。7.3 merge 之后 .git 体积膨胀记得 GC合并两个历史仓库等于把两套对象数据都并进来了.git目录可能直接翻倍。合并全部完成之后做一次对象压缩非常有必要git gc --prunenow --aggressive这条命令会重新打包对象并清理悬空对象。建议在全部合并完成后再执行一次不用每个项目合并一次如果仓库里有很多大文件可以先git count-objects -vH看看实际体积再决定。7.4 大小写不敏感的文件系统Windows 与 macOS 的坑在 Windows 或 macOS 默认大小写不敏感的文件系统上合并两个分别含README.md和Readme.md的历史仓库时工作区可能只保留一个文件但 Git 索引里两个都存在之后git status会一直有奇怪变更。解决办法合并前先检查git ls-files | grep -i readme.md确认两边是否有大小写重名如果有先把其中一个重命名再执行合并。7.5 浅克隆、LFS 这些历史不全的情况如果被合并的仓库是用git clone --depth 1浅克隆下来的那它本身就没有完整历史fetch 过来自然谈不上保留所有 commit。合并前第一件事是确认源仓库是完整克隆可以用git log --oneline | wc -l看看提交数量明显偏少就要怀疑浅克隆。用 Git LFS 的仓库合并后要确保每个开发环境都装了 LFS否则其他人检出时只会拿到指针文件真实内容拉不下来。必要时在合并前用git lfs migrate统一处理历史中的大文件。最后再分享一点个人习惯多仓库合并这件事最容易出问题的其实不是命令本身而是不验收就合并和不做备份就动手。我的一般流程是先把来源仓库整目录备份一份再在临时集成分支上合并第一个项目跑一遍git log和构建验证没问题再合并下一个全部完成后再执行一次git gc收尾。这样即使中途翻车回滚成本也只是删掉临时分支重来。合并多个项目并保留所有 commit 记录并不神秘把三条路线的机制和副作用搞清楚剩下的就是按部就班执行而已。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑