Git submodule 的精准用法: Difference between revisions
No edit summary |
|||
| Line 816: | Line 816: | ||
! 能力 | ! 能力 | ||
! 普通嵌套 <code>git clone</code> | ! 普通嵌套 <code>git clone</code> | ||
! Git submodule | |||
|- | |- | ||
|插件能否运行 | |插件能否运行 | ||
Revision as of 21:15, 16 July 2026
Git submodule 适合这种情况:
- 一个主项目依赖若干外部 Git 仓库,但又希望这些外部仓库继续保持独立,并且由主项目精确指定它们的版本。
在 Emacs 配置中,可以把:
~/.emacs.d/
视为主仓库,把 Vertico、Consult、Magit 等第三方插件视为独立仓库。
使用 submodule 后,主仓库可以记录每个插件的:
- 仓库 URL
- 保存路径
- 当前锁定的准确 commit
这样就能对整套插件环境进行精确控制、整体回滚和异地复现。
核心原理
普通 git clone 为什么不够
假设已经在 ~/.emacs.d/ 建立了 Git 仓库,然后直接执行:
cd ~/.emacs.d/travel/packages
git clone https://github.com/minad/vertico.git
目录会变成:
~/.emacs.d/ 主仓库
└── travel/packages/
└── vertico/ 另一个独立 Git 仓库
└── .git/
Vertico 当然可以正常运行。Emacs 并不关心插件是通过哪种方式下载的,只要源码位于 load-path 中即可。
但对外层的 ~/.emacs.d 主仓库而言,这个嵌套仓库只是一个未经正式登记的外部目录。主仓库没有完整记录:
- Vertico 从哪个 URL 下载
- 应该保存在哪个目录
- 应该使用哪个 commit
- 换电脑后应该怎样恢复
- 主配置回滚时,Vertico 应该回到哪个版本
普通 git clone 解决的是:
- 把插件下载到当前电脑。
Git submodule 解决的是:
- 让主仓库正式声明:本项目依赖这个外部仓库,它应放在这个路径,并固定使用这个准确版本。
submodule 到底记录了什么
执行:
git submodule add \
https://github.com/minad/vertico.git \
travel/packages/vertico
Git 会记录两类信息。
.gitmodules:记录 URL 和路径
主仓库根目录会出现:
.gitmodules
内容大致如下:
[submodule "travel/packages/vertico"]
path = travel/packages/vertico
url = https://github.com/minad/vertico.git
它记录:
插件 URL: https://github.com/minad/vertico.git
保存位置: travel/packages/vertico
.gitmodules 是主仓库里的普通文本文件,应当提交进 Git。
gitlink:记录准确 commit
主仓库不会把 Vertico 的所有源码文件当成自己的文件追踪,而是在:
travel/packages/vertico
这个路径上记录一个特殊条目,指向 Vertico 的某个 commit。
例如:
Vertico → 9f84c31
这类特殊引用通常称为 gitlink。
因此,主仓库记录的是:
- 当前这套 Emacs 配置应当使用 Vertico 的
9f84c31版本。
插件的完整开发历史仍然保存在 Vertico 自己的仓库里,并不会被复制进主仓库。
主仓库和插件仓库的职责分工
| 仓库 | 负责记录什么 |
|---|---|
~/.emacs.d 主仓库
|
自己的配置文件,以及每个插件应使用哪个 commit |
| Vertico 子仓库 | Vertico 源码和 Vertico 自己的完整开发历史 |
| Consult 子仓库 | Consult 源码和 Consult 自己的完整开发历史 |
| Magit 子仓库 | Magit 源码和 Magit 自己的完整开发历史 |
这种设计避免了两个问题:
- 不把别人写的几百个插件源码文件伪装成自己的文件
- 又能让配置仓库精确声明自己依赖哪个插件版本
submodule 最重要的价值
固定插件版本
主仓库可以准确表达:
这版 Emacs 配置
├── Vertico:commit A
├── Consult:commit B
└── Magit:commit C
它记录的不是模糊的“使用最新版”,而是:
- 这套配置经过测试时,各插件分别停留在哪个准确版本。
这相当于给插件环境加了一把版本锁。
整体回滚
假设主仓库中有两个提交:
提交 A:
Vertico = abc1234
提交 B:
Vertico = def5678
Vertico 升级后出现问题,把主仓库恢复到提交 A,再执行:
git submodule update --init --recursive
Vertico 就会重新回到:
abc1234
因此,回滚的不只是自己的 .el 配置,还包括与当时配置配套的插件版本。
普通嵌套 git clone 做不到这一点。即使主配置回到一个月前,插件仍可能停留在今天的新版本。
异地完整复现
在另一台电脑执行:
git clone --recurse-submodules \
主仓库URL \
~/.emacs.d
Git 会自动知道:
- 去哪个 URL 下载插件
- 把插件放在哪个目录
- 将插件切换到哪个 commit
恢复出来的插件环境与原电脑一致。
明确记录插件升级
使用 submodule 后,升级插件会被主仓库识别为一次正式变化:
Vertico 原来:abc1234
Vertico 现在:def5678
测试成功后可以提交:
git add travel/packages/vertico
git commit -m "Update Vertico"
这让插件升级变成一种:
- 可查看
- 可测试
- 可提交
- 可撤销
的配置变化。
目录设计与初始化
推荐的 Emacs 目录结构
第三方插件源码与自己的配置文件最好分开:
~/.emacs.d/
├── .git/
├── .gitmodules
├── init.el
└── travel/
├── packages/
│ ├── vertico/ Git submodule
│ ├── consult/ Git submodule
│ ├── orderless/ Git submodule
│ └── magit/ Git submodule
│
├── 10-qlzq/
├── 20-stable/
│ └── init-vertico.el
├── 30-maybe/
└── 40-test/
└── init-consult.el
其中:
travel/packages/
专门保存第三方插件原始仓库。
而:
10-qlzq
20-stable
30-maybe
40-test
保存自己写的配置文件,并表达对配置的理解程度和信任状态。
插件源码不必随着状态晋级而移动。例如:
travel/packages/vertico/
始终保持不动。
只有自己的配置文件移动:
travel/40-test/init-vertico.el
→ travel/30-maybe/init-vertico.el
→ travel/20-stable/init-vertico.el
这样不会反复修改 submodule 路径和 .gitmodules。
初始化主仓库
在整个 Emacs 配置目录建立主仓库:
cd ~/.emacs.d
git init -b main
先提交自己的基础配置:
git add init.el travel
git commit -m "Initialize Emacs configuration"
仓库建在:
~/.emacs.d/
而不是只建在:
~/.emacs.d/travel/
这样 init.el 和所有配置都能统一管理。
添加插件
以 Vertico 为例:
cd ~/.emacs.d
git submodule add
https://github.com/minad/vertico.git
travel/packages/vertico
检查状态:
git status
一般会看到:
new file: .gitmodules
new file: travel/packages/vertico
提交:
git commit -m "Add Vertico submodule"
git submodule add 本质上完成了三件事:
- 克隆插件仓库
- 把 URL 和路径写进
.gitmodules
- 让主仓库记录插件当前所在的 commit
不要修改插件原始仓库
不应把自己的配置写进:
travel/packages/vertico/
例如不要这样:
travel/packages/vertico/
├── vertico.el
├── vertico-indexed.el
└── my-vertico-config.el
因为这个目录属于 Vertico 自己的仓库。自行修改后,子模块会变成脏状态:
modified content
正确做法是:
travel/packages/vertico/ 插件原始源码
travel/40-test/init-vertico.el 自己的配置
例如:
(require 'vertico)
(vertico-mode 1)
原则是:
- 插件仓库保持原汁原味;自己的
require、setq、hook、advice 和按键配置放在自己的配置目录。
插件升级、回滚与删除
查看所有插件版本
在主仓库运行:
git submodule status
可能显示:
abc1234 travel/packages/vertico
def5678 travel/packages/consult
前面的 commit 就是主仓库当前锁定的插件版本。
还可以设置更清楚的 submodule 差异显示:
git config diff.submodule log
git config status.submodulesummary 1
以后运行:
git diff
便能更直观地看到插件从哪些提交升级到了哪些提交。
查看某个插件自己的提交历史:
git -C travel/packages/vertico log --oneline --decorate -10
这等价于先进入插件目录再执行 git log,但无需切换当前目录。
升级一个插件
推荐一次只升级一个插件:
cd ~/.emacs.d
git submodule update --remote -- travel/packages/vertico
此时 Vertico 会获取上游更新,并切换到远程跟踪分支的新提交。
主仓库会显示:
modified: travel/packages/vertico
这里的 modified 通常不是插件源码被改写了,而是:
主仓库原来记录:Vertico commit A
插件现在位于: Vertico commit B
查看升级内容:
git diff --submodule=log
然后正常使用几天,观察是否稳定。
升级成功后保留新版本
确认没有问题后:
git add travel/packages/vertico
git commit -m "Update Vertico"
主仓库便正式从:
Vertico commit A
改为:
Vertico commit B
最好坚持:
- 一次升级一个插件,一个插件形成一个独立提交。
例如:
Update Vertico
Update Consult
Update Magit
不要一次升级十几个插件后统一提交为:
Update packages
否则出问题时,很难判断究竟是哪一个插件造成的。
放弃尚未提交的插件升级
如果执行升级后发现新版本有问题,而且还没有提交:
git submodule update \
--checkout \
--force \
-- travel/packages/vertico
Git 会让插件回到主仓库当前记录的旧 commit。
含义是:
主仓库固定版本:commit A
刚才临时升级到:commit B
执行恢复后: commit A
这就是最简单的“升级失败,退回旧版”。
升级已经提交,后来才发现问题
假设升级 Vertico 的提交是:
7ac39fe Update Vertico
可以在主仓库运行:
git revert 7ac39fe
git submodule update --init --recursive
git revert 会新增一个提交,撤销那次插件升级;随后 git submodule update 让磁盘上的插件切回旧 commit。
这种做法比随意修改子仓库历史更清楚,因为:
- 决定插件正式版本的是主仓库中的 gitlink,而不是子仓库当前碰巧停留在哪里。
为什么 submodule 经常处于 detached HEAD
进入子模块后,运行:
git status
可能看到:
HEAD detached at abc1234
这在 submodule 中通常是正常现象。
主仓库需要插件准确停在:
abc1234
而不是自动跟随 main、master 等分支继续变化。
因此,submodule 默认常常直接检出某个 commit,形成 detached HEAD。
对只使用插件、不直接参与开发插件的人而言,这不是故障,而是版本锁定的正常状态。
是否一次升级全部插件
技术上可以一次升级所有子模块:
git submodule update --remote --recursive
但不建议日常这样做。
如果十几个插件同时升级后 Emacs 出问题,很难立即判断是:
- Vertico
- Consult
- Orderless
- Magit
- Transient
- 多个插件之间的兼容性变化
更稳妥的办法是:
git submodule update --remote -- travel/packages/vertico
测试、提交,再升级下一个插件。
适合这种管理方式的原则是:
- 少升级、慢升级、一次一个、稳定后再提交。
submodule 最大的价值不是方便追逐最新版,而是方便长期固定已验证版本。
删除一个插件
永久删除 submodule
例如删除 Vertico:
cd ~/.emacs.d
git rm travel/packages/vertico
git commit -m "Remove Vertico"
同时删除自己的插件配置:
git rm travel/40-test/init-vertico.el
git commit -m "Remove Vertico configuration"
也可以一次完成:
git rm travel/packages/vertico
git rm travel/40-test/init-vertico.el
git commit -m "Remove Vertico"
git rm 会同时更新主仓库索引和 .gitmodules 中相应的 submodule 记录。
只在当前电脑暂时停用
git submodule deinit -- travel/packages/vertico
这不会从主仓库历史中删除 Vertico,只是取消当前电脑上的初始化和工作目录。
重新恢复:
git submodule update --init -- travel/packages/vertico
两者区别如下:
| 命令 | 含义 |
|---|---|
git submodule deinit
|
当前电脑暂时不检出插件 |
git rm
|
从主仓库中正式删除插件依赖 |
异地恢复与日常同步
在另一台电脑恢复全部配置
克隆时直接取得所有子模块
git clone --recurse-submodules \
主仓库URL \
~/.emacs.d
这样主仓库和所有插件会一次性恢复。
已经普通 clone 过主仓库
如果已经执行:
git clone 主仓库URL ~/.emacs.d
插件目录可能存在,但内容尚未初始化。
继续执行:
cd ~/.emacs.d
git submodule update --init --recursive
其中:
--init:初始化尚未初始化的子模块--recursive:如果插件内部还有子模块,也继续处理
日常拉取主仓库更新
在另一台电脑同步配置时:
cd ~/.emacs.d
git pull
git submodule update --init --recursive
第一条命令更新:
- 自己的配置文件
.gitmodules- 插件版本指针
第二条命令让本机插件真正切换到主仓库指定的版本。
单纯运行:
git pull
只会更新主仓库中记录的指针,并不保证子模块工作目录已经切换到对应 commit。
使用边界与方案选择
临时测试是否必须使用 submodule
不一定。
如果只是临时试用一个插件,完全可以:
git clone URL travel/40-test/plugin
不喜欢就直接删除:
rm -rf travel/40-test/plugin
以下情况普通 clone 已经足够:
- 只试用几分钟或几天
- 随时准备删除
- 不打算同步到其他电脑
- 不在乎固定版本
- 不需要主配置与插件整体回滚
当决定长期保留后,再正式转为 submodule:
rm -rf travel/40-test/plugin
git submodule add URL travel/packages/plugin
git commit -m "Add plugin submodule"
当然,也可以从试用阶段就使用 submodule。删除 submodule 并不困难,只是 Git 历史中会留下加入和删除记录。
submodule 不会帮忙完成什么
Git submodule 只负责源码仓库和版本关系,不是完整的 Emacs 包管理器。
它不会自动完成:
- 把插件加入
load-path - 执行
require - 安装插件依赖
- 生成 autoload
- 字节编译
- native compilation
- 编写
setq - 添加 hook
- 配置快捷键
例如 Consult 依赖其他插件时,Git 不会自动读取 Emacs 的 Package-Requires 并安装依赖。
需要自己继续添加依赖:
git submodule add 依赖仓库URL travel/packages/依赖名
然后自己配置加载关系。
这套方案的特点是:
- 放弃包管理器的一部分自动化,换取透明、固定、可控和可复现。
对于大量、频繁更新的插件,手工管理会比较繁琐;对于少量精选、长期固定、谨慎升级的插件,非常合适。
普通 clone 与 submodule 对比
| 能力 | 普通嵌套 git clone
|
Git submodule |
|---|---|---|
| 插件能否运行 | 能 | 能 |
| 插件保持独立仓库 | 能 | 能 |
| 主仓库记录插件 URL | 否 | 是 |
| 主仓库记录插件路径 | 否 | 是 |
| 主仓库锁定插件 commit | 否 | 是 |
| 换电脑后一条命令恢复 | 否 | 是 |
| 主配置和插件整体回滚 | 否 | 是 |
| 正式记录插件升级 | 否 | 是 |
| 保留插件自己的完整历史 | 是 | 是 |
最核心的区别是:
普通 clone:
插件只是碰巧存在于这台电脑。
Git submodule:
插件是主项目正式声明的一项依赖。
命令速查与完整工作流
常用命令速查
| 目的 | 命令 |
|---|---|
| 添加插件 | git submodule add URL PATH
|
| 查看所有插件版本 | git submodule status
|
| 升级一个插件 | git submodule update --remote -- PATH
|
| 查看插件升级内容 | git diff --submodule=log
|
| 接受插件升级 | git add PATH && git commit
|
| 放弃未提交的升级 | git submodule update --checkout --force -- PATH
|
| 初始化全部插件 | git submodule update --init --recursive
|
| 克隆主仓库和全部插件 | git clone --recurse-submodules URL
|
| 暂时停用本机插件 | git submodule deinit -- PATH
|
| 永久删除插件 | git rm PATH && git commit
|
| 查看插件自己的日志 | git -C PATH log --oneline
|
添加插件
cd ~/.emacs.d
git submodule add
插件URL
travel/packages/插件名
git commit -m "Add 插件名"
升级插件
git submodule update \
--remote \
-- travel/packages/插件名
查看升级内容
git diff --submodule=log
测试失败,恢复旧版
git submodule update \
--checkout \
--force \
-- travel/packages/插件名
测试成功,接受新版
git add travel/packages/插件名
git commit -m "Update 插件名"
删除插件
git rm travel/packages/插件名
git rm travel/对应状态目录/init-插件名.el
git commit -m "Remove 插件名"
换电脑完整恢复
git clone --recurse-submodules \
主仓库URL \
~/.emacs.d
总结
普通 git clone 只是:
- 把插件下载到了当前电脑。
git submodule add 则是:
- 让主仓库正式记录插件的 URL、保存路径和准确版本。
Git submodule 对 Emacs 插件管理最大的意义是:
- 对插件版本状态进行精确控制,使整套配置可以稳定锁定、明确升级、快速回滚,并在其他电脑上一键复现。