把子目录拆成独立项目的同时保留GIT历史提交信息

起因

在我的日常开发或课程项目中,我常常会把相关联的多个模块放在一个总仓库里来方便开发和推进。
例如在我的 Java 课程作业仓库 (java-courseworks) 里,除了有一些单独的课程作业目录外,
还有一个 practicum 目录,里面是一个完整的学生管理系统实训项目。
在完成后续的维护和展示过程中,我发现把实训项目和课程作业放在一起,虽然方便了初期的开发,但随着时间推移,出现了一些问题:

  1. 记录复杂:查看 practicum 相关改动时会被其它目录提交干扰。
  2. 边界模糊:实训项目与课程作业是两类资产,其实并不适合放在一起。
  3. 发布困难:在其他大型项目中,一个模块想独立维护、打包、发布时,会受父仓库结构影响。
  4. 演示麻烦:给他人演示时,单独仓库更易理解与上手。

因此,本次我们将要一步步实现:

  • practicum 拆到新目录并初始化独立 Git 仓库;
  • 复原指定区间的历史提交(包含提交信息与时间);
  • 解除对父 POM 的依赖,使其可独立构建;
  • 配置独立远程仓库地址并推送到 GitHub 。

开始操作

迁移前务必做好以下准备:

  1. 确认源仓库工作区干净(避免混入无关改动)。
  2. 确认目标目录不存在或可安全覆盖
  3. 确认提交区间正确(尤其是起止 commit)。
  4. 准备本地工具:Git。

Step 1:从源仓库导出指定区间的补丁

我们需要先从源仓库导出仅 practicum 路径的补丁文件(保留提交顺序和元信息)。

1
2
3
4
5
$patchDir = "D:\@DEV\java-courseworks\.tmp_patches"
if (Test-Path $patchDir) { Remove-Item $patchDir -Recurse -Force }
New-Item -ItemType Directory -Path $patchDir | Out-Null

git -C "D:\@DEV\java-courseworks" --no-pager format-patch --binary --output-directory $patchDir 7b47ec1755525f05cd76683215dad5ed3585fe6d^..1effd2672b9a396db04668c0b1d9d89043f1aacd -- practicum

注意:这里的文件目录路径 D:\@DEV\java-courseworks、子目录名称 practicum 与 commit 区间均为示例,请根据实际情况替换为所需要数值。

其中,

  • format-patch 会按提交生成补丁,保留作者、时间、提交说明;
  • --binary 确保图片等二进制文件也可迁移;
  • -- <子目录名称> 只提取目标子目录相关改动。

Step 2:创建独立仓库目录并初始化 Git

这里我选择在原仓库同级目录下创建 StudentUI 目录作为新仓库,并初始化 Git:

1
2
3
$target = "D:\@DEV\StudentUI"
New-Item -ItemType Directory -Path $target -Force | Out-Null
git -C $target init

Step 3:回放补丁并去掉子目录前缀

由于目标仓库希望将 practicum 内容作为根目录,需要把路径前缀剥离。这里使用 git am -p2

1
2
$patches = Get-ChildItem "D:\@DEV\java-courseworks\.tmp_patches\*.patch" | Sort-Object Name | Select-Object -ExpandProperty FullName
git -C "D:\@DEV\StudentUI" am -p2 --committer-date-is-author-date $patches

其中,

  • -p2:将 a/practicum/...b/practicum/... 裁剪成 ...
  • --committer-date-is-author-date:使提交时间与作者时间保持一致,最大化还原历史。

Step 4:迁移后处理

此时已经成功迁移了历史提交,剩下的就是针对实际情况的一些后续处理了。

在本项目中,StudentUIpom.xml 需要从“子模块”改为“独立项目”,简单修改一下 pom.xml 即可。

Step 5:配置新仓库远程地址

1
2
git -C "D:\@DEV\StudentUI" remote add origin "https://github.com/CarmJos/StudentUI"
git -C "D:\@DEV\StudentUI" --no-pager remote -v

若已存在 origin,使用 remote set-url 即可。

1
git -C "D:\@DEV\StudentUI" remote set-url origin "https://github.com/CarmJos/StudentUI"

Step 6:验证结果

1
2
3
git -C "D:\@DEV\StudentUI" --no-pager log --oneline --decorate --max-count=20

git -C "D:\@DEV\StudentUI" --no-pager status --short --branch

建议比对点:

  • 提交顺序是否与源区间一致;
  • 提交时间是否保留;
  • 图片等资源是否完整;

Step 7:推送到远程仓库

1
git -C "D:\@DEV\StudentUI" push origin HEAD:main

Done! 现在 StudentUI 仓库已经成功迁移了 practicum 相关的历史提交,并且可以独立维护了。

欢迎参观 StudentUI 仓库,查看我本次迁移后的结果。

归纳总结

为了方便后续复用,这里总结一下核心步骤:

  1. 导出补丁git format-patch --binary --output-directory <补丁目录> <起始提交>^..<结束提交> -- <子目录>
  2. 初始化新仓库git init
  3. 回放补丁git am -p2 --committer-date-is-author-date <补丁目录>/*.patch
  4. 配置远程git remote add origin <远程地址>
  5. 验证:查看日志、状态,确认历史与资源完整。

快速脚本

如果你只要一个可快速复刻的版本,只需替换以下命令中的路径、提交区间和远程地址即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$srcParent = "D:\@DEV\java-courseworks"
$srcDir = "practicum"
$target = "D:\@DEV\StudentUI"
$patchDir = "D:\@DEV\java-courseworks\.tmp_patches"
$remoteUrl = "https://github.com/CarmJos/StudentUI"
$startCommit = "7b47ec1755525f05cd76683215dad5ed3585fe6d"
$endCommit = "1effd2672b9a396db04668c0b1d9d89043f1aacd"

if (Test-Path $patchDir) { Remove-Item $patchDir -Recurse -Force }
New-Item -ItemType Directory -Path $patchDir | Out-Null

git -C $srcParent --no-pager format-patch --binary --output-directory $patchDir $startCommit^..$endComit -- $srcDir

New-Item -ItemType Directory -Path $target -Force | Out-Null
git -C $target init

$patches = Get-ChildItem "$patchDir\*.patch" | Sort-Object Name | Select-Object -ExpandProperty FullName
git -C $target am -p2 --committer-date-is-author-date $patches

git -C $target remote add origin "$remoteUrl"

完成后别忘了手动进行后处理,并做一次日志与构建验证。

同时,我还将相关操作整合成了一个可复用的 Python 脚本,方便在类似场景下快速执行,
详见 git-dir-exporter 项目 (欢迎Star~)

FAQ(常见问题)

如果有其他问题,欢迎在评论区留言,我会持续更新这个 FAQ。

Q1:为什么这里选择 git format-patch + git am,而不是直接复制目录?

A: 直接复制目录只能拿到“当前快照”,拿不到历史;format-patch + am 可以逐条回放提交,保留提交说明、作者与时间,更适合教学、审计和后续追踪。

Q2:为什么不是用 git subtree split

A: subtree split 也能完成子目录拆分,且会生成仅包含该子目录的新历史分支。本文选择 format-patch + am 的原因是:

  • 更直观展示“迁移区间”和“提交回放”的过程;
  • 方便在迁移中插入额外处理(例如路径裁剪、单独补一条 POM 解耦提交);
  • 对教学场景更友好,读者容易理解每一步在做什么。

如果你更偏向“一条命令出结果”,subtree split 也是可选方案。

Q3:git am -p2 里的 -p2 是怎么确定的?

A: 补丁路径通常是 a/practicum/...b/practicum/...。需要去掉两层前缀(a/b + practicum),所以用 -p2
,最终让文件落在新仓库根目录。

Q4:回放补丁时提示冲突怎么办?

A: 常规处理流程:

  1. 查看冲突文件并手工解决;
  2. git add <冲突文件>
  3. git am --continue 继续回放。

如果发现本次迁移策略不对,可中止后重来:

1
git -C "D:\@DEV\StudentUI" am --abort

Q5:如何确认提交时间是否真的保留了?

A: 可以分别查看源仓库与新仓库日志并对比时间戳:

1
2
git -C "D:\@DEV\java-courseworks" --no-pager log --reverse --date=iso-strict --pretty=format:"%H|%ad|%cd|%s" 7b47ec1755525f05cd76683215dad5ed3585fe6d^..1effd2672b9a396db04668c0b1d9d89043f1aacd -- practicum
git -C "D:\@DEV\StudentUI" --no-pager log --reverse --date=iso-strict --pretty=format:"%H|%ad|%cd|%s"