一、迁移背景与动机

在实际的软件开发中,我们经常会遇到这样的场景:由于公司业务调整、项目重组或代码架构优化,需要将一个项目中的某个目录(如 backend/frontend/)迁移到另一个独立的仓库中。

核心诉求:

  • 将源仓库(Source Project)的某个子目录完整迁移到目标仓库(Target Project)
  • 保留该子目录的完整 Git 提交历史​,而不是一次性大提交
  • 迁移后的目录结构符合目标仓库的组织方式

不正确做法:

  • 简单地复制粘贴文件,然后在目标仓库中提交 → ❌ 丢失所有历史记录
  • 使用 git clone 然后删除其他目录 → ❌ 保留了不需要的历史,且目录结构不对

本文将介绍一种正确且优雅的迁移方案,使用 Git 原生命令 git subtree 来实现带历史的子目录迁移。

二、迁移原理解析

2.1 核心工具:git subtree

git subtree 是 Git 自带的子树管理工具,主要功能包括:

  1. git subtree split​:从当前分支中"抽取"某个子目录的所有历史,生成一个新分支
    • 该新分支中,原子目录的内容会被提升到仓库根目录
    • 所有涉及该子目录的提交会被重写(SHA 会变化),但保留提交信息、作者、时间等元数据
    • 提交的逻辑顺序和内容关系完全保留
  2. git subtree add​:将一个外部分支的内容合并到当前仓库的指定目录下
    • 通过 --prefix 参数指定目标目录名
    • 会保留外部分支的完整提交历史
    • 使用 merge 策略连接两条原本不相关的历史线

2.2 为什么 Commit SHA 会变化?

很多人担心"SHA 变化=历史丢失",这是误解。

  • SHA 变化原因​​:每个 Git commit 的 SHA 由提交内容的 tree 对象计算得出。当我们用 subtree splitbackend/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 关键要点回顾

  1. 使用 git subtree split 抽取子目录历史
    → 生成一个"根目录=原子目录内容"的新分支
  2. 使用 git subtree add --prefix 合并到目标仓库
    → 将历史放到指定目录下,而不是铺平到根目录
  3. 确保工作区干净
    → subtree 命令要求工作区无未提交修改
  4. 使用功能分支 + Pull Request
    → 便于 Code Review 和回滚
  5. 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 submodulegit subtree 的 pull 模式

参考资料