先给大家还原一个经常遇到的场景:手头攒了好几个 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 --prefix=project-b ../repo-b main注意--prefix的值不要带结尾斜杠。这条命令内部做了三件事:fetch 外部仓库历史;把所有文件路径加上project-b/前缀(相当于 filter-repo 的--to-subdirectory-filter);再生成一次合并提交。最终效果和方案二几乎一样:子目录里能看到完整历史,提交说明和作者全部保留。
5.2 不传 --squash 才是真保留历史
subtree add 默认会保留被合并仓库的全部历史,这正是我们要的。但如果你在社区或者同事的脚本里见过--squash选项,要格外小心:
# 这样会压缩成一条提交,历史就没了 git subtree add --prefix=project-b ../repo-b main --squashsquash的意思是只把最终文件快照合进来,生成一条类似 "Add 'project-b/' from commit 'xxx'" 的提交,原本几十上百条 commit 全部丢弃。对"保留所有 commit 记录"的需求来说,这是反操作。所以默认不带--squash就对了。
5.3 后续还能双向同步,这是它最大的价值
方案二合并完成之后,源仓库和主仓库基本就断了联系;subtree 则保留了继续同步的能力:
# 拉取上游最新提交到子目录 git subtree pull --prefix=project-b ../repo-b main # 把主仓库里 project-b/ 的改动推回上游仓库 git subtree push --prefix=project-b ../repo-b main这个能力很关键。如果仓库 B 还在独立演进,过几天你想让主仓库同步它的最新改动,subtree pull一次搞定;如果你在主仓库里改了 B 的逻辑想贡献回上游,subtree push也只提交project-b/下的变更。相比方案二只能重新走 fetch+merge 老流程,这一点是本质差异。
5.4 用之前要知道的代价
subtree 方便,但不是没有成本:
- 每次 add/pull/push 都会产生额外的合并提交,历史会变得冗余;
- 大仓库执行速度明显比 filter-repo 慢,因为要做额外的树拆分;
- subtree 同样会改写路径,所以原 commit hash 不保留。
从"一次性归档合并"的角度看,subtree 和 filter-repo 最终结果相近;从"长期内嵌公共库并保持同步"的角度看,subtree 优势明显。
6. 三个方案怎么选:一张表看清适用边界
6.1 核心差异对比
把三个方案放在一起,差异主要体现在四个维度:文件是否自动隔离到子目录、是否保留原 hash、命令成本、后续能否跟上游同步。
| 对比维度 | 直接 merge | filter-repo + merge | git subtree |
|---|---|---|---|
| 文件是否自动进入子目录 | 否 | 是 | 是 |
| 是否保留原 commit hash | 是 | 否(路径变了) | 否(路径变了) |
| 命令数量 | 3 步 | 5~6 步(含备份) | 1 步 |
| 后续拉取上游更新 | 手动 fetch+merge | 手动 fetch+merge | subtree 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。 - 案例二:公共组件库内嵌到多个产品仓库。组件库还在活跃开发,希望每个产品仓库随时同步。选 subtree,
git 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 --prune=now --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 记录并不神秘,把三条路线的机制和副作用搞清楚,剩下的就是按部就班执行而已。