Git 外部仓库集成与维护:Submodule、Subtree 和历史归档
太阳作者太阳
原创内容采用 CC-4.0 协议发布,转载请注明出处
GitSubmoduleSubtree仓库归档迁移

Git 外部仓库集成与维护:Submodule、Subtree 和历史归档

把一个已经存在的 Git 仓库放进另一个仓库时,首先要决定两者以后是否仍然独立。Submodule 保存的是“外部仓库地址 + 当前提交引用”,适合依赖继续独立演进的场景;Subtree 把外部文件和历史并入当前仓库,适合统一检出、迁移或长期归档。

如果目标不是“复制一份当前文件”,而是保留原项目的提交历史,就不能只用下载、复制、提交的方式。关键判断标准是:后续是否还需要通过 git log 追溯旧项目里的提交、作者、时间和变更过程。如果需要使用 Subtree,就不要添加 --squash

如何选择

需求 更合适的方式
外部项目继续拥有独立仓库和发布节奏 Submodule
主仓库只记录外部项目的一个确定提交 Submodule
克隆主仓库后不想再初始化额外仓库 Subtree
把旧项目迁入主仓库并保留原始历史 Subtree,不使用 --squash
只需要一次性文件快照,不需要完整历史 复制文件,或 Subtree 配合 --squash

使用 Git Submodule

添加子模块

git submodule add https://github.com/OWNER/DEPENDENCY.git path/to/dependency
git commit -m "Add dependency submodule"

这会把子模块 URL 和路径写入 .gitmodules,并在主仓库索引中记录子模块当前提交。主仓库不会直接保存子模块工作树中的全部文件历史。

如果子模块应该跟踪指定分支,可以在添加时声明:

git submodule add -b main https://github.com/OWNER/DEPENDENCY.git path/to/dependency

克隆和初始化

新克隆时直接递归初始化:

git clone --recurse-submodules https://github.com/OWNER/MAIN_REPO.git

如果已经克隆了主仓库,再补充初始化:

git submodule update --init --recursive

默认更新会检出主仓库记录的确定提交,而不是自动追到子模块远端的最新分支。这正是 Submodule 可复现性的来源。

更新子模块

先在子模块中获取并检出需要的提交,再由主仓库记录新的引用:

git -C path/to/dependency fetch
git -C path/to/dependency switch main
git -C path/to/dependency pull --ff-only
git add path/to/dependency
git commit -m "Update dependency submodule"

如果 .gitmodules 或本地配置已经声明了跟踪分支,也可以使用:

git submodule update --remote path/to/dependency
git add path/to/dependency
git commit -m "Update dependency submodule"

提交前应检查子模块指针变化,避免无意记录本地临时提交。

删除子模块

不要先用 rm -rf 直接删除目录。先注销本地工作树,再让 Git 删除 .gitmodules 条目和索引记录:

git submodule deinit -f -- path/to/dependency
git rm -f path/to/dependency
git commit -m "Remove dependency submodule"

git rm 后,主仓库里受版本控制的子模块配置已经删除。.git/modules/path/to/dependency 下可能仍保留本地仓库数据,Git 有意不自动删除它,以防误删尚未推送的提交。确认不再需要后,才手动清理这份本地数据:

rm -rf .git/modules/path/to/dependency

这一步只影响当前克隆,不应把手工编辑 .git/config 当作常规删除流程。

使用 Git Subtree 归档完整历史

这类操作适合归档旧项目、迁移历史资料、把独立文档仓库并入主仓库等场景。

推荐方式:git subtree

如果本机 Git 支持 git subtree,推荐直接使用它。假设要把 https://github.com/OWNER/REPO.git 放到当前仓库的 .archive/REPO 目录:

# 先确认当前工作区没有未提交改动,避免把归档操作和其他变更混在一起
git status --short

# 建议在独立分支操作,方便检查和回滚
git switch -c codex/archive-repo

# 确认外部仓库默认分支,输出通常会指向 refs/heads/main 或 refs/heads/master
git ls-remote --symref https://github.com/OWNER/REPO.git HEAD

# 如果默认分支是 main,就把完整历史合并到指定子目录
git subtree add --prefix=.archive/REPO https://github.com/OWNER/REPO.git main -m "Archive external repository"

如果默认分支是 master,最后一条命令里的 main 要替换成 master

这里不要加 --squash--squash 的效果是把外部仓库历史压成一个提交,只适合把外部依赖当作一次性快照引入;如果目标是“合并 Git 提交历史”,它反而会破坏这个目标。

备用方式:merge + read-tree

Git 官方文档里的 subtree merge 流程也可以完成同样的事。这个方式更底层,适合没有 git subtree 命令,或者想明确控制每一步时使用:

# 添加并抓取外部仓库
git remote add -f archived-repo https://github.com/OWNER/REPO.git

# 创建一次“采用当前树”的合并,允许两个无共同祖先的仓库建立历史关系
git merge -s ours --no-commit --allow-unrelated-histories archived-repo/main

# 把外部仓库的文件树读入当前仓库的目标子目录
git read-tree --prefix=.archive/REPO/ -u archived-repo/main

# 提交这次归档合并
git commit -m "Archive external repository"

如果外部仓库默认分支是 master,上面的 archived-repo/main 同样要换成 archived-repo/master

这套流程的核心是:

  • git merge -s ours --no-commit --allow-unrelated-histories:让两个无共同历史的仓库建立合并关系,但暂时不把外部文件铺到当前根目录。
  • git read-tree --prefix=... -u:把外部仓库当前分支的文件树放进指定子目录。
  • git commit:生成一个真正连接两边历史的合并提交。

检查历史是否保留下来

完成后不要只看文件是否出现,还要检查历史是否能追溯:

# 查看归档目录相关历史
git log --oneline --graph --decorate --all -- .archive/REPO

# 查看整体提交图,确认出现了外部仓库历史和合并节点
git log --oneline --graph --decorate --all --max-count=30

如果只能看到一个新增 .archive/REPO 的提交,而看不到外部项目原来的提交链,通常说明用了复制提交或 --squash,没有达到“合并提交历史”的目标。

.archive 被忽略时的处理

很多项目会把 .archive 放进 .gitignore,用于本地临时资料。如果要把 .archive/REPO 正式纳入版本控制,需要检查忽略规则:

# 检查目标路径是否被忽略,以及是哪条规则导致的
git check-ignore -v .archive/REPO

如果确实被忽略,可以在 .gitignore 中保留大部分 .archive 忽略规则,同时放行这一个归档目录,例如:

.archive/*
!.archive/REPO/
!.archive/REPO/**

具体规则要按仓库现有 .gitignore 写法调整。目标是只放行这次要纳入 Git 历史的目录,不把其他临时归档文件一起纳入。

后续同步外部仓库更新

如果之后还想继续从外部仓库同步新提交,git subtree 方式可以继续拉取:

git subtree pull --prefix=.archive/REPO https://github.com/OWNER/REPO.git main

如果外部仓库只是一次性归档,完成合并并验证历史后,就不需要保留远程配置。使用 merge + read-tree 时添加的 archived-repo remote 可以按需要删除:

git remote remove archived-repo

相关 Git 配置与仓库维护

这些配置不属于 Submodule 或 Subtree 本身,但经常影响外部仓库的访问和维护。

提交身份

全局身份适合个人设备;需要为单个仓库使用不同身份时,去掉 --global 并在仓库内执行:

git config --global user.name "YOUR_NAME"
git config --global user.email "YOUR_EMAIL"

HTTP 代理

只在网络环境确实需要时配置,并使用实际代理地址:

git config --global http.proxy http://PROXY_HOST:PROXY_PORT
git config --global https.proxy http://PROXY_HOST:PROXY_PORT

不再需要时应删除,避免以后所有 Git HTTP 请求继续经过旧代理:

git config --global --unset http.proxy
git config --global --unset https.proxy

凭据存储

credential.helper store 会把凭据以未加密形式写入磁盘,不应作为默认推荐。优先使用操作系统提供的凭据管理器或组织规定的 credential helper;只有理解风险并确认文件权限与设备边界后,才考虑 store

仓库清理

Git 通常会自动执行必要的维护。确实需要手动清理和优化当前仓库时,先使用普通命令:

git gc

旧备忘中的 git gc --aggressive --prune 不适合作为日常命令:--aggressive 会显著增加重新打包成本,立即裁剪不可达对象也会缩短误操作后的恢复窗口。只有明确理解对象保留策略并已经做好备份时,才应调整这些参数。

本项目记录

本次讨论的目标是把一个外部 Git 仓库合并到当前仓库的 .archive 下,并要求保留 Git 提交历史。建议目标目录使用 .archive/REPO,而不是直接铺到 .archive 根目录,原因是归档边界更清楚,也能避免和已有归档内容冲突。

优先命令是:

git subtree add --prefix=.archive/REPO https://github.com/OWNER/REPO.git main -m "Archive external repository"

执行前先用 git ls-remote --symref 确认默认分支;执行后用 git log --graph 检查外部仓库历史是否确实进入当前仓库。