GoSuda

Rethinking the Git Monorepo: 人間と Coding Agents のための CAS-Native アーキテクチャ

By Lemon Mint views ...

成長中のエンジニアリング組織は、いずれ必ず同じ分岐点に直面する。最初は数十の小規模なリポジトリで始めるため、当初は単純に感じられる。各ライブラリはそれぞれ独自の課題トラッカー、独自のCIパイプラインを持ち、git clone はコーヒーを一口飲む前に完了する。やがて「税金」が訪れる。コアとなるネットワーキングパッケージの構造体を一つ変更すると、その日の午後は六つの下流リポジトリにまたがるバージョン更新のプルリクエストを開き、トポロジカル順序でCIキューが空くのを待つことに費やされる。

すべてを従来のGitモノレポに移行すると、調整の苦痛は物理的な苦痛へと置き換わる。コミット履歴が深くなり、過去のリビジョンにバイナリアセットが蓄積すると、git fetch は遅くなる。ワーキングツリーはギガバイト単位に膨れ上がる。やがて、日常的な開発を耐えられる状態に保つために、独自の仮想ファイルシステムデーモン、専用のコードレビューサーバー、あるいはリモートビルドワーカーの群れを立ち上げようという提案がなされるようになる。

自律型コーディングエージェントがチームに加わると、両方の失敗モードはさらに悪化する。ポリレポでは、エージェントは他プロジェクトからの呼び出し元を把握できず、リポジトリをまたいで認証情報を切り替えなければならない。従来型のモノレポでは、タスクの初期化のたびにエージェントのコンテナのディスクとネットワーク帯域が圧迫される。さらに、同一ブランチを同時に操作する複数のエージェントは、終わりのないリベースの競合を生み出す。

我々は第三の選択肢を求めた。google3 アーキテクチャに着想を得て、我々は一つの単純な問いを立てた。「標準的なGitの基盤機能と汎用のオブジェクトストレージだけで、単一トランクへのアトミックな変更、サブ秒で完了するスパースなオンボーディング、階層的な OWNERS によるガバナンス、そして独立したオープンソースのミラーを実現できるか」。

答えは「可能である」だった。そのために独自のファイルシステムデーモンやサードパーティのレビュー専用アプライアンスは必要なかった。必要だったのは、Gitを内部的に実際に何であるかという観点で用いることだけであった。すなわち、Merkle木に基づく内容アドレス指定ストア(CAS)としてである。

遅いGitモノレポの中核にある誤解

開発者がGitは大規模なモノレポに対応できないと不満を述べるとき、実際にはGitのオブジェクトデータベースの限界に達していることはまれである。.git/objects の内部では、Gitは三つの不変のプリミティブを、SHA-1(またはSHA-256)の内容ハッシュをキーとして格納している。

  1. blob: 単一ファイルの生のバイト内容である。
  2. tree: ファイル名と権限を blob または子 tree のハッシュに対応づけるディレクトリの一覧である。
  3. commit: 単一のルート tree ハッシュ、ゼロ個以上の親 commit ハッシュ、および作成者のメタデータを指し示す小さなテキストヘッダー(通常200〜300バイト)である。

内容アドレス指定ストアとして、この設計は非常に効率的である。モノレポに50,000個のファイルが含まれており、そのうち一つのファイルの一行を編集したとする。すると新しいコミットは、残りの49,999個のファイルについて既存の blob と tree のハッシュをそのまま再利用する。

では、なぜ従来のGitモノレポは重く感じられるのか。それは、慣習によって一つの参照 refs/heads/main に三つの相反する役割が過剰に負わされているからである。

  • すべてのプロジェクトの統合されたディレクトリツリー全体を保持する。
  • 初日まで遡る親を連ねた歴史的コミットの連鎖全体を引きずる。
  • Gitサーバーが、すべての git clone および git fetch において既定で広告しネゴシエーションを行う名前空間 refs/heads/* に置かれる。

開発者またはCIランナーが refs/heads/main を取得するたびに、クライアントとサーバーは何千もの過去のリビジョンにわたるコミットグラフの到達可能性をネゴシエーションする。誰かが三年前に誤って100 MBのアーカイブをコミットし、翌日それを削除したとしよう。その blob は依然として、refs/heads/main から到達可能な過去の tree にぶら下がったままである。

参照名前空間を四つの層に分離する

Gitのワイヤープロトコルは、標準的なクローン時には refs/heads/* と refs/tags/* のみを広告する。refs/trunk/* や refs/ws/* のようなカスタム名前空間の下にある参照は、サーバー上の同一のオブジェクトストアに存在し、同じパックファイルのデルタ圧縮の恩恵を受け、明示的に取得またはプッシュできる。それでいて、既定のクローンに対する負荷はまったく生じない。

一つのリポジトリ内で責務を四つの参照名前空間に分割することで、ボトルネックは解消される。

1refs/heads/main          コントロールプレーンのみ(約20 KB:ルール、.gitconfig、同期設定、スクリプト)
2refs/trunk/main          最新の統合ルートツリーを指す親を持たないスナップショットコミット
3refs/trunk/history       正確に同じルートツリーのSHA-1を共有する、線形に親を連ねたコミットの連鎖
4refs/ws/<actor>/<id>     開発者またはエージェントごとの独立したワークスペーススナップショット(変更リスト)
5refs/heads/land/<id>     一時的なマージキューのトリガーで、統合された直後に削除される

1. コントロールプレーン(refs/heads/main)

既定のブランチには、アプリケーションのソースコードは一切含まれない。リポジトリの運用ルール(AGENTS.md、README.md)、ワークフローのエイリアスを記述した共有 .gitconfig、Starlarkによる同期設定(sync.star)、およびサーバー側のマージスクリプトのみを追跡する。既定のブランチをクローンすると約20 KBが転送され、ミリ秒単位で完了する。

2. 親を持たないトランクスナップショット(refs/trunk/main)

refs/trunk/main は組織全体の完全な統合ソースツリー(//web/*、//net/*、//ipc/*、//devtools/*、//corp/*、//third_party/*、//experimental/*)を指すが、そのコミットオブジェクトは -p による親指定なしで作成される。

refs/trunk/main は親をゼロ個持つ(parents = [])ため、取得にコミットグラフのネゴシエーションは不要である。Gitサーバーは履歴を辿ることなく、単一の先頭コミットと現在のディレクトリ tree オブジェクトを送信する。先月の過去のバイナリや削除されたファイルは refs/trunk/main から到達不能であり、決して転送されない。

3. 線形の監査履歴(refs/trunk/history)

開発者は回帰の原因を調査する際に、依然として git blame と git log を必要とする。変更がトランクに統合されるたびに、マージスクリプトはまったく同一のルート $NEW_TREE のSHA-1ハッシュを指す二つのコミットオブジェクトを作成する。

 1# 即時の深さ1ワークスペースチェックアウトのための、親を持たないスナップショットコミット
 2SNAP=$(git commit-tree "$NEW_TREE" -m "$RAW_MSG")
 3
 4# 線形の git log と git blame のための、親を連ねたコミット
 5HIST=$(git commit-tree "$NEW_TREE" -p "$OLD_HIST" -m "$RAW_MSG")
 6
 7git push --atomic origin \
 8  --force-with-lease="refs/trunk/main:${OLD_MAIN}" \
 9  --force-with-lease="refs/trunk/history:${OLD_HIST}" \
10  "${SNAP}:refs/trunk/main" \
11  "${HIST}:refs/trunk/history"

refs/trunk/main と refs/trunk/history の両方を維持すると、リポジトリのストレージ使用量は二倍になるのだろうか。まったくそうはならない。 両方のコミットが同一の $NEW_TREE ハッシュを参照するため、Gitの内容アドレス指定ストアは tree と blob オブジェクトを100%重複排除する。親を持たないスナップショットの追加ストレージコストは、250バイト程度のコミットヘッダー一つ分にすぎない。

4. 分離されたワークスペース(refs/ws/<actor>/<id>)

作業中のブランチは、refs/heads/* ではなく refs/ws/<actor>/<id> にプッシュされる。100体のコーディングエージェントが毎分チェックポイントを保存しても、ブランチのドロップダウンを汚染したり、人間のエンジニアの git fetch を遅らせたりすることはない。

五つのコマンドによるオンボーディング:スパースかつBlobless(blobなし)チェックアウトの仕組み

この参照構成のもとでは、新しい開発者や一時的なコーディングエージェントのコンテナは、独自のCLIやFUSEドライバをインストールする必要はない。オンボーディングは標準的なGitコマンド五つで完了する。

 1# 1. blobフィルタとスパースモードを有効にして、20 KBのコントロールプレーンをクローンする
 2git clone --filter=blob:none --sparse https://github.com/example-org/monorepo.git
 3cd monorepo
 4
 5# 2. リポジトリのエイリアスを読み込み、refs/trunk/* を接続する
 6git config include.path ../.gitconfig
 7git trunk-init
 8
 9# 3. このタスクに必要なドメインのディレクトリを宣言する
10git sparse-checkout set net/relay ipc/codec
11
12# 4. 最新のトランクスナップショットを起点とする独立したワークスペースを作成する
13git ws-new alice/optimize-framing

内部では、各ステップは必要最小限の作業のみを行う。

  1. git clone --filter=blob:none --sparse は、refs/heads/main 上の20 KBのコントロールプレーンをダウンロードする。
  2. git trunk-init は +refs/trunk/*:refs/remotes/origin/trunk/* を設定し、blobなしの取得を実行する。Gitは refs/trunk/main の tree メタデータ(通常は数十KB程度)をダウンロードするが、ファイルの blob は一つもダウンロードしない。
  3. git sparse-checkout set net/relay ipc/codec は、Gitのコーンモードのインデックスフィルタに対し、ルートディレクトリとその二つのサブツリーのみを実体化するよう指示する。
  4. git ws-new は origin/trunk/main を起点とする新しいブランチに切り替える。まさにその時点で、Gitは net/relay と ipc/codec 内の blob オブジェクトのみをオンデマンドで要求する。モノレポの残りの95%がディスクにダウンロードされることは決してない。

将来の展望:refs/trunk/main の仮想ファイルシステム(VFS)としてのマウント

blobなしクローンによるスパースチェックアウトは、追加のクライアントツールを必要とせずに日常のワークフローを網羅する。refs/trunk/main は内容アドレス指定ストア内の純粋なMerkle木であるため、本アーキテクチャは、Linux上のFUSE、macOS上のFileProvider、Windows上のProjFSを介した任意のユーザー空間仮想ファイルシステム(VFS)の直接的な基盤ともなる。

  1. 即時の全ツリーマウント: 軽量なローカルマウントデーモンが refs/trunk/main のルート tree オブジェクトを読み込み、モノレポのディレクトリ階層全体をミリ秒単位でワーキングディレクトリに投影する。これにより git sparse-checkout set を実行する必要がなくなる。
  2. オンデマンドのBlobフォールト処理: コンパイラ、IDE、またはエージェントが open(2) や read(2) のシステムコールを発行するまで、ファイルの内容は仮想のままである。その時点でデーモンは対応するSHA-1の blob をCASエンドポイントから取得し、ローカルのNVMeストレージにキャッシュする。
  3. ワークスペース用のコピーオンライトオーバーレイ: ローカルの編集は軽量な上位オーバーレイ層に書き込まれる。git ws-save を実行すると、そのオーバーレイ内の変更されたパスのみをハッシュ化し、更新された blob と tree オブジェクトを書き出し、結果のコミットを、変更のないディレクトリを走査することなく refs/ws/<actor>/<id> にプッシュする。

純粋なGit上の変更リストモデル:サーバー側のインメモリ統合

従来のGitのワークフローでは、フィーチャーブランチのマージは、二つの親を持つマージコミットを作成する(履歴を絡み合った操車場のようにする)か、開発者が手動でリベースし、main にプッシュする他のエンジニアと競争しなければならない。

Googleの google3 の変更リスト(CL)モデルに着想を得て、本アーキテクチャにおけるすべてのワークスペースは、トランクスナップショットに対する単一のアトミックな変更を表す。ws/alice/optimize-framing でローカルに作業する間、開発者またはエージェントは十個の段階的なコミット(wip、fix tests、lint)を行うかもしれない。変更の統合準備が整うと、サーバーはCIランナー上でワーキングディレクトリをチェックアウトすることなく、ワークスペースの差分を一つの線形なトランクのリビジョンに畳み込む。

以下は、ランディングスクリプト(land.sh)がGit 2.40以降のプラミングを用いてメモリ内で実行される様子である。

 1# 1. ワークスペースが分岐した元となるルートスナップショットコミットを特定する
 2BASE=$(git rev-list --max-parents=0 "$WORK_SHA")
 3
 4BASE_TREE=$(git rev-parse "${BASE}^{tree}")
 5MAIN_TREE=$(git rev-parse "${OLD_MAIN}^{tree}")
 6WORK_TREE=$(git rev-parse "${WORK_SHA}^{tree}")
 7
 8# 2. ワーキングツリーのチェックアウトなしで、統合されたルートツリーをメモリ内で計算する
 9if [ "$MAIN_TREE" = "$BASE_TREE" ]; then
10  NEW_TREE="$WORK_TREE"
11elif OUT=$(git merge-tree --write-tree --merge-base="$BASE_TREE" "$MAIN_TREE" "$WORK_TREE"); then
12  NEW_TREE=$(head -n1 <<<"$OUT")
13else
14  echo "Merge conflict detected; run git ws-sync to rebase." >&2
15  exit 1
16fi

ステップ2を注意深く見てみよう。

  • ワークスペースのブランチ上でローカルに何個のコミットを行ったかにかかわらず、スクリプトは三つのツリーハッシュ、すなわち元のベーススナップショット($BASE_TREE)、現在のトランクスナップショット($MAIN_TREE)、最終的なワークスペースツリー($WORK_TREE)のみを比較する。
  • Aliceが //net/relay を編集している間に refs/trunk/main が //web/blog で進んでいた場合、git merge-tree --write-tree は、Gitのオブジェクトストア内で結合されたルートツリー $NEW_TREE をミリ秒単位で直接計算する。ランナーのファイルシステムには何も書き込まれない。
  • 最後に、--force-with-lease を付けた git push --atomic は、単一のトランザクションにおいて、refs/trunk/main を更新し、refs/trunk/history に一つの線形なコミットを追加し、統合のトリガーである refs/heads/land/<id> を削除する。

アーキテクチャの境界の強制:四段階の可視性と階層的な OWNERS

コードを一つのリポジトリに統合することが機能するのは、コミットが統合される前に、ドメイン間の境界が機械的に強制される場合に限られる。git merge-tree --write-tree が $NEW_TREE を生成した直後(かつ git push --atomic がトランクを更新する前)に、当社のポリシーエンジンは git diff-tree "$MAIN_TREE" "$NEW_TREE" に対して二つのO(delta)のチェックを実行する。

1. 四段階のドメイン可視性

ルートツリーは四つの可視性の階層に分割される。

ディレクトリ接頭辞階層許可されるインポート禁止されるインポートOWNERS のゲート
//web/*、//net/*、//ipc/*、//storage/*、//crypto/*、//ai/*、//devtools/*公開本番公開本番、//third_party/*//corp/*、//experimental/*強制
//third_party/*外部ベンダー自己完結型、または //third_party/*third_party 以外のすべてのドメイン強制
//corp/*社内非公開公開本番、//corp/*、//third_party/*//experimental/*強制
//experimental/<actor>/*サンドボックスcorp 以外の任意のパッケージ//corp/*免除(自動承認)

//web/* 内の公開パッケージが //corp/* の社内パッケージ、または //experimental/* の未昇格のプロトタイプをインポートしようとすると、ランディングジョブは refs/trunk/main に触れる前に中止される。

2. Gerritサーバーを用いない階層的な OWNERS の検証

継承ルールを伴うディレクトリ単位の OWNERS ファイルを強制するために、専用のGerritインスタンスは必要ない。

  • $MAIN_TREE と $NEW_TREE の間で変更されたすべてのパスについて、検証器はファイルのディレクトリから //OWNERS に向かって上方向に辿る。
  • いずれの OWNERS ファイルも、親ディレクトリからの承認の継承を遮断するために set noparent を宣言でき、またファイル名のグロブによって承認の範囲を限定するために per-file *.sql=@dba-team を宣言できる。
  • 改ざん防止の不変条件: 既存の OWNERS ファイルは、常に $MAIN_TREE(現在のトランクツリー)から読み込まれ、決して $NEW_TREE から読み込まれない。承認を受けていない作成者が、同じワークスペースのコミットで OWNERS を編集することによって自らに権限を付与することはできない。
  • サンドボックスの免除: //experimental/<actor>/* 配下のパスはすべて OWNERS のチェックを迂回するため、エンジニアとエージェントは git ws-exp <actor> <project> によって自由にプロトタイプを作成でき、後に git ws-graduate によってパッケージを本番ドメインに昇格させることができる。

摩擦のないコードレビュー:一時的なPRとスナップショット差分

refs/heads/main にはコントロールプレーンのみが保持され、refs/trunk/main は親を持たないコミットであるため、ワークスペースのブランチから main へ標準のGitHubプルリクエストを直接開くと、無関係な履歴同士を比較しようとしてしまう。これを解決するために、異なる変更に合わせた三つのレビューワークフローを用意した。

  1. 即時の二点間ワークスペース差分(git ws-save と Edge Diff Router) git ws-save でワークスペースをプッシュすると、refs/ws/<actor>/<id> が軽量なエッジワーカーを介して公開される。このワーカーは refs/trunk/main とワークスペースの参照をそれぞれのコミットSHAに解決し、GitHubの二点比較ビュー(/compare/<trunk-sha>..<ws-sha>)にリダイレクトする。小規模なパッチやエージェントが生成した変更の場合、レビュアーは差分を確認し、Webインターフェース上で Land をクリックして直接統合できる。
  2. 一時的なGitHubプルリクエスト(git ws-pr) GitHub上で行単位のレビューを行う場合、git ws-pr はルートスナップショットコミット($(git rev-list --max-parents=0 HEAD))を refs/heads/base/<id> に、ワークスペースの先端を refs/heads/review/<id> にプッシュする。次に、review/<id> を base/<id> と比較するGitHubプルリクエストを開く。ベースブランチはワークスペースが分岐した正確なスナップショットであるため、GitHubの Files changed タブには、ワークスペースで変更された正確な行のみが表示される。レビュアーが Merge pull request をクリックすると、Webhookが先頭コミットを refs/heads/land/<id> に昇格させ、メモリ内のランディングパイプラインを実行し、refs/heads/base/<id> と refs/heads/review/<id> の両方を自動的に削除する。
  3. 外部ミラーへのプルリクエスト オープンソースの貢献者は、モノレポのワークフローを学ぶ必要が一切ない。通常どおりフォークし、独立したミラーリポジトリに対してプルリクエストを開けばよい。

O(delta)の双方向同期と独立した本番ブランチ

多くの組織は、外部のユーザーがモノレポに触れることなく go get、cargo install を実行したり、単一のプロジェクトをフォークしたりできるよう、個々のパッケージを独立したGitHubリポジトリとして公開している。静的ブログやリリースゲートを設けたWebアプリのような一部のプロジェクトでは、main と並行して別個の production ブランチも維持している。

当社のStarlark同期エンジンは、外部への書き出しと外部からの貢献の両方を、モノレポのサイズではなく、コミットで変更されたファイル数に比例する**O(delta)**の時間で処理する。

  • Blobなしのインデックススプライシング: ミラーリポジトリのサブツリーをモノレポのルートツリーに組み込む、またはそこから取り外す際、エンジンは GIT_NO_LAZY_FETCH=1 を設定し、git update-index --index-info に続いて git write-tree --missing-ok を実行する。変更されていないサブツリーと blob は、CIランナーにダウンロードされることなく、そのSHA-1ハッシュによってのみ参照される。
  • デルタのみの Blob プリフェッチ: 書き出されたコミットを独立したミラーにプッシュする前に、スクリプトは git diff-tree -r --diff-filter=AM "$ext_parent" "$export_tree" を実行し、git cat-file --batch を介して変更されたファイルの旧と新の blob のみをプリフェッチする。これにより、Gitは薄いデルタパックを計算し、数秒でミラーリポジトリにプッシュできる。
  • ワークフローおよびリリースブランチの保持: 外部への書き出しの際、同期ツールは各独立リポジトリ自身の .github/workflows/ ディレクトリを保持し、公開CIランナー上でミラーのプロジェクトテストが実行されるようにする。Starlark設定内の各ワークフローは main などの明示的なブランチを対象とし、これにより別個の production ブランチからデプロイするリポジトリのリリースゲートが維持される。モノレポへの統合はミラーの main ブランチと同期され、main 上で自動生成されたメタデータや翻訳のコミットは、Webhookを介して refs/trunk/main に同期して戻される。main を production に昇格させることは、引き続き明示的なリリース手順として残される。

結びに

高速に開発が進むモノレポを構築するにあたり、標準的なGitを放棄したり、複雑な独自のインフラストラクチャサーバー群を運用したりする必要はない。本アーキテクチャは、Gitのオブジェクトストアを内容アドレス指定ストアとして扱い、refs/heads/main を refs/trunk/main および refs/trunk/history から分離し、git merge-tree --write-tree によってツリーをメモリ内で統合する。その結果、人間のエンジニアと自律型コーディングエージェントの双方が、一秒未満でワークスペースをクローンでき、数百のプロジェクトにわたって無理なく拡張できる。