•
Git 子模块的 Detached HEAD:原理、流程与避坑指南
18 minutes read •
如果你在主仓库里改了功能,顺手也改了子模块的代码,进入子模块执行 git branch 时,却发现自己并不在 main 或 develop 上,而是看到 * (HEAD detached at a1b2c3d),这到底是怎么回事?
下面先解释 detached HEAD 是什么、为什么子模块默认会进入这种状态,再介绍修改与提交流程,以及已经在 detached HEAD 下提交时该如何补救。
一、先理解 HEAD 到底指向什么
在 Git 里,HEAD 表示当前检出的位置。通常,HEAD 指向一个分支引用,再由分支引用指向该分支的末端提交。这种间接关系可以画成下面这样:
HEAD ──▶ main ──▶ a1b2c3d (main 的末端提交)
执行 git commit 后,Git 创建新提交,并让当前分支(这里是 main)指向它。HEAD 仍然指向 main,因此当前检出的位置也随之前进。
二、什么是 detached HEAD(分离头指针)
所谓 detached HEAD,就是 HEAD 不再指向某个分支名,而是直接指向了一个具体的提交哈希:
HEAD ──▶ a1b2c3d (某个具体提交)
这种状态最常见的触发方式是直接检出一个提交哈希或一个标签,例如:
git checkout a1b2c3d
git checkout v1.0.0
注意,detached HEAD 只说明 HEAD 没有通过分支引用来指向提交,并不意味着没有分支指向同一个提交。即使 main 也指向 a1b2c3d,直接检出这个哈希后,HEAD 依然可以处于 detached 状态。
Git 通常会提示你进入了 detached HEAD。这不是错误,你仍然可以查看代码、编译,甚至做实验性的提交。区别在于:提交后 HEAD 会直接指向新提交,而原来的分支不会随之移动。
关键风险在于: 在 detached HEAD 下提交时,没有当前分支自动记录这些新提交。如果你没有创建分支或标签就切走,它们可能只剩 reflog 等临时恢复线索;等这些线索过期后,不再可达的提交可能被垃圾回收清理。
reflog 不是永久备份。普通条目默认在 90 天后过期,从当前分支末端不可达的条目默认在 30 天后过期,但这些设置可修改,实际回收时间也受其他因素影响。因此不能把「30 天内一定能找回来」当成保证。需要保留的提交,应及时用分支或标签保存。
三、为什么子模块默认就是 detached HEAD
子模块默认采用检出指定提交的更新方式,因此通常会进入 detached HEAD。这与主仓库如何记录子模块版本有关。
主仓库记录的是子模块的一个精确提交哈希,而不是一个会移动的分支名。.gitmodules 记录子模块的路径、仓库地址等配置;真正确定版本的,是主仓库提交树中的 gitlink 条目,其中保存了子模块的提交 ID。
这样设计是为了可重现性。如果主仓库只记录「使用子模块的 main 分支」,不同时间克隆时,main 可能已经前进,大家拿到的代码就不一致了。记录精确哈希后,只要该提交仍能获取,检出同一个主仓库版本就能得到同一版本的子模块代码。
所以,当 git submodule update 按默认的 checkout 模式执行实际更新时,它会把子模块检出到主仓库记录的目标提交,而不是切到某个分支。严格地说,默认目标来自主仓库的索引(暂存区);没有暂存子模块版本变化时,它通常与当前主仓库提交记录的版本一致。于是你会看到:
HEAD detached at a1b2c3d
这说明子模块定位到了主仓库要求的版本。子模块也可以手动切到分支,或采用其他更新模式,因此「默认 detached」不等于「永远只能 detached」。
四、修改子模块的完整流程
修改子模块时,先创建或切换到工作分支,便于保存和推送提交。下面以远程名 origin、主仓库分支 main 为例;实际操作时替换成项目使用的名称。
第 1 步:选择开发基线,创建工作分支
先进入子模块,检查是否有未提交的改动。如果要基于主仓库锁定的版本修复问题,可以直接从当前提交创建工作分支:
cd path/to/submodule
# 查看当前检出位置和未提交的改动
git status
# 从当前提交创建分支,保留原来的开发基线
git switch -c feature/submodule-fix
这与直接切到 main 有一个重要区别:创建分支不会改变当前提交,而切到 main 会使用本地 main 的末端提交,它可能与主仓库锁定的版本不同,也未必是远程最新版本。
如果团队要求基于最新 main 开发,可以改用下面这条路径:
git fetch origin
git switch main
# 若本地还没有 main,则用 git switch --track origin/main
git pull --ff-only origin main
git switch -c feature/submodule-fix
--ff-only 会在本地 main 与远程分叉时停止,避免无意中生成合并提交。选择这条路径也意味着最终的子模块升级可能包含 main 上的其他变化,需要一并检查。两条路径选其一,不要连续照抄。
如果已经有未提交的修改,从当前 HEAD 创建新分支通常可以直接保留这些修改;切到另一个已有分支则可能因文件冲突被阻止。先用 git diff 检查,必要时暂存到 stash,再切换并恢复,避免用强制切换来绕过检查。
第 2 步:在子模块里正常修改、提交、推送
现在你处于一个真实分支上,可以像操作普通仓库一样工作:
# (在编辑器里修改子模块代码)
git diff
git add path/to/changed-file # 替换为实际修改的文件,可列出多个
git diff --cached
git commit -m "feat: 实现子模块中的某功能"
# 把子模块的提交推送到子模块自己的远程仓库
git push -u origin feature/submodule-fix
子模块是独立仓库,提交需要推送到它自己的远程。 只推主仓库,并不会默认把子模块的本地提交上传。主仓库若引用了别人无法从子模块远程获取的提交,其他人执行 git submodule update 时就可能失败。
有代码审查流程时,先提交子模块的 PR,按团队约定完成合并,再更新主仓库的引用。尤其要注意 squash 或 rebase 合并会改变提交哈希:主仓库应引用合并后的目标提交,不能继续沿用合并前的哈希。更新引用前,在子模块中 fetch 并检出最终要使用的提交。
第 3 步:回到主仓库,提交子模块指针的变化
确认目标提交可从子模块远程获取后,回到主仓库。下面的路径占位符需要替换为实际的主仓库根目录;不要按子模块路径深度猜测要执行几次 cd ..:
cd /path/to/superproject
git status
git diff --submodule=log -- path/to/submodule
你会看到类似这样的输出:
modified: path/to/submodule (new commits)
new commits 表示子模块当前 HEAD 与主仓库索引记录的提交不同,并不保证它一定是向前升级,也可能是回退或切换到了另一条历史。先查看上面的 diff,确认最终要引用的提交,再将变化暂存:
# 暂存子模块当前 HEAD 对应的提交 ID
git add path/to/submodule
# 若主仓库也有改动,再按需 git add 对应文件
git diff --cached --submodule=log
git commit -m "feat: 更新功能并指向子模块新提交"
git push origin main
git add path/to/submodule 暂存的是子模块当前 HEAD 的提交 ID,不会替你提交子模块内部尚未提交的文件修改。主仓库提交记录的是「子模块应该使用哪个提交」,子模块的文件内容仍由子模块仓库管理。
流程小结
需要记住的是:先确定开发基线并创建工作分支;发布主仓库更新前,确保它引用的子模块提交已可从远程获取。
两个仓库的提交是独立的,本地 commit 的先后顺序没有硬性限制。关键是发布顺序:先让子模块提交可获取,再推送引用它的主仓库提交。
五、如果已经在 detached HEAD 状态下提交了怎么办
如果忘了先创建分支,已经在 detached HEAD 下完成提交,不必重做。只要仍停在这个提交上,就可以直接创建分支保存它;即使已经切走,也可能通过 reflog 找回。
做法是:在当前位置直接创建一个分支来「接住」这个提交。
# 情况 A:目标分支名还不存在,直接以当前 HEAD 为起点创建并切换
git switch -c feature-x
git switch -c(此处也可用 git checkout -b)以当前提交创建新分支,并让 HEAD 指向它。当前提交及其祖先都能通过这个分支找到;若之前连续做了多个提交,也不需要逐个创建分支。随后将分支推送到远程,完成备份或代码审查。
如果目标分支(比如 main)已经存在,git switch -c main 会报错。可以先用临时分支保住提交,再按项目流程合并到目标分支:
# 情况 B:目标分支已存在
git switch -c temp-fix # 先保存当前提交
git switch main # 切到真正的目标分支
git merge temp-fix # 把刚才的提交合并进来
git branch -d temp-fix # 合并完可以删掉临时分支
合并冲突需要解决后再继续;若团队通过 PR 合并,就推送临时分支并提交 PR。临时分支中若混有不需要的提交,应先检查历史,再考虑 cherry-pick 所需提交,而不是整条分支直接合并。
如果已经切走,先进入子模块自己的目录运行 git reflog。不带参数时,它显示本地 HEAD 的 reflog,包括检出和提交等操作;主仓库的 reflog 不会替子模块记录这些操作。找到目标提交后,先检查内容,再创建分支:
git reflog
# 在输出里找到对应的哈希,例如 a1b2c3d
git show a1b2c3d
git branch recovered-work a1b2c3d
git switch recovered-work
六、其他子模块注意事项
除了 detached HEAD,子模块还有几个容易让人困惑的地方,这里一并整理。
克隆带子模块的仓库时不要忘了初始化。 普通的 git clone 只会把子模块目录创建为空文件夹,并不会拉取内容。正确做法是克隆时加参数,或克隆后手动初始化:
# 克隆时一次性拉取所有子模块(含嵌套)
git clone --recurse-submodules <仓库地址>
# 或者克隆后再补
git submodule update --init --recursive
默认的 git submodule update 在实际检出目标提交时,会让子模块进入 detached HEAD。 但它不是每次都会强制检出:子模块 HEAD 已等于目标提交时,普通 update 可能不做任何操作,已有分支状态也就保留下来。--force 才会在相同提交时仍执行检出,并可能丢弃本地修改,不应把它当作常规更新参数。
更新前检查子模块中的未提交改动。普通 checkout 更新会在覆盖本地修改时停止;merge/rebase 模式也可能产生冲突。先提交或妥善保存修改,再更新。
--remote 决定目标提交的来源,不决定是否使用分支状态。 git submodule update --remote 通常先 fetch,再使用选定远程分支的末端提交作为目标。分支由 submodule.<名称>.branch 配置决定,未配置时使用远程 HEAD 对应的分支,不一定是 main,也不一定是子模块当前本地分支的 upstream。默认 checkout 模式在实际检出时仍会进入 detached HEAD。
merge/rebase 决定如何把目标提交整合到当前检出位置。 已在子模块工作分支上时,可以用 git submodule update --merge 将主仓库索引记录的目标提交合并进该分支,或用 --rebase 将当前工作重放到目标提交之上。加上 --remote 后,目标改为上面所说的远程分支末端。这些模式不会替一个已经 detached 的 HEAD 创建具名分支,应先在子模块中切到工作分支。
无论采用哪种更新模式,主仓库的 gitlink 始终记录精确提交。配置 branch 或 update = merge/rebase 不会改变这个机制;执行 --remote 也不会自动提交主仓库中的指针变化。若要分享更新后的子模块版本,仍需在主仓库中 add、commit 并推送。
嵌套子模块需要递归处理。 更新使用 git submodule update --init --recursive;克隆使用前面的 git clone --recurse-submodules。注意两个命令的选项写法不同。
主仓库看到的 (new commits) 和 (modified content) 含义不同。 前者表示子模块 HEAD 与主仓库索引记录的提交不同;后者表示子模块内部已跟踪的文件存在未提交修改(包括已暂存的修改)。未跟踪文件还可能显示为 (untracked content)。这些状态可以同时出现;暂存新的 gitlink 并不会把内部修改一并提交。
用推送检查减少遗漏。 在主仓库执行 git push --recurse-submodules=check,可以检查本次推送涉及的子模块提交是否已能从子模块的远程跟踪分支到达;检查不通过时会中止推送。也可以为当前主仓库设置默认检查:
git config push.recurseSubmodules check
git push --recurse-submodules=on-demand 则会尝试先推送所需的子模块提交,失败时中止主仓库推送。它依赖子模块的远程与推送配置,不会替你决定 detached HEAD 下的提交应发布到哪个分支,也不能绕过权限或受保护分支规则。先把子模块提交保存到合适的分支并配置好推送目标,再使用这个选项。
另外,git config submodule.recurse true 能让支持该配置的命令(如 pull、checkout)默认递归处理子模块。它会改变这些命令对子模块工作目录的影响,并不等于「把子模块保持在工作分支上」,也不能替代克隆时的 --recurse-submodules。
团队协作时约定开发基线和发布流程。 在项目文档中说明:修改应基于主仓库锁定的提交,还是子模块最新的 main;是否需要 PR;合并后应引用哪个提交。与其只要求大家切到同名分支,更重要的是确认大家使用了正确的版本。
结语
子模块的默认 detached HEAD 是检出精确版本的结果。阅读和构建代码时可以保持这种状态;需要开发时,先选好基线并创建工作分支。发布前再确认两件事:子模块目标提交已可从远程获取,主仓库记录的正是那个提交。