HN 日本語サマリー

← 一覧へ戻る
プログラミング

Gitサブモジュールをパッケージマネージャーとして使う

Git Submodules as a Package Manager (nesbitt.io)

79 pointsby ErenayDev27 コメント

要約

この記事では、Gitサブモジュールがパッケージマネージャーとしての機能を持つ一方で、その使い勝手や挙動が多くの点で劣っていることを指摘しています。特に、ワークツリーとの連携や依存関係の解決、更新プロセスにおける問題点を詳細に解説し、サブモジュールが抱える課題を浮き彫りにしています。

全文翻訳

Gitサブモジュールをパッケージマネージャーとして使う

先週、リポジトリにワークツリーを追加して、メインのチェックアウトと並行してブランチを試しました。ビルドがベンダー化された依存関係を必要としたため、その中で git submodule update --init を実行し、終了後に git worktree remove でクリーンアップしようとしましたが、Gitは拒否しました。manページによると、クリーンなワークツリーのみが削除可能であり、「サブモジュールを持つ、またはクリーンでないワークツリー」は --force が必要です。サブモジュールは、ダーティな状態とは別に、その文で独自の節を持っています。git worktree move はさらに厳格で、サブモジュールを含むワークツリーでは完全に拒否されます。私は前週にコマンドラインツールが --force フラグをどのように強化するかをカタログ化していましたが、ここではGitの2つの機能が衝突したために --force が必要とされました。

GitHubのgit 2.5アナウンスは2015年7月に git worktree を導入しましたが、その際に「サブモジュールを含むリポジトリでの git worktree の使用は推奨されません」という一文の注意書きがありました。11年後の現在でも、Gitはサブモジュールを含むワークツリーを削除するために --force を要求し、移動を拒否します。その間、worktree add は、それを尊重すると新しいワークツリーでまだ空のサブモジュールパスにリセットが再帰してしまうため、submodule.recurse を無視するようにパッチ適用される必要がありました。

これが私にサブモジュールをパッケージマネージャーとして考えるきっかけを与えました。ほとんどの要素は揃っており、動作も概ね一致しますが、完全に一致するわけではなく、それらを使用する体験はほとんどすべてのステップで悪化します。多くのプロジェクトがサブモジュールを採用した後で撤回したため、「なぜ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 を無視してそこから読み取ります。コミットされた .gitmodules をミラーやフォークを指すように編集しても、初期化済みのクローンは git submodule sync が新しい値をコピーするまで変更されません。

CIでの通常の回避策は、Gitのグローバルな url.<base>.insteadOf 設定です。これは、一致するプレフィックスを持つURLをフェッチ前に書き換えることで、サブモジュールURLも対象とします。一般的なケースは、https://github.com/ を [[email protected]](https://nesbitt.io/cdn-cgi/l/email-protection): に書き換えてSSHデプロイキーを適用するか、内部ホスト名をミラーにリダイレクトすることです。

インストール

プレーンな git clone は、スーパープロジェクトのインデックスに gitlink を書き込むため、サブモジュールディレクトリが存在しますが、git submodule update --init が実行されるか、--recurse-submodules 付きでクローンされるまで空のままです。submodule.recurse 設定は、checkout、fetch、pull、grep、およびその他のいくつかのコマンドが自動的に再帰するようにし、デフォルトではオフになっています。

デフォルトでは、update は gitlink コミットをデタッチした状態でチェックアウトします。2つの独立したフラグがそれを変更します。

  • --init: まず、不足している .gitmodules エントリを .git/config にコピーします。初回実行時に必要で、それ以降は no-op です。
  • --remote: gitlink コミットではなく、サブモジュールの設定済みリモート追跡ブランチの先端をチェックアウトします(submodule.<name>.branch が設定されていない場合は、リモートの HEAD)。

コマンドリファレンス は両方を文書化していますが、update という名前は「ピン留めされたものをインストールする」と「最新に更新する」を混同しています。スーパープロジェクトでブランチを切り替えると、インデックスの gitlink が変更され、サブモジュールのワーキングツリーはそのまま残るため、git status はすぐにサブモジュールを修正済みとして表示します。checkout に --recurse-submodules を渡すか、submodule.recurse を設定すると、ブランチ切り替え時にサブモジュールワーキングツリーも追従します。Rustプロジェクトのコンパイラサブプロジェクトをサブモジュールから移行した際の記録は、このクラスターを経験からリストアップしています。クローン後に空または間違ったコミットでチェックアウトが残る、ブランチ切り替えでサブモジュールがダーティになったためにプルリクエストに無関係なサブモジュール更新が含まれる、ビルド前に各サブモジュールを正しいコミットにチェックアウトするための bootstrap ビルドツールのカスタムロジック。

ストレージ

サブモジュールのgitディレクトリは、スーパープロジェクトの $GIT_DIR/modules/<name>/ の下に格納され、サブモジュールのワーキングツリーにはそれを指す gitdir: ポインタと、反対方向を指す core.worktree 設定を含む .git ファイルがあります。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、indexを持つからです。異なるスーパープロジェクトブランチに2つのワークツリーを配置すると、それらは2つの異なるコミットで同じサブモジュールを参照します。それぞれが、部分的にワークツリーごとで部分的に共有されるストレージに紐づけられた、独自のサブモジュールチェックアウトとインデックスを必要とします。worktree remove は、その状態が破棄可能かどうかをチェックするのではなく、オーバーライドを必要とし、worktree move は、それが実行する必要のあるポインタファイルの書き換えが未実装であるため拒否します。

Xavier Morelは今年の3月にGitメーリングリストで質問しました、サブモジュールのチェックアウト自体が既存の共有クローンの一つのワークツリーになり得るかについてです。ベアリポジトリとワークツリーの組み合わせは関連プロジェクトのセットにはうまく機能しましたが、その上にサブモジュールを追加すると常に新規クローンが発生していました。4月には、git worktree add への--recurse-submodules を追加するRFCと3つのパッチシリーズが続き、各リンクされたワークツリーに $GIT_COMMON_DIR/worktrees/<id>/modules/ の下に独自のサブモジュールgitディレクトリを与え、ハードリンクによってそれらの間のオブジェクトストレージを共有します。

単一ワークツリーのクローンで2つのサブモジュールが両方とも3番目のリポジトリに依存する場合にも、同じ乗算が発生します。スーパープロジェクト内の各パスは、独自の modules/ エントリ、手動で alternates が設定されていない限り独自のオブジェクトストア、および独自の gitlink を持ちます。2つのピンは同じリポジトリの異なるコミットを指すことができ、Gitはそれらを無関係なチェックアウトとして扱います。共有キャッシュを持つパッケージマネージャー(Cargoのレジストリキャッシュ、pnpmのコンテンツアドレス可能なストア、Goモジュールキャッシュ)は、バイトを一度格納し、場所ごとにチェックアウトします。

更新

gitlink は1つのコミットSHAを保持するため、サブモジュールを前進させるには、サブモジュールに入り、フェッチし、新しいコミットをチェックアウトし、出て、スーパープロジェクトで git add <path> を実行して新しい gitlink を記録する必要があります。git submodule update --remote は設定済みブランチの先端をフェッチし、それを記録された gitlink の代わりにチェックアウトし、その結果をスーパープロジェクトにコミットすることがピンを移動させることです。.gitmodules は、--remote と更新ボットが追従するために、サブモジュールごとに branch を指定できます。プレーンな update はそのフィールドを無視し、gitlink SHAをチェックアウトします。バージョン範囲、タグパターン、または最小コミットの構文はないため、マニフェストの唯一のフローティング参照はブランチ名であり、gitlink が唯一のピンです。

DependabotとRenovateの両方が、gitlink を更新するプルリクエストを開くことができます。Dependabotのgitsubmodule エコシステムは、サブモジュールの設定済みブランチが移動したときに新しい gitlink SHAを提案し、Renovateのgit-submodules マネージャーも同様のことを行いますが、デフォルトでは無効になっています。どちらもブランチの先端を追跡します。なぜなら、ブランチ名がマニフェストが公開する唯一の参照だからです。

セキュリティ

.gitmodules はリポジトリにコミットされるため、敵対的なアップストリームはその内容を制御できます。また、Gitはユーザーがフェッチされたファイルを見る前に clone --recurse-submodules 中にそれをパースします。この組み合わせにより、リモートコード実行が繰り返し発生しています。CVE-2018-11235 は、サブモジュールの名前に ../ を使用して、そのgitディレクトリ(フックを含む)が $GIT_DIR/modules/ の外に書き込まれ、クローン中に post-checkout フックが実行されるようにしました。CVE-2018-17456 では、サブモジュールURLが - で始まっていたため、子 git clone がそれをオプションとしてパースしました。これはGitの--end-of-options デリミタが防御するクラスのバグです。CVE-2022-39253 は開示バグでした。サブモジュールのオブジェクトディレクトリ内のシンボリックリンクにより、ローカルトランスポートクローンが被害者のディスクから任意のファイルをコピーしました。修正により、protocol.file.allow のデフォルトが user に変更されたため、ローカルパスのサブモジュールは明示的なオプトインが必要になりました。CVE-2024-32002 は、シンボリックリンクとケースインセンシティブなファイルシステムを組み合わせて、再帰的なクローン中に .git/ にフックを書き込むというバグでした。私はパッケージマネージャーのチェックアウトパスが攻撃対象領域となるというより広範なパターンをCWEフィールドガイドでカバーしました。

抽象化

サブモジュールはGitの内部を直接公開します。ピンとしてのオブジェクトID、更新後のデタッチHEAD、$GIT_DIR/modules/ レイアウト、マニフェスト内のトランスポートURLです。パッケージマネージャーは、マニフェスト形式、リゾルバ、ローカルキャッシュの背後にある同等のものをラップします。サブモジュールはそれらをそのまま表面化させます。

ギャップのほとんどは、パッケージマネージャーがすでに解決しているものに対応します。共有オブジェクトキャッシュ、クローンとチェックアウト時の依存関係へのデフォルトでの再帰、依存関係の追加と削除の単一ライフサイクル、マニフェストでの範囲制約です。4月のパッチシリーズでgit worktree addに--recurse-submodulesが追加されたことは、ストレージ問題の一例に対処し、各ワークツリーにハードリンクされた共有ストレージ上の独自のサブモジュールチェックアウトを提供します。解決策はより難しい問題です。コミットSHAはオブジェクトのホストに依存しないIDであり、.gitmodules のURLは、そのIDからそれを持っているサーバーへのGitの唯一のマッピングです。