親が覚えているのはブランチではなくコミット一つ
一言でいうと
サブモジュールで、親リポジトリが記録するのはコミットハッシュ1つです。ブランチではありません。そのため、サブモジュールは常にdetached HEADでチェックアウトされ、最新を取得して使った事実を親にコミットしないと、その変化は自分だけのものとして残ります。
なぜ必要なのか
複数のサービスが同じコードを共有する状況はよくあります。社内共通ライブラリ、プロトコル定義、設定テンプレートのようなものです。コピーしておくとすぐに分岐し、パッケージにして公開すると、デプロイの手順が1つ増えます。サブモジュールは、その中間の答えです。別のリポジトリを、自分のリポジトリの1つのディレクトリの位置に、ピンで固定しておきます。
問題は、「ピンで固定する」がどういう意味かを正確に知らないまま、使い始めることにあります。多くの人が、「親がそのライブラリのmainブランチを指している」と考えますが、そうではありません。親のツリーには、その位置にモード160000のエントリが1つ入り、値はコミットハッシュです。ブランチ名は、.gitmodulesにもデフォルトでは書かれません。
$ git ls-tree HEAD vendor/lib
160000 commit 9f3c1a4... vendor/lib
この1行が、サブモジュールのすべてです。これを知ると、続くすべての動作が説明できます。
どう動くのか
.gitmodulesは、どこから取得するか(url)とどこに置くか(path)だけを書いた設定ファイルで、追跡される通常のファイルです。実際にどのバージョンなのかは、ツリーの160000のエントリが決めます。2つは役割が違い、そのため、別々にコミットされます。
サブモジュールのディレクトリの中に入ってgit statusを実行すると、次のように表示されます。
HEAD detached at 9f3c1a4
親がブランチではなくコミットを記憶しているため、チェックアウトもコミットで行われます。ここでそのままコミットすると、どのブランチにも付いていないコミットになり、あとで探しにくくなります。サブモジュールで作業する場合は、先にブランチに切り替える必要があります。
git submodule statusの先頭の1文字が、状態を表します。
| 先頭の文字 | 意味 |
|---|---|
| (空白) | 親が記録したコミットと、実際のチェックアウトが同じです |
+ |
サブモジュールが、親が記録したものとは別のコミットにあります |
- |
まだ初期化されていません(ディレクトリが空です) |
U |
マージの衝突が解決されていません |
-は、特によく出会います。git cloneは、デフォルトではサブモジュールを取得しないため、取得した直後にビルドすると、空のディレクトリのために失敗します。git clone --recurse-submodulesで取得するか、取得したあとにgit submodule update --init --recursiveを実行します。普段からgit config submodule.recurse trueをオンにしておくと、checkout・pullがサブモジュールまで追従します。
+は、事故の種です。サブモジュールで最新を取得して使い、正常に動作するのを確認したのに、親でgit add vendor/libによってその変化をコミットしないと、自分のマシンでだけ新しいコードが使われます。同僚がcloneすると、親が記録した古いコミットを取得します。症状は、「自分のコンピューターでは動くのですが」の典型で、原因がサブモジュールだと気づくまで、長くかかります。
もう1つ。git 2.38.1から、サブモジュールをローカルパスやfile://で取得することが、デフォルトでブロックされました(CVE-2022-39253)。悪意のあるリポジトリが.gitmodulesを使って、他人のマシンのファイルを持ち出せたためです。ローカルパスでラボを行ったり、CIでキャッシュ用のリポジトリを使ったりするときは、-c protocol.file.allow=alwaysを明示する必要があります。
現場での姿
- CIがビルドに失敗するのに、ローカルは問題ありません。ランナーのcloneに
--recurse-submodulesが抜けていて、サブモジュールのディレクトリが空になっている場合です。 - リリースタグを付けたのに、再現できません。タグが指す親のコミットの
160000の値が、サブモジュールのどのコミットかを確認すると、その間にサブモジュールのリポジトリで強制プッシュによってそのコミットが消えている場合があります。サブモジュールのリポジトリでの履歴の書き換えは、親のピンを切ります。 - レビューで、
Subproject commit 9f3c...の1行だけのdiffを見ることになります。その行だけでは何が変わったのかわからないため、git diff --submodule=logで、コミットの一覧も一緒に表示するように設定しておくほうがよいです。
次のラボですること
ライブラリのリポジトリと親のリポジトリを自分で作って、サブモジュールとして組み込みます。ローカルパスがデフォルトでブロックされることに、まず出会い、親のツリーに160000のエントリが入るのを目で確認します。そのあと、ライブラリに新しいコミットを積み、サブモジュールだけを進めて、親をコミットしていない状態でcloneし、同僚が古いコードを取得する事故を、そのまま再現します。
公式ドキュメントは、git-submoduleとPro Git 7.11 Submodulesです。