TOOLS NOTE

本次补全(对照《Git常用命令》把架子填上)

1、补了子模块从加到删的完整链路,只留平时真会用到的部分
2、排了点卡片和表格,目录还是 toc: true 那一套
3、坑单独拎出来了,这个东西确实比普通命令更容易把自己绕进去


一些概念

子模块是什么

一个 Git 仓库嵌在另一个 Git 仓库里。表面上是普通目录,实际上自己有独立的 .git、独立的提交历史。

父仓库记的不是文件

父仓库只记录子模块的 pathurl,以及当前钉死的那个 commit SHA。文件内容不进父仓库。

.gitmodules

父仓库根目录下的清单文件,记录每个子模块的路径、远程地址、可选的跟踪分支。这个文件是要提交的。

gitlink / 160000

git ls-files --stage 里子模块目录的 mode 是 160000,它指向一次提交,不是一棵文件树。所以 git add lib 加进去的是指针,不是源码。

  • detached HEAD
    默认 update 完,子模块会停在那个被钉死的 commit 上,不在分支上。要改子模块代码,先 git checkout main(或你跟踪的分支),不然提交容易变成野指针。
  • 和普通目录的差别
    克隆父仓库时如果没初始化子模块,那个目录常常是空的,看起来像丢文件,其实只是指针还没把内容拉下来。

有点子模块很适合:公共组件库、主题、文档站、第三方源码要跟着版本走。
不太建议:改得特别勤、还要和父仓库同一份 PR 一起走的业务代码。那种用 monorepo 或 subtree 更省心。


一些选项

--init 第一次把 .gitmodules 里的配置写进本地 .git/config
--recursive 子模块里面还有子模块时,一层层拉完
--remote 按跟踪分支去拉远程最新,而不是父仓库钉死的那个 SHA
-b 添加时指定跟踪分支,后面 --remote 才知道追谁
-f / --force 强制,路径被占用或要覆盖时才用
--merge / --rebase 更新子模块时选择合并策略,默认是 checkout 到指定 commit
--checkout 默认行为,直接把子模块工作区切到目标 commit


它长什么样

父仓库 ├── src/ ├── lib/ ← 子模块目录,看起来像普通文件夹 │ └── ... ← 其实是另一个独立仓库的工作区 ├── .gitmodules ← 记录 path / url / branch └── .git/ └── modules/lib ← 子模块真正的 git 目录常住这儿

.gitmodules 大概长这样:

[submodule "lib"]
	path = lib
	url = https://github.com/xxx/lib.git
	branch = main

branch = main 不是必须的,但你后面想用 update --remote,最好写上,不然它可能去追远程默认分支,跟你想的不一致。

git submodule add

把别人的仓库挂进来,最常用的就是这一条。

git submodule add <远程网址> <本地目录>
git submodule add -b main https://github.com/xxx/lib.git lib

git add .gitmodules lib
git commit -m "add lib submodule"
  • 执行完会改三样东西:.gitmodules.git/config、以及工作区里的那个目录
  • 路径已经被占用就加不进去,先挪开或换个目录名
  • 添加完记得把 .gitmodules 和子模块路径一起提交,少交一个,别人拉下来是空的

git submodule add <url> 不写路径的话,会用仓库名当目录名

git clone(带上子模块)

普通 git clone 不会把子模块内容拉下来,目录在,货不在。

git clone --recurse-submodules <远程网址>
git clone --recurse-submodules -b <分支> <远程网址> <本地目录>

已经 clone 完才想起来?补这两步:

git submodule init
git submodule update
# 等价于:
git submodule update --init --recursive

新 Git 还可以:git clone --recursive,和 --recurse-submodules 一个意思。
CI 里也要开 recursive,不然构建机上子模块目录是空的,一跑就炸。

git submodule init & update

日常同步,这两条是核心。

git submodule init
# 把 .gitmodules 登记进本地 config,还没拉代码

git submodule update
# 按父仓库记录的 SHA,把子模块 checkout 到那一次提交

git submodule update --init --recursive
# 最常用:没初始化就初始化,有嵌套就递归,一次到位
  • update 默认是 钉死版本,不是最新 main
  • 父仓库 git pull 之后,如果子模块指针变了,还要再 update 一次,否则你本地子模块还停在旧 SHA 上
  • 想 checkout 时自动带着子模块走:
git config submodule.recurse true
git checkout --recurse-submodules <分支>

在子模块里干活

这个顺序一定要记住,反了同事就拉不到你的提交。

1

进子模块,先挂到真正的分支上,别在 detached HEAD 上直接改

2

改完:add / commit / push,先把子模块自己推到远程

3

回到父仓库,git add lib,提交这个新的 SHA 指针

4

git push 父仓库。别人 pull 父仓库 + update,才能对上你那次提交

cd lib
git checkout main
# 改代码...
git add .
git commit -m "fix: xxx"
git push

cd ..
git add lib
git commit -m "bump lib to xxx"
git push
最常见的翻车

子模块 commit 了但没 push,父仓库却先把新 SHA 推上去了。别人 update 时去远程找这个 commit,找不到,直接报错。先推子模块,再推父仓库。

git submodule update --remote

想把子模块从“钉死的旧 SHA”赶到跟踪分支的最新提交。

git submodule update --remote
git submodule update --remote --merge
git submodule update --remote lib
  • 它看的是 .gitmodules 里的 branch,没有就用远程默认分支
  • 跑完工作区变了,还要在父仓库再 commit 一次,否则只是你本地新了
  • 指定跟踪分支:
git submodule set-branch -b main -- lib
git submodule set-url lib https://github.com/xxx/lib.git

更稳妥的做法其实是进子模块里自己 pull,看一眼 diff,确认没问题再回到父仓库提交指针。--remote 省事,但你不一定知道它拉到了哪。

git submodule status / foreach / sync

git submodule status
# 前面带空格:和父仓库记录一致
# 前面带 + :本地子模块 SHA 和父仓库不一致(改过还没在父仓库提交,或还没 update)
# 前面带 - :还没初始化
# 后面带 (heads/main) 之类:当前在某分支上,没有则多半是 detached

git submodule foreach --recursive git status
git submodule foreach --recursive git pull

git submodule sync --recursive
# .gitmodules 里的 url 改了,用它同步到 .git/config

foreach 很好用,批量给所有子模块跑同一条命令,嵌套的记得加 --recursive

git rm(删除子模块)

不要只删目录,那会留下一堆残渣。

git submodule deinit -f lib
git rm -f lib
rm -rf .git/modules/lib
git commit -m "remove lib submodule"
  • deinit:卸本地 checkout,清 .git/config 里对应项
  • git rm:从索引和 .gitmodules 里拿掉
  • 最后清 .git/modules/lib,不然以后同名路径再加回来可能很怪

老 Git 没有 deinit 的话,手工活更多:改 .gitmodules、改 .git/configgit rm --cached、再删目录。能升 Git 就升。

常见坑(真的会踩)

1. clone 完目录是空的

漏了 --recurse-submodules。补 git submodule update --init --recursive

2. 父仓库 pull 了,子模块还是旧的

git pull 只更新指针,不自动把子模块内容切过去。再跑一次 update,或者打开 submodule.recurse

3. detached HEAD 上直接提交

提交悬在空中,一 update 就丢。先 checkout 到分支,再改再推。

4. 只推了父仓库,子模块新 commit 还在本地

别人拿到新 SHA,远程没有对应对象。顺序永远是:子模块 push → 父仓库 commit 指针 → 父仓库 push。

5. 换分支后子模块又乱了

A 分支有这个子模块,B 分支没有,来回切会留下脏目录。切分支用 git checkout --recurse-submodules,实在乱了就 deinitupdate --init

一个省心配置

git config --global submodule.recurse true,之后 checkout / pull 会尽量带着子模块一起走。不是万能,但少打很多字。

速查

你想做的事 命令
添加子模块 git submodule add -b main <url> <path>
克隆时一并拉下 git clone --recurse-submodules <url>
已有仓库补初始化 git submodule update --init --recursive
同步到父仓库记录的 SHA git submodule update
追跟踪分支最新 git submodule update --remote
看每个子模块状态 git submodule status
批量执行 git submodule foreach --recursive <cmd>
远程地址改了 git submodule sync --recursive
删掉 git submodule deinit -f <path> + git rm -f <path>

推荐工作流

不想每次都想“我现在到底在哪一层仓库”,就按这个来:

# 拉项目
git clone --recurse-submodules <url>
git pull
git submodule update --init --recursive

# 改公共库
cd lib && git checkout main && git pull
# ... 改 ...
git add . && git commit -m "xxx" && git push
cd .. && git add lib && git commit -m "bump lib" && git push

# 只想跟着父仓库走,不改子模块
git pull
git submodule update --init --recursive

记住一句话就够了:父仓库存的是指针,子模块才是代码。
改代码去子模块里改;给别人用,就把这个指针在父仓库里往前挪一格。