プログラミング
Gitサブモジュールをパッケージマネージャーとして使う
Git Submodules as a Package Manager (nesbitt.io)
要約
この記事では、Gitサブモジュールがパッケージマネージャーとしての機能を持つ一方で、その使い勝手や挙動が多くの点で劣っていることを指摘しています。特に、ワークツリーとの連携や依存関係の解決、更新プロセスにおける問題点を詳細に解説し、サブモジュールが抱える課題を浮き彫りにしています。
全文翻訳
先週、リポジトリにワークツリーを追加して、メインのチェックアウトと並行してブランチを試しました。ビルドがベンダー依存関係を必要としたため、その中で `git submodule update --init` を実行し、終わった後に `git worktree remove` でクリーンアップしようとしましたが、Gitは拒否しました。
manページによると、クリーンなワークツリーのみが削除可能であり、「サブモジュールを持つ、またはクリーンでないワークツリー」は `--force` が必要です。サブモジュールは、その文の中で、ダーティな状態とは別に独自の節を持っています。
`git worktree move` はさらに厳格で、サブモジュールを含むいかなるワークツリーに対しても outright に拒否します。私は前週、コマンドラインツールの `--force` フラグがどのように強化されているかをカタログ化していましたが、ここではGitがその2つの機能が衝突したために `--force` を要求していました。
GitHubのGit 2.5のアナウンスメントは、2015年7月にGitワークツリーを導入しましたが、その際に「サブモジュールを含むリポジトリでGitワークツリーを使用することは推奨されません」という一文の注意書きがありました。11年経った今でも、Gitはサブモジュールを含むワークツリーを削除するために `--force` を要求し、移動することは拒否します。
その間、ワークツリーの追加は、サブモジュールを無視するようにパッチを当てる必要がありました。なぜなら、それを尊重すると、まだ空の新しいワークツリー内のサブモジュールパスに内部リセット `--hard` が再帰的に入り込んでしまうからです。
これが、サブモジュールをパッケージマネージャーとして考えるきっかけとなりました。ほとんどの要素は揃っており、その挙動は概ね一致していますが、完全に一致するわけではなく、それらを使用する体験はほぼすべての段階で劣っています。
あまりにも多くのプロジェクトがそれらを導入してから撤退したため、「なぜGitサブモジュールは so bad なのか」というスレッドが繰り返し現れています。
スーパープロジェクトのツリーにあるgitlinkは、モード160000のパスに記録されたコミットSHAであり、ロックファイルのエントリです。そして、パスをフェッチURLにマッピングする `.gitmodules` ファイルはマニフェストです。
`git submodule update` は両方を読み込み、ワーキングツリーを投入します。これがインストールステップです。
ピン自体は、任意のパッケージマネージャーのピンと同じくらい正確です。オブジェクトIDによって識別される正確なコミットです。
解決
gitlinkはチェックアウトするコミットのみを記録するため、`.gitmodules` はサブモジュールごとにURLを保持し、`update` はそこからクローンします。これが唯一の解決メカニズムです。
アップストリームリポジトリの名前が変更されたり、別のホストに転送されたり、プライベートになったりすると、SHAは変更されておらず、それらが既に含まれているすべてのクローンにオブジェクトが存在していても、すべてのダウンストリームピンが壊れます。
マニフェストはホストをハードコードしています。なぜなら、GitにはコミットIDからそれを保持するサーバーへのルックアップがないからです。
Gitは、`git submodule init` が最初に実行されるときに、各URLをスーパープロジェクトの `.git/config` に `submodule.<name>.url` としてコピーし、後続のコマンドはそれを無視してそこから読み取ります。
コミットされた `.gitmodules` をミラーまたはフォークを指すように編集しても、`git submodule sync` が新しい値をコピーするまで、既に初期化されたクローンは変更されません。
CIでの一般的な回避策は、Gitのグローバルな `url.<base>.insteadOf` 設定です。これは、マッチするプレフィックスを持つ任意のURLをフェッチする前に書き換えます。サブモジュールURLも含まれます。
一般的なケースは、`https://github.com/` を `git@github.com:` に書き換えてSSHデプロイキーを適用するか、内部ホスト名をミラーにリダイレクトすることです。
インストール
プレーンな `git clone` は、gitlinkをインデックスに書き込み、サブモジュールディレクトリが存在するようにしますが、`git submodule update --init` が実行されるか、`--recurse-submodules` 付きでクローンが作成されるまで空のままです。
`submodule.recurse` の設定は、チェックアウト、フェッチ、プル、grep、およびその他のいくつかのコマンドを自動的に再帰させますが、デフォルトではオフです。
デフォルトでは、`update` はgitlinkコミットをデタッチ状態でチェックアウトします。2つの独立したフラグがそれを変更します。
`--init`: まず、欠落している `.gitmodules` エントリを `.git/config` にコピーします。初回実行時に必要で、それ以降は no-op です。
`--remote`: gitlinkコミットではなく、サブモジュールの設定されたリモート追跡ブランチの先端をチェックアウトします(`submodule.<name>.branch` が設定されていない場合はリモートのHEAD)。
コマンドリファレンスは両方を文書化していますが、`update` という名前は「ピン留めされたものをインストールする」と「最新に更新する」を混同しています。
スーパープロジェクトでブランチを切り替えると、インデックス内のgitlinkが変更され、サブモジュールのワーキングツリーはそのまま残るため、`git status` はすぐにサブモジュールをmodifiedとして表示します。
`checkout` に `--recurse-submodules` を渡すか、`submodule.recurse` を設定すると、ブランチ切り替え時にサブモジュールのワーキングツリーも一緒に移動します。
Rustプロジェクトがコンパイラサブプロジェクトをサブモジュールから移行した際の記録は、経験からこのクラスターを挙げています。チェックアウトがクローン後に空または間違ったコミットで残る、ブランチ切り替えがサブモジュールをダーティにしたために、無関係なサブモジュールの更新がプルリクエストに着陸する、ビルド前に各サブモジュールを正しいコミットにチェックアウトすることを確認するためのブートストラップビルドツールでのカスタムロジック。
ストレージ
サブモジュールのgitディレクトリは、スーパープロジェクトの `$GIT_DIR/modules/<name>/` の下に格納されます。サブモジュールのワーキングツリーには、そこへの `gitdir:` ポインタを含む `.git` ファイルがあり、`core.worktree` 設定は逆方向を指しています。
`git submodule absorbgitdirs` は、ネストされた `.git/` ディレクトリをまだ持っている古いクローンを移行します。
`modules/` の下の各エントリは、独自のrefs、HEAD、index、config、hooks、そしてデフォルトでは独自のオブジェクトストアを持つgitディレクトリです。
サブモジュールを削除することは、対応して3つの場所に分散しています。`git rm <path>` はgitlinkと `.gitmodules` エントリを削除し、`git submodule deinit <path>` はワーキングツリーと `.git/config` エントリをクリアし、両方が残した吸収された `$GIT_DIR/modules/<name>` ディレクトリは、手動の `rm -rf` として文書化されています。
ワークツリーとサブモジュールは、このレイアウトで衝突します。なぜなら、リンクされたワークツリーはスーパープロジェクトの `$GIT_DIR` を共有しますが、`$GIT_DIR/worktrees/<id>/` の下に独自のワーキングツリー、HEAD、インデックスを持っているからです。
異なるスーパープロジェクトブランチに2つのワークツリーを配置すると、それらは2つの異なるコミットで同じサブモジュールを参照します。
それぞれが独自のサブモジュールチェックアウトとインデックスを必要とし、それは部分的にワークツリーごと、部分的に共有されるストレージに結びついています。
`worktree remove` は、その状態が破棄可能かどうかを確認するのではなく、オーバーライドを必要とし、`worktree move` は、それが実行する必要のあるポインタファイルのリライトが未実装であるため拒否します。
Xavier Morelは今年の3月にGitメーリングリストで、ベアリポジトリとワークツリーのセットが関連プロジェクトのセットでうまく機能したが、その上にサブモジュールを追加すると常に新規クローンになってしまうことに気づいたため、サブモジュールのチェックアウト自体を既存の共有クローンの一部としてワークツリーにできるかどうか尋ねました。
RFCと、`git worktree add` 用の `--recurse-submodules` を提案する3パッチシリーズが4月に続きました。これにより、各リンクされたワークツリーは `$GIT_COMMON_DIR/worktrees/<id>/modules/` の下に独自のサブモジュールgitディレクトリを持ち、それらの間でオブジェクトストレージをハードリンクで共有します。
同じ乗算が、単一ワークツリーのクローンで2つのサブモジュールが両方とも3番目のリポジトリに依存している場合に発生します。
スーパープロジェクトの各パスは、独自の `modules/` エントリ、独自のオブジェクトストア(オルタネートが手動で設定されていない場合)、および独自のgitlinkを取得します。
2つのピンは同じリポジトリの異なるコミットを指すことができ、Gitはそれらを無関係なチェックアウトとして扱います。
共有キャッシュを持つパッケージマネージャー(cargoのレジストリキャッシュ、pnpmのコンテンツアドレス可能ストア、Goモジュールキャッシュ)は、バイトを一度格納し、場所ごとにチェックアウトします。
更新
gitlinkは1つのコミットSHAを保持するため、サブモジュールを前方に移動するには、それに入り、フェッチし、新しいコミットをチェックアウトし、出て、スーパープロジェクトで `git add <path>` を実行して新しいgitlinkを記録する必要があります。
`git submodule update --remote` は、設定されたブランチの先端をフェッチし、それを記録されたgitlinkの代わりにチェックアウトします。その結果をスーパープロジェクトにコミットすることが、ピンを移動することです。
`.gitmodules` は、`--remote` と更新ボットが追跡できるように、サブモジュールごとにブランチ名を指定できます。
プレーンな `update` はそのフィールドを無視し、gitlink SHAをチェックアウトします。
バージョン範囲、タグパターン、または最小コミットの構文はありません。そのため、マニフェストの唯一のフローティング参照はブランチ名であり、gitlinkが唯一のピンです。
DependabotとRenovateの両方が、gitlinkを更新するプルリクエストを開くことができます。
Dependabotのgitsubmoduleエコシステムは、サブモジュールの設定されたブランチが移動したときに新しいgitlink SHAを提案します。Renovateのg