Git子目录迁移:如何将项目子目录迁移到另一个仓库并保留完整提交历史
一、迁移背景与动机
在实际的软件开发中,我们经常会遇到这样的场景:由于公司业务调整、项目重组或代码架构优化,需要将一个项目中的某个目录(如 backend/ 或 frontend/)迁移到另一个独立的仓库中。
核心诉求:
- 将源仓库(Source Project)的某个子目录完整迁移到目标仓库(Target Project)
- 保留该子目录的完整 Git 提交历史,而不是一次性大提交
- 迁移后的目录结构符合目标仓库的组织方式
不正确做法:
- 简单地复制粘贴文件,然后在目标仓库中提交 → ❌ 丢失所有历史记录
- 使用
git clone然后删除其他目录 → ❌ 保留了不需要的历史,且目录结构不对
本文将介绍一种正确且优雅的迁移方案,使用 Git 原生命令 git subtree 来实现带历史的子目录迁移。
二、迁移原理解析
2.1 核心工具:git subtree
git subtree 是 Git 自带的子树管理工具,主要功能包括:
- git subtree split:从当前分支中"抽取"某个子目录的所有历史,生成一个新分支
- 该新分支中,原子目录的内容会被提升到仓库根目录
- 所有涉及该子目录的提交会被重写(SHA 会变化),但保留提交信息、作者、时间等元数据
- 提交的逻辑顺序和内容关系完全保留
- git subtree add:将一个外部分支的内容合并到当前仓库的指定目录下
- 通过
--prefix参数指定目标目录名 - 会保留外部分支的完整提交历史
- 使用 merge 策略连接两条原本不相关的历史线
- 通过
2.2 为什么 Commit SHA 会变化?
很多人担心"SHA 变化=历史丢失",这是误解。
- SHA 变化原因:每个 Git commit 的 SHA 由提交内容的 tree 对象计算得出。当我们用
subtree split把backend/foo.py变成foo.py时,文件路径改变了,tree 对象自然改变,SHA 必然重写。 - 历史保留内容:
- ✅ 提交信息(commit message)
- ✅ 作者和提交者信息
- ✅ 提交时间
- ✅ 提交之间的父子关系(提交链)
- ✅ 文件的修改内容和演进过程
所以SHA 变化不影响"保留历史"的本质目标,我们关心的历史演进轨迹、代码变更逻辑都完整保留。
2.3 --allow-unrelated-histories 的作用
当你尝试合并两个没有共同祖先的 Git 分支时,Git 默认会拒绝(避免误操作)。在跨仓库迁移场景中,源仓库和目标仓库本身就是两条独立的历史线,因此需要使用 --allow-unrelated-histories 显式告诉 Git:"我知道它们不相关,但我就是要合并"。
三、完整迁移操作流程
3.1 前置准备
关键术语约定:
- Source Project(源仓库):包含需要迁移子目录的原始仓库
- Target Project(目标仓库):接收迁移内容的目标仓库
- 分支名:假设源和目标都在
qubits分支(根据实际情况调整) - 子目录名:假设迁移
backend/(也可以是frontend/等)
环境要求:
- 确保本地有 Source Project 和 Target Project 的完整克隆
- 建议在新克隆的副本上操作,避免污染日常工作仓库
- 确保两个仓库的目标分支都已拉取到最新状态
3.2 步骤一:在源仓库中抽取子目录历史
在 Source Project 本地目录下执行:
cd /path/to/source-project
# 切换到目标分支并更新到最新
git checkout qubits
git pull --ff-only
# 使用 subtree split 抽取子目录历史
# --prefix=backend 指定要抽取的目录
# -b backend-qubits-split 指定新分支名
git subtree split --prefix=backend -b backend-qubits-split
执行结果:
- 创建了一个名为
backend-qubits-split的新分支 - 该分支的根目录内容 = 原
backend/目录的内容 - 该分支包含所有修改过
backend/目录的提交历史(按时间顺序)
时间提示:
如果子目录历史很长,这个过程可能需要几分钟。耐心等待,不要中断。
3.3 步骤二:在目标仓库中准备接收
在 Target Project 本地目录下执行:
cd /path/to/target-project
# 切换到目标分支并更新到最新
git checkout qubits
git pull --ff-only
# 创建一个新的功能分支用于迁移操作(便于后续 Code Review)
git checkout -b refactor/migrate-backend
# 添加源仓库作为临时远程仓库(用于 fetch 数据)
# 这里使用本地路径,也可以使用 SSH/HTTPS URL
git remote add source-local /path/to/source-project
# 验证远程仓库已添加
git remote -v
为什么用本地路径?
使用本地路径(/path/to/source-project)比 URL 更快,且无需网络。如果源仓库在另一台机器上,则使用完整的 Git URL。
3.4 步骤三:抓取源仓库的分支
# 从源仓库抓取我们刚才创建的 backend-qubits-split 分支
git fetch source-local backend-qubits-split
执行结果:
- 目标仓库的本地 Git 数据库中现在有了
source-local/backend-qubits-split的引用 - 所有相关的提交对象、tree 对象、blob 对象都被复制到目标仓库
- 但此时目标仓库的工作区和分支还没有任何变化
3.5 步骤四:使用 subtree add 合并历史(关键步骤)
# 使用 subtree add 将源分支的内容放到 backend/ 目录下
# --prefix=backend 指定目标目录名(可以根据需求改为其他名称)
git subtree add --prefix=backend source-local/backend-qubits-split
执行结果:
- 目标仓库根目录下出现
backend/目录 backend/目录中的文件和源仓库中backend/的最终状态一致- 所有源仓库中
backend/的提交历史被合并进当前分支 - Git 会自动创建一个 merge commit,连接目标仓库原有历史和迁移来的历史
验证结果:
# 查看目录结构
ls -la backend/
# 查看提交历史(会看到完整的迁移历史)
git log --oneline --graph backend/
# 查看具体某个文件的历史
git log --follow backend/src/main.py
3.6 步骤五:提交 Pull Request 并合并
# 推送新分支到远程仓库
git push origin refactor/migrate-backend
# 在 GitHub/GitLab 等平台上创建 Pull Request
# PR 标题示例:"refactor: migrate backend from Source Project with full history"
Code Review 要点:
- 检查
backend/目录结构是否正确 - 使用
git log --follow验证关键文件的历史是否完整 - 确认没有意外引入不相关的文件
合并后清理:
# 删除临时远程仓库引用(可选)
git remote remove source-local
四、常见问题与解决方案
4.1 问题:fatal: working tree has modifications. Cannot add.
原因:
git subtree add 要求工作区必须完全干净(没有未提交的修改)。
解决方案 A(推荐):
# 临时保存当前改动
git stash push -u -m "temp: before subtree add"
# 执行 subtree add
git subtree add --prefix=backend source-local/backend-qubits-split
# 如果需要,恢复改动
git stash pop
解决方案 B(确认无重要改动时):
# 放弃所有工作区改动
git restore --staged --worktree .
# 如果有子模块导致的 modified 状态
git submodule update --init --recursive --force
4.2 问题:迁移后文件直接铺到根目录,而不是在子目录中
原因:
使用了 git merge --allow-unrelated-histories 而不是 git subtree add --prefix。
正确做法:
必须使用 git subtree add --prefix=backend 来指定目标目录。
如果已经错误合并:
# 如果还没 push,回退到合并前
git reset --hard ORIG_HEAD
# 如果已经 push,使用 revert(保留历史)
git revert -m 1 <merge_commit_sha>
# 然后重新执行正确的 subtree add
4.3 问题:能否保留原始的 Commit SHA?
答案:不能,也不需要。
Git commit 的 SHA 是基于提交内容(tree 对象)计算的。当文件路径从 backend/foo.py 变成 foo.py(在 split 分支中)时,tree 对象必然改变,SHA 必然不同。
真正重要的是:
- ✅ 提交信息完全保留
- ✅ 提交顺序完全保留
- ✅ 作者、时间等元数据完全保留
- ✅ 代码演进逻辑完全保留
使用 git log --follow <file> 可以完整追溯文件的变更历史,这才是"保留历史"的核心价值。
五、相关 Git 知识扩展
5.1 git subtree vs git submodule
| 特性 | git subtree | git submodule |
|---|---|---|
| 历史记录 | 完全合并到主仓库 | 独立仓库,主仓库只记录 commit 引用 |
| 克隆体验 | git clone即可获得完整代码 |
需要git clone --recurse-submodules |
| 适用场景 | 一次性迁移、代码整合 | 长期维护独立组件 |
| 复杂度 | 学习成本低,但历史会膨胀 | 需要额外命令,但仓库更清晰 |
迁移场景选择 subtree 的原因:
- 迁移是一次性操作,不需要保持与源仓库的持续同步
- 目标是让代码"原生融入"目标仓库,而不是作为外部依赖
5.2 git filter-repo 作为替代方案
对于超大型仓库或需要更复杂过滤逻辑的场景,可以使用 git filter-repo(第三方工具,性能更好):
# 安装 git-filter-repo
pip install git-filter-repo
# 在源仓库的临时副本中操作
git clone /path/to/source-project source-temp
cd source-temp
git checkout qubits
# 重写历史,只保留 backend/ 并提升到根目录
git filter-repo --path backend/ --path-rename backend/:
# 后续步骤与 subtree 方法相同
优势:
- 性能更好(对大仓库友好)
- 支持更复杂的路径重写规则
劣势:
- 需要安装第三方工具
- 会彻底重写仓库(需要在临时副本上操作)
六、总结与最佳实践
6.1 关键要点回顾
- 使用
git subtree split抽取子目录历史
→ 生成一个"根目录=原子目录内容"的新分支 - 使用
git subtree add --prefix合并到目标仓库
→ 将历史放到指定目录下,而不是铺平到根目录 - 确保工作区干净
→ subtree 命令要求工作区无未提交修改 - 使用功能分支 + Pull Request
→ 便于 Code Review 和回滚 - Commit SHA 会变化,但历史完整保留
→ 使用git log --follow验证历史连续性
6.2 完整命令速查表
源仓库操作:
cd /path/to/source-project
git checkout qubits
git pull --ff-only
git subtree split --prefix=backend -b backend-qubits-split
目标仓库操作:
cd /path/to/target-project
git checkout qubits
git pull --ff-only
git checkout -b refactor/migrate-backend
git remote add source-local /path/to/source-project
git fetch source-local backend-qubits-split
git subtree add --prefix=backend source-local/backend-qubits-split
git push origin refactor/migrate-backend
6.3 适用场景
✅ 适合使用本方案的场景:
- 公司业务重组,需要拆分或合并代码库
- 将 monorepo 中的某个模块独立成单独仓库
- 将多个独立仓库合并成 monorepo
- 代码重构时需要保留完整演进历史供审计
❌ 不适合的场景:
- 只需要最新代码,不关心历史 → 直接复制粘贴即可
- 需要保持与源仓库长期同步 → 使用
git submodule或git subtree的 pull 模式
参考资料
- 感谢你赐予我前进的力量
