Webエンジニア向けプログラミング解説動画をYouTubeで配信中!
▶ チャンネル登録はこちら

【ITニュース解説】Growing Pains with Five Repositories: Gitlinks and Dual CI

2026年09月25日に「Dev.to」が公開したITニュース「Growing Pains with Five Repositories: Gitlinks and Dual CI」について初心者にもわかりやすく解説しています。

作成日: 更新日:

ITニュース概要

Gitサブモジュールで複数リポジトリを管理する際、子リポジトリのプッシュ漏れやCIでの依存関係・パス問題が発生した。複数の実行コンテキストに対応できるよう、厳密な同期とコード設計の工夫が必要となる。

ITニュース解説

プロジェクトの初期段階から、私たちは一つの大きなソフトウェアを複数の部品に分割して開発する方針を選んだ。通常はプロジェクトが成長してから分割することが多いが、私たちは最初から意図的に独立したリポジトリ(コードの保管場所)をいくつか作成した。具体的には、「Pipeline」「engine」「design-docs」「user-docs」といったプロジェクトがほぼ同時に立ち上がった。

この選択の目的は明確だった。コードをきれいに保ち、CI(継続的インテグレーション、コードの変更を自動的にテストする仕組み)を独立させて、各部品の変更履歴を分離し、さらにアクセス権限も部品ごとに細かく設定したかったからだ。これらの目標を達成するために、Gitのサブモジュールという機能を使った。サブモジュールは、あるリポジトリの中に別のリポジトリを組み込む機能で、親リポジトリが子リポジトリの特定のバージョンを指し示すことで連携する。このアイデアは計画段階では完璧に見えたが、運用してみると予期せぬ困難に直面した。

最初の大きな問題は、「Gitlinkの罠」、つまりリポジトリの状態を正しく同期させることの難しさだった。サブモジュールを使う際の基本的な作業手順は、実は非常に厳密な規律を必要とする。例えば、「Pipeline」という親リポジトリのフォルダ内で、「engine」というサブモジュールのコードを編集した場合、開発者は二段階のプッシュ(変更をリモートサーバーに送信する作業)を行わなければならない。まず、変更を加えた「engine」サブモジュール自身のフォルダ内で git push コマンドを実行し、その変更をサブモジュール側のリモートリポジトリに送信する。次に、親リポジトリである「Pipeline」のルートディレクトリに戻り、サブモジュールの「engine」が新しいコミット(変更の記録)を指し示すように、そのポインタ(gitlinkと呼ばれる)を更新するコミットを作成し、最後に「Pipeline」リポジトリ自体を git push する必要がある。

この「サブモジュール自体のプッシュ」を忘れてしまうことが非常に多かった。開発者のローカル環境では、サブモジュール内の変更は物理的に存在し、ビルドもテストも問題なく成功する。そのため、親リポジトリである「Pipeline」は、更新されたサブモジュールのポインタをコミットし、リモートサーバーにプッシュされる。しかし、肝心のサブモジュール側の変更はローカルに残ったままで、リモートには送信されていない状態になる。この問題は、CI環境で初めて明らかになる。CIは、常にクリーンな状態でリポジトリをチェックアウト(コピーしてくる)ため、git submodule update --init コマンドを実行すると、「fatal: remote error: upload-pack: not our ref <sha>」というエラーで失敗するのだ。GitHubは正直に「このコミットはリモートリポジトリに存在しません」と教えてくれる。ある時、「design-docs」「engine」「user-docs」の三つのサブモジュールが同時にこの罠にはまり、原因はすべて、サブモジュール内で直接行われた小さなコミットが開発者のローカルPCから離れることなく、リモートにプッシュされていなかったことだった。さらに、「ude_promotion」という別のサブモジュールでは、ローカルのブランチがリモートから大幅に遅れてしまい、通常のプッシュでは修正できず、インタラクティブなリベース(コミット履歴を修正する複雑な操作)が必要になるという、さらに厄介な状況も発生した。

二つ目の大きな問題は、CI環境での衝突、すなわち「Dual CI」と呼ばれる状況だった。「engine」リポジトリは、システムの核となる部分だが、これが二重の人格を持つことになった。一方では、それ自体が独立したリポジトリであり、自分自身のビルドプロセスとテストを持つ。他方では、「Pipeline」リポジトリ内のサブモジュールとして組み込まれ、親プロジェクト全体の文脈でテストされる。この二つの「人格」は避けられない衝突を引き起こした。

最初の衝突は、依存関係の欠如という形で現れた。例えば、test_integration_scripts.py というテストが、親リポジトリである「Pipeline」にしか存在しない verify_pages/check_links というモジュールを参照しようとした。当然ながら、「engine」を単独でチェックアウトしてCIを実行すると、これらのファイルはディスク上に存在しないため、テストが失敗してしまう。私たちはこの問題を、CIの設定ファイルに pytest --ignore=... という一行を追加し、単独実行時にはこの特定のテストを無視するようにすることで、荒っぽくではあったが一時的に解決した。しかし、これはその場しのぎのパッチであり、根本的な解決策ではなかった。

すぐにこのバグは別の形で再発した。今度は、コードが現在のファイルから親ディレクトリを遡って関連ファイルを探す、Path(__file__).resolve().parents[2] のような一般的なテクニックを使っているテストが次々と失敗し始めたのだ。サブモジュールとして「Pipeline」内で実行される場合、このコードは親リポジトリのルートディレクトリに正確に到達する。しかし、「engine」が単独でビルドされると、リポジトリのルートをさらに超えて、CIランナーのシステムファイルシステムにまで遡ってしまい、AssertionError というハードなエラーでクラッシュした。この二度目の発生を経験し、私たちは一時的なパッチではもはや対応できないことを悟った。何らかの一般的なルールや仕組みが必要だと判断したのだ。

そこで私たちは、スマートな「マーカーハック」を導入した。テストコードが、特定のディレクトリ階層を遡った先に、独自のシステムマーカーディレクトリ(「.workspace_config」)が存在するかどうかをチェックするようにしたのだ。もしこのディレクトリが存在すれば、それは「私たちは親リポジトリにネストされた(組み込まれた)コンテキストで実行されている」ことを意味し、テストは通常通り実行される。もしディレクトリが存在しなければ(つまり、単独で実行されている場合)、pytest.mark.skipif という仕組みを使ってテストを丁寧にスキップする。これにより、テストが赤色のエラーで失敗することなく、静かに無視されるようになった。

これらの経験から、マルチリポジトリアーキテクチャは、「疎結合」(各部品が独立しており、互いの影響を受けにくい状態)というメリットを、最初から無料で提供してくれるわけではないということがわかった。このアーキテクチャを採用するには、開発者全員が完璧なプッシュ同期の規律を守ること、そしてコード自体が複数の異なる実行コンテキスト(単独で動く場合と、親の一部として動く場合など)で同時に問題なく動作できるように設計されていることが求められるのだ。これらの教訓は、パイプラインの破損という痛みを伴ったが、これらがなければ私たちのシステムは今後のさらなる成長に耐えられなかっただろう。しかし、GitのサブモジュールとDual CIに関するこれらの問題は、実はまだ序章に過ぎなかった。この後、ネットワークが介入し、私たちのパイプラインが「ワイヤーの中の幽霊」のせいでクラッシュし始めたとき、本当の謎が始まったのだ。その捉えどころのないネットワークのバグをどのように調査したかについては、次の話で紹介される。

関連コンテンツ

関連IT用語