最快的修复: 在主仓库根目录依次执行 git submodule sync --recursive 和 git submodule update --init --recursive。前者把 .gitmodules 里的 URL 重新写进 .git/config,后者把 submodule checkout 到主仓库记录的那个 SHA,绝大多数「submodule 没更新」的情况到这一步就解决了。如果你真正想要的是 submodule 远端的最新 commit(而不是被钉住的那个),那是另一条命令——git submodule update --remote——这个区别正是大家以为 submodule「坏了」的头号原因。
你修改了 .gitmodules,把 vendor/ui-kit 指向新 URL,执行了 git submodule update --init --recursive,但 submodule 目录里还是旧 commit,或者目录干脆是空的。又或者同事在自己机器上提交了 submodule 指针更新并推送,你 git pull 后执行 git submodule update,却得到一个在 submodule 历史里根本不存在的 SHA。Submodule 之所以让人困惑,是因为有三样东西必须保持一致:
.gitmodules文件(被纳入版本管理的文本,保存 URL 和可选的 branch);.git/config里的[submodule]段落(你本地、不纳入版本管理的那份 URL 副本);- 主仓库里以「gitlink」树对象形式记录的 SHA 指针(
git ls-tree HEAD <path>)。
只要其中任意两者不一致,update 就会悄无声息地什么都不做,或者直接报错。以下命令和参数行为截至 2026 年 6 月(Git 2.4x)有效。
先判断你属于哪一种
先跑这三条命令,再对照下表找到你的原因:
git submodule status --recursive
git config --file .gitmodules submodule.vendor/ui-kit.url
git config submodule.vendor/ui-kit.url
| 症状 | 最可能的原因 | 跳到 |
|---|---|---|
git submodule status 某行以 - 开头 | submodule 从未初始化(clone 时漏了 --recurse-submodules) | 原因 1 / Step 2 |
某行以 + 开头 | 当前 checkout 的 SHA 与主仓库钉住的 SHA 不一致 | 原因 5 / Step 3 |
上面两个 git config ... .url 的值不一样 | 改了 .gitmodules 但 .git/config 没同步 | 原因 1 / Step 1 |
报错 fatal: reference is not a tree | 钉住的 SHA 在 submodule 远端不存在 | 原因 2 / Step 2 |
| submodule 里的 URL 还是旧地址 | clone 在迁址之前,之后没 sync | 原因 3 / Step 1 |
.git/modules/<path> 不存在 | 该目录是普通文件夹,不是嵌套仓库 | 原因 4 / Step 4 |
| submodule 只有一条历史 commit | 浅克隆把钉住的 SHA 截断了 | 原因 6 / Step 5 |
按 Git 官方文档,git submodule status 行首前缀的含义:空格表示已同步,- 表示未初始化,+ 表示当前 checkout 的 commit 与主仓库记录的 SHA 不一致,U 表示存在合并冲突。
常见原因
按命中率从高到低排列。
1. 改了 .gitmodules,但 .git/config 没同步
手动编辑 .gitmodules(而不是用 git submodule set-url)只改了被版本管理的那份文本,.git/config 里的 [submodule] 段落还指向旧 URL。git submodule sync 这条命令的存在意义就是把两者重新对齐;它只会处理那些 .git/config 里已经有记录的 submodule。
怎么判断:比对 git config --file .gitmodules submodule.vendor/ui-kit.url 和 git config submodule.vendor/ui-kit.url,两者不同就需要 sync。
2. 主仓库的 gitlink 指向一个还没推到 submodule 远端的 SHA
同事先在主仓库提交并推送了 submodule 指针更新,却忘了把 submodule 自己的那些新 commit 推到 submodule 远端。于是主仓库引用了一个别人 fetch 不到的 SHA。
怎么判断:cd vendor/ui-kit && git fetch && git cat-file -t <SHA>。如果返回 fatal: Not a valid object name,说明这个 SHA 还没推到远端,git submodule update 会以 fatal: reference is not a tree: <SHA> 失败。
3. submodule 初始化时用的是错误的 URL
团队迁移了托管平台(比如从 GitHub 迁到 GitLab),更新了 .gitmodules,但在迁移之前 clone 的开发者从没跑过 git submodule sync。他们的 .git/config 里还是旧 URL,fetch 时打到已失效的地址。
怎么判断:git -C vendor/ui-kit remote get-url origin 返回旧 URL,或者 git submodule foreach 'git remote -v' 显示各个 submodule 还指向旧主机。
4. submodule 目录是普通文件夹,不是 git 仓库
submodule 被误删后又被重建成一个普通文件夹,或者连接到 .git/modules/... 的那个 gitfile 被删了。Git 没法把一个普通目录当 submodule 来更新。
怎么判断:ls .git/modules/vendor/ui-kit 不存在,或者 vendor/ui-kit 里没有 .git 文件。
5. 该用 --remote 时跑成了 git submodule update
git submodule update 会 checkout 到主仓库 commit 里钉住的那个确切 SHA。它没出错——只是那个被钉住的 SHA 并不是 submodule 的最新 commit。要前进到远端最新,必须用 --remote。
怎么判断:git submodule status 显示的 SHA 与 git ls-tree HEAD vendor/ui-kit 一致(说明你正确地停在了钉住的 commit 上),但你期待的是更新的版本。
6. 浅克隆把 submodule 的 SHA 截断了
CI 用 --depth 1 克隆主仓库、又用 --depth 1 克隆 submodule,但 submodule 钉住的 SHA 比这条浅克隆边界更早。
怎么判断:git -C vendor/ui-kit log --oneline | wc -l 返回 1,而所需的 SHA 不在这条 commit 里。这是 CI 中 fatal: reference is not a tree 的经典根因。
最短修复路径
Step 1:把 .gitmodules 同步进 .git/config
# 在主仓库根目录执行
git submodule sync --recursive
这会把 .gitmodules 里的当前 URL 复制进每个 submodule 的 .git/config。今后改 URL 优先用 git submodule set-url <path> <newurl>(它一步改好两份文件),别手动编辑 .gitmodules。
Step 2:初始化并递归更新所有 submodule
git submodule update --init --recursive
如果某个 submodule 的 SHA 在远端缺失,你会看到 fatal: reference is not a tree: <SHA>。这是「没推上去」的问题,不是你本地的问题——让提交指针的人先把 submodule 的 commit 推上去,再重试。(可用 git -C vendor/ui-kit fetch && git cat-file -t <SHA> 确认。)
Step 3:把 submodule 推进到分支最新(仅当你确实想要最新 commit)
如果你想让 submodule 跟随某个分支,而不是停在钉住的 SHA:
git submodule update --remote --merge vendor/ui-kit
--remote 会拉取 submodule 所配置分支的最新 commit(即 .gitmodules 里的 branch =,缺省时取远端 HEAD),--merge 则让 submodule 不进入 detached HEAD。然后在主仓库记录新指针:
git add vendor/ui-kit
git commit -m "chore: bump ui-kit submodule to latest main"
只要你没提交这个 gitlink,队友就看不到这次升级——单独的 git submodule update --remote 只动了你自己的工作区。
Step 4:修复缺失或损坏的 submodule 目录
# 干净地 deinit、清掉嵌套仓库,再重新初始化
git submodule deinit -f vendor/ui-kit
rm -rf .git/modules/vendor/ui-kit
git submodule update --init vendor/ui-kit
如果这个路径从没被正确注册过,重新加回来:git submodule add <url> vendor/ui-kit,再 git submodule update --init vendor/ui-kit。
Step 5:修复 CI 里的浅克隆失败
# 把 submodule 取消浅克隆,让钉住的 SHA 可达
git -C vendor/ui-kit fetch --unshallow
git submodule update --init --recursive
更好的做法是在 checkout 环节修复。截至 2026 年 6 月,actions/checkout@v4(v5 已发布)可以这样配置:
- uses: actions/checkout@v4
with:
submodules: recursive
fetch-depth: 0 # 拉取完整历史;避免旧指针触发 "reference is not a tree"
在 GitHub Actions 之外,用 git clone --recurse-submodules <url> 且不加 --depth,或给一个宽松的 --depth,比如 --shallow-submodules --depth 50。
怎么确认已经修好
git submodule status --recursive
每一行都应以单个空格开头(不出现 -、+、U)。然后抽查一个 submodule:
git -C vendor/ui-kit rev-parse HEAD # 应等于下面主仓库期望的 SHA
git ls-tree HEAD vendor/ui-kit # 主仓库期望的 SHA
如果这两个 SHA 一致、状态行也干净,说明 submodule 已正确 checkout。
预防建议
- clone 时用
git clone --recurse-submodules <url>,让新 clone 自动初始化 submodule。 - 设置
git config --global submodule.recurse true(Git 2.14+),让git pull、git checkout、git switch自动帮你移动 submodule 指针。 - 升级 submodule 时,先把 submodule 自己的 commit 推到它的远端,再在主仓库提交指针变更。顺序反了,就会给所有人制造
fatal: reference is not a tree。 - 对任何打算用
--remote跟随的 submodule,在.gitmodules里钉一个分支(branch = main)。 - 在 GitHub Actions 里给
actions/checkout设submodules: recursive;如果指针可能很旧,再加fetch-depth: 0。 - 加一个 pre-push hook 拦截未提交的指针漂移:
git submodule status | grep '^+'有输出就说明有升级没提交。 - 在
CONTRIBUTING.md里写清流程。很多 submodule 问题源于开发者git pull后不知道还要git submodule update。 - 对那些你只消费、从不修改的第三方依赖,考虑改用真正的包管理器(npm、pip、Cargo)或
git subtree——submodule 真正的优势在于和你并行开发的第一方代码。
常见问答 (FAQ)
Q:git submodule update 后 submodule 处于 detached HEAD,是 bug 吗?
A:不是,这是设计如此。git submodule update checkout 的是一个具体 SHA、不是分支,所以 detached HEAD 是预期状态。要在 submodule 里提交,先在里面 git switch -c my-branch,或者用 --remote 配合 --merge / --rebase 让它停在分支上。
Q:git submodule update 和 git submodule update --remote 有什么区别?
A:不带参数的 update checkout 主仓库钉住的那个确切 SHA;--remote 则忽略钉住的 SHA,去拉取 submodule 所配置分支的最新 commit。如果你「拉不到最新」,要的几乎都是 --remote。
Q:怎么彻底删除一个 submodule?
A:依次执行 git submodule deinit -f vendor/ui-kit、git rm -f vendor/ui-kit、rm -rf .git/modules/vendor/ui-kit。.gitmodules 里的 [submodule "vendor/ui-kit"] 段落会随 git rm 自动移除,提交即可。
Q:能用 --depth 给 CI 里的 submodule 更新提速吗?
A:可以,git submodule update --init --recursive --depth 1。但只要钉住的 SHA 比这条边界更旧,fetch 就会以 fatal: reference is not a tree 失败。更稳妥的下限是 --depth 50(在 Actions 里则用 fetch-depth: 0)。
Q:我们全队改了 submodule 的 URL,最快的迁移方式是什么?
A:一个人执行 git submodule set-url vendor/ui-kit <newurl>,提交 .gitmodules 并推送。其他人按顺序执行:git pull && git submodule sync --recursive && git submodule update --init --recursive。
相关阅读
- 我在 detached HEAD 上提交了,怎么办
- Worktree 在分支删除后变成幽灵
- Git LFS pointer 文件没被真实文件替换
- Monorepo partial clone 数据过期
- 找回 Git 历史里的旧版本文件
- Clone 之后 git hooks 不执行
标签: #git #version-control #排查